react-shopwave-connect 0.3.0 → 0.4.1

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.
@@ -137,6 +137,20 @@ async function readBody(response) {
137
137
  return { raw: text };
138
138
  }
139
139
  }
140
+ /**
141
+ * Low-level request: resolves `{ status, body }` for 2xx responses and throws
142
+ * {@link ShopwaveApiError} otherwise. `init.headers` are merged over the
143
+ * standard ones (Accept + Authorization).
144
+ */
145
+ async function apiRequest(path, init, options = {}) {
146
+ return send(path, {
147
+ ...init,
148
+ headers: {
149
+ ...baseHeaders(options),
150
+ ...init.headers
151
+ }
152
+ }, options);
153
+ }
140
154
  async function send(path, init, options) {
141
155
  const doFetch = resolveFetch(options);
142
156
  const url = (options.baseUrl ?? "") + path;
@@ -522,6 +536,364 @@ function buildConsumerMap(consumers) {
522
536
  return map;
523
537
  }
524
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
+
525
897
  //#endregion
526
898
  //#region src/hooks/shared.ts
527
899
  function toMessage(e) {
@@ -879,6 +1251,85 @@ function useSession(options) {
879
1251
  }), [options?.baseUrl]);
880
1252
  }
881
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
+
882
1333
  //#endregion
883
1334
  //#region src/hooks/useBasketReport.ts
884
1335
  /**
@@ -989,10 +1440,15 @@ exports.useConsumer = useConsumer;
989
1440
  exports.useDelete = useDelete;
990
1441
  exports.useEmployee = useEmployee;
991
1442
  exports.useLogout = useLogout;
1443
+ exports.useMerchant = useMerchant;
992
1444
  exports.useProduct = useProduct;
993
1445
  exports.usePromotion = usePromotion;
994
1446
  exports.useReport = useReport;
995
1447
  exports.useSession = useSession;
996
1448
  exports.useStore = useStore;
997
1449
  exports.useSubmit = useSubmit;
1450
+ exports.useUpdateMerchant = useUpdateMerchant;
1451
+ exports.useUploadImage = useUploadImage;
1452
+ exports.useUploadMerchantImage = useUploadMerchantImage;
1453
+ exports.useUser = useUser;
998
1454
  //# sourceMappingURL=index.cjs.map