@tolinku/web-sdk 0.1.0 → 0.3.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
@@ -277,6 +277,208 @@ var Analytics = class {
277
277
  }
278
278
  };
279
279
 
280
+ // src/ecommerce.ts
281
+ var BATCH_SIZE2 = 10;
282
+ var FLUSH_INTERVAL_MS2 = 5e3;
283
+ var MAX_QUEUE_SIZE2 = 500;
284
+ var CART_ID_KEY = "tolk_cart_id";
285
+ var Ecommerce = class {
286
+ // fallback when sessionStorage unavailable
287
+ constructor(client, getUserId) {
288
+ this.client = client;
289
+ this.queue = [];
290
+ this.flushTimer = null;
291
+ this.unloadHandler = null;
292
+ this.memoryCartId = null;
293
+ this.getUserId = getUserId;
294
+ if (typeof window !== "undefined") {
295
+ this.unloadHandler = () => this.flushBeacon();
296
+ window.addEventListener("beforeunload", this.unloadHandler);
297
+ }
298
+ }
299
+ // ─── Public methods (13 event types) ────────────────────
300
+ async viewItem(params) {
301
+ await this.enqueue({ event_type: "view_item", items: params.items });
302
+ }
303
+ async addToCart(params) {
304
+ const cartId = params.cart_id || this.getOrCreateCartId();
305
+ await this.enqueue({ event_type: "add_to_cart", items: params.items, cart_id: cartId });
306
+ }
307
+ async removeFromCart(params) {
308
+ await this.enqueue({ event_type: "remove_from_cart", items: params.items, cart_id: params.cart_id || this.getCartId() });
309
+ }
310
+ async addToWishlist(params) {
311
+ await this.enqueue({ event_type: "add_to_wishlist", items: params.items });
312
+ }
313
+ async viewCart() {
314
+ await this.enqueue({ event_type: "view_cart", cart_id: this.getCartId() });
315
+ }
316
+ async addPaymentInfo(params) {
317
+ await this.enqueue({ event_type: "add_payment_info", cart_id: params?.cart_id || this.getCartId() });
318
+ }
319
+ async beginCheckout(params) {
320
+ await this.enqueue({
321
+ event_type: "begin_checkout",
322
+ revenue: params.revenue,
323
+ currency: params.currency,
324
+ cart_id: params.cart_id || this.getCartId(),
325
+ items: params.items
326
+ });
327
+ }
328
+ async purchase(params) {
329
+ const cartId = params.cart_id || this.getCartId();
330
+ await this.enqueue({
331
+ event_type: "purchase",
332
+ transaction_id: params.transaction_id,
333
+ revenue: params.revenue,
334
+ currency: params.currency,
335
+ cart_id: cartId,
336
+ coupon_code: params.coupon_code,
337
+ discount: params.discount,
338
+ shipping: params.shipping,
339
+ tax: params.tax,
340
+ items: params.items
341
+ });
342
+ this.clearCartId();
343
+ }
344
+ async refund(params) {
345
+ await this.enqueue({
346
+ event_type: "refund",
347
+ transaction_id: params.transaction_id,
348
+ revenue: params.revenue,
349
+ currency: params.currency,
350
+ items: params.items
351
+ });
352
+ }
353
+ async search(params) {
354
+ await this.enqueue({ event_type: "search", properties: { search_term: params.search_term } });
355
+ }
356
+ async share(params) {
357
+ const props = {};
358
+ if (params.item_id) props.item_id = params.item_id;
359
+ if (params.url) props.url = params.url;
360
+ if (params.method) props.method = params.method;
361
+ await this.enqueue({ event_type: "share", properties: props });
362
+ }
363
+ async rate(params) {
364
+ await this.enqueue({
365
+ event_type: "rate",
366
+ properties: {
367
+ item_id: params.item_id,
368
+ rating: String(params.rating),
369
+ ...params.max_rating != null ? { max_rating: String(params.max_rating) } : {}
370
+ }
371
+ });
372
+ }
373
+ async spendCredits(params) {
374
+ await this.enqueue({ event_type: "spend_credits", revenue: params.revenue, currency: params.currency });
375
+ }
376
+ // ─── Flush ─────────────────────────────────────────────
377
+ async flush() {
378
+ if (this.flushTimer) {
379
+ clearTimeout(this.flushTimer);
380
+ this.flushTimer = null;
381
+ }
382
+ if (this.queue.length === 0) return;
383
+ const events = this.queue.splice(0);
384
+ try {
385
+ const result = await this.client.post(
386
+ "/v1/api/analytics/ecommerce/batch",
387
+ { events }
388
+ );
389
+ if (result.errors && result.errors.length > 0) {
390
+ console.warn("[TolinkuSDK] Ecommerce batch partial failure:", result.errors);
391
+ }
392
+ } catch {
393
+ this.queue.unshift(...events);
394
+ if (this.queue.length > MAX_QUEUE_SIZE2) {
395
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE2);
396
+ }
397
+ }
398
+ }
399
+ destroy() {
400
+ this.flushBeacon();
401
+ if (this.flushTimer) {
402
+ clearTimeout(this.flushTimer);
403
+ this.flushTimer = null;
404
+ }
405
+ if (typeof window !== "undefined" && this.unloadHandler) {
406
+ window.removeEventListener("beforeunload", this.unloadHandler);
407
+ this.unloadHandler = null;
408
+ }
409
+ }
410
+ // ─── Private ───────────────────────────────────────────
411
+ async enqueue(event) {
412
+ const userId = this.getUserId();
413
+ if (userId) event.user_id = userId;
414
+ this.queue.push(event);
415
+ if (this.queue.length === 1 && !this.flushTimer) {
416
+ this.flushTimer = setTimeout(() => {
417
+ this.flushTimer = null;
418
+ this.flush();
419
+ }, FLUSH_INTERVAL_MS2);
420
+ }
421
+ if (this.queue.length >= BATCH_SIZE2) {
422
+ await this.flush();
423
+ }
424
+ }
425
+ flushBeacon() {
426
+ if (this.queue.length === 0) return;
427
+ const events = this.queue.splice(0);
428
+ const url = this.client.baseUrl + "/v1/api/analytics/ecommerce/batch";
429
+ const body = JSON.stringify({ events, apiKey: this.client.key });
430
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
431
+ navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
432
+ }
433
+ }
434
+ // ─── Cart ID lifecycle ─────────────────────────────────
435
+ getOrCreateCartId() {
436
+ const existing = this.getCartId();
437
+ if (existing) return existing;
438
+ const cartId = this.generateId();
439
+ this.setCartId(cartId);
440
+ return cartId;
441
+ }
442
+ getCartId() {
443
+ try {
444
+ if (typeof sessionStorage !== "undefined") {
445
+ const stored = sessionStorage.getItem(CART_ID_KEY);
446
+ if (stored) return stored;
447
+ }
448
+ } catch {
449
+ }
450
+ return this.memoryCartId || void 0;
451
+ }
452
+ setCartId(cartId) {
453
+ this.memoryCartId = cartId;
454
+ try {
455
+ if (typeof sessionStorage !== "undefined") {
456
+ sessionStorage.setItem(CART_ID_KEY, cartId);
457
+ }
458
+ } catch {
459
+ }
460
+ }
461
+ clearCartId() {
462
+ this.memoryCartId = null;
463
+ try {
464
+ if (typeof sessionStorage !== "undefined") {
465
+ sessionStorage.removeItem(CART_ID_KEY);
466
+ }
467
+ } catch {
468
+ }
469
+ }
470
+ generateId() {
471
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
472
+ return crypto.randomUUID();
473
+ }
474
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
475
+ const r = Math.random() * 16 | 0;
476
+ const v = c === "x" ? r : r & 3 | 8;
477
+ return v.toString(16);
478
+ });
479
+ }
480
+ };
481
+
280
482
  // src/referrals.ts
281
483
  var Referrals = class {
282
484
  constructor(client) {
@@ -346,9 +548,24 @@ var Deferred = class {
346
548
  timezone: options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
347
549
  language: options.language || navigator.language,
348
550
  screen_width: options.screenWidth || window.screen.width,
349
- screen_height: options.screenHeight || window.screen.height
551
+ screen_height: options.screenHeight || window.screen.height,
552
+ // Separates devices reporting identical logical dimensions.
553
+ device_pixel_ratio: options.devicePixelRatio || window.devicePixelRatio || 1
350
554
  });
351
555
  } catch (err) {
556
+ const status = err?.statusCode ?? err?.status;
557
+ if (status === 404) {
558
+ return null;
559
+ }
560
+ if (status === 403) {
561
+ console.warn(
562
+ "[Tolinku] Failed to claim deferred link by signals: HTTP 403.",
563
+ "Check that appspaceId is your Appspace ID (copy it from the dashboard",
564
+ "under Settings), not your subdomain or slug.",
565
+ err
566
+ );
567
+ return null;
568
+ }
352
569
  console.warn("[Tolinku] Failed to claim deferred link by signals:", err);
353
570
  return null;
354
571
  }
@@ -438,11 +655,56 @@ function sanitizeCssColor(value) {
438
655
  }
439
656
 
440
657
  // src/banners.ts
658
+ var themes = {
659
+ light: {
660
+ bg: "#ffffff",
661
+ border: "",
662
+ shadow: "sm",
663
+ title: { color: "#000000", size: 14, weight: 600 },
664
+ body: { color: "#000000", size: 12, weight: 400 },
665
+ cta: { size: 13, weight: 600, radius: 100 },
666
+ icon: { size: 40, radius: 10 }
667
+ },
668
+ dark: {
669
+ bg: "#1B1B1B",
670
+ border: "",
671
+ shadow: "sm",
672
+ title: { color: "#ffffff", size: 14, weight: 600 },
673
+ body: { color: "#ffffff", size: 12, weight: 400 },
674
+ cta: { size: 13, weight: 600, radius: 100 },
675
+ icon: { size: 40, radius: 10 }
676
+ }
677
+ };
678
+ var SHADOW_VALUES = {
679
+ none: "none",
680
+ sm: "0 2px 8px rgba(0,0,0,0.15)",
681
+ md: "0 6px 20px rgba(0,0,0,0.18)",
682
+ lg: "0 12px 36px rgba(0,0,0,0.22)"
683
+ };
684
+ function clampInt(val, min, max) {
685
+ if (val === void 0 || val === null || isNaN(val)) return null;
686
+ return Math.max(min, Math.min(max, Math.floor(val)));
687
+ }
688
+ function sanitizeClass(val) {
689
+ if (!val || typeof val !== "string") return "";
690
+ return val.replace(/[^a-zA-Z0-9_\-\s]/g, "").slice(0, 100).trim();
691
+ }
692
+ function isSafeUrl(url) {
693
+ try {
694
+ const parsed = new URL(url, window.location.href);
695
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
696
+ } catch {
697
+ return false;
698
+ }
699
+ }
441
700
  var Banners = class {
442
701
  constructor(client) {
443
702
  this.client = client;
444
703
  this.container = null;
445
704
  this.styleEl = null;
705
+ this.currentAnimation = "slide";
706
+ this.currentStyle = "pinned";
707
+ this.currentPosition = "top";
446
708
  }
447
709
  /** Fetch banner config and show the highest-priority banner */
448
710
  async show(options = {}, userId) {
@@ -460,94 +722,200 @@ var Banners = class {
460
722
  }
461
723
  }
462
724
  if (!banner) return;
463
- this.render(config, banner, options);
725
+ const delay = clampInt(options.delay, 0, 6e4);
726
+ if (delay && delay > 0) {
727
+ setTimeout(() => this.render(config, banner, options), delay);
728
+ } else {
729
+ this.render(config, banner, options);
730
+ }
464
731
  }
465
- /** Remove the banner from the DOM */
732
+ /** Remove the banner from the DOM, respecting the active animation */
466
733
  dismiss() {
467
- if (this.container) {
468
- this.container.classList.remove("tolk-visible");
469
- const pos = this.container.dataset.position || "top";
470
- document.body.style.removeProperty(pos === "top" ? "padding-top" : "padding-bottom");
471
- setTimeout(() => {
472
- this.container?.remove();
473
- this.styleEl?.remove();
734
+ if (!this.container) return;
735
+ const container = this.container;
736
+ const styleEl = this.styleEl;
737
+ container.classList.remove("tolk-visible");
738
+ if (this.currentStyle === "pinned") {
739
+ document.body.style.removeProperty(this.currentPosition === "top" ? "padding-top" : "padding-bottom");
740
+ }
741
+ const cleanup = () => {
742
+ container.remove();
743
+ styleEl?.remove();
744
+ if (this.container === container) {
474
745
  this.container = null;
475
746
  this.styleEl = null;
476
- }, 400);
747
+ }
748
+ };
749
+ if (this.currentAnimation === "none") {
750
+ cleanup();
751
+ } else {
752
+ setTimeout(cleanup, 400);
477
753
  }
478
754
  }
479
755
  render(config, banner, options) {
480
756
  if (this.container) {
481
757
  this.container.remove();
482
758
  this.styleEl?.remove();
759
+ if (this.currentStyle === "pinned") {
760
+ document.body.style.removeProperty(this.currentPosition === "top" ? "padding-top" : "padding-bottom");
761
+ }
762
+ }
763
+ const themeName = options.theme && themes[options.theme] ? options.theme : "light";
764
+ const theme = themes[themeName];
765
+ let position = "top";
766
+ if (options.position === "top" || options.position === "bottom") position = options.position;
767
+ else if (banner.position === "top" || banner.position === "bottom") position = banner.position;
768
+ let resolvedStyle = "pinned";
769
+ if (options.style === "pinned" || options.style === "floating" || options.style === "stacked") {
770
+ resolvedStyle = options.style;
771
+ } else if (banner.style === "pinned" || banner.style === "floating" || banner.style === "stacked") {
772
+ resolvedStyle = banner.style;
483
773
  }
484
- const position = options.position || banner.position || "top";
485
- const bgColor = sanitizeCssColor(banner.background_color) || "#ffffff";
486
- const textColor = sanitizeCssColor(banner.text_color) || "#000000";
774
+ const floating = resolvedStyle === "floating";
775
+ const stacked = resolvedStyle === "stacked";
776
+ let animation = options.animation || "slide";
777
+ if (animation === "pop" && !floating && !stacked) animation = "slide";
778
+ this.currentPosition = position;
779
+ this.currentStyle = resolvedStyle;
780
+ this.currentAnimation = animation;
781
+ const serverBg = sanitizeCssColor(banner.background_color);
782
+ const serverText = sanitizeCssColor(banner.text_color);
783
+ const optBg = sanitizeCssColor(options.bg);
784
+ const optBorder = sanitizeCssColor(options.border);
785
+ const optTitleColor = sanitizeCssColor(options.titleColor);
786
+ const optBodyColor = sanitizeCssColor(options.bodyColor);
787
+ const optCtaBg = sanitizeCssColor(options.ctaBg);
788
+ const optCtaColor = sanitizeCssColor(options.ctaColor);
789
+ const bg = optBg || serverBg || theme.bg;
790
+ const border = optBorder || theme.border;
791
+ const titleColor = optTitleColor || serverText || theme.title.color;
792
+ const bodyColor = optBodyColor || serverText || theme.body.color;
793
+ const ctaBg = optCtaBg || theme.cta.bg || titleColor;
794
+ const ctaColor = optCtaColor || theme.cta.color || bg;
795
+ const titleSize = clampInt(options.titleSize, 10, 24) ?? theme.title.size;
796
+ const titleWeight = options.titleWeight ?? theme.title.weight;
797
+ const bodySize = clampInt(options.bodySize, 10, 20) ?? theme.body.size;
798
+ const bodyWeight = options.bodyWeight ?? theme.body.weight;
799
+ const ctaSize = clampInt(options.ctaSize, 10, 18) ?? theme.cta.size;
800
+ const ctaWeight = options.ctaWeight ?? theme.cta.weight;
801
+ const ctaRadius = clampInt(options.ctaRadius, 0, 100) ?? theme.cta.radius;
802
+ const iconSize = clampInt(options.iconSize, 24, 64) ?? theme.icon.size;
803
+ const iconRadius = clampInt(options.iconRadius, 0, 32) ?? theme.icon.radius;
804
+ const optRadius = clampInt(options.radius, 0, 24);
805
+ const serverRadius = typeof banner.radius === "number" && banner.radius >= 0 && banner.radius <= 24 ? banner.radius : null;
806
+ const optMargin = clampInt(options.margin, 0, 24);
807
+ const serverMargin = typeof banner.margin === "number" && banner.margin >= 0 && banner.margin <= 24 ? banner.margin : null;
808
+ const bannerRadius = floating ? optRadius ?? serverRadius ?? 12 : 0;
809
+ const bannerMargin = floating ? optMargin ?? serverMargin ?? 12 : 0;
810
+ const shadowKey = options.shadow && SHADOW_VALUES[options.shadow] ? options.shadow : banner.shadow && SHADOW_VALUES[banner.shadow] ? banner.shadow : theme.shadow;
811
+ const shadow = SHADOW_VALUES[shadowKey];
812
+ const hideIcon = !!options.hideIcon;
813
+ const hideClose = !!options.hideClose;
814
+ const hideBody = !!options.hideBody;
815
+ const customClass = sanitizeClass(options.customClass);
487
816
  const ctaText = banner.cta_text || "Open";
488
- const baseUrl = this.client.baseUrl;
489
- const installUrl = banner.action_url || baseUrl + (config.install_url || "/install");
817
+ const installUrl = banner.action_url || this.client.baseUrl + (config.install_url || "/install");
490
818
  const safeTop = position === "top" ? "padding-top: env(safe-area-inset-top, 0px);" : "";
491
819
  const safeBottom = position === "bottom" ? "padding-bottom: env(safe-area-inset-bottom, 0px);" : "";
820
+ let containerPosition;
821
+ let containerInsets;
822
+ if (stacked) {
823
+ containerPosition = "position: sticky;";
824
+ containerInsets = `${position}: 0;`;
825
+ } else if (floating) {
826
+ containerPosition = "position: fixed;";
827
+ containerInsets = `left: ${bannerMargin}px; right: ${bannerMargin}px; ${position}: ${bannerMargin}px;`;
828
+ } else {
829
+ containerPosition = "position: fixed;";
830
+ containerInsets = `left: 0; right: 0; ${position}: 0;`;
831
+ }
832
+ const hideOffset = bannerMargin + 60;
833
+ const slideHide = position === "top" ? `translateY(calc(-100% - ${hideOffset}px))` : `translateY(calc(100% + ${hideOffset}px))`;
834
+ let hiddenCss = "";
835
+ let visibleCss = "";
836
+ let transitionCss = "";
837
+ if (animation === "slide") {
838
+ hiddenCss = `transform: ${slideHide};`;
839
+ visibleCss = "transform: translateY(0);";
840
+ transitionCss = "transition: transform 0.35s ease;";
841
+ } else if (animation === "fade") {
842
+ hiddenCss = "opacity: 0;";
843
+ visibleCss = "opacity: 1;";
844
+ transitionCss = "transition: opacity 0.3s ease;";
845
+ } else if (animation === "pop") {
846
+ hiddenCss = "opacity: 0; transform: scale(0.92);";
847
+ visibleCss = "opacity: 1; transform: scale(1);";
848
+ transitionCss = "transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;";
849
+ }
492
850
  const container = document.createElement("div");
493
851
  container.id = "tolinku-banner";
494
852
  container.setAttribute("role", "banner");
495
853
  container.setAttribute("aria-live", "polite");
496
854
  container.dataset.position = position;
855
+ if (customClass) container.className = customClass;
856
+ const borderCss = border ? `border: 1px solid ${border};` : "";
857
+ const radiusCss = bannerRadius > 0 ? `border-radius: ${bannerRadius}px;` : "";
497
858
  const style = document.createElement("style");
498
859
  style.textContent = `
499
860
  #tolinku-banner {
500
- position: fixed;
501
- ${position}: 0;
502
- left: 0; right: 0;
861
+ ${containerPosition}
862
+ ${containerInsets}
503
863
  z-index: 999999;
504
864
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
505
- transform: translateY(${position === "top" ? "-100%" : "100%"});
506
- transition: transform 0.35s ease;
865
+ ${hiddenCss}
866
+ ${transitionCss}
507
867
  ${safeTop}${safeBottom}
508
868
  }
509
- #tolinku-banner.tolk-visible { transform: translateY(0); }
869
+ #tolinku-banner.tolk-visible { ${visibleCss} }
510
870
  #tolinku-banner .tolk-inner {
511
871
  display: flex; align-items: center; gap: 10px;
512
872
  padding: 10px 14px;
513
- background: ${bgColor}; color: ${textColor};
514
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
873
+ background: ${bg};
874
+ ${borderCss}
875
+ ${radiusCss}
876
+ box-shadow: ${shadow};
515
877
  }
516
878
  #tolinku-banner .tolk-close {
517
879
  background: none; border: none; font-size: 20px; line-height: 1;
518
- cursor: pointer; color: ${textColor}; opacity: 0.6; padding: 0 4px; flex-shrink: 0;
880
+ cursor: pointer; color: ${titleColor}; opacity: 0.6; padding: 0 4px; flex-shrink: 0;
519
881
  }
520
882
  #tolinku-banner .tolk-close:hover { opacity: 1; }
521
883
  #tolinku-banner .tolk-icon {
522
- width: 40px; height: 40px; border-radius: 10px; flex-shrink: 0; object-fit: cover;
884
+ width: ${iconSize}px; height: ${iconSize}px; border-radius: ${iconRadius}px;
885
+ flex-shrink: 0; object-fit: cover;
523
886
  }
524
887
  #tolinku-banner .tolk-text { flex: 1; min-width: 0; }
525
888
  #tolinku-banner .tolk-title {
526
- font-size: 14px; font-weight: 600; margin: 0;
889
+ font-size: ${titleSize}px; font-weight: ${titleWeight}; line-height: 1.3;
890
+ color: ${titleColor}; margin: 0;
527
891
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
528
892
  }
529
893
  #tolinku-banner .tolk-body {
530
- font-size: 12px; margin: 0; opacity: 0.75;
894
+ font-size: ${bodySize}px; font-weight: ${bodyWeight}; line-height: 1.3;
895
+ color: ${bodyColor}; margin: 0; opacity: 0.75;
531
896
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
532
897
  }
533
898
  #tolinku-banner .tolk-cta {
534
- display: inline-block; padding: 6px 16px; border-radius: 100px;
535
- font-size: 13px; font-weight: 600; text-decoration: none;
536
- background: ${textColor}; color: ${bgColor}; flex-shrink: 0; text-align: center;
899
+ display: inline-block; padding: 0.5em 1.2em; border-radius: ${ctaRadius}px;
900
+ font-size: ${ctaSize}px; font-weight: ${ctaWeight}; line-height: 1.2;
901
+ text-decoration: none; background: ${ctaBg}; color: ${ctaColor};
902
+ flex-shrink: 0; text-align: center;
537
903
  }
538
904
  `;
539
905
  const inner = document.createElement("div");
540
906
  inner.className = "tolk-inner";
541
- const closeBtn = document.createElement("button");
542
- closeBtn.className = "tolk-close";
543
- closeBtn.setAttribute("aria-label", "Dismiss banner");
544
- closeBtn.textContent = "\xD7";
545
- closeBtn.addEventListener("click", () => {
546
- saveBannerDismissal(banner.id);
547
- this.dismiss();
548
- });
549
- inner.appendChild(closeBtn);
550
- if (config.app_icon && isSafeUrl(config.app_icon)) {
907
+ if (!hideClose) {
908
+ const closeBtn = document.createElement("button");
909
+ closeBtn.className = "tolk-close";
910
+ closeBtn.setAttribute("aria-label", "Dismiss banner");
911
+ closeBtn.textContent = "\xD7";
912
+ closeBtn.addEventListener("click", () => {
913
+ saveBannerDismissal(banner.id);
914
+ this.dismiss();
915
+ });
916
+ inner.appendChild(closeBtn);
917
+ }
918
+ if (!hideIcon && config.app_icon && isSafeUrl(config.app_icon)) {
551
919
  const icon = document.createElement("img");
552
920
  icon.className = "tolk-icon";
553
921
  icon.src = config.app_icon;
@@ -560,7 +928,7 @@ var Banners = class {
560
928
  titleEl.className = "tolk-title";
561
929
  titleEl.textContent = banner.title || config.app_name || "Get the App";
562
930
  textWrap.appendChild(titleEl);
563
- if (banner.body) {
931
+ if (!hideBody && banner.body) {
564
932
  const bodyEl = document.createElement("p");
565
933
  bodyEl.className = "tolk-body";
566
934
  bodyEl.textContent = banner.body;
@@ -574,29 +942,57 @@ var Banners = class {
574
942
  inner.appendChild(cta);
575
943
  container.appendChild(inner);
576
944
  document.head.appendChild(style);
577
- document.body.appendChild(container);
945
+ if (stacked) {
946
+ const anchor = findStackedAnchor(position, options.anchor);
947
+ if (anchor && anchor.parentNode) {
948
+ if (position === "top") {
949
+ anchor.parentNode.insertBefore(container, anchor);
950
+ } else if (anchor.nextSibling) {
951
+ anchor.parentNode.insertBefore(container, anchor.nextSibling);
952
+ } else {
953
+ anchor.parentNode.appendChild(container);
954
+ }
955
+ } else if (document.body) {
956
+ if (position === "top") {
957
+ document.body.insertBefore(container, document.body.firstChild);
958
+ } else {
959
+ document.body.appendChild(container);
960
+ }
961
+ }
962
+ } else {
963
+ document.body.appendChild(container);
964
+ }
578
965
  this.container = container;
579
966
  this.styleEl = style;
580
967
  requestAnimationFrame(() => {
581
968
  requestAnimationFrame(() => {
582
969
  container.classList.add("tolk-visible");
583
- const bannerHeight = container.offsetHeight + "px";
584
- if (position === "top") {
585
- document.body.style.paddingTop = bannerHeight;
586
- } else {
587
- document.body.style.paddingBottom = bannerHeight;
970
+ if (!floating && !stacked) {
971
+ const bannerHeight = container.offsetHeight + "px";
972
+ if (position === "top") {
973
+ document.body.style.paddingTop = bannerHeight;
974
+ } else {
975
+ document.body.style.paddingBottom = bannerHeight;
976
+ }
588
977
  }
589
978
  });
590
979
  });
591
980
  }
592
981
  };
593
- function isSafeUrl(url) {
594
- try {
595
- const parsed = new URL(url, window.location.href);
596
- return parsed.protocol === "http:" || parsed.protocol === "https:";
597
- } catch {
598
- return false;
982
+ function findStackedAnchor(position, explicit) {
983
+ if (explicit) {
984
+ try {
985
+ const el = document.querySelector(explicit);
986
+ if (el) return el;
987
+ } catch {
988
+ }
599
989
  }
990
+ const selectors = position === "top" ? ["header", '[role="banner"]', "nav", ".header", "#header", ".navbar"] : ["footer", '[role="contentinfo"]', ".footer", "#footer"];
991
+ for (const s of selectors) {
992
+ const el = document.body?.querySelector(s);
993
+ if (el) return el;
994
+ }
995
+ return null;
600
996
  }
601
997
 
602
998
  // src/messages.ts
@@ -857,6 +1253,7 @@ var Tolinku = class {
857
1253
  }
858
1254
  this.client = new HttpClient(resolvedConfig);
859
1255
  this.analytics = new Analytics(this.client);
1256
+ this.ecommerce = new Ecommerce(this.client, () => this._userId);
860
1257
  this.referrals = new Referrals(this.client);
861
1258
  this.deferred = new Deferred(this.client);
862
1259
  this.banners = new Banners(this.client);
@@ -894,13 +1291,14 @@ var Tolinku = class {
894
1291
  dismissMessage() {
895
1292
  this.messages.dismiss();
896
1293
  }
897
- /** Flush any queued analytics events immediately */
1294
+ /** Flush any queued analytics and ecommerce events immediately */
898
1295
  async flush() {
899
- return this.analytics.flush();
1296
+ await Promise.all([this.analytics.flush(), this.ecommerce.flush()]);
900
1297
  }
901
1298
  /** Clean up all DOM elements, flush events, and cancel in-flight requests (e.g. before unmounting in SPAs) */
902
1299
  destroy() {
903
1300
  this.analytics.destroy();
1301
+ this.ecommerce.destroy();
904
1302
  this.client.abort();
905
1303
  this.banners.dismiss();
906
1304
  this.messages.dismiss();