react-shopwave-connect 0.2.0 → 0.4.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 +215 -24
- package/dist/core/index.cjs +903 -88
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +604 -23
- package/dist/core/index.d.ts +604 -23
- package/dist/core/index.js +845 -89
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +735 -109
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +263 -12
- package/dist/hooks/index.d.ts +263 -12
- package/dist/hooks/index.js +732 -111
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +1087 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +670 -24
- package/dist/index.d.ts +670 -24
- package/dist/index.js +1025 -109
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +560 -2
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +249 -1
- package/dist/next/index.d.ts +249 -1
- package/dist/next/index.js +556 -3
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +531 -1
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +258 -1
- package/dist/server/index.d.ts +258 -1
- package/dist/server/index.js +521 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -24,89 +24,202 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
let react = require("react");
|
|
25
25
|
react = __toESM(react);
|
|
26
26
|
|
|
27
|
+
//#region src/core/errors.ts
|
|
28
|
+
/**
|
|
29
|
+
* Thrown by every `core` function when a request fails, either at the HTTP
|
|
30
|
+
* level (`status` is the HTTP status) or because the Shopwave response carried
|
|
31
|
+
* `api.message.errors` (then `status` is the HTTP status of that response,
|
|
32
|
+
* usually 200/201, and `errors` holds the API errors).
|
|
33
|
+
*
|
|
34
|
+
* Branch on `status` (e.g. `isUnauthorized` → send the user to log in) rather
|
|
35
|
+
* than parsing `message`.
|
|
36
|
+
*/
|
|
37
|
+
var ShopwaveApiError = class extends Error {
|
|
38
|
+
/** HTTP status of the response (0 when the request never got a response). */
|
|
39
|
+
status;
|
|
40
|
+
/** Shopwave API errors, when the response carried any. */
|
|
41
|
+
errors;
|
|
42
|
+
/** Parsed response body, when there was one. */
|
|
43
|
+
body;
|
|
44
|
+
constructor(status, message, details = {}) {
|
|
45
|
+
super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
|
|
46
|
+
this.name = "ShopwaveApiError";
|
|
47
|
+
this.status = status;
|
|
48
|
+
this.errors = details.errors ?? null;
|
|
49
|
+
this.body = details.body;
|
|
50
|
+
}
|
|
51
|
+
/** 401, or the Shopwave "token expired / invalid" error 908. */
|
|
52
|
+
get isUnauthorized() {
|
|
53
|
+
if (this.status === 401) return true;
|
|
54
|
+
return !!this.errors && Object.entries(this.errors).some(([k, v]) => k === "908" || Number(v?.id) === 908);
|
|
55
|
+
}
|
|
56
|
+
get isNotFound() {
|
|
57
|
+
return this.status === 404;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** The `api.message.errors` (or `api.message.error`) map of a Shopwave response, or `null` when there are none. */
|
|
61
|
+
function getApiErrorMap(body) {
|
|
62
|
+
const message = body?.api?.message;
|
|
63
|
+
const errors = message?.errors ?? message?.error;
|
|
64
|
+
if (errors && typeof errors === "object" && Object.keys(errors).length > 0) return errors;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function describeErrors(errors) {
|
|
68
|
+
return Object.entries(errors).map(([code, e]) => {
|
|
69
|
+
const text = [e?.title, e?.details].filter(Boolean).join(": ");
|
|
70
|
+
return text ? `${code} ${text}` : code;
|
|
71
|
+
}).join("; ");
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Builds a {@link ShopwaveApiError} for a response. Understands the Shopwave
|
|
75
|
+
* envelope (`api.message.errors`) and the `{ error, message }` bodies returned
|
|
76
|
+
* by the `react-shopwave-connect/next` routes.
|
|
77
|
+
*/
|
|
78
|
+
function toShopwaveApiError(status, body, fallback) {
|
|
79
|
+
const errors = getApiErrorMap(body);
|
|
80
|
+
let detail = errors ? describeErrors(errors) : "";
|
|
81
|
+
if (!detail && body && typeof body === "object") {
|
|
82
|
+
const b = body;
|
|
83
|
+
if (typeof b.message === "string") detail = b.message;
|
|
84
|
+
else if (typeof b.error === "string") detail = b.error;
|
|
85
|
+
}
|
|
86
|
+
if (!detail) detail = fallback ?? (status >= 400 ? "request failed" : "API returned errors");
|
|
87
|
+
return new ShopwaveApiError(status, `Shopwave API error (${status}): ${detail}`, {
|
|
88
|
+
errors,
|
|
89
|
+
body
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Throws a {@link ShopwaveApiError} when the response body carries API errors. */
|
|
93
|
+
function assertNoApiErrors(body, status) {
|
|
94
|
+
if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
27
98
|
//#region src/core/request.ts
|
|
28
99
|
function resolveFetch(options) {
|
|
29
100
|
const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
|
|
30
101
|
if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
|
|
31
102
|
return f;
|
|
32
103
|
}
|
|
33
|
-
|
|
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
|
-
|
|
127
|
+
delete merged.token;
|
|
39
128
|
return merged;
|
|
40
129
|
}
|
|
130
|
+
/** Parses a response body as JSON; `null` for an empty body, `{ raw }` for non-JSON. */
|
|
131
|
+
async function readBody(response) {
|
|
132
|
+
const text = await response.text();
|
|
133
|
+
if (!text) return null;
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text);
|
|
136
|
+
} catch {
|
|
137
|
+
return { raw: text };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Low-level request: resolves `{ status, body }` for 2xx responses and throws
|
|
142
|
+
* {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
|
|
143
|
+
* standard ones (Accept + Authorization).
|
|
144
|
+
*/
|
|
145
|
+
async function apiRequest(path, init, options = {}) {
|
|
146
|
+
return send(path, {
|
|
147
|
+
...init,
|
|
148
|
+
headers: {
|
|
149
|
+
...baseHeaders(options),
|
|
150
|
+
...init.headers
|
|
151
|
+
}
|
|
152
|
+
}, options);
|
|
153
|
+
}
|
|
154
|
+
async function send(path, init, options) {
|
|
155
|
+
const doFetch = resolveFetch(options);
|
|
156
|
+
const url = (options.baseUrl ?? "") + path;
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await doFetch(url, {
|
|
160
|
+
...init,
|
|
161
|
+
signal: options.signal
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error?.name === "AbortError") throw error;
|
|
165
|
+
throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
|
|
166
|
+
}
|
|
167
|
+
const body = await readBody(response);
|
|
168
|
+
if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
|
|
169
|
+
return {
|
|
170
|
+
status: response.status,
|
|
171
|
+
body
|
|
172
|
+
};
|
|
173
|
+
}
|
|
41
174
|
/**
|
|
42
175
|
* Returns a JSON string of API errors if the response carries any, otherwise
|
|
43
|
-
* `null`.
|
|
176
|
+
* `null`. Kept for backwards compatibility; prefer `ShopwaveApiError`.
|
|
44
177
|
*/
|
|
45
178
|
function getApiErrors(api) {
|
|
46
|
-
const errors = api
|
|
47
|
-
|
|
48
|
-
return null;
|
|
179
|
+
const errors = getApiErrorMap({ api });
|
|
180
|
+
return errors ? JSON.stringify(errors) : null;
|
|
49
181
|
}
|
|
50
182
|
/**
|
|
51
|
-
* GET helper. Sends request metadata via the `extras` header
|
|
52
|
-
*
|
|
183
|
+
* GET helper. Sends request metadata via the `extras` header, parses JSON and
|
|
184
|
+
* returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
|
|
53
185
|
*/
|
|
54
186
|
async function apiGet(path, extras, options = {}) {
|
|
55
|
-
const
|
|
56
|
-
const url = (options.baseUrl ?? "") + path;
|
|
57
|
-
const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
|
|
58
|
-
console.log("[API GET]", {
|
|
59
|
-
url,
|
|
60
|
-
headers
|
|
61
|
-
});
|
|
62
|
-
const response = await doFetch(url, {
|
|
187
|
+
const { body } = await send(path, {
|
|
63
188
|
method: "GET",
|
|
64
|
-
headers
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
189
|
+
headers: {
|
|
190
|
+
...baseHeaders(options),
|
|
191
|
+
extras: JSON.stringify(buildExtras(extras))
|
|
192
|
+
}
|
|
193
|
+
}, options);
|
|
194
|
+
return body;
|
|
69
195
|
}
|
|
70
196
|
/**
|
|
71
|
-
* JSON body helper for POST/PUT mutations.
|
|
197
|
+
* JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
|
|
198
|
+
* an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
|
|
72
199
|
*/
|
|
73
200
|
async function apiSend(path, method, payload, options = {}) {
|
|
74
|
-
const
|
|
75
|
-
const url = (options.baseUrl ?? "") + path;
|
|
76
|
-
const headers = { "Content-Type": "application/json" };
|
|
77
|
-
if (options.token != null) headers.token = options.token;
|
|
78
|
-
console.log(`[API ${method}]`, {
|
|
79
|
-
url,
|
|
80
|
-
headers,
|
|
81
|
-
payload
|
|
82
|
-
});
|
|
83
|
-
const response = await doFetch(url, {
|
|
201
|
+
const { body } = await send(path, {
|
|
84
202
|
method,
|
|
85
|
-
headers
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
203
|
+
headers: {
|
|
204
|
+
...baseHeaders(options),
|
|
205
|
+
"Content-Type": "application/json"
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(payload)
|
|
208
|
+
}, options);
|
|
209
|
+
return body;
|
|
91
210
|
}
|
|
92
211
|
/**
|
|
93
|
-
* DELETE helper.
|
|
212
|
+
* DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
|
|
213
|
+
* does come back and carries API errors, they are thrown. Returns the parsed
|
|
214
|
+
* body (`null` when empty).
|
|
94
215
|
*/
|
|
95
216
|
async function apiDelete(path, options = {}) {
|
|
96
|
-
const
|
|
97
|
-
const url = (options.baseUrl ?? "") + path;
|
|
98
|
-
const headers = {};
|
|
99
|
-
if (options.token != null) headers.token = options.token;
|
|
100
|
-
console.log("[API DELETE]", {
|
|
101
|
-
url,
|
|
102
|
-
headers
|
|
103
|
-
});
|
|
104
|
-
const response = await doFetch(url, {
|
|
217
|
+
const { status, body } = await send(path, {
|
|
105
218
|
method: "DELETE",
|
|
106
|
-
headers
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
219
|
+
headers: baseHeaders(options)
|
|
220
|
+
}, options);
|
|
221
|
+
if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
|
|
222
|
+
return body;
|
|
110
223
|
}
|
|
111
224
|
|
|
112
225
|
//#endregion
|
|
@@ -118,11 +231,9 @@ async function apiDelete(path, options = {}) {
|
|
|
118
231
|
async function fetchCategories(params = {}, options = {}) {
|
|
119
232
|
const extras = { deleted: params.deleted ?? false };
|
|
120
233
|
if (params.categoryIds) extras.categoryIds = params.categoryIds;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (error) throw new Error(error);
|
|
125
|
-
return Object.values(json.categories ?? {});
|
|
234
|
+
const json = await apiGet("/api/categories", extras, withToken(options, params.token));
|
|
235
|
+
assertNoApiErrors(json, 200);
|
|
236
|
+
return Object.values(json?.categories ?? {});
|
|
126
237
|
}
|
|
127
238
|
|
|
128
239
|
//#endregion
|
|
@@ -134,9 +245,8 @@ async function fetchCategories(params = {}, options = {}) {
|
|
|
134
245
|
async function fetchConsumers(consumerIds, options = {}) {
|
|
135
246
|
if (!consumerIds || consumerIds.length === 0) return [];
|
|
136
247
|
const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return Object.values(json.consumers ?? {});
|
|
248
|
+
assertNoApiErrors(json, 200);
|
|
249
|
+
return Object.values(json?.consumers ?? {});
|
|
140
250
|
}
|
|
141
251
|
|
|
142
252
|
//#endregion
|
|
@@ -154,10 +264,9 @@ const rolesById = {
|
|
|
154
264
|
async function fetchEmployees(params = {}, options = {}) {
|
|
155
265
|
const extras = { deleted: params.deleted ?? false };
|
|
156
266
|
if (params.employeeIds) extras.employeeIds = params.employeeIds;
|
|
157
|
-
const json = await apiGet("/api/employees", extras, options);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return Object.values(json.employees ?? {});
|
|
267
|
+
const json = await apiGet("/api/employees", extras, withToken(options, params.token));
|
|
268
|
+
assertNoApiErrors(json, 200);
|
|
269
|
+
return Object.values(json?.employees ?? {});
|
|
161
270
|
}
|
|
162
271
|
|
|
163
272
|
//#endregion
|
|
@@ -169,11 +278,9 @@ async function fetchProducts(params = {}, options = {}) {
|
|
|
169
278
|
const extras = { deleted: params.deleted ?? false };
|
|
170
279
|
if (params.productIds) extras.productIds = params.productIds;
|
|
171
280
|
if (params.storeId) extras.storeId = params.storeId;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (error) throw new Error(error);
|
|
176
|
-
return Object.values(json.products ?? {});
|
|
281
|
+
const json = await apiGet("/api/products", extras, withToken(options, params.token));
|
|
282
|
+
assertNoApiErrors(json, 200);
|
|
283
|
+
return Object.values(json?.products ?? {});
|
|
177
284
|
}
|
|
178
285
|
/**
|
|
179
286
|
* Fetches products by id and returns them keyed by id, transparently batching
|
|
@@ -209,11 +316,9 @@ async function fetchPromotions(params = {}, options = {}) {
|
|
|
209
316
|
if (params.imminent !== void 0) extras.imminent = params.imminent;
|
|
210
317
|
if (params.active !== void 0) extras.active = params.active;
|
|
211
318
|
if (params.expired !== void 0) extras.expired = params.expired;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (error) throw new Error(error);
|
|
216
|
-
return Object.values(json.promotions ?? {});
|
|
319
|
+
const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
|
|
320
|
+
assertNoApiErrors(json, 200);
|
|
321
|
+
return Object.values(json?.promotions ?? {});
|
|
217
322
|
}
|
|
218
323
|
|
|
219
324
|
//#endregion
|
|
@@ -222,10 +327,11 @@ async function fetchPromotions(params = {}, options = {}) {
|
|
|
222
327
|
* Fetches stores. Returns the flattened `Store[]`.
|
|
223
328
|
*/
|
|
224
329
|
async function fetchStores(params = {}, options = {}) {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
330
|
+
const extras = { deleted: params.deleted ?? false };
|
|
331
|
+
if (params.storeIds) extras.storeIds = params.storeIds;
|
|
332
|
+
const json = await apiGet("/api/stores", extras, withToken(options, params.token));
|
|
333
|
+
assertNoApiErrors(json, 200);
|
|
334
|
+
return Object.values(json?.stores ?? {});
|
|
229
335
|
}
|
|
230
336
|
|
|
231
337
|
//#endregion
|
|
@@ -237,9 +343,8 @@ async function fetchStores(params = {}, options = {}) {
|
|
|
237
343
|
*/
|
|
238
344
|
async function fetchReport(query, options = {}) {
|
|
239
345
|
const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return json.reports ?? {};
|
|
346
|
+
assertNoApiErrors(json, 200);
|
|
347
|
+
return json?.reports ?? {};
|
|
243
348
|
}
|
|
244
349
|
|
|
245
350
|
//#endregion
|
|
@@ -294,10 +399,12 @@ async function logout(options = {}) {
|
|
|
294
399
|
//#endregion
|
|
295
400
|
//#region src/core/entity.ts
|
|
296
401
|
/**
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
402
|
+
* Low-level, untyped mutations: the caller supplies the route and payload.
|
|
403
|
+
*
|
|
404
|
+
* Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
|
|
405
|
+
* …): they know each route, build the request envelope, check the response and
|
|
406
|
+
* return the saved entity with its id. Keep these for routes that have no typed
|
|
407
|
+
* function yet (e.g. `/api/merchant`).
|
|
301
408
|
*/
|
|
302
409
|
/**
|
|
303
410
|
* Deletes `/api/{resourcePath}/{entityId}`.
|
|
@@ -307,13 +414,182 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
|
|
|
307
414
|
await apiDelete(`/api/${resourcePath}/${entityId}`, options);
|
|
308
415
|
}
|
|
309
416
|
/**
|
|
310
|
-
* Sends a create/update mutation
|
|
311
|
-
* (
|
|
417
|
+
* Sends a create/update mutation. Returns the response's `result` field when it
|
|
418
|
+
* has one (older app routes wrapped their answer that way), otherwise the whole
|
|
419
|
+
* parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
|
|
312
420
|
*/
|
|
313
421
|
async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
|
|
314
|
-
|
|
422
|
+
const json = await apiSend(endpoint, method, payload, options);
|
|
423
|
+
assertNoApiErrors(json, 200);
|
|
424
|
+
if (json && typeof json === "object" && "result" in json) return json.result;
|
|
425
|
+
return json ?? void 0;
|
|
315
426
|
}
|
|
316
427
|
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/core/entities.ts
|
|
430
|
+
/**
|
|
431
|
+
* Addressing per entity, following the Shopwave API reference
|
|
432
|
+
* (https://developer.merchantstack.com/api-reference.html) and checked against
|
|
433
|
+
* the live API where noted:
|
|
434
|
+
* - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
|
|
435
|
+
* - promotion: no DELETE; "deleting" a promotion ends it by setting `endDate` to now.
|
|
436
|
+
* - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
|
|
437
|
+
* changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
|
|
438
|
+
* - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
|
|
439
|
+
*/
|
|
440
|
+
const SHOPWAVE_ENTITIES = Object.freeze({
|
|
441
|
+
product: {
|
|
442
|
+
route: "products",
|
|
443
|
+
collection: "products",
|
|
444
|
+
upstream: "product",
|
|
445
|
+
idsHeader: "productIds",
|
|
446
|
+
idHeader: "productId",
|
|
447
|
+
writable: true,
|
|
448
|
+
deleteMode: "delete"
|
|
449
|
+
},
|
|
450
|
+
category: {
|
|
451
|
+
route: "categories",
|
|
452
|
+
collection: "categories",
|
|
453
|
+
upstream: "category",
|
|
454
|
+
idsHeader: "categoryIds",
|
|
455
|
+
idHeader: "categoryId",
|
|
456
|
+
writable: true,
|
|
457
|
+
deleteMode: "delete"
|
|
458
|
+
},
|
|
459
|
+
store: {
|
|
460
|
+
route: "stores",
|
|
461
|
+
collection: "stores",
|
|
462
|
+
upstream: "store",
|
|
463
|
+
idsHeader: "storeIds",
|
|
464
|
+
idHeader: "storeId",
|
|
465
|
+
writable: true,
|
|
466
|
+
deleteMode: "delete"
|
|
467
|
+
},
|
|
468
|
+
promotion: {
|
|
469
|
+
route: "promotions",
|
|
470
|
+
collection: "promotions",
|
|
471
|
+
upstream: "promotion",
|
|
472
|
+
idsHeader: "promotionIds",
|
|
473
|
+
idHeader: "promotionId",
|
|
474
|
+
writable: true,
|
|
475
|
+
deleteMode: "retire",
|
|
476
|
+
retireField: "endDate"
|
|
477
|
+
},
|
|
478
|
+
employee: {
|
|
479
|
+
route: "employees",
|
|
480
|
+
collection: "employees",
|
|
481
|
+
upstream: "employee",
|
|
482
|
+
idsHeader: "employeeIds",
|
|
483
|
+
idHeader: "employeeId",
|
|
484
|
+
writable: true,
|
|
485
|
+
deleteMode: "retire",
|
|
486
|
+
retireField: "exitDate"
|
|
487
|
+
},
|
|
488
|
+
consumer: {
|
|
489
|
+
route: "consumer",
|
|
490
|
+
collection: "consumers",
|
|
491
|
+
upstream: "consumer",
|
|
492
|
+
idsHeader: "ids",
|
|
493
|
+
idHeader: "consumerId",
|
|
494
|
+
writable: false,
|
|
495
|
+
deleteMode: "none"
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
function definition(kind) {
|
|
499
|
+
const def = SHOPWAVE_ENTITIES[kind];
|
|
500
|
+
if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
|
|
501
|
+
return def;
|
|
502
|
+
}
|
|
503
|
+
function assertId(kind, id) {
|
|
504
|
+
const s = id == null ? "" : String(id).trim();
|
|
505
|
+
if (!s) throw new Error(`${kind} id is missing`);
|
|
506
|
+
return encodeURIComponent(s);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Creates or updates several entities of one kind in a single request and
|
|
510
|
+
* returns them as saved (with their ids), in the order given.
|
|
511
|
+
*
|
|
512
|
+
* The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
|
|
513
|
+
* answers 201 and echoes every entity under the ref it was sent with, so each
|
|
514
|
+
* result is matched back by ref. Throws {@link ShopwaveApiError} when the
|
|
515
|
+
* response carries `api.message.errors` or any ref is missing from it.
|
|
516
|
+
*
|
|
517
|
+
* The API's partial-success semantics for multi-entity saves haven't been
|
|
518
|
+
* confirmed yet; single saves (`saveProduct` etc.) are the tested path.
|
|
519
|
+
*/
|
|
520
|
+
async function saveEntities(kind, items, options = {}) {
|
|
521
|
+
const def = definition(kind);
|
|
522
|
+
if (items.length === 0) return [];
|
|
523
|
+
const refs = items.map((_, i) => String(i));
|
|
524
|
+
const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
|
|
525
|
+
const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: { "Content-Type": "application/json" },
|
|
528
|
+
body: JSON.stringify(payload)
|
|
529
|
+
}, options);
|
|
530
|
+
const body = raw;
|
|
531
|
+
assertNoApiErrors(body, status);
|
|
532
|
+
const echoed = body?.[def.collection] ?? {};
|
|
533
|
+
return refs.map((ref, i) => {
|
|
534
|
+
const saved = echoed[ref];
|
|
535
|
+
if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
|
|
536
|
+
return {
|
|
537
|
+
...items[i],
|
|
538
|
+
...saved,
|
|
539
|
+
id: Number(saved.id)
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
|
|
544
|
+
async function saveEntity(kind, item, options = {}) {
|
|
545
|
+
const [saved] = await saveEntities(kind, [item], options);
|
|
546
|
+
return saved;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Deletes one entity (a soft delete: it stays readable with `deleted: true`).
|
|
550
|
+
* Employees and promotions can't be deleted in Shopwave; for them the route
|
|
551
|
+
* sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`).
|
|
552
|
+
*
|
|
553
|
+
* Shopwave answers 205 with an empty body — also for ids that don't exist — so
|
|
554
|
+
* a resolved promise means "the API accepted the request", not "a record was
|
|
555
|
+
* deleted".
|
|
556
|
+
*/
|
|
557
|
+
async function deleteEntityById(kind, id, options = {}) {
|
|
558
|
+
await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
|
|
559
|
+
}
|
|
560
|
+
/** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
|
|
561
|
+
async function fetchEntityById(kind, id, params = {}, options = {}) {
|
|
562
|
+
const def = definition(kind);
|
|
563
|
+
const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
|
|
564
|
+
assertNoApiErrors(body, 200);
|
|
565
|
+
const map = body?.[def.collection] ?? {};
|
|
566
|
+
return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
|
|
567
|
+
}
|
|
568
|
+
const saveProduct = (product, options) => saveEntity("product", product, options);
|
|
569
|
+
const deleteProduct = (id, options) => deleteEntityById("product", id, options);
|
|
570
|
+
const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
|
|
571
|
+
const saveCategory = (category, options) => saveEntity("category", category, options);
|
|
572
|
+
const deleteCategory = (id, options) => deleteEntityById("category", id, options);
|
|
573
|
+
const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
|
|
574
|
+
const saveStore = (store, options) => saveEntity("store", store, options);
|
|
575
|
+
const deleteStore = (id, options) => deleteEntityById("store", id, options);
|
|
576
|
+
const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
|
|
577
|
+
const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
|
|
578
|
+
/** Ends a promotion: Shopwave has no promotion DELETE, so this sets `endDate` to now. */
|
|
579
|
+
const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
|
|
580
|
+
const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
|
|
581
|
+
/**
|
|
582
|
+
* Creates an employee, or updates one (with `id`). On update Shopwave only
|
|
583
|
+
* applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
|
|
584
|
+
* email can't be changed, and the echo only carries the updated fields — re-read
|
|
585
|
+
* with `fetchEmployee` if you need the stored record.
|
|
586
|
+
*/
|
|
587
|
+
const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
|
|
588
|
+
/** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
|
|
589
|
+
const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
|
|
590
|
+
const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
|
|
591
|
+
const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
|
|
592
|
+
|
|
317
593
|
//#endregion
|
|
318
594
|
//#region src/core/basket.ts
|
|
319
595
|
const DEFAULT_BASKET_FILTERS = {
|
|
@@ -458,56 +734,613 @@ function buildConsumerMap(consumers) {
|
|
|
458
734
|
return map;
|
|
459
735
|
}
|
|
460
736
|
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/core/resources.ts
|
|
739
|
+
/**
|
|
740
|
+
* - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
|
|
741
|
+
* updates it (without an `id` the API would create a second merchant, so the
|
|
742
|
+
* route requires one).
|
|
743
|
+
* - user: `GET /user` (read-only here; the API's `POST /user` invites users and
|
|
744
|
+
* needs the admin scope).
|
|
745
|
+
*/
|
|
746
|
+
const SHOPWAVE_RESOURCES = Object.freeze({
|
|
747
|
+
merchant: {
|
|
748
|
+
route: "merchant",
|
|
749
|
+
key: "merchant",
|
|
750
|
+
upstream: "merchant",
|
|
751
|
+
writable: true
|
|
752
|
+
},
|
|
753
|
+
user: {
|
|
754
|
+
route: "user",
|
|
755
|
+
key: "user",
|
|
756
|
+
upstream: "user",
|
|
757
|
+
writable: false
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
/** `UploadKind` → the API's `contentType` value. */
|
|
761
|
+
const UPLOAD_KINDS = Object.freeze({
|
|
762
|
+
merchant: "merchant",
|
|
763
|
+
merchantFeature: "merchantFeature",
|
|
764
|
+
product: "product",
|
|
765
|
+
user: "user",
|
|
766
|
+
applicationLogo: "applicaionLogo",
|
|
767
|
+
applicationImages: "applicaionImages"
|
|
768
|
+
});
|
|
769
|
+
/** How uploads are addressed (app route and Shopwave uploader). */
|
|
770
|
+
const SHOPWAVE_UPLOAD = Object.freeze({
|
|
771
|
+
route: "upload",
|
|
772
|
+
upstream: "uploader",
|
|
773
|
+
kindHeader: "contentType",
|
|
774
|
+
fileField: "file",
|
|
775
|
+
kindField: "kind"
|
|
776
|
+
});
|
|
777
|
+
function isUploadKind(value) {
|
|
778
|
+
return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/core/imageFit.ts
|
|
783
|
+
/**
|
|
784
|
+
* The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
|
|
785
|
+
* `dstWidth × dstHeight` (like CSS `object-fit: cover`).
|
|
786
|
+
*/
|
|
787
|
+
function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
|
|
788
|
+
if (![
|
|
789
|
+
srcWidth,
|
|
790
|
+
srcHeight,
|
|
791
|
+
dstWidth,
|
|
792
|
+
dstHeight
|
|
793
|
+
].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
|
|
794
|
+
const srcRatio = srcWidth / srcHeight;
|
|
795
|
+
const dstRatio = dstWidth / dstHeight;
|
|
796
|
+
if (srcRatio > dstRatio) {
|
|
797
|
+
const sw = Math.round(srcHeight * dstRatio);
|
|
798
|
+
return {
|
|
799
|
+
sx: Math.round((srcWidth - sw) / 2),
|
|
800
|
+
sy: 0,
|
|
801
|
+
sw,
|
|
802
|
+
sh: srcHeight
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
const sh = Math.round(srcWidth / dstRatio);
|
|
806
|
+
return {
|
|
807
|
+
sx: 0,
|
|
808
|
+
sy: Math.round((srcHeight - sh) / 2),
|
|
809
|
+
sw: srcWidth,
|
|
810
|
+
sh
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function requireBrowserImages(fn) {
|
|
814
|
+
if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
|
|
815
|
+
}
|
|
816
|
+
/** Reads an image file's pixel size (browser only). */
|
|
817
|
+
async function readImageSize(file) {
|
|
818
|
+
requireBrowserImages("readImageSize");
|
|
819
|
+
const bitmap = await createImageBitmap(file);
|
|
820
|
+
try {
|
|
821
|
+
return {
|
|
822
|
+
width: bitmap.width,
|
|
823
|
+
height: bitmap.height
|
|
824
|
+
};
|
|
825
|
+
} finally {
|
|
826
|
+
bitmap.close?.();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Returns the image at exactly `width × height`: scaled and centre-cropped
|
|
831
|
+
* (never stretched). Browser only. The result is a `File` named like the
|
|
832
|
+
* input, with the output type's extension.
|
|
833
|
+
*/
|
|
834
|
+
async function fitImageToSize(file, width, height, options = {}) {
|
|
835
|
+
requireBrowserImages("fitImageToSize");
|
|
836
|
+
const { type = "image/png", quality, keepIfExact = true } = options;
|
|
837
|
+
const name = file.name || "image";
|
|
838
|
+
const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
|
|
839
|
+
const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
|
|
840
|
+
const bitmap = await createImageBitmap(file);
|
|
841
|
+
try {
|
|
842
|
+
if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
|
|
843
|
+
const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
|
|
844
|
+
let blob;
|
|
845
|
+
if (typeof OffscreenCanvas === "function") {
|
|
846
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
847
|
+
const ctx = canvas.getContext("2d");
|
|
848
|
+
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
849
|
+
ctx.imageSmoothingQuality = "high";
|
|
850
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
851
|
+
blob = await canvas.convertToBlob({
|
|
852
|
+
type,
|
|
853
|
+
quality
|
|
854
|
+
});
|
|
855
|
+
} else {
|
|
856
|
+
const canvas = document.createElement("canvas");
|
|
857
|
+
canvas.width = width;
|
|
858
|
+
canvas.height = height;
|
|
859
|
+
const ctx = canvas.getContext("2d");
|
|
860
|
+
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
861
|
+
ctx.imageSmoothingQuality = "high";
|
|
862
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
863
|
+
blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
|
|
864
|
+
}
|
|
865
|
+
return new File([blob], outName, { type });
|
|
866
|
+
} finally {
|
|
867
|
+
bitmap.close?.();
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
//#endregion
|
|
872
|
+
//#region src/core/upload.ts
|
|
873
|
+
/**
|
|
874
|
+
* HTTPS host that serves the Shopwave CDN's images. The API returns
|
|
875
|
+
* `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
|
|
876
|
+
* (checked Sep 2026), so pages served over HTTPS can't show it.
|
|
877
|
+
*/
|
|
878
|
+
const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
|
|
879
|
+
const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
|
|
880
|
+
/**
|
|
881
|
+
* Turns an image path from the API into a URL a page can load over HTTPS:
|
|
882
|
+
* `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
|
|
883
|
+
* relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
|
|
884
|
+
* Other absolute URLs are returned unchanged; empty input gives `""`.
|
|
885
|
+
*/
|
|
886
|
+
function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
887
|
+
if (!path) return "";
|
|
888
|
+
const root = base.replace(/\/+$/, "");
|
|
889
|
+
if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
|
|
890
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
|
|
891
|
+
return `${root}/${path.replace(/^\/+/, "")}`;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Uploads one image to the Shopwave CDN through the app's `/api/upload` route
|
|
895
|
+
* (`createShopwaveApi(...).upload()`), and returns its id and URL.
|
|
896
|
+
*
|
|
897
|
+
* The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
|
|
898
|
+
* record has to point at it to use it. Live API (Sep 2026): the upload answers
|
|
899
|
+
* 201 `{ fileName, path }`.
|
|
900
|
+
*/
|
|
901
|
+
async function uploadImage(file, params, options = {}) {
|
|
902
|
+
if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
|
|
903
|
+
if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
|
|
904
|
+
const name = params.fileName ?? file.name ?? "upload";
|
|
905
|
+
const form = new FormData();
|
|
906
|
+
form.append(SHOPWAVE_UPLOAD.fileField, file, name);
|
|
907
|
+
form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
|
|
908
|
+
const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
|
|
909
|
+
method: "POST",
|
|
910
|
+
body: form
|
|
911
|
+
}, options);
|
|
912
|
+
const body = raw;
|
|
913
|
+
assertNoApiErrors(body, status);
|
|
914
|
+
const id = typeof body?.fileName === "string" ? body.fileName : "";
|
|
915
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
916
|
+
if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
|
|
917
|
+
return {
|
|
918
|
+
id,
|
|
919
|
+
url: getImageUrl(path),
|
|
920
|
+
path
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/core/merchantImages.ts
|
|
926
|
+
/** The three image slots on the merchant settings page. */
|
|
927
|
+
const MERCHANT_IMAGE_SLOT_KEYS = [
|
|
928
|
+
"receipt",
|
|
929
|
+
"square",
|
|
930
|
+
"featured"
|
|
931
|
+
];
|
|
932
|
+
/**
|
|
933
|
+
* Where each slot lives, its upload kind and its exact size (from adminV1's
|
|
934
|
+
* merchant settings page).
|
|
935
|
+
*/
|
|
936
|
+
const MERCHANT_IMAGE_SLOTS = Object.freeze({
|
|
937
|
+
receipt: {
|
|
938
|
+
field: "logo.receipt",
|
|
939
|
+
uploadKind: "merchant",
|
|
940
|
+
width: 576,
|
|
941
|
+
height: 325
|
|
942
|
+
},
|
|
943
|
+
square: {
|
|
944
|
+
field: "logo.square",
|
|
945
|
+
uploadKind: "merchant",
|
|
946
|
+
width: 1e3,
|
|
947
|
+
height: 1e3
|
|
948
|
+
},
|
|
949
|
+
featured: {
|
|
950
|
+
field: "feature[0]",
|
|
951
|
+
uploadKind: "merchantFeature",
|
|
952
|
+
width: 1200,
|
|
953
|
+
height: 600
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
/**
|
|
957
|
+
* The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
|
|
958
|
+
* `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
|
|
959
|
+
* Returns `null` for empty or non-string values.
|
|
960
|
+
*/
|
|
961
|
+
function imageFileName(value) {
|
|
962
|
+
if (typeof value !== "string") return null;
|
|
963
|
+
const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
|
|
964
|
+
return last ? decodeURIComponent(last) : null;
|
|
965
|
+
}
|
|
966
|
+
/** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
|
|
967
|
+
function getMerchantImage(imageIds, slot) {
|
|
968
|
+
if (!imageIds || typeof imageIds !== "object") return null;
|
|
969
|
+
let value;
|
|
970
|
+
if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
|
|
971
|
+
else {
|
|
972
|
+
const logo = imageIds.logo;
|
|
973
|
+
value = logo && typeof logo === "object" ? logo[slot] : void 0;
|
|
974
|
+
}
|
|
975
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
|
|
979
|
+
* holds only a file name (a fresh upload not yet saved; use the upload's `url`).
|
|
980
|
+
*/
|
|
981
|
+
function getMerchantImageUrl(imageIds, slot, base) {
|
|
982
|
+
const value = getMerchantImage(imageIds, slot);
|
|
983
|
+
if (!value || !value.includes("/")) return "";
|
|
984
|
+
return getImageUrl(value, base);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
|
|
988
|
+
* with `null`). Other slots and unknown keys are kept; an empty `logo` or
|
|
989
|
+
* `feature` is removed.
|
|
990
|
+
*/
|
|
991
|
+
function setMerchantImage(imageIds, slot, fileName) {
|
|
992
|
+
const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
|
|
993
|
+
if (slot === "featured") {
|
|
994
|
+
const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
|
|
995
|
+
const feature = fileName ? [fileName, ...rest] : rest;
|
|
996
|
+
if (feature.length > 0) next.feature = feature;
|
|
997
|
+
else delete next.feature;
|
|
998
|
+
} else {
|
|
999
|
+
const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
|
|
1000
|
+
if (fileName) logo[slot] = fileName;
|
|
1001
|
+
else delete logo[slot];
|
|
1002
|
+
if (Object.keys(logo).length > 0) next.logo = logo;
|
|
1003
|
+
else delete next.logo;
|
|
1004
|
+
}
|
|
1005
|
+
return next;
|
|
1006
|
+
}
|
|
1007
|
+
function toStored(value) {
|
|
1008
|
+
if (typeof value === "string") return imageFileName(value) ?? void 0;
|
|
1009
|
+
if (Array.isArray(value)) {
|
|
1010
|
+
const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
|
|
1011
|
+
return items.length > 0 ? items : void 0;
|
|
1012
|
+
}
|
|
1013
|
+
if (value && typeof value === "object") {
|
|
1014
|
+
const out = {};
|
|
1015
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1016
|
+
const stored = toStored(v);
|
|
1017
|
+
if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
|
|
1018
|
+
}
|
|
1019
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* The `imageIds` to send on a save: every URL is turned back into its file
|
|
1024
|
+
* name (what adminV1 does with images you didn't change) and empty values
|
|
1025
|
+
* are dropped. No images gives `{}`.
|
|
1026
|
+
*/
|
|
1027
|
+
function toStoredImageIds(imageIds) {
|
|
1028
|
+
return toStored(imageIds) ?? {};
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Uploads an image for a merchant slot: fits it to the slot's size, uploads
|
|
1032
|
+
* it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
|
|
1033
|
+
* `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
|
|
1034
|
+
*/
|
|
1035
|
+
async function uploadMerchantImage(file, slot, options = {}) {
|
|
1036
|
+
const def = MERCHANT_IMAGE_SLOTS[slot];
|
|
1037
|
+
if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
|
|
1038
|
+
const { fit = true, fitOptions,...requestOptions } = options;
|
|
1039
|
+
return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/core/merchant.ts
|
|
1044
|
+
const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
|
|
1045
|
+
/** Colour keys inside `colours.primary`, in display order. */
|
|
1046
|
+
const MERCHANT_COLOUR_KEYS = [
|
|
1047
|
+
"main",
|
|
1048
|
+
"highlight",
|
|
1049
|
+
"contrast"
|
|
1050
|
+
];
|
|
1051
|
+
/** Flat link fields a form edits, in display order. */
|
|
1052
|
+
const MERCHANT_LINK_KEYS = [
|
|
1053
|
+
"website",
|
|
1054
|
+
"twitter",
|
|
1055
|
+
"facebook",
|
|
1056
|
+
"instagram"
|
|
1057
|
+
];
|
|
1058
|
+
/** Where each flat link field lives in `merchant.links`. */
|
|
1059
|
+
const MERCHANT_LINK_PATHS = Object.freeze({
|
|
1060
|
+
website: ["website", "home"],
|
|
1061
|
+
twitter: ["social", "twitter"],
|
|
1062
|
+
facebook: ["social", "facebook"],
|
|
1063
|
+
instagram: ["social", "instagram"]
|
|
1064
|
+
});
|
|
1065
|
+
function trimmed(value) {
|
|
1066
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Reads `merchant.links` as flat fields (`{ website, twitter, facebook,
|
|
1070
|
+
* instagram }`), whatever shape it was saved in:
|
|
1071
|
+
* - the nested shape adminV1 saves (`{ website: { home }, social: { … } }`);
|
|
1072
|
+
* - flat keys (`{ website: "…" }`, what SDK 0.4.0 pre-releases sent);
|
|
1073
|
+
* - an array in {@link MERCHANT_LINK_KEYS} order (older AdminUI versions).
|
|
1074
|
+
* Empty values are dropped.
|
|
1075
|
+
*/
|
|
1076
|
+
function merchantLinksToForm(links) {
|
|
1077
|
+
const out = {};
|
|
1078
|
+
if (Array.isArray(links)) {
|
|
1079
|
+
links.forEach((value, i) => {
|
|
1080
|
+
const key = MERCHANT_LINK_KEYS[i];
|
|
1081
|
+
const v = trimmed(value);
|
|
1082
|
+
if (key && v) out[key] = v;
|
|
1083
|
+
});
|
|
1084
|
+
return out;
|
|
1085
|
+
}
|
|
1086
|
+
if (!links || typeof links !== "object") return out;
|
|
1087
|
+
const obj = links;
|
|
1088
|
+
for (const key of MERCHANT_LINK_KEYS) {
|
|
1089
|
+
const [group, field] = MERCHANT_LINK_PATHS[key];
|
|
1090
|
+
const nested = obj[group];
|
|
1091
|
+
const v = nested && typeof nested === "object" && !Array.isArray(nested) ? trimmed(nested[field]) : trimmed(obj[key]);
|
|
1092
|
+
if (v) out[key] = v;
|
|
1093
|
+
}
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Builds the stored `links` object from flat fields. Empty values are left
|
|
1098
|
+
* out, and so is a group with nothing in it; no links gives `{}`.
|
|
1099
|
+
*/
|
|
1100
|
+
function merchantLinksFromForm(form) {
|
|
1101
|
+
const out = {};
|
|
1102
|
+
for (const key of MERCHANT_LINK_KEYS) {
|
|
1103
|
+
const v = trimmed(form[key]);
|
|
1104
|
+
if (!v) continue;
|
|
1105
|
+
const [group, field] = MERCHANT_LINK_PATHS[key];
|
|
1106
|
+
(out[group] ??= {})[field] = v;
|
|
1107
|
+
}
|
|
1108
|
+
return out;
|
|
1109
|
+
}
|
|
1110
|
+
/** Reads any stored or older `links` value into the nested shape adminV1 saves. */
|
|
1111
|
+
function normalizeMerchantLinks(links) {
|
|
1112
|
+
return merchantLinksFromForm(merchantLinksToForm(links));
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Object fields of the merchant. The API keeps one you leave out, and
|
|
1116
|
+
* replaces it as a whole when you send it.
|
|
1117
|
+
*/
|
|
1118
|
+
const MERCHANT_OBJECT_FIELDS = [
|
|
1119
|
+
"colours",
|
|
1120
|
+
"links",
|
|
1121
|
+
"imageIds"
|
|
1122
|
+
];
|
|
1123
|
+
/** Fields the API sets itself; `updateMerchant` never sends them back. */
|
|
1124
|
+
const MERCHANT_READ_ONLY_FIELDS = ["createdDate", "modifiedDate"];
|
|
1125
|
+
/**
|
|
1126
|
+
* Longest values the API stores. Longer values are cut off silently
|
|
1127
|
+
* (live API, Sep 2026), so `updateMerchant` rejects them instead.
|
|
1128
|
+
* `vatNumber` fits a UK VAT number without the `GB` prefix.
|
|
1129
|
+
*/
|
|
1130
|
+
const MERCHANT_FIELD_MAX_LENGTH = Object.freeze({
|
|
1131
|
+
companyNumber: 11,
|
|
1132
|
+
vatNumber: 9
|
|
1133
|
+
});
|
|
1134
|
+
/** Returns `"<field> is longer than <n> characters"` messages for over-long fields. */
|
|
1135
|
+
function validateMerchant(merchant) {
|
|
1136
|
+
const problems = [];
|
|
1137
|
+
for (const [field, max] of Object.entries(MERCHANT_FIELD_MAX_LENGTH)) {
|
|
1138
|
+
const value = merchant[field];
|
|
1139
|
+
if (typeof value === "string" && value.length > max) problems.push(`${field} is longer than ${max} characters`);
|
|
1140
|
+
}
|
|
1141
|
+
return problems;
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Reads the logged-in user's merchant (a user has exactly one), or `null`
|
|
1145
|
+
* when the API returns none.
|
|
1146
|
+
*/
|
|
1147
|
+
async function fetchMerchant(options = {}) {
|
|
1148
|
+
const json = await apiGet(MERCHANT_PATH, {}, options);
|
|
1149
|
+
assertNoApiErrors(json, 200);
|
|
1150
|
+
return json?.merchant ?? null;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Updates the merchant and returns it as stored.
|
|
1154
|
+
*
|
|
1155
|
+
* Shopwave's `POST /merchant` replaces the whole record: a scalar field you
|
|
1156
|
+
* leave out (`companyNumber`, `vatNumber`, `note`, …) is set to null, while
|
|
1157
|
+
* an object field you leave out (`colours`, `links`, `imageIds`) is kept and
|
|
1158
|
+
* one you send replaces the stored one; sending `{}` clears it (live API,
|
|
1159
|
+
* Sep 2026). So by default the
|
|
1160
|
+
* current merchant is read first and the patch is merged over it.
|
|
1161
|
+
*
|
|
1162
|
+
* `imageIds` is always sent as file names ({@link toStoredImageIds}), so you
|
|
1163
|
+
* can pass back what you read. See `MerchantImageIds` for the shape.
|
|
1164
|
+
*
|
|
1165
|
+
* The API answers 205 with an empty body, so the merchant is read again after
|
|
1166
|
+
* the save and that is what's returned (it shows exactly what was kept).
|
|
1167
|
+
* Throws before sending when a value is longer than the API stores
|
|
1168
|
+
* ({@link MERCHANT_FIELD_MAX_LENGTH}).
|
|
1169
|
+
*/
|
|
1170
|
+
async function updateMerchant(patch, options = {}) {
|
|
1171
|
+
const { merge = true,...requestOptions } = options;
|
|
1172
|
+
let merchant = { ...patch };
|
|
1173
|
+
if (merge) {
|
|
1174
|
+
const current = await fetchMerchant(requestOptions);
|
|
1175
|
+
if (!current) throw new ShopwaveApiError(404, "Shopwave API error (404): no merchant to update");
|
|
1176
|
+
if (patch.id != null && String(patch.id) !== String(current.id)) throw new Error(`Merchant id ${patch.id} is not the logged-in merchant (${current.id})`);
|
|
1177
|
+
merchant = {
|
|
1178
|
+
...current,
|
|
1179
|
+
...patch,
|
|
1180
|
+
id: current.id
|
|
1181
|
+
};
|
|
1182
|
+
for (const field of MERCHANT_OBJECT_FIELDS) if (!(field in patch)) delete merchant[field];
|
|
1183
|
+
}
|
|
1184
|
+
if (merchant.imageIds && typeof merchant.imageIds === "object") merchant.imageIds = toStoredImageIds(merchant.imageIds);
|
|
1185
|
+
if (merchant.id == null || merchant.id === "") throw new Error("merchant id is missing");
|
|
1186
|
+
for (const field of MERCHANT_READ_ONLY_FIELDS) delete merchant[field];
|
|
1187
|
+
const problems = validateMerchant(merchant);
|
|
1188
|
+
if (problems.length > 0) throw new Error(`Invalid merchant: ${problems.join("; ")}`);
|
|
1189
|
+
const { status, body: raw } = await apiRequest(MERCHANT_PATH, {
|
|
1190
|
+
method: "PUT",
|
|
1191
|
+
headers: { "Content-Type": "application/json" },
|
|
1192
|
+
body: JSON.stringify({ merchant })
|
|
1193
|
+
}, requestOptions);
|
|
1194
|
+
const body = raw;
|
|
1195
|
+
assertNoApiErrors(body, status);
|
|
1196
|
+
const echoed = body?.merchant && typeof body.merchant === "object" ? body.merchant : null;
|
|
1197
|
+
if (echoed) return {
|
|
1198
|
+
...merchant,
|
|
1199
|
+
...echoed,
|
|
1200
|
+
id: Number(echoed.id ?? merchant.id)
|
|
1201
|
+
};
|
|
1202
|
+
return await fetchMerchant(requestOptions) ?? {
|
|
1203
|
+
...merchant,
|
|
1204
|
+
id: Number(merchant.id)
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/core/user.ts
|
|
1210
|
+
/** Reads the logged-in user, or `null` when the API returns none. */
|
|
1211
|
+
async function fetchUser(options = {}) {
|
|
1212
|
+
const json = await apiGet(`/api/${SHOPWAVE_RESOURCES.user.route}`, {}, options);
|
|
1213
|
+
assertNoApiErrors(json, 200);
|
|
1214
|
+
return json?.user ?? null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
461
1217
|
//#endregion
|
|
462
1218
|
//#region src/hooks/shared.ts
|
|
463
1219
|
function toMessage(e) {
|
|
464
1220
|
if (e instanceof Error) return e.message;
|
|
465
1221
|
return String(e);
|
|
466
1222
|
}
|
|
1223
|
+
function toStatus(e) {
|
|
1224
|
+
return e instanceof ShopwaveApiError ? e.status : null;
|
|
1225
|
+
}
|
|
1226
|
+
function queryReducer(state, action) {
|
|
1227
|
+
switch (action.type) {
|
|
1228
|
+
case "start":
|
|
1229
|
+
if (action.key !== state.key) return {
|
|
1230
|
+
data: null,
|
|
1231
|
+
key: action.key,
|
|
1232
|
+
fetching: true,
|
|
1233
|
+
error: null,
|
|
1234
|
+
errorStatus: null
|
|
1235
|
+
};
|
|
1236
|
+
return {
|
|
1237
|
+
...state,
|
|
1238
|
+
fetching: true,
|
|
1239
|
+
error: null,
|
|
1240
|
+
errorStatus: null
|
|
1241
|
+
};
|
|
1242
|
+
case "success":
|
|
1243
|
+
if (action.key !== state.key) return state;
|
|
1244
|
+
return {
|
|
1245
|
+
data: action.data,
|
|
1246
|
+
key: action.key,
|
|
1247
|
+
fetching: false,
|
|
1248
|
+
error: null,
|
|
1249
|
+
errorStatus: null
|
|
1250
|
+
};
|
|
1251
|
+
case "failure":
|
|
1252
|
+
if (action.key !== state.key) return state;
|
|
1253
|
+
return {
|
|
1254
|
+
...state,
|
|
1255
|
+
fetching: false,
|
|
1256
|
+
error: action.error,
|
|
1257
|
+
errorStatus: action.errorStatus
|
|
1258
|
+
};
|
|
1259
|
+
case "idle": return state.fetching ? {
|
|
1260
|
+
...state,
|
|
1261
|
+
fetching: false
|
|
1262
|
+
} : state;
|
|
1263
|
+
default: return state;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
/** Public view of the internal state for the params currently being rendered. */
|
|
1267
|
+
function selectQueryView(state, key, enabled) {
|
|
1268
|
+
if (state.key !== key) return {
|
|
1269
|
+
data: null,
|
|
1270
|
+
loading: enabled,
|
|
1271
|
+
fetching: enabled,
|
|
1272
|
+
error: null,
|
|
1273
|
+
errorStatus: null
|
|
1274
|
+
};
|
|
1275
|
+
return {
|
|
1276
|
+
data: state.data,
|
|
1277
|
+
loading: state.fetching && state.data === null,
|
|
1278
|
+
fetching: state.fetching,
|
|
1279
|
+
error: state.error,
|
|
1280
|
+
errorStatus: state.errorStatus
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
function depsKey(deps) {
|
|
1284
|
+
try {
|
|
1285
|
+
return JSON.stringify(deps);
|
|
1286
|
+
} catch {
|
|
1287
|
+
return String(deps);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
467
1290
|
/**
|
|
468
1291
|
* Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
|
|
469
1292
|
* whenever `deps` change, tracks loading/error/data, cancels in-flight requests
|
|
470
|
-
* on unmount or dep change, and exposes `refetch
|
|
1293
|
+
* on unmount or dep change, and exposes `refetch` (which keeps the current data
|
|
1294
|
+
* visible until the new data arrives).
|
|
471
1295
|
*
|
|
472
1296
|
* When `enabled` is false the fetch is skipped (used for hooks that need
|
|
473
1297
|
* arguments before they can run, e.g. consumers-by-id).
|
|
474
1298
|
*/
|
|
475
1299
|
function useQuery(fetcher, deps, enabled = true) {
|
|
476
|
-
const
|
|
477
|
-
const [
|
|
478
|
-
|
|
1300
|
+
const key = depsKey(deps);
|
|
1301
|
+
const [state, dispatch] = (0, react.useReducer)(queryReducer, {
|
|
1302
|
+
data: null,
|
|
1303
|
+
key: enabled ? key : null,
|
|
1304
|
+
fetching: enabled,
|
|
1305
|
+
error: null,
|
|
1306
|
+
errorStatus: null
|
|
1307
|
+
});
|
|
479
1308
|
const [tick, setTick] = (0, react.useState)(0);
|
|
480
1309
|
const refetch = (0, react.useCallback)(() => setTick((t) => t + 1), []);
|
|
481
1310
|
(0, react.useEffect)(() => {
|
|
482
1311
|
if (!enabled) {
|
|
483
|
-
|
|
1312
|
+
dispatch({ type: "idle" });
|
|
484
1313
|
return;
|
|
485
1314
|
}
|
|
486
1315
|
const controller = new AbortController();
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1316
|
+
dispatch({
|
|
1317
|
+
type: "start",
|
|
1318
|
+
key
|
|
1319
|
+
});
|
|
491
1320
|
fetcher(controller.signal).then((result) => {
|
|
492
|
-
if (
|
|
1321
|
+
if (!controller.signal.aborted) dispatch({
|
|
1322
|
+
type: "success",
|
|
1323
|
+
key,
|
|
1324
|
+
data: result
|
|
1325
|
+
});
|
|
493
1326
|
}).catch((e) => {
|
|
494
|
-
if (
|
|
495
|
-
|
|
496
|
-
|
|
1327
|
+
if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
|
|
1328
|
+
type: "failure",
|
|
1329
|
+
key,
|
|
1330
|
+
error: toMessage(e),
|
|
1331
|
+
errorStatus: toStatus(e)
|
|
1332
|
+
});
|
|
497
1333
|
});
|
|
498
1334
|
return () => {
|
|
499
|
-
active = false;
|
|
500
1335
|
controller.abort();
|
|
501
1336
|
};
|
|
502
1337
|
}, [
|
|
503
|
-
|
|
1338
|
+
key,
|
|
504
1339
|
tick,
|
|
505
1340
|
enabled
|
|
506
1341
|
]);
|
|
507
1342
|
return {
|
|
508
|
-
|
|
509
|
-
loading,
|
|
510
|
-
error,
|
|
1343
|
+
...selectQueryView(state, key, enabled),
|
|
511
1344
|
refetch
|
|
512
1345
|
};
|
|
513
1346
|
}
|
|
@@ -518,16 +1351,19 @@ function useMutation(action, deps) {
|
|
|
518
1351
|
const [data, setData] = (0, react.useState)(null);
|
|
519
1352
|
const [loading, setLoading] = (0, react.useState)(false);
|
|
520
1353
|
const [error, setError] = (0, react.useState)(null);
|
|
1354
|
+
const [errorStatus, setErrorStatus] = (0, react.useState)(null);
|
|
521
1355
|
return {
|
|
522
1356
|
mutate: (0, react.useCallback)(async (...args) => {
|
|
523
1357
|
setLoading(true);
|
|
524
1358
|
setError(null);
|
|
1359
|
+
setErrorStatus(null);
|
|
525
1360
|
try {
|
|
526
1361
|
const result = await action(...args);
|
|
527
1362
|
setData(result);
|
|
528
1363
|
return result;
|
|
529
1364
|
} catch (e) {
|
|
530
1365
|
setError(toMessage(e));
|
|
1366
|
+
setErrorStatus(toStatus(e));
|
|
531
1367
|
return null;
|
|
532
1368
|
} finally {
|
|
533
1369
|
setLoading(false);
|
|
@@ -535,7 +1371,8 @@ function useMutation(action, deps) {
|
|
|
535
1371
|
}, deps),
|
|
536
1372
|
data,
|
|
537
1373
|
loading,
|
|
538
|
-
error
|
|
1374
|
+
error,
|
|
1375
|
+
errorStatus
|
|
539
1376
|
};
|
|
540
1377
|
}
|
|
541
1378
|
|
|
@@ -734,6 +1571,85 @@ function useSession(options) {
|
|
|
734
1571
|
}), [options?.baseUrl]);
|
|
735
1572
|
}
|
|
736
1573
|
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/hooks/useMerchant.ts
|
|
1576
|
+
/**
|
|
1577
|
+
* The logged-in user's merchant, fetched on mount. `data` is `null` while
|
|
1578
|
+
* loading or when the API has no merchant.
|
|
1579
|
+
*
|
|
1580
|
+
* @example
|
|
1581
|
+
* const { data: merchant, loading, refetch } = useMerchant();
|
|
1582
|
+
*/
|
|
1583
|
+
function useMerchant(options) {
|
|
1584
|
+
return useQuery((signal) => fetchMerchant({
|
|
1585
|
+
...options,
|
|
1586
|
+
signal
|
|
1587
|
+
}), [options?.baseUrl, options?.token]);
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Manually-triggered merchant update (see `updateMerchant`: the patch is merged
|
|
1591
|
+
* over the current merchant by default). Resolves to the saved merchant, or
|
|
1592
|
+
* `null` on error (then `error` / `errorStatus` are set).
|
|
1593
|
+
*
|
|
1594
|
+
* @example
|
|
1595
|
+
* const { mutate: saveMerchant, loading: saving } = useUpdateMerchant();
|
|
1596
|
+
* const saved = await saveMerchant({ vatNumber: "GB123" });
|
|
1597
|
+
*/
|
|
1598
|
+
function useUpdateMerchant(options) {
|
|
1599
|
+
return useMutation((patch) => updateMerchant(patch, options), [
|
|
1600
|
+
options?.baseUrl,
|
|
1601
|
+
options?.token,
|
|
1602
|
+
options?.merge
|
|
1603
|
+
]);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/hooks/useUser.ts
|
|
1608
|
+
/**
|
|
1609
|
+
* The logged-in user, fetched on mount. `errorStatus` is 401 when logged out.
|
|
1610
|
+
*
|
|
1611
|
+
* @example
|
|
1612
|
+
* const { data: user } = useUser();
|
|
1613
|
+
*/
|
|
1614
|
+
function useUser(options) {
|
|
1615
|
+
const enabled = options?.enabled ?? true;
|
|
1616
|
+
return useQuery((signal) => fetchUser({
|
|
1617
|
+
...options,
|
|
1618
|
+
signal
|
|
1619
|
+
}), [options?.baseUrl, options?.token], enabled);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
//#endregion
|
|
1623
|
+
//#region src/hooks/useUploadImage.ts
|
|
1624
|
+
/**
|
|
1625
|
+
* Manually-triggered image upload. Resolves to `{ id, url }`, or `null` on
|
|
1626
|
+
* error (then `error` / `errorStatus` are set).
|
|
1627
|
+
*
|
|
1628
|
+
* @example
|
|
1629
|
+
* const { mutate: upload, loading: uploading } = useUploadImage();
|
|
1630
|
+
* const image = await upload(file, { kind: "merchant" });
|
|
1631
|
+
*/
|
|
1632
|
+
function useUploadImage(options) {
|
|
1633
|
+
return useMutation((file, params) => uploadImage(file, params, options), [options?.baseUrl, options?.token]);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Manually-triggered upload for a merchant image slot (`receipt`, `square`,
|
|
1637
|
+
* `featured`): fits the image to the slot's exact size, uploads it with the
|
|
1638
|
+
* slot's kind, and resolves to `{ id, url }`. Put `id` into `imageIds` with
|
|
1639
|
+
* `setMerchantImage`.
|
|
1640
|
+
*
|
|
1641
|
+
* @example
|
|
1642
|
+
* const { mutate: upload } = useUploadMerchantImage();
|
|
1643
|
+
* const image = await upload(file, "square");
|
|
1644
|
+
*/
|
|
1645
|
+
function useUploadMerchantImage(options) {
|
|
1646
|
+
return useMutation((file, slot) => uploadMerchantImage(file, slot, options), [
|
|
1647
|
+
options?.baseUrl,
|
|
1648
|
+
options?.token,
|
|
1649
|
+
options?.fit
|
|
1650
|
+
]);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
737
1653
|
//#endregion
|
|
738
1654
|
//#region src/hooks/useBasketReport.ts
|
|
739
1655
|
/**
|
|
@@ -840,44 +1756,108 @@ function useBasketReport(options) {
|
|
|
840
1756
|
//#endregion
|
|
841
1757
|
exports.DEFAULT_BASKET_FILTERS = DEFAULT_BASKET_FILTERS;
|
|
842
1758
|
exports.DEFAULT_PAGINATION = DEFAULT_PAGINATION;
|
|
1759
|
+
exports.MERCHANT_COLOUR_KEYS = MERCHANT_COLOUR_KEYS;
|
|
1760
|
+
exports.MERCHANT_FIELD_MAX_LENGTH = MERCHANT_FIELD_MAX_LENGTH;
|
|
1761
|
+
exports.MERCHANT_IMAGE_SLOTS = MERCHANT_IMAGE_SLOTS;
|
|
1762
|
+
exports.MERCHANT_IMAGE_SLOT_KEYS = MERCHANT_IMAGE_SLOT_KEYS;
|
|
1763
|
+
exports.MERCHANT_LINK_KEYS = MERCHANT_LINK_KEYS;
|
|
1764
|
+
exports.MERCHANT_LINK_PATHS = MERCHANT_LINK_PATHS;
|
|
1765
|
+
exports.MERCHANT_OBJECT_FIELDS = MERCHANT_OBJECT_FIELDS;
|
|
1766
|
+
exports.MERCHANT_READ_ONLY_FIELDS = MERCHANT_READ_ONLY_FIELDS;
|
|
843
1767
|
exports.PromotionRuleSet = PromotionRuleSet;
|
|
1768
|
+
exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
|
|
1769
|
+
exports.SHOPWAVE_IMAGE_BASE_URL = SHOPWAVE_IMAGE_BASE_URL;
|
|
1770
|
+
exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
|
|
1771
|
+
exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
|
|
1772
|
+
exports.ShopwaveApiError = ShopwaveApiError;
|
|
1773
|
+
exports.UPLOAD_KINDS = UPLOAD_KINDS;
|
|
844
1774
|
exports.apiDelete = apiDelete;
|
|
845
1775
|
exports.apiGet = apiGet;
|
|
1776
|
+
exports.apiRequest = apiRequest;
|
|
846
1777
|
exports.apiSend = apiSend;
|
|
1778
|
+
exports.assertNoApiErrors = assertNoApiErrors;
|
|
1779
|
+
exports.authorizationFor = authorizationFor;
|
|
847
1780
|
exports.buildBasketReportQuery = buildBasketReportQuery;
|
|
848
1781
|
exports.buildConsumerMap = buildConsumerMap;
|
|
849
1782
|
exports.collectConsumerIds = collectConsumerIds;
|
|
850
1783
|
exports.collectProductIds = collectProductIds;
|
|
851
1784
|
exports.combineBasketRows = combineBasketRows;
|
|
852
1785
|
exports.computeBasketSummary = computeBasketSummary;
|
|
1786
|
+
exports.coverCrop = coverCrop;
|
|
1787
|
+
exports.deleteCategory = deleteCategory;
|
|
1788
|
+
exports.deleteEmployee = deleteEmployee;
|
|
853
1789
|
exports.deleteEntity = deleteEntity;
|
|
1790
|
+
exports.deleteEntityById = deleteEntityById;
|
|
1791
|
+
exports.deleteProduct = deleteProduct;
|
|
1792
|
+
exports.deletePromotion = deletePromotion;
|
|
1793
|
+
exports.deleteStore = deleteStore;
|
|
854
1794
|
exports.fetchCategories = fetchCategories;
|
|
1795
|
+
exports.fetchCategory = fetchCategory;
|
|
1796
|
+
exports.fetchConsumer = fetchConsumer;
|
|
855
1797
|
exports.fetchConsumers = fetchConsumers;
|
|
1798
|
+
exports.fetchEmployee = fetchEmployee;
|
|
856
1799
|
exports.fetchEmployees = fetchEmployees;
|
|
1800
|
+
exports.fetchEntityById = fetchEntityById;
|
|
1801
|
+
exports.fetchMerchant = fetchMerchant;
|
|
1802
|
+
exports.fetchProduct = fetchProduct;
|
|
857
1803
|
exports.fetchProducts = fetchProducts;
|
|
858
1804
|
exports.fetchProductsMap = fetchProductsMap;
|
|
1805
|
+
exports.fetchPromotion = fetchPromotion;
|
|
859
1806
|
exports.fetchPromotions = fetchPromotions;
|
|
860
1807
|
exports.fetchReport = fetchReport;
|
|
861
1808
|
exports.fetchSession = fetchSession;
|
|
1809
|
+
exports.fetchStore = fetchStore;
|
|
862
1810
|
exports.fetchStores = fetchStores;
|
|
1811
|
+
exports.fetchUser = fetchUser;
|
|
1812
|
+
exports.fitImageToSize = fitImageToSize;
|
|
863
1813
|
exports.formatDateForQuery = formatDateForQuery;
|
|
1814
|
+
exports.getApiErrorMap = getApiErrorMap;
|
|
864
1815
|
exports.getApiErrors = getApiErrors;
|
|
1816
|
+
exports.getImageUrl = getImageUrl;
|
|
1817
|
+
exports.getMerchantImage = getMerchantImage;
|
|
1818
|
+
exports.getMerchantImageUrl = getMerchantImageUrl;
|
|
1819
|
+
exports.imageFileName = imageFileName;
|
|
1820
|
+
exports.isUploadKind = isUploadKind;
|
|
865
1821
|
exports.loginPath = loginPath;
|
|
866
1822
|
exports.logout = logout;
|
|
867
1823
|
exports.logoutPath = logoutPath;
|
|
1824
|
+
exports.merchantLinksFromForm = merchantLinksFromForm;
|
|
1825
|
+
exports.merchantLinksToForm = merchantLinksToForm;
|
|
1826
|
+
exports.normalizeMerchantLinks = normalizeMerchantLinks;
|
|
868
1827
|
exports.parseBasketReportData = parseBasketReportData;
|
|
1828
|
+
exports.readImageSize = readImageSize;
|
|
869
1829
|
exports.rolesById = rolesById;
|
|
1830
|
+
exports.saveCategory = saveCategory;
|
|
1831
|
+
exports.saveEmployee = saveEmployee;
|
|
1832
|
+
exports.saveEntities = saveEntities;
|
|
1833
|
+
exports.saveEntity = saveEntity;
|
|
1834
|
+
exports.saveProduct = saveProduct;
|
|
1835
|
+
exports.savePromotion = savePromotion;
|
|
1836
|
+
exports.saveStore = saveStore;
|
|
1837
|
+
exports.setMerchantImage = setMerchantImage;
|
|
870
1838
|
exports.submitEntity = submitEntity;
|
|
1839
|
+
exports.toShopwaveApiError = toShopwaveApiError;
|
|
1840
|
+
exports.toStoredImageIds = toStoredImageIds;
|
|
1841
|
+
exports.updateMerchant = updateMerchant;
|
|
1842
|
+
exports.uploadImage = uploadImage;
|
|
1843
|
+
exports.uploadMerchantImage = uploadMerchantImage;
|
|
871
1844
|
exports.useBasketReport = useBasketReport;
|
|
872
1845
|
exports.useCategory = useCategory;
|
|
873
1846
|
exports.useConsumer = useConsumer;
|
|
874
1847
|
exports.useDelete = useDelete;
|
|
875
1848
|
exports.useEmployee = useEmployee;
|
|
876
1849
|
exports.useLogout = useLogout;
|
|
1850
|
+
exports.useMerchant = useMerchant;
|
|
877
1851
|
exports.useProduct = useProduct;
|
|
878
1852
|
exports.usePromotion = usePromotion;
|
|
879
1853
|
exports.useReport = useReport;
|
|
880
1854
|
exports.useSession = useSession;
|
|
881
1855
|
exports.useStore = useStore;
|
|
882
1856
|
exports.useSubmit = useSubmit;
|
|
1857
|
+
exports.useUpdateMerchant = useUpdateMerchant;
|
|
1858
|
+
exports.useUploadImage = useUploadImage;
|
|
1859
|
+
exports.useUploadMerchantImage = useUploadMerchantImage;
|
|
1860
|
+
exports.useUser = useUser;
|
|
1861
|
+
exports.validateMerchant = validateMerchant;
|
|
1862
|
+
exports.withToken = withToken;
|
|
883
1863
|
//# sourceMappingURL=index.cjs.map
|