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.
@@ -1,88 +1,193 @@
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
+ }
17
116
  /**
18
- * Returns a JSON string of API errors if the response carries any, otherwise
19
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
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).
20
120
  */
21
- function getApiErrors(api) {
22
- const errors = api?.message?.errors;
23
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
24
- return null;
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
+ };
25
149
  }
26
150
  /**
27
- * GET helper. Sends request metadata via the `extras` header exactly like the
28
- * original hooks did, parses JSON, and returns the typed payload.
151
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
152
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
29
153
  */
30
154
  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, {
155
+ const { body } = await send(path, {
39
156
  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();
157
+ headers: {
158
+ ...baseHeaders(options),
159
+ extras: JSON.stringify(buildExtras(extras))
160
+ }
161
+ }, options);
162
+ return body;
45
163
  }
46
164
  /**
47
- * JSON body helper for POST/PUT mutations.
165
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
166
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
48
167
  */
49
168
  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, {
169
+ const { body } = await send(path, {
60
170
  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();
171
+ headers: {
172
+ ...baseHeaders(options),
173
+ "Content-Type": "application/json"
174
+ },
175
+ body: JSON.stringify(payload)
176
+ }, options);
177
+ return body;
67
178
  }
68
179
  /**
69
- * DELETE helper.
180
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
181
+ * does come back and carries API errors, they are thrown. Returns the parsed
182
+ * body (`null` when empty).
70
183
  */
71
184
  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, {
185
+ const { status, body } = await send(path, {
81
186
  method: "DELETE",
82
- headers,
83
- signal: options.signal
84
- });
85
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
187
+ headers: baseHeaders(options)
188
+ }, options);
189
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
190
+ return body;
86
191
  }
87
192
 
88
193
  //#endregion
@@ -94,11 +199,9 @@ async function apiDelete(path, options = {}) {
94
199
  async function fetchCategories(params = {}, options = {}) {
95
200
  const extras = { deleted: params.deleted ?? false };
96
201
  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 ?? {});
202
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
203
+ assertNoApiErrors(json, 200);
204
+ return Object.values(json?.categories ?? {});
102
205
  }
103
206
 
104
207
  //#endregion
@@ -110,9 +213,8 @@ async function fetchCategories(params = {}, options = {}) {
110
213
  async function fetchConsumers(consumerIds, options = {}) {
111
214
  if (!consumerIds || consumerIds.length === 0) return [];
112
215
  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 ?? {});
216
+ assertNoApiErrors(json, 200);
217
+ return Object.values(json?.consumers ?? {});
116
218
  }
117
219
 
118
220
  //#endregion
@@ -123,10 +225,9 @@ async function fetchConsumers(consumerIds, options = {}) {
123
225
  async function fetchEmployees(params = {}, options = {}) {
124
226
  const extras = { deleted: params.deleted ?? false };
125
227
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
126
- const json = await apiGet("/api/employees", extras, options);
127
- const error = getApiErrors(json.api);
128
- if (error) throw new Error(error);
129
- return Object.values(json.employees ?? {});
228
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
229
+ assertNoApiErrors(json, 200);
230
+ return Object.values(json?.employees ?? {});
130
231
  }
131
232
 
132
233
  //#endregion
@@ -138,11 +239,9 @@ async function fetchProducts(params = {}, options = {}) {
138
239
  const extras = { deleted: params.deleted ?? false };
139
240
  if (params.productIds) extras.productIds = params.productIds;
140
241
  if (params.storeId) extras.storeId = params.storeId;
141
- if (params.token) extras.token = params.token;
142
- const json = await apiGet("/api/products", extras, options);
143
- const error = getApiErrors(json.api);
144
- if (error) throw new Error(error);
145
- return Object.values(json.products ?? {});
242
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
243
+ assertNoApiErrors(json, 200);
244
+ return Object.values(json?.products ?? {});
146
245
  }
147
246
  /**
148
247
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -171,11 +270,9 @@ async function fetchPromotions(params = {}, options = {}) {
171
270
  if (params.imminent !== void 0) extras.imminent = params.imminent;
172
271
  if (params.active !== void 0) extras.active = params.active;
173
272
  if (params.expired !== void 0) extras.expired = params.expired;
174
- if (params.token) extras.token = params.token;
175
- const json = await apiGet("/api/promotions", extras, options);
176
- const error = getApiErrors(json.api);
177
- if (error) throw new Error(error);
178
- return Object.values(json.promotions ?? {});
273
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
274
+ assertNoApiErrors(json, 200);
275
+ return Object.values(json?.promotions ?? {});
179
276
  }
180
277
 
181
278
  //#endregion
@@ -184,10 +281,11 @@ async function fetchPromotions(params = {}, options = {}) {
184
281
  * Fetches stores. Returns the flattened `Store[]`.
185
282
  */
186
283
  async function fetchStores(params = {}, options = {}) {
187
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
188
- const error = getApiErrors(json.api);
189
- if (error) throw new Error(error);
190
- return Object.values(json.stores ?? {});
284
+ const extras = { deleted: params.deleted ?? false };
285
+ if (params.storeIds) extras.storeIds = params.storeIds;
286
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
287
+ assertNoApiErrors(json, 200);
288
+ return Object.values(json?.stores ?? {});
191
289
  }
192
290
 
193
291
  //#endregion
@@ -199,9 +297,8 @@ async function fetchStores(params = {}, options = {}) {
199
297
  */
200
298
  async function fetchReport(query, options = {}) {
201
299
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
202
- const error = getApiErrors(json.api);
203
- if (error) throw new Error(error);
204
- return json.reports ?? {};
300
+ assertNoApiErrors(json, 200);
301
+ return json?.reports ?? {};
205
302
  }
206
303
 
207
304
  //#endregion
@@ -245,10 +342,12 @@ async function logout(options = {}) {
245
342
  //#endregion
246
343
  //#region src/core/entity.ts
247
344
  /**
248
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
249
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
250
- * UI concern is deliberately left to the hooks/host layer so core stays
251
- * dependency-free.
345
+ * Low-level, untyped mutations: the caller supplies the route and payload.
346
+ *
347
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
348
+ * …): they know each route, build the request envelope, check the response and
349
+ * return the saved entity with its id. Keep these for routes that have no typed
350
+ * function yet (e.g. `/api/merchant`).
252
351
  */
253
352
  /**
254
353
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -258,11 +357,15 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
258
357
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
259
358
  }
260
359
  /**
261
- * Sends a create/update mutation and returns the parsed `result` field
262
- * (matching the original `result.result` access).
360
+ * Sends a create/update mutation. Returns the response's `result` field when it
361
+ * has one (older app routes wrapped their answer that way), otherwise the whole
362
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
263
363
  */
264
364
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
265
- return (await apiSend(endpoint, method, payload, options)).result;
365
+ const json = await apiSend(endpoint, method, payload, options);
366
+ assertNoApiErrors(json, 200);
367
+ if (json && typeof json === "object" && "result" in json) return json.result;
368
+ return json ?? void 0;
266
369
  }
267
370
 
268
371
  //#endregion
@@ -409,56 +512,491 @@ function buildConsumerMap(consumers) {
409
512
  return map;
410
513
  }
411
514
 
515
+ //#endregion
516
+ //#region src/core/resources.ts
517
+ /**
518
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
519
+ * updates it (without an `id` the API would create a second merchant, so the
520
+ * route requires one).
521
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
522
+ * needs the admin scope).
523
+ */
524
+ const SHOPWAVE_RESOURCES = Object.freeze({
525
+ merchant: {
526
+ route: "merchant",
527
+ key: "merchant",
528
+ upstream: "merchant",
529
+ writable: true
530
+ },
531
+ user: {
532
+ route: "user",
533
+ key: "user",
534
+ upstream: "user",
535
+ writable: false
536
+ }
537
+ });
538
+ /** `UploadKind` → the API's `contentType` value. */
539
+ const UPLOAD_KINDS = Object.freeze({
540
+ merchant: "merchant",
541
+ merchantFeature: "merchantFeature",
542
+ product: "product",
543
+ user: "user",
544
+ applicationLogo: "applicaionLogo",
545
+ applicationImages: "applicaionImages"
546
+ });
547
+ /** How uploads are addressed (app route and Shopwave uploader). */
548
+ const SHOPWAVE_UPLOAD = Object.freeze({
549
+ route: "upload",
550
+ upstream: "uploader",
551
+ kindHeader: "contentType",
552
+ fileField: "file",
553
+ kindField: "kind"
554
+ });
555
+ function isUploadKind(value) {
556
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
557
+ }
558
+
559
+ //#endregion
560
+ //#region src/core/imageFit.ts
561
+ /**
562
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
563
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
564
+ */
565
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
566
+ if (![
567
+ srcWidth,
568
+ srcHeight,
569
+ dstWidth,
570
+ dstHeight
571
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
572
+ const srcRatio = srcWidth / srcHeight;
573
+ const dstRatio = dstWidth / dstHeight;
574
+ if (srcRatio > dstRatio) {
575
+ const sw = Math.round(srcHeight * dstRatio);
576
+ return {
577
+ sx: Math.round((srcWidth - sw) / 2),
578
+ sy: 0,
579
+ sw,
580
+ sh: srcHeight
581
+ };
582
+ }
583
+ const sh = Math.round(srcWidth / dstRatio);
584
+ return {
585
+ sx: 0,
586
+ sy: Math.round((srcHeight - sh) / 2),
587
+ sw: srcWidth,
588
+ sh
589
+ };
590
+ }
591
+ function requireBrowserImages(fn) {
592
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
593
+ }
594
+ /**
595
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
596
+ * (never stretched). Browser only. The result is a `File` named like the
597
+ * input, with the output type's extension.
598
+ */
599
+ async function fitImageToSize(file, width, height, options = {}) {
600
+ requireBrowserImages("fitImageToSize");
601
+ const { type = "image/png", quality, keepIfExact = true } = options;
602
+ const name = file.name || "image";
603
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
604
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
605
+ const bitmap = await createImageBitmap(file);
606
+ try {
607
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
608
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
609
+ let blob;
610
+ if (typeof OffscreenCanvas === "function") {
611
+ const canvas = new OffscreenCanvas(width, height);
612
+ const ctx = canvas.getContext("2d");
613
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
614
+ ctx.imageSmoothingQuality = "high";
615
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
616
+ blob = await canvas.convertToBlob({
617
+ type,
618
+ quality
619
+ });
620
+ } else {
621
+ const canvas = document.createElement("canvas");
622
+ canvas.width = width;
623
+ canvas.height = height;
624
+ const ctx = canvas.getContext("2d");
625
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
626
+ ctx.imageSmoothingQuality = "high";
627
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
628
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
629
+ }
630
+ return new File([blob], outName, { type });
631
+ } finally {
632
+ bitmap.close?.();
633
+ }
634
+ }
635
+
636
+ //#endregion
637
+ //#region src/core/upload.ts
638
+ /**
639
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
640
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
641
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
642
+ */
643
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
644
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
645
+ /**
646
+ * Turns an image path from the API into a URL a page can load over HTTPS:
647
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
648
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
649
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
650
+ */
651
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
652
+ if (!path) return "";
653
+ const root = base.replace(/\/+$/, "");
654
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
655
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
656
+ return `${root}/${path.replace(/^\/+/, "")}`;
657
+ }
658
+ /**
659
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
660
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
661
+ *
662
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
663
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
664
+ * 201 `{ fileName, path }`.
665
+ */
666
+ async function uploadImage(file, params, options = {}) {
667
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
668
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
669
+ const name = params.fileName ?? file.name ?? "upload";
670
+ const form = new FormData();
671
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
672
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
673
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
674
+ method: "POST",
675
+ body: form
676
+ }, options);
677
+ const body = raw;
678
+ assertNoApiErrors(body, status);
679
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
680
+ const path = typeof body?.path === "string" ? body.path : "";
681
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
682
+ return {
683
+ id,
684
+ url: getImageUrl(path),
685
+ path
686
+ };
687
+ }
688
+
689
+ //#endregion
690
+ //#region src/core/merchantImages.ts
691
+ /**
692
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
693
+ * merchant settings page).
694
+ */
695
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
696
+ receipt: {
697
+ field: "logo.receipt",
698
+ uploadKind: "merchant",
699
+ width: 576,
700
+ height: 325
701
+ },
702
+ square: {
703
+ field: "logo.square",
704
+ uploadKind: "merchant",
705
+ width: 1e3,
706
+ height: 1e3
707
+ },
708
+ featured: {
709
+ field: "feature[0]",
710
+ uploadKind: "merchantFeature",
711
+ width: 1200,
712
+ height: 600
713
+ }
714
+ });
715
+ /**
716
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
717
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
718
+ * Returns `null` for empty or non-string values.
719
+ */
720
+ function imageFileName(value) {
721
+ if (typeof value !== "string") return null;
722
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
723
+ return last ? decodeURIComponent(last) : null;
724
+ }
725
+ function toStored(value) {
726
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
727
+ if (Array.isArray(value)) {
728
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
729
+ return items.length > 0 ? items : void 0;
730
+ }
731
+ if (value && typeof value === "object") {
732
+ const out = {};
733
+ for (const [k, v] of Object.entries(value)) {
734
+ const stored = toStored(v);
735
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
736
+ }
737
+ return Object.keys(out).length > 0 ? out : void 0;
738
+ }
739
+ }
740
+ /**
741
+ * The `imageIds` to send on a save: every URL is turned back into its file
742
+ * name (what adminV1 does with images you didn't change) and empty values
743
+ * are dropped. No images gives `{}`.
744
+ */
745
+ function toStoredImageIds(imageIds) {
746
+ return toStored(imageIds) ?? {};
747
+ }
748
+ /**
749
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
750
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
751
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
752
+ */
753
+ async function uploadMerchantImage(file, slot, options = {}) {
754
+ const def = MERCHANT_IMAGE_SLOTS[slot];
755
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
756
+ const { fit = true, fitOptions,...requestOptions } = options;
757
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
758
+ }
759
+
760
+ //#endregion
761
+ //#region src/core/merchant.ts
762
+ const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
763
+ /** Where each flat link field lives in `merchant.links`. */
764
+ const MERCHANT_LINK_PATHS = Object.freeze({
765
+ website: ["website", "home"],
766
+ twitter: ["social", "twitter"],
767
+ facebook: ["social", "facebook"],
768
+ instagram: ["social", "instagram"]
769
+ });
770
+ /**
771
+ * Object fields of the merchant. The API keeps one you leave out, and
772
+ * replaces it as a whole when you send it.
773
+ */
774
+ const MERCHANT_OBJECT_FIELDS = [
775
+ "colours",
776
+ "links",
777
+ "imageIds"
778
+ ];
779
+ /** Fields the API sets itself; `updateMerchant` never sends them back. */
780
+ const MERCHANT_READ_ONLY_FIELDS = ["createdDate", "modifiedDate"];
781
+ /**
782
+ * Longest values the API stores. Longer values are cut off silently
783
+ * (live API, Sep 2026), so `updateMerchant` rejects them instead.
784
+ * `vatNumber` fits a UK VAT number without the `GB` prefix.
785
+ */
786
+ const MERCHANT_FIELD_MAX_LENGTH = Object.freeze({
787
+ companyNumber: 11,
788
+ vatNumber: 9
789
+ });
790
+ /** Returns `"<field> is longer than <n> characters"` messages for over-long fields. */
791
+ function validateMerchant(merchant) {
792
+ const problems = [];
793
+ for (const [field, max] of Object.entries(MERCHANT_FIELD_MAX_LENGTH)) {
794
+ const value = merchant[field];
795
+ if (typeof value === "string" && value.length > max) problems.push(`${field} is longer than ${max} characters`);
796
+ }
797
+ return problems;
798
+ }
799
+ /**
800
+ * Reads the logged-in user's merchant (a user has exactly one), or `null`
801
+ * when the API returns none.
802
+ */
803
+ async function fetchMerchant(options = {}) {
804
+ const json = await apiGet(MERCHANT_PATH, {}, options);
805
+ assertNoApiErrors(json, 200);
806
+ return json?.merchant ?? null;
807
+ }
808
+ /**
809
+ * Updates the merchant and returns it as stored.
810
+ *
811
+ * Shopwave's `POST /merchant` replaces the whole record: a scalar field you
812
+ * leave out (`companyNumber`, `vatNumber`, `note`, …) is set to null, while
813
+ * an object field you leave out (`colours`, `links`, `imageIds`) is kept and
814
+ * one you send replaces the stored one; sending `{}` clears it (live API,
815
+ * Sep 2026). So by default the
816
+ * current merchant is read first and the patch is merged over it.
817
+ *
818
+ * `imageIds` is always sent as file names ({@link toStoredImageIds}), so you
819
+ * can pass back what you read. See `MerchantImageIds` for the shape.
820
+ *
821
+ * The API answers 205 with an empty body, so the merchant is read again after
822
+ * the save and that is what's returned (it shows exactly what was kept).
823
+ * Throws before sending when a value is longer than the API stores
824
+ * ({@link MERCHANT_FIELD_MAX_LENGTH}).
825
+ */
826
+ async function updateMerchant(patch, options = {}) {
827
+ const { merge = true,...requestOptions } = options;
828
+ let merchant = { ...patch };
829
+ if (merge) {
830
+ const current = await fetchMerchant(requestOptions);
831
+ if (!current) throw new ShopwaveApiError(404, "Shopwave API error (404): no merchant to update");
832
+ 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})`);
833
+ merchant = {
834
+ ...current,
835
+ ...patch,
836
+ id: current.id
837
+ };
838
+ for (const field of MERCHANT_OBJECT_FIELDS) if (!(field in patch)) delete merchant[field];
839
+ }
840
+ if (merchant.imageIds && typeof merchant.imageIds === "object") merchant.imageIds = toStoredImageIds(merchant.imageIds);
841
+ if (merchant.id == null || merchant.id === "") throw new Error("merchant id is missing");
842
+ for (const field of MERCHANT_READ_ONLY_FIELDS) delete merchant[field];
843
+ const problems = validateMerchant(merchant);
844
+ if (problems.length > 0) throw new Error(`Invalid merchant: ${problems.join("; ")}`);
845
+ const { status, body: raw } = await apiRequest(MERCHANT_PATH, {
846
+ method: "PUT",
847
+ headers: { "Content-Type": "application/json" },
848
+ body: JSON.stringify({ merchant })
849
+ }, requestOptions);
850
+ const body = raw;
851
+ assertNoApiErrors(body, status);
852
+ const echoed = body?.merchant && typeof body.merchant === "object" ? body.merchant : null;
853
+ if (echoed) return {
854
+ ...merchant,
855
+ ...echoed,
856
+ id: Number(echoed.id ?? merchant.id)
857
+ };
858
+ return await fetchMerchant(requestOptions) ?? {
859
+ ...merchant,
860
+ id: Number(merchant.id)
861
+ };
862
+ }
863
+
864
+ //#endregion
865
+ //#region src/core/user.ts
866
+ /** Reads the logged-in user, or `null` when the API returns none. */
867
+ async function fetchUser(options = {}) {
868
+ const json = await apiGet(`/api/${SHOPWAVE_RESOURCES.user.route}`, {}, options);
869
+ assertNoApiErrors(json, 200);
870
+ return json?.user ?? null;
871
+ }
872
+
412
873
  //#endregion
413
874
  //#region src/hooks/shared.ts
414
875
  function toMessage(e) {
415
876
  if (e instanceof Error) return e.message;
416
877
  return String(e);
417
878
  }
879
+ function toStatus(e) {
880
+ return e instanceof ShopwaveApiError ? e.status : null;
881
+ }
882
+ function queryReducer(state, action) {
883
+ switch (action.type) {
884
+ case "start":
885
+ if (action.key !== state.key) return {
886
+ data: null,
887
+ key: action.key,
888
+ fetching: true,
889
+ error: null,
890
+ errorStatus: null
891
+ };
892
+ return {
893
+ ...state,
894
+ fetching: true,
895
+ error: null,
896
+ errorStatus: null
897
+ };
898
+ case "success":
899
+ if (action.key !== state.key) return state;
900
+ return {
901
+ data: action.data,
902
+ key: action.key,
903
+ fetching: false,
904
+ error: null,
905
+ errorStatus: null
906
+ };
907
+ case "failure":
908
+ if (action.key !== state.key) return state;
909
+ return {
910
+ ...state,
911
+ fetching: false,
912
+ error: action.error,
913
+ errorStatus: action.errorStatus
914
+ };
915
+ case "idle": return state.fetching ? {
916
+ ...state,
917
+ fetching: false
918
+ } : state;
919
+ default: return state;
920
+ }
921
+ }
922
+ /** Public view of the internal state for the params currently being rendered. */
923
+ function selectQueryView(state, key, enabled) {
924
+ if (state.key !== key) return {
925
+ data: null,
926
+ loading: enabled,
927
+ fetching: enabled,
928
+ error: null,
929
+ errorStatus: null
930
+ };
931
+ return {
932
+ data: state.data,
933
+ loading: state.fetching && state.data === null,
934
+ fetching: state.fetching,
935
+ error: state.error,
936
+ errorStatus: state.errorStatus
937
+ };
938
+ }
939
+ function depsKey(deps) {
940
+ try {
941
+ return JSON.stringify(deps);
942
+ } catch {
943
+ return String(deps);
944
+ }
945
+ }
418
946
  /**
419
947
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
420
948
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
421
- * on unmount or dep change, and exposes `refetch`.
949
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
950
+ * visible until the new data arrives).
422
951
  *
423
952
  * When `enabled` is false the fetch is skipped (used for hooks that need
424
953
  * arguments before they can run, e.g. consumers-by-id).
425
954
  */
426
955
  function useQuery(fetcher, deps, enabled = true) {
427
- const [data, setData] = useState(null);
428
- const [loading, setLoading] = useState(enabled);
429
- const [error, setError] = useState(null);
956
+ const key = depsKey(deps);
957
+ const [state, dispatch] = useReducer(queryReducer, {
958
+ data: null,
959
+ key: enabled ? key : null,
960
+ fetching: enabled,
961
+ error: null,
962
+ errorStatus: null
963
+ });
430
964
  const [tick, setTick] = useState(0);
431
965
  const refetch = useCallback(() => setTick((t) => t + 1), []);
432
966
  useEffect(() => {
433
967
  if (!enabled) {
434
- setLoading(false);
968
+ dispatch({ type: "idle" });
435
969
  return;
436
970
  }
437
971
  const controller = new AbortController();
438
- let active = true;
439
- setData(null);
440
- setError(null);
441
- setLoading(true);
972
+ dispatch({
973
+ type: "start",
974
+ key
975
+ });
442
976
  fetcher(controller.signal).then((result) => {
443
- if (active) setData(result);
977
+ if (!controller.signal.aborted) dispatch({
978
+ type: "success",
979
+ key,
980
+ data: result
981
+ });
444
982
  }).catch((e) => {
445
- if (active && e?.name !== "AbortError") setError(toMessage(e));
446
- }).finally(() => {
447
- if (active) setLoading(false);
983
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
984
+ type: "failure",
985
+ key,
986
+ error: toMessage(e),
987
+ errorStatus: toStatus(e)
988
+ });
448
989
  });
449
990
  return () => {
450
- active = false;
451
991
  controller.abort();
452
992
  };
453
993
  }, [
454
- ...deps,
994
+ key,
455
995
  tick,
456
996
  enabled
457
997
  ]);
458
998
  return {
459
- data,
460
- loading,
461
- error,
999
+ ...selectQueryView(state, key, enabled),
462
1000
  refetch
463
1001
  };
464
1002
  }
@@ -469,16 +1007,19 @@ function useMutation(action, deps) {
469
1007
  const [data, setData] = useState(null);
470
1008
  const [loading, setLoading] = useState(false);
471
1009
  const [error, setError] = useState(null);
1010
+ const [errorStatus, setErrorStatus] = useState(null);
472
1011
  return {
473
1012
  mutate: useCallback(async (...args) => {
474
1013
  setLoading(true);
475
1014
  setError(null);
1015
+ setErrorStatus(null);
476
1016
  try {
477
1017
  const result = await action(...args);
478
1018
  setData(result);
479
1019
  return result;
480
1020
  } catch (e) {
481
1021
  setError(toMessage(e));
1022
+ setErrorStatus(toStatus(e));
482
1023
  return null;
483
1024
  } finally {
484
1025
  setLoading(false);
@@ -486,7 +1027,8 @@ function useMutation(action, deps) {
486
1027
  }, deps),
487
1028
  data,
488
1029
  loading,
489
- error
1030
+ error,
1031
+ errorStatus
490
1032
  };
491
1033
  }
492
1034
 
@@ -685,6 +1227,85 @@ function useSession(options) {
685
1227
  }), [options?.baseUrl]);
686
1228
  }
687
1229
 
1230
+ //#endregion
1231
+ //#region src/hooks/useMerchant.ts
1232
+ /**
1233
+ * The logged-in user's merchant, fetched on mount. `data` is `null` while
1234
+ * loading or when the API has no merchant.
1235
+ *
1236
+ * @example
1237
+ * const { data: merchant, loading, refetch } = useMerchant();
1238
+ */
1239
+ function useMerchant(options) {
1240
+ return useQuery((signal) => fetchMerchant({
1241
+ ...options,
1242
+ signal
1243
+ }), [options?.baseUrl, options?.token]);
1244
+ }
1245
+ /**
1246
+ * Manually-triggered merchant update (see `updateMerchant`: the patch is merged
1247
+ * over the current merchant by default). Resolves to the saved merchant, or
1248
+ * `null` on error (then `error` / `errorStatus` are set).
1249
+ *
1250
+ * @example
1251
+ * const { mutate: saveMerchant, loading: saving } = useUpdateMerchant();
1252
+ * const saved = await saveMerchant({ vatNumber: "GB123" });
1253
+ */
1254
+ function useUpdateMerchant(options) {
1255
+ return useMutation((patch) => updateMerchant(patch, options), [
1256
+ options?.baseUrl,
1257
+ options?.token,
1258
+ options?.merge
1259
+ ]);
1260
+ }
1261
+
1262
+ //#endregion
1263
+ //#region src/hooks/useUser.ts
1264
+ /**
1265
+ * The logged-in user, fetched on mount. `errorStatus` is 401 when logged out.
1266
+ *
1267
+ * @example
1268
+ * const { data: user } = useUser();
1269
+ */
1270
+ function useUser(options) {
1271
+ const enabled = options?.enabled ?? true;
1272
+ return useQuery((signal) => fetchUser({
1273
+ ...options,
1274
+ signal
1275
+ }), [options?.baseUrl, options?.token], enabled);
1276
+ }
1277
+
1278
+ //#endregion
1279
+ //#region src/hooks/useUploadImage.ts
1280
+ /**
1281
+ * Manually-triggered image upload. Resolves to `{ id, url }`, or `null` on
1282
+ * error (then `error` / `errorStatus` are set).
1283
+ *
1284
+ * @example
1285
+ * const { mutate: upload, loading: uploading } = useUploadImage();
1286
+ * const image = await upload(file, { kind: "merchant" });
1287
+ */
1288
+ function useUploadImage(options) {
1289
+ return useMutation((file, params) => uploadImage(file, params, options), [options?.baseUrl, options?.token]);
1290
+ }
1291
+ /**
1292
+ * Manually-triggered upload for a merchant image slot (`receipt`, `square`,
1293
+ * `featured`): fits the image to the slot's exact size, uploads it with the
1294
+ * slot's kind, and resolves to `{ id, url }`. Put `id` into `imageIds` with
1295
+ * `setMerchantImage`.
1296
+ *
1297
+ * @example
1298
+ * const { mutate: upload } = useUploadMerchantImage();
1299
+ * const image = await upload(file, "square");
1300
+ */
1301
+ function useUploadMerchantImage(options) {
1302
+ return useMutation((file, slot) => uploadMerchantImage(file, slot, options), [
1303
+ options?.baseUrl,
1304
+ options?.token,
1305
+ options?.fit
1306
+ ]);
1307
+ }
1308
+
688
1309
  //#endregion
689
1310
  //#region src/hooks/useBasketReport.ts
690
1311
  /**
@@ -789,5 +1410,5 @@ function useBasketReport(options) {
789
1410
  }
790
1411
 
791
1412
  //#endregion
792
- export { useBasketReport, useCategory, useConsumer, useDelete, useEmployee, useLogout, useProduct, usePromotion, useReport, useSession, useStore, useSubmit };
1413
+ export { useBasketReport, useCategory, useConsumer, useDelete, useEmployee, useLogout, useMerchant, useProduct, usePromotion, useReport, useSession, useStore, useSubmit, useUpdateMerchant, useUploadImage, useUploadMerchantImage, useUser };
793
1414
  //# sourceMappingURL=index.js.map