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.
- package/README.md +31 -4
- package/dist/core/index.cjs +526 -276
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +157 -6
- package/dist/core/index.d.ts +157 -6
- package/dist/core/index.js +517 -277
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +190 -170
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +19 -0
- package/dist/hooks/index.d.ts +19 -0
- package/dist/hooks/index.js +190 -170
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +526 -276
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +157 -6
- package/dist/index.d.ts +157 -6
- package/dist/index.js +517 -277
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +75 -57
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +21 -4
- package/dist/next/index.d.ts +21 -4
- package/dist/next/index.js +75 -58
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +75 -57
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +26 -5
- package/dist/server/index.d.ts +26 -5
- package/dist/server/index.js +75 -58
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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",
|
|
@@ -584,22 +596,503 @@ async function logout(options = {}) {
|
|
|
584
596
|
* function yet (e.g. `/api/merchant`).
|
|
585
597
|
*/
|
|
586
598
|
/**
|
|
587
|
-
* Deletes `/api/{resourcePath}/{entityId}`.
|
|
599
|
+
* Deletes `/api/{resourcePath}/{entityId}`.
|
|
600
|
+
*/
|
|
601
|
+
async function deleteEntity(resourcePath, entityId, options = {}) {
|
|
602
|
+
if (entityId == null || entityId === "") throw new Error("entityId is missing");
|
|
603
|
+
await apiDelete(`/api/${resourcePath}/${entityId}`, options);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Sends a create/update mutation. Returns the response's `result` field when it
|
|
607
|
+
* has one (older app routes wrapped their answer that way), otherwise the whole
|
|
608
|
+
* parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
|
|
609
|
+
*/
|
|
610
|
+
async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
|
|
611
|
+
const json = await apiSend(endpoint, method, payload, options);
|
|
612
|
+
assertNoApiErrors(json, 200);
|
|
613
|
+
if (json && typeof json === "object" && "result" in json) return json.result;
|
|
614
|
+
return json ?? void 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/core/imageFit.ts
|
|
619
|
+
/**
|
|
620
|
+
* The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
|
|
621
|
+
* `dstWidth × dstHeight` (like CSS `object-fit: cover`).
|
|
622
|
+
*/
|
|
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
|
+
};
|
|
640
|
+
}
|
|
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
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function requireBrowserImages(fn) {
|
|
650
|
+
if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
|
|
651
|
+
}
|
|
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 {
|
|
657
|
+
return {
|
|
658
|
+
width: bitmap.width,
|
|
659
|
+
height: bitmap.height
|
|
660
|
+
};
|
|
661
|
+
} finally {
|
|
662
|
+
bitmap.close?.();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/** Draws `crop` of the bitmap at `width × height` and encodes it (one resample). */
|
|
666
|
+
async function drawCrop(bitmap, crop, width, height, type, quality, fn) {
|
|
667
|
+
const { sx, sy, sw, sh } = crop;
|
|
668
|
+
if (typeof OffscreenCanvas === "function") {
|
|
669
|
+
const canvas$1 = new OffscreenCanvas(width, height);
|
|
670
|
+
const ctx$1 = canvas$1.getContext("2d");
|
|
671
|
+
if (!ctx$1) throw new Error(`${fn}: no 2D canvas context`);
|
|
672
|
+
ctx$1.imageSmoothingQuality = "high";
|
|
673
|
+
ctx$1.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
674
|
+
return canvas$1.convertToBlob({
|
|
675
|
+
type,
|
|
676
|
+
quality
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
const canvas = document.createElement("canvas");
|
|
680
|
+
canvas.width = width;
|
|
681
|
+
canvas.height = height;
|
|
682
|
+
const ctx = canvas.getContext("2d");
|
|
683
|
+
if (!ctx) throw new Error(`${fn}: no 2D canvas context`);
|
|
684
|
+
ctx.imageSmoothingQuality = "high";
|
|
685
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
686
|
+
return new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error(`${fn}: encoding failed`)), type, quality));
|
|
687
|
+
}
|
|
688
|
+
/** The input's name with the output type's extension. */
|
|
689
|
+
function outputName(file, type) {
|
|
690
|
+
const name = file.name || "image";
|
|
691
|
+
const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
|
|
692
|
+
return `${name.replace(/\.[^.]+$/, "")}.${ext}`;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Returns the image at exactly `width × height`: scaled and centre-cropped
|
|
696
|
+
* (never stretched). Browser only. The result is a `File` named like the
|
|
697
|
+
* input, with the output type's extension.
|
|
698
|
+
*/
|
|
699
|
+
async function fitImageToSize(file, width, height, options = {}) {
|
|
700
|
+
requireBrowserImages("fitImageToSize");
|
|
701
|
+
const { type = "image/png", quality, keepIfExact = true } = options;
|
|
702
|
+
const outName = outputName(file, type);
|
|
703
|
+
const bitmap = await createImageBitmap(file);
|
|
704
|
+
try {
|
|
705
|
+
if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
|
|
706
|
+
const blob = await drawCrop(bitmap, coverCrop(bitmap.width, bitmap.height, width, height), width, height, type, quality, "fitImageToSize");
|
|
707
|
+
return new File([blob], outName, { type });
|
|
708
|
+
} finally {
|
|
709
|
+
bitmap.close?.();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** A rule that can't be met by any image (a mistake in the rule, not the image). */
|
|
713
|
+
var ImageRuleError = class extends Error {
|
|
714
|
+
constructor(message) {
|
|
715
|
+
super(message);
|
|
716
|
+
this.name = "ImageRuleError";
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
/** The image is smaller than the rule's minimum (and `allowUpscale` is off). */
|
|
720
|
+
var ImageTooSmallError = class extends Error {
|
|
721
|
+
/** The smallest size the rule accepts. */
|
|
722
|
+
required;
|
|
723
|
+
/** The largest area of the right shape the image could give. */
|
|
724
|
+
actual;
|
|
725
|
+
constructor(required, actual) {
|
|
726
|
+
super(`The image is too small: it needs to be at least ${required.width} × ${required.height}, and it's ${actual.width} × ${actual.height}`);
|
|
727
|
+
this.name = "ImageTooSmallError";
|
|
728
|
+
this.required = required;
|
|
729
|
+
this.actual = actual;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
const isPositive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
|
|
733
|
+
/**
|
|
734
|
+
* An aspect ratio as width ÷ height: `"100:77"`, `"100/77"`, `[100, 77]` →
|
|
735
|
+
* 1.2987…; `1.3` → 1.3. Throws {@link ImageRuleError} for anything else.
|
|
736
|
+
*/
|
|
737
|
+
function parseAspectRatio(value) {
|
|
738
|
+
let parts = null;
|
|
739
|
+
if (typeof value === "number") return isPositive(value) ? value : badRatio(value);
|
|
740
|
+
if (Array.isArray(value)) parts = [value[0], value[1]];
|
|
741
|
+
else if (typeof value === "string") {
|
|
742
|
+
const text$1 = value.trim();
|
|
743
|
+
const match = /^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/i.exec(text$1);
|
|
744
|
+
if (match) parts = [Number(match[1]), Number(match[2])];
|
|
745
|
+
else if (/^\d+(?:\.\d+)?$/.test(text$1)) return isPositive(Number(text$1)) ? Number(text$1) : badRatio(value);
|
|
746
|
+
}
|
|
747
|
+
if (parts && isPositive(parts[0]) && isPositive(parts[1])) return parts[0] / parts[1];
|
|
748
|
+
return badRatio(value);
|
|
749
|
+
}
|
|
750
|
+
function badRatio(value) {
|
|
751
|
+
throw new ImageRuleError(`Not an aspect ratio: ${JSON.stringify(value)} (use e.g. "100:77", [100, 77] or 1.3)`);
|
|
752
|
+
}
|
|
753
|
+
/** Throws {@link ImageRuleError} when the rule contradicts itself. */
|
|
754
|
+
function checkImageRule(rule) {
|
|
755
|
+
if (!rule || typeof rule !== "object") throw new ImageRuleError("An image rule must be an object");
|
|
756
|
+
for (const key of [
|
|
757
|
+
"width",
|
|
758
|
+
"height",
|
|
759
|
+
"minWidth",
|
|
760
|
+
"minHeight",
|
|
761
|
+
"maxWidth",
|
|
762
|
+
"maxHeight",
|
|
763
|
+
"maxLongSide"
|
|
764
|
+
]) if (rule[key] != null && !isPositive(rule[key])) throw new ImageRuleError(`${key} must be a positive number`);
|
|
765
|
+
if (rule.width != null || rule.height != null) {
|
|
766
|
+
if (rule.width == null || rule.height == null) throw new ImageRuleError("An exact size needs both width and height");
|
|
767
|
+
if (rule.aspectRatio != null) throw new ImageRuleError("Give an exact size or an aspectRatio, not both (the size sets the ratio)");
|
|
768
|
+
const extra = [
|
|
769
|
+
"minWidth",
|
|
770
|
+
"minHeight",
|
|
771
|
+
"maxWidth",
|
|
772
|
+
"maxHeight",
|
|
773
|
+
"maxLongSide"
|
|
774
|
+
].filter((key) => rule[key] != null);
|
|
775
|
+
if (extra.length > 0) throw new ImageRuleError(`An exact size is already the minimum and the maximum; remove ${extra.join(", ")}`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (rule.aspectRatio != null) parseAspectRatio(rule.aspectRatio);
|
|
779
|
+
if (rule.minWidth != null && rule.maxWidth != null && rule.minWidth > rule.maxWidth) throw new ImageRuleError("minWidth is larger than maxWidth");
|
|
780
|
+
if (rule.minHeight != null && rule.maxHeight != null && rule.minHeight > rule.maxHeight) throw new ImageRuleError("minHeight is larger than maxHeight");
|
|
781
|
+
if (rule.maxLongSide != null && Math.max(rule.minWidth ?? 0, rule.minHeight ?? 0) > rule.maxLongSide) throw new ImageRuleError("The minimum size is larger than maxLongSide");
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Works out what to do to a `source`-sized image to meet `rule`: the centred
|
|
785
|
+
* crop of the right shape (the largest one), then the output size (scaled
|
|
786
|
+
* down to the maximum, never below the minimum). Pure: no browser needed.
|
|
787
|
+
*
|
|
788
|
+
* Throws {@link ImageTooSmallError} when the crop is below the minimum (or
|
|
789
|
+
* the exact size) and `allowUpscale` is off, and {@link ImageRuleError} for a
|
|
790
|
+
* contradictory rule.
|
|
791
|
+
*
|
|
792
|
+
* @example
|
|
793
|
+
* planImageFit({ width: 6000, height: 4000 }, { aspectRatio: "100:77", minWidth: 600, minHeight: 462, maxLongSide: 2000 })
|
|
794
|
+
* // → crop { sx: 403, sy: 0, sw: 5194, sh: 4000 }, 2000 × 1540
|
|
795
|
+
*/
|
|
796
|
+
function planImageFit(source, rule) {
|
|
797
|
+
checkImageRule(rule);
|
|
798
|
+
const { width: W, height: H } = source ?? {};
|
|
799
|
+
if (!isPositive(W) || !isPositive(H)) throw new Error("planImageFit: the image has no size");
|
|
800
|
+
const exact = rule.width != null && rule.height != null;
|
|
801
|
+
const ratio = exact ? rule.width / rule.height : rule.aspectRatio != null ? parseAspectRatio(rule.aspectRatio) : null;
|
|
802
|
+
const crop = ratio == null ? {
|
|
803
|
+
sx: 0,
|
|
804
|
+
sy: 0,
|
|
805
|
+
sw: W,
|
|
806
|
+
sh: H
|
|
807
|
+
} : coverCrop(W, H, ratio, 1);
|
|
808
|
+
const { sw, sh } = crop;
|
|
809
|
+
const kept = {
|
|
810
|
+
width: sw,
|
|
811
|
+
height: sh
|
|
812
|
+
};
|
|
813
|
+
let width;
|
|
814
|
+
let height;
|
|
815
|
+
if (exact) {
|
|
816
|
+
width = rule.width;
|
|
817
|
+
height = rule.height;
|
|
818
|
+
if ((sw < width || sh < height) && !rule.allowUpscale) throw new ImageTooSmallError({
|
|
819
|
+
width,
|
|
820
|
+
height
|
|
821
|
+
}, kept);
|
|
822
|
+
} else {
|
|
823
|
+
const minW = rule.minWidth ?? 0;
|
|
824
|
+
const minH = rule.minHeight ?? 0;
|
|
825
|
+
if ((sw < minW || sh < minH) && !rule.allowUpscale) throw new ImageTooSmallError(requiredSize(minW, minH, ratio), kept);
|
|
826
|
+
let scale = 1;
|
|
827
|
+
if (rule.maxWidth != null) scale = Math.min(scale, rule.maxWidth / sw);
|
|
828
|
+
if (rule.maxHeight != null) scale = Math.min(scale, rule.maxHeight / sh);
|
|
829
|
+
if (rule.maxLongSide != null) scale = Math.min(scale, rule.maxLongSide / Math.max(sw, sh));
|
|
830
|
+
scale = Math.max(scale, minW / sw, minH / sh);
|
|
831
|
+
width = Math.max(1, Math.round(sw * scale));
|
|
832
|
+
height = Math.max(1, Math.round(sh * scale));
|
|
833
|
+
}
|
|
834
|
+
const unchanged = crop.sx === 0 && crop.sy === 0 && sw === W && sh === H && width === W && height === H;
|
|
835
|
+
return {
|
|
836
|
+
crop,
|
|
837
|
+
width,
|
|
838
|
+
height,
|
|
839
|
+
unchanged
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
/** The minimum as a full size: a ratio rule with only one minimum implies the other. */
|
|
843
|
+
function requiredSize(minW, minH, ratio) {
|
|
844
|
+
if (ratio == null) return {
|
|
845
|
+
width: minW,
|
|
846
|
+
height: minH
|
|
847
|
+
};
|
|
848
|
+
return {
|
|
849
|
+
width: Math.max(minW, Math.ceil(minH * ratio)),
|
|
850
|
+
height: Math.max(minH, Math.ceil(minW / ratio))
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
const OUTPUT_TYPES = [
|
|
854
|
+
"image/png",
|
|
855
|
+
"image/jpeg",
|
|
856
|
+
"image/webp"
|
|
857
|
+
];
|
|
858
|
+
/**
|
|
859
|
+
* Returns the image meeting `rule` ({@link planImageFit}): centre-cropped to
|
|
860
|
+
* the right shape and scaled in a single draw. An image that already fits is
|
|
861
|
+
* returned untouched (same bytes). Browser only.
|
|
862
|
+
*
|
|
863
|
+
* Throws {@link ImageTooSmallError} (show `required` and `actual` to the
|
|
864
|
+
* person) or {@link ImageRuleError}; an unreadable file rejects with the
|
|
865
|
+
* browser's decoding error.
|
|
866
|
+
*
|
|
867
|
+
* @example
|
|
868
|
+
* const ready = await prepareImage(file, { aspectRatio: "100:77", minWidth: 600, minHeight: 462, maxLongSide: 2000 });
|
|
869
|
+
* await uploadImage(ready, { kind: "product", prefix: "product" });
|
|
870
|
+
*/
|
|
871
|
+
async function prepareImage(file, rule, options = {}) {
|
|
872
|
+
checkImageRule(rule);
|
|
873
|
+
requireBrowserImages("prepareImage");
|
|
874
|
+
const sourceType = file.type && OUTPUT_TYPES.includes(file.type) ? file.type : "image/png";
|
|
875
|
+
const type = options.type ?? sourceType;
|
|
876
|
+
const quality = options.quality ?? (type === "image/png" ? void 0 : .9);
|
|
877
|
+
const bitmap = await createImageBitmap(file);
|
|
878
|
+
try {
|
|
879
|
+
const plan = planImageFit({
|
|
880
|
+
width: bitmap.width,
|
|
881
|
+
height: bitmap.height
|
|
882
|
+
}, rule);
|
|
883
|
+
if (plan.unchanged && type === file.type) return file instanceof File ? file : new File([file], outputName(file, type), { type });
|
|
884
|
+
const blob = await drawCrop(bitmap, plan.crop, plan.width, plan.height, type, quality, "prepareImage");
|
|
885
|
+
return new File([blob], outputName(file, type), { type });
|
|
886
|
+
} finally {
|
|
887
|
+
bitmap.close?.();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
//#endregion
|
|
892
|
+
//#region src/core/upload.ts
|
|
893
|
+
/**
|
|
894
|
+
* HTTPS host that serves the Shopwave CDN's images. The API returns
|
|
895
|
+
* `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
|
|
896
|
+
* (checked Sep 2026), so pages served over HTTPS can't show it.
|
|
897
|
+
*/
|
|
898
|
+
const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
|
|
899
|
+
const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
|
|
900
|
+
/**
|
|
901
|
+
* Turns an image path from the API into a URL a page can load over HTTPS:
|
|
902
|
+
* `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
|
|
903
|
+
* relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
|
|
904
|
+
* Other absolute URLs are returned unchanged; empty input gives `""`.
|
|
905
|
+
*/
|
|
906
|
+
function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
907
|
+
if (!path) return "";
|
|
908
|
+
const root = base.replace(/\/+$/, "");
|
|
909
|
+
if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
|
|
910
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
|
|
911
|
+
return `${root}/${path.replace(/^\/+/, "")}`;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Uploads one image to the Shopwave CDN through the app's `/api/upload` route
|
|
915
|
+
* (`createShopwaveApi(...).upload()`), and returns its id and URL.
|
|
916
|
+
*
|
|
917
|
+
* The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
|
|
918
|
+
* record has to point at it to use it. Live API (Sep 2026): the upload answers
|
|
919
|
+
* 201 `{ fileName, path }`.
|
|
920
|
+
*/
|
|
921
|
+
async function uploadImage(file, params, options = {}) {
|
|
922
|
+
if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
|
|
923
|
+
if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
|
|
924
|
+
if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
|
|
925
|
+
const name = params.fileName ?? file.name ?? "upload";
|
|
926
|
+
const form = new FormData();
|
|
927
|
+
form.append(SHOPWAVE_UPLOAD.fileField, file, name);
|
|
928
|
+
form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
|
|
929
|
+
if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
|
|
930
|
+
const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
|
|
931
|
+
method: "POST",
|
|
932
|
+
body: form
|
|
933
|
+
}, options);
|
|
934
|
+
const body = raw;
|
|
935
|
+
assertNoApiErrors(body, status);
|
|
936
|
+
const id = typeof body?.fileName === "string" ? body.fileName : "";
|
|
937
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
938
|
+
if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
|
|
939
|
+
return {
|
|
940
|
+
id,
|
|
941
|
+
url: getImageUrl(path),
|
|
942
|
+
path
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/core/merchantImages.ts
|
|
948
|
+
/** The three image slots on the merchant settings page. */
|
|
949
|
+
const MERCHANT_IMAGE_SLOT_KEYS = [
|
|
950
|
+
"receipt",
|
|
951
|
+
"square",
|
|
952
|
+
"featured"
|
|
953
|
+
];
|
|
954
|
+
/**
|
|
955
|
+
* Where each slot lives, its upload kind and its exact size (from adminV1's
|
|
956
|
+
* merchant settings page).
|
|
957
|
+
*/
|
|
958
|
+
const MERCHANT_IMAGE_SLOTS = Object.freeze({
|
|
959
|
+
receipt: {
|
|
960
|
+
field: "logo.receipt",
|
|
961
|
+
uploadKind: "merchant",
|
|
962
|
+
width: 576,
|
|
963
|
+
height: 325
|
|
964
|
+
},
|
|
965
|
+
square: {
|
|
966
|
+
field: "logo.square",
|
|
967
|
+
uploadKind: "merchant",
|
|
968
|
+
width: 1e3,
|
|
969
|
+
height: 1e3
|
|
970
|
+
},
|
|
971
|
+
featured: {
|
|
972
|
+
field: "feature[0]",
|
|
973
|
+
uploadKind: "merchantFeature",
|
|
974
|
+
width: 1200,
|
|
975
|
+
height: 600
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
/**
|
|
979
|
+
* The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
|
|
980
|
+
* `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
|
|
981
|
+
* Returns `null` for empty or non-string values.
|
|
982
|
+
*/
|
|
983
|
+
function imageFileName(value) {
|
|
984
|
+
if (typeof value !== "string") return null;
|
|
985
|
+
const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
|
|
986
|
+
return last ? decodeURIComponent(last) : null;
|
|
987
|
+
}
|
|
988
|
+
/** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
|
|
989
|
+
function getMerchantImage(imageIds, slot) {
|
|
990
|
+
if (!imageIds || typeof imageIds !== "object") return null;
|
|
991
|
+
let value;
|
|
992
|
+
if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
|
|
993
|
+
else {
|
|
994
|
+
const logo = imageIds.logo;
|
|
995
|
+
value = logo && typeof logo === "object" ? logo[slot] : void 0;
|
|
996
|
+
}
|
|
997
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
|
|
1001
|
+
* holds only a file name (a fresh upload not yet saved; use the upload's `url`).
|
|
1002
|
+
*/
|
|
1003
|
+
function getMerchantImageUrl(imageIds, slot, base) {
|
|
1004
|
+
const value = getMerchantImage(imageIds, slot);
|
|
1005
|
+
if (!value || !value.includes("/")) return "";
|
|
1006
|
+
return getImageUrl(value, base);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
|
|
1010
|
+
* with `null`). Other slots and unknown keys are kept; an empty `logo` or
|
|
1011
|
+
* `feature` is removed.
|
|
1012
|
+
*/
|
|
1013
|
+
function setMerchantImage(imageIds, slot, fileName) {
|
|
1014
|
+
const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
|
|
1015
|
+
if (slot === "featured") {
|
|
1016
|
+
const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
|
|
1017
|
+
const feature = fileName ? [fileName, ...rest] : rest;
|
|
1018
|
+
if (feature.length > 0) next.feature = feature;
|
|
1019
|
+
else delete next.feature;
|
|
1020
|
+
} else {
|
|
1021
|
+
const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
|
|
1022
|
+
if (fileName) logo[slot] = fileName;
|
|
1023
|
+
else delete logo[slot];
|
|
1024
|
+
if (Object.keys(logo).length > 0) next.logo = logo;
|
|
1025
|
+
else delete next.logo;
|
|
1026
|
+
}
|
|
1027
|
+
return next;
|
|
1028
|
+
}
|
|
1029
|
+
function toStored(value) {
|
|
1030
|
+
if (typeof value === "string") return imageFileName(value) ?? void 0;
|
|
1031
|
+
if (Array.isArray(value)) {
|
|
1032
|
+
const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
|
|
1033
|
+
return items.length > 0 ? items : void 0;
|
|
1034
|
+
}
|
|
1035
|
+
if (value && typeof value === "object") {
|
|
1036
|
+
const out = {};
|
|
1037
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1038
|
+
const stored = toStored(v);
|
|
1039
|
+
if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
|
|
1040
|
+
}
|
|
1041
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The `imageIds` to send on a save: every URL is turned back into its file
|
|
1046
|
+
* name (what adminV1 does with images you didn't change) and empty values
|
|
1047
|
+
* are dropped. No images gives `{}`.
|
|
1048
|
+
*/
|
|
1049
|
+
function toStoredImageIds(imageIds) {
|
|
1050
|
+
return toStored(imageIds) ?? {};
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Uploads an image for a merchant slot: fits it to the slot's size, uploads
|
|
1054
|
+
* it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
|
|
1055
|
+
* `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
|
|
1056
|
+
*/
|
|
1057
|
+
async function uploadMerchantImage(file, slot, options = {}) {
|
|
1058
|
+
const def = MERCHANT_IMAGE_SLOTS[slot];
|
|
1059
|
+
if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
|
|
1060
|
+
const { fit = true, fitOptions,...requestOptions } = options;
|
|
1061
|
+
return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/core/productImages.ts
|
|
1066
|
+
/**
|
|
1067
|
+
* Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
|
|
1068
|
+
* - A product's `images` is a list. **Reads return full URLs**
|
|
1069
|
+
* (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
|
|
1070
|
+
* **writes take the uploader's file names** ({@link toStoredProductImages};
|
|
1071
|
+
* `saveProduct` does this for you).
|
|
1072
|
+
* - The uploader takes one file per request. With a `prefix` (see
|
|
1073
|
+
* `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
|
|
1074
|
+
* (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
|
|
1075
|
+
* image's role can be read back from its name ({@link productImageRole}).
|
|
1076
|
+
*/
|
|
1077
|
+
/**
|
|
1078
|
+
* The role in an image's file name or URL, from the uploader's `prefix`:
|
|
1079
|
+
* `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
|
|
1080
|
+
* without one (images uploaded without a prefix, e.g. before prefixes existed).
|
|
588
1081
|
*/
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
1082
|
+
function productImageRole(value) {
|
|
1083
|
+
const name = imageFileName(value);
|
|
1084
|
+
if (!name) return null;
|
|
1085
|
+
return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
|
|
592
1086
|
}
|
|
593
1087
|
/**
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
1088
|
+
* A product's `images` as the API takes them on a save: file names, not the
|
|
1089
|
+
* URLs a read returns. Non-strings, blanks and duplicates are dropped; the
|
|
1090
|
+
* order is kept.
|
|
597
1091
|
*/
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
return json ?? void 0;
|
|
1092
|
+
function toStoredProductImages(images) {
|
|
1093
|
+
if (!Array.isArray(images)) return [];
|
|
1094
|
+
const names = images.map(imageFileName).filter((name) => Boolean(name));
|
|
1095
|
+
return Array.from(new Set(names));
|
|
603
1096
|
}
|
|
604
1097
|
|
|
605
1098
|
//#endregion
|
|
@@ -756,12 +1249,20 @@ async function fetchEntityById(kind, id, params = {}, options = {}) {
|
|
|
756
1249
|
* read) so an edited price is sent as a new instance. As a safeguard, a new
|
|
757
1250
|
* product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
|
|
758
1251
|
* another product would otherwise point the save at that product's instances.
|
|
1252
|
+
*
|
|
1253
|
+
* `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
|
|
1254
|
+
* read returns can be passed straight back; leaving `images` out keeps them.
|
|
759
1255
|
*/
|
|
760
1256
|
const saveProduct = (product, options) => {
|
|
761
|
-
|
|
1257
|
+
let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
|
|
762
1258
|
...product,
|
|
763
1259
|
instances: toProductInstancesForSave(product.instances)
|
|
764
|
-
} : product
|
|
1260
|
+
} : product;
|
|
1261
|
+
if (Array.isArray(toSave.images)) toSave = {
|
|
1262
|
+
...toSave,
|
|
1263
|
+
images: toStoredProductImages(toSave.images)
|
|
1264
|
+
};
|
|
1265
|
+
return saveEntity("product", toSave, options);
|
|
765
1266
|
};
|
|
766
1267
|
const deleteProduct = (id, options) => deleteEntityById("product", id, options);
|
|
767
1268
|
const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
|
|
@@ -936,267 +1437,6 @@ function buildConsumerMap(consumers) {
|
|
|
936
1437
|
return map;
|
|
937
1438
|
}
|
|
938
1439
|
|
|
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 `""`.
|
|
1043
|
-
*/
|
|
1044
|
-
function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
1045
|
-
if (!path) return "";
|
|
1046
|
-
const root = base.replace(/\/+$/, "");
|
|
1047
|
-
if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
|
|
1048
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
|
|
1049
|
-
return `${root}/${path.replace(/^\/+/, "")}`;
|
|
1050
|
-
}
|
|
1051
|
-
/**
|
|
1052
|
-
* Uploads one image to the Shopwave CDN through the app's `/api/upload` route
|
|
1053
|
-
* (`createShopwaveApi(...).upload()`), and returns its id and URL.
|
|
1054
|
-
*
|
|
1055
|
-
* The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
|
|
1056
|
-
* record has to point at it to use it. Live API (Sep 2026): the upload answers
|
|
1057
|
-
* 201 `{ fileName, path }`.
|
|
1058
|
-
*/
|
|
1059
|
-
async function uploadImage(file, params, options = {}) {
|
|
1060
|
-
if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
|
|
1061
|
-
if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
|
|
1062
|
-
const name = params.fileName ?? file.name ?? "upload";
|
|
1063
|
-
const form = new FormData();
|
|
1064
|
-
form.append(SHOPWAVE_UPLOAD.fileField, file, name);
|
|
1065
|
-
form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
|
|
1066
|
-
const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
|
|
1067
|
-
method: "POST",
|
|
1068
|
-
body: form
|
|
1069
|
-
}, options);
|
|
1070
|
-
const body = raw;
|
|
1071
|
-
assertNoApiErrors(body, status);
|
|
1072
|
-
const id = typeof body?.fileName === "string" ? body.fileName : "";
|
|
1073
|
-
const path = typeof body?.path === "string" ? body.path : "";
|
|
1074
|
-
if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
|
|
1075
|
-
return {
|
|
1076
|
-
id,
|
|
1077
|
-
url: getImageUrl(path),
|
|
1078
|
-
path
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
//#endregion
|
|
1083
|
-
//#region src/core/merchantImages.ts
|
|
1084
|
-
/** The three image slots on the merchant settings page. */
|
|
1085
|
-
const MERCHANT_IMAGE_SLOT_KEYS = [
|
|
1086
|
-
"receipt",
|
|
1087
|
-
"square",
|
|
1088
|
-
"featured"
|
|
1089
|
-
];
|
|
1090
|
-
/**
|
|
1091
|
-
* Where each slot lives, its upload kind and its exact size (from adminV1's
|
|
1092
|
-
* merchant settings page).
|
|
1093
|
-
*/
|
|
1094
|
-
const MERCHANT_IMAGE_SLOTS = Object.freeze({
|
|
1095
|
-
receipt: {
|
|
1096
|
-
field: "logo.receipt",
|
|
1097
|
-
uploadKind: "merchant",
|
|
1098
|
-
width: 576,
|
|
1099
|
-
height: 325
|
|
1100
|
-
},
|
|
1101
|
-
square: {
|
|
1102
|
-
field: "logo.square",
|
|
1103
|
-
uploadKind: "merchant",
|
|
1104
|
-
width: 1e3,
|
|
1105
|
-
height: 1e3
|
|
1106
|
-
},
|
|
1107
|
-
featured: {
|
|
1108
|
-
field: "feature[0]",
|
|
1109
|
-
uploadKind: "merchantFeature",
|
|
1110
|
-
width: 1200,
|
|
1111
|
-
height: 600
|
|
1112
|
-
}
|
|
1113
|
-
});
|
|
1114
|
-
/**
|
|
1115
|
-
* The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
|
|
1116
|
-
* `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
|
|
1117
|
-
* Returns `null` for empty or non-string values.
|
|
1118
|
-
*/
|
|
1119
|
-
function imageFileName(value) {
|
|
1120
|
-
if (typeof value !== "string") return null;
|
|
1121
|
-
const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
|
|
1122
|
-
return last ? decodeURIComponent(last) : null;
|
|
1123
|
-
}
|
|
1124
|
-
/** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
|
|
1125
|
-
function getMerchantImage(imageIds, slot) {
|
|
1126
|
-
if (!imageIds || typeof imageIds !== "object") return null;
|
|
1127
|
-
let value;
|
|
1128
|
-
if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
|
|
1129
|
-
else {
|
|
1130
|
-
const logo = imageIds.logo;
|
|
1131
|
-
value = logo && typeof logo === "object" ? logo[slot] : void 0;
|
|
1132
|
-
}
|
|
1133
|
-
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
1134
|
-
}
|
|
1135
|
-
/**
|
|
1136
|
-
* An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
|
|
1137
|
-
* holds only a file name (a fresh upload not yet saved; use the upload's `url`).
|
|
1138
|
-
*/
|
|
1139
|
-
function getMerchantImageUrl(imageIds, slot, base) {
|
|
1140
|
-
const value = getMerchantImage(imageIds, slot);
|
|
1141
|
-
if (!value || !value.includes("/")) return "";
|
|
1142
|
-
return getImageUrl(value, base);
|
|
1143
|
-
}
|
|
1144
|
-
/**
|
|
1145
|
-
* Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
|
|
1146
|
-
* with `null`). Other slots and unknown keys are kept; an empty `logo` or
|
|
1147
|
-
* `feature` is removed.
|
|
1148
|
-
*/
|
|
1149
|
-
function setMerchantImage(imageIds, slot, fileName) {
|
|
1150
|
-
const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
|
|
1151
|
-
if (slot === "featured") {
|
|
1152
|
-
const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
|
|
1153
|
-
const feature = fileName ? [fileName, ...rest] : rest;
|
|
1154
|
-
if (feature.length > 0) next.feature = feature;
|
|
1155
|
-
else delete next.feature;
|
|
1156
|
-
} else {
|
|
1157
|
-
const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
|
|
1158
|
-
if (fileName) logo[slot] = fileName;
|
|
1159
|
-
else delete logo[slot];
|
|
1160
|
-
if (Object.keys(logo).length > 0) next.logo = logo;
|
|
1161
|
-
else delete next.logo;
|
|
1162
|
-
}
|
|
1163
|
-
return next;
|
|
1164
|
-
}
|
|
1165
|
-
function toStored(value) {
|
|
1166
|
-
if (typeof value === "string") return imageFileName(value) ?? void 0;
|
|
1167
|
-
if (Array.isArray(value)) {
|
|
1168
|
-
const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
|
|
1169
|
-
return items.length > 0 ? items : void 0;
|
|
1170
|
-
}
|
|
1171
|
-
if (value && typeof value === "object") {
|
|
1172
|
-
const out = {};
|
|
1173
|
-
for (const [k, v] of Object.entries(value)) {
|
|
1174
|
-
const stored = toStored(v);
|
|
1175
|
-
if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
|
|
1176
|
-
}
|
|
1177
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
/**
|
|
1181
|
-
* The `imageIds` to send on a save: every URL is turned back into its file
|
|
1182
|
-
* name (what adminV1 does with images you didn't change) and empty values
|
|
1183
|
-
* are dropped. No images gives `{}`.
|
|
1184
|
-
*/
|
|
1185
|
-
function toStoredImageIds(imageIds) {
|
|
1186
|
-
return toStored(imageIds) ?? {};
|
|
1187
|
-
}
|
|
1188
|
-
/**
|
|
1189
|
-
* Uploads an image for a merchant slot: fits it to the slot's size, uploads
|
|
1190
|
-
* it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
|
|
1191
|
-
* `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
|
|
1192
|
-
*/
|
|
1193
|
-
async function uploadMerchantImage(file, slot, options = {}) {
|
|
1194
|
-
const def = MERCHANT_IMAGE_SLOTS[slot];
|
|
1195
|
-
if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
|
|
1196
|
-
const { fit = true, fitOptions,...requestOptions } = options;
|
|
1197
|
-
return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1440
|
//#endregion
|
|
1201
1441
|
//#region src/core/merchant.ts
|
|
1202
1442
|
const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
|
|
@@ -1941,6 +2181,8 @@ function useBasketReport(options) {
|
|
|
1941
2181
|
//#endregion
|
|
1942
2182
|
exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
|
|
1943
2183
|
exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
|
|
2184
|
+
exports.ImageRuleError = ImageRuleError;
|
|
2185
|
+
exports.ImageTooSmallError = ImageTooSmallError;
|
|
1944
2186
|
exports.MERCHANT_COLOUR_KEYS = MERCHANT_COLOUR_KEYS;
|
|
1945
2187
|
exports.MERCHANT_FIELD_MAX_LENGTH = MERCHANT_FIELD_MAX_LENGTH;
|
|
1946
2188
|
exports.MERCHANT_IMAGE_SLOTS = MERCHANT_IMAGE_SLOTS;
|
|
@@ -1957,6 +2199,7 @@ exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
|
|
|
1957
2199
|
exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
|
|
1958
2200
|
exports.ShopwaveApiError = ShopwaveApiError;
|
|
1959
2201
|
exports.UPLOAD_KINDS = UPLOAD_KINDS;
|
|
2202
|
+
exports.UPLOAD_PREFIXES = UPLOAD_PREFIXES;
|
|
1960
2203
|
exports.apiDelete = apiDelete;
|
|
1961
2204
|
exports.apiGet = apiGet;
|
|
1962
2205
|
exports.apiRequest = apiRequest;
|
|
@@ -1965,6 +2208,7 @@ exports.assertNoApiErrors = assertNoApiErrors;
|
|
|
1965
2208
|
exports.authorizationFor = authorizationFor;
|
|
1966
2209
|
exports.buildBasketReportQuery = buildBasketReportQuery;
|
|
1967
2210
|
exports.buildConsumerMap = buildConsumerMap;
|
|
2211
|
+
exports.checkImageRule = checkImageRule;
|
|
1968
2212
|
exports.collectConsumerIds = collectConsumerIds;
|
|
1969
2213
|
exports.collectProductIds = collectProductIds;
|
|
1970
2214
|
exports.combineBasketRows = combineBasketRows;
|
|
@@ -2008,6 +2252,7 @@ exports.getMerchantImageUrl = getMerchantImageUrl;
|
|
|
2008
2252
|
exports.imageFileName = imageFileName;
|
|
2009
2253
|
exports.isSameInstancePrice = isSameInstancePrice;
|
|
2010
2254
|
exports.isUploadKind = isUploadKind;
|
|
2255
|
+
exports.isUploadPrefix = isUploadPrefix;
|
|
2011
2256
|
exports.loginPath = loginPath;
|
|
2012
2257
|
exports.logout = logout;
|
|
2013
2258
|
exports.logoutPath = logoutPath;
|
|
@@ -2015,7 +2260,11 @@ exports.merchantLinksFromForm = merchantLinksFromForm;
|
|
|
2015
2260
|
exports.merchantLinksToForm = merchantLinksToForm;
|
|
2016
2261
|
exports.normalizeMerchantLinks = normalizeMerchantLinks;
|
|
2017
2262
|
exports.normalizeTagList = normalizeTagList;
|
|
2263
|
+
exports.parseAspectRatio = parseAspectRatio;
|
|
2018
2264
|
exports.parseBasketReportData = parseBasketReportData;
|
|
2265
|
+
exports.planImageFit = planImageFit;
|
|
2266
|
+
exports.prepareImage = prepareImage;
|
|
2267
|
+
exports.productImageRole = productImageRole;
|
|
2019
2268
|
exports.readImageSize = readImageSize;
|
|
2020
2269
|
exports.rolesById = rolesById;
|
|
2021
2270
|
exports.saveCategory = saveCategory;
|
|
@@ -2030,6 +2279,7 @@ exports.submitEntity = submitEntity;
|
|
|
2030
2279
|
exports.toProductInstancesForSave = toProductInstancesForSave;
|
|
2031
2280
|
exports.toShopwaveApiError = toShopwaveApiError;
|
|
2032
2281
|
exports.toStoredImageIds = toStoredImageIds;
|
|
2282
|
+
exports.toStoredProductImages = toStoredProductImages;
|
|
2033
2283
|
exports.updateMerchant = updateMerchant;
|
|
2034
2284
|
exports.uploadImage = uploadImage;
|
|
2035
2285
|
exports.uploadMerchantImage = uploadMerchantImage;
|