react-shopwave-connect 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -22
- package/dist/core/index.cjs +392 -88
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +214 -23
- package/dist/core/index.d.ts +214 -23
- package/dist/core/index.js +365 -89
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +281 -111
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +34 -11
- package/dist/hooks/index.d.ts +34 -11
- package/dist/hooks/index.js +282 -112
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +492 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +227 -24
- package/dist/index.d.ts +227 -24
- package/dist/index.js +466 -109
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +436 -2
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +169 -1
- package/dist/next/index.d.ts +169 -1
- package/dist/next/index.js +434 -3
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +411 -1
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +170 -1
- package/dist/server/index.d.ts +170 -1
- package/dist/server/index.js +404 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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`.
|
|
152
|
+
* `null`. Kept for backwards compatibility; prefer `ShopwaveApiError`.
|
|
20
153
|
*/
|
|
21
154
|
function getApiErrors(api) {
|
|
22
|
-
const errors = api
|
|
23
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,9 +319,8 @@ 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
|
-
|
|
217
|
-
|
|
218
|
-
return json.reports ?? {};
|
|
322
|
+
assertNoApiErrors(json, 200);
|
|
323
|
+
return json?.reports ?? {};
|
|
219
324
|
}
|
|
220
325
|
|
|
221
326
|
//#endregion
|
|
@@ -270,10 +375,12 @@ async function logout(options = {}) {
|
|
|
270
375
|
//#endregion
|
|
271
376
|
//#region src/core/entity.ts
|
|
272
377
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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`).
|
|
277
384
|
*/
|
|
278
385
|
/**
|
|
279
386
|
* Deletes `/api/{resourcePath}/{entityId}`.
|
|
@@ -283,13 +390,182 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
|
|
|
283
390
|
await apiDelete(`/api/${resourcePath}/${entityId}`, options);
|
|
284
391
|
}
|
|
285
392
|
/**
|
|
286
|
-
* Sends a create/update mutation
|
|
287
|
-
* (
|
|
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.
|
|
288
396
|
*/
|
|
289
397
|
async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
|
|
290
|
-
|
|
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;
|
|
291
402
|
}
|
|
292
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
|
+
|
|
293
569
|
//#endregion
|
|
294
570
|
//#region src/core/basket.ts
|
|
295
571
|
const DEFAULT_BASKET_FILTERS = {
|
|
@@ -440,50 +716,127 @@ function toMessage(e) {
|
|
|
440
716
|
if (e instanceof Error) return e.message;
|
|
441
717
|
return String(e);
|
|
442
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
|
+
}
|
|
443
786
|
/**
|
|
444
787
|
* Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
|
|
445
788
|
* whenever `deps` change, tracks loading/error/data, cancels in-flight requests
|
|
446
|
-
* 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).
|
|
447
791
|
*
|
|
448
792
|
* When `enabled` is false the fetch is skipped (used for hooks that need
|
|
449
793
|
* arguments before they can run, e.g. consumers-by-id).
|
|
450
794
|
*/
|
|
451
795
|
function useQuery(fetcher, deps, enabled = true) {
|
|
452
|
-
const
|
|
453
|
-
const [
|
|
454
|
-
|
|
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
|
+
});
|
|
455
804
|
const [tick, setTick] = useState(0);
|
|
456
805
|
const refetch = useCallback(() => setTick((t) => t + 1), []);
|
|
457
806
|
useEffect(() => {
|
|
458
807
|
if (!enabled) {
|
|
459
|
-
|
|
808
|
+
dispatch({ type: "idle" });
|
|
460
809
|
return;
|
|
461
810
|
}
|
|
462
811
|
const controller = new AbortController();
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
812
|
+
dispatch({
|
|
813
|
+
type: "start",
|
|
814
|
+
key
|
|
815
|
+
});
|
|
467
816
|
fetcher(controller.signal).then((result) => {
|
|
468
|
-
if (
|
|
817
|
+
if (!controller.signal.aborted) dispatch({
|
|
818
|
+
type: "success",
|
|
819
|
+
key,
|
|
820
|
+
data: result
|
|
821
|
+
});
|
|
469
822
|
}).catch((e) => {
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
823
|
+
if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
|
|
824
|
+
type: "failure",
|
|
825
|
+
key,
|
|
826
|
+
error: toMessage(e),
|
|
827
|
+
errorStatus: toStatus(e)
|
|
828
|
+
});
|
|
473
829
|
});
|
|
474
830
|
return () => {
|
|
475
|
-
active = false;
|
|
476
831
|
controller.abort();
|
|
477
832
|
};
|
|
478
833
|
}, [
|
|
479
|
-
|
|
834
|
+
key,
|
|
480
835
|
tick,
|
|
481
836
|
enabled
|
|
482
837
|
]);
|
|
483
838
|
return {
|
|
484
|
-
|
|
485
|
-
loading,
|
|
486
|
-
error,
|
|
839
|
+
...selectQueryView(state, key, enabled),
|
|
487
840
|
refetch
|
|
488
841
|
};
|
|
489
842
|
}
|
|
@@ -494,16 +847,19 @@ function useMutation(action, deps) {
|
|
|
494
847
|
const [data, setData] = useState(null);
|
|
495
848
|
const [loading, setLoading] = useState(false);
|
|
496
849
|
const [error, setError] = useState(null);
|
|
850
|
+
const [errorStatus, setErrorStatus] = useState(null);
|
|
497
851
|
return {
|
|
498
852
|
mutate: useCallback(async (...args) => {
|
|
499
853
|
setLoading(true);
|
|
500
854
|
setError(null);
|
|
855
|
+
setErrorStatus(null);
|
|
501
856
|
try {
|
|
502
857
|
const result = await action(...args);
|
|
503
858
|
setData(result);
|
|
504
859
|
return result;
|
|
505
860
|
} catch (e) {
|
|
506
861
|
setError(toMessage(e));
|
|
862
|
+
setErrorStatus(toStatus(e));
|
|
507
863
|
return null;
|
|
508
864
|
} finally {
|
|
509
865
|
setLoading(false);
|
|
@@ -511,7 +867,8 @@ function useMutation(action, deps) {
|
|
|
511
867
|
}, deps),
|
|
512
868
|
data,
|
|
513
869
|
loading,
|
|
514
|
-
error
|
|
870
|
+
error,
|
|
871
|
+
errorStatus
|
|
515
872
|
};
|
|
516
873
|
}
|
|
517
874
|
|
|
@@ -814,5 +1171,5 @@ function useBasketReport(options) {
|
|
|
814
1171
|
}
|
|
815
1172
|
|
|
816
1173
|
//#endregion
|
|
817
|
-
export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, PromotionRuleSet, apiDelete, apiGet, apiSend, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, deleteEntity, fetchCategories, fetchConsumers, fetchEmployees, fetchProducts, fetchProductsMap, fetchPromotions, fetchReport, fetchSession, fetchStores, formatDateForQuery, getApiErrors, loginPath, logout, logoutPath, parseBasketReportData, rolesById, submitEntity, useBasketReport, useCategory, useConsumer, useDelete, useEmployee, useLogout, useProduct, usePromotion, useReport, useSession, 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 };
|
|
818
1175
|
//# sourceMappingURL=index.js.map
|