react-shopwave-connect 0.4.3 → 0.4.5

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.
@@ -382,17 +382,29 @@ const UPLOAD_KINDS = Object.freeze({
382
382
  applicationLogo: "applicaionLogo",
383
383
  applicationImages: "applicaionImages"
384
384
  });
385
+ /** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
386
+ const UPLOAD_PREFIXES = Object.freeze([
387
+ "product_thumbnail",
388
+ "variant_thumbnail",
389
+ "product",
390
+ "variant"
391
+ ]);
385
392
  /** How uploads are addressed (app route and Shopwave uploader). */
386
393
  const SHOPWAVE_UPLOAD = Object.freeze({
387
394
  route: "upload",
388
395
  upstream: "uploader",
389
396
  kindHeader: "contentType",
397
+ prefixHeader: "prefix",
390
398
  fileField: "file",
391
- kindField: "kind"
399
+ kindField: "kind",
400
+ prefixField: "prefix"
392
401
  });
393
402
  function isUploadKind(value) {
394
403
  return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
395
404
  }
405
+ function isUploadPrefix(value) {
406
+ return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
407
+ }
396
408
  const SHOPWAVE_LOOKUPS = Object.freeze({
397
409
  productTags: {
398
410
  route: "products/tags",
@@ -559,22 +571,503 @@ async function logout(options = {}) {
559
571
  * function yet (e.g. `/api/merchant`).
560
572
  */
561
573
  /**
562
- * Deletes `/api/{resourcePath}/{entityId}`.
574
+ * Deletes `/api/{resourcePath}/{entityId}`.
575
+ */
576
+ async function deleteEntity(resourcePath, entityId, options = {}) {
577
+ if (entityId == null || entityId === "") throw new Error("entityId is missing");
578
+ await apiDelete(`/api/${resourcePath}/${entityId}`, options);
579
+ }
580
+ /**
581
+ * Sends a create/update mutation. Returns the response's `result` field when it
582
+ * has one (older app routes wrapped their answer that way), otherwise the whole
583
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
584
+ */
585
+ async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
586
+ const json = await apiSend(endpoint, method, payload, options);
587
+ assertNoApiErrors(json, 200);
588
+ if (json && typeof json === "object" && "result" in json) return json.result;
589
+ return json ?? void 0;
590
+ }
591
+
592
+ //#endregion
593
+ //#region src/core/imageFit.ts
594
+ /**
595
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
596
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
597
+ */
598
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
599
+ if (![
600
+ srcWidth,
601
+ srcHeight,
602
+ dstWidth,
603
+ dstHeight
604
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
605
+ const srcRatio = srcWidth / srcHeight;
606
+ const dstRatio = dstWidth / dstHeight;
607
+ if (srcRatio > dstRatio) {
608
+ const sw = Math.round(srcHeight * dstRatio);
609
+ return {
610
+ sx: Math.round((srcWidth - sw) / 2),
611
+ sy: 0,
612
+ sw,
613
+ sh: srcHeight
614
+ };
615
+ }
616
+ const sh = Math.round(srcWidth / dstRatio);
617
+ return {
618
+ sx: 0,
619
+ sy: Math.round((srcHeight - sh) / 2),
620
+ sw: srcWidth,
621
+ sh
622
+ };
623
+ }
624
+ function requireBrowserImages(fn) {
625
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
626
+ }
627
+ /** Reads an image file's pixel size (browser only). */
628
+ async function readImageSize(file) {
629
+ requireBrowserImages("readImageSize");
630
+ const bitmap = await createImageBitmap(file);
631
+ try {
632
+ return {
633
+ width: bitmap.width,
634
+ height: bitmap.height
635
+ };
636
+ } finally {
637
+ bitmap.close?.();
638
+ }
639
+ }
640
+ /** Draws `crop` of the bitmap at `width × height` and encodes it (one resample). */
641
+ async function drawCrop(bitmap, crop, width, height, type, quality, fn) {
642
+ const { sx, sy, sw, sh } = crop;
643
+ if (typeof OffscreenCanvas === "function") {
644
+ const canvas$1 = new OffscreenCanvas(width, height);
645
+ const ctx$1 = canvas$1.getContext("2d");
646
+ if (!ctx$1) throw new Error(`${fn}: no 2D canvas context`);
647
+ ctx$1.imageSmoothingQuality = "high";
648
+ ctx$1.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
649
+ return canvas$1.convertToBlob({
650
+ type,
651
+ quality
652
+ });
653
+ }
654
+ const canvas = document.createElement("canvas");
655
+ canvas.width = width;
656
+ canvas.height = height;
657
+ const ctx = canvas.getContext("2d");
658
+ if (!ctx) throw new Error(`${fn}: no 2D canvas context`);
659
+ ctx.imageSmoothingQuality = "high";
660
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
661
+ return new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error(`${fn}: encoding failed`)), type, quality));
662
+ }
663
+ /** The input's name with the output type's extension. */
664
+ function outputName(file, type) {
665
+ const name = file.name || "image";
666
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
667
+ return `${name.replace(/\.[^.]+$/, "")}.${ext}`;
668
+ }
669
+ /**
670
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
671
+ * (never stretched). Browser only. The result is a `File` named like the
672
+ * input, with the output type's extension.
673
+ */
674
+ async function fitImageToSize(file, width, height, options = {}) {
675
+ requireBrowserImages("fitImageToSize");
676
+ const { type = "image/png", quality, keepIfExact = true } = options;
677
+ const outName = outputName(file, type);
678
+ const bitmap = await createImageBitmap(file);
679
+ try {
680
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
681
+ const blob = await drawCrop(bitmap, coverCrop(bitmap.width, bitmap.height, width, height), width, height, type, quality, "fitImageToSize");
682
+ return new File([blob], outName, { type });
683
+ } finally {
684
+ bitmap.close?.();
685
+ }
686
+ }
687
+ /** A rule that can't be met by any image (a mistake in the rule, not the image). */
688
+ var ImageRuleError = class extends Error {
689
+ constructor(message) {
690
+ super(message);
691
+ this.name = "ImageRuleError";
692
+ }
693
+ };
694
+ /** The image is smaller than the rule's minimum (and `allowUpscale` is off). */
695
+ var ImageTooSmallError = class extends Error {
696
+ /** The smallest size the rule accepts. */
697
+ required;
698
+ /** The largest area of the right shape the image could give. */
699
+ actual;
700
+ constructor(required, actual) {
701
+ super(`The image is too small: it needs to be at least ${required.width} × ${required.height}, and it's ${actual.width} × ${actual.height}`);
702
+ this.name = "ImageTooSmallError";
703
+ this.required = required;
704
+ this.actual = actual;
705
+ }
706
+ };
707
+ const isPositive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
708
+ /**
709
+ * An aspect ratio as width ÷ height: `"100:77"`, `"100/77"`, `[100, 77]` →
710
+ * 1.2987…; `1.3` → 1.3. Throws {@link ImageRuleError} for anything else.
711
+ */
712
+ function parseAspectRatio(value) {
713
+ let parts = null;
714
+ if (typeof value === "number") return isPositive(value) ? value : badRatio(value);
715
+ if (Array.isArray(value)) parts = [value[0], value[1]];
716
+ else if (typeof value === "string") {
717
+ const text$1 = value.trim();
718
+ const match = /^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/i.exec(text$1);
719
+ if (match) parts = [Number(match[1]), Number(match[2])];
720
+ else if (/^\d+(?:\.\d+)?$/.test(text$1)) return isPositive(Number(text$1)) ? Number(text$1) : badRatio(value);
721
+ }
722
+ if (parts && isPositive(parts[0]) && isPositive(parts[1])) return parts[0] / parts[1];
723
+ return badRatio(value);
724
+ }
725
+ function badRatio(value) {
726
+ throw new ImageRuleError(`Not an aspect ratio: ${JSON.stringify(value)} (use e.g. "100:77", [100, 77] or 1.3)`);
727
+ }
728
+ /** Throws {@link ImageRuleError} when the rule contradicts itself. */
729
+ function checkImageRule(rule) {
730
+ if (!rule || typeof rule !== "object") throw new ImageRuleError("An image rule must be an object");
731
+ for (const key of [
732
+ "width",
733
+ "height",
734
+ "minWidth",
735
+ "minHeight",
736
+ "maxWidth",
737
+ "maxHeight",
738
+ "maxLongSide"
739
+ ]) if (rule[key] != null && !isPositive(rule[key])) throw new ImageRuleError(`${key} must be a positive number`);
740
+ if (rule.width != null || rule.height != null) {
741
+ if (rule.width == null || rule.height == null) throw new ImageRuleError("An exact size needs both width and height");
742
+ if (rule.aspectRatio != null) throw new ImageRuleError("Give an exact size or an aspectRatio, not both (the size sets the ratio)");
743
+ const extra = [
744
+ "minWidth",
745
+ "minHeight",
746
+ "maxWidth",
747
+ "maxHeight",
748
+ "maxLongSide"
749
+ ].filter((key) => rule[key] != null);
750
+ if (extra.length > 0) throw new ImageRuleError(`An exact size is already the minimum and the maximum; remove ${extra.join(", ")}`);
751
+ return;
752
+ }
753
+ if (rule.aspectRatio != null) parseAspectRatio(rule.aspectRatio);
754
+ if (rule.minWidth != null && rule.maxWidth != null && rule.minWidth > rule.maxWidth) throw new ImageRuleError("minWidth is larger than maxWidth");
755
+ if (rule.minHeight != null && rule.maxHeight != null && rule.minHeight > rule.maxHeight) throw new ImageRuleError("minHeight is larger than maxHeight");
756
+ if (rule.maxLongSide != null && Math.max(rule.minWidth ?? 0, rule.minHeight ?? 0) > rule.maxLongSide) throw new ImageRuleError("The minimum size is larger than maxLongSide");
757
+ }
758
+ /**
759
+ * Works out what to do to a `source`-sized image to meet `rule`: the centred
760
+ * crop of the right shape (the largest one), then the output size (scaled
761
+ * down to the maximum, never below the minimum). Pure: no browser needed.
762
+ *
763
+ * Throws {@link ImageTooSmallError} when the crop is below the minimum (or
764
+ * the exact size) and `allowUpscale` is off, and {@link ImageRuleError} for a
765
+ * contradictory rule.
766
+ *
767
+ * @example
768
+ * planImageFit({ width: 6000, height: 4000 }, { aspectRatio: "100:77", minWidth: 600, minHeight: 462, maxLongSide: 2000 })
769
+ * // → crop { sx: 403, sy: 0, sw: 5194, sh: 4000 }, 2000 × 1540
770
+ */
771
+ function planImageFit(source, rule) {
772
+ checkImageRule(rule);
773
+ const { width: W, height: H } = source ?? {};
774
+ if (!isPositive(W) || !isPositive(H)) throw new Error("planImageFit: the image has no size");
775
+ const exact = rule.width != null && rule.height != null;
776
+ const ratio = exact ? rule.width / rule.height : rule.aspectRatio != null ? parseAspectRatio(rule.aspectRatio) : null;
777
+ const crop = ratio == null ? {
778
+ sx: 0,
779
+ sy: 0,
780
+ sw: W,
781
+ sh: H
782
+ } : coverCrop(W, H, ratio, 1);
783
+ const { sw, sh } = crop;
784
+ const kept = {
785
+ width: sw,
786
+ height: sh
787
+ };
788
+ let width;
789
+ let height;
790
+ if (exact) {
791
+ width = rule.width;
792
+ height = rule.height;
793
+ if ((sw < width || sh < height) && !rule.allowUpscale) throw new ImageTooSmallError({
794
+ width,
795
+ height
796
+ }, kept);
797
+ } else {
798
+ const minW = rule.minWidth ?? 0;
799
+ const minH = rule.minHeight ?? 0;
800
+ if ((sw < minW || sh < minH) && !rule.allowUpscale) throw new ImageTooSmallError(requiredSize(minW, minH, ratio), kept);
801
+ let scale = 1;
802
+ if (rule.maxWidth != null) scale = Math.min(scale, rule.maxWidth / sw);
803
+ if (rule.maxHeight != null) scale = Math.min(scale, rule.maxHeight / sh);
804
+ if (rule.maxLongSide != null) scale = Math.min(scale, rule.maxLongSide / Math.max(sw, sh));
805
+ scale = Math.max(scale, minW / sw, minH / sh);
806
+ width = Math.max(1, Math.round(sw * scale));
807
+ height = Math.max(1, Math.round(sh * scale));
808
+ }
809
+ const unchanged = crop.sx === 0 && crop.sy === 0 && sw === W && sh === H && width === W && height === H;
810
+ return {
811
+ crop,
812
+ width,
813
+ height,
814
+ unchanged
815
+ };
816
+ }
817
+ /** The minimum as a full size: a ratio rule with only one minimum implies the other. */
818
+ function requiredSize(minW, minH, ratio) {
819
+ if (ratio == null) return {
820
+ width: minW,
821
+ height: minH
822
+ };
823
+ return {
824
+ width: Math.max(minW, Math.ceil(minH * ratio)),
825
+ height: Math.max(minH, Math.ceil(minW / ratio))
826
+ };
827
+ }
828
+ const OUTPUT_TYPES = [
829
+ "image/png",
830
+ "image/jpeg",
831
+ "image/webp"
832
+ ];
833
+ /**
834
+ * Returns the image meeting `rule` ({@link planImageFit}): centre-cropped to
835
+ * the right shape and scaled in a single draw. An image that already fits is
836
+ * returned untouched (same bytes). Browser only.
837
+ *
838
+ * Throws {@link ImageTooSmallError} (show `required` and `actual` to the
839
+ * person) or {@link ImageRuleError}; an unreadable file rejects with the
840
+ * browser's decoding error.
841
+ *
842
+ * @example
843
+ * const ready = await prepareImage(file, { aspectRatio: "100:77", minWidth: 600, minHeight: 462, maxLongSide: 2000 });
844
+ * await uploadImage(ready, { kind: "product", prefix: "product" });
845
+ */
846
+ async function prepareImage(file, rule, options = {}) {
847
+ checkImageRule(rule);
848
+ requireBrowserImages("prepareImage");
849
+ const sourceType = file.type && OUTPUT_TYPES.includes(file.type) ? file.type : "image/png";
850
+ const type = options.type ?? sourceType;
851
+ const quality = options.quality ?? (type === "image/png" ? void 0 : .9);
852
+ const bitmap = await createImageBitmap(file);
853
+ try {
854
+ const plan = planImageFit({
855
+ width: bitmap.width,
856
+ height: bitmap.height
857
+ }, rule);
858
+ if (plan.unchanged && type === file.type) return file instanceof File ? file : new File([file], outputName(file, type), { type });
859
+ const blob = await drawCrop(bitmap, plan.crop, plan.width, plan.height, type, quality, "prepareImage");
860
+ return new File([blob], outputName(file, type), { type });
861
+ } finally {
862
+ bitmap.close?.();
863
+ }
864
+ }
865
+
866
+ //#endregion
867
+ //#region src/core/upload.ts
868
+ /**
869
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
870
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
871
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
872
+ */
873
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
874
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
875
+ /**
876
+ * Turns an image path from the API into a URL a page can load over HTTPS:
877
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
878
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
879
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
880
+ */
881
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
882
+ if (!path) return "";
883
+ const root = base.replace(/\/+$/, "");
884
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
885
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
886
+ return `${root}/${path.replace(/^\/+/, "")}`;
887
+ }
888
+ /**
889
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
890
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
891
+ *
892
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
893
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
894
+ * 201 `{ fileName, path }`.
895
+ */
896
+ async function uploadImage(file, params, options = {}) {
897
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
898
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
899
+ if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
900
+ const name = params.fileName ?? file.name ?? "upload";
901
+ const form = new FormData();
902
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
903
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
904
+ if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
905
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
906
+ method: "POST",
907
+ body: form
908
+ }, options);
909
+ const body = raw;
910
+ assertNoApiErrors(body, status);
911
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
912
+ const path = typeof body?.path === "string" ? body.path : "";
913
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
914
+ return {
915
+ id,
916
+ url: getImageUrl(path),
917
+ path
918
+ };
919
+ }
920
+
921
+ //#endregion
922
+ //#region src/core/merchantImages.ts
923
+ /** The three image slots on the merchant settings page. */
924
+ const MERCHANT_IMAGE_SLOT_KEYS = [
925
+ "receipt",
926
+ "square",
927
+ "featured"
928
+ ];
929
+ /**
930
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
931
+ * merchant settings page).
932
+ */
933
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
934
+ receipt: {
935
+ field: "logo.receipt",
936
+ uploadKind: "merchant",
937
+ width: 576,
938
+ height: 325
939
+ },
940
+ square: {
941
+ field: "logo.square",
942
+ uploadKind: "merchant",
943
+ width: 1e3,
944
+ height: 1e3
945
+ },
946
+ featured: {
947
+ field: "feature[0]",
948
+ uploadKind: "merchantFeature",
949
+ width: 1200,
950
+ height: 600
951
+ }
952
+ });
953
+ /**
954
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
955
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
956
+ * Returns `null` for empty or non-string values.
957
+ */
958
+ function imageFileName(value) {
959
+ if (typeof value !== "string") return null;
960
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
961
+ return last ? decodeURIComponent(last) : null;
962
+ }
963
+ /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
964
+ function getMerchantImage(imageIds, slot) {
965
+ if (!imageIds || typeof imageIds !== "object") return null;
966
+ let value;
967
+ if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
968
+ else {
969
+ const logo = imageIds.logo;
970
+ value = logo && typeof logo === "object" ? logo[slot] : void 0;
971
+ }
972
+ return typeof value === "string" && value.trim() ? value.trim() : null;
973
+ }
974
+ /**
975
+ * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
976
+ * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
977
+ */
978
+ function getMerchantImageUrl(imageIds, slot, base) {
979
+ const value = getMerchantImage(imageIds, slot);
980
+ if (!value || !value.includes("/")) return "";
981
+ return getImageUrl(value, base);
982
+ }
983
+ /**
984
+ * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
985
+ * with `null`). Other slots and unknown keys are kept; an empty `logo` or
986
+ * `feature` is removed.
987
+ */
988
+ function setMerchantImage(imageIds, slot, fileName) {
989
+ const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
990
+ if (slot === "featured") {
991
+ const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
992
+ const feature = fileName ? [fileName, ...rest] : rest;
993
+ if (feature.length > 0) next.feature = feature;
994
+ else delete next.feature;
995
+ } else {
996
+ const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
997
+ if (fileName) logo[slot] = fileName;
998
+ else delete logo[slot];
999
+ if (Object.keys(logo).length > 0) next.logo = logo;
1000
+ else delete next.logo;
1001
+ }
1002
+ return next;
1003
+ }
1004
+ function toStored(value) {
1005
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
1006
+ if (Array.isArray(value)) {
1007
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
1008
+ return items.length > 0 ? items : void 0;
1009
+ }
1010
+ if (value && typeof value === "object") {
1011
+ const out = {};
1012
+ for (const [k, v] of Object.entries(value)) {
1013
+ const stored = toStored(v);
1014
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
1015
+ }
1016
+ return Object.keys(out).length > 0 ? out : void 0;
1017
+ }
1018
+ }
1019
+ /**
1020
+ * The `imageIds` to send on a save: every URL is turned back into its file
1021
+ * name (what adminV1 does with images you didn't change) and empty values
1022
+ * are dropped. No images gives `{}`.
1023
+ */
1024
+ function toStoredImageIds(imageIds) {
1025
+ return toStored(imageIds) ?? {};
1026
+ }
1027
+ /**
1028
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
1029
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
1030
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
1031
+ */
1032
+ async function uploadMerchantImage(file, slot, options = {}) {
1033
+ const def = MERCHANT_IMAGE_SLOTS[slot];
1034
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
1035
+ const { fit = true, fitOptions,...requestOptions } = options;
1036
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1037
+ }
1038
+
1039
+ //#endregion
1040
+ //#region src/core/productImages.ts
1041
+ /**
1042
+ * Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
1043
+ * - A product's `images` is a list. **Reads return full URLs**
1044
+ * (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
1045
+ * **writes take the uploader's file names** ({@link toStoredProductImages};
1046
+ * `saveProduct` does this for you).
1047
+ * - The uploader takes one file per request. With a `prefix` (see
1048
+ * `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
1049
+ * (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
1050
+ * image's role can be read back from its name ({@link productImageRole}).
1051
+ */
1052
+ /**
1053
+ * The role in an image's file name or URL, from the uploader's `prefix`:
1054
+ * `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
1055
+ * without one (images uploaded without a prefix, e.g. before prefixes existed).
563
1056
  */
564
- async function deleteEntity(resourcePath, entityId, options = {}) {
565
- if (entityId == null || entityId === "") throw new Error("entityId is missing");
566
- await apiDelete(`/api/${resourcePath}/${entityId}`, options);
1057
+ function productImageRole(value) {
1058
+ const name = imageFileName(value);
1059
+ if (!name) return null;
1060
+ return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
567
1061
  }
568
1062
  /**
569
- * Sends a create/update mutation. Returns the response's `result` field when it
570
- * has one (older app routes wrapped their answer that way), otherwise the whole
571
- * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
1063
+ * A product's `images` as the API takes them on a save: file names, not the
1064
+ * URLs a read returns. Non-strings, blanks and duplicates are dropped; the
1065
+ * order is kept.
572
1066
  */
573
- async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
574
- const json = await apiSend(endpoint, method, payload, options);
575
- assertNoApiErrors(json, 200);
576
- if (json && typeof json === "object" && "result" in json) return json.result;
577
- return json ?? void 0;
1067
+ function toStoredProductImages(images) {
1068
+ if (!Array.isArray(images)) return [];
1069
+ const names = images.map(imageFileName).filter((name) => Boolean(name));
1070
+ return Array.from(new Set(names));
578
1071
  }
579
1072
 
580
1073
  //#endregion
@@ -731,12 +1224,20 @@ async function fetchEntityById(kind, id, params = {}, options = {}) {
731
1224
  * read) so an edited price is sent as a new instance. As a safeguard, a new
732
1225
  * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
733
1226
  * another product would otherwise point the save at that product's instances.
1227
+ *
1228
+ * `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
1229
+ * read returns can be passed straight back; leaving `images` out keeps them.
734
1230
  */
735
1231
  const saveProduct = (product, options) => {
736
- return saveEntity("product", (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
1232
+ let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
737
1233
  ...product,
738
1234
  instances: toProductInstancesForSave(product.instances)
739
- } : product, options);
1235
+ } : product;
1236
+ if (Array.isArray(toSave.images)) toSave = {
1237
+ ...toSave,
1238
+ images: toStoredProductImages(toSave.images)
1239
+ };
1240
+ return saveEntity("product", toSave, options);
740
1241
  };
741
1242
  const deleteProduct = (id, options) => deleteEntityById("product", id, options);
742
1243
  const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
@@ -911,267 +1412,6 @@ function buildConsumerMap(consumers) {
911
1412
  return map;
912
1413
  }
913
1414
 
914
- //#endregion
915
- //#region src/core/imageFit.ts
916
- /**
917
- * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
918
- * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
919
- */
920
- function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
921
- if (![
922
- srcWidth,
923
- srcHeight,
924
- dstWidth,
925
- dstHeight
926
- ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
927
- const srcRatio = srcWidth / srcHeight;
928
- const dstRatio = dstWidth / dstHeight;
929
- if (srcRatio > dstRatio) {
930
- const sw = Math.round(srcHeight * dstRatio);
931
- return {
932
- sx: Math.round((srcWidth - sw) / 2),
933
- sy: 0,
934
- sw,
935
- sh: srcHeight
936
- };
937
- }
938
- const sh = Math.round(srcWidth / dstRatio);
939
- return {
940
- sx: 0,
941
- sy: Math.round((srcHeight - sh) / 2),
942
- sw: srcWidth,
943
- sh
944
- };
945
- }
946
- function requireBrowserImages(fn) {
947
- if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
948
- }
949
- /** Reads an image file's pixel size (browser only). */
950
- async function readImageSize(file) {
951
- requireBrowserImages("readImageSize");
952
- const bitmap = await createImageBitmap(file);
953
- try {
954
- return {
955
- width: bitmap.width,
956
- height: bitmap.height
957
- };
958
- } finally {
959
- bitmap.close?.();
960
- }
961
- }
962
- /**
963
- * Returns the image at exactly `width × height`: scaled and centre-cropped
964
- * (never stretched). Browser only. The result is a `File` named like the
965
- * input, with the output type's extension.
966
- */
967
- async function fitImageToSize(file, width, height, options = {}) {
968
- requireBrowserImages("fitImageToSize");
969
- const { type = "image/png", quality, keepIfExact = true } = options;
970
- const name = file.name || "image";
971
- const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
972
- const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
973
- const bitmap = await createImageBitmap(file);
974
- try {
975
- if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
976
- const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
977
- let blob;
978
- if (typeof OffscreenCanvas === "function") {
979
- const canvas = new OffscreenCanvas(width, height);
980
- const ctx = canvas.getContext("2d");
981
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
982
- ctx.imageSmoothingQuality = "high";
983
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
984
- blob = await canvas.convertToBlob({
985
- type,
986
- quality
987
- });
988
- } else {
989
- const canvas = document.createElement("canvas");
990
- canvas.width = width;
991
- canvas.height = height;
992
- const ctx = canvas.getContext("2d");
993
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
994
- ctx.imageSmoothingQuality = "high";
995
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
996
- blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
997
- }
998
- return new File([blob], outName, { type });
999
- } finally {
1000
- bitmap.close?.();
1001
- }
1002
- }
1003
-
1004
- //#endregion
1005
- //#region src/core/upload.ts
1006
- /**
1007
- * HTTPS host that serves the Shopwave CDN's images. The API returns
1008
- * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
1009
- * (checked Sep 2026), so pages served over HTTPS can't show it.
1010
- */
1011
- const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
1012
- const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
1013
- /**
1014
- * Turns an image path from the API into a URL a page can load over HTTPS:
1015
- * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
1016
- * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
1017
- * Other absolute URLs are returned unchanged; empty input gives `""`.
1018
- */
1019
- function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
1020
- if (!path) return "";
1021
- const root = base.replace(/\/+$/, "");
1022
- if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
1023
- if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
1024
- return `${root}/${path.replace(/^\/+/, "")}`;
1025
- }
1026
- /**
1027
- * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
1028
- * (`createShopwaveApi(...).upload()`), and returns its id and URL.
1029
- *
1030
- * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
1031
- * record has to point at it to use it. Live API (Sep 2026): the upload answers
1032
- * 201 `{ fileName, path }`.
1033
- */
1034
- async function uploadImage(file, params, options = {}) {
1035
- if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
1036
- if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
1037
- const name = params.fileName ?? file.name ?? "upload";
1038
- const form = new FormData();
1039
- form.append(SHOPWAVE_UPLOAD.fileField, file, name);
1040
- form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
1041
- const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
1042
- method: "POST",
1043
- body: form
1044
- }, options);
1045
- const body = raw;
1046
- assertNoApiErrors(body, status);
1047
- const id = typeof body?.fileName === "string" ? body.fileName : "";
1048
- const path = typeof body?.path === "string" ? body.path : "";
1049
- if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
1050
- return {
1051
- id,
1052
- url: getImageUrl(path),
1053
- path
1054
- };
1055
- }
1056
-
1057
- //#endregion
1058
- //#region src/core/merchantImages.ts
1059
- /** The three image slots on the merchant settings page. */
1060
- const MERCHANT_IMAGE_SLOT_KEYS = [
1061
- "receipt",
1062
- "square",
1063
- "featured"
1064
- ];
1065
- /**
1066
- * Where each slot lives, its upload kind and its exact size (from adminV1's
1067
- * merchant settings page).
1068
- */
1069
- const MERCHANT_IMAGE_SLOTS = Object.freeze({
1070
- receipt: {
1071
- field: "logo.receipt",
1072
- uploadKind: "merchant",
1073
- width: 576,
1074
- height: 325
1075
- },
1076
- square: {
1077
- field: "logo.square",
1078
- uploadKind: "merchant",
1079
- width: 1e3,
1080
- height: 1e3
1081
- },
1082
- featured: {
1083
- field: "feature[0]",
1084
- uploadKind: "merchantFeature",
1085
- width: 1200,
1086
- height: 600
1087
- }
1088
- });
1089
- /**
1090
- * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
1091
- * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
1092
- * Returns `null` for empty or non-string values.
1093
- */
1094
- function imageFileName(value) {
1095
- if (typeof value !== "string") return null;
1096
- const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
1097
- return last ? decodeURIComponent(last) : null;
1098
- }
1099
- /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
1100
- function getMerchantImage(imageIds, slot) {
1101
- if (!imageIds || typeof imageIds !== "object") return null;
1102
- let value;
1103
- if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
1104
- else {
1105
- const logo = imageIds.logo;
1106
- value = logo && typeof logo === "object" ? logo[slot] : void 0;
1107
- }
1108
- return typeof value === "string" && value.trim() ? value.trim() : null;
1109
- }
1110
- /**
1111
- * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
1112
- * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
1113
- */
1114
- function getMerchantImageUrl(imageIds, slot, base) {
1115
- const value = getMerchantImage(imageIds, slot);
1116
- if (!value || !value.includes("/")) return "";
1117
- return getImageUrl(value, base);
1118
- }
1119
- /**
1120
- * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
1121
- * with `null`). Other slots and unknown keys are kept; an empty `logo` or
1122
- * `feature` is removed.
1123
- */
1124
- function setMerchantImage(imageIds, slot, fileName) {
1125
- const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
1126
- if (slot === "featured") {
1127
- const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
1128
- const feature = fileName ? [fileName, ...rest] : rest;
1129
- if (feature.length > 0) next.feature = feature;
1130
- else delete next.feature;
1131
- } else {
1132
- const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
1133
- if (fileName) logo[slot] = fileName;
1134
- else delete logo[slot];
1135
- if (Object.keys(logo).length > 0) next.logo = logo;
1136
- else delete next.logo;
1137
- }
1138
- return next;
1139
- }
1140
- function toStored(value) {
1141
- if (typeof value === "string") return imageFileName(value) ?? void 0;
1142
- if (Array.isArray(value)) {
1143
- const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
1144
- return items.length > 0 ? items : void 0;
1145
- }
1146
- if (value && typeof value === "object") {
1147
- const out = {};
1148
- for (const [k, v] of Object.entries(value)) {
1149
- const stored = toStored(v);
1150
- if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
1151
- }
1152
- return Object.keys(out).length > 0 ? out : void 0;
1153
- }
1154
- }
1155
- /**
1156
- * The `imageIds` to send on a save: every URL is turned back into its file
1157
- * name (what adminV1 does with images you didn't change) and empty values
1158
- * are dropped. No images gives `{}`.
1159
- */
1160
- function toStoredImageIds(imageIds) {
1161
- return toStored(imageIds) ?? {};
1162
- }
1163
- /**
1164
- * Uploads an image for a merchant slot: fits it to the slot's size, uploads
1165
- * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
1166
- * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
1167
- */
1168
- async function uploadMerchantImage(file, slot, options = {}) {
1169
- const def = MERCHANT_IMAGE_SLOTS[slot];
1170
- if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
1171
- const { fit = true, fitOptions,...requestOptions } = options;
1172
- return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1173
- }
1174
-
1175
1415
  //#endregion
1176
1416
  //#region src/core/merchant.ts
1177
1417
  const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
@@ -1350,6 +1590,8 @@ async function fetchUser(options = {}) {
1350
1590
  //#endregion
1351
1591
  exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
1352
1592
  exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
1593
+ exports.ImageRuleError = ImageRuleError;
1594
+ exports.ImageTooSmallError = ImageTooSmallError;
1353
1595
  exports.MERCHANT_COLOUR_KEYS = MERCHANT_COLOUR_KEYS;
1354
1596
  exports.MERCHANT_FIELD_MAX_LENGTH = MERCHANT_FIELD_MAX_LENGTH;
1355
1597
  exports.MERCHANT_IMAGE_SLOTS = MERCHANT_IMAGE_SLOTS;
@@ -1366,6 +1608,7 @@ exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
1366
1608
  exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
1367
1609
  exports.ShopwaveApiError = ShopwaveApiError;
1368
1610
  exports.UPLOAD_KINDS = UPLOAD_KINDS;
1611
+ exports.UPLOAD_PREFIXES = UPLOAD_PREFIXES;
1369
1612
  exports.apiDelete = apiDelete;
1370
1613
  exports.apiGet = apiGet;
1371
1614
  exports.apiRequest = apiRequest;
@@ -1374,6 +1617,7 @@ exports.assertNoApiErrors = assertNoApiErrors;
1374
1617
  exports.authorizationFor = authorizationFor;
1375
1618
  exports.buildBasketReportQuery = buildBasketReportQuery;
1376
1619
  exports.buildConsumerMap = buildConsumerMap;
1620
+ exports.checkImageRule = checkImageRule;
1377
1621
  exports.collectConsumerIds = collectConsumerIds;
1378
1622
  exports.collectProductIds = collectProductIds;
1379
1623
  exports.combineBasketRows = combineBasketRows;
@@ -1417,6 +1661,7 @@ exports.getMerchantImageUrl = getMerchantImageUrl;
1417
1661
  exports.imageFileName = imageFileName;
1418
1662
  exports.isSameInstancePrice = isSameInstancePrice;
1419
1663
  exports.isUploadKind = isUploadKind;
1664
+ exports.isUploadPrefix = isUploadPrefix;
1420
1665
  exports.loginPath = loginPath;
1421
1666
  exports.logout = logout;
1422
1667
  exports.logoutPath = logoutPath;
@@ -1424,7 +1669,11 @@ exports.merchantLinksFromForm = merchantLinksFromForm;
1424
1669
  exports.merchantLinksToForm = merchantLinksToForm;
1425
1670
  exports.normalizeMerchantLinks = normalizeMerchantLinks;
1426
1671
  exports.normalizeTagList = normalizeTagList;
1672
+ exports.parseAspectRatio = parseAspectRatio;
1427
1673
  exports.parseBasketReportData = parseBasketReportData;
1674
+ exports.planImageFit = planImageFit;
1675
+ exports.prepareImage = prepareImage;
1676
+ exports.productImageRole = productImageRole;
1428
1677
  exports.readImageSize = readImageSize;
1429
1678
  exports.rolesById = rolesById;
1430
1679
  exports.saveCategory = saveCategory;
@@ -1439,6 +1688,7 @@ exports.submitEntity = submitEntity;
1439
1688
  exports.toProductInstancesForSave = toProductInstancesForSave;
1440
1689
  exports.toShopwaveApiError = toShopwaveApiError;
1441
1690
  exports.toStoredImageIds = toStoredImageIds;
1691
+ exports.toStoredProductImages = toStoredProductImages;
1442
1692
  exports.updateMerchant = updateMerchant;
1443
1693
  exports.uploadImage = uploadImage;
1444
1694
  exports.uploadMerchantImage = uploadMerchantImage;