react-shopwave-connect 0.4.1 → 0.4.3

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
@@ -149,7 +149,7 @@ await deleteCategory(created.id);
149
149
  | product | `saveProduct`, `deleteProduct`, `fetchProduct` | `/api/products[/:id]` |
150
150
  | category | `saveCategory`, `deleteCategory`, `fetchCategory` | `/api/categories[/:id]` |
151
151
  | store | `saveStore`, `deleteStore`, `fetchStore` | `/api/stores[/:id]` |
152
- | promotion | `savePromotion`, `deletePromotion` (ends it), `fetchPromotion` | `/api/promotions[/:id]` |
152
+ | promotion | `savePromotion`, `deletePromotion` (expires it), `fetchPromotion` | `/api/promotions[/:id]` |
153
153
  | employee | `saveEmployee`, `deleteEmployee` (retires), `fetchEmployee` | `/api/employees[/:id]` |
154
154
  | consumer | `fetchConsumer` only — read-only in the API | `/api/consumer[/:id]` (GET) |
155
155
 
@@ -158,7 +158,7 @@ How they behave (matches what the Shopwave API does):
158
158
  - **Save** sends `{ <collection>: { "0": entity } }`. Shopwave upserts (an `id` means update), answers **201** and echoes the entity under the same ref with its `id` and server fields. The function checks `api.message.errors` and that the ref came back, and returns the saved entity (fields the echo leaves out are kept from your input). `saveEntities(kind, [a, b])` saves several at once and matches results back by ref.
159
159
  - **Delete** is a soft delete (read it back with `deleted: true`). Shopwave answers **205 with an empty body — also for ids that don't exist**, so a resolved promise means "accepted", not "a record was deleted".
160
160
  - **Read one** calls `GET /api/<route>/:id` and returns the record or `null`. List reads return `[]` when nothing matches (Shopwave answers with an empty body).
161
- - **Promotions:** Shopwave has no promotion DELETE; `deletePromotion` (and `DELETE /api/promotions/:id`) ends the promotion by setting `endDate` to now.
161
+ - **Promotions:** promotions are never deleted, only expired. `deletePromotion` (and `DELETE /api/promotions/:id`) reads the promotion and saves the whole record back with `endDate` set to now; one that hasn't started yet gets `startDate` = `endDate` = now. (The API rejects a partial `{ id, endDate }` save with error 899.) Dates go as UTC `YYYY-MM-DD HH:mm:ss`.
162
162
  - **Employees:** Shopwave has no employee DELETE, so `deleteEmployee` (and `DELETE /api/employees/:id`) retires the employee by setting `exitDate`. Updating an existing employee only changes `roleId`, `joinedDate` and `exitDate` — names and email are fixed — and the echo carries only those fields, so re-read with `fetchEmployee` if you need the stored record.
163
163
  - **Consumers** are read-only (`GET /consumer` by `ids`); their write/delete routes answer 405.
164
164
  - **Product prices (instances) can't be updated** — the API keeps them for history; a price change means a new instance with a new id. Build `instances` with `toProductInstancesForSave(formInstances, productAsLastRead)`: it keys them `"0"…"n"`, keeps an instance's `id` only when the original has that id with the same price and tax, and drops it otherwise (and drops server `timestamp`). `findRepricedInstances(formInstances, productAsLastRead)` lists the instances that will get new ids, so you can warn first. `saveProduct` without an `id` (a new product or a duplicate) always strips instance ids, so a copy can't point at the original's prices.
@@ -226,7 +226,7 @@ The shapes below are the ones adminV1 saves and adminV2 reads, so all three admi
226
226
  | category | `fetchCategories`, `fetchCategory`, `saveCategory`, `deleteCategory` |
227
227
  | consumer | `fetchConsumers`, `fetchConsumer` (read-only) |
228
228
  | employee | `fetchEmployees`, `fetchEmployee`, `saveEmployee`, `deleteEmployee` |
229
- | product | `fetchProducts`, `fetchProductsMap` (batched, keyed), `fetchProduct`, `saveProduct`, `deleteProduct`; prices `toProductInstancesForSave`, `findRepricedInstances`, `isSameInstancePrice` |
229
+ | product | `fetchProducts`, `fetchProductsMap` (batched, keyed), `fetchProduct`, `saveProduct`, `deleteProduct`; prices `toProductInstancesForSave`, `findRepricedInstances`, `isSameInstancePrice`; tag lists `fetchProductTags`, `fetchProductInstanceTags` (`string[]`), `normalizeTagList` |
230
230
  | promotion | `fetchPromotions`, `fetchPromotion`, `savePromotion`, `deletePromotion` |
231
231
  | store | `fetchStores` (now with `storeIds`), `fetchStore`, `saveStore`, `deleteStore` |
232
232
  | report | `fetchReport` |
@@ -281,7 +281,7 @@ const { data } = useProduct({ storeId }, { baseUrl: "https://api.merchantstack.c
281
281
 
282
282
  ### Auto-fetch hooks (`useEffect`-based)
283
283
 
284
- `useCategory`, `useConsumer`, `useEmployee`, `useProduct`, `usePromotion`, `useStore`, `useReport`, `useSession`, `useMerchant`, `useUser` (`useUser({ enabled: false })` stays idle).
284
+ `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).
285
285
  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.
286
286
 
287
287
  ### Manually-triggered hooks (`useCallback`-based)
@@ -467,12 +467,19 @@ export const { GET } = shopwave.resource("user");
467
467
 
468
468
  // app/api/upload/route.ts — image upload (uploadImage)
469
469
  export const { POST } = shopwave.upload(); // or upload({ maxBytes, accept })
470
+
471
+ // app/api/products/tags/route.ts — the merchant's product tags (fetchProductTags)
472
+ export const { GET } = shopwave.lookup("productTags");
473
+ // app/api/products/instance-tags/route.ts — price-instance (channel) tags (fetchProductInstanceTags)
474
+ export const { GET } = shopwave.lookup("productInstanceTags");
470
475
  ```
471
476
 
477
+ - `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]`.
478
+
472
479
  - `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.
473
480
  - `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.
474
481
 
475
- 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.
482
+ 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.
476
483
 
477
484
  Every handler:
478
485
 
@@ -503,6 +510,11 @@ Options: `apiVersion` (default `"2.0"`), `fetch`, `entities` (override a route/h
503
510
 
504
511
  ---
505
512
 
513
+ ## Contributing
514
+
515
+ - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): how the package is built and the rules for changing it: layers, request flow, contract tables, endpoint kinds (and how to choose one), a checklist for adding an endpoint, code rules, known gaps, and versioning.
516
+ - [CLAUDE.md](CLAUDE.md): the short rules for coding agents working in this repo.
517
+
506
518
  ## Build
507
519
 
508
520
  Built with [`tsdown`](https://tsdown.dev) into dual ESM + CJS with `.d.ts` types, via separate entries: `core` (no externals), `hooks` (`react`/`react-dom` external), `server` (no externals) and `next` (`next`/`iron-session` external — optional peer dependencies). `server` and `next` are never re-exported from the root entry, so they can't end up in client bundles.
@@ -350,6 +350,106 @@ async function fetchProductsMap(productIds, options = {}, batchSize = 200) {
350
350
  return result;
351
351
  }
352
352
 
353
+ //#endregion
354
+ //#region src/core/resources.ts
355
+ /**
356
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
357
+ * updates it (without an `id` the API would create a second merchant, so the
358
+ * route requires one).
359
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
360
+ * needs the admin scope).
361
+ */
362
+ const SHOPWAVE_RESOURCES = Object.freeze({
363
+ merchant: {
364
+ route: "merchant",
365
+ key: "merchant",
366
+ upstream: "merchant",
367
+ writable: true
368
+ },
369
+ user: {
370
+ route: "user",
371
+ key: "user",
372
+ upstream: "user",
373
+ writable: false
374
+ }
375
+ });
376
+ /** `UploadKind` → the API's `contentType` value. */
377
+ const UPLOAD_KINDS = Object.freeze({
378
+ merchant: "merchant",
379
+ merchantFeature: "merchantFeature",
380
+ product: "product",
381
+ user: "user",
382
+ applicationLogo: "applicaionLogo",
383
+ applicationImages: "applicaionImages"
384
+ });
385
+ /** How uploads are addressed (app route and Shopwave uploader). */
386
+ const SHOPWAVE_UPLOAD = Object.freeze({
387
+ route: "upload",
388
+ upstream: "uploader",
389
+ kindHeader: "contentType",
390
+ fileField: "file",
391
+ kindField: "kind"
392
+ });
393
+ function isUploadKind(value) {
394
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
395
+ }
396
+ const SHOPWAVE_LOOKUPS = Object.freeze({
397
+ productTags: {
398
+ route: "products/tags",
399
+ key: "tags",
400
+ upstream: "product/tags"
401
+ },
402
+ productInstanceTags: {
403
+ route: "products/instance-tags",
404
+ key: "tags",
405
+ upstream: "product/instance/tags"
406
+ }
407
+ });
408
+
409
+ //#endregion
410
+ //#region src/core/productTags.ts
411
+ /**
412
+ * Turns a tag list from the API into clean strings: trims each tag, drops
413
+ * blanks and anything that isn't text, and removes exact duplicates (first one
414
+ * wins). The API's own order is kept. A map is accepted too (its values are
415
+ * used), in case the API ever answers keyed by id.
416
+ */
417
+ function normalizeTagList(value) {
418
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
419
+ const seen = /* @__PURE__ */ new Set();
420
+ const out = [];
421
+ for (const item of list) {
422
+ if (typeof item !== "string" && typeof item !== "number") continue;
423
+ const tag = String(item).trim();
424
+ if (!tag || seen.has(tag)) continue;
425
+ seen.add(tag);
426
+ out.push(tag);
427
+ }
428
+ return out;
429
+ }
430
+ async function fetchLookup(kind, options) {
431
+ const def = SHOPWAVE_LOOKUPS[kind];
432
+ if (!def) throw new Error(`Unknown Shopwave lookup "${kind}"`);
433
+ const json = await apiGet(`/api/${def.route}`, {}, options);
434
+ assertNoApiErrors(json, 200);
435
+ return normalizeTagList(json?.[def.key]);
436
+ }
437
+ /**
438
+ * Every distinct tag used on the merchant's products (`GET /product/tags`),
439
+ * for a tag field's suggestions. Returns `[]` when there are none.
440
+ */
441
+ function fetchProductTags(options = {}) {
442
+ return fetchLookup("productTags", options);
443
+ }
444
+ /**
445
+ * Every distinct tag used on the merchant's price instances
446
+ * (`GET /product/instance/tags`): the channel names (Eat In, Takeout, …).
447
+ * Returns `[]` when there are none.
448
+ */
449
+ function fetchProductInstanceTags(options = {}) {
450
+ return fetchLookup("productInstanceTags", options);
451
+ }
452
+
353
453
  //#endregion
354
454
  //#region src/core/promotion.ts
355
455
  /** Promotion rule identifiers as stored by the Shopwave API. */
@@ -484,7 +584,10 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
484
584
  * (https://developer.merchantstack.com/api-reference.html) and checked against
485
585
  * the live API where noted:
486
586
  * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
487
- * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
587
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
588
+ * reads the promotion and saves the whole record back with `endDate` = now (and
589
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
590
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
488
591
  * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
489
592
  * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
490
593
  * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
@@ -525,7 +628,9 @@ const SHOPWAVE_ENTITIES = Object.freeze({
525
628
  idHeader: "promotionId",
526
629
  writable: true,
527
630
  deleteMode: "retire",
528
- retireField: "endDate"
631
+ retireField: "endDate",
632
+ retireWithRecord: true,
633
+ retireStartField: "startDate"
529
634
  },
530
635
  employee: {
531
636
  route: "employees",
@@ -600,7 +705,8 @@ async function saveEntity(kind, item, options = {}) {
600
705
  /**
601
706
  * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
602
707
  * Employees and promotions can't be deleted in Shopwave; for them the route
603
- * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`).
708
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
709
+ * promotion is saved back whole (see {@link deletePromotion}).
604
710
  *
605
711
  * Shopwave answers 205 with an empty body — also for ids that don't exist — so
606
712
  * a resolved promise means "the API accepted the request", not "a record was
@@ -641,7 +747,12 @@ const saveStore = (store, options) => saveEntity("store", store, options);
641
747
  const deleteStore = (id, options) => deleteEntityById("store", id, options);
642
748
  const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
643
749
  const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
644
- /** Ends a promotion: Shopwave has no promotion DELETE, so this sets `endDate` to now. */
750
+ /**
751
+ * Expires a promotion (Shopwave never deletes promotions). The route reads the
752
+ * promotion and saves it back with `endDate` set to now; a promotion that hasn't
753
+ * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
754
+ * exist or the save fails.
755
+ */
645
756
  const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
646
757
  const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
647
758
  /**
@@ -800,50 +911,6 @@ function buildConsumerMap(consumers) {
800
911
  return map;
801
912
  }
802
913
 
803
- //#endregion
804
- //#region src/core/resources.ts
805
- /**
806
- * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
807
- * updates it (without an `id` the API would create a second merchant, so the
808
- * route requires one).
809
- * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
810
- * needs the admin scope).
811
- */
812
- const SHOPWAVE_RESOURCES = Object.freeze({
813
- merchant: {
814
- route: "merchant",
815
- key: "merchant",
816
- upstream: "merchant",
817
- writable: true
818
- },
819
- user: {
820
- route: "user",
821
- key: "user",
822
- upstream: "user",
823
- writable: false
824
- }
825
- });
826
- /** `UploadKind` → the API's `contentType` value. */
827
- const UPLOAD_KINDS = Object.freeze({
828
- merchant: "merchant",
829
- merchantFeature: "merchantFeature",
830
- product: "product",
831
- user: "user",
832
- applicationLogo: "applicaionLogo",
833
- applicationImages: "applicaionImages"
834
- });
835
- /** How uploads are addressed (app route and Shopwave uploader). */
836
- const SHOPWAVE_UPLOAD = Object.freeze({
837
- route: "upload",
838
- upstream: "uploader",
839
- kindHeader: "contentType",
840
- fileField: "file",
841
- kindField: "kind"
842
- });
843
- function isUploadKind(value) {
844
- return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
845
- }
846
-
847
914
  //#endregion
848
915
  //#region src/core/imageFit.ts
849
916
  /**
@@ -1294,6 +1361,7 @@ exports.MERCHANT_READ_ONLY_FIELDS = MERCHANT_READ_ONLY_FIELDS;
1294
1361
  exports.PromotionRuleSet = PromotionRuleSet;
1295
1362
  exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
1296
1363
  exports.SHOPWAVE_IMAGE_BASE_URL = SHOPWAVE_IMAGE_BASE_URL;
1364
+ exports.SHOPWAVE_LOOKUPS = SHOPWAVE_LOOKUPS;
1297
1365
  exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
1298
1366
  exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
1299
1367
  exports.ShopwaveApiError = ShopwaveApiError;
@@ -1327,6 +1395,8 @@ exports.fetchEmployees = fetchEmployees;
1327
1395
  exports.fetchEntityById = fetchEntityById;
1328
1396
  exports.fetchMerchant = fetchMerchant;
1329
1397
  exports.fetchProduct = fetchProduct;
1398
+ exports.fetchProductInstanceTags = fetchProductInstanceTags;
1399
+ exports.fetchProductTags = fetchProductTags;
1330
1400
  exports.fetchProducts = fetchProducts;
1331
1401
  exports.fetchProductsMap = fetchProductsMap;
1332
1402
  exports.fetchPromotion = fetchPromotion;
@@ -1353,6 +1423,7 @@ exports.logoutPath = logoutPath;
1353
1423
  exports.merchantLinksFromForm = merchantLinksFromForm;
1354
1424
  exports.merchantLinksToForm = merchantLinksToForm;
1355
1425
  exports.normalizeMerchantLinks = normalizeMerchantLinks;
1426
+ exports.normalizeTagList = normalizeTagList;
1356
1427
  exports.parseBasketReportData = parseBasketReportData;
1357
1428
  exports.readImageSize = readImageSize;
1358
1429
  exports.rolesById = rolesById;