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.
@@ -24,89 +24,180 @@ 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
  }
41
- /**
42
- * Returns a JSON string of API errors if the response carries any, otherwise
43
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
44
- */
45
- function getApiErrors(api) {
46
- const errors = api?.message?.errors;
47
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
48
- return null;
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
+ async function send(path, init, options) {
141
+ const doFetch = resolveFetch(options);
142
+ const url = (options.baseUrl ?? "") + path;
143
+ let response;
144
+ try {
145
+ response = await doFetch(url, {
146
+ ...init,
147
+ signal: options.signal
148
+ });
149
+ } catch (error) {
150
+ if (error?.name === "AbortError") throw error;
151
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
152
+ }
153
+ const body = await readBody(response);
154
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
155
+ return {
156
+ status: response.status,
157
+ body
158
+ };
49
159
  }
50
160
  /**
51
- * GET helper. Sends request metadata via the `extras` header exactly like the
52
- * original hooks did, parses JSON, and returns the typed payload.
161
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
162
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
53
163
  */
54
164
  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, {
165
+ const { body } = await send(path, {
63
166
  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();
167
+ headers: {
168
+ ...baseHeaders(options),
169
+ extras: JSON.stringify(buildExtras(extras))
170
+ }
171
+ }, options);
172
+ return body;
69
173
  }
70
174
  /**
71
- * JSON body helper for POST/PUT mutations.
175
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
176
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
72
177
  */
73
178
  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, {
179
+ const { body } = await send(path, {
84
180
  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();
181
+ headers: {
182
+ ...baseHeaders(options),
183
+ "Content-Type": "application/json"
184
+ },
185
+ body: JSON.stringify(payload)
186
+ }, options);
187
+ return body;
91
188
  }
92
189
  /**
93
- * DELETE helper.
190
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
191
+ * does come back and carries API errors, they are thrown. Returns the parsed
192
+ * body (`null` when empty).
94
193
  */
95
194
  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, {
195
+ const { status, body } = await send(path, {
105
196
  method: "DELETE",
106
- headers,
107
- signal: options.signal
108
- });
109
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
197
+ headers: baseHeaders(options)
198
+ }, options);
199
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
200
+ return body;
110
201
  }
111
202
 
112
203
  //#endregion
@@ -118,11 +209,9 @@ async function apiDelete(path, options = {}) {
118
209
  async function fetchCategories(params = {}, options = {}) {
119
210
  const extras = { deleted: params.deleted ?? false };
120
211
  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 ?? {});
212
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
213
+ assertNoApiErrors(json, 200);
214
+ return Object.values(json?.categories ?? {});
126
215
  }
127
216
 
128
217
  //#endregion
@@ -134,9 +223,8 @@ async function fetchCategories(params = {}, options = {}) {
134
223
  async function fetchConsumers(consumerIds, options = {}) {
135
224
  if (!consumerIds || consumerIds.length === 0) return [];
136
225
  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 ?? {});
226
+ assertNoApiErrors(json, 200);
227
+ return Object.values(json?.consumers ?? {});
140
228
  }
141
229
 
142
230
  //#endregion
@@ -147,10 +235,9 @@ async function fetchConsumers(consumerIds, options = {}) {
147
235
  async function fetchEmployees(params = {}, options = {}) {
148
236
  const extras = { deleted: params.deleted ?? false };
149
237
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
150
- const json = await apiGet("/api/employees", extras, options);
151
- const error = getApiErrors(json.api);
152
- if (error) throw new Error(error);
153
- return Object.values(json.employees ?? {});
238
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
239
+ assertNoApiErrors(json, 200);
240
+ return Object.values(json?.employees ?? {});
154
241
  }
155
242
 
156
243
  //#endregion
@@ -162,11 +249,9 @@ async function fetchProducts(params = {}, options = {}) {
162
249
  const extras = { deleted: params.deleted ?? false };
163
250
  if (params.productIds) extras.productIds = params.productIds;
164
251
  if (params.storeId) extras.storeId = params.storeId;
165
- if (params.token) extras.token = params.token;
166
- const json = await apiGet("/api/products", extras, options);
167
- const error = getApiErrors(json.api);
168
- if (error) throw new Error(error);
169
- return Object.values(json.products ?? {});
252
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
253
+ assertNoApiErrors(json, 200);
254
+ return Object.values(json?.products ?? {});
170
255
  }
171
256
  /**
172
257
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -195,11 +280,9 @@ async function fetchPromotions(params = {}, options = {}) {
195
280
  if (params.imminent !== void 0) extras.imminent = params.imminent;
196
281
  if (params.active !== void 0) extras.active = params.active;
197
282
  if (params.expired !== void 0) extras.expired = params.expired;
198
- if (params.token) extras.token = params.token;
199
- const json = await apiGet("/api/promotions", extras, options);
200
- const error = getApiErrors(json.api);
201
- if (error) throw new Error(error);
202
- return Object.values(json.promotions ?? {});
283
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
284
+ assertNoApiErrors(json, 200);
285
+ return Object.values(json?.promotions ?? {});
203
286
  }
204
287
 
205
288
  //#endregion
@@ -208,10 +291,11 @@ async function fetchPromotions(params = {}, options = {}) {
208
291
  * Fetches stores. Returns the flattened `Store[]`.
209
292
  */
210
293
  async function fetchStores(params = {}, options = {}) {
211
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
212
- const error = getApiErrors(json.api);
213
- if (error) throw new Error(error);
214
- return Object.values(json.stores ?? {});
294
+ const extras = { deleted: params.deleted ?? false };
295
+ if (params.storeIds) extras.storeIds = params.storeIds;
296
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
297
+ assertNoApiErrors(json, 200);
298
+ return Object.values(json?.stores ?? {});
215
299
  }
216
300
 
217
301
  //#endregion
@@ -223,9 +307,8 @@ async function fetchStores(params = {}, options = {}) {
223
307
  */
224
308
  async function fetchReport(query, options = {}) {
225
309
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
226
- const error = getApiErrors(json.api);
227
- if (error) throw new Error(error);
228
- return json.reports ?? {};
310
+ assertNoApiErrors(json, 200);
311
+ return json?.reports ?? {};
229
312
  }
230
313
 
231
314
  //#endregion
@@ -269,10 +352,12 @@ async function logout(options = {}) {
269
352
  //#endregion
270
353
  //#region src/core/entity.ts
271
354
  /**
272
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
273
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
274
- * UI concern is deliberately left to the hooks/host layer so core stays
275
- * dependency-free.
355
+ * Low-level, untyped mutations: the caller supplies the route and payload.
356
+ *
357
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
358
+ * …): they know each route, build the request envelope, check the response and
359
+ * return the saved entity with its id. Keep these for routes that have no typed
360
+ * function yet (e.g. `/api/merchant`).
276
361
  */
277
362
  /**
278
363
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -282,11 +367,15 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
282
367
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
283
368
  }
284
369
  /**
285
- * Sends a create/update mutation and returns the parsed `result` field
286
- * (matching the original `result.result` access).
370
+ * Sends a create/update mutation. Returns the response's `result` field when it
371
+ * has one (older app routes wrapped their answer that way), otherwise the whole
372
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
287
373
  */
288
374
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
289
- return (await apiSend(endpoint, method, payload, options)).result;
375
+ const json = await apiSend(endpoint, method, payload, options);
376
+ assertNoApiErrors(json, 200);
377
+ if (json && typeof json === "object" && "result" in json) return json.result;
378
+ return json ?? void 0;
290
379
  }
291
380
 
292
381
  //#endregion
@@ -439,50 +528,127 @@ function toMessage(e) {
439
528
  if (e instanceof Error) return e.message;
440
529
  return String(e);
441
530
  }
531
+ function toStatus(e) {
532
+ return e instanceof ShopwaveApiError ? e.status : null;
533
+ }
534
+ function queryReducer(state, action) {
535
+ switch (action.type) {
536
+ case "start":
537
+ if (action.key !== state.key) return {
538
+ data: null,
539
+ key: action.key,
540
+ fetching: true,
541
+ error: null,
542
+ errorStatus: null
543
+ };
544
+ return {
545
+ ...state,
546
+ fetching: true,
547
+ error: null,
548
+ errorStatus: null
549
+ };
550
+ case "success":
551
+ if (action.key !== state.key) return state;
552
+ return {
553
+ data: action.data,
554
+ key: action.key,
555
+ fetching: false,
556
+ error: null,
557
+ errorStatus: null
558
+ };
559
+ case "failure":
560
+ if (action.key !== state.key) return state;
561
+ return {
562
+ ...state,
563
+ fetching: false,
564
+ error: action.error,
565
+ errorStatus: action.errorStatus
566
+ };
567
+ case "idle": return state.fetching ? {
568
+ ...state,
569
+ fetching: false
570
+ } : state;
571
+ default: return state;
572
+ }
573
+ }
574
+ /** Public view of the internal state for the params currently being rendered. */
575
+ function selectQueryView(state, key, enabled) {
576
+ if (state.key !== key) return {
577
+ data: null,
578
+ loading: enabled,
579
+ fetching: enabled,
580
+ error: null,
581
+ errorStatus: null
582
+ };
583
+ return {
584
+ data: state.data,
585
+ loading: state.fetching && state.data === null,
586
+ fetching: state.fetching,
587
+ error: state.error,
588
+ errorStatus: state.errorStatus
589
+ };
590
+ }
591
+ function depsKey(deps) {
592
+ try {
593
+ return JSON.stringify(deps);
594
+ } catch {
595
+ return String(deps);
596
+ }
597
+ }
442
598
  /**
443
599
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
444
600
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
445
- * on unmount or dep change, and exposes `refetch`.
601
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
602
+ * visible until the new data arrives).
446
603
  *
447
604
  * When `enabled` is false the fetch is skipped (used for hooks that need
448
605
  * arguments before they can run, e.g. consumers-by-id).
449
606
  */
450
607
  function useQuery(fetcher, deps, enabled = true) {
451
- const [data, setData] = (0, react.useState)(null);
452
- const [loading, setLoading] = (0, react.useState)(enabled);
453
- const [error, setError] = (0, react.useState)(null);
608
+ const key = depsKey(deps);
609
+ const [state, dispatch] = (0, react.useReducer)(queryReducer, {
610
+ data: null,
611
+ key: enabled ? key : null,
612
+ fetching: enabled,
613
+ error: null,
614
+ errorStatus: null
615
+ });
454
616
  const [tick, setTick] = (0, react.useState)(0);
455
617
  const refetch = (0, react.useCallback)(() => setTick((t) => t + 1), []);
456
618
  (0, react.useEffect)(() => {
457
619
  if (!enabled) {
458
- setLoading(false);
620
+ dispatch({ type: "idle" });
459
621
  return;
460
622
  }
461
623
  const controller = new AbortController();
462
- let active = true;
463
- setData(null);
464
- setError(null);
465
- setLoading(true);
624
+ dispatch({
625
+ type: "start",
626
+ key
627
+ });
466
628
  fetcher(controller.signal).then((result) => {
467
- if (active) setData(result);
629
+ if (!controller.signal.aborted) dispatch({
630
+ type: "success",
631
+ key,
632
+ data: result
633
+ });
468
634
  }).catch((e) => {
469
- if (active && e?.name !== "AbortError") setError(toMessage(e));
470
- }).finally(() => {
471
- if (active) setLoading(false);
635
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
636
+ type: "failure",
637
+ key,
638
+ error: toMessage(e),
639
+ errorStatus: toStatus(e)
640
+ });
472
641
  });
473
642
  return () => {
474
- active = false;
475
643
  controller.abort();
476
644
  };
477
645
  }, [
478
- ...deps,
646
+ key,
479
647
  tick,
480
648
  enabled
481
649
  ]);
482
650
  return {
483
- data,
484
- loading,
485
- error,
651
+ ...selectQueryView(state, key, enabled),
486
652
  refetch
487
653
  };
488
654
  }
@@ -493,16 +659,19 @@ function useMutation(action, deps) {
493
659
  const [data, setData] = (0, react.useState)(null);
494
660
  const [loading, setLoading] = (0, react.useState)(false);
495
661
  const [error, setError] = (0, react.useState)(null);
662
+ const [errorStatus, setErrorStatus] = (0, react.useState)(null);
496
663
  return {
497
664
  mutate: (0, react.useCallback)(async (...args) => {
498
665
  setLoading(true);
499
666
  setError(null);
667
+ setErrorStatus(null);
500
668
  try {
501
669
  const result = await action(...args);
502
670
  setData(result);
503
671
  return result;
504
672
  } catch (e) {
505
673
  setError(toMessage(e));
674
+ setErrorStatus(toStatus(e));
506
675
  return null;
507
676
  } finally {
508
677
  setLoading(false);
@@ -510,7 +679,8 @@ function useMutation(action, deps) {
510
679
  }, deps),
511
680
  data,
512
681
  loading,
513
- error
682
+ error,
683
+ errorStatus
514
684
  };
515
685
  }
516
686