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