react-shopwave-connect 0.3.0 → 0.4.0

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
@@ -165,6 +165,59 @@ How they behave (matches what the Shopwave API does):
165
165
 
166
166
  The generic forms are `saveEntity(kind, item)`, `deleteEntityById(kind, id)` and `fetchEntityById(kind, id)`; `SHOPWAVE_ENTITIES` lists each entity's route, collection key, Shopwave path and id headers.
167
167
 
168
+ ### Merchant, current user and image uploads
169
+
170
+ The merchant and the logged-in user are single records (one per login), and images go to the Shopwave CDN:
171
+
172
+ ```ts
173
+ import {
174
+ fetchMerchant, updateMerchant, fetchUser,
175
+ uploadMerchantImage, setMerchantImage, getMerchantImageUrl,
176
+ merchantLinksToForm, merchantLinksFromForm,
177
+ } from "react-shopwave-connect/core";
178
+
179
+ const merchant = await fetchMerchant(); // Merchant | null
180
+ const user = await fetchUser(); // User | null ({ id, firstName, lastName, email, employee })
181
+
182
+ // Reads the merchant, merges the patch over it and saves the record, so
183
+ // fields you don't mention (companyNumber, vatNumber, links, …) are kept.
184
+ // Returns the merchant as stored (read back after the save).
185
+ await updateMerchant({ vatNumber: "123456789", links: merchantLinksFromForm({ website: "https://example.com" }) });
186
+
187
+ // Images: fit to the slot's size, upload with the slot's kind, then save the file name.
188
+ const image = await uploadMerchantImage(file, "square"); // { id, url, path }
189
+ await updateMerchant({ imageIds: setMerchantImage(merchant?.imageIds, "square", image.id) });
190
+ getMerchantImageUrl(saved.imageIds, "square"); // HTTPS URL for <img src>
191
+ ```
192
+
193
+ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admins share one record (confirmed on the live API with merchant 5644, 30 Sep 2026):
194
+
195
+ | Field | Shape |
196
+ |---|---|
197
+ | `imageIds` | `{ logo: { receipt, square }, feature: [ … ] }` — **file names** on write (the uploader's `fileName`), **full `http://static.merchantstack.com/…` URLs** on read |
198
+ | `colours` | `{ primary: { main, highlight, contrast } }` (hex strings; adminV2 uses `primary.main` behind the logo) |
199
+ | `links` | `{ website: { home }, social: { twitter, facebook, instagram } }` |
200
+
201
+ - **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.
202
+ - **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`).
203
+ - **`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).
204
+ - **Merchant image slots** (`MERCHANT_IMAGE_SLOTS`): the API doesn't resize, and adminV1 accepts only these exact sizes.
205
+
206
+ | Slot | Stored at | Upload kind | Size |
207
+ |---|---|---|---|
208
+ | `receipt` | `imageIds.logo.receipt` | `merchant` | 576 × 325 |
209
+ | `square` | `imageIds.logo.square` | `merchant` | 1000 × 1000 |
210
+ | `featured` | `imageIds.feature[0]` | `merchantFeature` | 1200 × 600 |
211
+
212
+ 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; adminV1's `{ merchants: { "0": … } }` form isn't needed.
213
+
214
+ `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()`.
215
+ - **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`.
216
+ - **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 (adminV1 also reads `GET /merchant` separately and attaches it to its session). Read the merchant with `fetchMerchant()`.
217
+ - **`uploadImage(file, { kind, 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.
218
+
219
+ `SHOPWAVE_RESOURCES` (merchant, user) and `SHOPWAVE_UPLOAD` are shared with the server routes, like `SHOPWAVE_ENTITIES`.
220
+
168
221
  ### Available `core` functions
169
222
 
170
223
  | Domain | Function(s) |
@@ -176,12 +229,15 @@ The generic forms are `saveEntity(kind, item)`, `deleteEntityById(kind, id)` and
176
229
  | promotion | `fetchPromotions`, `fetchPromotion`, `savePromotion`, `deletePromotion` |
177
230
  | store | `fetchStores` (now with `storeIds`), `fetchStore`, `saveStore`, `deleteStore` |
178
231
  | report | `fetchReport` |
232
+ | merchant | `fetchMerchant`, `updateMerchant`, `validateMerchant`; links `merchantLinksToForm`, `merchantLinksFromForm`, `normalizeMerchantLinks`; images `MERCHANT_IMAGE_SLOTS`, `uploadMerchantImage`, `getMerchantImage`, `getMerchantImageUrl`, `setMerchantImage`, `toStoredImageIds`, `imageFileName` |
233
+ | user | `fetchUser` |
234
+ | upload | `uploadImage`, `getImageUrl`, `fitImageToSize`, `readImageSize`, `coverCrop` |
179
235
  | session | `fetchSession`, `loginPath`, `logoutPath`, `logout` |
180
236
  | entity | `saveEntity`, `saveEntities`, `deleteEntityById`, `fetchEntityById`; low-level `submitEntity` / `deleteEntity` (raw endpoint) |
181
237
  | errors | `ShopwaveApiError`, `getApiErrorMap`, `assertNoApiErrors` |
182
238
  | basket | `buildBasketReportQuery`, `parseBasketReportData`, `combineBasketRows`, `computeBasketSummary`, … (pure transforms) |
183
239
 
184
- All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `ReportQueryMap`, `Basket*`, `apiResponse`, …) are exported from `core` too.
240
+ All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `Merchant`, `User`, `UploadedImage`, `ReportQueryMap`, `Basket*`, `apiResponse`, …) are exported from `core` too.
185
241
 
186
242
  ---
187
243
 
@@ -224,14 +280,14 @@ const { data } = useProduct({ storeId }, { baseUrl: "https://api.merchantstack.c
224
280
 
225
281
  ### Auto-fetch hooks (`useEffect`-based)
226
282
 
227
- `useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `usePromotion`, `useStore`, `useReport`, `useSession`.
283
+ `useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `usePromotion`, `useStore`, `useReport`, `useSession`, `useMerchant`, `useUser` (`useUser({ enabled: false })` stays idle).
228
284
  They fetch on mount and re-run when their arguments change. `useConsumer` and `useReport` stay idle until you pass ids / a query (they return `loading: false`, `data: null` until then). `refetch()` replaces the old `reloadFlag` argument.
229
285
 
230
286
  ### Manually-triggered hooks (`useCallback`-based)
231
287
 
232
288
  For saves and deletes, prefer calling the typed `core` functions (`saveProduct`, `deleteStore`, …) from your event handlers — they return the saved entity with its id.
233
289
 
234
- `useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error, errorStatus }` — nothing fires until you call `mutate`:
290
+ `useUpdateMerchant`, `useUploadImage`, `useUploadMerchantImage`, `useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error, errorStatus }` — nothing fires until you call `mutate`:
235
291
 
236
292
  ```tsx
237
293
  import { useDelete, useSubmit } from "react-shopwave-connect/hooks";
@@ -401,8 +457,20 @@ export const { GET, PUT, DELETE } = shopwave.item("product");
401
457
  // app/api/report/route.ts — any other Shopwave path, read-only
402
458
  import { shopwave } from "@/lib/shopwave";
403
459
  export const { GET } = shopwave.passthrough("report");
460
+
461
+ // app/api/merchant/route.ts — read / update the merchant
462
+ export const { GET, PUT } = shopwave.resource("merchant");
463
+
464
+ // app/api/user/route.ts — the logged-in user (read-only)
465
+ export const { GET } = shopwave.resource("user");
466
+
467
+ // app/api/upload/route.ts — image upload (uploadImage)
468
+ export const { POST } = shopwave.upload(); // or upload({ maxBytes, accept })
404
469
  ```
405
470
 
471
+ - `resource("merchant")`: `PUT`/`POST` take `{ merchant: {…} }` or the bare object and send every field on as `postBody`. The `id` is required (400 without it), because a POST without one would create a second merchant. `resource("user")` answers 405 to writes.
472
+ - `upload()`: takes multipart `file` + `kind`, checks the size (default 10 MB → 413) and type (default `image/*` → 415), and `PUT`s only the file to `/uploader` with the `contentType` header. `extras` are ignored on uploads. Shopwave's reply (201 `{ fileName, path }`) is passed through.
473
+
406
474
  Entities: `product`, `category`, `store`, `promotion`, `employee` (DELETE retires via `exitDate`), `promotion` (DELETE ends it via `endDate`), `consumer` (mounted at `/api/consumer`; GET only, writes answer 405). For anything else, `shopwave.forward(request, { method, path, headers, postBody })` does the same token/refresh/encoding work for a custom handler.
407
475
 
408
476
  Every handler:
@@ -709,12 +709,504 @@ function buildConsumerMap(consumers) {
709
709
  return map;
710
710
  }
711
711
 
712
+ //#endregion
713
+ //#region src/core/resources.ts
714
+ /**
715
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
716
+ * updates it (without an `id` the API would create a second merchant, so the
717
+ * route requires one).
718
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
719
+ * needs the admin scope).
720
+ */
721
+ const SHOPWAVE_RESOURCES = Object.freeze({
722
+ merchant: {
723
+ route: "merchant",
724
+ key: "merchant",
725
+ upstream: "merchant",
726
+ writable: true
727
+ },
728
+ user: {
729
+ route: "user",
730
+ key: "user",
731
+ upstream: "user",
732
+ writable: false
733
+ }
734
+ });
735
+ /** `UploadKind` → the API's `contentType` value. */
736
+ const UPLOAD_KINDS = Object.freeze({
737
+ merchant: "merchant",
738
+ merchantFeature: "merchantFeature",
739
+ product: "product",
740
+ user: "user",
741
+ applicationLogo: "applicaionLogo",
742
+ applicationImages: "applicaionImages"
743
+ });
744
+ /** How uploads are addressed (app route and Shopwave uploader). */
745
+ const SHOPWAVE_UPLOAD = Object.freeze({
746
+ route: "upload",
747
+ upstream: "uploader",
748
+ kindHeader: "contentType",
749
+ fileField: "file",
750
+ kindField: "kind"
751
+ });
752
+ function isUploadKind(value) {
753
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
754
+ }
755
+
756
+ //#endregion
757
+ //#region src/core/imageFit.ts
758
+ /**
759
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
760
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
761
+ */
762
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
763
+ if (![
764
+ srcWidth,
765
+ srcHeight,
766
+ dstWidth,
767
+ dstHeight
768
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
769
+ const srcRatio = srcWidth / srcHeight;
770
+ const dstRatio = dstWidth / dstHeight;
771
+ if (srcRatio > dstRatio) {
772
+ const sw = Math.round(srcHeight * dstRatio);
773
+ return {
774
+ sx: Math.round((srcWidth - sw) / 2),
775
+ sy: 0,
776
+ sw,
777
+ sh: srcHeight
778
+ };
779
+ }
780
+ const sh = Math.round(srcWidth / dstRatio);
781
+ return {
782
+ sx: 0,
783
+ sy: Math.round((srcHeight - sh) / 2),
784
+ sw: srcWidth,
785
+ sh
786
+ };
787
+ }
788
+ function requireBrowserImages(fn) {
789
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
790
+ }
791
+ /** Reads an image file's pixel size (browser only). */
792
+ async function readImageSize(file) {
793
+ requireBrowserImages("readImageSize");
794
+ const bitmap = await createImageBitmap(file);
795
+ try {
796
+ return {
797
+ width: bitmap.width,
798
+ height: bitmap.height
799
+ };
800
+ } finally {
801
+ bitmap.close?.();
802
+ }
803
+ }
804
+ /**
805
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
806
+ * (never stretched). Browser only. The result is a `File` named like the
807
+ * input, with the output type's extension.
808
+ */
809
+ async function fitImageToSize(file, width, height, options = {}) {
810
+ requireBrowserImages("fitImageToSize");
811
+ const { type = "image/png", quality, keepIfExact = true } = options;
812
+ const name = file.name || "image";
813
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
814
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
815
+ const bitmap = await createImageBitmap(file);
816
+ try {
817
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
818
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
819
+ let blob;
820
+ if (typeof OffscreenCanvas === "function") {
821
+ const canvas = new OffscreenCanvas(width, height);
822
+ const ctx = canvas.getContext("2d");
823
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
824
+ ctx.imageSmoothingQuality = "high";
825
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
826
+ blob = await canvas.convertToBlob({
827
+ type,
828
+ quality
829
+ });
830
+ } else {
831
+ const canvas = document.createElement("canvas");
832
+ canvas.width = width;
833
+ canvas.height = height;
834
+ const ctx = canvas.getContext("2d");
835
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
836
+ ctx.imageSmoothingQuality = "high";
837
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
838
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
839
+ }
840
+ return new File([blob], outName, { type });
841
+ } finally {
842
+ bitmap.close?.();
843
+ }
844
+ }
845
+
846
+ //#endregion
847
+ //#region src/core/upload.ts
848
+ /**
849
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
850
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
851
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
852
+ */
853
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
854
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
855
+ /**
856
+ * Turns an image path from the API into a URL a page can load over HTTPS:
857
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
858
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
859
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
860
+ */
861
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
862
+ if (!path) return "";
863
+ const root = base.replace(/\/+$/, "");
864
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
865
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
866
+ return `${root}/${path.replace(/^\/+/, "")}`;
867
+ }
868
+ /**
869
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
870
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
871
+ *
872
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
873
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
874
+ * 201 `{ fileName, path }`.
875
+ */
876
+ async function uploadImage(file, params, options = {}) {
877
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
878
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
879
+ const name = params.fileName ?? file.name ?? "upload";
880
+ const form = new FormData();
881
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
882
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
883
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
884
+ method: "POST",
885
+ body: form
886
+ }, options);
887
+ const body = raw;
888
+ assertNoApiErrors(body, status);
889
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
890
+ const path = typeof body?.path === "string" ? body.path : "";
891
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
892
+ return {
893
+ id,
894
+ url: getImageUrl(path),
895
+ path
896
+ };
897
+ }
898
+
899
+ //#endregion
900
+ //#region src/core/merchantImages.ts
901
+ /** The three image slots on the merchant settings page. */
902
+ const MERCHANT_IMAGE_SLOT_KEYS = [
903
+ "receipt",
904
+ "square",
905
+ "featured"
906
+ ];
907
+ /**
908
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
909
+ * merchant settings page).
910
+ */
911
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
912
+ receipt: {
913
+ field: "logo.receipt",
914
+ uploadKind: "merchant",
915
+ width: 576,
916
+ height: 325
917
+ },
918
+ square: {
919
+ field: "logo.square",
920
+ uploadKind: "merchant",
921
+ width: 1e3,
922
+ height: 1e3
923
+ },
924
+ featured: {
925
+ field: "feature[0]",
926
+ uploadKind: "merchantFeature",
927
+ width: 1200,
928
+ height: 600
929
+ }
930
+ });
931
+ /**
932
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
933
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
934
+ * Returns `null` for empty or non-string values.
935
+ */
936
+ function imageFileName(value) {
937
+ if (typeof value !== "string") return null;
938
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
939
+ return last ? decodeURIComponent(last) : null;
940
+ }
941
+ /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
942
+ function getMerchantImage(imageIds, slot) {
943
+ if (!imageIds || typeof imageIds !== "object") return null;
944
+ let value;
945
+ if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
946
+ else {
947
+ const logo = imageIds.logo;
948
+ value = logo && typeof logo === "object" ? logo[slot] : void 0;
949
+ }
950
+ return typeof value === "string" && value.trim() ? value.trim() : null;
951
+ }
952
+ /**
953
+ * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
954
+ * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
955
+ */
956
+ function getMerchantImageUrl(imageIds, slot, base) {
957
+ const value = getMerchantImage(imageIds, slot);
958
+ if (!value || !value.includes("/")) return "";
959
+ return getImageUrl(value, base);
960
+ }
961
+ /**
962
+ * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
963
+ * with `null`). Other slots and unknown keys are kept; an empty `logo` or
964
+ * `feature` is removed.
965
+ */
966
+ function setMerchantImage(imageIds, slot, fileName) {
967
+ const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
968
+ if (slot === "featured") {
969
+ const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
970
+ const feature = fileName ? [fileName, ...rest] : rest;
971
+ if (feature.length > 0) next.feature = feature;
972
+ else delete next.feature;
973
+ } else {
974
+ const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
975
+ if (fileName) logo[slot] = fileName;
976
+ else delete logo[slot];
977
+ if (Object.keys(logo).length > 0) next.logo = logo;
978
+ else delete next.logo;
979
+ }
980
+ return next;
981
+ }
982
+ function toStored(value) {
983
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
984
+ if (Array.isArray(value)) {
985
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
986
+ return items.length > 0 ? items : void 0;
987
+ }
988
+ if (value && typeof value === "object") {
989
+ const out = {};
990
+ for (const [k, v] of Object.entries(value)) {
991
+ const stored = toStored(v);
992
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
993
+ }
994
+ return Object.keys(out).length > 0 ? out : void 0;
995
+ }
996
+ }
997
+ /**
998
+ * The `imageIds` to send on a save: every URL is turned back into its file
999
+ * name (what adminV1 does with images you didn't change) and empty values
1000
+ * are dropped. No images gives `{}`.
1001
+ */
1002
+ function toStoredImageIds(imageIds) {
1003
+ return toStored(imageIds) ?? {};
1004
+ }
1005
+ /**
1006
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
1007
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
1008
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
1009
+ */
1010
+ async function uploadMerchantImage(file, slot, options = {}) {
1011
+ const def = MERCHANT_IMAGE_SLOTS[slot];
1012
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
1013
+ const { fit = true, fitOptions,...requestOptions } = options;
1014
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1015
+ }
1016
+
1017
+ //#endregion
1018
+ //#region src/core/merchant.ts
1019
+ const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
1020
+ /** Colour keys inside `colours.primary`, in display order. */
1021
+ const MERCHANT_COLOUR_KEYS = [
1022
+ "main",
1023
+ "highlight",
1024
+ "contrast"
1025
+ ];
1026
+ /** Flat link fields a form edits, in display order. */
1027
+ const MERCHANT_LINK_KEYS = [
1028
+ "website",
1029
+ "twitter",
1030
+ "facebook",
1031
+ "instagram"
1032
+ ];
1033
+ /** Where each flat link field lives in `merchant.links`. */
1034
+ const MERCHANT_LINK_PATHS = Object.freeze({
1035
+ website: ["website", "home"],
1036
+ twitter: ["social", "twitter"],
1037
+ facebook: ["social", "facebook"],
1038
+ instagram: ["social", "instagram"]
1039
+ });
1040
+ function trimmed(value) {
1041
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1042
+ }
1043
+ /**
1044
+ * Reads `merchant.links` as flat fields (`{ website, twitter, facebook,
1045
+ * instagram }`), whatever shape it was saved in:
1046
+ * - the nested shape adminV1 saves (`{ website: { home }, social: { … } }`);
1047
+ * - flat keys (`{ website: "…" }`, what SDK 0.4.0 pre-releases sent);
1048
+ * - an array in {@link MERCHANT_LINK_KEYS} order (older AdminUI versions).
1049
+ * Empty values are dropped.
1050
+ */
1051
+ function merchantLinksToForm(links) {
1052
+ const out = {};
1053
+ if (Array.isArray(links)) {
1054
+ links.forEach((value, i) => {
1055
+ const key = MERCHANT_LINK_KEYS[i];
1056
+ const v = trimmed(value);
1057
+ if (key && v) out[key] = v;
1058
+ });
1059
+ return out;
1060
+ }
1061
+ if (!links || typeof links !== "object") return out;
1062
+ const obj = links;
1063
+ for (const key of MERCHANT_LINK_KEYS) {
1064
+ const [group, field] = MERCHANT_LINK_PATHS[key];
1065
+ const nested = obj[group];
1066
+ const v = nested && typeof nested === "object" && !Array.isArray(nested) ? trimmed(nested[field]) : trimmed(obj[key]);
1067
+ if (v) out[key] = v;
1068
+ }
1069
+ return out;
1070
+ }
1071
+ /**
1072
+ * Builds the stored `links` object from flat fields. Empty values are left
1073
+ * out, and so is a group with nothing in it; no links gives `{}`.
1074
+ */
1075
+ function merchantLinksFromForm(form) {
1076
+ const out = {};
1077
+ for (const key of MERCHANT_LINK_KEYS) {
1078
+ const v = trimmed(form[key]);
1079
+ if (!v) continue;
1080
+ const [group, field] = MERCHANT_LINK_PATHS[key];
1081
+ (out[group] ??= {})[field] = v;
1082
+ }
1083
+ return out;
1084
+ }
1085
+ /** Reads any stored or older `links` value into the nested shape adminV1 saves. */
1086
+ function normalizeMerchantLinks(links) {
1087
+ return merchantLinksFromForm(merchantLinksToForm(links));
1088
+ }
1089
+ /**
1090
+ * Object fields of the merchant. The API keeps one you leave out, and
1091
+ * replaces it as a whole when you send it.
1092
+ */
1093
+ const MERCHANT_OBJECT_FIELDS = [
1094
+ "colours",
1095
+ "links",
1096
+ "imageIds"
1097
+ ];
1098
+ /** Fields the API sets itself; `updateMerchant` never sends them back. */
1099
+ const MERCHANT_READ_ONLY_FIELDS = ["createdDate", "modifiedDate"];
1100
+ /**
1101
+ * Longest values the API stores. Longer values are cut off silently
1102
+ * (live API, Sep 2026), so `updateMerchant` rejects them instead.
1103
+ * `vatNumber` fits a UK VAT number without the `GB` prefix.
1104
+ */
1105
+ const MERCHANT_FIELD_MAX_LENGTH = Object.freeze({
1106
+ companyNumber: 11,
1107
+ vatNumber: 9
1108
+ });
1109
+ /** Returns `"<field> is longer than <n> characters"` messages for over-long fields. */
1110
+ function validateMerchant(merchant) {
1111
+ const problems = [];
1112
+ for (const [field, max] of Object.entries(MERCHANT_FIELD_MAX_LENGTH)) {
1113
+ const value = merchant[field];
1114
+ if (typeof value === "string" && value.length > max) problems.push(`${field} is longer than ${max} characters`);
1115
+ }
1116
+ return problems;
1117
+ }
1118
+ /**
1119
+ * Reads the logged-in user's merchant (a user has exactly one), or `null`
1120
+ * when the API returns none.
1121
+ */
1122
+ async function fetchMerchant(options = {}) {
1123
+ const json = await apiGet(MERCHANT_PATH, {}, options);
1124
+ assertNoApiErrors(json, 200);
1125
+ return json?.merchant ?? null;
1126
+ }
1127
+ /**
1128
+ * Updates the merchant and returns it as stored.
1129
+ *
1130
+ * Shopwave's `POST /merchant` replaces the whole record: a scalar field you
1131
+ * leave out (`companyNumber`, `vatNumber`, `note`, …) is set to null, while
1132
+ * an object field you leave out (`colours`, `links`, `imageIds`) is kept and
1133
+ * one you send replaces the stored one; sending `{}` clears it (live API,
1134
+ * Sep 2026). So by default the
1135
+ * current merchant is read first and the patch is merged over it.
1136
+ *
1137
+ * `imageIds` is always sent as file names ({@link toStoredImageIds}), so you
1138
+ * can pass back what you read. See `MerchantImageIds` for the shape.
1139
+ *
1140
+ * The API answers 205 with an empty body, so the merchant is read again after
1141
+ * the save and that is what's returned (it shows exactly what was kept).
1142
+ * Throws before sending when a value is longer than the API stores
1143
+ * ({@link MERCHANT_FIELD_MAX_LENGTH}).
1144
+ */
1145
+ async function updateMerchant(patch, options = {}) {
1146
+ const { merge = true,...requestOptions } = options;
1147
+ let merchant = { ...patch };
1148
+ if (merge) {
1149
+ const current = await fetchMerchant(requestOptions);
1150
+ if (!current) throw new ShopwaveApiError(404, "Shopwave API error (404): no merchant to update");
1151
+ if (patch.id != null && String(patch.id) !== String(current.id)) throw new Error(`Merchant id ${patch.id} is not the logged-in merchant (${current.id})`);
1152
+ merchant = {
1153
+ ...current,
1154
+ ...patch,
1155
+ id: current.id
1156
+ };
1157
+ for (const field of MERCHANT_OBJECT_FIELDS) if (!(field in patch)) delete merchant[field];
1158
+ }
1159
+ if (merchant.imageIds && typeof merchant.imageIds === "object") merchant.imageIds = toStoredImageIds(merchant.imageIds);
1160
+ if (merchant.id == null || merchant.id === "") throw new Error("merchant id is missing");
1161
+ for (const field of MERCHANT_READ_ONLY_FIELDS) delete merchant[field];
1162
+ const problems = validateMerchant(merchant);
1163
+ if (problems.length > 0) throw new Error(`Invalid merchant: ${problems.join("; ")}`);
1164
+ const { status, body: raw } = await apiRequest(MERCHANT_PATH, {
1165
+ method: "PUT",
1166
+ headers: { "Content-Type": "application/json" },
1167
+ body: JSON.stringify({ merchant })
1168
+ }, requestOptions);
1169
+ const body = raw;
1170
+ assertNoApiErrors(body, status);
1171
+ const echoed = body?.merchant && typeof body.merchant === "object" ? body.merchant : null;
1172
+ if (echoed) return {
1173
+ ...merchant,
1174
+ ...echoed,
1175
+ id: Number(echoed.id ?? merchant.id)
1176
+ };
1177
+ return await fetchMerchant(requestOptions) ?? {
1178
+ ...merchant,
1179
+ id: Number(merchant.id)
1180
+ };
1181
+ }
1182
+
1183
+ //#endregion
1184
+ //#region src/core/user.ts
1185
+ /** Reads the logged-in user, or `null` when the API returns none. */
1186
+ async function fetchUser(options = {}) {
1187
+ const json = await apiGet(`/api/${SHOPWAVE_RESOURCES.user.route}`, {}, options);
1188
+ assertNoApiErrors(json, 200);
1189
+ return json?.user ?? null;
1190
+ }
1191
+
712
1192
  //#endregion
713
1193
  exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
714
1194
  exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
1195
+ exports.MERCHANT_COLOUR_KEYS = MERCHANT_COLOUR_KEYS;
1196
+ exports.MERCHANT_FIELD_MAX_LENGTH = MERCHANT_FIELD_MAX_LENGTH;
1197
+ exports.MERCHANT_IMAGE_SLOTS = MERCHANT_IMAGE_SLOTS;
1198
+ exports.MERCHANT_IMAGE_SLOT_KEYS = MERCHANT_IMAGE_SLOT_KEYS;
1199
+ exports.MERCHANT_LINK_KEYS = MERCHANT_LINK_KEYS;
1200
+ exports.MERCHANT_LINK_PATHS = MERCHANT_LINK_PATHS;
1201
+ exports.MERCHANT_OBJECT_FIELDS = MERCHANT_OBJECT_FIELDS;
1202
+ exports.MERCHANT_READ_ONLY_FIELDS = MERCHANT_READ_ONLY_FIELDS;
715
1203
  exports.PromotionRuleSet = PromotionRuleSet;
716
1204
  exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
1205
+ exports.SHOPWAVE_IMAGE_BASE_URL = SHOPWAVE_IMAGE_BASE_URL;
1206
+ exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
1207
+ exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
717
1208
  exports.ShopwaveApiError = ShopwaveApiError;
1209
+ exports.UPLOAD_KINDS = UPLOAD_KINDS;
718
1210
  exports.apiDelete = apiDelete;
719
1211
  exports.apiGet = apiGet;
720
1212
  exports.apiRequest = apiRequest;
@@ -727,6 +1219,7 @@ exports.collectConsumerIds = collectConsumerIds;
727
1219
  exports.collectProductIds = collectProductIds;
728
1220
  exports.combineBasketRows = combineBasketRows;
729
1221
  exports.computeBasketSummary = computeBasketSummary;
1222
+ exports.coverCrop = coverCrop;
730
1223
  exports.deleteCategory = deleteCategory;
731
1224
  exports.deleteEmployee = deleteEmployee;
732
1225
  exports.deleteEntity = deleteEntity;
@@ -741,6 +1234,7 @@ exports.fetchConsumers = fetchConsumers;
741
1234
  exports.fetchEmployee = fetchEmployee;
742
1235
  exports.fetchEmployees = fetchEmployees;
743
1236
  exports.fetchEntityById = fetchEntityById;
1237
+ exports.fetchMerchant = fetchMerchant;
744
1238
  exports.fetchProduct = fetchProduct;
745
1239
  exports.fetchProducts = fetchProducts;
746
1240
  exports.fetchProductsMap = fetchProductsMap;
@@ -750,13 +1244,24 @@ exports.fetchReport = fetchReport;
750
1244
  exports.fetchSession = fetchSession;
751
1245
  exports.fetchStore = fetchStore;
752
1246
  exports.fetchStores = fetchStores;
1247
+ exports.fetchUser = fetchUser;
1248
+ exports.fitImageToSize = fitImageToSize;
753
1249
  exports.formatDateForQuery = formatDateForQuery;
754
1250
  exports.getApiErrorMap = getApiErrorMap;
755
1251
  exports.getApiErrors = getApiErrors;
1252
+ exports.getImageUrl = getImageUrl;
1253
+ exports.getMerchantImage = getMerchantImage;
1254
+ exports.getMerchantImageUrl = getMerchantImageUrl;
1255
+ exports.imageFileName = imageFileName;
1256
+ exports.isUploadKind = isUploadKind;
756
1257
  exports.loginPath = loginPath;
757
1258
  exports.logout = logout;
758
1259
  exports.logoutPath = logoutPath;
1260
+ exports.merchantLinksFromForm = merchantLinksFromForm;
1261
+ exports.merchantLinksToForm = merchantLinksToForm;
1262
+ exports.normalizeMerchantLinks = normalizeMerchantLinks;
759
1263
  exports.parseBasketReportData = parseBasketReportData;
1264
+ exports.readImageSize = readImageSize;
760
1265
  exports.rolesById = rolesById;
761
1266
  exports.saveCategory = saveCategory;
762
1267
  exports.saveEmployee = saveEmployee;
@@ -765,7 +1270,13 @@ exports.saveEntity = saveEntity;
765
1270
  exports.saveProduct = saveProduct;
766
1271
  exports.savePromotion = savePromotion;
767
1272
  exports.saveStore = saveStore;
1273
+ exports.setMerchantImage = setMerchantImage;
768
1274
  exports.submitEntity = submitEntity;
769
1275
  exports.toShopwaveApiError = toShopwaveApiError;
1276
+ exports.toStoredImageIds = toStoredImageIds;
1277
+ exports.updateMerchant = updateMerchant;
1278
+ exports.uploadImage = uploadImage;
1279
+ exports.uploadMerchantImage = uploadMerchantImage;
1280
+ exports.validateMerchant = validateMerchant;
770
1281
  exports.withToken = withToken;
771
1282
  //# sourceMappingURL=index.cjs.map