react-shopwave-connect 0.4.5 → 0.4.7

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
@@ -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 adminV1 saves and adminV2 reads, so all three admins share one record (confirmed on the live API with merchant 5644, 30 Sep 2026):
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; adminV2 uses `primary.main` behind the logo) |
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 adminV1 accepts only these exact sizes.
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,13 +210,13 @@ 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; adminV1's `{ merchants: { "0": … } }` form isn't needed.
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 (adminV1 also reads `GET /merchant` separately and attaches it to its session). Read the merchant with `fetchMerchant()`.
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, 5644, 4 Nov 2026: `product_thumbnail_1bdd….png`), so the role travels with the file; `productImageRole(nameOrUrl)` reads it back (`null` for a name without one). Leave it out for images without a role (merchant logos). An unknown prefix throws before any request; the route answers 400 to one.
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
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.
@@ -254,6 +254,7 @@ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admi
254
254
  | consumer | `fetchConsumers`, `fetchConsumer` (read-only) |
255
255
  | employee | `fetchEmployees`, `fetchEmployee`, `saveEmployee`, `deleteEmployee` |
256
256
  | product | `fetchProducts`, `fetchProductsMap` (batched, keyed), `fetchProduct`, `saveProduct`, `deleteProduct`; prices `toProductInstancesForSave`, `findRepricedInstances`, `isSameInstancePrice`; images `productImageRole`, `toStoredProductImages`; tag lists `fetchProductTags`, `fetchProductInstanceTags` (`string[]`), `normalizeTagList` |
257
+ | product component | `fetchProductComponents` (`productIds`, `productComponentIds`, `deleted`), `fetchProductComponent`, `saveProductComponent` (body built by `toProductComponentForSave`), `deleteProductComponent`, `deleteProductComponentInstances` (option rows) |
257
258
  | promotion | `fetchPromotions`, `fetchPromotion`, `savePromotion`, `deletePromotion` |
258
259
  | store | `fetchStores` (now with `storeIds`), `fetchStore`, `saveStore`, `deleteStore` |
259
260
  | report | `fetchReport` |
@@ -308,7 +309,7 @@ const { data } = useProduct({ storeId }, { baseUrl: "https://api.merchantstack.c
308
309
 
309
310
  ### Auto-fetch hooks (`useEffect`-based)
310
311
 
311
- `useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `useProductTags`, `useProductInstanceTags`, `usePromotion`, `useStore`, `useReport`, `useSession`, `useMerchant`, `useUser` (`useUser({ enabled: false })` stays idle; so do the tag hooks, so a tag field can load its list the first time it opens).
312
+ `useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `useProductComponent`, `useProductTags`, `useProductInstanceTags`, `usePromotion`, `useStore`, `useReport`, `useSession`, `useMerchant`, `useUser` (`useUser({ enabled: false })` stays idle; so do the tag hooks, so a tag field can load its list the first time it opens).
312
313
  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.
313
314
 
314
315
  ### Manually-triggered hooks (`useCallback`-based)
@@ -499,6 +500,11 @@ export const { POST } = shopwave.upload(); // or upload({ maxBytes,
499
500
  export const { GET } = shopwave.lookup("productTags");
500
501
  // app/api/products/instance-tags/route.ts — price-instance (channel) tags (fetchProductInstanceTags)
501
502
  export const { GET } = shopwave.lookup("productInstanceTags");
503
+
504
+ // app/api/product-components/route.ts — modifier groups: list/filter, create/update
505
+ export const { GET, POST, PUT } = shopwave.collection("productComponent");
506
+ // app/api/product-components/[id]/route.ts — read one, update, delete the group or some option rows
507
+ export const { GET, PUT, DELETE } = shopwave.item("productComponent");
502
508
  ```
503
509
 
504
510
  - `lookup(kind)`: the read-only lists in `SHOPWAVE_LOOKUPS`. `productTags` → `GET /product/tags`, `productInstanceTags` → `GET /product/instance/tags`; both answer `{ tags: string[] }`. They take no parameters, so `extras` aren't forwarded. The app routes are `products/tags` and `products/instance-tags`: static segments, so they sit beside `products/[id]`.
@@ -506,7 +512,7 @@ export const { GET } = shopwave.lookup("productInstanceTags");
506
512
  - `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.
507
513
  - `upload()`: takes multipart `file` + `kind` and an optional `prefix`, checks the size (default 10 MB → 413), type (default `image/*` → 415) and prefix (one of `UPLOAD_PREFIXES`, else 400), and `PUT`s only the file to `/uploader` with the `contentType` header, plus the `prefix` header when one was given. `extras` are ignored on uploads. Shopwave's reply (201 `{ fileName, path }`) is passed through.
508
514
 
509
- Entities: `product`, `category`, `store`, `promotion`, `employee` (DELETE retires via `exitDate`), `promotion` (DELETE expires it: the whole record saved back with `endDate`, and a not-yet-started `startDate`, set to now), `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.
515
+ Entities: `product`, `category`, `store`, `promotion`, `productComponent` (modifier groups, mounted at `/api/product-components`; DELETE with `extras` `{ productComponentInstanceIds }` deletes just those option rows, and a non-empty list of ids is required, else 400), `employee` (DELETE retires via `exitDate`), `promotion` (DELETE expires it: the whole record saved back with `endDate`, and a not-yet-started `startDate`, set to now), `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.
510
516
 
511
517
  Every handler:
512
518
 
@@ -530,7 +536,7 @@ Options: `apiVersion` (default `"2.0"`), `fetch`, `entities` (override a route/h
530
536
 
531
537
  ### Moving an existing app over
532
538
 
533
- - Keep `redirectUri` equal to the URL already registered on the auth server (AdminUI: `…/auth`) and mount `handlers.auth` at that path.
539
+ - Keep `redirectUri` equal to the URL already registered on the auth server and mount `handlers.auth` at that path.
534
540
  - 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.
535
541
  - Per-tool auth routes can go: link to `auth.loginPath("/tools/where-to-next")` instead.
536
542
  - `core.logout()` / `useLogout()` still work (`DELETE /api/session`), but prefer linking to `logoutPath()` so the auth-server session ends too.
@@ -561,7 +567,7 @@ Unit tests (offline, no credentials) cover the OAuth client, token handling, the
561
567
  npm test # = npm run test:unit
562
568
  ```
563
569
 
564
- Integration tests use [Vitest](https://vitest.dev) and run against **an app that mounts the SDK routes** (e.g. AdminUI 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.
570
+ 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, a create → read → update → delete-rows → delete lifecycle for Product components, plus unknown ids and the legacy `submitEntity` body. They create and delete real records on the account behind the token.
565
571
 
566
572
  ### Environment Variables
567
573
 
@@ -574,7 +580,7 @@ Integration tests use [Vitest](https://vitest.dev) and run against **an app that
574
580
 
575
581
  ```bash
576
582
  # Set environment variables
577
- # with the app running (AdminUI: npm run dev)
583
+ # with the app running (npm run dev)
578
584
  export API_URL=http://localhost:3000
579
585
  export API_TOKEN=<bare access token>
580
586
 
@@ -596,6 +602,7 @@ tests/
596
602
  request.test.ts # token header, ShopwaveApiError, no logging
597
603
  entities.test.ts # save/delete/fetch-by-id per entity
598
604
  api-routes.test.ts # /api route handlers
605
+ product-components.test.ts # product component functions and handlers
599
606
  query-state.test.ts # useQuery keeps data while refetching
600
607
  integration/
601
608
  setup.ts # Shared config and helpers
@@ -603,6 +610,7 @@ tests/
603
610
  categories.integration.test.ts # Categories CRUD lifecycle
604
611
  consumers.integration.test.ts # Consumers CRUD lifecycle
605
612
  employees.integration.test.ts # Employees CRUD lifecycle
613
+ product-components.integration.test.ts # Product components: create, read, update, delete rows, delete
606
614
  ```
607
615
 
608
616
  Each test file uses `describe.sequential` to guarantee execution order (create → read → update → delete) with shared state across tests. An `afterAll` cleanup ensures created resources are deleted even if a test fails midway.
@@ -186,12 +186,15 @@ async function apiSend(path, method, payload, options = {}) {
186
186
  /**
187
187
  * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
188
188
  * does come back and carries API errors, they are thrown. Returns the parsed
189
- * body (`null` when empty).
189
+ * body (`null` when empty). `extras`, when given, travel in the `extras` header
190
+ * (the server forwards only keys its delete route allows).
190
191
  */
191
- async function apiDelete(path, options = {}) {
192
+ async function apiDelete(path, options = {}, extras) {
193
+ const headers = baseHeaders(options);
194
+ if (extras) headers.extras = JSON.stringify(buildExtras(extras));
192
195
  const { status, body } = await send(path, {
193
196
  method: "DELETE",
194
- headers: baseHeaders(options)
197
+ headers
195
198
  }, options);
196
199
  if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
197
200
  return body;
@@ -462,133 +465,6 @@ function fetchProductInstanceTags(options = {}) {
462
465
  return fetchLookup("productInstanceTags", options);
463
466
  }
464
467
 
465
- //#endregion
466
- //#region src/core/promotion.ts
467
- /** Promotion rule identifiers as stored by the Shopwave API. */
468
- let PromotionRuleSet = /* @__PURE__ */ function(PromotionRuleSet$1) {
469
- PromotionRuleSet$1["Percentage"] = "|%^/";
470
- PromotionRuleSet$1["Combo"] = "&%</";
471
- PromotionRuleSet$1["MealDeal"] = "&=^/";
472
- return PromotionRuleSet$1;
473
- }({});
474
- /**
475
- * Fetches promotions. Returns the flattened `Promotion[]`.
476
- */
477
- async function fetchPromotions(params = {}, options = {}) {
478
- const extras = { deleted: params.deleted ?? false };
479
- if (params.promotionIds) extras.promotionIds = params.promotionIds;
480
- if (params.imminent !== void 0) extras.imminent = params.imminent;
481
- if (params.active !== void 0) extras.active = params.active;
482
- if (params.expired !== void 0) extras.expired = params.expired;
483
- const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
484
- assertNoApiErrors(json, 200);
485
- return Object.values(json?.promotions ?? {});
486
- }
487
-
488
- //#endregion
489
- //#region src/core/store.ts
490
- /**
491
- * Fetches stores. Returns the flattened `Store[]`.
492
- */
493
- async function fetchStores(params = {}, options = {}) {
494
- const extras = { deleted: params.deleted ?? false };
495
- if (params.storeIds) extras.storeIds = params.storeIds;
496
- const json = await apiGet("/api/stores", extras, withToken(options, params.token));
497
- assertNoApiErrors(json, 200);
498
- return Object.values(json?.stores ?? {});
499
- }
500
-
501
- //#endregion
502
- //#region src/core/report.ts
503
- /**
504
- * Runs one or more report queries and returns the `reports` map keyed by query
505
- * name. The query is JSON-encoded into the `extras.query` header, matching the
506
- * original endpoint contract.
507
- */
508
- async function fetchReport(query, options = {}) {
509
- const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
510
- assertNoApiErrors(json, 200);
511
- return json?.reports ?? {};
512
- }
513
-
514
- //#endregion
515
- //#region src/core/session.ts
516
- /**
517
- * Reads the current login status from `GET /api/session`.
518
- *
519
- * Works with the new `react-shopwave-connect/next` session route
520
- * (`{ loggedIn }`) and with older routes that returned the raw session
521
- * (`{ token: {...} }`) — in that case only a boolean is surfaced.
522
- */
523
- async function fetchSession(options = {}) {
524
- const doFetch = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
525
- if (!doFetch) throw new Error("No fetch implementation available. Pass `options.fetch`.");
526
- const response = await doFetch(`${options.baseUrl ?? ""}/api/session`, {
527
- method: "GET",
528
- headers: { Accept: "application/json" },
529
- cache: "no-store",
530
- credentials: "same-origin",
531
- signal: options.signal
532
- });
533
- if (response.status === 401) return { loggedIn: false };
534
- if (!response.ok) throw new Error(`Session request failed (${response.status})`);
535
- const json = await response.json();
536
- if (json && typeof json.loggedIn === "boolean") return {
537
- loggedIn: json.loggedIn,
538
- expiresAt: typeof json.expiresAt === "number" ? json.expiresAt : void 0
539
- };
540
- return { loggedIn: Boolean(json && json.token) };
541
- }
542
- /**
543
- * Path that starts a login and returns to `returnTo` afterwards.
544
- * Navigate the browser to it (`window.location.href = loginPath(...)`).
545
- */
546
- function loginPath(returnTo, authPath = "/auth") {
547
- return returnTo ? `${authPath}?returnTo=${encodeURIComponent(returnTo)}` : authPath;
548
- }
549
- /** Path that logs out of the app and the Shopwave auth server. */
550
- function logoutPath(authPath = "/auth") {
551
- return `${authPath}/logout`;
552
- }
553
- /**
554
- * Ends the current server session (no redirect).
555
- *
556
- * Prefer navigating to {@link logoutPath} when using
557
- * `react-shopwave-connect/next`: it also signs the user out of the auth server.
558
- */
559
- async function logout(options = {}) {
560
- await apiDelete("/api/session?action=logout", options);
561
- }
562
-
563
- //#endregion
564
- //#region src/core/entity.ts
565
- /**
566
- * Low-level, untyped mutations: the caller supplies the route and payload.
567
- *
568
- * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
569
- * …): they know each route, build the request envelope, check the response and
570
- * return the saved entity with its id. Keep these for routes that have no typed
571
- * function yet (e.g. `/api/merchant`).
572
- */
573
- /**
574
- * Deletes `/api/{resourcePath}/{entityId}`.
575
- */
576
- async function deleteEntity(resourcePath, entityId, options = {}) {
577
- if (entityId == null || entityId === "") throw new Error("entityId is missing");
578
- await apiDelete(`/api/${resourcePath}/${entityId}`, options);
579
- }
580
- /**
581
- * Sends a create/update mutation. Returns the response's `result` field when it
582
- * has one (older app routes wrapped their answer that way), otherwise the whole
583
- * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
584
- */
585
- async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
586
- const json = await apiSend(endpoint, method, payload, options);
587
- assertNoApiErrors(json, 200);
588
- if (json && typeof json === "object" && "result" in json) return json.result;
589
- return json ?? void 0;
590
- }
591
-
592
468
  //#endregion
593
469
  //#region src/core/imageFit.ts
594
470
  /**
@@ -875,7 +751,7 @@ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
875
751
  /**
876
752
  * Turns an image path from the API into a URL a page can load over HTTPS:
877
753
  * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
878
- * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
754
+ * relative path (`merchant/123/logo/x.png`) → `<base>/merchant/123/logo/x.png`.
879
755
  * Other absolute URLs are returned unchanged; empty input gives `""`.
880
756
  */
881
757
  function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
@@ -927,7 +803,7 @@ const MERCHANT_IMAGE_SLOT_KEYS = [
927
803
  "featured"
928
804
  ];
929
805
  /**
930
- * Where each slot lives, its upload kind and its exact size (from adminV1's
806
+ * Where each slot lives, its upload kind and its exact size (from the existing admin's
931
807
  * merchant settings page).
932
808
  */
933
809
  const MERCHANT_IMAGE_SLOTS = Object.freeze({
@@ -1018,7 +894,7 @@ function toStored(value) {
1018
894
  }
1019
895
  /**
1020
896
  * The `imageIds` to send on a save: every URL is turned back into its file
1021
- * name (what adminV1 does with images you didn't change) and empty values
897
+ * name (what the existing admin does with images you didn't change) and empty values
1022
898
  * are dropped. No images gives `{}`.
1023
899
  */
1024
900
  function toStoredImageIds(imageIds) {
@@ -1039,7 +915,7 @@ async function uploadMerchantImage(file, slot, options = {}) {
1039
915
  //#endregion
1040
916
  //#region src/core/productImages.ts
1041
917
  /**
1042
- * Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
918
+ * Product images, as the live API stores them (4 Nov 2026):
1043
919
  * - A product's `images` is a list. **Reads return full URLs**
1044
920
  * (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
1045
921
  * **writes take the uploader's file names** ({@link toStoredProductImages};
@@ -1084,6 +960,27 @@ function toStoredProductImages(images) {
1084
960
  * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
1085
961
  * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
1086
962
  * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
963
+ * - productComponent: modifier groups, `/product/component` (live, staging, 11 Nov 2026). The
964
+ * API reference lists no read filters; these are what the live API does:
965
+ * - Reads filter by `componentIds` (group ids) or `productIds` (the parent product; option
966
+ * products don't match). `productComponentIds` is ignored on reads: every group comes back.
967
+ * `deleted: false` gives live groups only, `true` deleted groups only, no header both.
968
+ * No match → 204 with an empty body. No order, sort, priority or position field anywhere.
969
+ * - Groups are keyed by id; `products` (option products) by product id; `productInstances`
970
+ * (option rows) by row id. Reads reformat values: price `"100"` → `"100.0"`,
971
+ * vatPercentage `"0.2000"` → `"0.200"`. `tags` and `name` are stored as sent (no trimming;
972
+ * a `tags` array is joined with commas; `tags` null or left out → null).
973
+ * - POST upserts (201, echoed under the ref). For each option product in `products`, its rows
974
+ * are replaced: every row sent is created afresh with a new id (a row `id` is ignored, also
975
+ * on a price change) and rows left out are removed. Option products left out of the save
976
+ * are untouched, and an empty `productInstances` removes nothing. A save without `products`
977
+ * updates only the group's own fields. The echo isn't proof of what was stored: renames of
978
+ * groups created moments earlier by a test run echoed the new name but never stuck (re-read
979
+ * for 90 s), while older groups, and groups created in Shopwave's own admin, renamed at
980
+ * once. Cause not yet known.
981
+ * - DELETE with `productComponentIds` (plural, comma list) → 205, soft delete. Adding
982
+ * `productComponentInstanceIds` deletes just those rows (205); they stay in live reads with
983
+ * `deleteDate` set. The singular `productComponentId` hangs (504 after 60 s).
1087
984
  */
1088
985
  const SHOPWAVE_ENTITIES = Object.freeze({
1089
986
  product: {
@@ -1143,6 +1040,16 @@ const SHOPWAVE_ENTITIES = Object.freeze({
1143
1040
  idHeader: "consumerId",
1144
1041
  writable: false,
1145
1042
  deleteMode: "none"
1043
+ },
1044
+ productComponent: {
1045
+ route: "product-components",
1046
+ collection: "productComponents",
1047
+ upstream: "product/component",
1048
+ idsHeader: "componentIds",
1049
+ idHeader: "productComponentIds",
1050
+ writable: true,
1051
+ deleteMode: "delete",
1052
+ childIdsHeader: "productComponentInstanceIds"
1146
1053
  }
1147
1054
  });
1148
1055
  function definition(kind) {
@@ -1268,6 +1175,237 @@ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options
1268
1175
  const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
1269
1176
  const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
1270
1177
 
1178
+ //#endregion
1179
+ //#region src/core/productComponent.ts
1180
+ /** Row fields the server sets; never sent back. */
1181
+ const SERVER_ROW_FIELDS = [
1182
+ "id",
1183
+ "activeDate",
1184
+ "deleteDate",
1185
+ "timestamp"
1186
+ ];
1187
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1188
+ function listOf(value) {
1189
+ if (!value) return [];
1190
+ if (Array.isArray(value)) return value.map((v) => [null, v]);
1191
+ return Object.entries(value);
1192
+ }
1193
+ /**
1194
+ * Builds the save body for a product component from a group as read (or as a form holds it):
1195
+ * - `products` keyed by product id (a product without `id` takes its key);
1196
+ * - each product's rows keyed `"0"`, `"1"`, …, like every other ref in a save;
1197
+ * - rows that have a `deleteDate` are left out: the API recreates every row it's sent, so
1198
+ * sending one back would bring a deleted option back as a new row;
1199
+ * - server row fields (`id`, `activeDate`, `deleteDate`, `timestamp`) are removed, since the
1200
+ * API gives every row it's sent a new id anyway;
1201
+ * - a `tags` list becomes a comma string.
1202
+ *
1203
+ * The API replaces the rows of every option product in the save and leaves the others alone,
1204
+ * so send all the rows to keep for each product included. To remove an option product
1205
+ * entirely, delete its rows with {@link deleteProductComponentInstances}.
1206
+ */
1207
+ function toProductComponentForSave(input) {
1208
+ const { products,...group } = input;
1209
+ if (products == null) return { ...group };
1210
+ const out = {};
1211
+ for (const [key, product] of listOf(products)) {
1212
+ if (!isObject(product)) continue;
1213
+ const id = product.id ?? key;
1214
+ if (id == null || String(id).trim() === "") continue;
1215
+ const { productInstances,...rest } = product;
1216
+ const rows = {};
1217
+ let index = 0;
1218
+ for (const [, row] of listOf(productInstances)) {
1219
+ if (!isObject(row) || row.deleteDate) continue;
1220
+ const copy = { ...row };
1221
+ for (const field of SERVER_ROW_FIELDS) delete copy[field];
1222
+ if (Array.isArray(copy.tags)) copy.tags = copy.tags.map(String).join(",");
1223
+ rows[String(index++)] = copy;
1224
+ }
1225
+ out[String(id)] = {
1226
+ ...rest,
1227
+ id,
1228
+ productInstances: rows
1229
+ };
1230
+ }
1231
+ return {
1232
+ ...group,
1233
+ products: out
1234
+ };
1235
+ }
1236
+ /**
1237
+ * Reads product components, optionally filtered by parent product and/or group id.
1238
+ * With no filter it returns every group on the merchant. Returns `[]` when nothing matches
1239
+ * (the API answers 204), and when a filter list is empty (no request is made).
1240
+ *
1241
+ * Rows deleted with {@link deleteProductComponentInstances} are still returned, with
1242
+ * `deleteDate` set; skip them when listing options.
1243
+ */
1244
+ async function fetchProductComponents(params = {}, options = {}) {
1245
+ const def = SHOPWAVE_ENTITIES.productComponent;
1246
+ if (params.productIds?.length === 0 || params.productComponentIds?.length === 0) return [];
1247
+ const extras = { deleted: params.deleted ?? false };
1248
+ if (params.productIds) extras.productIds = params.productIds;
1249
+ if (params.productComponentIds) extras[def.idsHeader] = params.productComponentIds;
1250
+ const json = await apiGet(`/api/${def.route}`, extras, options);
1251
+ assertNoApiErrors(json, 200);
1252
+ const map = json?.[def.collection];
1253
+ return isObject(map) ? Object.values(map) : [];
1254
+ }
1255
+ /**
1256
+ * Reads one product component by id, or `null` when there's no such live group.
1257
+ * Note that `{ deleted: true }` returns the group only if it *has* been deleted.
1258
+ */
1259
+ const fetchProductComponent = (id, params, options) => fetchEntityById("productComponent", id, params, options);
1260
+ /**
1261
+ * Creates (no `id`) or updates (with `id`) a product component and returns it with its id.
1262
+ * The body is built with {@link toProductComponentForSave}. Every option row sent is stored
1263
+ * under a new id, so re-read with {@link fetchProductComponent} for the stored rows.
1264
+ */
1265
+ const saveProductComponent = (input, options) => saveEntity("productComponent", toProductComponentForSave(input), options);
1266
+ /** Deletes a product component (soft delete: `deleted: true` reads still return it). */
1267
+ const deleteProductComponent = (id, options) => deleteEntityById("productComponent", id, options);
1268
+ /**
1269
+ * Deletes some option rows of a product component, leaving the group and its other rows.
1270
+ * The rows stay in reads with `deleteDate` set. Throws without calling the API when
1271
+ * `instanceIds` is empty, because a delete without them would delete the whole group.
1272
+ */
1273
+ async function deleteProductComponentInstances(id, instanceIds, options = {}) {
1274
+ const def = SHOPWAVE_ENTITIES.productComponent;
1275
+ const groupId = id == null ? "" : String(id).trim();
1276
+ if (!groupId) throw new Error("productComponent id is missing");
1277
+ const ids = (instanceIds ?? []).map((v) => String(v).trim()).filter(Boolean);
1278
+ if (ids.length === 0) throw new Error("productComponent instance ids are missing");
1279
+ await apiDelete(`/api/${def.route}/${encodeURIComponent(groupId)}`, options, { [def.childIdsHeader]: ids.join(",") });
1280
+ }
1281
+
1282
+ //#endregion
1283
+ //#region src/core/promotion.ts
1284
+ /** Promotion rule identifiers as stored by the Shopwave API. */
1285
+ let PromotionRuleSet = /* @__PURE__ */ function(PromotionRuleSet$1) {
1286
+ PromotionRuleSet$1["Percentage"] = "|%^/";
1287
+ PromotionRuleSet$1["Combo"] = "&%</";
1288
+ PromotionRuleSet$1["MealDeal"] = "&=^/";
1289
+ return PromotionRuleSet$1;
1290
+ }({});
1291
+ /**
1292
+ * Fetches promotions. Returns the flattened `Promotion[]`.
1293
+ */
1294
+ async function fetchPromotions(params = {}, options = {}) {
1295
+ const extras = { deleted: params.deleted ?? false };
1296
+ if (params.promotionIds) extras.promotionIds = params.promotionIds;
1297
+ if (params.imminent !== void 0) extras.imminent = params.imminent;
1298
+ if (params.active !== void 0) extras.active = params.active;
1299
+ if (params.expired !== void 0) extras.expired = params.expired;
1300
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
1301
+ assertNoApiErrors(json, 200);
1302
+ return Object.values(json?.promotions ?? {});
1303
+ }
1304
+
1305
+ //#endregion
1306
+ //#region src/core/store.ts
1307
+ /**
1308
+ * Fetches stores. Returns the flattened `Store[]`.
1309
+ */
1310
+ async function fetchStores(params = {}, options = {}) {
1311
+ const extras = { deleted: params.deleted ?? false };
1312
+ if (params.storeIds) extras.storeIds = params.storeIds;
1313
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
1314
+ assertNoApiErrors(json, 200);
1315
+ return Object.values(json?.stores ?? {});
1316
+ }
1317
+
1318
+ //#endregion
1319
+ //#region src/core/report.ts
1320
+ /**
1321
+ * Runs one or more report queries and returns the `reports` map keyed by query
1322
+ * name. The query is JSON-encoded into the `extras.query` header, matching the
1323
+ * original endpoint contract.
1324
+ */
1325
+ async function fetchReport(query, options = {}) {
1326
+ const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
1327
+ assertNoApiErrors(json, 200);
1328
+ return json?.reports ?? {};
1329
+ }
1330
+
1331
+ //#endregion
1332
+ //#region src/core/session.ts
1333
+ /**
1334
+ * Reads the current login status from `GET /api/session`.
1335
+ *
1336
+ * Works with the new `react-shopwave-connect/next` session route
1337
+ * (`{ loggedIn }`) and with older routes that returned the raw session
1338
+ * (`{ token: {...} }`) — in that case only a boolean is surfaced.
1339
+ */
1340
+ async function fetchSession(options = {}) {
1341
+ const doFetch = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
1342
+ if (!doFetch) throw new Error("No fetch implementation available. Pass `options.fetch`.");
1343
+ const response = await doFetch(`${options.baseUrl ?? ""}/api/session`, {
1344
+ method: "GET",
1345
+ headers: { Accept: "application/json" },
1346
+ cache: "no-store",
1347
+ credentials: "same-origin",
1348
+ signal: options.signal
1349
+ });
1350
+ if (response.status === 401) return { loggedIn: false };
1351
+ if (!response.ok) throw new Error(`Session request failed (${response.status})`);
1352
+ const json = await response.json();
1353
+ if (json && typeof json.loggedIn === "boolean") return {
1354
+ loggedIn: json.loggedIn,
1355
+ expiresAt: typeof json.expiresAt === "number" ? json.expiresAt : void 0
1356
+ };
1357
+ return { loggedIn: Boolean(json && json.token) };
1358
+ }
1359
+ /**
1360
+ * Path that starts a login and returns to `returnTo` afterwards.
1361
+ * Navigate the browser to it (`window.location.href = loginPath(...)`).
1362
+ */
1363
+ function loginPath(returnTo, authPath = "/auth") {
1364
+ return returnTo ? `${authPath}?returnTo=${encodeURIComponent(returnTo)}` : authPath;
1365
+ }
1366
+ /** Path that logs out of the app and the Shopwave auth server. */
1367
+ function logoutPath(authPath = "/auth") {
1368
+ return `${authPath}/logout`;
1369
+ }
1370
+ /**
1371
+ * Ends the current server session (no redirect).
1372
+ *
1373
+ * Prefer navigating to {@link logoutPath} when using
1374
+ * `react-shopwave-connect/next`: it also signs the user out of the auth server.
1375
+ */
1376
+ async function logout(options = {}) {
1377
+ await apiDelete("/api/session?action=logout", options);
1378
+ }
1379
+
1380
+ //#endregion
1381
+ //#region src/core/entity.ts
1382
+ /**
1383
+ * Low-level, untyped mutations: the caller supplies the route and payload.
1384
+ *
1385
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
1386
+ * …): they know each route, build the request envelope, check the response and
1387
+ * return the saved entity with its id. Keep these for routes that have no typed
1388
+ * function yet (e.g. `/api/merchant`).
1389
+ */
1390
+ /**
1391
+ * Deletes `/api/{resourcePath}/{entityId}`.
1392
+ */
1393
+ async function deleteEntity(resourcePath, entityId, options = {}) {
1394
+ if (entityId == null || entityId === "") throw new Error("entityId is missing");
1395
+ await apiDelete(`/api/${resourcePath}/${entityId}`, options);
1396
+ }
1397
+ /**
1398
+ * Sends a create/update mutation. Returns the response's `result` field when it
1399
+ * has one (older app routes wrapped their answer that way), otherwise the whole
1400
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
1401
+ */
1402
+ async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
1403
+ const json = await apiSend(endpoint, method, payload, options);
1404
+ assertNoApiErrors(json, 200);
1405
+ if (json && typeof json === "object" && "result" in json) return json.result;
1406
+ return json ?? void 0;
1407
+ }
1408
+
1271
1409
  //#endregion
1272
1410
  //#region src/core/basket.ts
1273
1411
  const DEFAULT_BASKET_FILTERS = {
@@ -1441,9 +1579,9 @@ function trimmed(value) {
1441
1579
  /**
1442
1580
  * Reads `merchant.links` as flat fields (`{ website, twitter, facebook,
1443
1581
  * instagram }`), whatever shape it was saved in:
1444
- * - the nested shape adminV1 saves (`{ website: { home }, social: { … } }`);
1582
+ * - the nested shape the existing admin saves (`{ website: { home }, social: { … } }`);
1445
1583
  * - flat keys (`{ website: "…" }`, what SDK 0.4.0 pre-releases sent);
1446
- * - an array in {@link MERCHANT_LINK_KEYS} order (older AdminUI versions).
1584
+ * - an array in {@link MERCHANT_LINK_KEYS} order (older app versions).
1447
1585
  * Empty values are dropped.
1448
1586
  */
1449
1587
  function merchantLinksToForm(links) {
@@ -1480,7 +1618,7 @@ function merchantLinksFromForm(form) {
1480
1618
  }
1481
1619
  return out;
1482
1620
  }
1483
- /** Reads any stored or older `links` value into the nested shape adminV1 saves. */
1621
+ /** Reads any stored or older `links` value into the nested shape the existing admin saves. */
1484
1622
  function normalizeMerchantLinks(links) {
1485
1623
  return merchantLinksFromForm(merchantLinksToForm(links));
1486
1624
  }
@@ -1628,6 +1766,8 @@ exports.deleteEmployee = deleteEmployee;
1628
1766
  exports.deleteEntity = deleteEntity;
1629
1767
  exports.deleteEntityById = deleteEntityById;
1630
1768
  exports.deleteProduct = deleteProduct;
1769
+ exports.deleteProductComponent = deleteProductComponent;
1770
+ exports.deleteProductComponentInstances = deleteProductComponentInstances;
1631
1771
  exports.deletePromotion = deletePromotion;
1632
1772
  exports.deleteStore = deleteStore;
1633
1773
  exports.fetchCategories = fetchCategories;
@@ -1639,6 +1779,8 @@ exports.fetchEmployees = fetchEmployees;
1639
1779
  exports.fetchEntityById = fetchEntityById;
1640
1780
  exports.fetchMerchant = fetchMerchant;
1641
1781
  exports.fetchProduct = fetchProduct;
1782
+ exports.fetchProductComponent = fetchProductComponent;
1783
+ exports.fetchProductComponents = fetchProductComponents;
1642
1784
  exports.fetchProductInstanceTags = fetchProductInstanceTags;
1643
1785
  exports.fetchProductTags = fetchProductTags;
1644
1786
  exports.fetchProducts = fetchProducts;
@@ -1681,10 +1823,12 @@ exports.saveEmployee = saveEmployee;
1681
1823
  exports.saveEntities = saveEntities;
1682
1824
  exports.saveEntity = saveEntity;
1683
1825
  exports.saveProduct = saveProduct;
1826
+ exports.saveProductComponent = saveProductComponent;
1684
1827
  exports.savePromotion = savePromotion;
1685
1828
  exports.saveStore = saveStore;
1686
1829
  exports.setMerchantImage = setMerchantImage;
1687
1830
  exports.submitEntity = submitEntity;
1831
+ exports.toProductComponentForSave = toProductComponentForSave;
1688
1832
  exports.toProductInstancesForSave = toProductInstancesForSave;
1689
1833
  exports.toShopwaveApiError = toShopwaveApiError;
1690
1834
  exports.toStoredImageIds = toStoredImageIds;