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.
@@ -283,6 +283,118 @@ async function fetchProductsMap(productIds, options = {}, batchSize = 200) {
283
283
  return result;
284
284
  }
285
285
 
286
+ //#endregion
287
+ //#region src/core/resources.ts
288
+ /**
289
+ * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
290
+ * updates it (without an `id` the API would create a second merchant, so the
291
+ * route requires one).
292
+ * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
293
+ * needs the admin scope).
294
+ */
295
+ const SHOPWAVE_RESOURCES = Object.freeze({
296
+ merchant: {
297
+ route: "merchant",
298
+ key: "merchant",
299
+ upstream: "merchant",
300
+ writable: true
301
+ },
302
+ user: {
303
+ route: "user",
304
+ key: "user",
305
+ upstream: "user",
306
+ writable: false
307
+ }
308
+ });
309
+ /** `UploadKind` → the API's `contentType` value. */
310
+ const UPLOAD_KINDS = Object.freeze({
311
+ merchant: "merchant",
312
+ merchantFeature: "merchantFeature",
313
+ product: "product",
314
+ user: "user",
315
+ applicationLogo: "applicaionLogo",
316
+ applicationImages: "applicaionImages"
317
+ });
318
+ /** Every `UploadPrefix`, longest first so a name check never takes `product` for `product_thumbnail`. */
319
+ const UPLOAD_PREFIXES = Object.freeze([
320
+ "product_thumbnail",
321
+ "variant_thumbnail",
322
+ "product",
323
+ "variant"
324
+ ]);
325
+ /** How uploads are addressed (app route and Shopwave uploader). */
326
+ const SHOPWAVE_UPLOAD = Object.freeze({
327
+ route: "upload",
328
+ upstream: "uploader",
329
+ kindHeader: "contentType",
330
+ prefixHeader: "prefix",
331
+ fileField: "file",
332
+ kindField: "kind",
333
+ prefixField: "prefix"
334
+ });
335
+ function isUploadKind(value) {
336
+ return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
337
+ }
338
+ function isUploadPrefix(value) {
339
+ return typeof value === "string" && UPLOAD_PREFIXES.includes(value);
340
+ }
341
+ const SHOPWAVE_LOOKUPS = Object.freeze({
342
+ productTags: {
343
+ route: "products/tags",
344
+ key: "tags",
345
+ upstream: "product/tags"
346
+ },
347
+ productInstanceTags: {
348
+ route: "products/instance-tags",
349
+ key: "tags",
350
+ upstream: "product/instance/tags"
351
+ }
352
+ });
353
+
354
+ //#endregion
355
+ //#region src/core/productTags.ts
356
+ /**
357
+ * Turns a tag list from the API into clean strings: trims each tag, drops
358
+ * blanks and anything that isn't text, and removes exact duplicates (first one
359
+ * wins). The API's own order is kept. A map is accepted too (its values are
360
+ * used), in case the API ever answers keyed by id.
361
+ */
362
+ function normalizeTagList(value) {
363
+ const list = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
364
+ const seen = /* @__PURE__ */ new Set();
365
+ const out = [];
366
+ for (const item of list) {
367
+ if (typeof item !== "string" && typeof item !== "number") continue;
368
+ const tag = String(item).trim();
369
+ if (!tag || seen.has(tag)) continue;
370
+ seen.add(tag);
371
+ out.push(tag);
372
+ }
373
+ return out;
374
+ }
375
+ async function fetchLookup(kind, options) {
376
+ const def = SHOPWAVE_LOOKUPS[kind];
377
+ if (!def) throw new Error(`Unknown Shopwave lookup "${kind}"`);
378
+ const json = await apiGet(`/api/${def.route}`, {}, options);
379
+ assertNoApiErrors(json, 200);
380
+ return normalizeTagList(json?.[def.key]);
381
+ }
382
+ /**
383
+ * Every distinct tag used on the merchant's products (`GET /product/tags`),
384
+ * for a tag field's suggestions. Returns `[]` when there are none.
385
+ */
386
+ function fetchProductTags(options = {}) {
387
+ return fetchLookup("productTags", options);
388
+ }
389
+ /**
390
+ * Every distinct tag used on the merchant's price instances
391
+ * (`GET /product/instance/tags`): the channel names (Eat In, Takeout, …).
392
+ * Returns `[]` when there are none.
393
+ */
394
+ function fetchProductInstanceTags(options = {}) {
395
+ return fetchLookup("productInstanceTags", options);
396
+ }
397
+
286
398
  //#endregion
287
399
  //#region src/core/promotion.ts
288
400
  /**
@@ -392,194 +504,6 @@ async function submitEntity({ endpoint, method = "POST", payload }, options = {}
392
504
  return json ?? void 0;
393
505
  }
394
506
 
395
- //#endregion
396
- //#region src/core/basket.ts
397
- const DEFAULT_BASKET_FILTERS = {
398
- search: "",
399
- dateRange: {
400
- start: null,
401
- end: null
402
- },
403
- consumerId: null,
404
- status: "all"
405
- };
406
- const DEFAULT_PAGINATION = {
407
- page: 0,
408
- pageSize: 25,
409
- total: 0
410
- };
411
- /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
412
- function formatDateForQuery(date, isEndOfDay = false) {
413
- return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
414
- }
415
- /** Builds the `Basket` report query from a set of filters. */
416
- function buildBasketReportQuery(filters) {
417
- const whereConditions = [];
418
- const today = /* @__PURE__ */ new Date();
419
- const startDate = filters.dateRange.start ?? today;
420
- const endDate = filters.dateRange.end ?? today;
421
- whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
422
- whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
423
- if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
424
- if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
425
- return { baskets: {
426
- FROM: "Basket",
427
- WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
428
- } };
429
- }
430
- /** Flattens the raw report rows/keys into structured baskets. */
431
- function parseBasketReportData(reports) {
432
- if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
433
- const { keys, data } = reports.baskets;
434
- const keyIndices = {};
435
- keys.forEach((key, index) => {
436
- keyIndices[key.name] = index;
437
- });
438
- const productKey = keys.find((k) => k.name === "Basket.Product");
439
- const productSubKeys = {};
440
- if (productKey?.item) productKey.item.forEach((subKey, index) => {
441
- productSubKeys[subKey.name] = index;
442
- });
443
- return data.map((row) => {
444
- const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
445
- productId: productRow[productSubKeys["Basket.Product.productId"]],
446
- productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
447
- quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
448
- price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
449
- tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
450
- }));
451
- const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
452
- const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
453
- const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
454
- const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
455
- return {
456
- id: row[keyIndices["Basket.id"]],
457
- name: row[keyIndices["Basket.name"]],
458
- consumerId: row[keyIndices["Basket.consumerId"]],
459
- products,
460
- total,
461
- taxTotal,
462
- status: "completed",
463
- completeDate
464
- };
465
- });
466
- }
467
- /** Unique non-null consumer ids referenced by a set of baskets. */
468
- function collectConsumerIds(baskets) {
469
- if (!baskets.length) return null;
470
- const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
471
- return ids.length > 0 ? [...new Set(ids)] : null;
472
- }
473
- /** Unique product ids referenced by a set of baskets. */
474
- function collectProductIds(baskets) {
475
- if (!baskets.length) return null;
476
- const ids = /* @__PURE__ */ new Set();
477
- baskets.forEach((basket) => {
478
- basket.products.forEach((product) => {
479
- if (product.productId) ids.add(product.productId);
480
- });
481
- });
482
- return ids.size > 0 ? [...ids] : null;
483
- }
484
- /** Joins baskets with consumer + product lookups, applies search, sorts. */
485
- function combineBasketRows({ baskets, consumerMap, productMap, search }) {
486
- if (!baskets.length) return [];
487
- let combinedRows = baskets.map((basket) => {
488
- const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
489
- const productsWithNames = basket.products.map((p) => {
490
- const product = productMap[p.productId];
491
- return {
492
- ...p,
493
- productName: product?.name || `Product #${p.productId}`
494
- };
495
- });
496
- return {
497
- id: basket.id,
498
- basketId: basket.id,
499
- basketName: basket.name || `Basket #${basket.id}`,
500
- consumerId: basket.consumerId,
501
- consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
502
- consumerEmail: consumer?.email || "-",
503
- products: productsWithNames,
504
- basketTotal: (basket.total || 0) / 100,
505
- tax: basket.taxTotal || 0,
506
- dateTime: basket.completeDate,
507
- status: basket.status || "pending"
508
- };
509
- });
510
- if (search) {
511
- const searchLower = search.toLowerCase();
512
- combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
513
- }
514
- combinedRows.sort((a, b) => {
515
- const dateA = new Date(a.dateTime).getTime();
516
- return new Date(b.dateTime).getTime() - dateA;
517
- });
518
- return combinedRows;
519
- }
520
- /** Aggregate totals for a set of report rows. */
521
- function computeBasketSummary(rows) {
522
- const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
523
- return {
524
- totalBaskets: rows.length,
525
- totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
526
- totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
527
- uniqueConsumers: uniqueConsumerIds.size
528
- };
529
- }
530
- /** Builds a consumer id → consumer lookup. */
531
- function buildConsumerMap(consumers) {
532
- const map = {};
533
- if (consumers) consumers.forEach((consumer) => {
534
- map[consumer.id] = consumer;
535
- });
536
- return map;
537
- }
538
-
539
- //#endregion
540
- //#region src/core/resources.ts
541
- /**
542
- * - merchant: `GET /merchant`; `POST /merchant` with `{ merchant: { id, … } }`
543
- * updates it (without an `id` the API would create a second merchant, so the
544
- * route requires one).
545
- * - user: `GET /user` (read-only here; the API's `POST /user` invites users and
546
- * needs the admin scope).
547
- */
548
- const SHOPWAVE_RESOURCES = Object.freeze({
549
- merchant: {
550
- route: "merchant",
551
- key: "merchant",
552
- upstream: "merchant",
553
- writable: true
554
- },
555
- user: {
556
- route: "user",
557
- key: "user",
558
- upstream: "user",
559
- writable: false
560
- }
561
- });
562
- /** `UploadKind` → the API's `contentType` value. */
563
- const UPLOAD_KINDS = Object.freeze({
564
- merchant: "merchant",
565
- merchantFeature: "merchantFeature",
566
- product: "product",
567
- user: "user",
568
- applicationLogo: "applicaionLogo",
569
- applicationImages: "applicaionImages"
570
- });
571
- /** How uploads are addressed (app route and Shopwave uploader). */
572
- const SHOPWAVE_UPLOAD = Object.freeze({
573
- route: "upload",
574
- upstream: "uploader",
575
- kindHeader: "contentType",
576
- fileField: "file",
577
- kindField: "kind"
578
- });
579
- function isUploadKind(value) {
580
- return typeof value === "string" && Object.prototype.hasOwnProperty.call(UPLOAD_KINDS, value);
581
- }
582
-
583
507
  //#endregion
584
508
  //#region src/core/imageFit.ts
585
509
  /**
@@ -690,10 +614,12 @@ function getImageUrl(path, base = SHOPWAVE_IMAGE_BASE_URL) {
690
614
  async function uploadImage(file, params, options = {}) {
691
615
  if (!file || typeof file.size !== "number") throw new Error("uploadImage: file must be a File or Blob");
692
616
  if (!isUploadKind(params?.kind)) throw new Error(`uploadImage: unknown kind "${params?.kind}"`);
617
+ if (params.prefix != null && !isUploadPrefix(params.prefix)) throw new Error(`uploadImage: unknown prefix "${params.prefix}"`);
693
618
  const name = params.fileName ?? file.name ?? "upload";
694
619
  const form = new FormData();
695
620
  form.append(SHOPWAVE_UPLOAD.fileField, file, name);
696
621
  form.append(SHOPWAVE_UPLOAD.kindField, params.kind);
622
+ if (params.prefix != null) form.append(SHOPWAVE_UPLOAD.prefixField, params.prefix);
697
623
  const { status, body: raw } = await apiRequest(`/api/${SHOPWAVE_UPLOAD.route}`, {
698
624
  method: "POST",
699
625
  body: form
@@ -781,6 +707,150 @@ async function uploadMerchantImage(file, slot, options = {}) {
781
707
  return uploadImage(fit ? await fitImageToSize(file, def.width, def.height, fitOptions) : file, { kind: def.uploadKind }, requestOptions);
782
708
  }
783
709
 
710
+ //#endregion
711
+ //#region src/core/basket.ts
712
+ const DEFAULT_BASKET_FILTERS = {
713
+ search: "",
714
+ dateRange: {
715
+ start: null,
716
+ end: null
717
+ },
718
+ consumerId: null,
719
+ status: "all"
720
+ };
721
+ const DEFAULT_PAGINATION = {
722
+ page: 0,
723
+ pageSize: 25,
724
+ total: 0
725
+ };
726
+ /** Formats a date as `YYYY-MM-DD HH:mm:ss` for the report query. */
727
+ function formatDateForQuery(date, isEndOfDay = false) {
728
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${isEndOfDay ? "23:59:59" : "00:00:00"}`;
729
+ }
730
+ /** Builds the `Basket` report query from a set of filters. */
731
+ function buildBasketReportQuery(filters) {
732
+ const whereConditions = [];
733
+ const today = /* @__PURE__ */ new Date();
734
+ const startDate = filters.dateRange.start ?? today;
735
+ const endDate = filters.dateRange.end ?? today;
736
+ whereConditions.push(`Basket.completeDate > '${formatDateForQuery(startDate, false)}'`);
737
+ whereConditions.push(`Basket.completeDate < '${formatDateForQuery(endDate, true)}'`);
738
+ if (filters.consumerId) whereConditions.push(`Basket.consumerId = ${filters.consumerId}`);
739
+ if (filters.status !== "all") whereConditions.push(`Basket.status = '${filters.status}'`);
740
+ return { baskets: {
741
+ FROM: "Basket",
742
+ WHERE: whereConditions.length > 0 ? { AND: whereConditions } : void 0
743
+ } };
744
+ }
745
+ /** Flattens the raw report rows/keys into structured baskets. */
746
+ function parseBasketReportData(reports) {
747
+ if (!reports?.baskets?.data || !reports?.baskets?.keys) return [];
748
+ const { keys, data } = reports.baskets;
749
+ const keyIndices = {};
750
+ keys.forEach((key, index) => {
751
+ keyIndices[key.name] = index;
752
+ });
753
+ const productKey = keys.find((k) => k.name === "Basket.Product");
754
+ const productSubKeys = {};
755
+ if (productKey?.item) productKey.item.forEach((subKey, index) => {
756
+ productSubKeys[subKey.name] = index;
757
+ });
758
+ return data.map((row) => {
759
+ const products = (row[keyIndices["Basket.Product"]] || []).map((productRow) => ({
760
+ productId: productRow[productSubKeys["Basket.Product.productId"]],
761
+ productName: `Product #${productRow[productSubKeys["Basket.Product.productId"]]}`,
762
+ quantity: productRow[productSubKeys["Basket.Product.quantity"]] || 1,
763
+ price: productRow[productSubKeys["Basket.Product.productPrice"]] || 0,
764
+ tax: productRow[productSubKeys["Basket.Product.productTaxPercentage"]] || 0
765
+ }));
766
+ const total = products.reduce((sum, p) => sum + p.price * p.quantity, 0);
767
+ const taxTotal = products.reduce((sum, p) => sum + p.price * p.quantity * p.tax, 0);
768
+ const completeDateTimestamp = row[keyIndices["Basket.completeDate"]];
769
+ const completeDate = completeDateTimestamp ? new Date(completeDateTimestamp).toISOString() : "";
770
+ return {
771
+ id: row[keyIndices["Basket.id"]],
772
+ name: row[keyIndices["Basket.name"]],
773
+ consumerId: row[keyIndices["Basket.consumerId"]],
774
+ products,
775
+ total,
776
+ taxTotal,
777
+ status: "completed",
778
+ completeDate
779
+ };
780
+ });
781
+ }
782
+ /** Unique non-null consumer ids referenced by a set of baskets. */
783
+ function collectConsumerIds(baskets) {
784
+ if (!baskets.length) return null;
785
+ const ids = baskets.map((b) => b.consumerId).filter((id) => id != null);
786
+ return ids.length > 0 ? [...new Set(ids)] : null;
787
+ }
788
+ /** Unique product ids referenced by a set of baskets. */
789
+ function collectProductIds(baskets) {
790
+ if (!baskets.length) return null;
791
+ const ids = /* @__PURE__ */ new Set();
792
+ baskets.forEach((basket) => {
793
+ basket.products.forEach((product) => {
794
+ if (product.productId) ids.add(product.productId);
795
+ });
796
+ });
797
+ return ids.size > 0 ? [...ids] : null;
798
+ }
799
+ /** Joins baskets with consumer + product lookups, applies search, sorts. */
800
+ function combineBasketRows({ baskets, consumerMap, productMap, search }) {
801
+ if (!baskets.length) return [];
802
+ let combinedRows = baskets.map((basket) => {
803
+ const consumer = basket.consumerId ? consumerMap[basket.consumerId] : null;
804
+ const productsWithNames = basket.products.map((p) => {
805
+ const product = productMap[p.productId];
806
+ return {
807
+ ...p,
808
+ productName: product?.name || `Product #${p.productId}`
809
+ };
810
+ });
811
+ return {
812
+ id: basket.id,
813
+ basketId: basket.id,
814
+ basketName: basket.name || `Basket #${basket.id}`,
815
+ consumerId: basket.consumerId,
816
+ consumerName: consumer ? `${consumer.firstName} ${consumer.lastName}` : "Unknown",
817
+ consumerEmail: consumer?.email || "-",
818
+ products: productsWithNames,
819
+ basketTotal: (basket.total || 0) / 100,
820
+ tax: basket.taxTotal || 0,
821
+ dateTime: basket.completeDate,
822
+ status: basket.status || "pending"
823
+ };
824
+ });
825
+ if (search) {
826
+ const searchLower = search.toLowerCase();
827
+ combinedRows = combinedRows.filter((row) => row.basketId.toString().includes(searchLower) || row.basketName.toLowerCase().includes(searchLower) || row.consumerName.toLowerCase().includes(searchLower) || row.consumerEmail.toLowerCase().includes(searchLower));
828
+ }
829
+ combinedRows.sort((a, b) => {
830
+ const dateA = new Date(a.dateTime).getTime();
831
+ return new Date(b.dateTime).getTime() - dateA;
832
+ });
833
+ return combinedRows;
834
+ }
835
+ /** Aggregate totals for a set of report rows. */
836
+ function computeBasketSummary(rows) {
837
+ const uniqueConsumerIds = new Set(rows.map((row) => row.consumerId));
838
+ return {
839
+ totalBaskets: rows.length,
840
+ totalRevenue: rows.reduce((sum, row) => sum + row.basketTotal, 0),
841
+ totalTax: rows.reduce((sum, row) => sum + row.tax, 0),
842
+ uniqueConsumers: uniqueConsumerIds.size
843
+ };
844
+ }
845
+ /** Builds a consumer id → consumer lookup. */
846
+ function buildConsumerMap(consumers) {
847
+ const map = {};
848
+ if (consumers) consumers.forEach((consumer) => {
849
+ map[consumer.id] = consumer;
850
+ });
851
+ return map;
852
+ }
853
+
784
854
  //#endregion
785
855
  //#region src/core/merchant.ts
786
856
  const MERCHANT_PATH = `/api/${SHOPWAVE_RESOURCES.merchant.route}`;
@@ -1124,6 +1194,33 @@ function useProduct(params = {}, options) {
1124
1194
  ]);
1125
1195
  }
1126
1196
 
1197
+ //#endregion
1198
+ //#region src/hooks/useProductTags.ts
1199
+ /**
1200
+ * The merchant's distinct product tags, for a tag field's suggestions.
1201
+ * `data` is `string[]` once loaded.
1202
+ *
1203
+ * @example
1204
+ * const [opened, setOpened] = useState(false);
1205
+ * const { data: tags, loading } = useProductTags({ enabled: opened });
1206
+ */
1207
+ function useProductTags(options) {
1208
+ return useQuery((signal) => fetchProductTags({
1209
+ ...options,
1210
+ signal
1211
+ }), [options?.baseUrl, options?.token], options?.enabled ?? true);
1212
+ }
1213
+ /**
1214
+ * The merchant's distinct price-instance (channel) tags: Eat In, Takeout, …
1215
+ * `data` is `string[]` once loaded.
1216
+ */
1217
+ function useProductInstanceTags(options) {
1218
+ return useQuery((signal) => fetchProductInstanceTags({
1219
+ ...options,
1220
+ signal
1221
+ }), [options?.baseUrl, options?.token], options?.enabled ?? true);
1222
+ }
1223
+
1127
1224
  //#endregion
1128
1225
  //#region src/hooks/usePromotion.ts
1129
1226
  /**
@@ -1442,6 +1539,8 @@ exports.useEmployee = useEmployee;
1442
1539
  exports.useLogout = useLogout;
1443
1540
  exports.useMerchant = useMerchant;
1444
1541
  exports.useProduct = useProduct;
1542
+ exports.useProductInstanceTags = useProductInstanceTags;
1543
+ exports.useProductTags = useProductTags;
1445
1544
  exports.usePromotion = usePromotion;
1446
1545
  exports.useReport = useReport;
1447
1546
  exports.useSession = useSession;