react-shopwave-connect 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -375,6 +375,118 @@ async function fetchProductsMap(productIds, options = {}, batchSize = 200) {
375
375
  return result;
376
376
  }
377
377
 
378
+ //#endregion
379
+ //#region src/core/resources.ts
380
+ /**
381
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
382
+ * updates it (without an `id` the API would create a second merchant, so the
383
+ * route requires one).
384
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
385
+ * needs the admin scope).
386
+ */
387
+ const SHOPWAVE_RESOURCES = Object.freeze({
388
+ merchant: {
389
+ route: "merchant",
390
+ key: "merchant",
391
+ upstream: "merchant",
392
+ writable: true
393
+ },
394
+ user: {
395
+ route: "user",
396
+ key: "user",
397
+ upstream: "user",
398
+ writable: false
399
+ }
400
+ });
401
+ /** `UploadKind` → the API's `contentType` value. */
402
+ const UPLOAD_KINDS = Object.freeze({
403
+ merchant: "merchant",
404
+ merchantFeature: "merchantFeature",
405
+ product: "product",
406
+ user: "user",
407
+ applicationLogo: "applicaionLogo",
408
+ applicationImages: "applicaionImages"
409
+ });
410
+ /** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
411
+ const UPLOAD_PREFIXES = Object.freeze([
412
+ "product_thumbnail",
413
+ "variant_thumbnail",
414
+ "product",
415
+ "variant"
416
+ ]);
417
+ /** How uploads are addressed (app route and Shopwave uploader). */
418
+ const SHOPWAVE_UPLOAD = Object.freeze({
419
+ route: "upload",
420
+ upstream: "uploader",
421
+ kindHeader: "contentType",
422
+ prefixHeader: "prefix",
423
+ fileField: "file",
424
+ kindField: "kind",
425
+ prefixField: "prefix"
426
+ });
427
+ function isUploadKind(value) {
428
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
429
+ }
430
+ function isUploadPrefix(value) {
431
+ return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
432
+ }
433
+ const SHOPWAVE_LOOKUPS = Object.freeze({
434
+ productTags: {
435
+ route: "products/tags",
436
+ key: "tags",
437
+ upstream: "product/tags"
438
+ },
439
+ productInstanceTags: {
440
+ route: "products/instance-tags",
441
+ key: "tags",
442
+ upstream: "product/instance/tags"
443
+ }
444
+ });
445
+
446
+ //#endregion
447
+ //#region src/core/productTags.ts
448
+ /**
449
+ * Turns a tag list from the API into clean strings: trims each tag, drops
450
+ * blanks and anything that isn't text, and removes exact duplicates (first one
451
+ * wins). The API's own order is kept. A map is accepted too (its values are
452
+ * used), in case the API ever answers keyed by id.
453
+ */
454
+ function normalizeTagList(value) {
455
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
456
+ const seen = /* @__PURE__ */ new Set();
457
+ const out = [];
458
+ for (const item of list) {
459
+ if (typeof item !== "string" && typeof item !== "number") continue;
460
+ const tag = String(item).trim();
461
+ if (!tag || seen.has(tag)) continue;
462
+ seen.add(tag);
463
+ out.push(tag);
464
+ }
465
+ return out;
466
+ }
467
+ async function fetchLookup(kind, options) {
468
+ const def = SHOPWAVE_LOOKUPS[kind];
469
+ if (!def) throw new Error(`Unknown Shopwave lookup "${kind}"`);
470
+ const json = await apiGet(`/api/${def.route}`, {}, options);
471
+ assertNoApiErrors(json, 200);
472
+ return normalizeTagList(json?.[def.key]);
473
+ }
474
+ /**
475
+ * Every distinct tag used on the merchant's products (`GET /product/tags`),
476
+ * for a tag field's suggestions. Returns `[]` when there are none.
477
+ */
478
+ function fetchProductTags(options = {}) {
479
+ return fetchLookup("productTags", options);
480
+ }
481
+ /**
482
+ * Every distinct tag used on the merchant's price instances
483
+ * (`GET /product/instance/tags`): the channel names (Eat In, Takeout, …).
484
+ * Returns `[]` when there are none.
485
+ */
486
+ function fetchProductInstanceTags(options = {}) {
487
+ return fetchLookup("productInstanceTags", options);
488
+ }
489
+
378
490
  //#endregion
379
491
  //#region src/core/promotion.ts
380
492
  /** Promotion rule identifiers as stored by the Shopwave API. */
@@ -503,435 +615,57 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
503
615
  }
504
616
 
505
617
  //#endregion
506
- //#region src/core/entities.ts
618
+ //#region src/core/imageFit.ts
507
619
  /**
508
- * Addressing per entity, following the Shopwave API reference
509
- * (https://developer.merchantstack.com/api-reference.html) and checked against
510
- * the live API where noted:
511
- * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
512
- * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
513
- * reads the promotion and saves the whole record back with `endDate` = now (and
514
- * `startDate` = now too if it hadn't started); the live API rejects a partial
515
- * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
516
- * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
517
- * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
518
- * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
620
+ * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
621
+ * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
519
622
  */
520
- const SHOPWAVE_ENTITIES = Object.freeze({
521
- product: {
522
- route: "products",
523
- collection: "products",
524
- upstream: "product",
525
- idsHeader: "productIds",
526
- idHeader: "productId",
527
- writable: true,
528
- deleteMode: "delete"
529
- },
530
- category: {
531
- route: "categories",
532
- collection: "categories",
533
- upstream: "category",
534
- idsHeader: "categoryIds",
535
- idHeader: "categoryId",
536
- writable: true,
537
- deleteMode: "delete"
538
- },
539
- store: {
540
- route: "stores",
541
- collection: "stores",
542
- upstream: "store",
543
- idsHeader: "storeIds",
544
- idHeader: "storeId",
545
- writable: true,
546
- deleteMode: "delete"
547
- },
548
- promotion: {
549
- route: "promotions",
550
- collection: "promotions",
551
- upstream: "promotion",
552
- idsHeader: "promotionIds",
553
- idHeader: "promotionId",
554
- writable: true,
555
- deleteMode: "retire",
556
- retireField: "endDate",
557
- retireWithRecord: true,
558
- retireStartField: "startDate"
559
- },
560
- employee: {
561
- route: "employees",
562
- collection: "employees",
563
- upstream: "employee",
564
- idsHeader: "employeeIds",
565
- idHeader: "employeeId",
566
- writable: true,
567
- deleteMode: "retire",
568
- retireField: "exitDate"
569
- },
570
- consumer: {
571
- route: "consumer",
572
- collection: "consumers",
573
- upstream: "consumer",
574
- idsHeader: "ids",
575
- idHeader: "consumerId",
576
- writable: false,
577
- deleteMode: "none"
623
+ function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
624
+ if (![
625
+ srcWidth,
626
+ srcHeight,
627
+ dstWidth,
628
+ dstHeight
629
+ ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
630
+ const srcRatio = srcWidth / srcHeight;
631
+ const dstRatio = dstWidth / dstHeight;
632
+ if (srcRatio > dstRatio) {
633
+ const sw = Math.round(srcHeight * dstRatio);
634
+ return {
635
+ sx: Math.round((srcWidth - sw) / 2),
636
+ sy: 0,
637
+ sw,
638
+ sh: srcHeight
639
+ };
578
640
  }
579
- });
580
- function definition(kind) {
581
- const def = SHOPWAVE_ENTITIES[kind];
582
- if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
583
- return def;
641
+ const sh = Math.round(srcWidth / dstRatio);
642
+ return {
643
+ sx: 0,
644
+ sy: Math.round((srcHeight - sh) / 2),
645
+ sw: srcWidth,
646
+ sh
647
+ };
584
648
  }
585
- function assertId(kind, id) {
586
- const s = id == null ? "" : String(id).trim();
587
- if (!s) throw new Error(`${kind} id is missing`);
588
- return encodeURIComponent(s);
649
+ function requireBrowserImages(fn) {
650
+ if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
589
651
  }
590
- /**
591
- * Creates or updates several entities of one kind in a single request and
592
- * returns them as saved (with their ids), in the order given.
593
- *
594
- * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
595
- * answers 201 and echoes every entity under the ref it was sent with, so each
596
- * result is matched back by ref. Throws {@link ShopwaveApiError} when the
597
- * response carries `api.message.errors` or any ref is missing from it.
598
- *
599
- * The API's partial-success semantics for multi-entity saves haven't been
600
- * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
601
- */
602
- async function saveEntities(kind, items, options = {}) {
603
- const def = definition(kind);
604
- if (items.length === 0) return [];
605
- const refs = items.map((_, i) => String(i));
606
- const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
607
- const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
608
- method: "POST",
609
- headers: { "Content-Type": "application/json" },
610
- body: JSON.stringify(payload)
611
- }, options);
612
- const body = raw;
613
- assertNoApiErrors(body, status);
614
- const echoed = body?.[def.collection] ?? {};
615
- return refs.map((ref, i) => {
616
- const saved = echoed[ref];
617
- if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
652
+ /** Reads an image file's pixel size (browser only). */
653
+ async function readImageSize(file) {
654
+ requireBrowserImages("readImageSize");
655
+ const bitmap = await createImageBitmap(file);
656
+ try {
618
657
  return {
619
- ...items[i],
620
- ...saved,
621
- id: Number(saved.id)
658
+ width: bitmap.width,
659
+ height: bitmap.height
622
660
  };
623
- });
624
- }
625
- /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
626
- async function saveEntity(kind, item, options = {}) {
627
- const [saved] = await saveEntities(kind, [item], options);
628
- return saved;
629
- }
630
- /**
631
- * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
632
- * Employees and promotions can't be deleted in Shopwave; for them the route
633
- * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
634
- * promotion is saved back whole (see {@link deletePromotion}).
635
- *
636
- * Shopwave answers 205 with an empty body — also for ids that don't exist — so
637
- * a resolved promise means "the API accepted the request", not "a record was
638
- * deleted".
639
- */
640
- async function deleteEntityById(kind, id, options = {}) {
641
- await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
642
- }
643
- /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
644
- async function fetchEntityById(kind, id, params = {}, options = {}) {
645
- const def = definition(kind);
646
- const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
647
- assertNoApiErrors(body, 200);
648
- const map = body?.[def.collection] ?? {};
649
- return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
661
+ } finally {
662
+ bitmap.close?.();
663
+ }
650
664
  }
651
665
  /**
652
- * Creates (no `id`) or updates (with `id`) a product.
653
- *
654
- * Shopwave instances (prices) can't be updated, only replaced. Build
655
- * `instances` with {@link toProductInstancesForSave} (passing the product as last
656
- * read) so an edited price is sent as a new instance. As a safeguard, a new
657
- * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
658
- * another product would otherwise point the save at that product's instances.
659
- */
660
- const saveProduct = (product, options) => {
661
- return saveEntity("product", (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
662
- ...product,
663
- instances: toProductInstancesForSave(product.instances)
664
- } : product, options);
665
- };
666
- const deleteProduct = (id, options) => deleteEntityById("product", id, options);
667
- const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
668
- const saveCategory = (category, options) => saveEntity("category", category, options);
669
- const deleteCategory = (id, options) => deleteEntityById("category", id, options);
670
- const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
671
- const saveStore = (store, options) => saveEntity("store", store, options);
672
- const deleteStore = (id, options) => deleteEntityById("store", id, options);
673
- const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
674
- const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
675
- /**
676
- * Expires a promotion (Shopwave never deletes promotions). The route reads the
677
- * promotion and saves it back with `endDate` set to now; a promotion that hasn't
678
- * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
679
- * exist or the save fails.
680
- */
681
- const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
682
- const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
683
- /**
684
- * Creates an employee, or updates one (with `id`). On update Shopwave only
685
- * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
686
- * email can't be changed, and the echo only carries the updated fields — re-read
687
- * with `fetchEmployee` if you need the stored record.
688
- */
689
- const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
690
- /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
691
- const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
692
- const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
693
- const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
694
-
695
- //#endregion
696
- //#region src/core/basket.ts
697
- const DEFAULT_BASKET_FILTERS = {
698
- search: "",
699
- dateRange: {
700
- start: null,
701
- end: null
702
- },
703
- consumerId: null,
704
- status: "all"
705
- };
706
- const DEFAULT_PAGINATION = {
707
- page: 0,
708
- pageSize: 25,
709
- total: 0
710
- };
711
- /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
712
- function formatDateForQuery(date, isEndOfDay = false) {
713
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
714
- }
715
- /** Builds the `Basket` report query from a set of filters. */
716
- function buildBasketReportQuery(filters) {
717
- const whereConditions = [];
718
- const today = /* @__PURE__ */ new Date();
719
- const startDate = filters.dateRange.start ?? today;
720
- const endDate = filters.dateRange.end ?? today;
721
- whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
722
- whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
723
- if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
724
- if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
725
- return { baskets: {
726
- FROM: "Basket",
727
- WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
728
- } };
729
- }
730
- /** Flattens the raw report rows/keys into structured baskets. */
731
- function parseBasketReportData(reports) {
732
- if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
733
- const { keys, data } = reports.baskets;
734
- const keyIndices = {};
735
- keys.forEach((key, index) => {
736
- keyIndices[key.name] = index;
737
- });
738
- const productKey = keys.find((k) => k.name === "Basket.Product");
739
- const productSubKeys = {};
740
- if (productKey?.item) productKey.item.forEach((subKey, index) => {
741
- productSubKeys[subKey.name] = index;
742
- });
743
- return data.map((row) => {
744
- const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
745
- productId: productRow[productSubKeys["Basket.Product.productId"]],
746
- productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
747
- quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
748
- price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
749
- tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
750
- }));
751
- const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
752
- const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
753
- const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
754
- const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
755
- return {
756
- id: row[keyIndices["Basket.id"]],
757
- name: row[keyIndices["Basket.name"]],
758
- consumerId: row[keyIndices["Basket.consumerId"]],
759
- products,
760
- total,
761
- taxTotal,
762
- status: "completed",
763
- completeDate
764
- };
765
- });
766
- }
767
- /** Unique non-null consumer ids referenced by a set of baskets. */
768
- function collectConsumerIds(baskets) {
769
- if (!baskets.length) return null;
770
- const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
771
- return ids.length > 0 ? [...new Set(ids)] : null;
772
- }
773
- /** Unique product ids referenced by a set of baskets. */
774
- function collectProductIds(baskets) {
775
- if (!baskets.length) return null;
776
- const ids = /* @__PURE__ */ new Set();
777
- baskets.forEach((basket) => {
778
- basket.products.forEach((product) => {
779
- if (product.productId) ids.add(product.productId);
780
- });
781
- });
782
- return ids.size > 0 ? [...ids] : null;
783
- }
784
- /** Joins baskets with consumer + product lookups, applies search, sorts. */
785
- function combineBasketRows({ baskets, consumerMap, productMap, search }) {
786
- if (!baskets.length) return [];
787
- let combinedRows = baskets.map((basket) => {
788
- const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
789
- const productsWithNames = basket.products.map((p) => {
790
- const product = productMap[p.productId];
791
- return {
792
- ...p,
793
- productName: product?.name || `Product #${p.productId}`
794
- };
795
- });
796
- return {
797
- id: basket.id,
798
- basketId: basket.id,
799
- basketName: basket.name || `Basket #${basket.id}`,
800
- consumerId: basket.consumerId,
801
- consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
802
- consumerEmail: consumer?.email || "-",
803
- products: productsWithNames,
804
- basketTotal: (basket.total || 0) / 100,
805
- tax: basket.taxTotal || 0,
806
- dateTime: basket.completeDate,
807
- status: basket.status || "pending"
808
- };
809
- });
810
- if (search) {
811
- const searchLower = search.toLowerCase();
812
- combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
813
- }
814
- combinedRows.sort((a, b) => {
815
- const dateA = new Date(a.dateTime).getTime();
816
- return new Date(b.dateTime).getTime() - dateA;
817
- });
818
- return combinedRows;
819
- }
820
- /** Aggregate totals for a set of report rows. */
821
- function computeBasketSummary(rows) {
822
- const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
823
- return {
824
- totalBaskets: rows.length,
825
- totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
826
- totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
827
- uniqueConsumers: uniqueConsumerIds.size
828
- };
829
- }
830
- /** Builds a consumer id → consumer lookup. */
831
- function buildConsumerMap(consumers) {
832
- const map = {};
833
- if (consumers) consumers.forEach((consumer) => {
834
- map[consumer.id] = consumer;
835
- });
836
- return map;
837
- }
838
-
839
- //#endregion
840
- //#region src/core/resources.ts
841
- /**
842
- * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
843
- * updates it (without an `id` the API would create a second merchant, so the
844
- * route requires one).
845
- * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
846
- * needs the admin scope).
847
- */
848
- const SHOPWAVE_RESOURCES = Object.freeze({
849
- merchant: {
850
- route: "merchant",
851
- key: "merchant",
852
- upstream: "merchant",
853
- writable: true
854
- },
855
- user: {
856
- route: "user",
857
- key: "user",
858
- upstream: "user",
859
- writable: false
860
- }
861
- });
862
- /** `UploadKind` → the API's `contentType` value. */
863
- const UPLOAD_KINDS = Object.freeze({
864
- merchant: "merchant",
865
- merchantFeature: "merchantFeature",
866
- product: "product",
867
- user: "user",
868
- applicationLogo: "applicaionLogo",
869
- applicationImages: "applicaionImages"
870
- });
871
- /** How uploads are addressed (app route and Shopwave uploader). */
872
- const SHOPWAVE_UPLOAD = Object.freeze({
873
- route: "upload",
874
- upstream: "uploader",
875
- kindHeader: "contentType",
876
- fileField: "file",
877
- kindField: "kind"
878
- });
879
- function isUploadKind(value) {
880
- return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
881
- }
882
-
883
- //#endregion
884
- //#region src/core/imageFit.ts
885
- /**
886
- * The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
887
- * `dstWidth × dstHeight` (like CSS `object-fit: cover`).
888
- */
889
- function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
890
- if (![
891
- srcWidth,
892
- srcHeight,
893
- dstWidth,
894
- dstHeight
895
- ].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
896
- const srcRatio = srcWidth / srcHeight;
897
- const dstRatio = dstWidth / dstHeight;
898
- if (srcRatio > dstRatio) {
899
- const sw = Math.round(srcHeight * dstRatio);
900
- return {
901
- sx: Math.round((srcWidth - sw) / 2),
902
- sy: 0,
903
- sw,
904
- sh: srcHeight
905
- };
906
- }
907
- const sh = Math.round(srcWidth / dstRatio);
908
- return {
909
- sx: 0,
910
- sy: Math.round((srcHeight - sh) / 2),
911
- sw: srcWidth,
912
- sh
913
- };
914
- }
915
- function requireBrowserImages(fn) {
916
- if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
917
- }
918
- /** Reads an image file's pixel size (browser only). */
919
- async function readImageSize(file) {
920
- requireBrowserImages("readImageSize");
921
- const bitmap = await createImageBitmap(file);
922
- try {
923
- return {
924
- width: bitmap.width,
925
- height: bitmap.height
926
- };
927
- } finally {
928
- bitmap.close?.();
929
- }
930
- }
931
- /**
932
- * Returns the image at exactly `width × height`: scaled and centre-cropped
933
- * (never stretched). Browser only. The result is a `File` named like the
934
- * input, with the output type's extension.
666
+ * Returns the image at exactly `width × height`: scaled and centre-cropped
667
+ * (never stretched). Browser only. The result is a `File` named like the
668
+ * input, with the output type's extension.
935
669
  */
936
670
  async function fitImageToSize(file, width, height, options = {}) {
937
671
  requireBrowserImages("fitImageToSize");
@@ -964,181 +698,559 @@ async function fitImageToSize(file, width, height, options = {}) {
964
698
  ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
965
699
  blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
966
700
  }
967
- return new File([blob], outName, { type });
968
- } finally {
969
- bitmap.close?.();
701
+ return new File([blob], outName, { type });
702
+ } finally {
703
+ bitmap.close?.();
704
+ }
705
+ }
706
+
707
+ //#endregion
708
+ //#region src/core/upload.ts
709
+ /**
710
+ * HTTPS host that serves the Shopwave CDN's images. The API returns
711
+ * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
712
+ * (checked Sep 2026), so pages served over HTTPS can't show it.
713
+ */
714
+ const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
715
+ const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
716
+ /**
717
+ * Turns an image path from the API into a URL a page can load over HTTPS:
718
+ * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
719
+ * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
720
+ * Other absolute URLs are returned unchanged; empty input gives `""`.
721
+ */
722
+ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
723
+ if (!path) return "";
724
+ const root = base.replace(/\/+$/, "");
725
+ if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
726
+ if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
727
+ return `${root}/${path.replace(/^\/+/, "")}`;
728
+ }
729
+ /**
730
+ * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
731
+ * (`createShopwaveApi(...).upload()`), and returns its id and URL.
732
+ *
733
+ * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
734
+ * record has to point at it to use it. Live API (Sep 2026): the upload answers
735
+ * 201 `{ fileName, path }`.
736
+ */
737
+ async function uploadImage(file, params, options = {}) {
738
+ if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
739
+ if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
740
+ if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
741
+ const name = params.fileName ?? file.name ?? "upload";
742
+ const form = new FormData();
743
+ form.append(SHOPWAVE_UPLOAD.fileField, file, name);
744
+ form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
745
+ if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
746
+ const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
747
+ method: "POST",
748
+ body: form
749
+ }, options);
750
+ const body = raw;
751
+ assertNoApiErrors(body, status);
752
+ const id = typeof body?.fileName === "string" ? body.fileName : "";
753
+ const path = typeof body?.path === "string" ? body.path : "";
754
+ if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
755
+ return {
756
+ id,
757
+ url: getImageUrl(path),
758
+ path
759
+ };
760
+ }
761
+
762
+ //#endregion
763
+ //#region src/core/merchantImages.ts
764
+ /** The three image slots on the merchant settings page. */
765
+ const MERCHANT_IMAGE_SLOT_KEYS = [
766
+ "receipt",
767
+ "square",
768
+ "featured"
769
+ ];
770
+ /**
771
+ * Where each slot lives, its upload kind and its exact size (from adminV1's
772
+ * merchant settings page).
773
+ */
774
+ const MERCHANT_IMAGE_SLOTS = Object.freeze({
775
+ receipt: {
776
+ field: "logo.receipt",
777
+ uploadKind: "merchant",
778
+ width: 576,
779
+ height: 325
780
+ },
781
+ square: {
782
+ field: "logo.square",
783
+ uploadKind: "merchant",
784
+ width: 1e3,
785
+ height: 1e3
786
+ },
787
+ featured: {
788
+ field: "feature[0]",
789
+ uploadKind: "merchantFeature",
790
+ width: 1200,
791
+ height: 600
792
+ }
793
+ });
794
+ /**
795
+ * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
796
+ * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
797
+ * Returns `null` for empty or non-string values.
798
+ */
799
+ function imageFileName(value) {
800
+ if (typeof value !== "string") return null;
801
+ const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
802
+ return last ? decodeURIComponent(last) : null;
803
+ }
804
+ /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
805
+ function getMerchantImage(imageIds, slot) {
806
+ if (!imageIds || typeof imageIds !== "object") return null;
807
+ let value;
808
+ if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
809
+ else {
810
+ const logo = imageIds.logo;
811
+ value = logo && typeof logo === "object" ? logo[slot] : void 0;
812
+ }
813
+ return typeof value === "string" && value.trim() ? value.trim() : null;
814
+ }
815
+ /**
816
+ * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
817
+ * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
818
+ */
819
+ function getMerchantImageUrl(imageIds, slot, base) {
820
+ const value = getMerchantImage(imageIds, slot);
821
+ if (!value || !value.includes("/")) return "";
822
+ return getImageUrl(value, base);
823
+ }
824
+ /**
825
+ * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
826
+ * with `null`). Other slots and unknown keys are kept; an empty `logo` or
827
+ * `feature` is removed.
828
+ */
829
+ function setMerchantImage(imageIds, slot, fileName) {
830
+ const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
831
+ if (slot === "featured") {
832
+ const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
833
+ const feature = fileName ? [fileName, ...rest] : rest;
834
+ if (feature.length > 0) next.feature = feature;
835
+ else delete next.feature;
836
+ } else {
837
+ const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
838
+ if (fileName) logo[slot] = fileName;
839
+ else delete logo[slot];
840
+ if (Object.keys(logo).length > 0) next.logo = logo;
841
+ else delete next.logo;
842
+ }
843
+ return next;
844
+ }
845
+ function toStored(value) {
846
+ if (typeof value === "string") return imageFileName(value) ?? void 0;
847
+ if (Array.isArray(value)) {
848
+ const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
849
+ return items.length > 0 ? items : void 0;
850
+ }
851
+ if (value && typeof value === "object") {
852
+ const out = {};
853
+ for (const [k, v] of Object.entries(value)) {
854
+ const stored = toStored(v);
855
+ if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
856
+ }
857
+ return Object.keys(out).length > 0 ? out : void 0;
970
858
  }
971
859
  }
860
+ /**
861
+ * The `imageIds` to send on a save: every URL is turned back into its file
862
+ * name (what adminV1 does with images you didn't change) and empty values
863
+ * are dropped. No images gives `{}`.
864
+ */
865
+ function toStoredImageIds(imageIds) {
866
+ return toStored(imageIds) ?? {};
867
+ }
868
+ /**
869
+ * Uploads an image for a merchant slot: fits it to the slot's size, uploads
870
+ * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
871
+ * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
872
+ */
873
+ async function uploadMerchantImage(file, slot, options = {}) {
874
+ const def = MERCHANT_IMAGE_SLOTS[slot];
875
+ if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
876
+ const { fit = true, fitOptions,...requestOptions } = options;
877
+ return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
878
+ }
972
879
 
973
880
  //#endregion
974
- //#region src/core/upload.ts
881
+ //#region src/core/productImages.ts
975
882
  /**
976
- * HTTPS host that serves the Shopwave CDN's images. The API returns
977
- * `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
978
- * (checked Sep 2026), so pages served over HTTPS can't show it.
883
+ * Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
884
+ * - A product's `images` is a list. **Reads return full URLs**
885
+ * (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
886
+ * **writes take the uploader's file names** ({@link toStoredProductImages};
887
+ * `saveProduct` does this for you).
888
+ * - The uploader takes one file per request. With a `prefix` (see
889
+ * `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
890
+ * (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
891
+ * image's role can be read back from its name ({@link productImageRole}).
979
892
  */
980
- const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
981
- const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
982
893
  /**
983
- * Turns an image path from the API into a URL a page can load over HTTPS:
984
- * `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
985
- * relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
986
- * Other absolute URLs are returned unchanged; empty input gives `""`.
894
+ * The role in an image's file name or URL, from the uploader's `prefix`:
895
+ * `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
896
+ * without one (images uploaded without a prefix, e.g. before prefixes existed).
987
897
  */
988
- function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
989
- if (!path) return "";
990
- const root = base.replace(/\/+$/, "");
991
- if (STATIC_HOST.test(path)) return `${root}/${path.replace(STATIC_HOST, "")}`;
992
- if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) return path;
993
- return `${root}/${path.replace(/^\/+/, "")}`;
898
+ function productImageRole(value) {
899
+ const name = imageFileName(value);
900
+ if (!name) return null;
901
+ return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
994
902
  }
995
903
  /**
996
- * Uploads one image to the Shopwave CDN through the app's `/api/upload` route
997
- * (`createShopwaveApi(...).upload()`), and returns its id and URL.
904
+ * A product's `images` as the API takes them on a save: file names, not the
905
+ * URLs a read returns. Non-strings, blanks and duplicates are dropped; the
906
+ * order is kept.
907
+ */
908
+ function toStoredProductImages(images) {
909
+ if (!Array.isArray(images)) return [];
910
+ const names = images.map(imageFileName).filter((name) => Boolean(name));
911
+ return Array.from(new Set(names));
912
+ }
913
+
914
+ //#endregion
915
+ //#region src/core/entities.ts
916
+ /**
917
+ * Addressing per entity, following the Shopwave API reference
918
+ * (https://developer.merchantstack.com/api-reference.html) and checked against
919
+ * the live API where noted:
920
+ * - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
921
+ * - promotion: no DELETE; promotions are never deleted, only expired. Expiring
922
+ * reads the promotion and saves the whole record back with `endDate` = now (and
923
+ * `startDate` = now too if it hadn't started); the live API rejects a partial
924
+ * `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
925
+ * - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
926
+ * changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
927
+ * - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
928
+ */
929
+ const SHOPWAVE_ENTITIES = Object.freeze({
930
+ product: {
931
+ route: "products",
932
+ collection: "products",
933
+ upstream: "product",
934
+ idsHeader: "productIds",
935
+ idHeader: "productId",
936
+ writable: true,
937
+ deleteMode: "delete"
938
+ },
939
+ category: {
940
+ route: "categories",
941
+ collection: "categories",
942
+ upstream: "category",
943
+ idsHeader: "categoryIds",
944
+ idHeader: "categoryId",
945
+ writable: true,
946
+ deleteMode: "delete"
947
+ },
948
+ store: {
949
+ route: "stores",
950
+ collection: "stores",
951
+ upstream: "store",
952
+ idsHeader: "storeIds",
953
+ idHeader: "storeId",
954
+ writable: true,
955
+ deleteMode: "delete"
956
+ },
957
+ promotion: {
958
+ route: "promotions",
959
+ collection: "promotions",
960
+ upstream: "promotion",
961
+ idsHeader: "promotionIds",
962
+ idHeader: "promotionId",
963
+ writable: true,
964
+ deleteMode: "retire",
965
+ retireField: "endDate",
966
+ retireWithRecord: true,
967
+ retireStartField: "startDate"
968
+ },
969
+ employee: {
970
+ route: "employees",
971
+ collection: "employees",
972
+ upstream: "employee",
973
+ idsHeader: "employeeIds",
974
+ idHeader: "employeeId",
975
+ writable: true,
976
+ deleteMode: "retire",
977
+ retireField: "exitDate"
978
+ },
979
+ consumer: {
980
+ route: "consumer",
981
+ collection: "consumers",
982
+ upstream: "consumer",
983
+ idsHeader: "ids",
984
+ idHeader: "consumerId",
985
+ writable: false,
986
+ deleteMode: "none"
987
+ }
988
+ });
989
+ function definition(kind) {
990
+ const def = SHOPWAVE_ENTITIES[kind];
991
+ if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
992
+ return def;
993
+ }
994
+ function assertId(kind, id) {
995
+ const s = id == null ? "" : String(id).trim();
996
+ if (!s) throw new Error(`${kind} id is missing`);
997
+ return encodeURIComponent(s);
998
+ }
999
+ /**
1000
+ * Creates or updates several entities of one kind in a single request and
1001
+ * returns them as saved (with their ids), in the order given.
998
1002
  *
999
- * The file only lands on the CDN (under `…/images/<kind>/<merchantId>/…`); a
1000
- * record has to point at it to use it. Live API (Sep 2026): the upload answers
1001
- * 201 `{ fileName, path }`.
1003
+ * The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
1004
+ * answers 201 and echoes every entity under the ref it was sent with, so each
1005
+ * result is matched back by ref. Throws {@link ShopwaveApiError} when the
1006
+ * response carries `api.message.errors` or any ref is missing from it.
1007
+ *
1008
+ * The API's partial-success semantics for multi-entity saves haven't been
1009
+ * confirmed yet; single saves (`saveProduct` etc.) are the tested path.
1002
1010
  */
1003
- async function uploadImage(file, params, options = {}) {
1004
- if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
1005
- if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
1006
- const name = params.fileName ?? file.name ?? "upload";
1007
- const form = new FormData();
1008
- form.append(SHOPWAVE_UPLOAD.fileField, file, name);
1009
- form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
1010
- const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
1011
+ async function saveEntities(kind, items, options = {}) {
1012
+ const def = definition(kind);
1013
+ if (items.length === 0) return [];
1014
+ const refs = items.map((_, i) => String(i));
1015
+ const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
1016
+ const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
1011
1017
  method: "POST",
1012
- body: form
1018
+ headers: { "Content-Type": "application/json" },
1019
+ body: JSON.stringify(payload)
1013
1020
  }, options);
1014
1021
  const body = raw;
1015
1022
  assertNoApiErrors(body, status);
1016
- const id = typeof body?.fileName === "string" ? body.fileName : "";
1017
- const path = typeof body?.path === "string" ? body.path : "";
1018
- if (!id) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the upload response has no fileName`, { body });
1019
- return {
1020
- id,
1021
- url: getImageUrl(path),
1022
- path
1023
- };
1023
+ const echoed = body?.[def.collection] ?? {};
1024
+ return refs.map((ref, i) => {
1025
+ const saved = echoed[ref];
1026
+ if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
1027
+ return {
1028
+ ...items[i],
1029
+ ...saved,
1030
+ id: Number(saved.id)
1031
+ };
1032
+ });
1033
+ }
1034
+ /** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
1035
+ async function saveEntity(kind, item, options = {}) {
1036
+ const [saved] = await saveEntities(kind, [item], options);
1037
+ return saved;
1024
1038
  }
1025
-
1026
- //#endregion
1027
- //#region src/core/merchantImages.ts
1028
- /** The three image slots on the merchant settings page. */
1029
- const MERCHANT_IMAGE_SLOT_KEYS = [
1030
- "receipt",
1031
- "square",
1032
- "featured"
1033
- ];
1034
1039
  /**
1035
- * Where each slot lives, its upload kind and its exact size (from adminV1's
1036
- * merchant settings page).
1040
+ * Deletes one entity (a soft delete: it stays readable with `deleted: true`).
1041
+ * Employees and promotions can't be deleted in Shopwave; for them the route
1042
+ * sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
1043
+ * promotion is saved back whole (see {@link deletePromotion}).
1044
+ *
1045
+ * Shopwave answers 205 with an empty body — also for ids that don't exist — so
1046
+ * a resolved promise means "the API accepted the request", not "a record was
1047
+ * deleted".
1048
+ */
1049
+ async function deleteEntityById(kind, id, options = {}) {
1050
+ await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
1051
+ }
1052
+ /** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
1053
+ async function fetchEntityById(kind, id, params = {}, options = {}) {
1054
+ const def = definition(kind);
1055
+ const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
1056
+ assertNoApiErrors(body, 200);
1057
+ const map = body?.[def.collection] ?? {};
1058
+ return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
1059
+ }
1060
+ /**
1061
+ * Creates (no `id`) or updates (with `id`) a product.
1062
+ *
1063
+ * Shopwave instances (prices) can't be updated, only replaced. Build
1064
+ * `instances` with {@link toProductInstancesForSave} (passing the product as last
1065
+ * read) so an edited price is sent as a new instance. As a safeguard, a new
1066
+ * product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
1067
+ * another product would otherwise point the save at that product's instances.
1068
+ *
1069
+ * `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
1070
+ * read returns can be passed straight back; leaving `images` out keeps them.
1071
+ */
1072
+ const saveProduct = (product, options) => {
1073
+ let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
1074
+ ...product,
1075
+ instances: toProductInstancesForSave(product.instances)
1076
+ } : product;
1077
+ if (Array.isArray(toSave.images)) toSave = {
1078
+ ...toSave,
1079
+ images: toStoredProductImages(toSave.images)
1080
+ };
1081
+ return saveEntity("product", toSave, options);
1082
+ };
1083
+ const deleteProduct = (id, options) => deleteEntityById("product", id, options);
1084
+ const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
1085
+ const saveCategory = (category, options) => saveEntity("category", category, options);
1086
+ const deleteCategory = (id, options) => deleteEntityById("category", id, options);
1087
+ const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
1088
+ const saveStore = (store, options) => saveEntity("store", store, options);
1089
+ const deleteStore = (id, options) => deleteEntityById("store", id, options);
1090
+ const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
1091
+ const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
1092
+ /**
1093
+ * Expires a promotion (Shopwave never deletes promotions). The route reads the
1094
+ * promotion and saves it back with `endDate` set to now; a promotion that hasn't
1095
+ * started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
1096
+ * exist or the save fails.
1037
1097
  */
1038
- const MERCHANT_IMAGE_SLOTS = Object.freeze({
1039
- receipt: {
1040
- field: "logo.receipt",
1041
- uploadKind: "merchant",
1042
- width: 576,
1043
- height: 325
1044
- },
1045
- square: {
1046
- field: "logo.square",
1047
- uploadKind: "merchant",
1048
- width: 1e3,
1049
- height: 1e3
1050
- },
1051
- featured: {
1052
- field: "feature[0]",
1053
- uploadKind: "merchantFeature",
1054
- width: 1200,
1055
- height: 600
1056
- }
1057
- });
1098
+ const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
1099
+ const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
1058
1100
  /**
1059
- * The file name in an image value: `"http://…/logo/abc.png"` → `"abc.png"`,
1060
- * `"abc.png"` → `"abc.png"`. Query strings and fragments are ignored.
1061
- * Returns `null` for empty or non-string values.
1101
+ * Creates an employee, or updates one (with `id`). On update Shopwave only
1102
+ * applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
1103
+ * email can't be changed, and the echo only carries the updated fields — re-read
1104
+ * with `fetchEmployee` if you need the stored record.
1062
1105
  */
1063
- function imageFileName(value) {
1064
- if (typeof value !== "string") return null;
1065
- const last = value.trim().replace(/[?#].*$/, "").split("/").pop() ?? "";
1066
- return last ? decodeURIComponent(last) : null;
1106
+ const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
1107
+ /** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
1108
+ const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
1109
+ const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
1110
+ const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
1111
+
1112
+ //#endregion
1113
+ //#region src/core/basket.ts
1114
+ const DEFAULT_BASKET_FILTERS = {
1115
+ search: "",
1116
+ dateRange: {
1117
+ start: null,
1118
+ end: null
1119
+ },
1120
+ consumerId: null,
1121
+ status: "all"
1122
+ };
1123
+ const DEFAULT_PAGINATION = {
1124
+ page: 0,
1125
+ pageSize: 25,
1126
+ total: 0
1127
+ };
1128
+ /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
1129
+ function formatDateForQuery(date, isEndOfDay = false) {
1130
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
1067
1131
  }
1068
- /** The stored value for a slot (a URL when read from the API; a file name right after an upload). */
1069
- function getMerchantImage(imageIds, slot) {
1070
- if (!imageIds || typeof imageIds !== "object") return null;
1071
- let value;
1072
- if (slot === "featured") value = Array.isArray(imageIds.feature) ? imageIds.feature[0] : void 0;
1073
- else {
1074
- const logo = imageIds.logo;
1075
- value = logo && typeof logo === "object" ? logo[slot] : void 0;
1076
- }
1077
- return typeof value === "string" && value.trim() ? value.trim() : null;
1132
+ /** Builds the `Basket` report query from a set of filters. */
1133
+ function buildBasketReportQuery(filters) {
1134
+ const whereConditions = [];
1135
+ const today = /* @__PURE__ */ new Date();
1136
+ const startDate = filters.dateRange.start ?? today;
1137
+ const endDate = filters.dateRange.end ?? today;
1138
+ whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
1139
+ whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
1140
+ if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
1141
+ if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
1142
+ return { baskets: {
1143
+ FROM: "Basket",
1144
+ WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
1145
+ } };
1078
1146
  }
1079
- /**
1080
- * An HTTPS URL for a slot's stored image, or `""` when the slot is empty or
1081
- * holds only a file name (a fresh upload not yet saved; use the upload's `url`).
1082
- */
1083
- function getMerchantImageUrl(imageIds, slot, base) {
1084
- const value = getMerchantImage(imageIds, slot);
1085
- if (!value || !value.includes("/")) return "";
1086
- return getImageUrl(value, base);
1147
+ /** Flattens the raw report rows/keys into structured baskets. */
1148
+ function parseBasketReportData(reports) {
1149
+ if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
1150
+ const { keys, data } = reports.baskets;
1151
+ const keyIndices = {};
1152
+ keys.forEach((key, index) => {
1153
+ keyIndices[key.name] = index;
1154
+ });
1155
+ const productKey = keys.find((k) => k.name === "Basket.Product");
1156
+ const productSubKeys = {};
1157
+ if (productKey?.item) productKey.item.forEach((subKey, index) => {
1158
+ productSubKeys[subKey.name] = index;
1159
+ });
1160
+ return data.map((row) => {
1161
+ const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
1162
+ productId: productRow[productSubKeys["Basket.Product.productId"]],
1163
+ productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
1164
+ quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
1165
+ price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
1166
+ tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
1167
+ }));
1168
+ const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
1169
+ const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
1170
+ const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
1171
+ const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
1172
+ return {
1173
+ id: row[keyIndices["Basket.id"]],
1174
+ name: row[keyIndices["Basket.name"]],
1175
+ consumerId: row[keyIndices["Basket.consumerId"]],
1176
+ products,
1177
+ total,
1178
+ taxTotal,
1179
+ status: "completed",
1180
+ completeDate
1181
+ };
1182
+ });
1087
1183
  }
1088
- /**
1089
- * Returns a copy of `imageIds` with one slot set to `fileName` (or cleared
1090
- * with `null`). Other slots and unknown keys are kept; an empty `logo` or
1091
- * `feature` is removed.
1092
- */
1093
- function setMerchantImage(imageIds, slot, fileName) {
1094
- const next = { ...imageIds && typeof imageIds === "object" ? imageIds : {} };
1095
- if (slot === "featured") {
1096
- const rest = Array.isArray(next.feature) ? next.feature.slice(1) : [];
1097
- const feature = fileName ? [fileName, ...rest] : rest;
1098
- if (feature.length > 0) next.feature = feature;
1099
- else delete next.feature;
1100
- } else {
1101
- const logo = { ...next.logo && typeof next.logo === "object" ? next.logo : {} };
1102
- if (fileName) logo[slot] = fileName;
1103
- else delete logo[slot];
1104
- if (Object.keys(logo).length > 0) next.logo = logo;
1105
- else delete next.logo;
1106
- }
1107
- return next;
1184
+ /** Unique non-null consumer ids referenced by a set of baskets. */
1185
+ function collectConsumerIds(baskets) {
1186
+ if (!baskets.length) return null;
1187
+ const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
1188
+ return ids.length > 0 ? [...new Set(ids)] : null;
1108
1189
  }
1109
- function toStored(value) {
1110
- if (typeof value === "string") return imageFileName(value) ?? void 0;
1111
- if (Array.isArray(value)) {
1112
- const items = value.map(toStored).filter((v) => v !== void 0 && v !== null && v !== "");
1113
- return items.length > 0 ? items : void 0;
1114
- }
1115
- if (value && typeof value === "object") {
1116
- const out = {};
1117
- for (const [k, v] of Object.entries(value)) {
1118
- const stored = toStored(v);
1119
- if (stored !== void 0 && stored !== null && stored !== "") out[k] = stored;
1120
- }
1121
- return Object.keys(out).length > 0 ? out : void 0;
1190
+ /** Unique product ids referenced by a set of baskets. */
1191
+ function collectProductIds(baskets) {
1192
+ if (!baskets.length) return null;
1193
+ const ids = /* @__PURE__ */ new Set();
1194
+ baskets.forEach((basket) => {
1195
+ basket.products.forEach((product) => {
1196
+ if (product.productId) ids.add(product.productId);
1197
+ });
1198
+ });
1199
+ return ids.size > 0 ? [...ids] : null;
1200
+ }
1201
+ /** Joins baskets with consumer + product lookups, applies search, sorts. */
1202
+ function combineBasketRows({ baskets, consumerMap, productMap, search }) {
1203
+ if (!baskets.length) return [];
1204
+ let combinedRows = baskets.map((basket) => {
1205
+ const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
1206
+ const productsWithNames = basket.products.map((p) => {
1207
+ const product = productMap[p.productId];
1208
+ return {
1209
+ ...p,
1210
+ productName: product?.name || `Product #${p.productId}`
1211
+ };
1212
+ });
1213
+ return {
1214
+ id: basket.id,
1215
+ basketId: basket.id,
1216
+ basketName: basket.name || `Basket #${basket.id}`,
1217
+ consumerId: basket.consumerId,
1218
+ consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
1219
+ consumerEmail: consumer?.email || "-",
1220
+ products: productsWithNames,
1221
+ basketTotal: (basket.total || 0) / 100,
1222
+ tax: basket.taxTotal || 0,
1223
+ dateTime: basket.completeDate,
1224
+ status: basket.status || "pending"
1225
+ };
1226
+ });
1227
+ if (search) {
1228
+ const searchLower = search.toLowerCase();
1229
+ combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
1122
1230
  }
1231
+ combinedRows.sort((a, b) => {
1232
+ const dateA = new Date(a.dateTime).getTime();
1233
+ return new Date(b.dateTime).getTime() - dateA;
1234
+ });
1235
+ return combinedRows;
1123
1236
  }
1124
- /**
1125
- * The `imageIds` to send on a save: every URL is turned back into its file
1126
- * name (what adminV1 does with images you didn't change) and empty values
1127
- * are dropped. No images gives `{}`.
1128
- */
1129
- function toStoredImageIds(imageIds) {
1130
- return toStored(imageIds) ?? {};
1237
+ /** Aggregate totals for a set of report rows. */
1238
+ function computeBasketSummary(rows) {
1239
+ const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
1240
+ return {
1241
+ totalBaskets: rows.length,
1242
+ totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
1243
+ totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
1244
+ uniqueConsumers: uniqueConsumerIds.size
1245
+ };
1131
1246
  }
1132
- /**
1133
- * Uploads an image for a merchant slot: fits it to the slot's size, uploads
1134
- * it with the slot's kind, and returns `{ id, url, path }`. Put `id` into
1135
- * `imageIds` with {@link setMerchantImage} and save with `updateMerchant`.
1136
- */
1137
- async function uploadMerchantImage(file, slot, options = {}) {
1138
- const def = MERCHANT_IMAGE_SLOTS[slot];
1139
- if (!def) throw new Error(`uploadMerchantImage: unknown slot "${slot}"`);
1140
- const { fit = true, fitOptions,...requestOptions } = options;
1141
- return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
1247
+ /** Builds a consumer id → consumer lookup. */
1248
+ function buildConsumerMap(consumers) {
1249
+ const map = {};
1250
+ if (consumers) consumers.forEach((consumer) => {
1251
+ map[consumer.id] = consumer;
1252
+ });
1253
+ return map;
1142
1254
  }
1143
1255
 
1144
1256
  //#endregion
@@ -1546,6 +1658,33 @@ function useProduct(params = {}, options) {
1546
1658
  ]);
1547
1659
  }
1548
1660
 
1661
+ //#endregion
1662
+ //#region src/hooks/useProductTags.ts
1663
+ /**
1664
+ * The merchant's distinct product tags, for a tag field's suggestions.
1665
+ * `data` is `string[]` once loaded.
1666
+ *
1667
+ * @example
1668
+ * const [opened, setOpened] = useState(false);
1669
+ * const { data: tags, loading } = useProductTags({ enabled: opened });
1670
+ */
1671
+ function useProductTags(options) {
1672
+ return useQuery((signal) => fetchProductTags({
1673
+ ...options,
1674
+ signal
1675
+ }), [options?.baseUrl, options?.token], options?.enabled ?? true);
1676
+ }
1677
+ /**
1678
+ * The merchant's distinct price-instance (channel) tags: Eat In, Takeout, …
1679
+ * `data` is `string[]` once loaded.
1680
+ */
1681
+ function useProductInstanceTags(options) {
1682
+ return useQuery((signal) => fetchProductInstanceTags({
1683
+ ...options,
1684
+ signal
1685
+ }), [options?.baseUrl, options?.token], options?.enabled ?? true);
1686
+ }
1687
+
1549
1688
  //#endregion
1550
1689
  //#region src/hooks/usePromotion.ts
1551
1690
  /**
@@ -1869,10 +2008,12 @@ exports.MERCHANT_READ_ONLY_FIELDS = MERCHANT_READ_ONLY_FIELDS;
1869
2008
  exports.PromotionRuleSet = PromotionRuleSet;
1870
2009
  exports.SHOPWAVE_ENTITIES = SHOPWAVE_ENTITIES;
1871
2010
  exports.SHOPWAVE_IMAGE_BASE_URL = SHOPWAVE_IMAGE_BASE_URL;
2011
+ exports.SHOPWAVE_LOOKUPS = SHOPWAVE_LOOKUPS;
1872
2012
  exports.SHOPWAVE_RESOURCES = SHOPWAVE_RESOURCES;
1873
2013
  exports.SHOPWAVE_UPLOAD = SHOPWAVE_UPLOAD;
1874
2014
  exports.ShopwaveApiError = ShopwaveApiError;
1875
2015
  exports.UPLOAD_KINDS = UPLOAD_KINDS;
2016
+ exports.UPLOAD_PREFIXES = UPLOAD_PREFIXES;
1876
2017
  exports.apiDelete = apiDelete;
1877
2018
  exports.apiGet = apiGet;
1878
2019
  exports.apiRequest = apiRequest;
@@ -1902,6 +2043,8 @@ exports.fetchEmployees = fetchEmployees;
1902
2043
  exports.fetchEntityById = fetchEntityById;
1903
2044
  exports.fetchMerchant = fetchMerchant;
1904
2045
  exports.fetchProduct = fetchProduct;
2046
+ exports.fetchProductInstanceTags = fetchProductInstanceTags;
2047
+ exports.fetchProductTags = fetchProductTags;
1905
2048
  exports.fetchProducts = fetchProducts;
1906
2049
  exports.fetchProductsMap = fetchProductsMap;
1907
2050
  exports.fetchPromotion = fetchPromotion;
@@ -1922,13 +2065,16 @@ exports.getMerchantImageUrl = getMerchantImageUrl;
1922
2065
  exports.imageFileName = imageFileName;
1923
2066
  exports.isSameInstancePrice = isSameInstancePrice;
1924
2067
  exports.isUploadKind = isUploadKind;
2068
+ exports.isUploadPrefix = isUploadPrefix;
1925
2069
  exports.loginPath = loginPath;
1926
2070
  exports.logout = logout;
1927
2071
  exports.logoutPath = logoutPath;
1928
2072
  exports.merchantLinksFromForm = merchantLinksFromForm;
1929
2073
  exports.merchantLinksToForm = merchantLinksToForm;
1930
2074
  exports.normalizeMerchantLinks = normalizeMerchantLinks;
2075
+ exports.normalizeTagList = normalizeTagList;
1931
2076
  exports.parseBasketReportData = parseBasketReportData;
2077
+ exports.productImageRole = productImageRole;
1932
2078
  exports.readImageSize = readImageSize;
1933
2079
  exports.rolesById = rolesById;
1934
2080
  exports.saveCategory = saveCategory;
@@ -1943,6 +2089,7 @@ exports.submitEntity = submitEntity;
1943
2089
  exports.toProductInstancesForSave = toProductInstancesForSave;
1944
2090
  exports.toShopwaveApiError = toShopwaveApiError;
1945
2091
  exports.toStoredImageIds = toStoredImageIds;
2092
+ exports.toStoredProductImages = toStoredProductImages;
1946
2093
  exports.updateMerchant = updateMerchant;
1947
2094
  exports.uploadImage = uploadImage;
1948
2095
  exports.uploadMerchantImage = uploadMerchantImage;
@@ -1954,6 +2101,8 @@ exports.useEmployee = useEmployee;
1954
2101
  exports.useLogout = useLogout;
1955
2102
  exports.useMerchant = useMerchant;
1956
2103
  exports.useProduct = useProduct;
2104
+ exports.useProductInstanceTags = useProductInstanceTags;
2105
+ exports.useProductTags = useProductTags;
1957
2106
  exports.usePromotion = usePromotion;
1958
2107
  exports.useReport = useReport;
1959
2108
  exports.useSession = useSession;