react-shopwave-connect 0.1.2 → 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/dist/index.cjs CHANGED
@@ -24,89 +24,202 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let react = require("react");
25
25
  react = __toESM(react);
26
26
 
27
+ //#region src/core/errors.ts
28
+ /**
29
+ * Thrown by every `core` function when a request fails, either at the HTTP
30
+ * level (`status` is the HTTP status) or because the Shopwave response carried
31
+ * `api.message.errors` (then `status` is the HTTP status of that response,
32
+ * usually 200/201, and `errors` holds the API errors).
33
+ *
34
+ * Branch on `status` (e.g. `isUnauthorized` → send the user to log in) rather
35
+ * than parsing `message`.
36
+ */
37
+ var ShopwaveApiError = class extends Error {
38
+ /** HTTP status of the response (0 when the request never got a response). */
39
+ status;
40
+ /** Shopwave API errors, when the response carried any. */
41
+ errors;
42
+ /** Parsed response body, when there was one. */
43
+ body;
44
+ constructor(status, message, details = {}) {
45
+ super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
46
+ this.name = "ShopwaveApiError";
47
+ this.status = status;
48
+ this.errors = details.errors ?? null;
49
+ this.body = details.body;
50
+ }
51
+ /** 401, or the Shopwave "token expired / invalid" error 908. */
52
+ get isUnauthorized() {
53
+ if (this.status === 401) return true;
54
+ return !!this.errors && Object.entries(this.errors).some(([k, v]) => k === "908" || Number(v?.id) === 908);
55
+ }
56
+ get isNotFound() {
57
+ return this.status === 404;
58
+ }
59
+ };
60
+ /** The `api.message.errors` (or `api.message.error`) map of a Shopwave response, or `null` when there are none. */
61
+ function getApiErrorMap(body) {
62
+ const message = body?.api?.message;
63
+ const errors = message?.errors ?? message?.error;
64
+ if (errors && typeof errors === "object" && Object.keys(errors).length > 0) return errors;
65
+ return null;
66
+ }
67
+ function describeErrors(errors) {
68
+ return Object.entries(errors).map(([code, e]) => {
69
+ const text = [e?.title, e?.details].filter(Boolean).join(": ");
70
+ return text ? `${code} ${text}` : code;
71
+ }).join("; ");
72
+ }
73
+ /**
74
+ * Builds a {@link ShopwaveApiError} for a response. Understands the Shopwave
75
+ * envelope (`api.message.errors`) and the `{ error, message }` bodies returned
76
+ * by the `react-shopwave-connect/next` routes.
77
+ */
78
+ function toShopwaveApiError(status, body, fallback) {
79
+ const errors = getApiErrorMap(body);
80
+ let detail = errors ? describeErrors(errors) : "";
81
+ if (!detail && body && typeof body === "object") {
82
+ const b = body;
83
+ if (typeof b.message === "string") detail = b.message;
84
+ else if (typeof b.error === "string") detail = b.error;
85
+ }
86
+ if (!detail) detail = fallback ?? (status >= 400 ? "request failed" : "API returned errors");
87
+ return new ShopwaveApiError(status, `Shopwave API error (${status}): ${detail}`, {
88
+ errors,
89
+ body
90
+ });
91
+ }
92
+ /** Throws a {@link ShopwaveApiError} when the response body carries API errors. */
93
+ function assertNoApiErrors(body, status) {
94
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
95
+ }
96
+
97
+ //#endregion
27
98
  //#region src/core/request.ts
28
99
  function resolveFetch(options) {
29
100
  const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
30
101
  if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
31
102
  return f;
32
103
  }
33
- function buildExtras(extras, options) {
104
+ /** Options with `token` filled in from a legacy per-call `params.token`, unless `options.token` is set. */
105
+ function withToken(options, token) {
106
+ return token && !options.token ? {
107
+ ...options,
108
+ token
109
+ } : options;
110
+ }
111
+ /** `Authorization` header value for a token (`OAuth <token>` unless it already has a scheme). */
112
+ function authorizationFor(token) {
113
+ const t = token.trim();
114
+ return /^(oauth|bearer)\s+\S/i.test(t) ? t : `OAuth ${t}`;
115
+ }
116
+ /** Headers shared by every request: Accept + the token, when one is given. */
117
+ function baseHeaders(options) {
118
+ const headers = { Accept: "application/json" };
119
+ if (options.token) headers.Authorization = authorizationFor(options.token);
120
+ return headers;
121
+ }
122
+ function buildExtras(extras) {
34
123
  const merged = {
35
124
  "Content-Type": "application/json",
36
125
  ...extras
37
126
  };
38
- if (options.token != null && merged.token == null) merged.token = options.token;
127
+ delete merged.token;
39
128
  return merged;
40
129
  }
130
+ /** Parses a response body as JSON; `null` for an empty body, `{ raw }` for non-JSON. */
131
+ async function readBody(response) {
132
+ const text = await response.text();
133
+ if (!text) return null;
134
+ try {
135
+ return JSON.parse(text);
136
+ } catch {
137
+ return { raw: text };
138
+ }
139
+ }
140
+ /**
141
+ * Low-level request: resolves `{ status, body }` for 2xx responses and throws
142
+ * {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
143
+ * standard ones (Accept + Authorization).
144
+ */
145
+ async function apiRequest(path, init, options = {}) {
146
+ return send(path, {
147
+ ...init,
148
+ headers: {
149
+ ...baseHeaders(options),
150
+ ...init.headers
151
+ }
152
+ }, options);
153
+ }
154
+ async function send(path, init, options) {
155
+ const doFetch = resolveFetch(options);
156
+ const url = (options.baseUrl ?? "") + path;
157
+ let response;
158
+ try {
159
+ response = await doFetch(url, {
160
+ ...init,
161
+ signal: options.signal
162
+ });
163
+ } catch (error) {
164
+ if (error?.name === "AbortError") throw error;
165
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
166
+ }
167
+ const body = await readBody(response);
168
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
169
+ return {
170
+ status: response.status,
171
+ body
172
+ };
173
+ }
41
174
  /**
42
175
  * Returns a JSON string of API errors if the response carries any, otherwise
43
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
176
+ * `null`. Kept for backwards compatibility; prefer `ShopwaveApiError`.
44
177
  */
45
178
  function getApiErrors(api) {
46
- const errors = api?.message?.errors;
47
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
48
- return null;
179
+ const errors = getApiErrorMap({ api });
180
+ return errors ? JSON.stringify(errors) : null;
49
181
  }
50
182
  /**
51
- * GET helper. Sends request metadata via the `extras` header exactly like the
52
- * original hooks did, parses JSON, and returns the typed payload.
183
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
184
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
53
185
  */
54
186
  async function apiGet(path, extras, options = {}) {
55
- const doFetch = resolveFetch(options);
56
- const url = (options.baseUrl ?? "") + path;
57
- const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
58
- console.log("[API GET]", {
59
- url,
60
- headers
61
- });
62
- const response = await doFetch(url, {
187
+ const { body } = await send(path, {
63
188
  method: "GET",
64
- headers,
65
- signal: options.signal
66
- });
67
- if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
68
- return await response.json();
189
+ headers: {
190
+ ...baseHeaders(options),
191
+ extras: JSON.stringify(buildExtras(extras))
192
+ }
193
+ }, options);
194
+ return body;
69
195
  }
70
196
  /**
71
- * JSON body helper for POST/PUT mutations.
197
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
198
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
72
199
  */
73
200
  async function apiSend(path, method, payload, options = {}) {
74
- const doFetch = resolveFetch(options);
75
- const url = (options.baseUrl ?? "") + path;
76
- const headers = { "Content-Type": "application/json" };
77
- if (options.token != null) headers.token = options.token;
78
- console.log(`[API ${method}]`, {
79
- url,
80
- headers,
81
- payload
82
- });
83
- const response = await doFetch(url, {
201
+ const { body } = await send(path, {
84
202
  method,
85
- headers,
86
- body: JSON.stringify(payload),
87
- signal: options.signal
88
- });
89
- if (!response.ok) throw new Error(`Failed to ${method.toLowerCase()}: ${response.statusText}`);
90
- return await response.json();
203
+ headers: {
204
+ ...baseHeaders(options),
205
+ "Content-Type": "application/json"
206
+ },
207
+ body: JSON.stringify(payload)
208
+ }, options);
209
+ return body;
91
210
  }
92
211
  /**
93
- * DELETE helper.
212
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
213
+ * does come back and carries API errors, they are thrown. Returns the parsed
214
+ * body (`null` when empty).
94
215
  */
95
216
  async function apiDelete(path, options = {}) {
96
- const doFetch = resolveFetch(options);
97
- const url = (options.baseUrl ?? "") + path;
98
- const headers = {};
99
- if (options.token != null) headers.token = options.token;
100
- console.log("[API DELETE]", {
101
- url,
102
- headers
103
- });
104
- const response = await doFetch(url, {
217
+ const { status, body } = await send(path, {
105
218
  method: "DELETE",
106
- headers,
107
- signal: options.signal
108
- });
109
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
219
+ headers: baseHeaders(options)
220
+ }, options);
221
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
222
+ return body;
110
223
  }
111
224
 
112
225
  //#endregion
@@ -118,11 +231,9 @@ async function apiDelete(path, options = {}) {
118
231
  async function fetchCategories(params = {}, options = {}) {
119
232
  const extras = { deleted: params.deleted ?? false };
120
233
  if (params.categoryIds) extras.categoryIds = params.categoryIds;
121
- if (params.token) extras.token = params.token;
122
- const json = await apiGet("/api/categories", extras, options);
123
- const error = getApiErrors(json.api);
124
- if (error) throw new Error(error);
125
- return Object.values(json.categories ?? {});
234
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
235
+ assertNoApiErrors(json, 200);
236
+ return Object.values(json?.categories ?? {});
126
237
  }
127
238
 
128
239
  //#endregion
@@ -134,9 +245,8 @@ async function fetchCategories(params = {}, options = {}) {
134
245
  async function fetchConsumers(consumerIds, options = {}) {
135
246
  if (!consumerIds || consumerIds.length === 0) return [];
136
247
  const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
137
- const error = getApiErrors(json.api);
138
- if (error) throw new Error(error);
139
- return Object.values(json.consumers ?? {});
248
+ assertNoApiErrors(json, 200);
249
+ return Object.values(json?.consumers ?? {});
140
250
  }
141
251
 
142
252
  //#endregion
@@ -154,10 +264,9 @@ const rolesById = {
154
264
  async function fetchEmployees(params = {}, options = {}) {
155
265
  const extras = { deleted: params.deleted ?? false };
156
266
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
157
- const json = await apiGet("/api/employees", extras, options);
158
- const error = getApiErrors(json.api);
159
- if (error) throw new Error(error);
160
- return Object.values(json.employees ?? {});
267
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
268
+ assertNoApiErrors(json, 200);
269
+ return Object.values(json?.employees ?? {});
161
270
  }
162
271
 
163
272
  //#endregion
@@ -169,11 +278,9 @@ async function fetchProducts(params = {}, options = {}) {
169
278
  const extras = { deleted: params.deleted ?? false };
170
279
  if (params.productIds) extras.productIds = params.productIds;
171
280
  if (params.storeId) extras.storeId = params.storeId;
172
- if (params.token) extras.token = params.token;
173
- const json = await apiGet("/api/products", extras, options);
174
- const error = getApiErrors(json.api);
175
- if (error) throw new Error(error);
176
- return Object.values(json.products ?? {});
281
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
282
+ assertNoApiErrors(json, 200);
283
+ return Object.values(json?.products ?? {});
177
284
  }
178
285
  /**
179
286
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -209,11 +316,9 @@ async function fetchPromotions(params = {}, options = {}) {
209
316
  if (params.imminent !== void 0) extras.imminent = params.imminent;
210
317
  if (params.active !== void 0) extras.active = params.active;
211
318
  if (params.expired !== void 0) extras.expired = params.expired;
212
- if (params.token) extras.token = params.token;
213
- const json = await apiGet("/api/promotions", extras, options);
214
- const error = getApiErrors(json.api);
215
- if (error) throw new Error(error);
216
- return Object.values(json.promotions ?? {});
319
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
320
+ assertNoApiErrors(json, 200);
321
+ return Object.values(json?.promotions ?? {});
217
322
  }
218
323
 
219
324
  //#endregion
@@ -222,10 +327,11 @@ async function fetchPromotions(params = {}, options = {}) {
222
327
  * Fetches stores. Returns the flattened `Store[]`.
223
328
  */
224
329
  async function fetchStores(params = {}, options = {}) {
225
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
226
- const error = getApiErrors(json.api);
227
- if (error) throw new Error(error);
228
- return Object.values(json.stores ?? {});
330
+ const extras = { deleted: params.deleted ?? false };
331
+ if (params.storeIds) extras.storeIds = params.storeIds;
332
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
333
+ assertNoApiErrors(json, 200);
334
+ return Object.values(json?.stores ?? {});
229
335
  }
230
336
 
231
337
  //#endregion
@@ -237,20 +343,54 @@ async function fetchStores(params = {}, options = {}) {
237
343
  */
238
344
  async function fetchReport(query, options = {}) {
239
345
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
240
- const error = getApiErrors(json.api);
241
- if (error) throw new Error(error);
242
- return json.reports ?? {};
346
+ assertNoApiErrors(json, 200);
347
+ return json?.reports ?? {};
243
348
  }
244
349
 
245
350
  //#endregion
246
351
  //#region src/core/session.ts
247
352
  /**
248
- * Ends the current server session.
353
+ * Reads the current login status from `GET /api/session`.
354
+ *
355
+ * Works with the new `react-shopwave-connect/next` session route
356
+ * (`{ loggedIn }`) and with older routes that returned the raw session
357
+ * (`{ token: {...} }`) — in that case only a boolean is surfaced.
358
+ */
359
+ async function fetchSession(options = {}) {
360
+ const doFetch = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
361
+ if (!doFetch) throw new Error("No fetch implementation available. Pass `options.fetch`.");
362
+ const response = await doFetch(`${options.baseUrl ?? ""}/api/session`, {
363
+ method: "GET",
364
+ headers: { Accept: "application/json" },
365
+ cache: "no-store",
366
+ credentials: "same-origin",
367
+ signal: options.signal
368
+ });
369
+ if (response.status === 401) return { loggedIn: false };
370
+ if (!response.ok) throw new Error(`Session request failed (${response.status})`);
371
+ const json = await response.json();
372
+ if (json && typeof json.loggedIn === "boolean") return {
373
+ loggedIn: json.loggedIn,
374
+ expiresAt: typeof json.expiresAt === "number" ? json.expiresAt : void 0
375
+ };
376
+ return { loggedIn: Boolean(json && json.token) };
377
+ }
378
+ /**
379
+ * Path that starts a login and returns to `returnTo` afterwards.
380
+ * Navigate the browser to it (`window.location.href = loginPath(...)`).
381
+ */
382
+ function loginPath(returnTo, authPath = "/auth") {
383
+ return returnTo ? `${authPath}?returnTo=${encodeURIComponent(returnTo)}` : authPath;
384
+ }
385
+ /** Path that logs out of the app and the Shopwave auth server. */
386
+ function logoutPath(authPath = "/auth") {
387
+ return `${authPath}/logout`;
388
+ }
389
+ /**
390
+ * Ends the current server session (no redirect).
249
391
  *
250
- * This is the framework-agnostic half of the original `useLogout` hook. The
251
- * Next.js-specific redirect logic (router.push to the auth server logout URL,
252
- * built from server actions) is intentionally NOT included here because it is
253
- * tied to `next/navigation` and `'use server'` actions — see the README.
392
+ * Prefer navigating to {@link logoutPath} when using
393
+ * `react-shopwave-connect/next`: it also signs the user out of the auth server.
254
394
  */
255
395
  async function logout(options = {}) {
256
396
  await apiDelete("/api/session?action=logout", options);
@@ -259,10 +399,12 @@ async function logout(options = {}) {
259
399
  //#endregion
260
400
  //#region src/core/entity.ts
261
401
  /**
262
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
263
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
264
- * UI concern is deliberately left to the hooks/host layer so core stays
265
- * dependency-free.
402
+ * Low-level, untyped mutations: the caller supplies the route and payload.
403
+ *
404
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
405
+ * …): they know each route, build the request envelope, check the response and
406
+ * return the saved entity with its id. Keep these for routes that have no typed
407
+ * function yet (e.g. `/api/merchant`).
266
408
  */
267
409
  /**
268
410
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -272,13 +414,182 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
272
414
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
273
415
  }
274
416
  /**
275
- * Sends a create/update mutation and returns the parsed `result` field
276
- * (matching the original `result.result` access).
417
+ * Sends a create/update mutation. Returns the response's `result` field when it
418
+ * has one (older app routes wrapped their answer that way), otherwise the whole
419
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
277
420
  */
278
421
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
279
- return (await apiSend(endpoint, method, payload, options)).result;
422
+ const json = await apiSend(endpoint, method, payload, options);
423
+ assertNoApiErrors(json, 200);
424
+ if (json && typeof json === "object" && "result" in json) return json.result;
425
+ return json ?? void 0;
280
426
  }
281
427
 
428
+ //#endregion
429
+ //#region src/core/entities.ts
430
+ /**
431
+ * Addressing per entity, following the Shopwave API reference
432
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
433
+ * the live API where noted:
434
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
435
+ * - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
436
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
437
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
438
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
439
+ */
440
+ const SHOPWAVE_ENTITIES = Object.freeze({
441
+ product: {
442
+ route: "products",
443
+ collection: "products",
444
+ upstream: "product",
445
+ idsHeader: "productIds",
446
+ idHeader: "productId",
447
+ writable: true,
448
+ deleteMode: "delete"
449
+ },
450
+ category: {
451
+ route: "categories",
452
+ collection: "categories",
453
+ upstream: "category",
454
+ idsHeader: "categoryIds",
455
+ idHeader: "categoryId",
456
+ writable: true,
457
+ deleteMode: "delete"
458
+ },
459
+ store: {
460
+ route: "stores",
461
+ collection: "stores",
462
+ upstream: "store",
463
+ idsHeader: "storeIds",
464
+ idHeader: "storeId",
465
+ writable: true,
466
+ deleteMode: "delete"
467
+ },
468
+ promotion: {
469
+ route: "promotions",
470
+ collection: "promotions",
471
+ upstream: "promotion",
472
+ idsHeader: "promotionIds",
473
+ idHeader: "promotionId",
474
+ writable: true,
475
+ deleteMode: "retire",
476
+ retireField: "endDate"
477
+ },
478
+ employee: {
479
+ route: "employees",
480
+ collection: "employees",
481
+ upstream: "employee",
482
+ idsHeader: "employeeIds",
483
+ idHeader: "employeeId",
484
+ writable: true,
485
+ deleteMode: "retire",
486
+ retireField: "exitDate"
487
+ },
488
+ consumer: {
489
+ route: "consumer",
490
+ collection: "consumers",
491
+ upstream: "consumer",
492
+ idsHeader: "ids",
493
+ idHeader: "consumerId",
494
+ writable: false,
495
+ deleteMode: "none"
496
+ }
497
+ });
498
+ function definition(kind) {
499
+ const def = SHOPWAVE_ENTITIES[kind];
500
+ if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
501
+ return def;
502
+ }
503
+ function assertId(kind, id) {
504
+ const s = id == null ? "" : String(id).trim();
505
+ if (!s) throw new Error(`${kind} id is missing`);
506
+ return encodeURIComponent(s);
507
+ }
508
+ /**
509
+ * Creates or updates several entities of one kind in a single request and
510
+ * returns them as saved (with their ids), in the order given.
511
+ *
512
+ * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
513
+ * answers 201 and echoes every entity under the ref it was sent with, so each
514
+ * result is matched back by ref. Throws {@link ShopwaveApiError} when the
515
+ * response carries `api.message.errors` or any ref is missing from it.
516
+ *
517
+ * The API's partial-success semantics for multi-entity saves haven't been
518
+ * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
519
+ */
520
+ async function saveEntities(kind, items, options = {}) {
521
+ const def = definition(kind);
522
+ if (items.length === 0) return [];
523
+ const refs = items.map((_, i) => String(i));
524
+ const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
525
+ const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
526
+ method: "POST",
527
+ headers: { "Content-Type": "application/json" },
528
+ body: JSON.stringify(payload)
529
+ }, options);
530
+ const body = raw;
531
+ assertNoApiErrors(body, status);
532
+ const echoed = body?.[def.collection] ?? {};
533
+ return refs.map((ref, i) => {
534
+ const saved = echoed[ref];
535
+ if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
536
+ return {
537
+ ...items[i],
538
+ ...saved,
539
+ id: Number(saved.id)
540
+ };
541
+ });
542
+ }
543
+ /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
544
+ async function saveEntity(kind, item, options = {}) {
545
+ const [saved] = await saveEntities(kind, [item], options);
546
+ return saved;
547
+ }
548
+ /**
549
+ * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
550
+ * Employees and promotions can't be deleted in Shopwave; for them the route
551
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`).
552
+ *
553
+ * Shopwave answers 205 with an empty body — also for ids that don't exist — so
554
+ * a resolved promise means "the API accepted the request", not "a record was
555
+ * deleted".
556
+ */
557
+ async function deleteEntityById(kind, id, options = {}) {
558
+ await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
559
+ }
560
+ /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
561
+ async function fetchEntityById(kind, id, params = {}, options = {}) {
562
+ const def = definition(kind);
563
+ const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
564
+ assertNoApiErrors(body, 200);
565
+ const map = body?.[def.collection] ?? {};
566
+ return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
567
+ }
568
+ const saveProduct = (product, options) => saveEntity("product", product, options);
569
+ const deleteProduct = (id, options) => deleteEntityById("product", id, options);
570
+ const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
571
+ const saveCategory = (category, options) => saveEntity("category", category, options);
572
+ const deleteCategory = (id, options) => deleteEntityById("category", id, options);
573
+ const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
574
+ const saveStore = (store, options) => saveEntity("store", store, options);
575
+ const deleteStore = (id, options) => deleteEntityById("store", id, options);
576
+ const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
577
+ const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
578
+ /** Ends a promotion: Shopwave has no promotion DELETE, so this sets `endDate` to now. */
579
+ const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
580
+ const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
581
+ /**
582
+ * Creates an employee, or updates one (with `id`). On update Shopwave only
583
+ * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
584
+ * email can't be changed, and the echo only carries the updated fields — re-read
585
+ * with `fetchEmployee` if you need the stored record.
586
+ */
587
+ const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
588
+ /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
589
+ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
590
+ const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
591
+ const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
592
+
282
593
  //#endregion
283
594
  //#region src/core/basket.ts
284
595
  const DEFAULT_BASKET_FILTERS = {
@@ -429,50 +740,127 @@ function toMessage(e) {
429
740
  if (e instanceof Error) return e.message;
430
741
  return String(e);
431
742
  }
743
+ function toStatus(e) {
744
+ return e instanceof ShopwaveApiError ? e.status : null;
745
+ }
746
+ function queryReducer(state, action) {
747
+ switch (action.type) {
748
+ case "start":
749
+ if (action.key !== state.key) return {
750
+ data: null,
751
+ key: action.key,
752
+ fetching: true,
753
+ error: null,
754
+ errorStatus: null
755
+ };
756
+ return {
757
+ ...state,
758
+ fetching: true,
759
+ error: null,
760
+ errorStatus: null
761
+ };
762
+ case "success":
763
+ if (action.key !== state.key) return state;
764
+ return {
765
+ data: action.data,
766
+ key: action.key,
767
+ fetching: false,
768
+ error: null,
769
+ errorStatus: null
770
+ };
771
+ case "failure":
772
+ if (action.key !== state.key) return state;
773
+ return {
774
+ ...state,
775
+ fetching: false,
776
+ error: action.error,
777
+ errorStatus: action.errorStatus
778
+ };
779
+ case "idle": return state.fetching ? {
780
+ ...state,
781
+ fetching: false
782
+ } : state;
783
+ default: return state;
784
+ }
785
+ }
786
+ /** Public view of the internal state for the params currently being rendered. */
787
+ function selectQueryView(state, key, enabled) {
788
+ if (state.key !== key) return {
789
+ data: null,
790
+ loading: enabled,
791
+ fetching: enabled,
792
+ error: null,
793
+ errorStatus: null
794
+ };
795
+ return {
796
+ data: state.data,
797
+ loading: state.fetching && state.data === null,
798
+ fetching: state.fetching,
799
+ error: state.error,
800
+ errorStatus: state.errorStatus
801
+ };
802
+ }
803
+ function depsKey(deps) {
804
+ try {
805
+ return JSON.stringify(deps);
806
+ } catch {
807
+ return String(deps);
808
+ }
809
+ }
432
810
  /**
433
811
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
434
812
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
435
- * on unmount or dep change, and exposes `refetch`.
813
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
814
+ * visible until the new data arrives).
436
815
  *
437
816
  * When `enabled` is false the fetch is skipped (used for hooks that need
438
817
  * arguments before they can run, e.g. consumers-by-id).
439
818
  */
440
819
  function useQuery(fetcher, deps, enabled = true) {
441
- const [data, setData] = (0, react.useState)(null);
442
- const [loading, setLoading] = (0, react.useState)(enabled);
443
- const [error, setError] = (0, react.useState)(null);
820
+ const key = depsKey(deps);
821
+ const [state, dispatch] = (0, react.useReducer)(queryReducer, {
822
+ data: null,
823
+ key: enabled ? key : null,
824
+ fetching: enabled,
825
+ error: null,
826
+ errorStatus: null
827
+ });
444
828
  const [tick, setTick] = (0, react.useState)(0);
445
829
  const refetch = (0, react.useCallback)(() => setTick((t) => t + 1), []);
446
830
  (0, react.useEffect)(() => {
447
831
  if (!enabled) {
448
- setLoading(false);
832
+ dispatch({ type: "idle" });
449
833
  return;
450
834
  }
451
835
  const controller = new AbortController();
452
- let active = true;
453
- setData(null);
454
- setError(null);
455
- setLoading(true);
836
+ dispatch({
837
+ type: "start",
838
+ key
839
+ });
456
840
  fetcher(controller.signal).then((result) => {
457
- if (active) setData(result);
841
+ if (!controller.signal.aborted) dispatch({
842
+ type: "success",
843
+ key,
844
+ data: result
845
+ });
458
846
  }).catch((e) => {
459
- if (active && e?.name !== "AbortError") setError(toMessage(e));
460
- }).finally(() => {
461
- if (active) setLoading(false);
847
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
848
+ type: "failure",
849
+ key,
850
+ error: toMessage(e),
851
+ errorStatus: toStatus(e)
852
+ });
462
853
  });
463
854
  return () => {
464
- active = false;
465
855
  controller.abort();
466
856
  };
467
857
  }, [
468
- ...deps,
858
+ key,
469
859
  tick,
470
860
  enabled
471
861
  ]);
472
862
  return {
473
- data,
474
- loading,
475
- error,
863
+ ...selectQueryView(state, key, enabled),
476
864
  refetch
477
865
  };
478
866
  }
@@ -483,16 +871,19 @@ function useMutation(action, deps) {
483
871
  const [data, setData] = (0, react.useState)(null);
484
872
  const [loading, setLoading] = (0, react.useState)(false);
485
873
  const [error, setError] = (0, react.useState)(null);
874
+ const [errorStatus, setErrorStatus] = (0, react.useState)(null);
486
875
  return {
487
876
  mutate: (0, react.useCallback)(async (...args) => {
488
877
  setLoading(true);
489
878
  setError(null);
879
+ setErrorStatus(null);
490
880
  try {
491
881
  const result = await action(...args);
492
882
  setData(result);
493
883
  return result;
494
884
  } catch (e) {
495
885
  setError(toMessage(e));
886
+ setErrorStatus(toStatus(e));
496
887
  return null;
497
888
  } finally {
498
889
  setLoading(false);
@@ -500,7 +891,8 @@ function useMutation(action, deps) {
500
891
  }, deps),
501
892
  data,
502
893
  loading,
503
- error
894
+ error,
895
+ errorStatus
504
896
  };
505
897
  }
506
898
 
@@ -682,6 +1074,23 @@ function useLogout(options) {
682
1074
  return useMutation(() => logout(options), [options?.baseUrl, options?.token]);
683
1075
  }
684
1076
 
1077
+ //#endregion
1078
+ //#region src/hooks/useSession.ts
1079
+ /**
1080
+ * Current login status (`{ loggedIn, expiresAt }`) from `GET /api/session`.
1081
+ * Tokens are never exposed to the browser.
1082
+ *
1083
+ * @example
1084
+ * const { data: session, loading } = useSession();
1085
+ * if (!loading && !session?.loggedIn) window.location.href = loginPath(location.pathname);
1086
+ */
1087
+ function useSession(options) {
1088
+ return useQuery((signal) => fetchSession({
1089
+ ...options,
1090
+ signal
1091
+ }), [options?.baseUrl]);
1092
+ }
1093
+
685
1094
  //#endregion
686
1095
  //#region src/hooks/useBasketReport.ts
687
1096
  /**
@@ -789,30 +1198,60 @@ function useBasketReport(options) {
789
1198
  exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
790
1199
  exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
791
1200
  exports.PromotionRuleSet = PromotionRuleSet;
1201
+ exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
1202
+ exports.ShopwaveApiError = ShopwaveApiError;
792
1203
  exports.apiDelete = apiDelete;
793
1204
  exports.apiGet = apiGet;
1205
+ exports.apiRequest = apiRequest;
794
1206
  exports.apiSend = apiSend;
1207
+ exports.assertNoApiErrors = assertNoApiErrors;
1208
+ exports.authorizationFor = authorizationFor;
795
1209
  exports.buildBasketReportQuery = buildBasketReportQuery;
796
1210
  exports.buildConsumerMap = buildConsumerMap;
797
1211
  exports.collectConsumerIds = collectConsumerIds;
798
1212
  exports.collectProductIds = collectProductIds;
799
1213
  exports.combineBasketRows = combineBasketRows;
800
1214
  exports.computeBasketSummary = computeBasketSummary;
1215
+ exports.deleteCategory = deleteCategory;
1216
+ exports.deleteEmployee = deleteEmployee;
801
1217
  exports.deleteEntity = deleteEntity;
1218
+ exports.deleteEntityById = deleteEntityById;
1219
+ exports.deleteProduct = deleteProduct;
1220
+ exports.deletePromotion = deletePromotion;
1221
+ exports.deleteStore = deleteStore;
802
1222
  exports.fetchCategories = fetchCategories;
1223
+ exports.fetchCategory = fetchCategory;
1224
+ exports.fetchConsumer = fetchConsumer;
803
1225
  exports.fetchConsumers = fetchConsumers;
1226
+ exports.fetchEmployee = fetchEmployee;
804
1227
  exports.fetchEmployees = fetchEmployees;
1228
+ exports.fetchEntityById = fetchEntityById;
1229
+ exports.fetchProduct = fetchProduct;
805
1230
  exports.fetchProducts = fetchProducts;
806
1231
  exports.fetchProductsMap = fetchProductsMap;
1232
+ exports.fetchPromotion = fetchPromotion;
807
1233
  exports.fetchPromotions = fetchPromotions;
808
1234
  exports.fetchReport = fetchReport;
1235
+ exports.fetchSession = fetchSession;
1236
+ exports.fetchStore = fetchStore;
809
1237
  exports.fetchStores = fetchStores;
810
1238
  exports.formatDateForQuery = formatDateForQuery;
1239
+ exports.getApiErrorMap = getApiErrorMap;
811
1240
  exports.getApiErrors = getApiErrors;
1241
+ exports.loginPath = loginPath;
812
1242
  exports.logout = logout;
1243
+ exports.logoutPath = logoutPath;
813
1244
  exports.parseBasketReportData = parseBasketReportData;
814
1245
  exports.rolesById = rolesById;
1246
+ exports.saveCategory = saveCategory;
1247
+ exports.saveEmployee = saveEmployee;
1248
+ exports.saveEntities = saveEntities;
1249
+ exports.saveEntity = saveEntity;
1250
+ exports.saveProduct = saveProduct;
1251
+ exports.savePromotion = savePromotion;
1252
+ exports.saveStore = saveStore;
815
1253
  exports.submitEntity = submitEntity;
1254
+ exports.toShopwaveApiError = toShopwaveApiError;
816
1255
  exports.useBasketReport = useBasketReport;
817
1256
  exports.useCategory = useCategory;
818
1257
  exports.useConsumer = useConsumer;
@@ -822,6 +1261,8 @@ exports.useLogout = useLogout;
822
1261
  exports.useProduct = useProduct;
823
1262
  exports.usePromotion = usePromotion;
824
1263
  exports.useReport = useReport;
1264
+ exports.useSession = useSession;
825
1265
  exports.useStore = useStore;
826
1266
  exports.useSubmit = useSubmit;
1267
+ exports.withToken = withToken;
827
1268
  //# sourceMappingURL=index.cjs.map