react-shopwave-connect 0.4.4 → 0.4.6
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 +35 -10
- package/dist/core/index.cjs +222 -32
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +117 -20
- package/dist/core/index.d.ts +117 -20
- package/dist/core/index.js +217 -33
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +34 -28
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +6 -6
- package/dist/hooks/index.d.ts +6 -6
- package/dist/hooks/index.js +34 -28
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +222 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +117 -20
- package/dist/index.d.ts +117 -20
- package/dist/index.js +217 -33
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +2 -2
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +3 -3
- package/dist/next/index.d.ts +3 -3
- package/dist/next/index.js +2 -2
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +2 -2
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +5 -5
- package/dist/server/index.d.ts +5 -5
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -191,18 +191,18 @@ await updateMerchant({ imageIds: setMerchantImage(merchant?.imageIds, "square",
|
|
|
191
191
|
getMerchantImageUrl(saved.imageIds, "square"); // HTTPS URL for <img src>
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
The shapes below are the ones
|
|
194
|
+
The shapes below are the ones Shopwave's existing admin apps save and read, so every client shares one record (confirmed on the live API, 30 Sep 2026):
|
|
195
195
|
|
|
196
196
|
| Field | Shape |
|
|
197
197
|
|---|---|
|
|
198
198
|
| `imageIds` | `{ logo: { receipt, square }, feature: [ … ] }` — **file names** on write (the uploader's `fileName`), **full `http://static.merchantstack.com/…` URLs** on read |
|
|
199
|
-
| `colours` | `{ primary: { main, highlight, contrast } }` (hex strings;
|
|
199
|
+
| `colours` | `{ primary: { main, highlight, contrast } }` (hex strings; `primary.main` is the colour shown behind the logo) |
|
|
200
200
|
| `links` | `{ website: { home }, social: { twitter, facebook, instagram } }` |
|
|
201
201
|
|
|
202
202
|
- **Merchant fields** (`GET`/`POST /merchant`): `id`, `name`, `description`, `companyNumber`, `vatNumber`, `categoryId`, `estAnnualRevenue`, `note`, and the three objects above. The API returns `{}` for an empty object.
|
|
203
203
|
- **What the live API does on `POST /merchant`** (checked 30 Sep 2026): it replaces the record. A scalar field left out (`companyNumber`, `vatNumber`, `note`, …) becomes `null`; an object field left out (`colours`, `links`, `imageIds`) is kept, and one that is sent replaces the stored one (`{}` clears it). It answers **205 with an empty body**. It silently cuts `companyNumber` to 11 characters and `vatNumber` to 9 (a UK VAT number without `GB`).
|
|
204
204
|
- **`updateMerchant(patch, { merge })`**: `merge` (default `true`) reads first and merges the scalar fields, so nothing is wiped. Object fields (`MERCHANT_OBJECT_FIELDS`) are sent only when the patch has them, and then replace the stored one — to change one image, pass the whole `imageIds` (`setMerchantImage` does that). `imageIds` is always sent as file names (`toStoredImageIds`), so you can pass back what you read. It refuses a different merchant `id`, never sends `createdDate`/`modifiedDate`, rejects values longer than `MERCHANT_FIELD_MAX_LENGTH` (`validateMerchant(m)` lists them) instead of letting the API cut them, and returns the merchant **read back after the save**, so you see exactly what was kept. `merge: false` sends only the patch (which must include `id`, and clears the scalar fields you leave out).
|
|
205
|
-
- **Merchant image slots** (`MERCHANT_IMAGE_SLOTS`): the API doesn't resize, and
|
|
205
|
+
- **Merchant image slots** (`MERCHANT_IMAGE_SLOTS`): the API doesn't resize, and Shopwave's existing admin accepts only these exact sizes.
|
|
206
206
|
|
|
207
207
|
| Slot | Stored at | Upload kind | Size |
|
|
208
208
|
|---|---|---|---|
|
|
@@ -210,15 +210,40 @@ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admi
|
|
|
210
210
|
| `square` | `imageIds.logo.square` | `merchant` | 1000 × 1000 |
|
|
211
211
|
| `featured` | `imageIds.feature[0]` | `merchantFeature` | 1200 × 600 |
|
|
212
212
|
|
|
213
|
-
The API builds each image URL from its key, not from where the file was uploaded (`logo.*` → `…/merchant/<id>/logo/<file>`, `feature` → `…/feature/<file>`), so the featured image must be uploaded as `merchantFeature`. `POST /merchant` stores all three objects with the SDK's `{ merchant: … }` body;
|
|
213
|
+
The API builds each image URL from its key, not from where the file was uploaded (`logo.*` → `…/merchant/<id>/logo/<file>`, `feature` → `…/feature/<file>`), so the featured image must be uploaded as `merchantFeature`. `POST /merchant` stores all three objects with the SDK's `{ merchant: … }` body; the existing admin's `{ merchants: { "0": … } }` form isn't needed.
|
|
214
214
|
|
|
215
215
|
`uploadMerchantImage(file, slot, { fit = true })` scales and centre-crops the image to the slot's size in the browser (`fitImageToSize`, `coverCrop`) and uploads it with the slot's kind; `fit: false` sends the file as-is. `getMerchantImage` / `getMerchantImageUrl` / `setMerchantImage` read and write a slot; `imageFileName(url)` gives the file name in a URL. Hook: `useUploadMerchantImage()`.
|
|
216
216
|
- **Links:** `merchantLinksToForm(links)` gives flat fields (`website`, `twitter`, `facebook`, `instagram`) from the stored shape, flat keys or the old array form; `merchantLinksFromForm(fields)` builds the stored shape; `normalizeMerchantLinks(links)` converts any of them to the stored shape. Colour keys: `MERCHANT_COLOUR_KEYS`.
|
|
217
|
-
- **User** (`GET /user`): the live API returns `id`, `firstName`, `lastName`, `email` and `employee: { merchantId, roleId, stores }` — no `createdDate` and **no `merchant`**, although the reference lists them. There's no option to include it (
|
|
217
|
+
- **User** (`GET /user`): the live API returns `id`, `firstName`, `lastName`, `email` and `employee: { merchantId, roleId, stores }` — no `createdDate` and **no `merchant`**, although the reference lists them. There's no option to include it (Shopwave's existing admin also reads `GET /merchant` separately and attaches it to its session). Read the merchant with `fetchMerchant()`.
|
|
218
218
|
- **`uploadImage(file, { kind, prefix?, fileName? })`** posts the file to `/api/upload`, which `PUT`s it to Shopwave's `/uploader` (multipart `file`, header `contentType: <kind>`). Kinds: `merchant`, `merchantFeature`, `product`, `user`, `applicationLogo`, `applicationImages` (the last two sent as the API's own spelling, `applicaionLogo`/`applicaionImages`). Live: 201 `{ fileName, path }`. Returns `{ id, url, path }`: `id` is the generated file name (what records store), `path` the URL as the API gave it (`http://static.merchantstack.com/images/…`, which has no HTTPS endpoint), and `url` the same file over HTTPS via `getImageUrl(path)` (`SHOPWAVE_IMAGE_BASE_URL`, the S3 bucket behind the CDN). The upload alone changes no record.
|
|
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,
|
|
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, 4 Nov 2026: `product_thumbnail_<hash>.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` |
|
|
@@ -505,7 +530,7 @@ Options: `apiVersion` (default `"2.0"`), `fetch`, `entities` (override a route/h
|
|
|
505
530
|
|
|
506
531
|
### Moving an existing app over
|
|
507
532
|
|
|
508
|
-
- Keep `redirectUri` equal to the URL already registered on the auth server
|
|
533
|
+
- Keep `redirectUri` equal to the URL already registered on the auth server and mount `handlers.auth` at that path.
|
|
509
534
|
- Keep `cookieName` the same and use the same `SESSION_SECRET` value as the old iron-session password — current users stay logged in; their old token shape is read and upgraded on the next refresh.
|
|
510
535
|
- Per-tool auth routes can go: link to `auth.loginPath("/tools/where-to-next")` instead.
|
|
511
536
|
- `core.logout()` / `useLogout()` still work (`DELETE /api/session`), but prefer linking to `logoutPath()` so the auth-server session ends too.
|
|
@@ -536,7 +561,7 @@ Unit tests (offline, no credentials) cover the OAuth client, token handling, the
|
|
|
536
561
|
npm test # = npm run test:unit
|
|
537
562
|
```
|
|
538
563
|
|
|
539
|
-
Integration tests use [Vitest](https://vitest.dev) and run against **an app that mounts the SDK routes** (
|
|
564
|
+
Integration tests use [Vitest](https://vitest.dev) and run against **an app that mounts the SDK routes** (for example your app on `npm run dev`), which forwards to the Shopwave API. They cover create → read (by id and by filter) → update → delete with the typed functions for Products and Categories, create → read → role update → retire for Employees, read-only checks for Consumers, plus unknown ids and the legacy `submitEntity` body. They create and delete real records on the account behind the token.
|
|
540
565
|
|
|
541
566
|
### Environment Variables
|
|
542
567
|
|
|
@@ -549,7 +574,7 @@ Integration tests use [Vitest](https://vitest.dev) and run against **an app that
|
|
|
549
574
|
|
|
550
575
|
```bash
|
|
551
576
|
# Set environment variables
|
|
552
|
-
# with the app running (
|
|
577
|
+
# with the app running (npm run dev)
|
|
553
578
|
export API_URL=http://localhost:3000
|
|
554
579
|
export API_TOKEN=<bare access token>
|
|
555
580
|
|
package/dist/core/index.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
@@ -691,7 +875,7 @@ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
|
|
|
691
875
|
/**
|
|
692
876
|
* Turns an image path from the API into a URL a page can load over HTTPS:
|
|
693
877
|
* `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
|
|
694
|
-
* relative path (`merchant/
|
|
878
|
+
* relative path (`merchant/123/logo/x.png`) → `<base>/merchant/123/logo/x.png`.
|
|
695
879
|
* Other absolute URLs are returned unchanged; empty input gives `""`.
|
|
696
880
|
*/
|
|
697
881
|
function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
@@ -743,7 +927,7 @@ const MERCHANT_IMAGE_SLOT_KEYS = [
|
|
|
743
927
|
"featured"
|
|
744
928
|
];
|
|
745
929
|
/**
|
|
746
|
-
* Where each slot lives, its upload kind and its exact size (from
|
|
930
|
+
* Where each slot lives, its upload kind and its exact size (from the existing admin's
|
|
747
931
|
* merchant settings page).
|
|
748
932
|
*/
|
|
749
933
|
const MERCHANT_IMAGE_SLOTS = Object.freeze({
|
|
@@ -834,7 +1018,7 @@ function toStored(value) {
|
|
|
834
1018
|
}
|
|
835
1019
|
/**
|
|
836
1020
|
* The `imageIds` to send on a save: every URL is turned back into its file
|
|
837
|
-
* name (what
|
|
1021
|
+
* name (what the existing admin does with images you didn't change) and empty values
|
|
838
1022
|
* are dropped. No images gives `{}`.
|
|
839
1023
|
*/
|
|
840
1024
|
function toStoredImageIds(imageIds) {
|
|
@@ -855,7 +1039,7 @@ async function uploadMerchantImage(file, slot, options = {}) {
|
|
|
855
1039
|
//#endregion
|
|
856
1040
|
//#region src/core/productImages.ts
|
|
857
1041
|
/**
|
|
858
|
-
* Product images, as the live API stores them (
|
|
1042
|
+
* Product images, as the live API stores them (4 Nov 2026):
|
|
859
1043
|
* - A product's `images` is a list. **Reads return full URLs**
|
|
860
1044
|
* (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
|
|
861
1045
|
* **writes take the uploader's file names** ({@link toStoredProductImages};
|
|
@@ -1257,9 +1441,9 @@ function trimmed(value) {
|
|
|
1257
1441
|
/**
|
|
1258
1442
|
* Reads `merchant.links` as flat fields (`{ website, twitter, facebook,
|
|
1259
1443
|
* instagram }`), whatever shape it was saved in:
|
|
1260
|
-
* - the nested shape
|
|
1444
|
+
* - the nested shape the existing admin saves (`{ website: { home }, social: { … } }`);
|
|
1261
1445
|
* - flat keys (`{ website: "…" }`, what SDK 0.4.0 pre-releases sent);
|
|
1262
|
-
* - an array in {@link MERCHANT_LINK_KEYS} order (older
|
|
1446
|
+
* - an array in {@link MERCHANT_LINK_KEYS} order (older app versions).
|
|
1263
1447
|
* Empty values are dropped.
|
|
1264
1448
|
*/
|
|
1265
1449
|
function merchantLinksToForm(links) {
|
|
@@ -1296,7 +1480,7 @@ function merchantLinksFromForm(form) {
|
|
|
1296
1480
|
}
|
|
1297
1481
|
return out;
|
|
1298
1482
|
}
|
|
1299
|
-
/** Reads any stored or older `links` value into the nested shape
|
|
1483
|
+
/** Reads any stored or older `links` value into the nested shape the existing admin saves. */
|
|
1300
1484
|
function normalizeMerchantLinks(links) {
|
|
1301
1485
|
return merchantLinksFromForm(merchantLinksToForm(links));
|
|
1302
1486
|
}
|
|
@@ -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;
|