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/dist/index.js CHANGED
@@ -1,88 +1,201 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
2
2
 
3
+ //#region src/core/errors.ts
4
+ /**
5
+ * Thrown by every `core` function when a request fails, either at the HTTP
6
+ * level (`status` is the HTTP status) or because the Shopwave response carried
7
+ * `api.message.errors` (then `status` is the HTTP status of that response,
8
+ * usually 200/201, and `errors` holds the API errors).
9
+ *
10
+ * Branch on `status` (e.g. `isUnauthorized` → send the user to log in) rather
11
+ * than parsing `message`.
12
+ */
13
+ var ShopwaveApiError = class extends Error {
14
+ /** HTTP status of the response (0 when the request never got a response). */
15
+ status;
16
+ /** Shopwave API errors, when the response carried any. */
17
+ errors;
18
+ /** Parsed response body, when there was one. */
19
+ body;
20
+ constructor(status, message, details = {}) {
21
+ super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
22
+ this.name = "ShopwaveApiError";
23
+ this.status = status;
24
+ this.errors = details.errors ?? null;
25
+ this.body = details.body;
26
+ }
27
+ /** 401, or the Shopwave "token expired / invalid" error 908. */
28
+ get isUnauthorized() {
29
+ if (this.status === 401) return true;
30
+ return !!this.errors && Object.entries(this.errors).some(([k, v]) => k === "908" || Number(v?.id) === 908);
31
+ }
32
+ get isNotFound() {
33
+ return this.status === 404;
34
+ }
35
+ };
36
+ /** The `api.message.errors` (or `api.message.error`) map of a Shopwave response, or `null` when there are none. */
37
+ function getApiErrorMap(body) {
38
+ const message = body?.api?.message;
39
+ const errors = message?.errors ?? message?.error;
40
+ if (errors && typeof errors === "object" && Object.keys(errors).length > 0) return errors;
41
+ return null;
42
+ }
43
+ function describeErrors(errors) {
44
+ return Object.entries(errors).map(([code, e]) => {
45
+ const text = [e?.title, e?.details].filter(Boolean).join(": ");
46
+ return text ? `${code} ${text}` : code;
47
+ }).join("; ");
48
+ }
49
+ /**
50
+ * Builds a {@link ShopwaveApiError} for a response. Understands the Shopwave
51
+ * envelope (`api.message.errors`) and the `{ error, message }` bodies returned
52
+ * by the `react-shopwave-connect/next` routes.
53
+ */
54
+ function toShopwaveApiError(status, body, fallback) {
55
+ const errors = getApiErrorMap(body);
56
+ let detail = errors ? describeErrors(errors) : "";
57
+ if (!detail && body && typeof body === "object") {
58
+ const b = body;
59
+ if (typeof b.message === "string") detail = b.message;
60
+ else if (typeof b.error === "string") detail = b.error;
61
+ }
62
+ if (!detail) detail = fallback ?? (status >= 400 ? "request failed" : "API returned errors");
63
+ return new ShopwaveApiError(status, `Shopwave API error (${status}): ${detail}`, {
64
+ errors,
65
+ body
66
+ });
67
+ }
68
+ /** Throws a {@link ShopwaveApiError} when the response body carries API errors. */
69
+ function assertNoApiErrors(body, status) {
70
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
71
+ }
72
+
73
+ //#endregion
3
74
  //#region src/core/request.ts
4
75
  function resolveFetch(options) {
5
76
  const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
6
77
  if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
7
78
  return f;
8
79
  }
9
- function buildExtras(extras, options) {
80
+ /** Options with `token` filled in from a legacy per-call `params.token`, unless `options.token` is set. */
81
+ function withToken(options, token) {
82
+ return token && !options.token ? {
83
+ ...options,
84
+ token
85
+ } : options;
86
+ }
87
+ /** `Authorization` header value for a token (`OAuth <token>` unless it already has a scheme). */
88
+ function authorizationFor(token) {
89
+ const t = token.trim();
90
+ return /^(oauth|bearer)\s+\S/i.test(t) ? t : `OAuth ${t}`;
91
+ }
92
+ /** Headers shared by every request: Accept + the token, when one is given. */
93
+ function baseHeaders(options) {
94
+ const headers = { Accept: "application/json" };
95
+ if (options.token) headers.Authorization = authorizationFor(options.token);
96
+ return headers;
97
+ }
98
+ function buildExtras(extras) {
10
99
  const merged = {
11
100
  "Content-Type": "application/json",
12
101
  ...extras
13
102
  };
14
- if (options.token != null && merged.token == null) merged.token = options.token;
103
+ delete merged.token;
15
104
  return merged;
16
105
  }
106
+ /** Parses a response body as JSON; `null` for an empty body, `{ raw }` for non-JSON. */
107
+ async function readBody(response) {
108
+ const text = await response.text();
109
+ if (!text) return null;
110
+ try {
111
+ return JSON.parse(text);
112
+ } catch {
113
+ return { raw: text };
114
+ }
115
+ }
116
+ /**
117
+ * Low-level request: resolves `{ status, body }` for 2xx responses and throws
118
+ * {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
119
+ * standard ones (Accept + Authorization).
120
+ */
121
+ async function apiRequest(path, init, options = {}) {
122
+ return send(path, {
123
+ ...init,
124
+ headers: {
125
+ ...baseHeaders(options),
126
+ ...init.headers
127
+ }
128
+ }, options);
129
+ }
130
+ async function send(path, init, options) {
131
+ const doFetch = resolveFetch(options);
132
+ const url = (options.baseUrl ?? "") + path;
133
+ let response;
134
+ try {
135
+ response = await doFetch(url, {
136
+ ...init,
137
+ signal: options.signal
138
+ });
139
+ } catch (error) {
140
+ if (error?.name === "AbortError") throw error;
141
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
142
+ }
143
+ const body = await readBody(response);
144
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
145
+ return {
146
+ status: response.status,
147
+ body
148
+ };
149
+ }
17
150
  /**
18
151
  * Returns a JSON string of API errors if the response carries any, otherwise
19
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
152
+ * `null`. Kept for backwards compatibility; prefer `ShopwaveApiError`.
20
153
  */
21
154
  function getApiErrors(api) {
22
- const errors = api?.message?.errors;
23
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
24
- return null;
155
+ const errors = getApiErrorMap({ api });
156
+ return errors ? JSON.stringify(errors) : null;
25
157
  }
26
158
  /**
27
- * GET helper. Sends request metadata via the `extras` header exactly like the
28
- * original hooks did, parses JSON, and returns the typed payload.
159
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
160
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
29
161
  */
30
162
  async function apiGet(path, extras, options = {}) {
31
- const doFetch = resolveFetch(options);
32
- const url = (options.baseUrl ?? "") + path;
33
- const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
34
- console.log("[API GET]", {
35
- url,
36
- headers
37
- });
38
- const response = await doFetch(url, {
163
+ const { body } = await send(path, {
39
164
  method: "GET",
40
- headers,
41
- signal: options.signal
42
- });
43
- if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
44
- return await response.json();
165
+ headers: {
166
+ ...baseHeaders(options),
167
+ extras: JSON.stringify(buildExtras(extras))
168
+ }
169
+ }, options);
170
+ return body;
45
171
  }
46
172
  /**
47
- * JSON body helper for POST/PUT mutations.
173
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
174
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
48
175
  */
49
176
  async function apiSend(path, method, payload, options = {}) {
50
- const doFetch = resolveFetch(options);
51
- const url = (options.baseUrl ?? "") + path;
52
- const headers = { "Content-Type": "application/json" };
53
- if (options.token != null) headers.token = options.token;
54
- console.log(`[API ${method}]`, {
55
- url,
56
- headers,
57
- payload
58
- });
59
- const response = await doFetch(url, {
177
+ const { body } = await send(path, {
60
178
  method,
61
- headers,
62
- body: JSON.stringify(payload),
63
- signal: options.signal
64
- });
65
- if (!response.ok) throw new Error(`Failed to ${method.toLowerCase()}: ${response.statusText}`);
66
- return await response.json();
179
+ headers: {
180
+ ...baseHeaders(options),
181
+ "Content-Type": "application/json"
182
+ },
183
+ body: JSON.stringify(payload)
184
+ }, options);
185
+ return body;
67
186
  }
68
187
  /**
69
- * DELETE helper.
188
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
189
+ * does come back and carries API errors, they are thrown. Returns the parsed
190
+ * body (`null` when empty).
70
191
  */
71
192
  async function apiDelete(path, options = {}) {
72
- const doFetch = resolveFetch(options);
73
- const url = (options.baseUrl ?? "") + path;
74
- const headers = {};
75
- if (options.token != null) headers.token = options.token;
76
- console.log("[API DELETE]", {
77
- url,
78
- headers
79
- });
80
- const response = await doFetch(url, {
193
+ const { status, body } = await send(path, {
81
194
  method: "DELETE",
82
- headers,
83
- signal: options.signal
84
- });
85
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
195
+ headers: baseHeaders(options)
196
+ }, options);
197
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
198
+ return body;
86
199
  }
87
200
 
88
201
  //#endregion
@@ -94,11 +207,9 @@ async function apiDelete(path, options = {}) {
94
207
  async function fetchCategories(params = {}, options = {}) {
95
208
  const extras = { deleted: params.deleted ?? false };
96
209
  if (params.categoryIds) extras.categoryIds = params.categoryIds;
97
- if (params.token) extras.token = params.token;
98
- const json = await apiGet("/api/categories", extras, options);
99
- const error = getApiErrors(json.api);
100
- if (error) throw new Error(error);
101
- return Object.values(json.categories ?? {});
210
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
211
+ assertNoApiErrors(json, 200);
212
+ return Object.values(json?.categories ?? {});
102
213
  }
103
214
 
104
215
  //#endregion
@@ -110,9 +221,8 @@ async function fetchCategories(params = {}, options = {}) {
110
221
  async function fetchConsumers(consumerIds, options = {}) {
111
222
  if (!consumerIds || consumerIds.length === 0) return [];
112
223
  const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
113
- const error = getApiErrors(json.api);
114
- if (error) throw new Error(error);
115
- return Object.values(json.consumers ?? {});
224
+ assertNoApiErrors(json, 200);
225
+ return Object.values(json?.consumers ?? {});
116
226
  }
117
227
 
118
228
  //#endregion
@@ -130,10 +240,9 @@ const rolesById = {
130
240
  async function fetchEmployees(params = {}, options = {}) {
131
241
  const extras = { deleted: params.deleted ?? false };
132
242
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
133
- const json = await apiGet("/api/employees", extras, options);
134
- const error = getApiErrors(json.api);
135
- if (error) throw new Error(error);
136
- return Object.values(json.employees ?? {});
243
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
244
+ assertNoApiErrors(json, 200);
245
+ return Object.values(json?.employees ?? {});
137
246
  }
138
247
 
139
248
  //#endregion
@@ -145,11 +254,9 @@ async function fetchProducts(params = {}, options = {}) {
145
254
  const extras = { deleted: params.deleted ?? false };
146
255
  if (params.productIds) extras.productIds = params.productIds;
147
256
  if (params.storeId) extras.storeId = params.storeId;
148
- if (params.token) extras.token = params.token;
149
- const json = await apiGet("/api/products", extras, options);
150
- const error = getApiErrors(json.api);
151
- if (error) throw new Error(error);
152
- return Object.values(json.products ?? {});
257
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
258
+ assertNoApiErrors(json, 200);
259
+ return Object.values(json?.products ?? {});
153
260
  }
154
261
  /**
155
262
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -185,11 +292,9 @@ async function fetchPromotions(params = {}, options = {}) {
185
292
  if (params.imminent !== void 0) extras.imminent = params.imminent;
186
293
  if (params.active !== void 0) extras.active = params.active;
187
294
  if (params.expired !== void 0) extras.expired = params.expired;
188
- if (params.token) extras.token = params.token;
189
- const json = await apiGet("/api/promotions", extras, options);
190
- const error = getApiErrors(json.api);
191
- if (error) throw new Error(error);
192
- return Object.values(json.promotions ?? {});
295
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
296
+ assertNoApiErrors(json, 200);
297
+ return Object.values(json?.promotions ?? {});
193
298
  }
194
299
 
195
300
  //#endregion
@@ -198,10 +303,11 @@ async function fetchPromotions(params = {}, options = {}) {
198
303
  * Fetches stores. Returns the flattened `Store[]`.
199
304
  */
200
305
  async function fetchStores(params = {}, options = {}) {
201
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
202
- const error = getApiErrors(json.api);
203
- if (error) throw new Error(error);
204
- return Object.values(json.stores ?? {});
306
+ const extras = { deleted: params.deleted ?? false };
307
+ if (params.storeIds) extras.storeIds = params.storeIds;
308
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
309
+ assertNoApiErrors(json, 200);
310
+ return Object.values(json?.stores ?? {});
205
311
  }
206
312
 
207
313
  //#endregion
@@ -213,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
- const error = getApiErrors(json.api);
217
- if (error) throw new Error(error);
218
- return json.reports ?? {};
322
+ assertNoApiErrors(json, 200);
323
+ return json?.reports ?? {};
219
324
  }
220
325
 
221
326
  //#endregion
@@ -270,10 +375,12 @@ async function logout(options = {}) {
270
375
  //#endregion
271
376
  //#region src/core/entity.ts
272
377
  /**
273
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
274
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
275
- * UI concern is deliberately left to the hooks/host layer so core stays
276
- * dependency-free.
378
+ * Low-level, untyped mutations: the caller supplies the route and payload.
379
+ *
380
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
381
+ * …): they know each route, build the request envelope, check the response and
382
+ * return the saved entity with its id. Keep these for routes that have no typed
383
+ * function yet (e.g. `/api/merchant`).
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 and returns the parsed `result` field
287
- * (matching the original `result.result` access).
393
+ * Sends a create/update mutation. Returns the response's `result` field when it
394
+ * has one (older app routes wrapped their answer that way), otherwise the whole
395
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
288
396
  */
289
397
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
290
- return (await apiSend(endpoint, method, payload, options)).result;
398
+ const json = await apiSend(endpoint, method, payload, options);
399
+ assertNoApiErrors(json, 200);
400
+ if (json && typeof json === "object" && "result" in json) return json.result;
401
+ return json ?? void 0;
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 = {
@@ -434,56 +710,613 @@ function buildConsumerMap(consumers) {
434
710
  return map;
435
711
  }
436
712
 
713
+ //#endregion
714
+ //#region src/core/resources.ts
715
+ /**
716
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
717
+ * updates it (without an `id` the API would create a second merchant, so the
718
+ * route requires one).
719
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
720
+ * needs the admin scope).
721
+ */
722
+ const SHOPWAVE_RESOURCES = Object.freeze({
723
+ merchant: {
724
+ route: "merchant",
725
+ key: "merchant",
726
+ upstream: "merchant",
727
+ writable: true
728
+ },
729
+ user: {
730
+ route: "user",
731
+ key: "user",
732
+ upstream: "user",
733
+ writable: false
734
+ }
735
+ });
736
+ /** `UploadKind` → the API's `contentType` value. */
737
+ const UPLOAD_KINDS = Object.freeze({
738
+ merchant: "merchant",
739
+ merchantFeature: "merchantFeature",
740
+ product: "product",
741
+ user: "user",
742
+ applicationLogo: "applicaionLogo",
743
+ applicationImages: "applicaionImages"
744
+ });
745
+ /** How uploads are addressed (app route and Shopwave uploader). */
746
+ const SHOPWAVE_UPLOAD = Object.freeze({
747
+ route: "upload",
748
+ upstream: "uploader",
749
+ kindHeader: "contentType",
750
+ fileField: "file",
751
+ kindField: "kind"
752
+ });
753
+ function isUploadKind(value) {
754
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
755
+ }
756
+
757
+ //#endregion
758
+ //#region src/core/imageFit.ts
759
+ /**
760
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
761
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
762
+ */
763
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
764
+ if (![
765
+ srcWidth,
766
+ srcHeight,
767
+ dstWidth,
768
+ dstHeight
769
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
770
+ const srcRatio = srcWidth / srcHeight;
771
+ const dstRatio = dstWidth / dstHeight;
772
+ if (srcRatio > dstRatio) {
773
+ const sw = Math.round(srcHeight * dstRatio);
774
+ return {
775
+ sx: Math.round((srcWidth - sw) / 2),
776
+ sy: 0,
777
+ sw,
778
+ sh: srcHeight
779
+ };
780
+ }
781
+ const sh = Math.round(srcWidth / dstRatio);
782
+ return {
783
+ sx: 0,
784
+ sy: Math.round((srcHeight - sh) / 2),
785
+ sw: srcWidth,
786
+ sh
787
+ };
788
+ }
789
+ function requireBrowserImages(fn) {
790
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
791
+ }
792
+ /** Reads an image file's pixel size (browser only). */
793
+ async function readImageSize(file) {
794
+ requireBrowserImages("readImageSize");
795
+ const bitmap = await createImageBitmap(file);
796
+ try {
797
+ return {
798
+ width: bitmap.width,
799
+ height: bitmap.height
800
+ };
801
+ } finally {
802
+ bitmap.close?.();
803
+ }
804
+ }
805
+ /**
806
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
807
+ * (never stretched). Browser only. The result is a `File` named like the
808
+ * input, with the output type's extension.
809
+ */
810
+ async function fitImageToSize(file, width, height, options = {}) {
811
+ requireBrowserImages("fitImageToSize");
812
+ const { type = "image/png", quality, keepIfExact = true } = options;
813
+ const name = file.name || "image";
814
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
815
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
816
+ const bitmap = await createImageBitmap(file);
817
+ try {
818
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
819
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
820
+ let blob;
821
+ if (typeof OffscreenCanvas === "function") {
822
+ const canvas = new OffscreenCanvas(width, height);
823
+ const ctx = canvas.getContext("2d");
824
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
825
+ ctx.imageSmoothingQuality = "high";
826
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
827
+ blob = await canvas.convertToBlob({
828
+ type,
829
+ quality
830
+ });
831
+ } else {
832
+ const canvas = document.createElement("canvas");
833
+ canvas.width = width;
834
+ canvas.height = height;
835
+ const ctx = canvas.getContext("2d");
836
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
837
+ ctx.imageSmoothingQuality = "high";
838
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
839
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
840
+ }
841
+ return new File([blob], outName, { type });
842
+ } finally {
843
+ bitmap.close?.();
844
+ }
845
+ }
846
+
847
+ //#endregion
848
+ //#region src/core/upload.ts
849
+ /**
850
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
851
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
852
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
853
+ */
854
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
855
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
856
+ /**
857
+ * Turns an image path from the API into a URL a page can load over HTTPS:
858
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
859
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
860
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
861
+ */
862
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
863
+ if (!path) return "";
864
+ const root = base.replace(/\/+$/, "");
865
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
866
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
867
+ return `${root}/${path.replace(/^\/+/, "")}`;
868
+ }
869
+ /**
870
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
871
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
872
+ *
873
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
874
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
875
+ * 201 `{ fileName, path }`.
876
+ */
877
+ async function uploadImage(file, params, options = {}) {
878
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
879
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
880
+ const name = params.fileName ?? file.name ?? "upload";
881
+ const form = new FormData();
882
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
883
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
884
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
885
+ method: "POST",
886
+ body: form
887
+ }, options);
888
+ const body = raw;
889
+ assertNoApiErrors(body, status);
890
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
891
+ const path = typeof body?.path === "string" ? body.path : "";
892
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
893
+ return {
894
+ id,
895
+ url: getImageUrl(path),
896
+ path
897
+ };
898
+ }
899
+
900
+ //#endregion
901
+ //#region src/core/merchantImages.ts
902
+ /** The three image slots on the merchant settings page. */
903
+ const MERCHANT_IMAGE_SLOT_KEYS = [
904
+ "receipt",
905
+ "square",
906
+ "featured"
907
+ ];
908
+ /**
909
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
910
+ * merchant settings page).
911
+ */
912
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
913
+ receipt: {
914
+ field: "logo.receipt",
915
+ uploadKind: "merchant",
916
+ width: 576,
917
+ height: 325
918
+ },
919
+ square: {
920
+ field: "logo.square",
921
+ uploadKind: "merchant",
922
+ width: 1e3,
923
+ height: 1e3
924
+ },
925
+ featured: {
926
+ field: "feature[0]",
927
+ uploadKind: "merchantFeature",
928
+ width: 1200,
929
+ height: 600
930
+ }
931
+ });
932
+ /**
933
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
934
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
935
+ * Returns `null` for empty or non-string values.
936
+ */
937
+ function imageFileName(value) {
938
+ if (typeof value !== "string") return null;
939
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
940
+ return last ? decodeURIComponent(last) : null;
941
+ }
942
+ /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
943
+ function getMerchantImage(imageIds, slot) {
944
+ if (!imageIds || typeof imageIds !== "object") return null;
945
+ let value;
946
+ if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
947
+ else {
948
+ const logo = imageIds.logo;
949
+ value = logo && typeof logo === "object" ? logo[slot] : void 0;
950
+ }
951
+ return typeof value === "string" && value.trim() ? value.trim() : null;
952
+ }
953
+ /**
954
+ * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
955
+ * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
956
+ */
957
+ function getMerchantImageUrl(imageIds, slot, base) {
958
+ const value = getMerchantImage(imageIds, slot);
959
+ if (!value || !value.includes("/")) return "";
960
+ return getImageUrl(value, base);
961
+ }
962
+ /**
963
+ * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
964
+ * with `null`). Other slots and unknown keys are kept; an empty `logo` or
965
+ * `feature` is removed.
966
+ */
967
+ function setMerchantImage(imageIds, slot, fileName) {
968
+ const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
969
+ if (slot === "featured") {
970
+ const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
971
+ const feature = fileName ? [fileName, ...rest] : rest;
972
+ if (feature.length > 0) next.feature = feature;
973
+ else delete next.feature;
974
+ } else {
975
+ const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
976
+ if (fileName) logo[slot] = fileName;
977
+ else delete logo[slot];
978
+ if (Object.keys(logo).length > 0) next.logo = logo;
979
+ else delete next.logo;
980
+ }
981
+ return next;
982
+ }
983
+ function toStored(value) {
984
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
985
+ if (Array.isArray(value)) {
986
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
987
+ return items.length > 0 ? items : void 0;
988
+ }
989
+ if (value && typeof value === "object") {
990
+ const out = {};
991
+ for (const [k, v] of Object.entries(value)) {
992
+ const stored = toStored(v);
993
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
994
+ }
995
+ return Object.keys(out).length > 0 ? out : void 0;
996
+ }
997
+ }
998
+ /**
999
+ * The `imageIds` to send on a save: every URL is turned back into its file
1000
+ * name (what adminV1 does with images you didn't change) and empty values
1001
+ * are dropped. No images gives `{}`.
1002
+ */
1003
+ function toStoredImageIds(imageIds) {
1004
+ return toStored(imageIds) ?? {};
1005
+ }
1006
+ /**
1007
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
1008
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
1009
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
1010
+ */
1011
+ async function uploadMerchantImage(file, slot, options = {}) {
1012
+ const def = MERCHANT_IMAGE_SLOTS[slot];
1013
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
1014
+ const { fit = true, fitOptions,...requestOptions } = options;
1015
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1016
+ }
1017
+
1018
+ //#endregion
1019
+ //#region src/core/merchant.ts
1020
+ const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
1021
+ /** Colour keys inside `colours.primary`, in display order. */
1022
+ const MERCHANT_COLOUR_KEYS = [
1023
+ "main",
1024
+ "highlight",
1025
+ "contrast"
1026
+ ];
1027
+ /** Flat link fields a form edits, in display order. */
1028
+ const MERCHANT_LINK_KEYS = [
1029
+ "website",
1030
+ "twitter",
1031
+ "facebook",
1032
+ "instagram"
1033
+ ];
1034
+ /** Where each flat link field lives in `merchant.links`. */
1035
+ const MERCHANT_LINK_PATHS = Object.freeze({
1036
+ website: ["website", "home"],
1037
+ twitter: ["social", "twitter"],
1038
+ facebook: ["social", "facebook"],
1039
+ instagram: ["social", "instagram"]
1040
+ });
1041
+ function trimmed(value) {
1042
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
1043
+ }
1044
+ /**
1045
+ * Reads `merchant.links` as flat fields (`{ website, twitter, facebook,
1046
+ * instagram }`), whatever shape it was saved in:
1047
+ * - the nested shape adminV1 saves (`{ website: { home }, social: { … } }`);
1048
+ * - flat keys (`{ website: "…" }`, what SDK 0.4.0 pre-releases sent);
1049
+ * - an array in {@link MERCHANT_LINK_KEYS} order (older AdminUI versions).
1050
+ * Empty values are dropped.
1051
+ */
1052
+ function merchantLinksToForm(links) {
1053
+ const out = {};
1054
+ if (Array.isArray(links)) {
1055
+ links.forEach((value, i) => {
1056
+ const key = MERCHANT_LINK_KEYS[i];
1057
+ const v = trimmed(value);
1058
+ if (key && v) out[key] = v;
1059
+ });
1060
+ return out;
1061
+ }
1062
+ if (!links || typeof links !== "object") return out;
1063
+ const obj = links;
1064
+ for (const key of MERCHANT_LINK_KEYS) {
1065
+ const [group, field] = MERCHANT_LINK_PATHS[key];
1066
+ const nested = obj[group];
1067
+ const v = nested && typeof nested === "object" && !Array.isArray(nested) ? trimmed(nested[field]) : trimmed(obj[key]);
1068
+ if (v) out[key] = v;
1069
+ }
1070
+ return out;
1071
+ }
1072
+ /**
1073
+ * Builds the stored `links` object from flat fields. Empty values are left
1074
+ * out, and so is a group with nothing in it; no links gives `{}`.
1075
+ */
1076
+ function merchantLinksFromForm(form) {
1077
+ const out = {};
1078
+ for (const key of MERCHANT_LINK_KEYS) {
1079
+ const v = trimmed(form[key]);
1080
+ if (!v) continue;
1081
+ const [group, field] = MERCHANT_LINK_PATHS[key];
1082
+ (out[group] ??= {})[field] = v;
1083
+ }
1084
+ return out;
1085
+ }
1086
+ /** Reads any stored or older `links` value into the nested shape adminV1 saves. */
1087
+ function normalizeMerchantLinks(links) {
1088
+ return merchantLinksFromForm(merchantLinksToForm(links));
1089
+ }
1090
+ /**
1091
+ * Object fields of the merchant. The API keeps one you leave out, and
1092
+ * replaces it as a whole when you send it.
1093
+ */
1094
+ const MERCHANT_OBJECT_FIELDS = [
1095
+ "colours",
1096
+ "links",
1097
+ "imageIds"
1098
+ ];
1099
+ /** Fields the API sets itself; `updateMerchant` never sends them back. */
1100
+ const MERCHANT_READ_ONLY_FIELDS = ["createdDate", "modifiedDate"];
1101
+ /**
1102
+ * Longest values the API stores. Longer values are cut off silently
1103
+ * (live API, Sep 2026), so `updateMerchant` rejects them instead.
1104
+ * `vatNumber` fits a UK VAT number without the `GB` prefix.
1105
+ */
1106
+ const MERCHANT_FIELD_MAX_LENGTH = Object.freeze({
1107
+ companyNumber: 11,
1108
+ vatNumber: 9
1109
+ });
1110
+ /** Returns `"<field> is longer than <n> characters"` messages for over-long fields. */
1111
+ function validateMerchant(merchant) {
1112
+ const problems = [];
1113
+ for (const [field, max] of Object.entries(MERCHANT_FIELD_MAX_LENGTH)) {
1114
+ const value = merchant[field];
1115
+ if (typeof value === "string" && value.length > max) problems.push(`${field} is longer than ${max} characters`);
1116
+ }
1117
+ return problems;
1118
+ }
1119
+ /**
1120
+ * Reads the logged-in user's merchant (a user has exactly one), or `null`
1121
+ * when the API returns none.
1122
+ */
1123
+ async function fetchMerchant(options = {}) {
1124
+ const json = await apiGet(MERCHANT_PATH, {}, options);
1125
+ assertNoApiErrors(json, 200);
1126
+ return json?.merchant ?? null;
1127
+ }
1128
+ /**
1129
+ * Updates the merchant and returns it as stored.
1130
+ *
1131
+ * Shopwave's `POST /merchant` replaces the whole record: a scalar field you
1132
+ * leave out (`companyNumber`, `vatNumber`, `note`, …) is set to null, while
1133
+ * an object field you leave out (`colours`, `links`, `imageIds`) is kept and
1134
+ * one you send replaces the stored one; sending `{}` clears it (live API,
1135
+ * Sep 2026). So by default the
1136
+ * current merchant is read first and the patch is merged over it.
1137
+ *
1138
+ * `imageIds` is always sent as file names ({@link toStoredImageIds}), so you
1139
+ * can pass back what you read. See `MerchantImageIds` for the shape.
1140
+ *
1141
+ * The API answers 205 with an empty body, so the merchant is read again after
1142
+ * the save and that is what's returned (it shows exactly what was kept).
1143
+ * Throws before sending when a value is longer than the API stores
1144
+ * ({@link MERCHANT_FIELD_MAX_LENGTH}).
1145
+ */
1146
+ async function updateMerchant(patch, options = {}) {
1147
+ const { merge = true,...requestOptions } = options;
1148
+ let merchant = { ...patch };
1149
+ if (merge) {
1150
+ const current = await fetchMerchant(requestOptions);
1151
+ if (!current) throw new ShopwaveApiError(404, "Shopwave API error (404): no merchant to update");
1152
+ 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})`);
1153
+ merchant = {
1154
+ ...current,
1155
+ ...patch,
1156
+ id: current.id
1157
+ };
1158
+ for (const field of MERCHANT_OBJECT_FIELDS) if (!(field in patch)) delete merchant[field];
1159
+ }
1160
+ if (merchant.imageIds && typeof merchant.imageIds === "object") merchant.imageIds = toStoredImageIds(merchant.imageIds);
1161
+ if (merchant.id == null || merchant.id === "") throw new Error("merchant id is missing");
1162
+ for (const field of MERCHANT_READ_ONLY_FIELDS) delete merchant[field];
1163
+ const problems = validateMerchant(merchant);
1164
+ if (problems.length > 0) throw new Error(`Invalid merchant: ${problems.join("; ")}`);
1165
+ const { status, body: raw } = await apiRequest(MERCHANT_PATH, {
1166
+ method: "PUT",
1167
+ headers: { "Content-Type": "application/json" },
1168
+ body: JSON.stringify({ merchant })
1169
+ }, requestOptions);
1170
+ const body = raw;
1171
+ assertNoApiErrors(body, status);
1172
+ const echoed = body?.merchant && typeof body.merchant === "object" ? body.merchant : null;
1173
+ if (echoed) return {
1174
+ ...merchant,
1175
+ ...echoed,
1176
+ id: Number(echoed.id ?? merchant.id)
1177
+ };
1178
+ return await fetchMerchant(requestOptions) ?? {
1179
+ ...merchant,
1180
+ id: Number(merchant.id)
1181
+ };
1182
+ }
1183
+
1184
+ //#endregion
1185
+ //#region src/core/user.ts
1186
+ /** Reads the logged-in user, or `null` when the API returns none. */
1187
+ async function fetchUser(options = {}) {
1188
+ const json = await apiGet(`/api/${SHOPWAVE_RESOURCES.user.route}`, {}, options);
1189
+ assertNoApiErrors(json, 200);
1190
+ return json?.user ?? null;
1191
+ }
1192
+
437
1193
  //#endregion
438
1194
  //#region src/hooks/shared.ts
439
1195
  function toMessage(e) {
440
1196
  if (e instanceof Error) return e.message;
441
1197
  return String(e);
442
1198
  }
1199
+ function toStatus(e) {
1200
+ return e instanceof ShopwaveApiError ? e.status : null;
1201
+ }
1202
+ function queryReducer(state, action) {
1203
+ switch (action.type) {
1204
+ case "start":
1205
+ if (action.key !== state.key) return {
1206
+ data: null,
1207
+ key: action.key,
1208
+ fetching: true,
1209
+ error: null,
1210
+ errorStatus: null
1211
+ };
1212
+ return {
1213
+ ...state,
1214
+ fetching: true,
1215
+ error: null,
1216
+ errorStatus: null
1217
+ };
1218
+ case "success":
1219
+ if (action.key !== state.key) return state;
1220
+ return {
1221
+ data: action.data,
1222
+ key: action.key,
1223
+ fetching: false,
1224
+ error: null,
1225
+ errorStatus: null
1226
+ };
1227
+ case "failure":
1228
+ if (action.key !== state.key) return state;
1229
+ return {
1230
+ ...state,
1231
+ fetching: false,
1232
+ error: action.error,
1233
+ errorStatus: action.errorStatus
1234
+ };
1235
+ case "idle": return state.fetching ? {
1236
+ ...state,
1237
+ fetching: false
1238
+ } : state;
1239
+ default: return state;
1240
+ }
1241
+ }
1242
+ /** Public view of the internal state for the params currently being rendered. */
1243
+ function selectQueryView(state, key, enabled) {
1244
+ if (state.key !== key) return {
1245
+ data: null,
1246
+ loading: enabled,
1247
+ fetching: enabled,
1248
+ error: null,
1249
+ errorStatus: null
1250
+ };
1251
+ return {
1252
+ data: state.data,
1253
+ loading: state.fetching && state.data === null,
1254
+ fetching: state.fetching,
1255
+ error: state.error,
1256
+ errorStatus: state.errorStatus
1257
+ };
1258
+ }
1259
+ function depsKey(deps) {
1260
+ try {
1261
+ return JSON.stringify(deps);
1262
+ } catch {
1263
+ return String(deps);
1264
+ }
1265
+ }
443
1266
  /**
444
1267
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
445
1268
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
446
- * on unmount or dep change, and exposes `refetch`.
1269
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
1270
+ * visible until the new data arrives).
447
1271
  *
448
1272
  * When `enabled` is false the fetch is skipped (used for hooks that need
449
1273
  * arguments before they can run, e.g. consumers-by-id).
450
1274
  */
451
1275
  function useQuery(fetcher, deps, enabled = true) {
452
- const [data, setData] = useState(null);
453
- const [loading, setLoading] = useState(enabled);
454
- const [error, setError] = useState(null);
1276
+ const key = depsKey(deps);
1277
+ const [state, dispatch] = useReducer(queryReducer, {
1278
+ data: null,
1279
+ key: enabled ? key : null,
1280
+ fetching: enabled,
1281
+ error: null,
1282
+ errorStatus: null
1283
+ });
455
1284
  const [tick, setTick] = useState(0);
456
1285
  const refetch = useCallback(() => setTick((t) => t + 1), []);
457
1286
  useEffect(() => {
458
1287
  if (!enabled) {
459
- setLoading(false);
1288
+ dispatch({ type: "idle" });
460
1289
  return;
461
1290
  }
462
1291
  const controller = new AbortController();
463
- let active = true;
464
- setData(null);
465
- setError(null);
466
- setLoading(true);
1292
+ dispatch({
1293
+ type: "start",
1294
+ key
1295
+ });
467
1296
  fetcher(controller.signal).then((result) => {
468
- if (active) setData(result);
1297
+ if (!controller.signal.aborted) dispatch({
1298
+ type: "success",
1299
+ key,
1300
+ data: result
1301
+ });
469
1302
  }).catch((e) => {
470
- if (active && e?.name !== "AbortError") setError(toMessage(e));
471
- }).finally(() => {
472
- if (active) setLoading(false);
1303
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
1304
+ type: "failure",
1305
+ key,
1306
+ error: toMessage(e),
1307
+ errorStatus: toStatus(e)
1308
+ });
473
1309
  });
474
1310
  return () => {
475
- active = false;
476
1311
  controller.abort();
477
1312
  };
478
1313
  }, [
479
- ...deps,
1314
+ key,
480
1315
  tick,
481
1316
  enabled
482
1317
  ]);
483
1318
  return {
484
- data,
485
- loading,
486
- error,
1319
+ ...selectQueryView(state, key, enabled),
487
1320
  refetch
488
1321
  };
489
1322
  }
@@ -494,16 +1327,19 @@ function useMutation(action, deps) {
494
1327
  const [data, setData] = useState(null);
495
1328
  const [loading, setLoading] = useState(false);
496
1329
  const [error, setError] = useState(null);
1330
+ const [errorStatus, setErrorStatus] = useState(null);
497
1331
  return {
498
1332
  mutate: useCallback(async (...args) => {
499
1333
  setLoading(true);
500
1334
  setError(null);
1335
+ setErrorStatus(null);
501
1336
  try {
502
1337
  const result = await action(...args);
503
1338
  setData(result);
504
1339
  return result;
505
1340
  } catch (e) {
506
1341
  setError(toMessage(e));
1342
+ setErrorStatus(toStatus(e));
507
1343
  return null;
508
1344
  } finally {
509
1345
  setLoading(false);
@@ -511,7 +1347,8 @@ function useMutation(action, deps) {
511
1347
  }, deps),
512
1348
  data,
513
1349
  loading,
514
- error
1350
+ error,
1351
+ errorStatus
515
1352
  };
516
1353
  }
517
1354
 
@@ -710,6 +1547,85 @@ function useSession(options) {
710
1547
  }), [options?.baseUrl]);
711
1548
  }
712
1549
 
1550
+ //#endregion
1551
+ //#region src/hooks/useMerchant.ts
1552
+ /**
1553
+ * The logged-in user's merchant, fetched on mount. `data` is `null` while
1554
+ * loading or when the API has no merchant.
1555
+ *
1556
+ * @example
1557
+ * const { data: merchant, loading, refetch } = useMerchant();
1558
+ */
1559
+ function useMerchant(options) {
1560
+ return useQuery((signal) => fetchMerchant({
1561
+ ...options,
1562
+ signal
1563
+ }), [options?.baseUrl, options?.token]);
1564
+ }
1565
+ /**
1566
+ * Manually-triggered merchant update (see `updateMerchant`: the patch is merged
1567
+ * over the current merchant by default). Resolves to the saved merchant, or
1568
+ * `null` on error (then `error` / `errorStatus` are set).
1569
+ *
1570
+ * @example
1571
+ * const { mutate: saveMerchant, loading: saving } = useUpdateMerchant();
1572
+ * const saved = await saveMerchant({ vatNumber: "GB123" });
1573
+ */
1574
+ function useUpdateMerchant(options) {
1575
+ return useMutation((patch) => updateMerchant(patch, options), [
1576
+ options?.baseUrl,
1577
+ options?.token,
1578
+ options?.merge
1579
+ ]);
1580
+ }
1581
+
1582
+ //#endregion
1583
+ //#region src/hooks/useUser.ts
1584
+ /**
1585
+ * The logged-in user, fetched on mount. `errorStatus` is 401 when logged out.
1586
+ *
1587
+ * @example
1588
+ * const { data: user } = useUser();
1589
+ */
1590
+ function useUser(options) {
1591
+ const enabled = options?.enabled ?? true;
1592
+ return useQuery((signal) => fetchUser({
1593
+ ...options,
1594
+ signal
1595
+ }), [options?.baseUrl, options?.token], enabled);
1596
+ }
1597
+
1598
+ //#endregion
1599
+ //#region src/hooks/useUploadImage.ts
1600
+ /**
1601
+ * Manually-triggered image upload. Resolves to `{ id, url }`, or `null` on
1602
+ * error (then `error` / `errorStatus` are set).
1603
+ *
1604
+ * @example
1605
+ * const { mutate: upload, loading: uploading } = useUploadImage();
1606
+ * const image = await upload(file, { kind: "merchant" });
1607
+ */
1608
+ function useUploadImage(options) {
1609
+ return useMutation((file, params) => uploadImage(file, params, options), [options?.baseUrl, options?.token]);
1610
+ }
1611
+ /**
1612
+ * Manually-triggered upload for a merchant image slot (`receipt`, `square`,
1613
+ * `featured`): fits the image to the slot's exact size, uploads it with the
1614
+ * slot's kind, and resolves to `{ id, url }`. Put `id` into `imageIds` with
1615
+ * `setMerchantImage`.
1616
+ *
1617
+ * @example
1618
+ * const { mutate: upload } = useUploadMerchantImage();
1619
+ * const image = await upload(file, "square");
1620
+ */
1621
+ function useUploadMerchantImage(options) {
1622
+ return useMutation((file, slot) => uploadMerchantImage(file, slot, options), [
1623
+ options?.baseUrl,
1624
+ options?.token,
1625
+ options?.fit
1626
+ ]);
1627
+ }
1628
+
713
1629
  //#endregion
714
1630
  //#region src/hooks/useBasketReport.ts
715
1631
  /**
@@ -814,5 +1730,5 @@ function useBasketReport(options) {
814
1730
  }
815
1731
 
816
1732
  //#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 };
1733
+ export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, MERCHANT_COLOUR_KEYS, MERCHANT_FIELD_MAX_LENGTH, MERCHANT_IMAGE_SLOTS, MERCHANT_IMAGE_SLOT_KEYS, MERCHANT_LINK_KEYS, MERCHANT_LINK_PATHS, MERCHANT_OBJECT_FIELDS, MERCHANT_READ_ONLY_FIELDS, PromotionRuleSet, SHOPWAVE_ENTITIES, SHOPWAVE_IMAGE_BASE_URL, SHOPWAVE_RESOURCES, SHOPWAVE_UPLOAD, ShopwaveApiError, UPLOAD_KINDS, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, coverCrop, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchMerchant, fetchProduct, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, fetchUser, fitImageToSize, formatDateForQuery, getApiErrorMap, getApiErrors, getImageUrl, getMerchantImage, getMerchantImageUrl, imageFileName, isUploadKind, loginPath, logout, logoutPath, merchantLinksFromForm, merchantLinksToForm, normalizeMerchantLinks, parseBasketReportData, readImageSize, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, setMerchantImage, submitEntity, toShopwaveApiError, toStoredImageIds, updateMerchant, uploadImage, uploadMerchantImage, useBasketReport, useCategory, useConsumer, useDelete, useEmployee, useLogout, useMerchant, useProduct, usePromotion, useReport, useSession, useStore, useSubmit, useUpdateMerchant, useUploadImage, useUploadMerchantImage, useUser, validateMerchant, withToken };
818
1734
  //# sourceMappingURL=index.js.map