react-shopwave-connect 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,12 @@ Shopwave API client split into two clearly separated layers:
4
4
 
5
5
  - **`core`** — framework-agnostic async API functions + all TypeScript types. No React anywhere. Works in any TS/JS project (Node script, Vue, Angular, CLI, …).
6
6
  - **`hooks`** — thin React wrappers around `core`, with a consistent `{ data, loading, error, refetch }` shape. React is a **peer dependency** and is never bundled.
7
+ - **`server` / `next`** — the other half of the contract: OAuth login and the `/api/*` route handlers that `core` calls, so an app's API routes are one line each.
8
+
9
+ ```
10
+ browser / Node ──core──▶ your app's /api/<route> ──next route handlers──▶ Shopwave API
11
+ (session cookie or Authorization: OAuth <token>)
12
+ ```
7
13
 
8
14
  ```
9
15
  src/
@@ -29,7 +35,7 @@ npm install react react-dom
29
35
  | `react-shopwave-connect/hooks` | React hooks only |
30
36
  | `react-shopwave-connect` | Both (re-exports `core` + `hooks`) |
31
37
  | `react-shopwave-connect/server` | **Server-only.** Framework-agnostic Shopwave OAuth client + token helpers |
32
- | `react-shopwave-connect/next` | **Server-only.** Next.js login/callback/logout/session route handlers, token refresh, proxy guard |
38
+ | `react-shopwave-connect/next` | **Server-only.** Next.js login/callback/logout/session handlers, token refresh, proxy guard, and the `/api/*` route handlers (`createShopwaveApi`) |
33
39
 
34
40
  Prefer the subpath imports when you want a hard boundary — e.g. a Node service should import from `/core` so React never enters the dependency graph.
35
41
 
@@ -41,13 +47,18 @@ Every `core` function (and every hook) accepts an optional `options` argument:
41
47
  interface RequestOptions {
42
48
  baseUrl?: string; // prefix for every path; omit in the browser to use
43
49
  // relative URLs like "/api/products"
44
- token?: string; // forwarded as the `token` request header
50
+ token?: string; // sent as `Authorization: OAuth <token>` on every
51
+ // request (GET, POST, PUT, DELETE alike)
45
52
  fetch?: typeof fetch; // custom fetch (Node < 18, tests, interceptors)
46
53
  signal?: AbortSignal; // cancellation (hooks pass this automatically)
47
54
  }
48
55
  ```
49
56
 
50
- In the browser / Next.js you can usually omit `options` entirely (relative URLs resolve against the current origin). Outside the browser, pass an absolute `baseUrl` and a `token`.
57
+ In the browser / Next.js you can usually omit `options` entirely (relative URLs resolve against the current origin, and the app's routes use the session cookie). Outside the browser, pass the absolute `baseUrl` of an app that mounts the SDK routes (see [API routes](#4-api-routes-nextjs)) and a `token`.
58
+
59
+ `core` always calls `/api/<route>` paths with the request metadata in an `extras` header, so `baseUrl` must point at such an app, not at the Shopwave API itself.
60
+
61
+ > **0.3 change:** the token used to travel in `extras.token` for reads and a separate `token` header for writes. It is now always the `Authorization` header. The 0.3 route handlers still accept both old forms, so older clients keep working.
51
62
 
52
63
  ---
53
64
 
@@ -64,8 +75,8 @@ import {
64
75
  } from "react-shopwave-connect/core";
65
76
 
66
77
  const options = {
67
- baseUrl: "https://api.shopwave.example",
68
- token: process.env.SHOPWAVE_TOKEN,
78
+ baseUrl: "https://admin.example.com", // an app with the SDK's /api routes
79
+ token: process.env.SHOPWAVE_TOKEN, // bare access token
69
80
  };
70
81
 
71
82
  async function main() {
@@ -94,23 +105,80 @@ On Node < 18 (no global `fetch`), inject one:
94
105
  import { fetchStores } from "react-shopwave-connect/core";
95
106
  import fetch from "node-fetch";
96
107
 
97
- await fetchStores({}, { baseUrl: "https://api.merchantstack.com", fetch });
108
+ await fetchStores({}, { baseUrl: "https://admin.example.com", token, fetch });
109
+ ```
110
+
111
+ Functions **throw** `ShopwaveApiError` on failure:
112
+
113
+ ```ts
114
+ import { saveProduct, ShopwaveApiError } from "react-shopwave-connect/core";
115
+
116
+ try {
117
+ await saveProduct({ name: "Tea", barcode: "123" });
118
+ } catch (e) {
119
+ if (e instanceof ShopwaveApiError) {
120
+ e.status; // HTTP status (0 = no response). 200/201 when the API answered with errors.
121
+ e.errors; // api.message.errors, e.g. { 908: { id: 908, title: … } }
122
+ e.isUnauthorized; // 401 or error 908 → send the user to log in
123
+ }
124
+ }
98
125
  ```
99
126
 
100
- Functions **throw** on network failure or API errors (the error message contains the serialized API errors), so wrap calls in `try/catch`.
127
+ The SDK never logs requests (older versions `console.log`ged headers, including tokens).
128
+
129
+ ### Saving and deleting (typed)
130
+
131
+ Each entity has `save…`, `delete…` and `fetch…(id)`:
132
+
133
+ ```ts
134
+ import { saveCategory, deleteCategory, fetchCategory } from "react-shopwave-connect/core";
135
+
136
+ const created = await saveCategory({ title: "Drinks", parentId: null }); // no id → create
137
+ created.id; // the new id, straight from the save
138
+
139
+ const updated = await saveCategory({ id: created.id, title: "Hot drinks" }); // id → update
140
+
141
+ await fetchCategory(created.id); // Category | null
142
+ await fetchCategory(created.id, { deleted: true }); // include soft-deleted
143
+
144
+ await deleteCategory(created.id);
145
+ ```
146
+
147
+ | Entity | Save / delete / read one | App routes |
148
+ | --------- | --------------------------------------------------------- | ----------------------------- |
149
+ | product | `saveProduct`, `deleteProduct`, `fetchProduct` | `/api/products[/:id]` |
150
+ | category | `saveCategory`, `deleteCategory`, `fetchCategory` | `/api/categories[/:id]` |
151
+ | store | `saveStore`, `deleteStore`, `fetchStore` | `/api/stores[/:id]` |
152
+ | promotion | `savePromotion`, `deletePromotion` (ends it), `fetchPromotion` | `/api/promotions[/:id]` |
153
+ | employee | `saveEmployee`, `deleteEmployee` (retires), `fetchEmployee` | `/api/employees[/:id]` |
154
+ | consumer | `fetchConsumer` only — read-only in the API | `/api/consumer[/:id]` (GET) |
155
+
156
+ How they behave (matches what the Shopwave API does):
157
+
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
+ - **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
+ - **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.
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
+ - **Consumers** are read-only (`GET /consumer` by `ids`); their write/delete routes answer 405.
164
+ - Errors are read from `api.message.errors` and also `api.message.error` (the name used in the [API reference](https://developer.merchantstack.com/api-reference.html)).
165
+
166
+ The generic forms are `saveEntity(kind, item)`, `deleteEntityById(kind, id)` and `fetchEntityById(kind, id)`; `SHOPWAVE_ENTITIES` lists each entity's route, collection key, Shopwave path and id headers.
101
167
 
102
168
  ### Available `core` functions
103
169
 
104
170
  | Domain | Function(s) |
105
171
  | --------- | ------------------------------------------------------- |
106
- | category | `fetchCategories` |
107
- | consumer | `fetchConsumers` |
108
- | employee | `fetchEmployees` |
109
- | product | `fetchProducts`, `fetchProductsMap` (batched, keyed) |
110
- | store | `fetchStores` |
172
+ | category | `fetchCategories`, `fetchCategory`, `saveCategory`, `deleteCategory` |
173
+ | consumer | `fetchConsumers`, `fetchConsumer` (read-only) |
174
+ | employee | `fetchEmployees`, `fetchEmployee`, `saveEmployee`, `deleteEmployee` |
175
+ | product | `fetchProducts`, `fetchProductsMap` (batched, keyed), `fetchProduct`, `saveProduct`, `deleteProduct` |
176
+ | promotion | `fetchPromotions`, `fetchPromotion`, `savePromotion`, `deletePromotion` |
177
+ | store | `fetchStores` (now with `storeIds`), `fetchStore`, `saveStore`, `deleteStore` |
111
178
  | report | `fetchReport` |
112
179
  | session | `fetchSession`, `loginPath`, `logoutPath`, `logout` |
113
- | entity | `deleteEntity`, `submitEntity` |
180
+ | entity | `saveEntity`, `saveEntities`, `deleteEntityById`, `fetchEntityById`; low-level `submitEntity` / `deleteEntity` (raw endpoint) |
181
+ | errors | `ShopwaveApiError`, `getApiErrorMap`, `assertNoApiErrors` |
114
182
  | basket | `buildBasketReportQuery`, `parseBasketReportData`, `combineBasketRows`, `computeBasketSummary`, … (pure transforms) |
115
183
 
116
184
  All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `ReportQueryMap`, `Basket*`, `apiResponse`, …) are exported from `core` too.
@@ -122,9 +190,13 @@ All types/interfaces (`Product`, `Store`, `Category`, `Consumer`, `Employee`, `R
122
190
  Every auto-fetching hook returns the same shape:
123
191
 
124
192
  ```ts
125
- { data, loading, error, refetch }
193
+ { data, loading, fetching, error, errorStatus, refetch }
126
194
  ```
127
195
 
196
+ - `loading` is true only while there's nothing to show yet (first load, or the params changed).
197
+ - `refetch()` keeps the current `data` on screen until the new data arrives (`fetching` is true meanwhile), so lists don't flash a skeleton after every save. A failed refetch keeps the old data and sets `error`.
198
+ - `errorStatus` is the HTTP status of the last error.
199
+
128
200
  ```tsx
129
201
  "use client";
130
202
  import { useProduct, useStore } from "react-shopwave-connect/hooks";
@@ -157,7 +229,9 @@ They fetch on mount and re-run when their arguments change. `useConsumer` and `u
157
229
 
158
230
  ### Manually-triggered hooks (`useCallback`-based)
159
231
 
160
- `useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error }` — nothing fires until you call `mutate`:
232
+ For saves and deletes, prefer calling the typed `core` functions (`saveProduct`, `deleteStore`, …) from your event handlers — they return the saved entity with its id.
233
+
234
+ `useDelete`, `useSubmit`, `useLogout` return `{ mutate, data, loading, error, errorStatus }` — nothing fires until you call `mutate`:
161
235
 
162
236
  ```tsx
163
237
  import { useDelete, useSubmit } from "react-shopwave-connect/hooks";
@@ -303,9 +377,53 @@ export function AccountButton() {
303
377
 
304
378
  Use plain links / `window.location` for login and logout — they are full-page redirects to the auth server, not client-side navigations.
305
379
 
380
+ ### 4. API routes (Next.js)
381
+
382
+ `core` calls `/api/<route>` on your app. `createShopwaveApi` provides those routes, using the session from step 1:
383
+
384
+ ```ts
385
+ // lib/shopwave.ts
386
+ import { createShopwaveApi } from "react-shopwave-connect/next";
387
+ import { auth } from "@/lib/auth";
388
+
389
+ export const shopwave = createShopwaveApi({ auth, apiUrl: process.env.SHOPWAVE_API_SERVER_URL! });
390
+ ```
391
+
392
+ ```ts
393
+ // app/api/products/route.ts — list/filter, create/update
394
+ import { shopwave } from "@/lib/shopwave";
395
+ export const { GET, POST, PUT } = shopwave.collection("product");
396
+
397
+ // app/api/products/[id]/route.ts — read one, update, delete
398
+ import { shopwave } from "@/lib/shopwave";
399
+ export const { GET, PUT, DELETE } = shopwave.item("product");
400
+
401
+ // app/api/report/route.ts — any other Shopwave path, read-only
402
+ import { shopwave } from "@/lib/shopwave";
403
+ export const { GET } = shopwave.passthrough("report");
404
+ ```
405
+
406
+ Entities: `product`, `category`, `store`, `promotion`, `employee` (DELETE retires via `exitDate`), `promotion` (DELETE ends it via `endDate`), `consumer` (mounted at `/api/consumer`; GET only, writes answer 405). For anything else, `shopwave.forward(request, { method, path, headers, postBody })` does the same token/refresh/encoding work for a custom handler.
407
+
408
+ Every handler:
409
+
410
+ - uses the caller's `Authorization: OAuth <token>` when present (also the legacy `token` header / `extras.token`), otherwise the logged-in user's token; `401` when there's neither. Turn caller tokens off with `allowRequestToken: false`.
411
+ - turns the `extras` JSON header into upstream headers, dropping `Authorization`, `token`, `Cookie`, `Host`, `x-accept-version` and other transport headers; `400` if it isn't a JSON object.
412
+ - sends writes as the form field `postBody=<JSON>`, accepting the SDK shape `{ products: { "0": {…} } }`, the older `{ products: { new | updated: {…} } }`, or a bare entity. Item `PUT` forces the id from the URL.
413
+ - on HTTP 401 or API error 908 with the session token, refreshes once and retries.
414
+ - answers with Shopwave's own status and body (201 + echo for saves, 205 + empty body for deletes, error envelopes as-is); `502` if the API can't be reached. Only the method and path are ever logged.
415
+
416
+ For calls that bring their own token (scripts, the integration tests), let them past the proxy guard:
417
+
418
+ ```ts
419
+ await auth.protect(request, { publicPaths: [...], allowRequestToken: true });
420
+ ```
421
+
422
+ Options: `apiVersion` (default `"2.0"`), `fetch`, `entities` (override a route/header, e.g. `{ employee: { idHeader: "userId" } }`), `blockedExtras`, `onError`.
423
+
306
424
  ### Other frameworks
307
425
 
308
- `react-shopwave-connect/server` has the same building blocks without Next.js: `createShopwaveOAuth(config)` → `buildLoginUrl({ state })`, `exchangeCode(code)`, `refreshToken(token)`, `buildLogoutUrl()`, plus `createState`, `sanitizeReturnTo`, `isTokenExpired`, `isExpiredTokenResponse` and `authorizationHeader`. Store the token in your framework's session (Express `express-session`, iron-session's `getIronSession(req, res)`, …).
426
+ `react-shopwave-connect/server` has the same building blocks without Next.js: `createShopwaveOAuth(config)` → `buildLoginUrl({ state })`, `exchangeCode(code)`, `refreshToken(token)`, `buildLogoutUrl()`, plus `createState`, `sanitizeReturnTo`, `isTokenExpired`, `isExpiredTokenResponse` and `authorizationHeader`. The API routes are `createShopwaveApiHandlers({ apiUrl, getAuthorization })` — plain `Request → Response` handlers, the same ones `createShopwaveApi` wraps. Store the token in your framework's session (Express `express-session`, iron-session's `getIronSession(req, res)`, …).
309
427
 
310
428
  ### Moving an existing app over
311
429
 
@@ -329,27 +447,28 @@ npm run typecheck # tsc --noEmit
329
447
 
330
448
  ## Testing
331
449
 
332
- Unit tests (offline, no credentials) cover the OAuth client, token handling, the Next.js handlers (with an in-memory cookie store) and the session client:
450
+ Unit tests (offline, no credentials) cover the OAuth client, token handling, the Next.js handlers (with an in-memory cookie store), the session client, the request layer (token header, errors, no logging), the typed save/delete/read functions, the API route handlers (extras filtering, refresh-and-retry, body normalisation, 205 pass-through) and the hooks' query state:
333
451
 
334
452
  ```bash
335
453
  npm test # = npm run test:unit
336
454
  ```
337
455
 
338
- Integration tests run against a live API using [Vitest](https://vitest.dev). Tests are located in `tests/integration/` and cover full CRUD lifecycles for Products, Categories, Consumers, and Employees.
456
+ 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.
339
457
 
340
458
  ### Environment Variables
341
459
 
342
460
  | Variable | Description | Example |
343
461
  |-------------|--------------------------------------|-------------------------------------------|
344
- | `API_URL` | Base URL for the API | `https://api.staging.merchantstack.com` |
345
- | `API_TOKEN` | Authentication token | `your-staging-token` |
462
+ | `API_URL` | Origin of the app with the SDK routes | `http://localhost:3000` |
463
+ | `API_TOKEN` | Bare Shopwave access token (no `OAuth`/`Bearer` prefix) | `111ad…` |
346
464
 
347
465
  ### Running Tests
348
466
 
349
467
  ```bash
350
468
  # Set environment variables
351
- export API_URL=https://api.staging.merchantstack.com
352
- export API_TOKEN=your-staging-token
469
+ # with the app running (AdminUI: npm run dev)
470
+ export API_URL=http://localhost:3000
471
+ export API_TOKEN=<bare access token>
353
472
 
354
473
  # Run all integration tests
355
474
  npm run test:integration
@@ -366,6 +485,10 @@ tests/
366
485
  server.test.ts # OAuth client, tokens, returnTo/state
367
486
  next.test.ts # Next.js handlers, refresh, proxy guard
368
487
  session-client.test.ts # fetchSession / loginPath / logoutPath
488
+ request.test.ts # token header, ShopwaveApiError, no logging
489
+ entities.test.ts # save/delete/fetch-by-id per entity
490
+ api-routes.test.ts # /api route handlers
491
+ query-state.test.ts # useQuery keeps data while refetching
369
492
  integration/
370
493
  setup.ts # Shared config and helpers
371
494
  products.integration.test.ts # Products CRUD lifecycle