react-shopwave-connect 0.4.6 → 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 +11 -3
- package/dist/core/index.cjs +274 -130
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +292 -145
- package/dist/core/index.d.ts +292 -145
- package/dist/core/index.js +269 -131
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +265 -112
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +90 -23
- package/dist/hooks/index.d.ts +90 -23
- package/dist/hooks/index.js +265 -113
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +294 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +301 -145
- package/dist/index.d.ts +301 -145
- package/dist/index.js +288 -131
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +67 -0
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +33 -2
- package/dist/next/index.d.ts +33 -2
- package/dist/next/index.js +67 -0
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +68 -0
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +40 -3
- package/dist/server/index.d.ts +40 -3
- package/dist/server/index.js +68 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -254,6 +254,7 @@ The shapes below are the ones Shopwave's existing admin apps save and read, so e
|
|
|
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
|
|
|
@@ -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** (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.
|
|
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
|
|
|
@@ -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.
|
package/dist/core/index.cjs
CHANGED
|
@@ -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
|
|
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
|
/**
|
|
@@ -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 = {
|
|
@@ -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;
|