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.
@@ -24,89 +24,194 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let react = require("react");
25
25
  react = __toESM(react);
26
26
 
27
+ //#region src/core/errors.ts
28
+ /**
29
+ * Thrown by every `core` function when a request fails, either at the HTTP
30
+ * level (`status` is the HTTP status) or because the Shopwave response carried
31
+ * `api.message.errors` (then `status` is the HTTP status of that response,
32
+ * usually 200/201, and `errors` holds the API errors).
33
+ *
34
+ * Branch on `status` (e.g. `isUnauthorized` → send the user to log in) rather
35
+ * than parsing `message`.
36
+ */
37
+ var ShopwaveApiError = class extends Error {
38
+ /** HTTP status of the response (0 when the request never got a response). */
39
+ status;
40
+ /** Shopwave API errors, when the response carried any. */
41
+ errors;
42
+ /** Parsed response body, when there was one. */
43
+ body;
44
+ constructor(status, message, details = {}) {
45
+ super(message, details.cause !== void 0 ? { cause: details.cause } : void 0);
46
+ this.name = "ShopwaveApiError";
47
+ this.status = status;
48
+ this.errors = details.errors ?? null;
49
+ this.body = details.body;
50
+ }
51
+ /** 401, or the Shopwave "token expired / invalid" error 908. */
52
+ get isUnauthorized() {
53
+ if (this.status === 401) return true;
54
+ return !!this.errors && Object.entries(this.errors).some(([k, v]) => k === "908" || Number(v?.id) === 908);
55
+ }
56
+ get isNotFound() {
57
+ return this.status === 404;
58
+ }
59
+ };
60
+ /** The `api.message.errors` (or `api.message.error`) map of a Shopwave response, or `null` when there are none. */
61
+ function getApiErrorMap(body) {
62
+ const message = body?.api?.message;
63
+ const errors = message?.errors ?? message?.error;
64
+ if (errors && typeof errors === "object" && Object.keys(errors).length > 0) return errors;
65
+ return null;
66
+ }
67
+ function describeErrors(errors) {
68
+ return Object.entries(errors).map(([code, e]) => {
69
+ const text = [e?.title, e?.details].filter(Boolean).join(": ");
70
+ return text ? `${code} ${text}` : code;
71
+ }).join("; ");
72
+ }
73
+ /**
74
+ * Builds a {@link ShopwaveApiError} for a response. Understands the Shopwave
75
+ * envelope (`api.message.errors`) and the `{ error, message }` bodies returned
76
+ * by the `react-shopwave-connect/next` routes.
77
+ */
78
+ function toShopwaveApiError(status, body, fallback) {
79
+ const errors = getApiErrorMap(body);
80
+ let detail = errors ? describeErrors(errors) : "";
81
+ if (!detail && body && typeof body === "object") {
82
+ const b = body;
83
+ if (typeof b.message === "string") detail = b.message;
84
+ else if (typeof b.error === "string") detail = b.error;
85
+ }
86
+ if (!detail) detail = fallback ?? (status >= 400 ? "request failed" : "API returned errors");
87
+ return new ShopwaveApiError(status, `Shopwave API error (${status}): ${detail}`, {
88
+ errors,
89
+ body
90
+ });
91
+ }
92
+ /** Throws a {@link ShopwaveApiError} when the response body carries API errors. */
93
+ function assertNoApiErrors(body, status) {
94
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
95
+ }
96
+
97
+ //#endregion
27
98
  //#region src/core/request.ts
28
99
  function resolveFetch(options) {
29
100
  const f = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
30
101
  if (!f) throw new Error("No fetch implementation available. Pass `options.fetch` (e.g. node-fetch / undici) when running outside the browser.");
31
102
  return f;
32
103
  }
33
- function buildExtras(extras, options) {
104
+ /** Options with `token` filled in from a legacy per-call `params.token`, unless `options.token` is set. */
105
+ function withToken(options, token) {
106
+ return token && !options.token ? {
107
+ ...options,
108
+ token
109
+ } : options;
110
+ }
111
+ /** `Authorization` header value for a token (`OAuth <token>` unless it already has a scheme). */
112
+ function authorizationFor(token) {
113
+ const t = token.trim();
114
+ return /^(oauth|bearer)\s+\S/i.test(t) ? t : `OAuth ${t}`;
115
+ }
116
+ /** Headers shared by every request: Accept + the token, when one is given. */
117
+ function baseHeaders(options) {
118
+ const headers = { Accept: "application/json" };
119
+ if (options.token) headers.Authorization = authorizationFor(options.token);
120
+ return headers;
121
+ }
122
+ function buildExtras(extras) {
34
123
  const merged = {
35
124
  "Content-Type": "application/json",
36
125
  ...extras
37
126
  };
38
- if (options.token != null && merged.token == null) merged.token = options.token;
127
+ delete merged.token;
39
128
  return merged;
40
129
  }
130
+ /** Parses a response body as JSON; `null` for an empty body, `{ raw }` for non-JSON. */
131
+ async function readBody(response) {
132
+ const text = await response.text();
133
+ if (!text) return null;
134
+ try {
135
+ return JSON.parse(text);
136
+ } catch {
137
+ return { raw: text };
138
+ }
139
+ }
41
140
  /**
42
- * Returns a JSON string of API errors if the response carries any, otherwise
43
- * `null`. Mirrors the original `!responseJson.api.message.errors` checks.
141
+ * Low-level request: resolves `{ status, body }` for 2xx responses and throws
142
+ * {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
143
+ * standard ones (Accept + Authorization).
44
144
  */
45
- function getApiErrors(api) {
46
- const errors = api?.message?.errors;
47
- if (errors && Object.keys(errors).length > 0) return JSON.stringify(errors);
48
- return null;
145
+ async function apiRequest(path, init, options = {}) {
146
+ return send(path, {
147
+ ...init,
148
+ headers: {
149
+ ...baseHeaders(options),
150
+ ...init.headers
151
+ }
152
+ }, options);
153
+ }
154
+ async function send(path, init, options) {
155
+ const doFetch = resolveFetch(options);
156
+ const url = (options.baseUrl ?? "") + path;
157
+ let response;
158
+ try {
159
+ response = await doFetch(url, {
160
+ ...init,
161
+ signal: options.signal
162
+ });
163
+ } catch (error) {
164
+ if (error?.name === "AbortError") throw error;
165
+ throw new ShopwaveApiError(0, `Shopwave API request failed: ${error?.message ?? error}`, { cause: error });
166
+ }
167
+ const body = await readBody(response);
168
+ if (!response.ok) throw toShopwaveApiError(response.status, body, response.statusText || void 0);
169
+ return {
170
+ status: response.status,
171
+ body
172
+ };
49
173
  }
50
174
  /**
51
- * GET helper. Sends request metadata via the `extras` header exactly like the
52
- * original hooks did, parses JSON, and returns the typed payload.
175
+ * GET helper. Sends request metadata via the `extras` header, parses JSON and
176
+ * returns the payload. Throws {@link ShopwaveApiError} for non-2xx responses.
53
177
  */
54
178
  async function apiGet(path, extras, options = {}) {
55
- const doFetch = resolveFetch(options);
56
- const url = (options.baseUrl ?? "") + path;
57
- const headers = { extras: JSON.stringify(buildExtras(extras, options)) };
58
- console.log("[API GET]", {
59
- url,
60
- headers
61
- });
62
- const response = await doFetch(url, {
179
+ const { body } = await send(path, {
63
180
  method: "GET",
64
- headers,
65
- signal: options.signal
66
- });
67
- if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
68
- return await response.json();
181
+ headers: {
182
+ ...baseHeaders(options),
183
+ extras: JSON.stringify(buildExtras(extras))
184
+ }
185
+ }, options);
186
+ return body;
69
187
  }
70
188
  /**
71
- * JSON body helper for POST/PUT mutations.
189
+ * JSON body helper for POST/PUT mutations. Returns the parsed body (`null` for
190
+ * an empty one). Throws {@link ShopwaveApiError} for non-2xx responses.
72
191
  */
73
192
  async function apiSend(path, method, payload, options = {}) {
74
- const doFetch = resolveFetch(options);
75
- const url = (options.baseUrl ?? "") + path;
76
- const headers = { "Content-Type": "application/json" };
77
- if (options.token != null) headers.token = options.token;
78
- console.log(`[API ${method}]`, {
79
- url,
80
- headers,
81
- payload
82
- });
83
- const response = await doFetch(url, {
193
+ const { body } = await send(path, {
84
194
  method,
85
- headers,
86
- body: JSON.stringify(payload),
87
- signal: options.signal
88
- });
89
- if (!response.ok) throw new Error(`Failed to ${method.toLowerCase()}: ${response.statusText}`);
90
- return await response.json();
195
+ headers: {
196
+ ...baseHeaders(options),
197
+ "Content-Type": "application/json"
198
+ },
199
+ body: JSON.stringify(payload)
200
+ }, options);
201
+ return body;
91
202
  }
92
203
  /**
93
- * DELETE helper.
204
+ * DELETE helper. Shopwave answers deletes with 205 and an empty body; if a body
205
+ * does come back and carries API errors, they are thrown. Returns the parsed
206
+ * body (`null` when empty).
94
207
  */
95
208
  async function apiDelete(path, options = {}) {
96
- const doFetch = resolveFetch(options);
97
- const url = (options.baseUrl ?? "") + path;
98
- const headers = {};
99
- if (options.token != null) headers.token = options.token;
100
- console.log("[API DELETE]", {
101
- url,
102
- headers
103
- });
104
- const response = await doFetch(url, {
209
+ const { status, body } = await send(path, {
105
210
  method: "DELETE",
106
- headers,
107
- signal: options.signal
108
- });
109
- if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
211
+ headers: baseHeaders(options)
212
+ }, options);
213
+ if (getApiErrorMap(body)) throw toShopwaveApiError(status, body);
214
+ return body;
110
215
  }
111
216
 
112
217
  //#endregion
@@ -118,11 +223,9 @@ async function apiDelete(path, options = {}) {
118
223
  async function fetchCategories(params = {}, options = {}) {
119
224
  const extras = { deleted: params.deleted ?? false };
120
225
  if (params.categoryIds) extras.categoryIds = params.categoryIds;
121
- if (params.token) extras.token = params.token;
122
- const json = await apiGet("/api/categories", extras, options);
123
- const error = getApiErrors(json.api);
124
- if (error) throw new Error(error);
125
- return Object.values(json.categories ?? {});
226
+ const json = await apiGet("/api/categories", extras, withToken(options, params.token));
227
+ assertNoApiErrors(json, 200);
228
+ return Object.values(json?.categories ?? {});
126
229
  }
127
230
 
128
231
  //#endregion
@@ -134,9 +237,8 @@ async function fetchCategories(params = {}, options = {}) {
134
237
  async function fetchConsumers(consumerIds, options = {}) {
135
238
  if (!consumerIds || consumerIds.length === 0) return [];
136
239
  const json = await apiGet("/api/consumer", { ids: consumerIds.join(",") }, options);
137
- const error = getApiErrors(json.api);
138
- if (error) throw new Error(error);
139
- return Object.values(json.consumers ?? {});
240
+ assertNoApiErrors(json, 200);
241
+ return Object.values(json?.consumers ?? {});
140
242
  }
141
243
 
142
244
  //#endregion
@@ -147,10 +249,9 @@ async function fetchConsumers(consumerIds, options = {}) {
147
249
  async function fetchEmployees(params = {}, options = {}) {
148
250
  const extras = { deleted: params.deleted ?? false };
149
251
  if (params.employeeIds) extras.employeeIds = params.employeeIds;
150
- const json = await apiGet("/api/employees", extras, options);
151
- const error = getApiErrors(json.api);
152
- if (error) throw new Error(error);
153
- return Object.values(json.employees ?? {});
252
+ const json = await apiGet("/api/employees", extras, withToken(options, params.token));
253
+ assertNoApiErrors(json, 200);
254
+ return Object.values(json?.employees ?? {});
154
255
  }
155
256
 
156
257
  //#endregion
@@ -162,11 +263,9 @@ async function fetchProducts(params = {}, options = {}) {
162
263
  const extras = { deleted: params.deleted ?? false };
163
264
  if (params.productIds) extras.productIds = params.productIds;
164
265
  if (params.storeId) extras.storeId = params.storeId;
165
- if (params.token) extras.token = params.token;
166
- const json = await apiGet("/api/products", extras, options);
167
- const error = getApiErrors(json.api);
168
- if (error) throw new Error(error);
169
- return Object.values(json.products ?? {});
266
+ const json = await apiGet("/api/products", extras, withToken(options, params.token));
267
+ assertNoApiErrors(json, 200);
268
+ return Object.values(json?.products ?? {});
170
269
  }
171
270
  /**
172
271
  * Fetches products by id and returns them keyed by id, transparently batching
@@ -195,11 +294,9 @@ async function fetchPromotions(params = {}, options = {}) {
195
294
  if (params.imminent !== void 0) extras.imminent = params.imminent;
196
295
  if (params.active !== void 0) extras.active = params.active;
197
296
  if (params.expired !== void 0) extras.expired = params.expired;
198
- if (params.token) extras.token = params.token;
199
- const json = await apiGet("/api/promotions", extras, options);
200
- const error = getApiErrors(json.api);
201
- if (error) throw new Error(error);
202
- return Object.values(json.promotions ?? {});
297
+ const json = await apiGet("/api/promotions", extras, withToken(options, params.token));
298
+ assertNoApiErrors(json, 200);
299
+ return Object.values(json?.promotions ?? {});
203
300
  }
204
301
 
205
302
  //#endregion
@@ -208,10 +305,11 @@ async function fetchPromotions(params = {}, options = {}) {
208
305
  * Fetches stores. Returns the flattened `Store[]`.
209
306
  */
210
307
  async function fetchStores(params = {}, options = {}) {
211
- const json = await apiGet("/api/stores", { deleted: params.deleted ?? false }, options);
212
- const error = getApiErrors(json.api);
213
- if (error) throw new Error(error);
214
- return Object.values(json.stores ?? {});
308
+ const extras = { deleted: params.deleted ?? false };
309
+ if (params.storeIds) extras.storeIds = params.storeIds;
310
+ const json = await apiGet("/api/stores", extras, withToken(options, params.token));
311
+ assertNoApiErrors(json, 200);
312
+ return Object.values(json?.stores ?? {});
215
313
  }
216
314
 
217
315
  //#endregion
@@ -223,9 +321,8 @@ async function fetchStores(params = {}, options = {}) {
223
321
  */
224
322
  async function fetchReport(query, options = {}) {
225
323
  const json = await apiGet("/api/report", { query: JSON.stringify(query) }, options);
226
- const error = getApiErrors(json.api);
227
- if (error) throw new Error(error);
228
- return json.reports ?? {};
324
+ assertNoApiErrors(json, 200);
325
+ return json?.reports ?? {};
229
326
  }
230
327
 
231
328
  //#endregion
@@ -269,10 +366,12 @@ async function logout(options = {}) {
269
366
  //#endregion
270
367
  //#region src/core/entity.ts
271
368
  /**
272
- * Generic CRUD-style mutations extracted from `useHandleDelete` /
273
- * `useHandleSubmit`. The original hooks also surfaced notistack snackbars; that
274
- * UI concern is deliberately left to the hooks/host layer so core stays
275
- * dependency-free.
369
+ * Low-level, untyped mutations: the caller supplies the route and payload.
370
+ *
371
+ * Prefer the typed functions in `./entities` (`saveProduct`, `deleteCategory`,
372
+ * …): they know each route, build the request envelope, check the response and
373
+ * return the saved entity with its id. Keep these for routes that have no typed
374
+ * function yet (e.g. `/api/merchant`).
276
375
  */
277
376
  /**
278
377
  * Deletes `/api/{resourcePath}/{entityId}`.
@@ -282,11 +381,15 @@ async function deleteEntity(resourcePath, entityId, options = {}) {
282
381
  await apiDelete(`/api/${resourcePath}/${entityId}`, options);
283
382
  }
284
383
  /**
285
- * Sends a create/update mutation and returns the parsed `result` field
286
- * (matching the original `result.result` access).
384
+ * Sends a create/update mutation. Returns the response's `result` field when it
385
+ * has one (older app routes wrapped their answer that way), otherwise the whole
386
+ * parsed response body. Throws `ShopwaveApiError` on HTTP or API errors.
287
387
  */
288
388
  async function submitEntity({ endpoint, method = "POST", payload }, options = {}) {
289
- return (await apiSend(endpoint, method, payload, options)).result;
389
+ const json = await apiSend(endpoint, method, payload, options);
390
+ assertNoApiErrors(json, 200);
391
+ if (json && typeof json === "object" && "result" in json) return json.result;
392
+ return json ?? void 0;
290
393
  }
291
394
 
292
395
  //#endregion
@@ -433,56 +536,491 @@ function buildConsumerMap(consumers) {
433
536
  return map;
434
537
  }
435
538
 
539
+ //#endregion
540
+ //#region src/core/resources.ts
541
+ /**
542
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
543
+ * updates it (without an `id` the API would create a second merchant, so the
544
+ * route requires one).
545
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
546
+ * needs the admin scope).
547
+ */
548
+ const SHOPWAVE_RESOURCES = Object.freeze({
549
+ merchant: {
550
+ route: "merchant",
551
+ key: "merchant",
552
+ upstream: "merchant",
553
+ writable: true
554
+ },
555
+ user: {
556
+ route: "user",
557
+ key: "user",
558
+ upstream: "user",
559
+ writable: false
560
+ }
561
+ });
562
+ /** `UploadKind` → the API's `contentType` value. */
563
+ const UPLOAD_KINDS = Object.freeze({
564
+ merchant: "merchant",
565
+ merchantFeature: "merchantFeature",
566
+ product: "product",
567
+ user: "user",
568
+ applicationLogo: "applicaionLogo",
569
+ applicationImages: "applicaionImages"
570
+ });
571
+ /** How uploads are addressed (app route and Shopwave uploader). */
572
+ const SHOPWAVE_UPLOAD = Object.freeze({
573
+ route: "upload",
574
+ upstream: "uploader",
575
+ kindHeader: "contentType",
576
+ fileField: "file",
577
+ kindField: "kind"
578
+ });
579
+ function isUploadKind(value) {
580
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
581
+ }
582
+
583
+ //#endregion
584
+ //#region src/core/imageFit.ts
585
+ /**
586
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
587
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
588
+ */
589
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
590
+ if (![
591
+ srcWidth,
592
+ srcHeight,
593
+ dstWidth,
594
+ dstHeight
595
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
596
+ const srcRatio = srcWidth / srcHeight;
597
+ const dstRatio = dstWidth / dstHeight;
598
+ if (srcRatio > dstRatio) {
599
+ const sw = Math.round(srcHeight * dstRatio);
600
+ return {
601
+ sx: Math.round((srcWidth - sw) / 2),
602
+ sy: 0,
603
+ sw,
604
+ sh: srcHeight
605
+ };
606
+ }
607
+ const sh = Math.round(srcWidth / dstRatio);
608
+ return {
609
+ sx: 0,
610
+ sy: Math.round((srcHeight - sh) / 2),
611
+ sw: srcWidth,
612
+ sh
613
+ };
614
+ }
615
+ function requireBrowserImages(fn) {
616
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
617
+ }
618
+ /**
619
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
620
+ * (never stretched). Browser only. The result is a `File` named like the
621
+ * input, with the output type's extension.
622
+ */
623
+ async function fitImageToSize(file, width, height, options = {}) {
624
+ requireBrowserImages("fitImageToSize");
625
+ const { type = "image/png", quality, keepIfExact = true } = options;
626
+ const name = file.name || "image";
627
+ const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
628
+ const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
629
+ const bitmap = await createImageBitmap(file);
630
+ try {
631
+ if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
632
+ const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
633
+ let blob;
634
+ if (typeof OffscreenCanvas === "function") {
635
+ const canvas = new OffscreenCanvas(width, height);
636
+ const ctx = canvas.getContext("2d");
637
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
638
+ ctx.imageSmoothingQuality = "high";
639
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
640
+ blob = await canvas.convertToBlob({
641
+ type,
642
+ quality
643
+ });
644
+ } else {
645
+ const canvas = document.createElement("canvas");
646
+ canvas.width = width;
647
+ canvas.height = height;
648
+ const ctx = canvas.getContext("2d");
649
+ if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
650
+ ctx.imageSmoothingQuality = "high";
651
+ ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
652
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
653
+ }
654
+ return new File([blob], outName, { type });
655
+ } finally {
656
+ bitmap.close?.();
657
+ }
658
+ }
659
+
660
+ //#endregion
661
+ //#region src/core/upload.ts
662
+ /**
663
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
664
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
665
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
666
+ */
667
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
668
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
669
+ /**
670
+ * Turns an image path from the API into a URL a page can load over HTTPS:
671
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
672
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
673
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
674
+ */
675
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
676
+ if (!path) return "";
677
+ const root = base.replace(/\/+$/, "");
678
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
679
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
680
+ return `${root}/${path.replace(/^\/+/, "")}`;
681
+ }
682
+ /**
683
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
684
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
685
+ *
686
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
687
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
688
+ * 201 `{ fileName, path }`.
689
+ */
690
+ async function uploadImage(file, params, options = {}) {
691
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
692
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
693
+ const name = params.fileName ?? file.name ?? "upload";
694
+ const form = new FormData();
695
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
696
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
697
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
698
+ method: "POST",
699
+ body: form
700
+ }, options);
701
+ const body = raw;
702
+ assertNoApiErrors(body, status);
703
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
704
+ const path = typeof body?.path === "string" ? body.path : "";
705
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
706
+ return {
707
+ id,
708
+ url: getImageUrl(path),
709
+ path
710
+ };
711
+ }
712
+
713
+ //#endregion
714
+ //#region src/core/merchantImages.ts
715
+ /**
716
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
717
+ * merchant settings page).
718
+ */
719
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
720
+ receipt: {
721
+ field: "logo.receipt",
722
+ uploadKind: "merchant",
723
+ width: 576,
724
+ height: 325
725
+ },
726
+ square: {
727
+ field: "logo.square",
728
+ uploadKind: "merchant",
729
+ width: 1e3,
730
+ height: 1e3
731
+ },
732
+ featured: {
733
+ field: "feature[0]",
734
+ uploadKind: "merchantFeature",
735
+ width: 1200,
736
+ height: 600
737
+ }
738
+ });
739
+ /**
740
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
741
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
742
+ * Returns `null` for empty or non-string values.
743
+ */
744
+ function imageFileName(value) {
745
+ if (typeof value !== "string") return null;
746
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
747
+ return last ? decodeURIComponent(last) : null;
748
+ }
749
+ function toStored(value) {
750
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
751
+ if (Array.isArray(value)) {
752
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
753
+ return items.length > 0 ? items : void 0;
754
+ }
755
+ if (value && typeof value === "object") {
756
+ const out = {};
757
+ for (const [k, v] of Object.entries(value)) {
758
+ const stored = toStored(v);
759
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
760
+ }
761
+ return Object.keys(out).length > 0 ? out : void 0;
762
+ }
763
+ }
764
+ /**
765
+ * The `imageIds` to send on a save: every URL is turned back into its file
766
+ * name (what adminV1 does with images you didn't change) and empty values
767
+ * are dropped. No images gives `{}`.
768
+ */
769
+ function toStoredImageIds(imageIds) {
770
+ return toStored(imageIds) ?? {};
771
+ }
772
+ /**
773
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
774
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
775
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
776
+ */
777
+ async function uploadMerchantImage(file, slot, options = {}) {
778
+ const def = MERCHANT_IMAGE_SLOTS[slot];
779
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
780
+ const { fit = true, fitOptions,...requestOptions } = options;
781
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
782
+ }
783
+
784
+ //#endregion
785
+ //#region src/core/merchant.ts
786
+ const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
787
+ /** Where each flat link field lives in `merchant.links`. */
788
+ const MERCHANT_LINK_PATHS = Object.freeze({
789
+ website: ["website", "home"],
790
+ twitter: ["social", "twitter"],
791
+ facebook: ["social", "facebook"],
792
+ instagram: ["social", "instagram"]
793
+ });
794
+ /**
795
+ * Object fields of the merchant. The API keeps one you leave out, and
796
+ * replaces it as a whole when you send it.
797
+ */
798
+ const MERCHANT_OBJECT_FIELDS = [
799
+ "colours",
800
+ "links",
801
+ "imageIds"
802
+ ];
803
+ /** Fields the API sets itself; `updateMerchant` never sends them back. */
804
+ const MERCHANT_READ_ONLY_FIELDS = ["createdDate", "modifiedDate"];
805
+ /**
806
+ * Longest values the API stores. Longer values are cut off silently
807
+ * (live API, Sep 2026), so `updateMerchant` rejects them instead.
808
+ * `vatNumber` fits a UK VAT number without the `GB` prefix.
809
+ */
810
+ const MERCHANT_FIELD_MAX_LENGTH = Object.freeze({
811
+ companyNumber: 11,
812
+ vatNumber: 9
813
+ });
814
+ /** Returns `"<field> is longer than <n> characters"` messages for over-long fields. */
815
+ function validateMerchant(merchant) {
816
+ const problems = [];
817
+ for (const [field, max] of Object.entries(MERCHANT_FIELD_MAX_LENGTH)) {
818
+ const value = merchant[field];
819
+ if (typeof value === "string" && value.length > max) problems.push(`${field} is longer than ${max} characters`);
820
+ }
821
+ return problems;
822
+ }
823
+ /**
824
+ * Reads the logged-in user's merchant (a user has exactly one), or `null`
825
+ * when the API returns none.
826
+ */
827
+ async function fetchMerchant(options = {}) {
828
+ const json = await apiGet(MERCHANT_PATH, {}, options);
829
+ assertNoApiErrors(json, 200);
830
+ return json?.merchant ?? null;
831
+ }
832
+ /**
833
+ * Updates the merchant and returns it as stored.
834
+ *
835
+ * Shopwave's `POST /merchant` replaces the whole record: a scalar field you
836
+ * leave out (`companyNumber`, `vatNumber`, `note`, …) is set to null, while
837
+ * an object field you leave out (`colours`, `links`, `imageIds`) is kept and
838
+ * one you send replaces the stored one; sending `{}` clears it (live API,
839
+ * Sep 2026). So by default the
840
+ * current merchant is read first and the patch is merged over it.
841
+ *
842
+ * `imageIds` is always sent as file names ({@link toStoredImageIds}), so you
843
+ * can pass back what you read. See `MerchantImageIds` for the shape.
844
+ *
845
+ * The API answers 205 with an empty body, so the merchant is read again after
846
+ * the save and that is what's returned (it shows exactly what was kept).
847
+ * Throws before sending when a value is longer than the API stores
848
+ * ({@link MERCHANT_FIELD_MAX_LENGTH}).
849
+ */
850
+ async function updateMerchant(patch, options = {}) {
851
+ const { merge = true,...requestOptions } = options;
852
+ let merchant = { ...patch };
853
+ if (merge) {
854
+ const current = await fetchMerchant(requestOptions);
855
+ if (!current) throw new ShopwaveApiError(404, "Shopwave API error (404): no merchant to update");
856
+ 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})`);
857
+ merchant = {
858
+ ...current,
859
+ ...patch,
860
+ id: current.id
861
+ };
862
+ for (const field of MERCHANT_OBJECT_FIELDS) if (!(field in patch)) delete merchant[field];
863
+ }
864
+ if (merchant.imageIds && typeof merchant.imageIds === "object") merchant.imageIds = toStoredImageIds(merchant.imageIds);
865
+ if (merchant.id == null || merchant.id === "") throw new Error("merchant id is missing");
866
+ for (const field of MERCHANT_READ_ONLY_FIELDS) delete merchant[field];
867
+ const problems = validateMerchant(merchant);
868
+ if (problems.length > 0) throw new Error(`Invalid merchant: ${problems.join("; ")}`);
869
+ const { status, body: raw } = await apiRequest(MERCHANT_PATH, {
870
+ method: "PUT",
871
+ headers: { "Content-Type": "application/json" },
872
+ body: JSON.stringify({ merchant })
873
+ }, requestOptions);
874
+ const body = raw;
875
+ assertNoApiErrors(body, status);
876
+ const echoed = body?.merchant && typeof body.merchant === "object" ? body.merchant : null;
877
+ if (echoed) return {
878
+ ...merchant,
879
+ ...echoed,
880
+ id: Number(echoed.id ?? merchant.id)
881
+ };
882
+ return await fetchMerchant(requestOptions) ?? {
883
+ ...merchant,
884
+ id: Number(merchant.id)
885
+ };
886
+ }
887
+
888
+ //#endregion
889
+ //#region src/core/user.ts
890
+ /** Reads the logged-in user, or `null` when the API returns none. */
891
+ async function fetchUser(options = {}) {
892
+ const json = await apiGet(`/api/${SHOPWAVE_RESOURCES.user.route}`, {}, options);
893
+ assertNoApiErrors(json, 200);
894
+ return json?.user ?? null;
895
+ }
896
+
436
897
  //#endregion
437
898
  //#region src/hooks/shared.ts
438
899
  function toMessage(e) {
439
900
  if (e instanceof Error) return e.message;
440
901
  return String(e);
441
902
  }
903
+ function toStatus(e) {
904
+ return e instanceof ShopwaveApiError ? e.status : null;
905
+ }
906
+ function queryReducer(state, action) {
907
+ switch (action.type) {
908
+ case "start":
909
+ if (action.key !== state.key) return {
910
+ data: null,
911
+ key: action.key,
912
+ fetching: true,
913
+ error: null,
914
+ errorStatus: null
915
+ };
916
+ return {
917
+ ...state,
918
+ fetching: true,
919
+ error: null,
920
+ errorStatus: null
921
+ };
922
+ case "success":
923
+ if (action.key !== state.key) return state;
924
+ return {
925
+ data: action.data,
926
+ key: action.key,
927
+ fetching: false,
928
+ error: null,
929
+ errorStatus: null
930
+ };
931
+ case "failure":
932
+ if (action.key !== state.key) return state;
933
+ return {
934
+ ...state,
935
+ fetching: false,
936
+ error: action.error,
937
+ errorStatus: action.errorStatus
938
+ };
939
+ case "idle": return state.fetching ? {
940
+ ...state,
941
+ fetching: false
942
+ } : state;
943
+ default: return state;
944
+ }
945
+ }
946
+ /** Public view of the internal state for the params currently being rendered. */
947
+ function selectQueryView(state, key, enabled) {
948
+ if (state.key !== key) return {
949
+ data: null,
950
+ loading: enabled,
951
+ fetching: enabled,
952
+ error: null,
953
+ errorStatus: null
954
+ };
955
+ return {
956
+ data: state.data,
957
+ loading: state.fetching && state.data === null,
958
+ fetching: state.fetching,
959
+ error: state.error,
960
+ errorStatus: state.errorStatus
961
+ };
962
+ }
963
+ function depsKey(deps) {
964
+ try {
965
+ return JSON.stringify(deps);
966
+ } catch {
967
+ return String(deps);
968
+ }
969
+ }
442
970
  /**
443
971
  * Internal primitive powering the auto-fetch hooks. Runs `fetcher` on mount and
444
972
  * whenever `deps` change, tracks loading/error/data, cancels in-flight requests
445
- * on unmount or dep change, and exposes `refetch`.
973
+ * on unmount or dep change, and exposes `refetch` (which keeps the current data
974
+ * visible until the new data arrives).
446
975
  *
447
976
  * When `enabled` is false the fetch is skipped (used for hooks that need
448
977
  * arguments before they can run, e.g. consumers-by-id).
449
978
  */
450
979
  function useQuery(fetcher, deps, enabled = true) {
451
- const [data, setData] = (0, react.useState)(null);
452
- const [loading, setLoading] = (0, react.useState)(enabled);
453
- const [error, setError] = (0, react.useState)(null);
980
+ const key = depsKey(deps);
981
+ const [state, dispatch] = (0, react.useReducer)(queryReducer, {
982
+ data: null,
983
+ key: enabled ? key : null,
984
+ fetching: enabled,
985
+ error: null,
986
+ errorStatus: null
987
+ });
454
988
  const [tick, setTick] = (0, react.useState)(0);
455
989
  const refetch = (0, react.useCallback)(() => setTick((t) => t + 1), []);
456
990
  (0, react.useEffect)(() => {
457
991
  if (!enabled) {
458
- setLoading(false);
992
+ dispatch({ type: "idle" });
459
993
  return;
460
994
  }
461
995
  const controller = new AbortController();
462
- let active = true;
463
- setData(null);
464
- setError(null);
465
- setLoading(true);
996
+ dispatch({
997
+ type: "start",
998
+ key
999
+ });
466
1000
  fetcher(controller.signal).then((result) => {
467
- if (active) setData(result);
1001
+ if (!controller.signal.aborted) dispatch({
1002
+ type: "success",
1003
+ key,
1004
+ data: result
1005
+ });
468
1006
  }).catch((e) => {
469
- if (active && e?.name !== "AbortError") setError(toMessage(e));
470
- }).finally(() => {
471
- if (active) setLoading(false);
1007
+ if (!controller.signal.aborted && e?.name !== "AbortError") dispatch({
1008
+ type: "failure",
1009
+ key,
1010
+ error: toMessage(e),
1011
+ errorStatus: toStatus(e)
1012
+ });
472
1013
  });
473
1014
  return () => {
474
- active = false;
475
1015
  controller.abort();
476
1016
  };
477
1017
  }, [
478
- ...deps,
1018
+ key,
479
1019
  tick,
480
1020
  enabled
481
1021
  ]);
482
1022
  return {
483
- data,
484
- loading,
485
- error,
1023
+ ...selectQueryView(state, key, enabled),
486
1024
  refetch
487
1025
  };
488
1026
  }
@@ -493,16 +1031,19 @@ function useMutation(action, deps) {
493
1031
  const [data, setData] = (0, react.useState)(null);
494
1032
  const [loading, setLoading] = (0, react.useState)(false);
495
1033
  const [error, setError] = (0, react.useState)(null);
1034
+ const [errorStatus, setErrorStatus] = (0, react.useState)(null);
496
1035
  return {
497
1036
  mutate: (0, react.useCallback)(async (...args) => {
498
1037
  setLoading(true);
499
1038
  setError(null);
1039
+ setErrorStatus(null);
500
1040
  try {
501
1041
  const result = await action(...args);
502
1042
  setData(result);
503
1043
  return result;
504
1044
  } catch (e) {
505
1045
  setError(toMessage(e));
1046
+ setErrorStatus(toStatus(e));
506
1047
  return null;
507
1048
  } finally {
508
1049
  setLoading(false);
@@ -510,7 +1051,8 @@ function useMutation(action, deps) {
510
1051
  }, deps),
511
1052
  data,
512
1053
  loading,
513
- error
1054
+ error,
1055
+ errorStatus
514
1056
  };
515
1057
  }
516
1058
 
@@ -709,6 +1251,85 @@ function useSession(options) {
709
1251
  }), [options?.baseUrl]);
710
1252
  }
711
1253
 
1254
+ //#endregion
1255
+ //#region src/hooks/useMerchant.ts
1256
+ /**
1257
+ * The logged-in user's merchant, fetched on mount. `data` is `null` while
1258
+ * loading or when the API has no merchant.
1259
+ *
1260
+ * @example
1261
+ * const { data: merchant, loading, refetch } = useMerchant();
1262
+ */
1263
+ function useMerchant(options) {
1264
+ return useQuery((signal) => fetchMerchant({
1265
+ ...options,
1266
+ signal
1267
+ }), [options?.baseUrl, options?.token]);
1268
+ }
1269
+ /**
1270
+ * Manually-triggered merchant update (see `updateMerchant`: the patch is merged
1271
+ * over the current merchant by default). Resolves to the saved merchant, or
1272
+ * `null` on error (then `error` / `errorStatus` are set).
1273
+ *
1274
+ * @example
1275
+ * const { mutate: saveMerchant, loading: saving } = useUpdateMerchant();
1276
+ * const saved = await saveMerchant({ vatNumber: "GB123" });
1277
+ */
1278
+ function useUpdateMerchant(options) {
1279
+ return useMutation((patch) => updateMerchant(patch, options), [
1280
+ options?.baseUrl,
1281
+ options?.token,
1282
+ options?.merge
1283
+ ]);
1284
+ }
1285
+
1286
+ //#endregion
1287
+ //#region src/hooks/useUser.ts
1288
+ /**
1289
+ * The logged-in user, fetched on mount. `errorStatus` is 401 when logged out.
1290
+ *
1291
+ * @example
1292
+ * const { data: user } = useUser();
1293
+ */
1294
+ function useUser(options) {
1295
+ const enabled = options?.enabled ?? true;
1296
+ return useQuery((signal) => fetchUser({
1297
+ ...options,
1298
+ signal
1299
+ }), [options?.baseUrl, options?.token], enabled);
1300
+ }
1301
+
1302
+ //#endregion
1303
+ //#region src/hooks/useUploadImage.ts
1304
+ /**
1305
+ * Manually-triggered image upload. Resolves to `{ id, url }`, or `null` on
1306
+ * error (then `error` / `errorStatus` are set).
1307
+ *
1308
+ * @example
1309
+ * const { mutate: upload, loading: uploading } = useUploadImage();
1310
+ * const image = await upload(file, { kind: "merchant" });
1311
+ */
1312
+ function useUploadImage(options) {
1313
+ return useMutation((file, params) => uploadImage(file, params, options), [options?.baseUrl, options?.token]);
1314
+ }
1315
+ /**
1316
+ * Manually-triggered upload for a merchant image slot (`receipt`, `square`,
1317
+ * `featured`): fits the image to the slot's exact size, uploads it with the
1318
+ * slot's kind, and resolves to `{ id, url }`. Put `id` into `imageIds` with
1319
+ * `setMerchantImage`.
1320
+ *
1321
+ * @example
1322
+ * const { mutate: upload } = useUploadMerchantImage();
1323
+ * const image = await upload(file, "square");
1324
+ */
1325
+ function useUploadMerchantImage(options) {
1326
+ return useMutation((file, slot) => uploadMerchantImage(file, slot, options), [
1327
+ options?.baseUrl,
1328
+ options?.token,
1329
+ options?.fit
1330
+ ]);
1331
+ }
1332
+
712
1333
  //#endregion
713
1334
  //#region src/hooks/useBasketReport.ts
714
1335
  /**
@@ -819,10 +1440,15 @@ exports.useConsumer = useConsumer;
819
1440
  exports.useDelete = useDelete;
820
1441
  exports.useEmployee = useEmployee;
821
1442
  exports.useLogout = useLogout;
1443
+ exports.useMerchant = useMerchant;
822
1444
  exports.useProduct = useProduct;
823
1445
  exports.usePromotion = usePromotion;
824
1446
  exports.useReport = useReport;
825
1447
  exports.useSession = useSession;
826
1448
  exports.useStore = useStore;
827
1449
  exports.useSubmit = useSubmit;
1450
+ exports.useUpdateMerchant = useUpdateMerchant;
1451
+ exports.useUploadImage = useUploadImage;
1452
+ exports.useUploadMerchantImage = useUploadMerchantImage;
1453
+ exports.useUser = useUser;
828
1454
  //# sourceMappingURL=index.cjs.map