@akropolys/sdk 1.0.2 → 1.1.0

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
@@ -125,7 +125,7 @@ var AkropolysAPI = class {
125
125
  }
126
126
  }
127
127
  async ingest(product) {
128
- log("info", "ingesting product", product.name);
128
+ log("info", "ingesting product", product.name || product.id || "");
129
129
  return this.post("/ingest", { siteId: this.siteId, product });
130
130
  }
131
131
  async ingestBatch(products) {
@@ -409,113 +409,6 @@ function getEnvVar(key) {
409
409
  }
410
410
  return void 0;
411
411
  }
412
- function mapRawProduct(input) {
413
- const name = input.name || input.title || input.productName || "";
414
- let price = "";
415
- let priceNumeric = void 0;
416
- if (input.price !== void 0) {
417
- if (typeof input.price === "number") {
418
- priceNumeric = input.price;
419
- price = String(input.price);
420
- } else {
421
- price = input.price;
422
- const num = parseFloat(input.price.replace(/[^0-9.]/g, ""));
423
- priceNumeric = isNaN(num) ? void 0 : num;
424
- }
425
- }
426
- if (input.priceNumeric !== void 0) {
427
- priceNumeric = input.priceNumeric;
428
- }
429
- let url = input.url || "";
430
- if (!url && typeof window !== "undefined") {
431
- url = window.location.href;
432
- }
433
- let slug = input.slug || input.id || input.productId || "";
434
- if (!slug && url) {
435
- slug = url.split("/").filter(Boolean).pop() || "";
436
- }
437
- if (!slug && name) {
438
- slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
439
- }
440
- let images = [];
441
- if (input.images) {
442
- images = input.images;
443
- } else if (input.image) {
444
- images = [input.image];
445
- } else if (input.listing_agent_photo) {
446
- images = [input.listing_agent_photo];
447
- } else if (input.thumbnail) {
448
- images = [input.thumbnail];
449
- }
450
- if (!name) {
451
- console.warn("[Akropolys] Validation warning: Product name/title is missing. Skipping:", input);
452
- return null;
453
- }
454
- if (!price) {
455
- console.warn("[Akropolys] Validation warning: Product price is missing. Skipping:", input);
456
- return null;
457
- }
458
- if (!url) {
459
- console.warn("[Akropolys] Validation warning: Product URL is missing. Skipping:", input);
460
- return null;
461
- }
462
- const coreKeys = /* @__PURE__ */ new Set([
463
- "name",
464
- "title",
465
- "productName",
466
- "price",
467
- "priceNumeric",
468
- "url",
469
- "image",
470
- "thumbnail",
471
- "images",
472
- "slug",
473
- "id",
474
- "productId",
475
- "brand",
476
- "description",
477
- "originalPrice",
478
- "discount",
479
- "currency",
480
- "stock",
481
- "availability",
482
- "rating",
483
- "reviewCount",
484
- "category",
485
- "subCategory",
486
- "tags",
487
- "specs",
488
- "metadata"
489
- ]);
490
- const metadata = { ...input.metadata };
491
- for (const [key, value] of Object.entries(input)) {
492
- if (!coreKeys.has(key) && value !== void 0) {
493
- metadata[key] = value;
494
- }
495
- }
496
- return {
497
- name,
498
- price,
499
- url,
500
- brand: input.brand,
501
- description: input.description,
502
- originalPrice: input.originalPrice,
503
- discount: input.discount,
504
- currency: input.currency ?? "KES",
505
- stock: input.stock,
506
- availability: input.availability,
507
- rating: input.rating,
508
- reviewCount: input.reviewCount,
509
- category: input.category,
510
- subCategory: input.subCategory,
511
- tags: input.tags,
512
- images: images.length > 0 ? images : void 0,
513
- specs: input.specs,
514
- priceNumeric,
515
- slug,
516
- metadata: Object.keys(metadata).length > 0 ? metadata : void 0
517
- };
518
- }
519
412
  function generateUUID() {
520
413
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
521
414
  return crypto.randomUUID();
@@ -533,6 +426,8 @@ var _AkropolysClient = class _AkropolysClient {
533
426
  this.ingestedUrls = /* @__PURE__ */ new Set();
534
427
  this.onlineHandler = null;
535
428
  this.sessionId = "";
429
+ this.isFlushing = false;
430
+ this.retryCount = 0;
536
431
  const siteId = config.siteId || getEnvVar("NEXT_PUBLIC_AKROPOLYS_SITE_ID") || "";
537
432
  const apiUrl = config.apiUrl || getEnvVar("NEXT_PUBLIC_AKROPOLYS_API_URL") || "";
538
433
  const apiToken = config.apiToken || getEnvVar("NEXT_PUBLIC_AKROPOLYS_API_TOKEN") || "";
@@ -595,6 +490,10 @@ var _AkropolysClient = class _AkropolysClient {
595
490
  }
596
491
  reRegister() {
597
492
  instance = this;
493
+ if (typeof window !== "undefined" && !this.onlineHandler) {
494
+ this.onlineHandler = () => this.flushQueue();
495
+ window.addEventListener("online", this.onlineHandler);
496
+ }
598
497
  }
599
498
  setShopperId(id) {
600
499
  this.shopperId = id;
@@ -641,74 +540,114 @@ var _AkropolysClient = class _AkropolysClient {
641
540
  }
642
541
  if (instance === this) instance = null;
643
542
  }
644
- async queueIngest(rawProduct) {
645
- const product = mapRawProduct(rawProduct);
646
- if (!product) return;
647
- if (this.ingestedUrls.has(product.url)) {
543
+ async queueIngest(rawItem) {
544
+ const id = rawItem.id ?? rawItem.productId ?? rawItem.slug ?? rawItem.url ?? rawItem.name ?? "";
545
+ const url = rawItem.url || (typeof window !== "undefined" ? window.location.href : "");
546
+ if (!id && !url) {
547
+ console.warn("[Akropolys] Ingestion warning: Item is missing both a stable identifier and a URL. Skipping.");
648
548
  return;
649
549
  }
650
- this.ingestedUrls.add(product.url);
651
- this.saveIngestedCache();
652
- this.ingestQueue.push(product);
550
+ if (url) {
551
+ if (this.ingestedUrls.has(url)) {
552
+ return;
553
+ }
554
+ this.ingestedUrls.add(url);
555
+ this.saveIngestedCache();
556
+ }
557
+ this.ingestQueue.push(rawItem);
653
558
  this.scheduleFlush();
654
559
  }
655
- async queueIngestBatch(rawProducts) {
656
- rawProducts.forEach((p) => {
657
- const product = mapRawProduct(p);
658
- if (!product) return;
659
- if (this.ingestedUrls.has(product.url)) {
560
+ async queueIngestBatch(rawItems) {
561
+ let hasNew = false;
562
+ rawItems.forEach((rawItem) => {
563
+ const id = rawItem.id ?? rawItem.productId ?? rawItem.slug ?? rawItem.url ?? rawItem.name ?? "";
564
+ const url = rawItem.url || (typeof window !== "undefined" ? window.location.href : "");
565
+ if (!id && !url) {
566
+ console.warn("[Akropolys] Ingestion warning: Item is missing both a stable identifier and a URL. Skipping.");
660
567
  return;
661
568
  }
662
- this.ingestedUrls.add(product.url);
663
- this.ingestQueue.push(product);
569
+ if (url) {
570
+ if (this.ingestedUrls.has(url)) {
571
+ return;
572
+ }
573
+ this.ingestedUrls.add(url);
574
+ hasNew = true;
575
+ }
576
+ this.ingestQueue.push(rawItem);
664
577
  });
665
- if (this.ingestQueue.length > 0) {
578
+ if (hasNew) {
666
579
  this.saveIngestedCache();
580
+ }
581
+ if (this.ingestQueue.length > 0) {
667
582
  this.scheduleFlush();
668
583
  }
669
584
  }
670
585
  scheduleFlush() {
671
- if (this.ingestTimer) return;
586
+ if (this.ingestTimer || this.isFlushing) return;
672
587
  this.ingestTimer = setTimeout(() => {
673
588
  this.flushQueue();
674
589
  }, 300);
675
590
  }
676
591
  async flushQueue() {
677
- this.ingestTimer = null;
678
- if (this.ingestQueue.length === 0) return;
592
+ if (this.isFlushing) return;
593
+ this.isFlushing = true;
594
+ if (this.ingestTimer) {
595
+ clearTimeout(this.ingestTimer);
596
+ this.ingestTimer = null;
597
+ }
679
598
  if (this.authLoading) {
680
599
  console.log("[Akropolys] Authentication is loading. Deferring ingestion flush.");
600
+ this.isFlushing = false;
681
601
  return;
682
602
  }
683
603
  if (typeof navigator !== "undefined" && !navigator.onLine) {
684
604
  console.warn("[Akropolys] Browser offline. Postponing ingestion.");
605
+ this.isFlushing = false;
685
606
  return;
686
607
  }
687
- const batch = [...this.ingestQueue];
688
- this.ingestQueue = [];
608
+ const maxBatchSize = 50;
689
609
  try {
690
- await this.api.ingestBatch(batch);
691
- } catch (e) {
692
- const akropolysError = {
693
- status: e.status || 500,
694
- message: e.message || "Unknown network error"
695
- };
696
- if (this.onError) {
610
+ while (this.ingestQueue.length > 0) {
611
+ const batch = this.ingestQueue.slice(0, maxBatchSize);
697
612
  try {
698
- this.onError(akropolysError);
699
- } catch (err) {
700
- console.error("[Akropolys] Error inside onError callback:", err);
613
+ await this.api.ingestBatch(batch);
614
+ this.ingestQueue.splice(0, batch.length);
615
+ this.retryCount = 0;
616
+ } catch (e) {
617
+ const status = e.status || 500;
618
+ const message = e.message || "Unknown network error";
619
+ if (this.onError) {
620
+ try {
621
+ this.onError({ status, message });
622
+ } catch (err) {
623
+ console.error("[Akropolys] Error inside onError callback:", err);
624
+ }
625
+ }
626
+ if (status >= 400 && status < 500 && status !== 429) {
627
+ console.error("[Akropolys] Ingestion discarded due to client error:", message);
628
+ this.ingestQueue.splice(0, batch.length);
629
+ continue;
630
+ } else {
631
+ console.warn("[Akropolys] Ingestion temporarily failed. Retrying later.", message);
632
+ this.scheduleFlushWithBackoff();
633
+ break;
634
+ }
701
635
  }
702
636
  }
703
- if (e.status && e.status >= 400 && e.status < 500) {
704
- console.error("[Akropolys] Ingestion discarded due to client error:", e.message);
705
- return;
706
- }
707
- console.warn("[Akropolys] Ingestion failed. Re-queuing to retry.", e);
708
- this.ingestQueue = [...batch, ...this.ingestQueue];
709
- this.scheduleFlush();
637
+ } finally {
638
+ this.isFlushing = false;
710
639
  }
711
640
  }
641
+ scheduleFlushWithBackoff() {
642
+ if (this.ingestTimer) return;
643
+ const baseDelay = 1e3;
644
+ const jitter = Math.random() * 1e3;
645
+ const delay = Math.min(baseDelay * Math.pow(2, this.retryCount), 3e4) + jitter;
646
+ this.retryCount++;
647
+ this.ingestTimer = setTimeout(() => {
648
+ this.flushQueue();
649
+ }, delay);
650
+ }
712
651
  };
713
652
  _AkropolysClient.INGEST_CACHE_KEY = "akropolys_ingested_v2";
714
653
  _AkropolysClient.INGEST_CACHE_TTL = 24 * 60 * 60 * 1e3;
@@ -852,9 +791,40 @@ function useSearch() {
852
791
 
853
792
  // src/hooks/useIngest.ts
854
793
  var import_react4 = require("react");
855
- var recentlyIngested = /* @__PURE__ */ new Set();
794
+
795
+ // src/utils/TTLCache.ts
796
+ var TTLCache = class {
797
+ constructor(ttlMs = 24 * 60 * 60 * 1e3) {
798
+ this.cache = /* @__PURE__ */ new Map();
799
+ this.ttl = ttlMs;
800
+ }
801
+ add(key) {
802
+ this.cache.set(key, Date.now());
803
+ this.evictExpired();
804
+ }
805
+ has(key) {
806
+ const timestamp = this.cache.get(key);
807
+ if (timestamp === void 0) return false;
808
+ if (Date.now() - timestamp > this.ttl) {
809
+ this.cache.delete(key);
810
+ return false;
811
+ }
812
+ return true;
813
+ }
814
+ evictExpired() {
815
+ const now = Date.now();
816
+ for (const [key, timestamp] of this.cache.entries()) {
817
+ if (now - timestamp > this.ttl) {
818
+ this.cache.delete(key);
819
+ }
820
+ }
821
+ }
822
+ };
823
+
824
+ // src/hooks/useIngest.ts
825
+ var recentlyIngested = new TTLCache(24 * 60 * 60 * 1e3);
856
826
  function getProductKey(p) {
857
- return p.url || p.id || p.productId || p.slug || p.name || p.title || p.productName || null;
827
+ return p.id || p.productId || p.slug || p.url || p.name || p.title || p.productName || null;
858
828
  }
859
829
  function useIngest() {
860
830
  const client = useAkropolysContext();
@@ -884,14 +854,14 @@ function useIngest() {
884
854
 
885
855
  // src/hooks/useListIngest.ts
886
856
  var import_react5 = require("react");
887
- function useListIngest(products) {
857
+ function useListIngest(items) {
888
858
  const { ingestBatch } = useIngest();
889
859
  const processedIdsRef = (0, import_react5.useRef)(/* @__PURE__ */ new Set());
890
- const listKey = (products || []).map((p) => p.url || p.id || p.productId || p.slug || p.name || p.title || p.productName || "").join(",");
860
+ const listKey = (items || []).map((p) => p.id ?? p.productId ?? p.slug ?? p.url ?? p.name ?? "").join(",");
891
861
  (0, import_react5.useEffect)(() => {
892
- if (!products || !products.length) return;
893
- const newItems = products.filter((item) => {
894
- const id = item.id || item.productId || item.url || item.slug || item.name || item.title || item.productName;
862
+ if (!items || !items.length) return;
863
+ const newItems = items.filter((item) => {
864
+ const id = item.id ?? item.productId ?? item.slug ?? item.url ?? item.name ?? "";
895
865
  if (!id) return true;
896
866
  if (processedIdsRef.current.has(id)) {
897
867
  return false;
@@ -908,17 +878,20 @@ function useListIngest(products) {
908
878
  // src/hooks/usePageIngest.ts
909
879
  var import_react6 = require("react");
910
880
  function usePageIngest(product) {
911
- const ingestedRef = (0, import_react6.useRef)(null);
881
+ const lastIngestedKey = (0, import_react6.useRef)(null);
882
+ const uniqueId = product ? product.id ?? product.productId ?? product.slug ?? product.url ?? "" : "";
912
883
  (0, import_react6.useEffect)(() => {
913
- if (!product) return;
914
- const url = product.url || (typeof window !== "undefined" ? window.location.href : "");
915
- if (ingestedRef.current === url) return;
916
- ingestedRef.current = url;
884
+ if (!product || !uniqueId) return;
885
+ if (lastIngestedKey.current === uniqueId) return;
886
+ lastIngestedKey.current = uniqueId;
917
887
  try {
918
- getAkropolysClient().queueIngest({ ...product, url });
919
- } catch {
888
+ getAkropolysClient().queueIngest(product);
889
+ } catch (err) {
890
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "production") {
891
+ console.warn("[Akropolys] Ingestion failed inside usePageIngest:", err);
892
+ }
920
893
  }
921
- }, [product?.url ?? product?.name]);
894
+ }, [uniqueId]);
922
895
  }
923
896
 
924
897
  // src/hooks/useKiku.ts