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