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