react-shopwave-connect 0.4.3 → 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/README.md +6 -4
- package/dist/core/index.cjs +484 -424
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +56 -2
- package/dist/core/index.d.ts +56 -2
- package/dist/core/index.js +481 -425
- package/dist/core/index.js.map +1 -1
- package/dist/hooks/index.cjs +159 -145
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +19 -0
- package/dist/hooks/index.d.ts +19 -0
- package/dist/hooks/index.js +159 -145
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.cjs +484 -424
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -2
- package/dist/index.d.ts +56 -2
- package/dist/index.js +481 -425
- package/dist/index.js.map +1 -1
- package/dist/next/index.cjs +75 -57
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +21 -4
- package/dist/next/index.d.ts +21 -4
- package/dist/next/index.js +75 -58
- package/dist/next/index.js.map +1 -1
- package/dist/server/index.cjs +75 -57
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +26 -5
- package/dist/server/index.d.ts +26 -5
- package/dist/server/index.js +75 -58
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/core/index.js
CHANGED
|
@@ -381,17 +381,29 @@ const UPLOAD_KINDS = Object.freeze({
|
|
|
381
381
|
applicationLogo: "applicaionLogo",
|
|
382
382
|
applicationImages: "applicaionImages"
|
|
383
383
|
});
|
|
384
|
+
/** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
|
|
385
|
+
const UPLOAD_PREFIXES = Object.freeze([
|
|
386
|
+
"product_thumbnail",
|
|
387
|
+
"variant_thumbnail",
|
|
388
|
+
"product",
|
|
389
|
+
"variant"
|
|
390
|
+
]);
|
|
384
391
|
/** How uploads are addressed (app route and Shopwave uploader). */
|
|
385
392
|
const SHOPWAVE_UPLOAD = Object.freeze({
|
|
386
393
|
route: "upload",
|
|
387
394
|
upstream: "uploader",
|
|
388
395
|
kindHeader: "contentType",
|
|
396
|
+
prefixHeader: "prefix",
|
|
389
397
|
fileField: "file",
|
|
390
|
-
kindField: "kind"
|
|
398
|
+
kindField: "kind",
|
|
399
|
+
prefixField: "prefix"
|
|
391
400
|
});
|
|
392
401
|
function isUploadKind(value) {
|
|
393
402
|
return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
|
|
394
403
|
}
|
|
404
|
+
function isUploadPrefix(value) {
|
|
405
|
+
return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
|
|
406
|
+
}
|
|
395
407
|
const SHOPWAVE_LOOKUPS = Object.freeze({
|
|
396
408
|
productTags: {
|
|
397
409
|
route: "products/tags",
|
|
@@ -577,443 +589,109 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
|
|
|
577
589
|
}
|
|
578
590
|
|
|
579
591
|
//#endregion
|
|
580
|
-
//#region src/core/
|
|
592
|
+
//#region src/core/imageFit.ts
|
|
581
593
|
/**
|
|
582
|
-
*
|
|
583
|
-
* (
|
|
584
|
-
* the live API where noted:
|
|
585
|
-
* - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
|
|
586
|
-
* - promotion: no DELETE; promotions are never deleted, only expired. Expiring
|
|
587
|
-
* reads the promotion and saves the whole record back with `endDate` = now (and
|
|
588
|
-
* `startDate` = now too if it hadn't started); the live API rejects a partial
|
|
589
|
-
* `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
|
|
590
|
-
* - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
|
|
591
|
-
* changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
|
|
592
|
-
* - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
|
|
594
|
+
* The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
|
|
595
|
+
* `dstWidth × dstHeight` (like CSS `object-fit: cover`).
|
|
593
596
|
*/
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
deleteMode: "delete"
|
|
612
|
-
},
|
|
613
|
-
store: {
|
|
614
|
-
route: "stores",
|
|
615
|
-
collection: "stores",
|
|
616
|
-
upstream: "store",
|
|
617
|
-
idsHeader: "storeIds",
|
|
618
|
-
idHeader: "storeId",
|
|
619
|
-
writable: true,
|
|
620
|
-
deleteMode: "delete"
|
|
621
|
-
},
|
|
622
|
-
promotion: {
|
|
623
|
-
route: "promotions",
|
|
624
|
-
collection: "promotions",
|
|
625
|
-
upstream: "promotion",
|
|
626
|
-
idsHeader: "promotionIds",
|
|
627
|
-
idHeader: "promotionId",
|
|
628
|
-
writable: true,
|
|
629
|
-
deleteMode: "retire",
|
|
630
|
-
retireField: "endDate",
|
|
631
|
-
retireWithRecord: true,
|
|
632
|
-
retireStartField: "startDate"
|
|
633
|
-
},
|
|
634
|
-
employee: {
|
|
635
|
-
route: "employees",
|
|
636
|
-
collection: "employees",
|
|
637
|
-
upstream: "employee",
|
|
638
|
-
idsHeader: "employeeIds",
|
|
639
|
-
idHeader: "employeeId",
|
|
640
|
-
writable: true,
|
|
641
|
-
deleteMode: "retire",
|
|
642
|
-
retireField: "exitDate"
|
|
643
|
-
},
|
|
644
|
-
consumer: {
|
|
645
|
-
route: "consumer",
|
|
646
|
-
collection: "consumers",
|
|
647
|
-
upstream: "consumer",
|
|
648
|
-
idsHeader: "ids",
|
|
649
|
-
idHeader: "consumerId",
|
|
650
|
-
writable: false,
|
|
651
|
-
deleteMode: "none"
|
|
597
|
+
function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
|
|
598
|
+
if (![
|
|
599
|
+
srcWidth,
|
|
600
|
+
srcHeight,
|
|
601
|
+
dstWidth,
|
|
602
|
+
dstHeight
|
|
603
|
+
].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
|
|
604
|
+
const srcRatio = srcWidth / srcHeight;
|
|
605
|
+
const dstRatio = dstWidth / dstHeight;
|
|
606
|
+
if (srcRatio > dstRatio) {
|
|
607
|
+
const sw = Math.round(srcHeight * dstRatio);
|
|
608
|
+
return {
|
|
609
|
+
sx: Math.round((srcWidth - sw) / 2),
|
|
610
|
+
sy: 0,
|
|
611
|
+
sw,
|
|
612
|
+
sh: srcHeight
|
|
613
|
+
};
|
|
652
614
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
615
|
+
const sh = Math.round(srcWidth / dstRatio);
|
|
616
|
+
return {
|
|
617
|
+
sx: 0,
|
|
618
|
+
sy: Math.round((srcHeight - sh) / 2),
|
|
619
|
+
sw: srcWidth,
|
|
620
|
+
sh
|
|
621
|
+
};
|
|
658
622
|
}
|
|
659
|
-
function
|
|
660
|
-
|
|
661
|
-
if (!s) throw new Error(`${kind} id is missing`);
|
|
662
|
-
return encodeURIComponent(s);
|
|
623
|
+
function requireBrowserImages(fn) {
|
|
624
|
+
if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
|
|
663
625
|
}
|
|
664
|
-
/**
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
* answers 201 and echoes every entity under the ref it was sent with, so each
|
|
670
|
-
* result is matched back by ref. Throws {@link ShopwaveApiError} when the
|
|
671
|
-
* response carries `api.message.errors` or any ref is missing from it.
|
|
672
|
-
*
|
|
673
|
-
* The API's partial-success semantics for multi-entity saves haven't been
|
|
674
|
-
* confirmed yet; single saves (`saveProduct` etc.) are the tested path.
|
|
675
|
-
*/
|
|
676
|
-
async function saveEntities(kind, items, options = {}) {
|
|
677
|
-
const def = definition(kind);
|
|
678
|
-
if (items.length === 0) return [];
|
|
679
|
-
const refs = items.map((_, i) => String(i));
|
|
680
|
-
const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
|
|
681
|
-
const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
|
|
682
|
-
method: "POST",
|
|
683
|
-
headers: { "Content-Type": "application/json" },
|
|
684
|
-
body: JSON.stringify(payload)
|
|
685
|
-
}, options);
|
|
686
|
-
const body = raw;
|
|
687
|
-
assertNoApiErrors(body, status);
|
|
688
|
-
const echoed = body?.[def.collection] ?? {};
|
|
689
|
-
return refs.map((ref, i) => {
|
|
690
|
-
const saved = echoed[ref];
|
|
691
|
-
if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
|
|
626
|
+
/** Reads an image file's pixel size (browser only). */
|
|
627
|
+
async function readImageSize(file) {
|
|
628
|
+
requireBrowserImages("readImageSize");
|
|
629
|
+
const bitmap = await createImageBitmap(file);
|
|
630
|
+
try {
|
|
692
631
|
return {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
id: Number(saved.id)
|
|
632
|
+
width: bitmap.width,
|
|
633
|
+
height: bitmap.height
|
|
696
634
|
};
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
async function saveEntity(kind, item, options = {}) {
|
|
701
|
-
const [saved] = await saveEntities(kind, [item], options);
|
|
702
|
-
return saved;
|
|
635
|
+
} finally {
|
|
636
|
+
bitmap.close?.();
|
|
637
|
+
}
|
|
703
638
|
}
|
|
704
639
|
/**
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
* promotion is saved back whole (see {@link deletePromotion}).
|
|
709
|
-
*
|
|
710
|
-
* Shopwave answers 205 with an empty body — also for ids that don't exist — so
|
|
711
|
-
* a resolved promise means "the API accepted the request", not "a record was
|
|
712
|
-
* deleted".
|
|
640
|
+
* Returns the image at exactly `width × height`: scaled and centre-cropped
|
|
641
|
+
* (never stretched). Browser only. The result is a `File` named like the
|
|
642
|
+
* input, with the output type's extension.
|
|
713
643
|
*/
|
|
714
|
-
async function
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
644
|
+
async function fitImageToSize(file, width, height, options = {}) {
|
|
645
|
+
requireBrowserImages("fitImageToSize");
|
|
646
|
+
const { type = "image/png", quality, keepIfExact = true } = options;
|
|
647
|
+
const name = file.name || "image";
|
|
648
|
+
const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
|
|
649
|
+
const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
|
|
650
|
+
const bitmap = await createImageBitmap(file);
|
|
651
|
+
try {
|
|
652
|
+
if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
|
|
653
|
+
const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
|
|
654
|
+
let blob;
|
|
655
|
+
if (typeof OffscreenCanvas === "function") {
|
|
656
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
657
|
+
const ctx = canvas.getContext("2d");
|
|
658
|
+
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
659
|
+
ctx.imageSmoothingQuality = "high";
|
|
660
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
661
|
+
blob = await canvas.convertToBlob({
|
|
662
|
+
type,
|
|
663
|
+
quality
|
|
664
|
+
});
|
|
665
|
+
} else {
|
|
666
|
+
const canvas = document.createElement("canvas");
|
|
667
|
+
canvas.width = width;
|
|
668
|
+
canvas.height = height;
|
|
669
|
+
const ctx = canvas.getContext("2d");
|
|
670
|
+
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
671
|
+
ctx.imageSmoothingQuality = "high";
|
|
672
|
+
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
673
|
+
blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
|
|
674
|
+
}
|
|
675
|
+
return new File([blob], outName, { type });
|
|
676
|
+
} finally {
|
|
677
|
+
bitmap.close?.();
|
|
678
|
+
}
|
|
724
679
|
}
|
|
680
|
+
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/core/upload.ts
|
|
725
683
|
/**
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
* `instances` with {@link toProductInstancesForSave} (passing the product as last
|
|
730
|
-
* read) so an edited price is sent as a new instance. As a safeguard, a new
|
|
731
|
-
* product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
|
|
732
|
-
* another product would otherwise point the save at that product's instances.
|
|
733
|
-
*/
|
|
734
|
-
const saveProduct = (product, options) => {
|
|
735
|
-
return saveEntity("product", (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
|
|
736
|
-
...product,
|
|
737
|
-
instances: toProductInstancesForSave(product.instances)
|
|
738
|
-
} : product, options);
|
|
739
|
-
};
|
|
740
|
-
const deleteProduct = (id, options) => deleteEntityById("product", id, options);
|
|
741
|
-
const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
|
|
742
|
-
const saveCategory = (category, options) => saveEntity("category", category, options);
|
|
743
|
-
const deleteCategory = (id, options) => deleteEntityById("category", id, options);
|
|
744
|
-
const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
|
|
745
|
-
const saveStore = (store, options) => saveEntity("store", store, options);
|
|
746
|
-
const deleteStore = (id, options) => deleteEntityById("store", id, options);
|
|
747
|
-
const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
|
|
748
|
-
const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
|
|
749
|
-
/**
|
|
750
|
-
* Expires a promotion (Shopwave never deletes promotions). The route reads the
|
|
751
|
-
* promotion and saves it back with `endDate` set to now; a promotion that hasn't
|
|
752
|
-
* started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
|
|
753
|
-
* exist or the save fails.
|
|
684
|
+
* HTTPS host that serves the Shopwave CDN's images. The API returns
|
|
685
|
+
* `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
|
|
686
|
+
* (checked Sep 2026), so pages served over HTTPS can't show it.
|
|
754
687
|
*/
|
|
755
|
-
const
|
|
756
|
-
const
|
|
688
|
+
const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
|
|
689
|
+
const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
|
|
757
690
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*/
|
|
763
|
-
const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
|
|
764
|
-
/** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
|
|
765
|
-
const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
|
|
766
|
-
const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
|
|
767
|
-
const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
|
|
768
|
-
|
|
769
|
-
//#endregion
|
|
770
|
-
//#region src/core/basket.ts
|
|
771
|
-
const DEFAULT_BASKET_FILTERS = {
|
|
772
|
-
search: "",
|
|
773
|
-
dateRange: {
|
|
774
|
-
start: null,
|
|
775
|
-
end: null
|
|
776
|
-
},
|
|
777
|
-
consumerId: null,
|
|
778
|
-
status: "all"
|
|
779
|
-
};
|
|
780
|
-
const DEFAULT_PAGINATION = {
|
|
781
|
-
page: 0,
|
|
782
|
-
pageSize: 25,
|
|
783
|
-
total: 0
|
|
784
|
-
};
|
|
785
|
-
/** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
|
|
786
|
-
function formatDateForQuery(date, isEndOfDay = false) {
|
|
787
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
|
|
788
|
-
}
|
|
789
|
-
/** Builds the `Basket` report query from a set of filters. */
|
|
790
|
-
function buildBasketReportQuery(filters) {
|
|
791
|
-
const whereConditions = [];
|
|
792
|
-
const today = /* @__PURE__ */ new Date();
|
|
793
|
-
const startDate = filters.dateRange.start ?? today;
|
|
794
|
-
const endDate = filters.dateRange.end ?? today;
|
|
795
|
-
whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
|
|
796
|
-
whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
|
|
797
|
-
if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
|
|
798
|
-
if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
|
|
799
|
-
return { baskets: {
|
|
800
|
-
FROM: "Basket",
|
|
801
|
-
WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
|
|
802
|
-
} };
|
|
803
|
-
}
|
|
804
|
-
/** Flattens the raw report rows/keys into structured baskets. */
|
|
805
|
-
function parseBasketReportData(reports) {
|
|
806
|
-
if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
|
|
807
|
-
const { keys, data } = reports.baskets;
|
|
808
|
-
const keyIndices = {};
|
|
809
|
-
keys.forEach((key, index) => {
|
|
810
|
-
keyIndices[key.name] = index;
|
|
811
|
-
});
|
|
812
|
-
const productKey = keys.find((k) => k.name === "Basket.Product");
|
|
813
|
-
const productSubKeys = {};
|
|
814
|
-
if (productKey?.item) productKey.item.forEach((subKey, index) => {
|
|
815
|
-
productSubKeys[subKey.name] = index;
|
|
816
|
-
});
|
|
817
|
-
return data.map((row) => {
|
|
818
|
-
const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
|
|
819
|
-
productId: productRow[productSubKeys["Basket.Product.productId"]],
|
|
820
|
-
productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
|
|
821
|
-
quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
|
|
822
|
-
price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
|
|
823
|
-
tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
|
|
824
|
-
}));
|
|
825
|
-
const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
|
|
826
|
-
const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
|
|
827
|
-
const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
|
|
828
|
-
const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
|
|
829
|
-
return {
|
|
830
|
-
id: row[keyIndices["Basket.id"]],
|
|
831
|
-
name: row[keyIndices["Basket.name"]],
|
|
832
|
-
consumerId: row[keyIndices["Basket.consumerId"]],
|
|
833
|
-
products,
|
|
834
|
-
total,
|
|
835
|
-
taxTotal,
|
|
836
|
-
status: "completed",
|
|
837
|
-
completeDate
|
|
838
|
-
};
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
/** Unique non-null consumer ids referenced by a set of baskets. */
|
|
842
|
-
function collectConsumerIds(baskets) {
|
|
843
|
-
if (!baskets.length) return null;
|
|
844
|
-
const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
|
|
845
|
-
return ids.length > 0 ? [...new Set(ids)] : null;
|
|
846
|
-
}
|
|
847
|
-
/** Unique product ids referenced by a set of baskets. */
|
|
848
|
-
function collectProductIds(baskets) {
|
|
849
|
-
if (!baskets.length) return null;
|
|
850
|
-
const ids = /* @__PURE__ */ new Set();
|
|
851
|
-
baskets.forEach((basket) => {
|
|
852
|
-
basket.products.forEach((product) => {
|
|
853
|
-
if (product.productId) ids.add(product.productId);
|
|
854
|
-
});
|
|
855
|
-
});
|
|
856
|
-
return ids.size > 0 ? [...ids] : null;
|
|
857
|
-
}
|
|
858
|
-
/** Joins baskets with consumer + product lookups, applies search, sorts. */
|
|
859
|
-
function combineBasketRows({ baskets, consumerMap, productMap, search }) {
|
|
860
|
-
if (!baskets.length) return [];
|
|
861
|
-
let combinedRows = baskets.map((basket) => {
|
|
862
|
-
const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
|
|
863
|
-
const productsWithNames = basket.products.map((p) => {
|
|
864
|
-
const product = productMap[p.productId];
|
|
865
|
-
return {
|
|
866
|
-
...p,
|
|
867
|
-
productName: product?.name || `Product #${p.productId}`
|
|
868
|
-
};
|
|
869
|
-
});
|
|
870
|
-
return {
|
|
871
|
-
id: basket.id,
|
|
872
|
-
basketId: basket.id,
|
|
873
|
-
basketName: basket.name || `Basket #${basket.id}`,
|
|
874
|
-
consumerId: basket.consumerId,
|
|
875
|
-
consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
|
|
876
|
-
consumerEmail: consumer?.email || "-",
|
|
877
|
-
products: productsWithNames,
|
|
878
|
-
basketTotal: (basket.total || 0) / 100,
|
|
879
|
-
tax: basket.taxTotal || 0,
|
|
880
|
-
dateTime: basket.completeDate,
|
|
881
|
-
status: basket.status || "pending"
|
|
882
|
-
};
|
|
883
|
-
});
|
|
884
|
-
if (search) {
|
|
885
|
-
const searchLower = search.toLowerCase();
|
|
886
|
-
combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
|
|
887
|
-
}
|
|
888
|
-
combinedRows.sort((a, b) => {
|
|
889
|
-
const dateA = new Date(a.dateTime).getTime();
|
|
890
|
-
return new Date(b.dateTime).getTime() - dateA;
|
|
891
|
-
});
|
|
892
|
-
return combinedRows;
|
|
893
|
-
}
|
|
894
|
-
/** Aggregate totals for a set of report rows. */
|
|
895
|
-
function computeBasketSummary(rows) {
|
|
896
|
-
const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
|
|
897
|
-
return {
|
|
898
|
-
totalBaskets: rows.length,
|
|
899
|
-
totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
|
|
900
|
-
totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
|
|
901
|
-
uniqueConsumers: uniqueConsumerIds.size
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
/** Builds a consumer id → consumer lookup. */
|
|
905
|
-
function buildConsumerMap(consumers) {
|
|
906
|
-
const map = {};
|
|
907
|
-
if (consumers) consumers.forEach((consumer) => {
|
|
908
|
-
map[consumer.id] = consumer;
|
|
909
|
-
});
|
|
910
|
-
return map;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
//#endregion
|
|
914
|
-
//#region src/core/imageFit.ts
|
|
915
|
-
/**
|
|
916
|
-
* The centred part of a `srcWidth × srcHeight` image with the aspect ratio of
|
|
917
|
-
* `dstWidth × dstHeight` (like CSS `object-fit: cover`).
|
|
918
|
-
*/
|
|
919
|
-
function coverCrop(srcWidth, srcHeight, dstWidth, dstHeight) {
|
|
920
|
-
if (![
|
|
921
|
-
srcWidth,
|
|
922
|
-
srcHeight,
|
|
923
|
-
dstWidth,
|
|
924
|
-
dstHeight
|
|
925
|
-
].every((n) => Number.isFinite(n) && n > 0)) throw new Error("coverCrop: sizes must be positive numbers");
|
|
926
|
-
const srcRatio = srcWidth / srcHeight;
|
|
927
|
-
const dstRatio = dstWidth / dstHeight;
|
|
928
|
-
if (srcRatio > dstRatio) {
|
|
929
|
-
const sw = Math.round(srcHeight * dstRatio);
|
|
930
|
-
return {
|
|
931
|
-
sx: Math.round((srcWidth - sw) / 2),
|
|
932
|
-
sy: 0,
|
|
933
|
-
sw,
|
|
934
|
-
sh: srcHeight
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
const sh = Math.round(srcWidth / dstRatio);
|
|
938
|
-
return {
|
|
939
|
-
sx: 0,
|
|
940
|
-
sy: Math.round((srcHeight - sh) / 2),
|
|
941
|
-
sw: srcWidth,
|
|
942
|
-
sh
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
function requireBrowserImages(fn) {
|
|
946
|
-
if (typeof createImageBitmap !== "function") throw new Error(`${fn} needs a browser (createImageBitmap is not available)`);
|
|
947
|
-
}
|
|
948
|
-
/** Reads an image file's pixel size (browser only). */
|
|
949
|
-
async function readImageSize(file) {
|
|
950
|
-
requireBrowserImages("readImageSize");
|
|
951
|
-
const bitmap = await createImageBitmap(file);
|
|
952
|
-
try {
|
|
953
|
-
return {
|
|
954
|
-
width: bitmap.width,
|
|
955
|
-
height: bitmap.height
|
|
956
|
-
};
|
|
957
|
-
} finally {
|
|
958
|
-
bitmap.close?.();
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
/**
|
|
962
|
-
* Returns the image at exactly `width × height`: scaled and centre-cropped
|
|
963
|
-
* (never stretched). Browser only. The result is a `File` named like the
|
|
964
|
-
* input, with the output type's extension.
|
|
965
|
-
*/
|
|
966
|
-
async function fitImageToSize(file, width, height, options = {}) {
|
|
967
|
-
requireBrowserImages("fitImageToSize");
|
|
968
|
-
const { type = "image/png", quality, keepIfExact = true } = options;
|
|
969
|
-
const name = file.name || "image";
|
|
970
|
-
const ext = type === "image/jpeg" ? "jpg" : type.split("/")[1];
|
|
971
|
-
const outName = `${name.replace(/\.[^.]+$/, "")}.${ext}`;
|
|
972
|
-
const bitmap = await createImageBitmap(file);
|
|
973
|
-
try {
|
|
974
|
-
if (keepIfExact && bitmap.width === width && bitmap.height === height && file.type === type) return file instanceof File ? file : new File([file], outName, { type });
|
|
975
|
-
const { sx, sy, sw, sh } = coverCrop(bitmap.width, bitmap.height, width, height);
|
|
976
|
-
let blob;
|
|
977
|
-
if (typeof OffscreenCanvas === "function") {
|
|
978
|
-
const canvas = new OffscreenCanvas(width, height);
|
|
979
|
-
const ctx = canvas.getContext("2d");
|
|
980
|
-
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
981
|
-
ctx.imageSmoothingQuality = "high";
|
|
982
|
-
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
983
|
-
blob = await canvas.convertToBlob({
|
|
984
|
-
type,
|
|
985
|
-
quality
|
|
986
|
-
});
|
|
987
|
-
} else {
|
|
988
|
-
const canvas = document.createElement("canvas");
|
|
989
|
-
canvas.width = width;
|
|
990
|
-
canvas.height = height;
|
|
991
|
-
const ctx = canvas.getContext("2d");
|
|
992
|
-
if (!ctx) throw new Error("fitImageToSize: no 2D canvas context");
|
|
993
|
-
ctx.imageSmoothingQuality = "high";
|
|
994
|
-
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, width, height);
|
|
995
|
-
blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(/* @__PURE__ */ new Error("fitImageToSize: encoding failed")), type, quality));
|
|
996
|
-
}
|
|
997
|
-
return new File([blob], outName, { type });
|
|
998
|
-
} finally {
|
|
999
|
-
bitmap.close?.();
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
//#endregion
|
|
1004
|
-
//#region src/core/upload.ts
|
|
1005
|
-
/**
|
|
1006
|
-
* HTTPS host that serves the Shopwave CDN's images. The API returns
|
|
1007
|
-
* `http://static.merchantstack.com/images/…`, which has no HTTPS endpoint
|
|
1008
|
-
* (checked Sep 2026), so pages served over HTTPS can't show it.
|
|
1009
|
-
*/
|
|
1010
|
-
const SHOPWAVE_IMAGE_BASE_URL = "https://s3-eu-west-1.amazonaws.com/static.merchantstack.com/images";
|
|
1011
|
-
const STATIC_HOST = /^https?:\/\/static\.merchantstack\.com\/images\//i;
|
|
1012
|
-
/**
|
|
1013
|
-
* Turns an image path from the API into a URL a page can load over HTTPS:
|
|
1014
|
-
* `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
|
|
1015
|
-
* relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
|
|
1016
|
-
* Other absolute URLs are returned unchanged; empty input gives `""`.
|
|
691
|
+
* Turns an image path from the API into a URL a page can load over HTTPS:
|
|
692
|
+
* `http://static.merchantstack.com/images/x.png` → `<base>/x.png`, and a
|
|
693
|
+
* relative path (`merchant/5644/logo/x.png`) → `<base>/merchant/5644/logo/x.png`.
|
|
694
|
+
* Other absolute URLs are returned unchanged; empty input gives `""`.
|
|
1017
695
|
*/
|
|
1018
696
|
function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
1019
697
|
if (!path) return "";
|
|
@@ -1033,10 +711,12 @@ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
|
|
|
1033
711
|
async function uploadImage(file, params, options = {}) {
|
|
1034
712
|
if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
|
|
1035
713
|
if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
|
|
714
|
+
if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
|
|
1036
715
|
const name = params.fileName ?? file.name ?? "upload";
|
|
1037
716
|
const form = new FormData();
|
|
1038
717
|
form.append(SHOPWAVE_UPLOAD.fileField, file, name);
|
|
1039
718
|
form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
|
|
719
|
+
if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
|
|
1040
720
|
const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
|
|
1041
721
|
method: "POST",
|
|
1042
722
|
body: form
|
|
@@ -1171,6 +851,382 @@ async function uploadMerchantImage(file, slot, options = {}) {
|
|
|
1171
851
|
return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
|
|
1172
852
|
}
|
|
1173
853
|
|
|
854
|
+
//#endregion
|
|
855
|
+
//#region src/core/productImages.ts
|
|
856
|
+
/**
|
|
857
|
+
* Product images, as the live API stores them (merchant 5644, 4 Nov 2026):
|
|
858
|
+
* - A product's `images` is a list. **Reads return full URLs**
|
|
859
|
+
* (`http://static.merchantstack.com/images/merchant/<id>/product/<file>`);
|
|
860
|
+
* **writes take the uploader's file names** ({@link toStoredProductImages};
|
|
861
|
+
* `saveProduct` does this for you).
|
|
862
|
+
* - The uploader takes one file per request. With a `prefix` (see
|
|
863
|
+
* `UploadPrefix`) it names the file `<prefix>_<hash>.<ext>`
|
|
864
|
+
* (`product_thumbnail_1bdd…41090b.png`); without one, `<hash>.<ext>`. So an
|
|
865
|
+
* image's role can be read back from its name ({@link productImageRole}).
|
|
866
|
+
*/
|
|
867
|
+
/**
|
|
868
|
+
* The role in an image's file name or URL, from the uploader's `prefix`:
|
|
869
|
+
* `"…/product_thumbnail_1bdd.png"` → `"product_thumbnail"`. `null` for a name
|
|
870
|
+
* without one (images uploaded without a prefix, e.g. before prefixes existed).
|
|
871
|
+
*/
|
|
872
|
+
function productImageRole(value) {
|
|
873
|
+
const name = imageFileName(value);
|
|
874
|
+
if (!name) return null;
|
|
875
|
+
return UPLOAD_PREFIXES.find((prefix) => name.startsWith(`${prefix}_`)) ?? null;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* A product's `images` as the API takes them on a save: file names, not the
|
|
879
|
+
* URLs a read returns. Non-strings, blanks and duplicates are dropped; the
|
|
880
|
+
* order is kept.
|
|
881
|
+
*/
|
|
882
|
+
function toStoredProductImages(images) {
|
|
883
|
+
if (!Array.isArray(images)) return [];
|
|
884
|
+
const names = images.map(imageFileName).filter((name) => Boolean(name));
|
|
885
|
+
return Array.from(new Set(names));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/core/entities.ts
|
|
890
|
+
/**
|
|
891
|
+
* Addressing per entity, following the Shopwave API reference
|
|
892
|
+
* (https://developer.merchantstack.com/api-reference.html) and checked against
|
|
893
|
+
* the live API where noted:
|
|
894
|
+
* - category/product/store: GET filtered by `<x>Ids`, POST upsert, DELETE with `<x>Id` → 205.
|
|
895
|
+
* - promotion: no DELETE; promotions are never deleted, only expired. Expiring
|
|
896
|
+
* reads the promotion and saves the whole record back with `endDate` = now (and
|
|
897
|
+
* `startDate` = now too if it hadn't started); the live API rejects a partial
|
|
898
|
+
* `{ id, endDate }` save with error 899. Dates are UTC `YYYY-MM-DD HH:mm:ss`.
|
|
899
|
+
* - employee: POST updates `roleId`, `joinedDate`, `exitDate` (names can't be
|
|
900
|
+
* changed); no DELETE (live API: "Cannot DELETE /employee") → retired via `exitDate`.
|
|
901
|
+
* - consumer: read-only (`GET` with `ids`, comma-separated); live POST → 404.
|
|
902
|
+
*/
|
|
903
|
+
const SHOPWAVE_ENTITIES = Object.freeze({
|
|
904
|
+
product: {
|
|
905
|
+
route: "products",
|
|
906
|
+
collection: "products",
|
|
907
|
+
upstream: "product",
|
|
908
|
+
idsHeader: "productIds",
|
|
909
|
+
idHeader: "productId",
|
|
910
|
+
writable: true,
|
|
911
|
+
deleteMode: "delete"
|
|
912
|
+
},
|
|
913
|
+
category: {
|
|
914
|
+
route: "categories",
|
|
915
|
+
collection: "categories",
|
|
916
|
+
upstream: "category",
|
|
917
|
+
idsHeader: "categoryIds",
|
|
918
|
+
idHeader: "categoryId",
|
|
919
|
+
writable: true,
|
|
920
|
+
deleteMode: "delete"
|
|
921
|
+
},
|
|
922
|
+
store: {
|
|
923
|
+
route: "stores",
|
|
924
|
+
collection: "stores",
|
|
925
|
+
upstream: "store",
|
|
926
|
+
idsHeader: "storeIds",
|
|
927
|
+
idHeader: "storeId",
|
|
928
|
+
writable: true,
|
|
929
|
+
deleteMode: "delete"
|
|
930
|
+
},
|
|
931
|
+
promotion: {
|
|
932
|
+
route: "promotions",
|
|
933
|
+
collection: "promotions",
|
|
934
|
+
upstream: "promotion",
|
|
935
|
+
idsHeader: "promotionIds",
|
|
936
|
+
idHeader: "promotionId",
|
|
937
|
+
writable: true,
|
|
938
|
+
deleteMode: "retire",
|
|
939
|
+
retireField: "endDate",
|
|
940
|
+
retireWithRecord: true,
|
|
941
|
+
retireStartField: "startDate"
|
|
942
|
+
},
|
|
943
|
+
employee: {
|
|
944
|
+
route: "employees",
|
|
945
|
+
collection: "employees",
|
|
946
|
+
upstream: "employee",
|
|
947
|
+
idsHeader: "employeeIds",
|
|
948
|
+
idHeader: "employeeId",
|
|
949
|
+
writable: true,
|
|
950
|
+
deleteMode: "retire",
|
|
951
|
+
retireField: "exitDate"
|
|
952
|
+
},
|
|
953
|
+
consumer: {
|
|
954
|
+
route: "consumer",
|
|
955
|
+
collection: "consumers",
|
|
956
|
+
upstream: "consumer",
|
|
957
|
+
idsHeader: "ids",
|
|
958
|
+
idHeader: "consumerId",
|
|
959
|
+
writable: false,
|
|
960
|
+
deleteMode: "none"
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
function definition(kind) {
|
|
964
|
+
const def = SHOPWAVE_ENTITIES[kind];
|
|
965
|
+
if (!def) throw new Error(`Unknown Shopwave entity "${kind}"`);
|
|
966
|
+
return def;
|
|
967
|
+
}
|
|
968
|
+
function assertId(kind, id) {
|
|
969
|
+
const s = id == null ? "" : String(id).trim();
|
|
970
|
+
if (!s) throw new Error(`${kind} id is missing`);
|
|
971
|
+
return encodeURIComponent(s);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Creates or updates several entities of one kind in a single request and
|
|
975
|
+
* returns them as saved (with their ids), in the order given.
|
|
976
|
+
*
|
|
977
|
+
* The request is `{ <collection>: { "0": item0, "1": item1, … } }`. Shopwave
|
|
978
|
+
* answers 201 and echoes every entity under the ref it was sent with, so each
|
|
979
|
+
* result is matched back by ref. Throws {@link ShopwaveApiError} when the
|
|
980
|
+
* response carries `api.message.errors` or any ref is missing from it.
|
|
981
|
+
*
|
|
982
|
+
* The API's partial-success semantics for multi-entity saves haven't been
|
|
983
|
+
* confirmed yet; single saves (`saveProduct` etc.) are the tested path.
|
|
984
|
+
*/
|
|
985
|
+
async function saveEntities(kind, items, options = {}) {
|
|
986
|
+
const def = definition(kind);
|
|
987
|
+
if (items.length === 0) return [];
|
|
988
|
+
const refs = items.map((_, i) => String(i));
|
|
989
|
+
const payload = { [def.collection]: Object.fromEntries(refs.map((ref, i) => [ref, items[i]])) };
|
|
990
|
+
const { status, body: raw } = await apiRequest(`/api/${def.route}`, {
|
|
991
|
+
method: "POST",
|
|
992
|
+
headers: { "Content-Type": "application/json" },
|
|
993
|
+
body: JSON.stringify(payload)
|
|
994
|
+
}, options);
|
|
995
|
+
const body = raw;
|
|
996
|
+
assertNoApiErrors(body, status);
|
|
997
|
+
const echoed = body?.[def.collection] ?? {};
|
|
998
|
+
return refs.map((ref, i) => {
|
|
999
|
+
const saved = echoed[ref];
|
|
1000
|
+
if (!saved || saved.id == null) throw new ShopwaveApiError(status, `Shopwave API error (${status}): the saved ${kind} (ref "${ref}") was not returned`, { body });
|
|
1001
|
+
return {
|
|
1002
|
+
...items[i],
|
|
1003
|
+
...saved,
|
|
1004
|
+
id: Number(saved.id)
|
|
1005
|
+
};
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
/** Creates (no `id`) or updates (with `id`) one entity and returns it with its id. */
|
|
1009
|
+
async function saveEntity(kind, item, options = {}) {
|
|
1010
|
+
const [saved] = await saveEntities(kind, [item], options);
|
|
1011
|
+
return saved;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Deletes one entity (a soft delete: it stays readable with `deleted: true`).
|
|
1015
|
+
* Employees and promotions can't be deleted in Shopwave; for them the route
|
|
1016
|
+
* sets `exitDate` / `endDate` to now instead (see `SHOPWAVE_ENTITIES`), and a
|
|
1017
|
+
* promotion is saved back whole (see {@link deletePromotion}).
|
|
1018
|
+
*
|
|
1019
|
+
* Shopwave answers 205 with an empty body — also for ids that don't exist — so
|
|
1020
|
+
* a resolved promise means "the API accepted the request", not "a record was
|
|
1021
|
+
* deleted".
|
|
1022
|
+
*/
|
|
1023
|
+
async function deleteEntityById(kind, id, options = {}) {
|
|
1024
|
+
await apiDelete(`/api/${definition(kind).route}/${assertId(kind, id)}`, options);
|
|
1025
|
+
}
|
|
1026
|
+
/** Reads one entity by id, or `null` when there's no such (non-deleted) record. */
|
|
1027
|
+
async function fetchEntityById(kind, id, params = {}, options = {}) {
|
|
1028
|
+
const def = definition(kind);
|
|
1029
|
+
const body = await apiGet(`/api/${def.route}/${assertId(kind, id)}`, { deleted: params.deleted ?? false }, options);
|
|
1030
|
+
assertNoApiErrors(body, 200);
|
|
1031
|
+
const map = body?.[def.collection] ?? {};
|
|
1032
|
+
return map[String(id)] ?? Object.values(map).find((e) => String(e?.id) === String(id)) ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Creates (no `id`) or updates (with `id`) a product.
|
|
1036
|
+
*
|
|
1037
|
+
* Shopwave instances (prices) can't be updated, only replaced. Build
|
|
1038
|
+
* `instances` with {@link toProductInstancesForSave} (passing the product as last
|
|
1039
|
+
* read) so an edited price is sent as a new instance. As a safeguard, a new
|
|
1040
|
+
* product (no `id`, e.g. a duplicate) never sends instance ids: an id copied from
|
|
1041
|
+
* another product would otherwise point the save at that product's instances.
|
|
1042
|
+
*
|
|
1043
|
+
* `images` is sent as file names ({@link toStoredProductImages}), so the URLs a
|
|
1044
|
+
* read returns can be passed straight back; leaving `images` out keeps them.
|
|
1045
|
+
*/
|
|
1046
|
+
const saveProduct = (product, options) => {
|
|
1047
|
+
let toSave = (product.id == null || String(product.id).trim() === "") && product.instances != null ? {
|
|
1048
|
+
...product,
|
|
1049
|
+
instances: toProductInstancesForSave(product.instances)
|
|
1050
|
+
} : product;
|
|
1051
|
+
if (Array.isArray(toSave.images)) toSave = {
|
|
1052
|
+
...toSave,
|
|
1053
|
+
images: toStoredProductImages(toSave.images)
|
|
1054
|
+
};
|
|
1055
|
+
return saveEntity("product", toSave, options);
|
|
1056
|
+
};
|
|
1057
|
+
const deleteProduct = (id, options) => deleteEntityById("product", id, options);
|
|
1058
|
+
const fetchProduct = (id, params, options) => fetchEntityById("product", id, params, options);
|
|
1059
|
+
const saveCategory = (category, options) => saveEntity("category", category, options);
|
|
1060
|
+
const deleteCategory = (id, options) => deleteEntityById("category", id, options);
|
|
1061
|
+
const fetchCategory = (id, params, options) => fetchEntityById("category", id, params, options);
|
|
1062
|
+
const saveStore = (store, options) => saveEntity("store", store, options);
|
|
1063
|
+
const deleteStore = (id, options) => deleteEntityById("store", id, options);
|
|
1064
|
+
const fetchStore = (id, params, options) => fetchEntityById("store", id, params, options);
|
|
1065
|
+
const savePromotion = (promotion, options) => saveEntity("promotion", promotion, options);
|
|
1066
|
+
/**
|
|
1067
|
+
* Expires a promotion (Shopwave never deletes promotions). The route reads the
|
|
1068
|
+
* promotion and saves it back with `endDate` set to now; a promotion that hasn't
|
|
1069
|
+
* started yet gets `startDate` = `endDate` = now. Rejects if the promotion doesn't
|
|
1070
|
+
* exist or the save fails.
|
|
1071
|
+
*/
|
|
1072
|
+
const deletePromotion = (id, options) => deleteEntityById("promotion", id, options);
|
|
1073
|
+
const fetchPromotion = (id, params, options) => fetchEntityById("promotion", id, params, options);
|
|
1074
|
+
/**
|
|
1075
|
+
* Creates an employee, or updates one (with `id`). On update Shopwave only
|
|
1076
|
+
* applies `roleId`, `joinedDate` and `exitDate` (and store roles); names and
|
|
1077
|
+
* email can't be changed, and the echo only carries the updated fields — re-read
|
|
1078
|
+
* with `fetchEmployee` if you need the stored record.
|
|
1079
|
+
*/
|
|
1080
|
+
const saveEmployee = (employee, options) => saveEntity("employee", employee, options);
|
|
1081
|
+
/** Retires an employee: Shopwave has no employee DELETE, so this sets `exitDate` to now. */
|
|
1082
|
+
const deleteEmployee = (id, options) => deleteEntityById("employee", id, options);
|
|
1083
|
+
const fetchEmployee = (id, params, options) => fetchEntityById("employee", id, params, options);
|
|
1084
|
+
const fetchConsumer = (id, params, options) => fetchEntityById("consumer", id, params, options);
|
|
1085
|
+
|
|
1086
|
+
//#endregion
|
|
1087
|
+
//#region src/core/basket.ts
|
|
1088
|
+
const DEFAULT_BASKET_FILTERS = {
|
|
1089
|
+
search: "",
|
|
1090
|
+
dateRange: {
|
|
1091
|
+
start: null,
|
|
1092
|
+
end: null
|
|
1093
|
+
},
|
|
1094
|
+
consumerId: null,
|
|
1095
|
+
status: "all"
|
|
1096
|
+
};
|
|
1097
|
+
const DEFAULT_PAGINATION = {
|
|
1098
|
+
page: 0,
|
|
1099
|
+
pageSize: 25,
|
|
1100
|
+
total: 0
|
|
1101
|
+
};
|
|
1102
|
+
/** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
|
|
1103
|
+
function formatDateForQuery(date, isEndOfDay = false) {
|
|
1104
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
|
|
1105
|
+
}
|
|
1106
|
+
/** Builds the `Basket` report query from a set of filters. */
|
|
1107
|
+
function buildBasketReportQuery(filters) {
|
|
1108
|
+
const whereConditions = [];
|
|
1109
|
+
const today = /* @__PURE__ */ new Date();
|
|
1110
|
+
const startDate = filters.dateRange.start ?? today;
|
|
1111
|
+
const endDate = filters.dateRange.end ?? today;
|
|
1112
|
+
whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
|
|
1113
|
+
whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
|
|
1114
|
+
if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
|
|
1115
|
+
if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
|
|
1116
|
+
return { baskets: {
|
|
1117
|
+
FROM: "Basket",
|
|
1118
|
+
WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
|
|
1119
|
+
} };
|
|
1120
|
+
}
|
|
1121
|
+
/** Flattens the raw report rows/keys into structured baskets. */
|
|
1122
|
+
function parseBasketReportData(reports) {
|
|
1123
|
+
if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
|
|
1124
|
+
const { keys, data } = reports.baskets;
|
|
1125
|
+
const keyIndices = {};
|
|
1126
|
+
keys.forEach((key, index) => {
|
|
1127
|
+
keyIndices[key.name] = index;
|
|
1128
|
+
});
|
|
1129
|
+
const productKey = keys.find((k) => k.name === "Basket.Product");
|
|
1130
|
+
const productSubKeys = {};
|
|
1131
|
+
if (productKey?.item) productKey.item.forEach((subKey, index) => {
|
|
1132
|
+
productSubKeys[subKey.name] = index;
|
|
1133
|
+
});
|
|
1134
|
+
return data.map((row) => {
|
|
1135
|
+
const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
|
|
1136
|
+
productId: productRow[productSubKeys["Basket.Product.productId"]],
|
|
1137
|
+
productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
|
|
1138
|
+
quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
|
|
1139
|
+
price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
|
|
1140
|
+
tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
|
|
1141
|
+
}));
|
|
1142
|
+
const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
|
|
1143
|
+
const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
|
|
1144
|
+
const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
|
|
1145
|
+
const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
|
|
1146
|
+
return {
|
|
1147
|
+
id: row[keyIndices["Basket.id"]],
|
|
1148
|
+
name: row[keyIndices["Basket.name"]],
|
|
1149
|
+
consumerId: row[keyIndices["Basket.consumerId"]],
|
|
1150
|
+
products,
|
|
1151
|
+
total,
|
|
1152
|
+
taxTotal,
|
|
1153
|
+
status: "completed",
|
|
1154
|
+
completeDate
|
|
1155
|
+
};
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/** Unique non-null consumer ids referenced by a set of baskets. */
|
|
1159
|
+
function collectConsumerIds(baskets) {
|
|
1160
|
+
if (!baskets.length) return null;
|
|
1161
|
+
const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
|
|
1162
|
+
return ids.length > 0 ? [...new Set(ids)] : null;
|
|
1163
|
+
}
|
|
1164
|
+
/** Unique product ids referenced by a set of baskets. */
|
|
1165
|
+
function collectProductIds(baskets) {
|
|
1166
|
+
if (!baskets.length) return null;
|
|
1167
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1168
|
+
baskets.forEach((basket) => {
|
|
1169
|
+
basket.products.forEach((product) => {
|
|
1170
|
+
if (product.productId) ids.add(product.productId);
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
return ids.size > 0 ? [...ids] : null;
|
|
1174
|
+
}
|
|
1175
|
+
/** Joins baskets with consumer + product lookups, applies search, sorts. */
|
|
1176
|
+
function combineBasketRows({ baskets, consumerMap, productMap, search }) {
|
|
1177
|
+
if (!baskets.length) return [];
|
|
1178
|
+
let combinedRows = baskets.map((basket) => {
|
|
1179
|
+
const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
|
|
1180
|
+
const productsWithNames = basket.products.map((p) => {
|
|
1181
|
+
const product = productMap[p.productId];
|
|
1182
|
+
return {
|
|
1183
|
+
...p,
|
|
1184
|
+
productName: product?.name || `Product #${p.productId}`
|
|
1185
|
+
};
|
|
1186
|
+
});
|
|
1187
|
+
return {
|
|
1188
|
+
id: basket.id,
|
|
1189
|
+
basketId: basket.id,
|
|
1190
|
+
basketName: basket.name || `Basket #${basket.id}`,
|
|
1191
|
+
consumerId: basket.consumerId,
|
|
1192
|
+
consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
|
|
1193
|
+
consumerEmail: consumer?.email || "-",
|
|
1194
|
+
products: productsWithNames,
|
|
1195
|
+
basketTotal: (basket.total || 0) / 100,
|
|
1196
|
+
tax: basket.taxTotal || 0,
|
|
1197
|
+
dateTime: basket.completeDate,
|
|
1198
|
+
status: basket.status || "pending"
|
|
1199
|
+
};
|
|
1200
|
+
});
|
|
1201
|
+
if (search) {
|
|
1202
|
+
const searchLower = search.toLowerCase();
|
|
1203
|
+
combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
|
|
1204
|
+
}
|
|
1205
|
+
combinedRows.sort((a, b) => {
|
|
1206
|
+
const dateA = new Date(a.dateTime).getTime();
|
|
1207
|
+
return new Date(b.dateTime).getTime() - dateA;
|
|
1208
|
+
});
|
|
1209
|
+
return combinedRows;
|
|
1210
|
+
}
|
|
1211
|
+
/** Aggregate totals for a set of report rows. */
|
|
1212
|
+
function computeBasketSummary(rows) {
|
|
1213
|
+
const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
|
|
1214
|
+
return {
|
|
1215
|
+
totalBaskets: rows.length,
|
|
1216
|
+
totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
|
|
1217
|
+
totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
|
|
1218
|
+
uniqueConsumers: uniqueConsumerIds.size
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/** Builds a consumer id → consumer lookup. */
|
|
1222
|
+
function buildConsumerMap(consumers) {
|
|
1223
|
+
const map = {};
|
|
1224
|
+
if (consumers) consumers.forEach((consumer) => {
|
|
1225
|
+
map[consumer.id] = consumer;
|
|
1226
|
+
});
|
|
1227
|
+
return map;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1174
1230
|
//#endregion
|
|
1175
1231
|
//#region src/core/merchant.ts
|
|
1176
1232
|
const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
|
|
@@ -1347,5 +1403,5 @@ async function fetchUser(options = {}) {
|
|
|
1347
1403
|
}
|
|
1348
1404
|
|
|
1349
1405
|
//#endregion
|
|
1350
|
-
export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, MERCHANT_COLOUR_KEYS, MERCHANT_FIELD_MAX_LENGTH, MERCHANT_IMAGE_SLOTS, MERCHANT_IMAGE_SLOT_KEYS, MERCHANT_LINK_KEYS, MERCHANT_LINK_PATHS, MERCHANT_OBJECT_FIELDS, MERCHANT_READ_ONLY_FIELDS, PromotionRuleSet, SHOPWAVE_ENTITIES, SHOPWAVE_IMAGE_BASE_URL, SHOPWAVE_LOOKUPS, SHOPWAVE_RESOURCES, SHOPWAVE_UPLOAD, ShopwaveApiError, UPLOAD_KINDS, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, coverCrop, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchMerchant, fetchProduct, fetchProductInstanceTags, fetchProductTags, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, fetchUser, findRepricedInstances, fitImageToSize, formatDateForQuery, getApiErrorMap, getApiErrors, getImageUrl, getMerchantImage, getMerchantImageUrl, imageFileName, isSameInstancePrice, isUploadKind, loginPath, logout, logoutPath, merchantLinksFromForm, merchantLinksToForm, normalizeMerchantLinks, normalizeTagList, parseBasketReportData, readImageSize, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, setMerchantImage, submitEntity, toProductInstancesForSave, toShopwaveApiError, toStoredImageIds, updateMerchant, uploadImage, uploadMerchantImage, validateMerchant, withToken };
|
|
1406
|
+
export { DEFAULT_BASKET_FILTERS, DEFAULT_PAGINATION, MERCHANT_COLOUR_KEYS, MERCHANT_FIELD_MAX_LENGTH, MERCHANT_IMAGE_SLOTS, MERCHANT_IMAGE_SLOT_KEYS, MERCHANT_LINK_KEYS, MERCHANT_LINK_PATHS, MERCHANT_OBJECT_FIELDS, MERCHANT_READ_ONLY_FIELDS, PromotionRuleSet, SHOPWAVE_ENTITIES, SHOPWAVE_IMAGE_BASE_URL, SHOPWAVE_LOOKUPS, SHOPWAVE_RESOURCES, SHOPWAVE_UPLOAD, ShopwaveApiError, UPLOAD_KINDS, UPLOAD_PREFIXES, apiDelete, apiGet, apiRequest, apiSend, assertNoApiErrors, authorizationFor, buildBasketReportQuery, buildConsumerMap, collectConsumerIds, collectProductIds, combineBasketRows, computeBasketSummary, coverCrop, deleteCategory, deleteEmployee, deleteEntity, deleteEntityById, deleteProduct, deletePromotion, deleteStore, fetchCategories, fetchCategory, fetchConsumer, fetchConsumers, fetchEmployee, fetchEmployees, fetchEntityById, fetchMerchant, fetchProduct, fetchProductInstanceTags, fetchProductTags, fetchProducts, fetchProductsMap, fetchPromotion, fetchPromotions, fetchReport, fetchSession, fetchStore, fetchStores, fetchUser, findRepricedInstances, fitImageToSize, formatDateForQuery, getApiErrorMap, getApiErrors, getImageUrl, getMerchantImage, getMerchantImageUrl, imageFileName, isSameInstancePrice, isUploadKind, isUploadPrefix, loginPath, logout, logoutPath, merchantLinksFromForm, merchantLinksToForm, normalizeMerchantLinks, normalizeTagList, parseBasketReportData, productImageRole, readImageSize, rolesById, saveCategory, saveEmployee, saveEntities, saveEntity, saveProduct, savePromotion, saveStore, setMerchantImage, submitEntity, toProductInstancesForSave, toShopwaveApiError, toStoredImageIds, toStoredProductImages, updateMerchant, uploadImage, uploadMerchantImage, validateMerchant, withToken };
|
|
1351
1407
|
//# sourceMappingURL=index.js.map
|