react-shopwave-connect 0.4.3 → 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.
package/dist/index.cjs CHANGED
@@ -407,17 +407,29 @@ const UPLOAD_KINDS = Object.freeze({
407
407
  applicationLogo: "applicaionLogo",
408
408
  applicationImages: "applicaionImages"
409
409
  });
410
+ /** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
411
+ const UPLOAD_PREFIXES = Object.freeze([
412
+ "product_thumbnail",
413
+ "variant_thumbnail",
414
+ "product",
415
+ "variant"
416
+ ]);
410
417
  /** How uploads are addressed (app route and Shopwave uploader). */
411
418
  const SHOPWAVE_UPLOAD = Object.freeze({
412
419
  route: "upload",
413
420
  upstream: "uploader",
414
421
  kindHeader: "contentType",
422
+ prefixHeader: "prefix",
415
423
  fileField: "file",
416
- kindField: "kind"
424
+ kindField: "kind",
425
+ prefixField: "prefix"
417
426
  });
418
427
  function isUploadKind(value) {
419
428
  return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
420
429
  }
430
+ function isUploadPrefix(value) {
431
+ return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
432
+ }
421
433
  const SHOPWAVE_LOOKUPS = Object.freeze({
422
434
  productTags: {
423
435
  route: "products/tags",
@@ -603,443 +615,109 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
603
615
  }
604
616
 
605
617
  //#endregion
606
- //#region src/core/entities.ts
618
+ //#region src/core/imageFit.ts
607
619
  /**
608
- * Addressing per entity, following the Shopwave API reference
609
- * (https://developer.merchantstack.com/api-reference.html) and checked against
610
- * the live API where noted:
611
- * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
612
- * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
613
- * reads the promotion and saves the whole record back with `endDate` = now (and
614
- * `startDate` = now too if it hadn't started); the live API rejects a partial
615
- * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
616
- * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
617
- * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
618
- * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
620
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
621
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
619
622
  */
620
- const SHOPWAVE_ENTITIES = Object.freeze({
621
- product: {
622
- route: "products",
623
- collection: "products",
624
- upstream: "product",
625
- idsHeader: "productIds",
626
- idHeader: "productId",
627
- writable: true,
628
- deleteMode: "delete"
629
- },
630
- category: {
631
- route: "categories",
632
- collection: "categories",
633
- upstream: "category",
634
- idsHeader: "categoryIds",
635
- idHeader: "categoryId",
636
- writable: true,
637
- deleteMode: "delete"
638
- },
639
- store: {
640
- route: "stores",
641
- collection: "stores",
642
- upstream: "store",
643
- idsHeader: "storeIds",
644
- idHeader: "storeId",
645
- writable: true,
646
- deleteMode: "delete"
647
- },
648
- promotion: {
649
- route: "promotions",
650
- collection: "promotions",
651
- upstream: "promotion",
652
- idsHeader: "promotionIds",
653
- idHeader: "promotionId",
654
- writable: true,
655
- deleteMode: "retire",
656
- retireField: "endDate",
657
- retireWithRecord: true,
658
- retireStartField: "startDate"
659
- },
660
- employee: {
661
- route: "employees",
662
- collection: "employees",
663
- upstream: "employee",
664
- idsHeader: "employeeIds",
665
- idHeader: "employeeId",
666
- writable: true,
667
- deleteMode: "retire",
668
- retireField: "exitDate"
669
- },
670
- consumer: {
671
- route: "consumer",
672
- collection: "consumers",
673
- upstream: "consumer",
674
- idsHeader: "ids",
675
- idHeader: "consumerId",
676
- writable: false,
677
- deleteMode: "none"
623
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
624
+ if (![
625
+ srcWidth,
626
+ srcHeight,
627
+ dstWidth,
628
+ dstHeight
629
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
630
+ const srcRatio = srcWidth / srcHeight;
631
+ const dstRatio = dstWidth / dstHeight;
632
+ if (srcRatio > dstRatio) {
633
+ const sw = Math.round(srcHeight * dstRatio);
634
+ return {
635
+ sx: Math.round((srcWidth - sw) / 2),
636
+ sy: 0,
637
+ sw,
638
+ sh: srcHeight
639
+ };
678
640
  }
679
- });
680
- function definition(kind) {
681
- const def = SHOPWAVE_ENTITIES[kind];
682
- if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
683
- return def;
641
+ const sh = Math.round(srcWidth / dstRatio);
642
+ return {
643
+ sx: 0,
644
+ sy: Math.round((srcHeight - sh) / 2),
645
+ sw: srcWidth,
646
+ sh
647
+ };
684
648
  }
685
- function assertId(kind, id) {
686
- const s = id == null ? "" : String(id).trim();
687
- if (!s) throw new Error(`${kind} id is missing`);
688
- return encodeURIComponent(s);
649
+ function requireBrowserImages(fn) {
650
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
689
651
  }
690
- /**
691
- * Creates or updates several entities of one kind in a single request and
692
- * returns them as saved (with their ids), in the order given.
693
- *
694
- * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
695
- * answers 201 and echoes every entity under the ref it was sent with, so each
696
- * result is matched back by ref. Throws {@link ShopwaveApiError} when the
697
- * response carries `api.message.errors` or any ref is missing from it.
698
- *
699
- * The API's partial-success semantics for multi-entity saves haven't been
700
- * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
701
- */
702
- async function saveEntities(kind, items, options = {}) {
703
- const def = definition(kind);
704
- if (items.length === 0) return [];
705
- const refs = items.map((_, i) => String(i));
706
- const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
707
- const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
708
- method: "POST",
709
- headers: { "Content-Type": "application/json" },
710
- body: JSON.stringify(payload)
711
- }, options);
712
- const body = raw;
713
- assertNoApiErrors(body, status);
714
- const echoed = body?.[def.collection] ?? {};
715
- return refs.map((ref, i) => {
716
- const saved = echoed[ref];
717
- if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
652
+ /** Reads an image file's pixel size (browser only). */
653
+ async function readImageSize(file) {
654
+ requireBrowserImages("readImageSize");
655
+ const bitmap = await createImageBitmap(file);
656
+ try {
718
657
  return {
719
- ...items[i],
720
- ...saved,
721
- id: Number(saved.id)
658
+ width: bitmap.width,
659
+ height: bitmap.height
722
660
  };
723
- });
724
- }
725
- /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
726
- async function saveEntity(kind, item, options = {}) {
727
- const [saved] = await saveEntities(kind, [item], options);
728
- return saved;
661
+ } finally {
662
+ bitmap.close?.();
663
+ }
729
664
  }
730
665
  /**
731
- * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
732
- * Employees and promotions can't be deleted in Shopwave; for them the route
733
- * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
734
- * promotion is saved back whole (see {@link deletePromotion}).
735
- *
736
- * Shopwave answers 205 with an empty body — also for ids that don't exist — so
737
- * a resolved promise means "the API accepted the request", not "a record was
738
- * deleted".
666
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
667
+ * (never stretched). Browser only. The result is a `File` named like the
668
+ * input, with the output type's extension.
739
669
  */
740
- async function deleteEntityById(kind, id, options = {}) {
741
- await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
742
- }
743
- /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
744
- async function fetchEntityById(kind, id, params = {}, options = {}) {
745
- const def = definition(kind);
746
- const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
747
- assertNoApiErrors(body, 200);
748
- const map = body?.[def.collection] ?? {};
749
- return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
670
+ async function fitImageToSize(file, width, height, options = {}) {
671
+ requireBrowserImages("fitImageToSize");
672
+ const { type = "image/png", quality, keepIfExact = true } = options;
673
+ const name = file.name || "image";
674
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
675
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
676
+ const bitmap = await createImageBitmap(file);
677
+ try {
678
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
679
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
680
+ let blob;
681
+ if (typeof OffscreenCanvas === "function") {
682
+ const canvas = new OffscreenCanvas(width, height);
683
+ const ctx = canvas.getContext("2d");
684
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
685
+ ctx.imageSmoothingQuality = "high";
686
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
687
+ blob = await canvas.convertToBlob({
688
+ type,
689
+ quality
690
+ });
691
+ } else {
692
+ const canvas = document.createElement("canvas");
693
+ canvas.width = width;
694
+ canvas.height = height;
695
+ const ctx = canvas.getContext("2d");
696
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
697
+ ctx.imageSmoothingQuality = "high";
698
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
699
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
700
+ }
701
+ return new File([blob], outName, { type });
702
+ } finally {
703
+ bitmap.close?.();
704
+ }
750
705
  }
706
+
707
+ //#endregion
708
+ //#region src/core/upload.ts
751
709
  /**
752
- * Creates (no `id`) or updates (with `id`) a product.
753
- *
754
- * Shopwave instances (prices) can't be updated, only replaced. Build
755
- * `instances` with {@link toProductInstancesForSave} (passing the product as last
756
- * read) so an edited price is sent as a new instance. As a safeguard, a new
757
- * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
758
- * another product would otherwise point the save at that product's instances.
759
- */
760
- const saveProduct = (product, options) => {
761
- return saveEntity("product", (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
762
- ...product,
763
- instances: toProductInstancesForSave(product.instances)
764
- } : product, options);
765
- };
766
- const deleteProduct = (id, options) => deleteEntityById("product", id, options);
767
- const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
768
- const saveCategory = (category, options) => saveEntity("category", category, options);
769
- const deleteCategory = (id, options) => deleteEntityById("category", id, options);
770
- const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
771
- const saveStore = (store, options) => saveEntity("store", store, options);
772
- const deleteStore = (id, options) => deleteEntityById("store", id, options);
773
- const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
774
- const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
775
- /**
776
- * Expires a promotion (Shopwave never deletes promotions). The route reads the
777
- * promotion and saves it back with `endDate` set to now; a promotion that hasn't
778
- * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
779
- * exist or the save fails.
710
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
711
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
712
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
780
713
  */
781
- const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
782
- const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
714
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
715
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
783
716
  /**
784
- * Creates an employee, or updates one (with `id`). On update Shopwave only
785
- * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
786
- * email can't be changed, and the echo only carries the updated fields — re-read
787
- * with `fetchEmployee` if you need the stored record.
788
- */
789
- const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
790
- /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
791
- const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
792
- const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
793
- const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
794
-
795
- //#endregion
796
- //#region src/core/basket.ts
797
- const DEFAULT_BASKET_FILTERS = {
798
- search: "",
799
- dateRange: {
800
- start: null,
801
- end: null
802
- },
803
- consumerId: null,
804
- status: "all"
805
- };
806
- const DEFAULT_PAGINATION = {
807
- page: 0,
808
- pageSize: 25,
809
- total: 0
810
- };
811
- /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
812
- function formatDateForQuery(date, isEndOfDay = false) {
813
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
814
- }
815
- /** Builds the `Basket` report query from a set of filters. */
816
- function buildBasketReportQuery(filters) {
817
- const whereConditions = [];
818
- const today = /* @__PURE__ */ new Date();
819
- const startDate = filters.dateRange.start ?? today;
820
- const endDate = filters.dateRange.end ?? today;
821
- whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
822
- whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
823
- if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
824
- if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
825
- return { baskets: {
826
- FROM: "Basket",
827
- WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
828
- } };
829
- }
830
- /** Flattens the raw report rows/keys into structured baskets. */
831
- function parseBasketReportData(reports) {
832
- if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
833
- const { keys, data } = reports.baskets;
834
- const keyIndices = {};
835
- keys.forEach((key, index) => {
836
- keyIndices[key.name] = index;
837
- });
838
- const productKey = keys.find((k) => k.name === "Basket.Product");
839
- const productSubKeys = {};
840
- if (productKey?.item) productKey.item.forEach((subKey, index) => {
841
- productSubKeys[subKey.name] = index;
842
- });
843
- return data.map((row) => {
844
- const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
845
- productId: productRow[productSubKeys["Basket.Product.productId"]],
846
- productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
847
- quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
848
- price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
849
- tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
850
- }));
851
- const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
852
- const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
853
- const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
854
- const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
855
- return {
856
- id: row[keyIndices["Basket.id"]],
857
- name: row[keyIndices["Basket.name"]],
858
- consumerId: row[keyIndices["Basket.consumerId"]],
859
- products,
860
- total,
861
- taxTotal,
862
- status: "completed",
863
- completeDate
864
- };
865
- });
866
- }
867
- /** Unique non-null consumer ids referenced by a set of baskets. */
868
- function collectConsumerIds(baskets) {
869
- if (!baskets.length) return null;
870
- const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
871
- return ids.length > 0 ? [...new Set(ids)] : null;
872
- }
873
- /** Unique product ids referenced by a set of baskets. */
874
- function collectProductIds(baskets) {
875
- if (!baskets.length) return null;
876
- const ids = /* @__PURE__ */ new Set();
877
- baskets.forEach((basket) => {
878
- basket.products.forEach((product) => {
879
- if (product.productId) ids.add(product.productId);
880
- });
881
- });
882
- return ids.size > 0 ? [...ids] : null;
883
- }
884
- /** Joins baskets with consumer + product lookups, applies search, sorts. */
885
- function combineBasketRows({ baskets, consumerMap, productMap, search }) {
886
- if (!baskets.length) return [];
887
- let combinedRows = baskets.map((basket) => {
888
- const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
889
- const productsWithNames = basket.products.map((p) => {
890
- const product = productMap[p.productId];
891
- return {
892
- ...p,
893
- productName: product?.name || `Product #${p.productId}`
894
- };
895
- });
896
- return {
897
- id: basket.id,
898
- basketId: basket.id,
899
- basketName: basket.name || `Basket #${basket.id}`,
900
- consumerId: basket.consumerId,
901
- consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
902
- consumerEmail: consumer?.email || "-",
903
- products: productsWithNames,
904
- basketTotal: (basket.total || 0) / 100,
905
- tax: basket.taxTotal || 0,
906
- dateTime: basket.completeDate,
907
- status: basket.status || "pending"
908
- };
909
- });
910
- if (search) {
911
- const searchLower = search.toLowerCase();
912
- combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
913
- }
914
- combinedRows.sort((a, b) => {
915
- const dateA = new Date(a.dateTime).getTime();
916
- return new Date(b.dateTime).getTime() - dateA;
917
- });
918
- return combinedRows;
919
- }
920
- /** Aggregate totals for a set of report rows. */
921
- function computeBasketSummary(rows) {
922
- const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
923
- return {
924
- totalBaskets: rows.length,
925
- totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
926
- totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
927
- uniqueConsumers: uniqueConsumerIds.size
928
- };
929
- }
930
- /** Builds a consumer id → consumer lookup. */
931
- function buildConsumerMap(consumers) {
932
- const map = {};
933
- if (consumers) consumers.forEach((consumer) => {
934
- map[consumer.id] = consumer;
935
- });
936
- return map;
937
- }
938
-
939
- //#endregion
940
- //#region src/core/imageFit.ts
941
- /**
942
- * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
943
- * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
944
- */
945
- function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
946
- if (![
947
- srcWidth,
948
- srcHeight,
949
- dstWidth,
950
- dstHeight
951
- ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
952
- const srcRatio = srcWidth / srcHeight;
953
- const dstRatio = dstWidth / dstHeight;
954
- if (srcRatio > dstRatio) {
955
- const sw = Math.round(srcHeight * dstRatio);
956
- return {
957
- sx: Math.round((srcWidth - sw) / 2),
958
- sy: 0,
959
- sw,
960
- sh: srcHeight
961
- };
962
- }
963
- const sh = Math.round(srcWidth / dstRatio);
964
- return {
965
- sx: 0,
966
- sy: Math.round((srcHeight - sh) / 2),
967
- sw: srcWidth,
968
- sh
969
- };
970
- }
971
- function requireBrowserImages(fn) {
972
- if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
973
- }
974
- /** Reads an image file's pixel size (browser only). */
975
- async function readImageSize(file) {
976
- requireBrowserImages("readImageSize");
977
- const bitmap = await createImageBitmap(file);
978
- try {
979
- return {
980
- width: bitmap.width,
981
- height: bitmap.height
982
- };
983
- } finally {
984
- bitmap.close?.();
985
- }
986
- }
987
- /**
988
- * Returns the image at exactly `width × height`: scaled and centre-cropped
989
- * (never stretched). Browser only. The result is a `File` named like the
990
- * input, with the output type's extension.
991
- */
992
- async function fitImageToSize(file, width, height, options = {}) {
993
- requireBrowserImages("fitImageToSize");
994
- const { type = "image/png", quality, keepIfExact = true } = options;
995
- const name = file.name || "image";
996
- const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
997
- const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
998
- const bitmap = await createImageBitmap(file);
999
- try {
1000
- if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
1001
- const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
1002
- let blob;
1003
- if (typeof OffscreenCanvas === "function") {
1004
- const canvas = new OffscreenCanvas(width, height);
1005
- const ctx = canvas.getContext("2d");
1006
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
1007
- ctx.imageSmoothingQuality = "high";
1008
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
1009
- blob = await canvas.convertToBlob({
1010
- type,
1011
- quality
1012
- });
1013
- } else {
1014
- const canvas = document.createElement("canvas");
1015
- canvas.width = width;
1016
- canvas.height = height;
1017
- const ctx = canvas.getContext("2d");
1018
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
1019
- ctx.imageSmoothingQuality = "high";
1020
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
1021
- blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
1022
- }
1023
- return new File([blob], outName, { type });
1024
- } finally {
1025
- bitmap.close?.();
1026
- }
1027
- }
1028
-
1029
- //#endregion
1030
- //#region src/core/upload.ts
1031
- /**
1032
- * HTTPS host that serves the Shopwave CDN's images. The API returns
1033
- * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
1034
- * (checked Sep 2026), so pages served over HTTPS can't show it.
1035
- */
1036
- const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
1037
- const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
1038
- /**
1039
- * Turns an image path from the API into a URL a page can load over HTTPS:
1040
- * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
1041
- * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
1042
- * Other absolute URLs are returned unchanged; empty input gives `""`.
717
+ * Turns an image path from the API into a URL a page can load over HTTPS:
718
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
719
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
720
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
1043
721
  */
1044
722
  function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
1045
723
  if (!path) return "";
@@ -1059,10 +737,12 @@ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
1059
737
  async function uploadImage(file, params, options = {}) {
1060
738
  if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
1061
739
  if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
740
+ if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
1062
741
  const name = params.fileName ?? file.name ?? "upload";
1063
742
  const form = new FormData();
1064
743
  form.append(SHOPWAVE_UPLOAD.fileField, file, name);
1065
744
  form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
745
+ if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
1066
746
  const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
1067
747
  method: "POST",
1068
748
  body: form
@@ -1197,6 +877,382 @@ async function uploadMerchantImage(file, slot, options = {}) {
1197
877
  return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1198
878
  }
1199
879
 
880
+ //#endregion
881
+ //#region src/core/productImages.ts
882
+ /**
883
+ * Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
884
+ * - A product's `images` is a list. **Reads return full URLs**
885
+ * (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
886
+ * **writes take the uploader's file names** ({@link toStoredProductImages};
887
+ * `saveProduct` does this for you).
888
+ * - The uploader takes one file per request. With a `prefix` (see
889
+ * `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
890
+ * (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
891
+ * image's role can be read back from its name ({@link productImageRole}).
892
+ */
893
+ /**
894
+ * The role in an image's file name or URL, from the uploader's `prefix`:
895
+ * `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
896
+ * without one (images uploaded without a prefix, e.g. before prefixes existed).
897
+ */
898
+ function productImageRole(value) {
899
+ const name = imageFileName(value);
900
+ if (!name) return null;
901
+ return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
902
+ }
903
+ /**
904
+ * A product's `images` as the API takes them on a save: file names, not the
905
+ * URLs a read returns. Non-strings, blanks and duplicates are dropped; the
906
+ * order is kept.
907
+ */
908
+ function toStoredProductImages(images) {
909
+ if (!Array.isArray(images)) return [];
910
+ const names = images.map(imageFileName).filter((name) => Boolean(name));
911
+ return Array.from(new Set(names));
912
+ }
913
+
914
+ //#endregion
915
+ //#region src/core/entities.ts
916
+ /**
917
+ * Addressing per entity, following the Shopwave API reference
918
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
919
+ * the live API where noted:
920
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
921
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
922
+ * reads the promotion and saves the whole record back with `endDate` = now (and
923
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
924
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
925
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
926
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
927
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
928
+ */
929
+ const SHOPWAVE_ENTITIES = Object.freeze({
930
+ product: {
931
+ route: "products",
932
+ collection: "products",
933
+ upstream: "product",
934
+ idsHeader: "productIds",
935
+ idHeader: "productId",
936
+ writable: true,
937
+ deleteMode: "delete"
938
+ },
939
+ category: {
940
+ route: "categories",
941
+ collection: "categories",
942
+ upstream: "category",
943
+ idsHeader: "categoryIds",
944
+ idHeader: "categoryId",
945
+ writable: true,
946
+ deleteMode: "delete"
947
+ },
948
+ store: {
949
+ route: "stores",
950
+ collection: "stores",
951
+ upstream: "store",
952
+ idsHeader: "storeIds",
953
+ idHeader: "storeId",
954
+ writable: true,
955
+ deleteMode: "delete"
956
+ },
957
+ promotion: {
958
+ route: "promotions",
959
+ collection: "promotions",
960
+ upstream: "promotion",
961
+ idsHeader: "promotionIds",
962
+ idHeader: "promotionId",
963
+ writable: true,
964
+ deleteMode: "retire",
965
+ retireField: "endDate",
966
+ retireWithRecord: true,
967
+ retireStartField: "startDate"
968
+ },
969
+ employee: {
970
+ route: "employees",
971
+ collection: "employees",
972
+ upstream: "employee",
973
+ idsHeader: "employeeIds",
974
+ idHeader: "employeeId",
975
+ writable: true,
976
+ deleteMode: "retire",
977
+ retireField: "exitDate"
978
+ },
979
+ consumer: {
980
+ route: "consumer",
981
+ collection: "consumers",
982
+ upstream: "consumer",
983
+ idsHeader: "ids",
984
+ idHeader: "consumerId",
985
+ writable: false,
986
+ deleteMode: "none"
987
+ }
988
+ });
989
+ function definition(kind) {
990
+ const def = SHOPWAVE_ENTITIES[kind];
991
+ if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
992
+ return def;
993
+ }
994
+ function assertId(kind, id) {
995
+ const s = id == null ? "" : String(id).trim();
996
+ if (!s) throw new Error(`${kind} id is missing`);
997
+ return encodeURIComponent(s);
998
+ }
999
+ /**
1000
+ * Creates or updates several entities of one kind in a single request and
1001
+ * returns them as saved (with their ids), in the order given.
1002
+ *
1003
+ * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
1004
+ * answers 201 and echoes every entity under the ref it was sent with, so each
1005
+ * result is matched back by ref. Throws {@link ShopwaveApiError} when the
1006
+ * response carries `api.message.errors` or any ref is missing from it.
1007
+ *
1008
+ * The API's partial-success semantics for multi-entity saves haven't been
1009
+ * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
1010
+ */
1011
+ async function saveEntities(kind, items, options = {}) {
1012
+ const def = definition(kind);
1013
+ if (items.length === 0) return [];
1014
+ const refs = items.map((_, i) => String(i));
1015
+ const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
1016
+ const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
1017
+ method: "POST",
1018
+ headers: { "Content-Type": "application/json" },
1019
+ body: JSON.stringify(payload)
1020
+ }, options);
1021
+ const body = raw;
1022
+ assertNoApiErrors(body, status);
1023
+ const echoed = body?.[def.collection] ?? {};
1024
+ return refs.map((ref, i) => {
1025
+ const saved = echoed[ref];
1026
+ if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
1027
+ return {
1028
+ ...items[i],
1029
+ ...saved,
1030
+ id: Number(saved.id)
1031
+ };
1032
+ });
1033
+ }
1034
+ /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
1035
+ async function saveEntity(kind, item, options = {}) {
1036
+ const [saved] = await saveEntities(kind, [item], options);
1037
+ return saved;
1038
+ }
1039
+ /**
1040
+ * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
1041
+ * Employees and promotions can't be deleted in Shopwave; for them the route
1042
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
1043
+ * promotion is saved back whole (see {@link deletePromotion}).
1044
+ *
1045
+ * Shopwave answers 205 with an empty body — also for ids that don't exist — so
1046
+ * a resolved promise means "the API accepted the request", not "a record was
1047
+ * deleted".
1048
+ */
1049
+ async function deleteEntityById(kind, id, options = {}) {
1050
+ await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
1051
+ }
1052
+ /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
1053
+ async function fetchEntityById(kind, id, params = {}, options = {}) {
1054
+ const def = definition(kind);
1055
+ const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
1056
+ assertNoApiErrors(body, 200);
1057
+ const map = body?.[def.collection] ?? {};
1058
+ return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
1059
+ }
1060
+ /**
1061
+ * Creates (no `id`) or updates (with `id`) a product.
1062
+ *
1063
+ * Shopwave instances (prices) can't be updated, only replaced. Build
1064
+ * `instances` with {@link toProductInstancesForSave} (passing the product as last
1065
+ * read) so an edited price is sent as a new instance. As a safeguard, a new
1066
+ * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
1067
+ * another product would otherwise point the save at that product's instances.
1068
+ *
1069
+ * `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
1070
+ * read returns can be passed straight back; leaving `images` out keeps them.
1071
+ */
1072
+ const saveProduct = (product, options) => {
1073
+ let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
1074
+ ...product,
1075
+ instances: toProductInstancesForSave(product.instances)
1076
+ } : product;
1077
+ if (Array.isArray(toSave.images)) toSave = {
1078
+ ...toSave,
1079
+ images: toStoredProductImages(toSave.images)
1080
+ };
1081
+ return saveEntity("product", toSave, options);
1082
+ };
1083
+ const deleteProduct = (id, options) => deleteEntityById("product", id, options);
1084
+ const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
1085
+ const saveCategory = (category, options) => saveEntity("category", category, options);
1086
+ const deleteCategory = (id, options) => deleteEntityById("category", id, options);
1087
+ const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
1088
+ const saveStore = (store, options) => saveEntity("store", store, options);
1089
+ const deleteStore = (id, options) => deleteEntityById("store", id, options);
1090
+ const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
1091
+ const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
1092
+ /**
1093
+ * Expires a promotion (Shopwave never deletes promotions). The route reads the
1094
+ * promotion and saves it back with `endDate` set to now; a promotion that hasn't
1095
+ * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
1096
+ * exist or the save fails.
1097
+ */
1098
+ const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
1099
+ const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
1100
+ /**
1101
+ * Creates an employee, or updates one (with `id`). On update Shopwave only
1102
+ * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
1103
+ * email can't be changed, and the echo only carries the updated fields — re-read
1104
+ * with `fetchEmployee` if you need the stored record.
1105
+ */
1106
+ const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
1107
+ /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
1108
+ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
1109
+ const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
1110
+ const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
1111
+
1112
+ //#endregion
1113
+ //#region src/core/basket.ts
1114
+ const DEFAULT_BASKET_FILTERS = {
1115
+ search: "",
1116
+ dateRange: {
1117
+ start: null,
1118
+ end: null
1119
+ },
1120
+ consumerId: null,
1121
+ status: "all"
1122
+ };
1123
+ const DEFAULT_PAGINATION = {
1124
+ page: 0,
1125
+ pageSize: 25,
1126
+ total: 0
1127
+ };
1128
+ /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
1129
+ function formatDateForQuery(date, isEndOfDay = false) {
1130
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
1131
+ }
1132
+ /** Builds the `Basket` report query from a set of filters. */
1133
+ function buildBasketReportQuery(filters) {
1134
+ const whereConditions = [];
1135
+ const today = /* @__PURE__ */ new Date();
1136
+ const startDate = filters.dateRange.start ?? today;
1137
+ const endDate = filters.dateRange.end ?? today;
1138
+ whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
1139
+ whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
1140
+ if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
1141
+ if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
1142
+ return { baskets: {
1143
+ FROM: "Basket",
1144
+ WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
1145
+ } };
1146
+ }
1147
+ /** Flattens the raw report rows/keys into structured baskets. */
1148
+ function parseBasketReportData(reports) {
1149
+ if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
1150
+ const { keys, data } = reports.baskets;
1151
+ const keyIndices = {};
1152
+ keys.forEach((key, index) => {
1153
+ keyIndices[key.name] = index;
1154
+ });
1155
+ const productKey = keys.find((k) => k.name === "Basket.Product");
1156
+ const productSubKeys = {};
1157
+ if (productKey?.item) productKey.item.forEach((subKey, index) => {
1158
+ productSubKeys[subKey.name] = index;
1159
+ });
1160
+ return data.map((row) => {
1161
+ const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
1162
+ productId: productRow[productSubKeys["Basket.Product.productId"]],
1163
+ productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
1164
+ quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
1165
+ price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
1166
+ tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
1167
+ }));
1168
+ const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
1169
+ const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
1170
+ const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
1171
+ const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
1172
+ return {
1173
+ id: row[keyIndices["Basket.id"]],
1174
+ name: row[keyIndices["Basket.name"]],
1175
+ consumerId: row[keyIndices["Basket.consumerId"]],
1176
+ products,
1177
+ total,
1178
+ taxTotal,
1179
+ status: "completed",
1180
+ completeDate
1181
+ };
1182
+ });
1183
+ }
1184
+ /** Unique non-null consumer ids referenced by a set of baskets. */
1185
+ function collectConsumerIds(baskets) {
1186
+ if (!baskets.length) return null;
1187
+ const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
1188
+ return ids.length > 0 ? [...new Set(ids)] : null;
1189
+ }
1190
+ /** Unique product ids referenced by a set of baskets. */
1191
+ function collectProductIds(baskets) {
1192
+ if (!baskets.length) return null;
1193
+ const ids = /* @__PURE__ */ new Set();
1194
+ baskets.forEach((basket) => {
1195
+ basket.products.forEach((product) => {
1196
+ if (product.productId) ids.add(product.productId);
1197
+ });
1198
+ });
1199
+ return ids.size > 0 ? [...ids] : null;
1200
+ }
1201
+ /** Joins baskets with consumer + product lookups, applies search, sorts. */
1202
+ function combineBasketRows({ baskets, consumerMap, productMap, search }) {
1203
+ if (!baskets.length) return [];
1204
+ let combinedRows = baskets.map((basket) => {
1205
+ const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
1206
+ const productsWithNames = basket.products.map((p) => {
1207
+ const product = productMap[p.productId];
1208
+ return {
1209
+ ...p,
1210
+ productName: product?.name || `Product #${p.productId}`
1211
+ };
1212
+ });
1213
+ return {
1214
+ id: basket.id,
1215
+ basketId: basket.id,
1216
+ basketName: basket.name || `Basket #${basket.id}`,
1217
+ consumerId: basket.consumerId,
1218
+ consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
1219
+ consumerEmail: consumer?.email || "-",
1220
+ products: productsWithNames,
1221
+ basketTotal: (basket.total || 0) / 100,
1222
+ tax: basket.taxTotal || 0,
1223
+ dateTime: basket.completeDate,
1224
+ status: basket.status || "pending"
1225
+ };
1226
+ });
1227
+ if (search) {
1228
+ const searchLower = search.toLowerCase();
1229
+ combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
1230
+ }
1231
+ combinedRows.sort((a, b) => {
1232
+ const dateA = new Date(a.dateTime).getTime();
1233
+ return new Date(b.dateTime).getTime() - dateA;
1234
+ });
1235
+ return combinedRows;
1236
+ }
1237
+ /** Aggregate totals for a set of report rows. */
1238
+ function computeBasketSummary(rows) {
1239
+ const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
1240
+ return {
1241
+ totalBaskets: rows.length,
1242
+ totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
1243
+ totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
1244
+ uniqueConsumers: uniqueConsumerIds.size
1245
+ };
1246
+ }
1247
+ /** Builds a consumer id → consumer lookup. */
1248
+ function buildConsumerMap(consumers) {
1249
+ const map = {};
1250
+ if (consumers) consumers.forEach((consumer) => {
1251
+ map[consumer.id] = consumer;
1252
+ });
1253
+ return map;
1254
+ }
1255
+
1200
1256
  //#endregion
1201
1257
  //#region src/core/merchant.ts
1202
1258
  const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
@@ -1957,6 +2013,7 @@ exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
1957
2013
  exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
1958
2014
  exports.ShopwaveApiError = ShopwaveApiError;
1959
2015
  exports.UPLOAD_KINDS = UPLOAD_KINDS;
2016
+ exports.UPLOAD_PREFIXES = UPLOAD_PREFIXES;
1960
2017
  exports.apiDelete = apiDelete;
1961
2018
  exports.apiGet = apiGet;
1962
2019
  exports.apiRequest = apiRequest;
@@ -2008,6 +2065,7 @@ exports.getMerchantImageUrl = getMerchantImageUrl;
2008
2065
  exports.imageFileName = imageFileName;
2009
2066
  exports.isSameInstancePrice = isSameInstancePrice;
2010
2067
  exports.isUploadKind = isUploadKind;
2068
+ exports.isUploadPrefix = isUploadPrefix;
2011
2069
  exports.loginPath = loginPath;
2012
2070
  exports.logout = logout;
2013
2071
  exports.logoutPath = logoutPath;
@@ -2016,6 +2074,7 @@ exports.merchantLinksToForm = merchantLinksToForm;
2016
2074
  exports.normalizeMerchantLinks = normalizeMerchantLinks;
2017
2075
  exports.normalizeTagList = normalizeTagList;
2018
2076
  exports.parseBasketReportData = parseBasketReportData;
2077
+ exports.productImageRole = productImageRole;
2019
2078
  exports.readImageSize = readImageSize;
2020
2079
  exports.rolesById = rolesById;
2021
2080
  exports.saveCategory = saveCategory;
@@ -2030,6 +2089,7 @@ exports.submitEntity = submitEntity;
2030
2089
  exports.toProductInstancesForSave = toProductInstancesForSave;
2031
2090
  exports.toShopwaveApiError = toShopwaveApiError;
2032
2091
  exports.toStoredImageIds = toStoredImageIds;
2092
+ exports.toStoredProductImages = toStoredProductImages;
2033
2093
  exports.updateMerchant = updateMerchant;
2034
2094
  exports.uploadImage = uploadImage;
2035
2095
  exports.uploadMerchantImage = uploadMerchantImage;