react-shopwave-connect 0.4.2 → 0.4.4

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.
@@ -349,6 +349,118 @@ async function fetchProductsMap(productIds, options = {}, batchSize = 200) {
349
349
  return result;
350
350
  }
351
351
 
352
+ //#endregion
353
+ //#region src/core/resources.ts
354
+ /**
355
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
356
+ * updates it (without an `id` the API would create a second merchant, so the
357
+ * route requires one).
358
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
359
+ * needs the admin scope).
360
+ */
361
+ const SHOPWAVE_RESOURCES = Object.freeze({
362
+ merchant: {
363
+ route: "merchant",
364
+ key: "merchant",
365
+ upstream: "merchant",
366
+ writable: true
367
+ },
368
+ user: {
369
+ route: "user",
370
+ key: "user",
371
+ upstream: "user",
372
+ writable: false
373
+ }
374
+ });
375
+ /** `UploadKind` → the API's `contentType` value. */
376
+ const UPLOAD_KINDS = Object.freeze({
377
+ merchant: "merchant",
378
+ merchantFeature: "merchantFeature",
379
+ product: "product",
380
+ user: "user",
381
+ applicationLogo: "applicaionLogo",
382
+ applicationImages: "applicaionImages"
383
+ });
384
+ /** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
385
+ const UPLOAD_PREFIXES = Object.freeze([
386
+ "product_thumbnail",
387
+ "variant_thumbnail",
388
+ "product",
389
+ "variant"
390
+ ]);
391
+ /** How uploads are addressed (app route and Shopwave uploader). */
392
+ const SHOPWAVE_UPLOAD = Object.freeze({
393
+ route: "upload",
394
+ upstream: "uploader",
395
+ kindHeader: "contentType",
396
+ prefixHeader: "prefix",
397
+ fileField: "file",
398
+ kindField: "kind",
399
+ prefixField: "prefix"
400
+ });
401
+ function isUploadKind(value) {
402
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
403
+ }
404
+ function isUploadPrefix(value) {
405
+ return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
406
+ }
407
+ const SHOPWAVE_LOOKUPS = Object.freeze({
408
+ productTags: {
409
+ route: "products/tags",
410
+ key: "tags",
411
+ upstream: "product/tags"
412
+ },
413
+ productInstanceTags: {
414
+ route: "products/instance-tags",
415
+ key: "tags",
416
+ upstream: "product/instance/tags"
417
+ }
418
+ });
419
+
420
+ //#endregion
421
+ //#region src/core/productTags.ts
422
+ /**
423
+ * Turns a tag list from the API into clean strings: trims each tag, drops
424
+ * blanks and anything that isn't text, and removes exact duplicates (first one
425
+ * wins). The API's own order is kept. A map is accepted too (its values are
426
+ * used), in case the API ever answers keyed by id.
427
+ */
428
+ function normalizeTagList(value) {
429
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
430
+ const seen = /* @__PURE__ */ new Set();
431
+ const out = [];
432
+ for (const item of list) {
433
+ if (typeof item !== "string" && typeof item !== "number") continue;
434
+ const tag = String(item).trim();
435
+ if (!tag || seen.has(tag)) continue;
436
+ seen.add(tag);
437
+ out.push(tag);
438
+ }
439
+ return out;
440
+ }
441
+ async function fetchLookup(kind, options) {
442
+ const def = SHOPWAVE_LOOKUPS[kind];
443
+ if (!def) throw new Error(`Unknown Shopwave lookup "${kind}"`);
444
+ const json = await apiGet(`/api/${def.route}`, {}, options);
445
+ assertNoApiErrors(json, 200);
446
+ return normalizeTagList(json?.[def.key]);
447
+ }
448
+ /**
449
+ * Every distinct tag used on the merchant's products (`GET /product/tags`),
450
+ * for a tag field's suggestions. Returns `[]` when there are none.
451
+ */
452
+ function fetchProductTags(options = {}) {
453
+ return fetchLookup("productTags", options);
454
+ }
455
+ /**
456
+ * Every distinct tag used on the merchant's price instances
457
+ * (`GET /product/instance/tags`): the channel names (Eat In, Takeout, …).
458
+ * Returns `[]` when there are none.
459
+ */
460
+ function fetchProductInstanceTags(options = {}) {
461
+ return fetchLookup("productInstanceTags", options);
462
+ }
463
+
352
464
  //#endregion
353
465
  //#region src/core/promotion.ts
354
466
  /** Promotion rule identifiers as stored by the Shopwave API. */
@@ -477,537 +589,161 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
477
589
  }
478
590
 
479
591
  //#endregion
480
- //#region src/core/entities.ts
592
+ //#region src/core/imageFit.ts
481
593
  /**
482
- * Addressing per entity, following the Shopwave API reference
483
- * (https://developer.merchantstack.com/api-reference.html) and checked against
484
- * the live API where noted:
485
- * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
486
- * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
487
- * reads the promotion and saves the whole record back with `endDate` = now (and
488
- * `startDate` = now too if it hadn't started); the live API rejects a partial
489
- * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
490
- * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
491
- * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
492
- * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
594
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
595
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
493
596
  */
494
- const SHOPWAVE_ENTITIES = Object.freeze({
495
- product: {
496
- route: "products",
497
- collection: "products",
498
- upstream: "product",
499
- idsHeader: "productIds",
500
- idHeader: "productId",
501
- writable: true,
502
- deleteMode: "delete"
503
- },
504
- category: {
505
- route: "categories",
506
- collection: "categories",
507
- upstream: "category",
508
- idsHeader: "categoryIds",
509
- idHeader: "categoryId",
510
- writable: true,
511
- deleteMode: "delete"
512
- },
513
- store: {
514
- route: "stores",
515
- collection: "stores",
516
- upstream: "store",
517
- idsHeader: "storeIds",
518
- idHeader: "storeId",
519
- writable: true,
520
- deleteMode: "delete"
521
- },
522
- promotion: {
523
- route: "promotions",
524
- collection: "promotions",
525
- upstream: "promotion",
526
- idsHeader: "promotionIds",
527
- idHeader: "promotionId",
528
- writable: true,
529
- deleteMode: "retire",
530
- retireField: "endDate",
531
- retireWithRecord: true,
532
- retireStartField: "startDate"
533
- },
534
- employee: {
535
- route: "employees",
536
- collection: "employees",
537
- upstream: "employee",
538
- idsHeader: "employeeIds",
539
- idHeader: "employeeId",
540
- writable: true,
541
- deleteMode: "retire",
542
- retireField: "exitDate"
543
- },
544
- consumer: {
545
- route: "consumer",
546
- collection: "consumers",
547
- upstream: "consumer",
548
- idsHeader: "ids",
549
- idHeader: "consumerId",
550
- writable: false,
551
- deleteMode: "none"
597
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
598
+ if (![
599
+ srcWidth,
600
+ srcHeight,
601
+ dstWidth,
602
+ dstHeight
603
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
604
+ const srcRatio = srcWidth / srcHeight;
605
+ const dstRatio = dstWidth / dstHeight;
606
+ if (srcRatio > dstRatio) {
607
+ const sw = Math.round(srcHeight * dstRatio);
608
+ return {
609
+ sx: Math.round((srcWidth - sw) / 2),
610
+ sy: 0,
611
+ sw,
612
+ sh: srcHeight
613
+ };
552
614
  }
553
- });
554
- function definition(kind) {
555
- const def = SHOPWAVE_ENTITIES[kind];
556
- if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
557
- return def;
615
+ const sh = Math.round(srcWidth / dstRatio);
616
+ return {
617
+ sx: 0,
618
+ sy: Math.round((srcHeight - sh) / 2),
619
+ sw: srcWidth,
620
+ sh
621
+ };
558
622
  }
559
- function assertId(kind, id) {
560
- const s = id == null ? "" : String(id).trim();
561
- if (!s) throw new Error(`${kind} id is missing`);
562
- return encodeURIComponent(s);
623
+ function requireBrowserImages(fn) {
624
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
563
625
  }
564
- /**
565
- * Creates or updates several entities of one kind in a single request and
566
- * returns them as saved (with their ids), in the order given.
567
- *
568
- * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
569
- * answers 201 and echoes every entity under the ref it was sent with, so each
570
- * result is matched back by ref. Throws {@link ShopwaveApiError} when the
571
- * response carries `api.message.errors` or any ref is missing from it.
572
- *
573
- * The API's partial-success semantics for multi-entity saves haven't been
574
- * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
575
- */
576
- async function saveEntities(kind, items, options = {}) {
577
- const def = definition(kind);
578
- if (items.length === 0) return [];
579
- const refs = items.map((_, i) => String(i));
580
- const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
581
- const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
582
- method: "POST",
583
- headers: { "Content-Type": "application/json" },
584
- body: JSON.stringify(payload)
585
- }, options);
586
- const body = raw;
587
- assertNoApiErrors(body, status);
588
- const echoed = body?.[def.collection] ?? {};
589
- return refs.map((ref, i) => {
590
- const saved = echoed[ref];
591
- if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
626
+ /** Reads an image file's pixel size (browser only). */
627
+ async function readImageSize(file) {
628
+ requireBrowserImages("readImageSize");
629
+ const bitmap = await createImageBitmap(file);
630
+ try {
592
631
  return {
593
- ...items[i],
594
- ...saved,
595
- id: Number(saved.id)
632
+ width: bitmap.width,
633
+ height: bitmap.height
596
634
  };
597
- });
598
- }
599
- /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
600
- async function saveEntity(kind, item, options = {}) {
601
- const [saved] = await saveEntities(kind, [item], options);
602
- return saved;
603
- }
604
- /**
605
- * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
606
- * Employees and promotions can't be deleted in Shopwave; for them the route
607
- * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
608
- * promotion is saved back whole (see {@link deletePromotion}).
609
- *
610
- * Shopwave answers 205 with an empty body — also for ids that don't exist — so
611
- * a resolved promise means "the API accepted the request", not "a record was
612
- * deleted".
613
- */
614
- async function deleteEntityById(kind, id, options = {}) {
615
- await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
616
- }
617
- /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
618
- async function fetchEntityById(kind, id, params = {}, options = {}) {
619
- const def = definition(kind);
620
- const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
621
- assertNoApiErrors(body, 200);
622
- const map = body?.[def.collection] ?? {};
623
- return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
635
+ } finally {
636
+ bitmap.close?.();
637
+ }
624
638
  }
625
639
  /**
626
- * Creates (no `id`) or updates (with `id`) a product.
627
- *
628
- * Shopwave instances (prices) can't be updated, only replaced. Build
629
- * `instances` with {@link toProductInstancesForSave} (passing the product as last
630
- * read) so an edited price is sent as a new instance. As a safeguard, a new
631
- * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
632
- * another product would otherwise point the save at that product's instances.
640
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
641
+ * (never stretched). Browser only. The result is a `File` named like the
642
+ * input, with the output type's extension.
633
643
  */
634
- const saveProduct = (product, options) => {
635
- return saveEntity("product", (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
636
- ...product,
637
- instances: toProductInstancesForSave(product.instances)
638
- } : product, options);
639
- };
640
- const deleteProduct = (id, options) => deleteEntityById("product", id, options);
641
- const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
642
- const saveCategory = (category, options) => saveEntity("category", category, options);
643
- const deleteCategory = (id, options) => deleteEntityById("category", id, options);
644
- const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
645
- const saveStore = (store, options) => saveEntity("store", store, options);
646
- const deleteStore = (id, options) => deleteEntityById("store", id, options);
647
- const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
648
- const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
644
+ async function fitImageToSize(file, width, height, options = {}) {
645
+ requireBrowserImages("fitImageToSize");
646
+ const { type = "image/png", quality, keepIfExact = true } = options;
647
+ const name = file.name || "image";
648
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
649
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
650
+ const bitmap = await createImageBitmap(file);
651
+ try {
652
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
653
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
654
+ let blob;
655
+ if (typeof OffscreenCanvas === "function") {
656
+ const canvas = new OffscreenCanvas(width, height);
657
+ const ctx = canvas.getContext("2d");
658
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
659
+ ctx.imageSmoothingQuality = "high";
660
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
661
+ blob = await canvas.convertToBlob({
662
+ type,
663
+ quality
664
+ });
665
+ } else {
666
+ const canvas = document.createElement("canvas");
667
+ canvas.width = width;
668
+ canvas.height = height;
669
+ const ctx = canvas.getContext("2d");
670
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
671
+ ctx.imageSmoothingQuality = "high";
672
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
673
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
674
+ }
675
+ return new File([blob], outName, { type });
676
+ } finally {
677
+ bitmap.close?.();
678
+ }
679
+ }
680
+
681
+ //#endregion
682
+ //#region src/core/upload.ts
649
683
  /**
650
- * Expires a promotion (Shopwave never deletes promotions). The route reads the
651
- * promotion and saves it back with `endDate` set to now; a promotion that hasn't
652
- * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
653
- * exist or the save fails.
684
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
685
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
686
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
654
687
  */
655
- const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
656
- const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
688
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
689
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
657
690
  /**
658
- * Creates an employee, or updates one (with `id`). On update Shopwave only
659
- * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
660
- * email can't be changed, and the echo only carries the updated fields — re-read
661
- * with `fetchEmployee` if you need the stored record.
691
+ * Turns an image path from the API into a URL a page can load over HTTPS:
692
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
693
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
694
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
662
695
  */
663
- const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
664
- /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
665
- const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
666
- const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
667
- const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
668
-
669
- //#endregion
670
- //#region src/core/basket.ts
671
- const DEFAULT_BASKET_FILTERS = {
672
- search: "",
673
- dateRange: {
674
- start: null,
675
- end: null
676
- },
677
- consumerId: null,
678
- status: "all"
679
- };
680
- const DEFAULT_PAGINATION = {
681
- page: 0,
682
- pageSize: 25,
683
- total: 0
684
- };
685
- /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
686
- function formatDateForQuery(date, isEndOfDay = false) {
687
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
688
- }
689
- /** Builds the `Basket` report query from a set of filters. */
690
- function buildBasketReportQuery(filters) {
691
- const whereConditions = [];
692
- const today = /* @__PURE__ */ new Date();
693
- const startDate = filters.dateRange.start ?? today;
694
- const endDate = filters.dateRange.end ?? today;
695
- whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
696
- whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
697
- if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
698
- if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
699
- return { baskets: {
700
- FROM: "Basket",
701
- WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
702
- } };
703
- }
704
- /** Flattens the raw report rows/keys into structured baskets. */
705
- function parseBasketReportData(reports) {
706
- if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
707
- const { keys, data } = reports.baskets;
708
- const keyIndices = {};
709
- keys.forEach((key, index) => {
710
- keyIndices[key.name] = index;
711
- });
712
- const productKey = keys.find((k) => k.name === "Basket.Product");
713
- const productSubKeys = {};
714
- if (productKey?.item) productKey.item.forEach((subKey, index) => {
715
- productSubKeys[subKey.name] = index;
716
- });
717
- return data.map((row) => {
718
- const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
719
- productId: productRow[productSubKeys["Basket.Product.productId"]],
720
- productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
721
- quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
722
- price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
723
- tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
724
- }));
725
- const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
726
- const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
727
- const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
728
- const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
729
- return {
730
- id: row[keyIndices["Basket.id"]],
731
- name: row[keyIndices["Basket.name"]],
732
- consumerId: row[keyIndices["Basket.consumerId"]],
733
- products,
734
- total,
735
- taxTotal,
736
- status: "completed",
737
- completeDate
738
- };
739
- });
740
- }
741
- /** Unique non-null consumer ids referenced by a set of baskets. */
742
- function collectConsumerIds(baskets) {
743
- if (!baskets.length) return null;
744
- const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
745
- return ids.length > 0 ? [...new Set(ids)] : null;
746
- }
747
- /** Unique product ids referenced by a set of baskets. */
748
- function collectProductIds(baskets) {
749
- if (!baskets.length) return null;
750
- const ids = /* @__PURE__ */ new Set();
751
- baskets.forEach((basket) => {
752
- basket.products.forEach((product) => {
753
- if (product.productId) ids.add(product.productId);
754
- });
755
- });
756
- return ids.size > 0 ? [...ids] : null;
757
- }
758
- /** Joins baskets with consumer + product lookups, applies search, sorts. */
759
- function combineBasketRows({ baskets, consumerMap, productMap, search }) {
760
- if (!baskets.length) return [];
761
- let combinedRows = baskets.map((basket) => {
762
- const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
763
- const productsWithNames = basket.products.map((p) => {
764
- const product = productMap[p.productId];
765
- return {
766
- ...p,
767
- productName: product?.name || `Product #${p.productId}`
768
- };
769
- });
770
- return {
771
- id: basket.id,
772
- basketId: basket.id,
773
- basketName: basket.name || `Basket #${basket.id}`,
774
- consumerId: basket.consumerId,
775
- consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
776
- consumerEmail: consumer?.email || "-",
777
- products: productsWithNames,
778
- basketTotal: (basket.total || 0) / 100,
779
- tax: basket.taxTotal || 0,
780
- dateTime: basket.completeDate,
781
- status: basket.status || "pending"
782
- };
783
- });
784
- if (search) {
785
- const searchLower = search.toLowerCase();
786
- combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
787
- }
788
- combinedRows.sort((a, b) => {
789
- const dateA = new Date(a.dateTime).getTime();
790
- return new Date(b.dateTime).getTime() - dateA;
791
- });
792
- return combinedRows;
696
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
697
+ if (!path) return "";
698
+ const root = base.replace(/\/+$/, "");
699
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
700
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
701
+ return `${root}/${path.replace(/^\/+/, "")}`;
793
702
  }
794
- /** Aggregate totals for a set of report rows. */
795
- function computeBasketSummary(rows) {
796
- const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
703
+ /**
704
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
705
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
706
+ *
707
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
708
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
709
+ * 201 `{ fileName, path }`.
710
+ */
711
+ async function uploadImage(file, params, options = {}) {
712
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
713
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
714
+ if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
715
+ const name = params.fileName ?? file.name ?? "upload";
716
+ const form = new FormData();
717
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
718
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
719
+ if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
720
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
721
+ method: "POST",
722
+ body: form
723
+ }, options);
724
+ const body = raw;
725
+ assertNoApiErrors(body, status);
726
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
727
+ const path = typeof body?.path === "string" ? body.path : "";
728
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
797
729
  return {
798
- totalBaskets: rows.length,
799
- totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
800
- totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
801
- uniqueConsumers: uniqueConsumerIds.size
730
+ id,
731
+ url: getImageUrl(path),
732
+ path
802
733
  };
803
734
  }
804
- /** Builds a consumer id → consumer lookup. */
805
- function buildConsumerMap(consumers) {
806
- const map = {};
807
- if (consumers) consumers.forEach((consumer) => {
808
- map[consumer.id] = consumer;
809
- });
810
- return map;
811
- }
812
735
 
813
736
  //#endregion
814
- //#region src/core/resources.ts
737
+ //#region src/core/merchantImages.ts
738
+ /** The three image slots on the merchant settings page. */
739
+ const MERCHANT_IMAGE_SLOT_KEYS = [
740
+ "receipt",
741
+ "square",
742
+ "featured"
743
+ ];
815
744
  /**
816
- * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
817
- * updates it (without an `id` the API would create a second merchant, so the
818
- * route requires one).
819
- * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
820
- * needs the admin scope).
821
- */
822
- const SHOPWAVE_RESOURCES = Object.freeze({
823
- merchant: {
824
- route: "merchant",
825
- key: "merchant",
826
- upstream: "merchant",
827
- writable: true
828
- },
829
- user: {
830
- route: "user",
831
- key: "user",
832
- upstream: "user",
833
- writable: false
834
- }
835
- });
836
- /** `UploadKind` → the API's `contentType` value. */
837
- const UPLOAD_KINDS = Object.freeze({
838
- merchant: "merchant",
839
- merchantFeature: "merchantFeature",
840
- product: "product",
841
- user: "user",
842
- applicationLogo: "applicaionLogo",
843
- applicationImages: "applicaionImages"
844
- });
845
- /** How uploads are addressed (app route and Shopwave uploader). */
846
- const SHOPWAVE_UPLOAD = Object.freeze({
847
- route: "upload",
848
- upstream: "uploader",
849
- kindHeader: "contentType",
850
- fileField: "file",
851
- kindField: "kind"
852
- });
853
- function isUploadKind(value) {
854
- return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
855
- }
856
-
857
- //#endregion
858
- //#region src/core/imageFit.ts
859
- /**
860
- * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
861
- * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
862
- */
863
- function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
864
- if (![
865
- srcWidth,
866
- srcHeight,
867
- dstWidth,
868
- dstHeight
869
- ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
870
- const srcRatio = srcWidth / srcHeight;
871
- const dstRatio = dstWidth / dstHeight;
872
- if (srcRatio > dstRatio) {
873
- const sw = Math.round(srcHeight * dstRatio);
874
- return {
875
- sx: Math.round((srcWidth - sw) / 2),
876
- sy: 0,
877
- sw,
878
- sh: srcHeight
879
- };
880
- }
881
- const sh = Math.round(srcWidth / dstRatio);
882
- return {
883
- sx: 0,
884
- sy: Math.round((srcHeight - sh) / 2),
885
- sw: srcWidth,
886
- sh
887
- };
888
- }
889
- function requireBrowserImages(fn) {
890
- if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
891
- }
892
- /** Reads an image file's pixel size (browser only). */
893
- async function readImageSize(file) {
894
- requireBrowserImages("readImageSize");
895
- const bitmap = await createImageBitmap(file);
896
- try {
897
- return {
898
- width: bitmap.width,
899
- height: bitmap.height
900
- };
901
- } finally {
902
- bitmap.close?.();
903
- }
904
- }
905
- /**
906
- * Returns the image at exactly `width × height`: scaled and centre-cropped
907
- * (never stretched). Browser only. The result is a `File` named like the
908
- * input, with the output type's extension.
909
- */
910
- async function fitImageToSize(file, width, height, options = {}) {
911
- requireBrowserImages("fitImageToSize");
912
- const { type = "image/png", quality, keepIfExact = true } = options;
913
- const name = file.name || "image";
914
- const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
915
- const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
916
- const bitmap = await createImageBitmap(file);
917
- try {
918
- if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
919
- const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
920
- let blob;
921
- if (typeof OffscreenCanvas === "function") {
922
- const canvas = new OffscreenCanvas(width, height);
923
- const ctx = canvas.getContext("2d");
924
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
925
- ctx.imageSmoothingQuality = "high";
926
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
927
- blob = await canvas.convertToBlob({
928
- type,
929
- quality
930
- });
931
- } else {
932
- const canvas = document.createElement("canvas");
933
- canvas.width = width;
934
- canvas.height = height;
935
- const ctx = canvas.getContext("2d");
936
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
937
- ctx.imageSmoothingQuality = "high";
938
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
939
- blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
940
- }
941
- return new File([blob], outName, { type });
942
- } finally {
943
- bitmap.close?.();
944
- }
945
- }
946
-
947
- //#endregion
948
- //#region src/core/upload.ts
949
- /**
950
- * HTTPS host that serves the Shopwave CDN's images. The API returns
951
- * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
952
- * (checked Sep 2026), so pages served over HTTPS can't show it.
953
- */
954
- const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
955
- const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
956
- /**
957
- * Turns an image path from the API into a URL a page can load over HTTPS:
958
- * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
959
- * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
960
- * Other absolute URLs are returned unchanged; empty input gives `""`.
961
- */
962
- function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
963
- if (!path) return "";
964
- const root = base.replace(/\/+$/, "");
965
- if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
966
- if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
967
- return `${root}/${path.replace(/^\/+/, "")}`;
968
- }
969
- /**
970
- * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
971
- * (`createShopwaveApi(...).upload()`), and returns its id and URL.
972
- *
973
- * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
974
- * record has to point at it to use it. Live API (Sep 2026): the upload answers
975
- * 201 `{ fileName, path }`.
976
- */
977
- async function uploadImage(file, params, options = {}) {
978
- if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
979
- if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
980
- const name = params.fileName ?? file.name ?? "upload";
981
- const form = new FormData();
982
- form.append(SHOPWAVE_UPLOAD.fileField, file, name);
983
- form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
984
- const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
985
- method: "POST",
986
- body: form
987
- }, options);
988
- const body = raw;
989
- assertNoApiErrors(body, status);
990
- const id = typeof body?.fileName === "string" ? body.fileName : "";
991
- const path = typeof body?.path === "string" ? body.path : "";
992
- if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
993
- return {
994
- id,
995
- url: getImageUrl(path),
996
- path
997
- };
998
- }
999
-
1000
- //#endregion
1001
- //#region src/core/merchantImages.ts
1002
- /** The three image slots on the merchant settings page. */
1003
- const MERCHANT_IMAGE_SLOT_KEYS = [
1004
- "receipt",
1005
- "square",
1006
- "featured"
1007
- ];
1008
- /**
1009
- * Where each slot lives, its upload kind and its exact size (from adminV1's
1010
- * merchant settings page).
745
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
746
+ * merchant settings page).
1011
747
  */
1012
748
  const MERCHANT_IMAGE_SLOTS = Object.freeze({
1013
749
  receipt: {
@@ -1115,6 +851,382 @@ async function uploadMerchantImage(file, slot, options = {}) {
1115
851
  return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1116
852
  }
1117
853
 
854
+ //#endregion
855
+ //#region src/core/productImages.ts
856
+ /**
857
+ * Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
858
+ * - A product's `images` is a list. **Reads return full URLs**
859
+ * (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
860
+ * **writes take the uploader's file names** ({@link toStoredProductImages};
861
+ * `saveProduct` does this for you).
862
+ * - The uploader takes one file per request. With a `prefix` (see
863
+ * `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
864
+ * (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
865
+ * image's role can be read back from its name ({@link productImageRole}).
866
+ */
867
+ /**
868
+ * The role in an image's file name or URL, from the uploader's `prefix`:
869
+ * `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
870
+ * without one (images uploaded without a prefix, e.g. before prefixes existed).
871
+ */
872
+ function productImageRole(value) {
873
+ const name = imageFileName(value);
874
+ if (!name) return null;
875
+ return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
876
+ }
877
+ /**
878
+ * A product's `images` as the API takes them on a save: file names, not the
879
+ * URLs a read returns. Non-strings, blanks and duplicates are dropped; the
880
+ * order is kept.
881
+ */
882
+ function toStoredProductImages(images) {
883
+ if (!Array.isArray(images)) return [];
884
+ const names = images.map(imageFileName).filter((name) => Boolean(name));
885
+ return Array.from(new Set(names));
886
+ }
887
+
888
+ //#endregion
889
+ //#region src/core/entities.ts
890
+ /**
891
+ * Addressing per entity, following the Shopwave API reference
892
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
893
+ * the live API where noted:
894
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
895
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
896
+ * reads the promotion and saves the whole record back with `endDate` = now (and
897
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
898
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
899
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
900
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
901
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
902
+ */
903
+ const SHOPWAVE_ENTITIES = Object.freeze({
904
+ product: {
905
+ route: "products",
906
+ collection: "products",
907
+ upstream: "product",
908
+ idsHeader: "productIds",
909
+ idHeader: "productId",
910
+ writable: true,
911
+ deleteMode: "delete"
912
+ },
913
+ category: {
914
+ route: "categories",
915
+ collection: "categories",
916
+ upstream: "category",
917
+ idsHeader: "categoryIds",
918
+ idHeader: "categoryId",
919
+ writable: true,
920
+ deleteMode: "delete"
921
+ },
922
+ store: {
923
+ route: "stores",
924
+ collection: "stores",
925
+ upstream: "store",
926
+ idsHeader: "storeIds",
927
+ idHeader: "storeId",
928
+ writable: true,
929
+ deleteMode: "delete"
930
+ },
931
+ promotion: {
932
+ route: "promotions",
933
+ collection: "promotions",
934
+ upstream: "promotion",
935
+ idsHeader: "promotionIds",
936
+ idHeader: "promotionId",
937
+ writable: true,
938
+ deleteMode: "retire",
939
+ retireField: "endDate",
940
+ retireWithRecord: true,
941
+ retireStartField: "startDate"
942
+ },
943
+ employee: {
944
+ route: "employees",
945
+ collection: "employees",
946
+ upstream: "employee",
947
+ idsHeader: "employeeIds",
948
+ idHeader: "employeeId",
949
+ writable: true,
950
+ deleteMode: "retire",
951
+ retireField: "exitDate"
952
+ },
953
+ consumer: {
954
+ route: "consumer",
955
+ collection: "consumers",
956
+ upstream: "consumer",
957
+ idsHeader: "ids",
958
+ idHeader: "consumerId",
959
+ writable: false,
960
+ deleteMode: "none"
961
+ }
962
+ });
963
+ function definition(kind) {
964
+ const def = SHOPWAVE_ENTITIES[kind];
965
+ if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
966
+ return def;
967
+ }
968
+ function assertId(kind, id) {
969
+ const s = id == null ? "" : String(id).trim();
970
+ if (!s) throw new Error(`${kind} id is missing`);
971
+ return encodeURIComponent(s);
972
+ }
973
+ /**
974
+ * Creates or updates several entities of one kind in a single request and
975
+ * returns them as saved (with their ids), in the order given.
976
+ *
977
+ * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
978
+ * answers 201 and echoes every entity under the ref it was sent with, so each
979
+ * result is matched back by ref. Throws {@link ShopwaveApiError} when the
980
+ * response carries `api.message.errors` or any ref is missing from it.
981
+ *
982
+ * The API's partial-success semantics for multi-entity saves haven't been
983
+ * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
984
+ */
985
+ async function saveEntities(kind, items, options = {}) {
986
+ const def = definition(kind);
987
+ if (items.length === 0) return [];
988
+ const refs = items.map((_, i) => String(i));
989
+ const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
990
+ const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
991
+ method: "POST",
992
+ headers: { "Content-Type": "application/json" },
993
+ body: JSON.stringify(payload)
994
+ }, options);
995
+ const body = raw;
996
+ assertNoApiErrors(body, status);
997
+ const echoed = body?.[def.collection] ?? {};
998
+ return refs.map((ref, i) => {
999
+ const saved = echoed[ref];
1000
+ if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
1001
+ return {
1002
+ ...items[i],
1003
+ ...saved,
1004
+ id: Number(saved.id)
1005
+ };
1006
+ });
1007
+ }
1008
+ /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
1009
+ async function saveEntity(kind, item, options = {}) {
1010
+ const [saved] = await saveEntities(kind, [item], options);
1011
+ return saved;
1012
+ }
1013
+ /**
1014
+ * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
1015
+ * Employees and promotions can't be deleted in Shopwave; for them the route
1016
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
1017
+ * promotion is saved back whole (see {@link deletePromotion}).
1018
+ *
1019
+ * Shopwave answers 205 with an empty body — also for ids that don't exist — so
1020
+ * a resolved promise means "the API accepted the request", not "a record was
1021
+ * deleted".
1022
+ */
1023
+ async function deleteEntityById(kind, id, options = {}) {
1024
+ await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
1025
+ }
1026
+ /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
1027
+ async function fetchEntityById(kind, id, params = {}, options = {}) {
1028
+ const def = definition(kind);
1029
+ const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
1030
+ assertNoApiErrors(body, 200);
1031
+ const map = body?.[def.collection] ?? {};
1032
+ return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
1033
+ }
1034
+ /**
1035
+ * Creates (no `id`) or updates (with `id`) a product.
1036
+ *
1037
+ * Shopwave instances (prices) can't be updated, only replaced. Build
1038
+ * `instances` with {@link toProductInstancesForSave} (passing the product as last
1039
+ * read) so an edited price is sent as a new instance. As a safeguard, a new
1040
+ * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
1041
+ * another product would otherwise point the save at that product's instances.
1042
+ *
1043
+ * `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
1044
+ * read returns can be passed straight back; leaving `images` out keeps them.
1045
+ */
1046
+ const saveProduct = (product, options) => {
1047
+ let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
1048
+ ...product,
1049
+ instances: toProductInstancesForSave(product.instances)
1050
+ } : product;
1051
+ if (Array.isArray(toSave.images)) toSave = {
1052
+ ...toSave,
1053
+ images: toStoredProductImages(toSave.images)
1054
+ };
1055
+ return saveEntity("product", toSave, options);
1056
+ };
1057
+ const deleteProduct = (id, options) => deleteEntityById("product", id, options);
1058
+ const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
1059
+ const saveCategory = (category, options) => saveEntity("category", category, options);
1060
+ const deleteCategory = (id, options) => deleteEntityById("category", id, options);
1061
+ const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
1062
+ const saveStore = (store, options) => saveEntity("store", store, options);
1063
+ const deleteStore = (id, options) => deleteEntityById("store", id, options);
1064
+ const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
1065
+ const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
1066
+ /**
1067
+ * Expires a promotion (Shopwave never deletes promotions). The route reads the
1068
+ * promotion and saves it back with `endDate` set to now; a promotion that hasn't
1069
+ * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
1070
+ * exist or the save fails.
1071
+ */
1072
+ const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
1073
+ const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
1074
+ /**
1075
+ * Creates an employee, or updates one (with `id`). On update Shopwave only
1076
+ * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
1077
+ * email can't be changed, and the echo only carries the updated fields — re-read
1078
+ * with `fetchEmployee` if you need the stored record.
1079
+ */
1080
+ const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
1081
+ /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
1082
+ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
1083
+ const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
1084
+ const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
1085
+
1086
+ //#endregion
1087
+ //#region src/core/basket.ts
1088
+ const DEFAULT_BASKET_FILTERS = {
1089
+ search: "",
1090
+ dateRange: {
1091
+ start: null,
1092
+ end: null
1093
+ },
1094
+ consumerId: null,
1095
+ status: "all"
1096
+ };
1097
+ const DEFAULT_PAGINATION = {
1098
+ page: 0,
1099
+ pageSize: 25,
1100
+ total: 0
1101
+ };
1102
+ /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
1103
+ function formatDateForQuery(date, isEndOfDay = false) {
1104
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
1105
+ }
1106
+ /** Builds the `Basket` report query from a set of filters. */
1107
+ function buildBasketReportQuery(filters) {
1108
+ const whereConditions = [];
1109
+ const today = /* @__PURE__ */ new Date();
1110
+ const startDate = filters.dateRange.start ?? today;
1111
+ const endDate = filters.dateRange.end ?? today;
1112
+ whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
1113
+ whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
1114
+ if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
1115
+ if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
1116
+ return { baskets: {
1117
+ FROM: "Basket",
1118
+ WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
1119
+ } };
1120
+ }
1121
+ /** Flattens the raw report rows/keys into structured baskets. */
1122
+ function parseBasketReportData(reports) {
1123
+ if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
1124
+ const { keys, data } = reports.baskets;
1125
+ const keyIndices = {};
1126
+ keys.forEach((key, index) => {
1127
+ keyIndices[key.name] = index;
1128
+ });
1129
+ const productKey = keys.find((k) => k.name === "Basket.Product");
1130
+ const productSubKeys = {};
1131
+ if (productKey?.item) productKey.item.forEach((subKey, index) => {
1132
+ productSubKeys[subKey.name] = index;
1133
+ });
1134
+ return data.map((row) => {
1135
+ const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
1136
+ productId: productRow[productSubKeys["Basket.Product.productId"]],
1137
+ productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
1138
+ quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
1139
+ price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
1140
+ tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
1141
+ }));
1142
+ const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
1143
+ const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
1144
+ const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
1145
+ const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
1146
+ return {
1147
+ id: row[keyIndices["Basket.id"]],
1148
+ name: row[keyIndices["Basket.name"]],
1149
+ consumerId: row[keyIndices["Basket.consumerId"]],
1150
+ products,
1151
+ total,
1152
+ taxTotal,
1153
+ status: "completed",
1154
+ completeDate
1155
+ };
1156
+ });
1157
+ }
1158
+ /** Unique non-null consumer ids referenced by a set of baskets. */
1159
+ function collectConsumerIds(baskets) {
1160
+ if (!baskets.length) return null;
1161
+ const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
1162
+ return ids.length > 0 ? [...new Set(ids)] : null;
1163
+ }
1164
+ /** Unique product ids referenced by a set of baskets. */
1165
+ function collectProductIds(baskets) {
1166
+ if (!baskets.length) return null;
1167
+ const ids = /* @__PURE__ */ new Set();
1168
+ baskets.forEach((basket) => {
1169
+ basket.products.forEach((product) => {
1170
+ if (product.productId) ids.add(product.productId);
1171
+ });
1172
+ });
1173
+ return ids.size > 0 ? [...ids] : null;
1174
+ }
1175
+ /** Joins baskets with consumer + product lookups, applies search, sorts. */
1176
+ function combineBasketRows({ baskets, consumerMap, productMap, search }) {
1177
+ if (!baskets.length) return [];
1178
+ let combinedRows = baskets.map((basket) => {
1179
+ const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
1180
+ const productsWithNames = basket.products.map((p) => {
1181
+ const product = productMap[p.productId];
1182
+ return {
1183
+ ...p,
1184
+ productName: product?.name || `Product #${p.productId}`
1185
+ };
1186
+ });
1187
+ return {
1188
+ id: basket.id,
1189
+ basketId: basket.id,
1190
+ basketName: basket.name || `Basket #${basket.id}`,
1191
+ consumerId: basket.consumerId,
1192
+ consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
1193
+ consumerEmail: consumer?.email || "-",
1194
+ products: productsWithNames,
1195
+ basketTotal: (basket.total || 0) / 100,
1196
+ tax: basket.taxTotal || 0,
1197
+ dateTime: basket.completeDate,
1198
+ status: basket.status || "pending"
1199
+ };
1200
+ });
1201
+ if (search) {
1202
+ const searchLower = search.toLowerCase();
1203
+ combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
1204
+ }
1205
+ combinedRows.sort((a, b) => {
1206
+ const dateA = new Date(a.dateTime).getTime();
1207
+ return new Date(b.dateTime).getTime() - dateA;
1208
+ });
1209
+ return combinedRows;
1210
+ }
1211
+ /** Aggregate totals for a set of report rows. */
1212
+ function computeBasketSummary(rows) {
1213
+ const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
1214
+ return {
1215
+ totalBaskets: rows.length,
1216
+ totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
1217
+ totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
1218
+ uniqueConsumers: uniqueConsumerIds.size
1219
+ };
1220
+ }
1221
+ /** Builds a consumer id → consumer lookup. */
1222
+ function buildConsumerMap(consumers) {
1223
+ const map = {};
1224
+ if (consumers) consumers.forEach((consumer) => {
1225
+ map[consumer.id] = consumer;
1226
+ });
1227
+ return map;
1228
+ }
1229
+
1118
1230
  //#endregion
1119
1231
  //#region src/core/merchant.ts
1120
1232
  const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
@@ -1291,5 +1403,5 @@ async function fetchUser(options = {}) {
1291
1403
  }
1292
1404
 
1293
1405
  //#endregion
1294
- export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, MERCHANT_COLOUR_KEYS, MERCHANT_FIELD_MAX_LENGTH, MERCHANT_IMAGE_SLOTS, MERCHANT_IMAGE_SLOT_KEYS, MERCHANT_LINK_KEYS, MERCHANT_LINK_PATHS, MERCHANT_OBJECT_FIELDS, MERCHANT_READ_ONLY_FIELDS, PromotionRuleSet, SHOPWAVE_ENTITIES, SHOPWAVE_IMAGE_BASE_URL, SHOPWAVE_RESOURCES, SHOPWAVE_UPLOAD, ShopwaveApiError, UPLOAD_KINDS, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, coverCrop, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchMerchant, fetchProduct, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, fetchUser, findRepricedInstances, fitImageToSize, formatDateForQuery, getApiErrorMap, getApiErrors, getImageUrl, getMerchantImage, getMerchantImageUrl, imageFileName, isSameInstancePrice, isUploadKind, loginPath, logout, logoutPath, merchantLinksFromForm, merchantLinksToForm, normalizeMerchantLinks, parseBasketReportData, readImageSize, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, setMerchantImage, submitEntity, toProductInstancesForSave, toShopwaveApiError, toStoredImageIds, updateMerchant, uploadImage, uploadMerchantImage, validateMerchant, withToken };
1406
+ export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, MERCHANT_COLOUR_KEYS, MERCHANT_FIELD_MAX_LENGTH, MERCHANT_IMAGE_SLOTS, MERCHANT_IMAGE_SLOT_KEYS, MERCHANT_LINK_KEYS, MERCHANT_LINK_PATHS, MERCHANT_OBJECT_FIELDS, MERCHANT_READ_ONLY_FIELDS, PromotionRuleSet, SHOPWAVE_ENTITIES, SHOPWAVE_IMAGE_BASE_URL, SHOPWAVE_LOOKUPS, SHOPWAVE_RESOURCES, SHOPWAVE_UPLOAD, ShopwaveApiError, UPLOAD_KINDS, UPLOAD_PREFIXES, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, coverCrop, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchMerchant, fetchProduct, fetchProductInstanceTags, fetchProductTags, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, fetchUser, findRepricedInstances, fitImageToSize, formatDateForQuery, getApiErrorMap, getApiErrors, getImageUrl, getMerchantImage, getMerchantImageUrl, imageFileName, isSameInstancePrice, isUploadKind, isUploadPrefix, loginPath, logout, logoutPath, merchantLinksFromForm, merchantLinksToForm, normalizeMerchantLinks, normalizeTagList, parseBasketReportData, productImageRole, readImageSize, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, setMerchantImage, submitEntity, toProductInstancesForSave, toShopwaveApiError, toStoredImageIds, toStoredProductImages, updateMerchant, uploadImage, uploadMerchantImage, validateMerchant, withToken };
1295
1407
  //# sourceMappingURL=index.js.map