react-shopwave-connect 0.4.4 → 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.
package/README.md CHANGED
@@ -219,6 +219,31 @@ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admi
219
219
  - **`prefix`** (optional) is the image's role, sent as the uploader's `prefix` header: `product`, `product_thumbnail`, `variant`, `variant_thumbnail` (`UPLOAD_PREFIXES`). The uploader then names the file `<prefix>_<hash>.<ext>` (live, 5644, 4 Nov 2026: `product_thumbnail_1bdd….png`), so the role travels with the file; `productImageRole(nameOrUrl)` reads it back (`null` for a name without one). Leave it out for images without a role (merchant logos). An unknown prefix throws before any request; the route answers 400 to one.
220
220
  - **Product images:** `images` is a list — **full URLs on read** (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`), **file names on write**. The uploader takes one file per request, so upload each file on its own (`uploadImage(file, { kind: "product", prefix })`) and save the product with all the names. `saveProduct` sends `images` as file names (`toStoredProductImages`), so URLs read back can be passed straight in; leaving `images` out keeps them.
221
221
 
222
+ - **Image rules (browser).** Shopwave doesn't resize uploads, so prepare each image before `uploadImage` with `prepareImage(file, rule, { type?, quality? })`. A rule is an exact size or a shape plus limits; the image is scaled down and centre-cropped in one draw (never stretched), and one that already fits is returned untouched.
223
+
224
+ ```ts
225
+ import { prepareImage, ImageTooSmallError, uploadImage } from "react-shopwave-connect";
226
+
227
+ const rule = { aspectRatio: "100:77", minWidth: 600, minHeight: 462, maxLongSide: 2000 };
228
+ try {
229
+ const ready = await prepareImage(file, rule); // 6000 × 4000 → 2000 × 1540
230
+ await uploadImage(ready, { kind: "product", prefix: "product" });
231
+ } catch (error) {
232
+ if (error instanceof ImageTooSmallError) showWarning(error.required, error.actual); // e.g. 600 × 462 vs 500 × 385
233
+ else throw error;
234
+ }
235
+ ```
236
+
237
+ | Rule field | Meaning |
238
+ |---|---|
239
+ | `width` + `height` | Exact output size (the ratio follows); can't be combined with the others |
240
+ | `aspectRatio` | Shape to crop to: `"100:77"`, `"100/77"`, `[100, 77]` or `1.3` |
241
+ | `minWidth`, `minHeight` | Smaller crops are refused with `ImageTooSmallError` (`required`, `actual`) |
242
+ | `maxWidth`, `maxHeight`, `maxLongSide` | Larger crops are scaled down to fit |
243
+ | `allowUpscale` | Enlarge a too-small image instead of refusing it (default `false`) |
244
+
245
+ Output keeps the source type when it's PNG, JPEG or WebP (JPEG/WebP at quality 0.9), else PNG. `planImageFit(size, rule)` gives the crop and output size without touching an image (pure); `checkImageRule(rule)` and `parseAspectRatio(value)` throw `ImageRuleError` for a rule that contradicts itself.
246
+
222
247
  `SHOPWAVE_RESOURCES` (merchant, user) and `SHOPWAVE_UPLOAD` are shared with the server routes, like `SHOPWAVE_ENTITIES`.
223
248
 
224
249
  ### Available `core` functions
@@ -234,7 +259,7 @@ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admi
234
259
  | report | `fetchReport` |
235
260
  | merchant | `fetchMerchant`, `updateMerchant`, `validateMerchant`; links `merchantLinksToForm`, `merchantLinksFromForm`, `normalizeMerchantLinks`; images `MERCHANT_IMAGE_SLOTS`, `uploadMerchantImage`, `getMerchantImage`, `getMerchantImageUrl`, `setMerchantImage`, `toStoredImageIds`, `imageFileName` |
236
261
  | user | `fetchUser` |
237
- | upload | `uploadImage`, `getImageUrl`, `fitImageToSize`, `readImageSize`, `coverCrop`; `UPLOAD_KINDS`, `UPLOAD_PREFIXES`, `isUploadPrefix` |
262
+ | upload | `uploadImage`, `getImageUrl`, `fitImageToSize`, `readImageSize`, `coverCrop`; `UPLOAD_KINDS`, `UPLOAD_PREFIXES`, `isUploadPrefix`; image rules `prepareImage`, `planImageFit`, `checkImageRule`, `parseAspectRatio`, `ImageTooSmallError`, `ImageRuleError` |
238
263
  | session | `fetchSession`, `loginPath`, `logoutPath`, `logout` |
239
264
  | entity | `saveEntity`, `saveEntities`, `deleteEntityById`, `fetchEntityById`; low-level `submitEntity` / `deleteEntity` (raw endpoint) |
240
265
  | errors | `ShopwaveApiError`, `getApiErrorMap`, `assertNoApiErrors` |
@@ -637,6 +637,35 @@ async function readImageSize(file) {
637
637
  bitmap.close?.();
638
638
  }
639
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
+ }
640
669
  /**
641
670
  * Returns the image at exactly `width × height`: scaled and centre-cropped
642
671
  * (never stretched). Browser only. The result is a `File` named like the
@@ -645,39 +674,194 @@ async function readImageSize(file) {
645
674
  async function fitImageToSize(file, width, height, options = {}) {
646
675
  requireBrowserImages("fitImageToSize");
647
676
  const { type = "image/png", quality, keepIfExact = true } = options;
648
- const name = file.name || "image";
649
- const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
650
- const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
677
+ const outName = outputName(file, type);
651
678
  const bitmap = await createImageBitmap(file);
652
679
  try {
653
680
  if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
654
- const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
655
- let blob;
656
- if (typeof OffscreenCanvas === "function") {
657
- const canvas = new OffscreenCanvas(width, height);
658
- const ctx = canvas.getContext("2d");
659
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
660
- ctx.imageSmoothingQuality = "high";
661
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
662
- blob = await canvas.convertToBlob({
663
- type,
664
- quality
665
- });
666
- } else {
667
- const canvas = document.createElement("canvas");
668
- canvas.width = width;
669
- canvas.height = height;
670
- const ctx = canvas.getContext("2d");
671
- if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
672
- ctx.imageSmoothingQuality = "high";
673
- ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
674
- blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
675
- }
681
+ const blob = await drawCrop(bitmap, coverCrop(bitmap.width, bitmap.height, width, height), width, height, type, quality, "fitImageToSize");
676
682
  return new File([blob], outName, { type });
677
683
  } finally {
678
684
  bitmap.close?.();
679
685
  }
680
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
+ }
681
865
 
682
866
  //#endregion
683
867
  //#region src/core/upload.ts
@@ -1406,6 +1590,8 @@ async function fetchUser(options = {}) {
1406
1590
  //#endregion
1407
1591
  exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
1408
1592
  exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
1593
+ exports.ImageRuleError = ImageRuleError;
1594
+ exports.ImageTooSmallError = ImageTooSmallError;
1409
1595
  exports.MERCHANT_COLOUR_KEYS = MERCHANT_COLOUR_KEYS;
1410
1596
  exports.MERCHANT_FIELD_MAX_LENGTH = MERCHANT_FIELD_MAX_LENGTH;
1411
1597
  exports.MERCHANT_IMAGE_SLOTS = MERCHANT_IMAGE_SLOTS;
@@ -1431,6 +1617,7 @@ exports.assertNoApiErrors = assertNoApiErrors;
1431
1617
  exports.authorizationFor = authorizationFor;
1432
1618
  exports.buildBasketReportQuery = buildBasketReportQuery;
1433
1619
  exports.buildConsumerMap = buildConsumerMap;
1620
+ exports.checkImageRule = checkImageRule;
1434
1621
  exports.collectConsumerIds = collectConsumerIds;
1435
1622
  exports.collectProductIds = collectProductIds;
1436
1623
  exports.combineBasketRows = combineBasketRows;
@@ -1482,7 +1669,10 @@ exports.merchantLinksFromForm = merchantLinksFromForm;
1482
1669
  exports.merchantLinksToForm = merchantLinksToForm;
1483
1670
  exports.normalizeMerchantLinks = normalizeMerchantLinks;
1484
1671
  exports.normalizeTagList = normalizeTagList;
1672
+ exports.parseAspectRatio = parseAspectRatio;
1485
1673
  exports.parseBasketReportData = parseBasketReportData;
1674
+ exports.planImageFit = planImageFit;
1675
+ exports.prepareImage = prepareImage;
1486
1676
  exports.productImageRole = productImageRole;
1487
1677
  exports.readImageSize = readImageSize;
1488
1678
  exports.rolesById = rolesById;