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.
@@ -1,88 +1,179 @@
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
  }
17
- /**
18
- * Returns a JSON string of API errors if the response carries any, otherwise
19
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
20
- */
21
- function getApiErrors(api) {
22
- const errors = api?.message?.errors;
23
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
24
- return null;
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
+ async function send(path, init, options) {
117
+ const doFetch = resolveFetch(options);
118
+ const url = (options.baseUrl ?? "") + path;
119
+ let response;
120
+ try {
121
+ response = await doFetch(url, {
122
+ ...init,
123
+ signal: options.signal
124
+ });
125
+ } catch (error) {
126
+ if (error?.name === "AbortError") throw error;
127
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
128
+ }
129
+ const body = await readBody(response);
130
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
131
+ return {
132
+ status: response.status,
133
+ body
134
+ };
25
135
  }
26
136
  /**
27
- * GET helper. Sends request metadata via the `extras` header exactly like the
28
- * original hooks did, parses JSON, and returns the typed payload.
137
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
138
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
29
139
  */
30
140
  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, {
141
+ const { body } = await send(path, {
39
142
  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();
143
+ headers: {
144
+ ...baseHeaders(options),
145
+ extras: JSON.stringify(buildExtras(extras))
146
+ }
147
+ }, options);
148
+ return body;
45
149
  }
46
150
  /**
47
- * JSON body helper for POST/PUT mutations.
151
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
152
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
48
153
  */
49
154
  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, {
155
+ const { body } = await send(path, {
60
156
  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();
157
+ headers: {
158
+ ...baseHeaders(options),
159
+ "Content-Type": "application/json"
160
+ },
161
+ body: JSON.stringify(payload)
162
+ }, options);
163
+ return body;
67
164
  }
68
165
  /**
69
- * DELETE helper.
166
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
167
+ * does come back and carries API errors, they are thrown. Returns the parsed
168
+ * body (`null` when empty).
70
169
  */
71
170
  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, {
171
+ const { status, body } = await send(path, {
81
172
  method: "DELETE",
82
- headers,
83
- signal: options.signal
84
- });
85
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
173
+ headers: baseHeaders(options)
174
+ }, options);
175
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
176
+ return body;
86
177
  }
87
178
 
88
179
  //#endregion
@@ -94,11 +185,9 @@ async function apiDelete(path, options = {}) {
94
185
  async function fetchCategories(params = {}, options = {}) {
95
186
  const extras = { deleted: params.deleted ?? false };
96
187
  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 ?? {});
188
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
189
+ assertNoApiErrors(json, 200);
190
+ return Object.values(json?.categories ?? {});
102
191
  }
103
192
 
104
193
  //#endregion
@@ -110,9 +199,8 @@ async function fetchCategories(params = {}, options = {}) {
110
199
  async function fetchConsumers(consumerIds, options = {}) {
111
200
  if (!consumerIds || consumerIds.length === 0) return [];
112
201
  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 ?? {});
202
+ assertNoApiErrors(json, 200);
203
+ return Object.values(json?.consumers ?? {});
116
204
  }
117
205
 
118
206
  //#endregion
@@ -123,10 +211,9 @@ async function fetchConsumers(consumerIds, options = {}) {
123
211
  async function fetchEmployees(params = {}, options = {}) {
124
212
  const extras = { deleted: params.deleted ?? false };
125
213
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
126
- const json = await apiGet("/api/employees", extras, options);
127
- const error = getApiErrors(json.api);
128
- if (error) throw new Error(error);
129
- return Object.values(json.employees ?? {});
214
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
215
+ assertNoApiErrors(json, 200);
216
+ return Object.values(json?.employees ?? {});
130
217
  }
131
218
 
132
219
  //#endregion
@@ -138,11 +225,9 @@ async function fetchProducts(params = {}, options = {}) {
138
225
  const extras = { deleted: params.deleted ?? false };
139
226
  if (params.productIds) extras.productIds = params.productIds;
140
227
  if (params.storeId) extras.storeId = params.storeId;
141
- if (params.token) extras.token = params.token;
142
- const json = await apiGet("/api/products", extras, options);
143
- const error = getApiErrors(json.api);
144
- if (error) throw new Error(error);
145
- return Object.values(json.products ?? {});
228
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
229
+ assertNoApiErrors(json, 200);
230
+ return Object.values(json?.products ?? {});
146
231
  }
147
232
  /**
148
233
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -171,11 +256,9 @@ async function fetchPromotions(params = {}, options = {}) {
171
256
  if (params.imminent !== void 0) extras.imminent = params.imminent;
172
257
  if (params.active !== void 0) extras.active = params.active;
173
258
  if (params.expired !== void 0) extras.expired = params.expired;
174
- if (params.token) extras.token = params.token;
175
- const json = await apiGet("/api/promotions", extras, options);
176
- const error = getApiErrors(json.api);
177
- if (error) throw new Error(error);
178
- return Object.values(json.promotions ?? {});
259
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
260
+ assertNoApiErrors(json, 200);
261
+ return Object.values(json?.promotions ?? {});
179
262
  }
180
263
 
181
264
  //#endregion
@@ -184,10 +267,11 @@ async function fetchPromotions(params = {}, options = {}) {
184
267
  * Fetches stores. Returns the flattened `Store[]`.
185
268
  */
186
269
  async function fetchStores(params = {}, options = {}) {
187
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
188
- const error = getApiErrors(json.api);
189
- if (error) throw new Error(error);
190
- return Object.values(json.stores ?? {});
270
+ const extras = { deleted: params.deleted ?? false };
271
+ if (params.storeIds) extras.storeIds = params.storeIds;
272
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
273
+ assertNoApiErrors(json, 200);
274
+ return Object.values(json?.stores ?? {});
191
275
  }
192
276
 
193
277
  //#endregion
@@ -199,9 +283,8 @@ async function fetchStores(params = {}, options = {}) {
199
283
  */
200
284
  async function fetchReport(query, options = {}) {
201
285
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
202
- const error = getApiErrors(json.api);
203
- if (error) throw new Error(error);
204
- return json.reports ?? {};
286
+ assertNoApiErrors(json, 200);
287
+ return json?.reports ?? {};
205
288
  }
206
289
 
207
290
  //#endregion
@@ -245,10 +328,12 @@ async function logout(options = {}) {
245
328
  //#endregion
246
329
  //#region src/core/entity.ts
247
330
  /**
248
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
249
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
250
- * UI concern is deliberately left to the hooks/host layer so core stays
251
- * dependency-free.
331
+ * Low-level, untyped mutations: the caller supplies the route and payload.
332
+ *
333
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
334
+ * …): they know each route, build the request envelope, check the response and
335
+ * return the saved entity with its id. Keep these for routes that have no typed
336
+ * function yet (e.g. `/api/merchant`).
252
337
  */
253
338
  /**
254
339
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -258,11 +343,15 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
258
343
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
259
344
  }
260
345
  /**
261
- * Sends a create/update mutation and returns the parsed `result` field
262
- * (matching the original `result.result` access).
346
+ * Sends a create/update mutation. Returns the response's `result` field when it
347
+ * has one (older app routes wrapped their answer that way), otherwise the whole
348
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
263
349
  */
264
350
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
265
- return (await apiSend(endpoint, method, payload, options)).result;
351
+ const json = await apiSend(endpoint, method, payload, options);
352
+ assertNoApiErrors(json, 200);
353
+ if (json && typeof json === "object" && "result" in json) return json.result;
354
+ return json ?? void 0;
266
355
  }
267
356
 
268
357
  //#endregion
@@ -415,50 +504,127 @@ function toMessage(e) {
415
504
  if (e instanceof Error) return e.message;
416
505
  return String(e);
417
506
  }
507
+ function toStatus(e) {
508
+ return e instanceof ShopwaveApiError ? e.status : null;
509
+ }
510
+ function queryReducer(state, action) {
511
+ switch (action.type) {
512
+ case "start":
513
+ if (action.key !== state.key) return {
514
+ data: null,
515
+ key: action.key,
516
+ fetching: true,
517
+ error: null,
518
+ errorStatus: null
519
+ };
520
+ return {
521
+ ...state,
522
+ fetching: true,
523
+ error: null,
524
+ errorStatus: null
525
+ };
526
+ case "success":
527
+ if (action.key !== state.key) return state;
528
+ return {
529
+ data: action.data,
530
+ key: action.key,
531
+ fetching: false,
532
+ error: null,
533
+ errorStatus: null
534
+ };
535
+ case "failure":
536
+ if (action.key !== state.key) return state;
537
+ return {
538
+ ...state,
539
+ fetching: false,
540
+ error: action.error,
541
+ errorStatus: action.errorStatus
542
+ };
543
+ case "idle": return state.fetching ? {
544
+ ...state,
545
+ fetching: false
546
+ } : state;
547
+ default: return state;
548
+ }
549
+ }
550
+ /** Public view of the internal state for the params currently being rendered. */
551
+ function selectQueryView(state, key, enabled) {
552
+ if (state.key !== key) return {
553
+ data: null,
554
+ loading: enabled,
555
+ fetching: enabled,
556
+ error: null,
557
+ errorStatus: null
558
+ };
559
+ return {
560
+ data: state.data,
561
+ loading: state.fetching && state.data === null,
562
+ fetching: state.fetching,
563
+ error: state.error,
564
+ errorStatus: state.errorStatus
565
+ };
566
+ }
567
+ function depsKey(deps) {
568
+ try {
569
+ return JSON.stringify(deps);
570
+ } catch {
571
+ return String(deps);
572
+ }
573
+ }
418
574
  /**
419
575
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
420
576
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
421
- * on unmount or dep change, and exposes `refetch`.
577
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
578
+ * visible until the new data arrives).
422
579
  *
423
580
  * When `enabled` is false the fetch is skipped (used for hooks that need
424
581
  * arguments before they can run, e.g. consumers-by-id).
425
582
  */
426
583
  function useQuery(fetcher, deps, enabled = true) {
427
- const [data, setData] = useState(null);
428
- const [loading, setLoading] = useState(enabled);
429
- const [error, setError] = useState(null);
584
+ const key = depsKey(deps);
585
+ const [state, dispatch] = useReducer(queryReducer, {
586
+ data: null,
587
+ key: enabled ? key : null,
588
+ fetching: enabled,
589
+ error: null,
590
+ errorStatus: null
591
+ });
430
592
  const [tick, setTick] = useState(0);
431
593
  const refetch = useCallback(() => setTick((t) => t + 1), []);
432
594
  useEffect(() => {
433
595
  if (!enabled) {
434
- setLoading(false);
596
+ dispatch({ type: "idle" });
435
597
  return;
436
598
  }
437
599
  const controller = new AbortController();
438
- let active = true;
439
- setData(null);
440
- setError(null);
441
- setLoading(true);
600
+ dispatch({
601
+ type: "start",
602
+ key
603
+ });
442
604
  fetcher(controller.signal).then((result) => {
443
- if (active) setData(result);
605
+ if (!controller.signal.aborted) dispatch({
606
+ type: "success",
607
+ key,
608
+ data: result
609
+ });
444
610
  }).catch((e) => {
445
- if (active && e?.name !== "AbortError") setError(toMessage(e));
446
- }).finally(() => {
447
- if (active) setLoading(false);
611
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
612
+ type: "failure",
613
+ key,
614
+ error: toMessage(e),
615
+ errorStatus: toStatus(e)
616
+ });
448
617
  });
449
618
  return () => {
450
- active = false;
451
619
  controller.abort();
452
620
  };
453
621
  }, [
454
- ...deps,
622
+ key,
455
623
  tick,
456
624
  enabled
457
625
  ]);
458
626
  return {
459
- data,
460
- loading,
461
- error,
627
+ ...selectQueryView(state, key, enabled),
462
628
  refetch
463
629
  };
464
630
  }
@@ -469,16 +635,19 @@ function useMutation(action, deps) {
469
635
  const [data, setData] = useState(null);
470
636
  const [loading, setLoading] = useState(false);
471
637
  const [error, setError] = useState(null);
638
+ const [errorStatus, setErrorStatus] = useState(null);
472
639
  return {
473
640
  mutate: useCallback(async (...args) => {
474
641
  setLoading(true);
475
642
  setError(null);
643
+ setErrorStatus(null);
476
644
  try {
477
645
  const result = await action(...args);
478
646
  setData(result);
479
647
  return result;
480
648
  } catch (e) {
481
649
  setError(toMessage(e));
650
+ setErrorStatus(toStatus(e));
482
651
  return null;
483
652
  } finally {
484
653
  setLoading(false);
@@ -486,7 +655,8 @@ function useMutation(action, deps) {
486
655
  }, deps),
487
656
  data,
488
657
  loading,
489
- error
658
+ error,
659
+ errorStatus
490
660
  };
491
661
  }
492
662