@tolinku/web-sdk 0.2.0 → 0.4.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
@@ -169,7 +169,11 @@ var HttpClient = class {
169
169
  }
170
170
  headers() {
171
171
  return {
172
- "X-API-Key": this.apiKey
172
+ "X-API-Key": this.apiKey,
173
+ // Browsers default to "Accept: */*", which a server content-negotiating an
174
+ // error page reads as "HTML is fine". Saying so explicitly keeps an error
175
+ // parseable instead of arriving as a rendered page.
176
+ "Accept": "application/json"
173
177
  };
174
178
  }
175
179
  };
@@ -527,6 +531,7 @@ var Referrals = class {
527
531
  };
528
532
 
529
533
  // src/deferred.ts
534
+ var CLAIMED_KEY = "tolinku_deferred_claimed";
530
535
  var Deferred = class {
531
536
  constructor(client) {
532
537
  this.client = client;
@@ -540,19 +545,85 @@ var Deferred = class {
540
545
  return null;
541
546
  }
542
547
  }
548
+ /**
549
+ * Recover the link that led here, once.
550
+ *
551
+ * There is no Play Install Referrer on the web, so this is signal matching
552
+ * with the bookkeeping that makes calling it safe. That bookkeeping is the
553
+ * point: a claim is consumed the first time it succeeds, so an app calling
554
+ * claimBySignals on every page load asks again after the answer is already
555
+ * spent, and every one of those asks is recorded as a miss. The match rate on
556
+ * the dashboard then falls towards zero while the integration is working
557
+ * correctly, which is a hard thing to diagnose from the outside.
558
+ *
559
+ * Call it once on first run. Calling it again is free after the first.
560
+ *
561
+ * Named to match the Android, React Native and Flutter SDKs, where the same
562
+ * call also tries the install referrer before falling back to here.
563
+ */
564
+ async claimDeferredLink(options) {
565
+ if (!options.appspaceId || !options.appspaceId.trim()) {
566
+ throw new Error("Tolinku: appspaceId is required and must not be blank for claimDeferredLink.");
567
+ }
568
+ if (!options.force && this.alreadyAttempted()) return null;
569
+ const { link, settled } = await this.attemptSignals({ appspaceId: options.appspaceId });
570
+ if (settled) this.rememberAttempt();
571
+ return link;
572
+ }
573
+ alreadyAttempted() {
574
+ try {
575
+ return window.localStorage.getItem(CLAIMED_KEY) !== null;
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+ rememberAttempt() {
581
+ try {
582
+ window.localStorage.setItem(CLAIMED_KEY, (/* @__PURE__ */ new Date()).toISOString());
583
+ } catch {
584
+ }
585
+ }
543
586
  /** Claim a deferred deep link by device signal matching */
544
587
  async claimBySignals(options) {
588
+ return (await this.attemptSignals(options)).link;
589
+ }
590
+ /**
591
+ * The signal claim, with whether the server actually answered.
592
+ *
593
+ * `settled` separates "nothing is waiting for this device", which no amount
594
+ * of asking will change, from "the request never got there". Both surface as
595
+ * null to callers of claimBySignals, but claimDeferredLink has to tell them
596
+ * apart: recording an attempt that never reached the server would spend an
597
+ * install's one chance at attribution on a dropped connection.
598
+ */
599
+ async attemptSignals(options) {
545
600
  try {
546
- return await this.client.postPublic("/v1/api/deferred/claim-by-signals", {
601
+ const link = await this.client.postPublic("/v1/api/deferred/claim-by-signals", {
547
602
  appspace_id: options.appspaceId,
548
603
  timezone: options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
549
604
  language: options.language || navigator.language,
550
605
  screen_width: options.screenWidth || window.screen.width,
551
- screen_height: options.screenHeight || window.screen.height
606
+ screen_height: options.screenHeight || window.screen.height,
607
+ // Separates devices reporting identical logical dimensions.
608
+ device_pixel_ratio: options.devicePixelRatio || window.devicePixelRatio || 1
552
609
  });
610
+ return { link, settled: true };
553
611
  } catch (err) {
612
+ const status = err?.statusCode ?? err?.status;
613
+ if (status === 404) {
614
+ return { link: null, settled: true };
615
+ }
616
+ if (status === 403) {
617
+ console.warn(
618
+ "[Tolinku] Failed to claim deferred link by signals: HTTP 403.",
619
+ "Check that appspaceId is your Appspace ID (copy it from the dashboard",
620
+ "under Settings), not your subdomain or slug.",
621
+ err
622
+ );
623
+ return { link: null, settled: false };
624
+ }
554
625
  console.warn("[Tolinku] Failed to claim deferred link by signals:", err);
555
- return null;
626
+ return { link: null, settled: false };
556
627
  }
557
628
  }
558
629
  };
@@ -640,11 +711,56 @@ function sanitizeCssColor(value) {
640
711
  }
641
712
 
642
713
  // src/banners.ts
714
+ var themes = {
715
+ light: {
716
+ bg: "#ffffff",
717
+ border: "",
718
+ shadow: "sm",
719
+ title: { color: "#000000", size: 14, weight: 600 },
720
+ body: { color: "#000000", size: 12, weight: 400 },
721
+ cta: { size: 13, weight: 600, radius: 100 },
722
+ icon: { size: 40, radius: 10 }
723
+ },
724
+ dark: {
725
+ bg: "#1B1B1B",
726
+ border: "",
727
+ shadow: "sm",
728
+ title: { color: "#ffffff", size: 14, weight: 600 },
729
+ body: { color: "#ffffff", size: 12, weight: 400 },
730
+ cta: { size: 13, weight: 600, radius: 100 },
731
+ icon: { size: 40, radius: 10 }
732
+ }
733
+ };
734
+ var SHADOW_VALUES = {
735
+ none: "none",
736
+ sm: "0 2px 8px rgba(0,0,0,0.15)",
737
+ md: "0 6px 20px rgba(0,0,0,0.18)",
738
+ lg: "0 12px 36px rgba(0,0,0,0.22)"
739
+ };
740
+ function clampInt(val, min, max) {
741
+ if (val === void 0 || val === null || isNaN(val)) return null;
742
+ return Math.max(min, Math.min(max, Math.floor(val)));
743
+ }
744
+ function sanitizeClass(val) {
745
+ if (!val || typeof val !== "string") return "";
746
+ return val.replace(/[^a-zA-Z0-9_\-\s]/g, "").slice(0, 100).trim();
747
+ }
748
+ function isSafeUrl(url) {
749
+ try {
750
+ const parsed = new URL(url, window.location.href);
751
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
752
+ } catch {
753
+ return false;
754
+ }
755
+ }
643
756
  var Banners = class {
644
757
  constructor(client) {
645
758
  this.client = client;
646
759
  this.container = null;
647
760
  this.styleEl = null;
761
+ this.currentAnimation = "slide";
762
+ this.currentStyle = "pinned";
763
+ this.currentPosition = "top";
648
764
  }
649
765
  /** Fetch banner config and show the highest-priority banner */
650
766
  async show(options = {}, userId) {
@@ -662,94 +778,200 @@ var Banners = class {
662
778
  }
663
779
  }
664
780
  if (!banner) return;
665
- this.render(config, banner, options);
781
+ const delay = clampInt(options.delay, 0, 6e4);
782
+ if (delay && delay > 0) {
783
+ setTimeout(() => this.render(config, banner, options), delay);
784
+ } else {
785
+ this.render(config, banner, options);
786
+ }
666
787
  }
667
- /** Remove the banner from the DOM */
788
+ /** Remove the banner from the DOM, respecting the active animation */
668
789
  dismiss() {
669
- if (this.container) {
670
- this.container.classList.remove("tolk-visible");
671
- const pos = this.container.dataset.position || "top";
672
- document.body.style.removeProperty(pos === "top" ? "padding-top" : "padding-bottom");
673
- setTimeout(() => {
674
- this.container?.remove();
675
- this.styleEl?.remove();
790
+ if (!this.container) return;
791
+ const container = this.container;
792
+ const styleEl = this.styleEl;
793
+ container.classList.remove("tolk-visible");
794
+ if (this.currentStyle === "pinned") {
795
+ document.body.style.removeProperty(this.currentPosition === "top" ? "padding-top" : "padding-bottom");
796
+ }
797
+ const cleanup = () => {
798
+ container.remove();
799
+ styleEl?.remove();
800
+ if (this.container === container) {
676
801
  this.container = null;
677
802
  this.styleEl = null;
678
- }, 400);
803
+ }
804
+ };
805
+ if (this.currentAnimation === "none") {
806
+ cleanup();
807
+ } else {
808
+ setTimeout(cleanup, 400);
679
809
  }
680
810
  }
681
811
  render(config, banner, options) {
682
812
  if (this.container) {
683
813
  this.container.remove();
684
814
  this.styleEl?.remove();
815
+ if (this.currentStyle === "pinned") {
816
+ document.body.style.removeProperty(this.currentPosition === "top" ? "padding-top" : "padding-bottom");
817
+ }
685
818
  }
686
- const position = options.position || banner.position || "top";
687
- const bgColor = sanitizeCssColor(banner.background_color) || "#ffffff";
688
- const textColor = sanitizeCssColor(banner.text_color) || "#000000";
819
+ const themeName = options.theme && themes[options.theme] ? options.theme : "light";
820
+ const theme = themes[themeName];
821
+ let position = "top";
822
+ if (options.position === "top" || options.position === "bottom") position = options.position;
823
+ else if (banner.position === "top" || banner.position === "bottom") position = banner.position;
824
+ let resolvedStyle = "pinned";
825
+ if (options.style === "pinned" || options.style === "floating" || options.style === "stacked") {
826
+ resolvedStyle = options.style;
827
+ } else if (banner.style === "pinned" || banner.style === "floating" || banner.style === "stacked") {
828
+ resolvedStyle = banner.style;
829
+ }
830
+ const floating = resolvedStyle === "floating";
831
+ const stacked = resolvedStyle === "stacked";
832
+ let animation = options.animation || "slide";
833
+ if (animation === "pop" && !floating && !stacked) animation = "slide";
834
+ this.currentPosition = position;
835
+ this.currentStyle = resolvedStyle;
836
+ this.currentAnimation = animation;
837
+ const serverBg = sanitizeCssColor(banner.background_color);
838
+ const serverText = sanitizeCssColor(banner.text_color);
839
+ const optBg = sanitizeCssColor(options.bg);
840
+ const optBorder = sanitizeCssColor(options.border);
841
+ const optTitleColor = sanitizeCssColor(options.titleColor);
842
+ const optBodyColor = sanitizeCssColor(options.bodyColor);
843
+ const optCtaBg = sanitizeCssColor(options.ctaBg);
844
+ const optCtaColor = sanitizeCssColor(options.ctaColor);
845
+ const bg = optBg || serverBg || theme.bg;
846
+ const border = optBorder || theme.border;
847
+ const titleColor = optTitleColor || serverText || theme.title.color;
848
+ const bodyColor = optBodyColor || serverText || theme.body.color;
849
+ const ctaBg = optCtaBg || theme.cta.bg || titleColor;
850
+ const ctaColor = optCtaColor || theme.cta.color || bg;
851
+ const titleSize = clampInt(options.titleSize, 10, 24) ?? theme.title.size;
852
+ const titleWeight = options.titleWeight ?? theme.title.weight;
853
+ const bodySize = clampInt(options.bodySize, 10, 20) ?? theme.body.size;
854
+ const bodyWeight = options.bodyWeight ?? theme.body.weight;
855
+ const ctaSize = clampInt(options.ctaSize, 10, 18) ?? theme.cta.size;
856
+ const ctaWeight = options.ctaWeight ?? theme.cta.weight;
857
+ const ctaRadius = clampInt(options.ctaRadius, 0, 100) ?? theme.cta.radius;
858
+ const iconSize = clampInt(options.iconSize, 24, 64) ?? theme.icon.size;
859
+ const iconRadius = clampInt(options.iconRadius, 0, 32) ?? theme.icon.radius;
860
+ const optRadius = clampInt(options.radius, 0, 24);
861
+ const serverRadius = typeof banner.radius === "number" && banner.radius >= 0 && banner.radius <= 24 ? banner.radius : null;
862
+ const optMargin = clampInt(options.margin, 0, 24);
863
+ const serverMargin = typeof banner.margin === "number" && banner.margin >= 0 && banner.margin <= 24 ? banner.margin : null;
864
+ const bannerRadius = floating ? optRadius ?? serverRadius ?? 12 : 0;
865
+ const bannerMargin = floating ? optMargin ?? serverMargin ?? 12 : 0;
866
+ const shadowKey = options.shadow && SHADOW_VALUES[options.shadow] ? options.shadow : banner.shadow && SHADOW_VALUES[banner.shadow] ? banner.shadow : theme.shadow;
867
+ const shadow = SHADOW_VALUES[shadowKey];
868
+ const hideIcon = !!options.hideIcon;
869
+ const hideClose = !!options.hideClose;
870
+ const hideBody = !!options.hideBody;
871
+ const customClass = sanitizeClass(options.customClass);
689
872
  const ctaText = banner.cta_text || "Open";
690
- const baseUrl = this.client.baseUrl;
691
- const installUrl = banner.action_url || baseUrl + (config.install_url || "/install");
873
+ const installUrl = banner.action_url || this.client.baseUrl + (config.install_url || "/install");
692
874
  const safeTop = position === "top" ? "padding-top: env(safe-area-inset-top, 0px);" : "";
693
875
  const safeBottom = position === "bottom" ? "padding-bottom: env(safe-area-inset-bottom, 0px);" : "";
876
+ let containerPosition;
877
+ let containerInsets;
878
+ if (stacked) {
879
+ containerPosition = "position: sticky;";
880
+ containerInsets = `${position}: 0;`;
881
+ } else if (floating) {
882
+ containerPosition = "position: fixed;";
883
+ containerInsets = `left: ${bannerMargin}px; right: ${bannerMargin}px; ${position}: ${bannerMargin}px;`;
884
+ } else {
885
+ containerPosition = "position: fixed;";
886
+ containerInsets = `left: 0; right: 0; ${position}: 0;`;
887
+ }
888
+ const hideOffset = bannerMargin + 60;
889
+ const slideHide = position === "top" ? `translateY(calc(-100% - ${hideOffset}px))` : `translateY(calc(100% + ${hideOffset}px))`;
890
+ let hiddenCss = "";
891
+ let visibleCss = "";
892
+ let transitionCss = "";
893
+ if (animation === "slide") {
894
+ hiddenCss = `transform: ${slideHide};`;
895
+ visibleCss = "transform: translateY(0);";
896
+ transitionCss = "transition: transform 0.35s ease;";
897
+ } else if (animation === "fade") {
898
+ hiddenCss = "opacity: 0;";
899
+ visibleCss = "opacity: 1;";
900
+ transitionCss = "transition: opacity 0.3s ease;";
901
+ } else if (animation === "pop") {
902
+ hiddenCss = "opacity: 0; transform: scale(0.92);";
903
+ visibleCss = "opacity: 1; transform: scale(1);";
904
+ transitionCss = "transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;";
905
+ }
694
906
  const container = document.createElement("div");
695
907
  container.id = "tolinku-banner";
696
908
  container.setAttribute("role", "banner");
697
909
  container.setAttribute("aria-live", "polite");
698
910
  container.dataset.position = position;
911
+ if (customClass) container.className = customClass;
912
+ const borderCss = border ? `border: 1px solid ${border};` : "";
913
+ const radiusCss = bannerRadius > 0 ? `border-radius: ${bannerRadius}px;` : "";
699
914
  const style = document.createElement("style");
700
915
  style.textContent = `
701
916
  #tolinku-banner {
702
- position: fixed;
703
- ${position}: 0;
704
- left: 0; right: 0;
917
+ ${containerPosition}
918
+ ${containerInsets}
705
919
  z-index: 999999;
706
920
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
707
- transform: translateY(${position === "top" ? "-100%" : "100%"});
708
- transition: transform 0.35s ease;
921
+ ${hiddenCss}
922
+ ${transitionCss}
709
923
  ${safeTop}${safeBottom}
710
924
  }
711
- #tolinku-banner.tolk-visible { transform: translateY(0); }
925
+ #tolinku-banner.tolk-visible { ${visibleCss} }
712
926
  #tolinku-banner .tolk-inner {
713
927
  display: flex; align-items: center; gap: 10px;
714
928
  padding: 10px 14px;
715
- background: ${bgColor}; color: ${textColor};
716
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
929
+ background: ${bg};
930
+ ${borderCss}
931
+ ${radiusCss}
932
+ box-shadow: ${shadow};
717
933
  }
718
934
  #tolinku-banner .tolk-close {
719
935
  background: none; border: none; font-size: 20px; line-height: 1;
720
- cursor: pointer; color: ${textColor}; opacity: 0.6; padding: 0 4px; flex-shrink: 0;
936
+ cursor: pointer; color: ${titleColor}; opacity: 0.6; padding: 0 4px; flex-shrink: 0;
721
937
  }
722
938
  #tolinku-banner .tolk-close:hover { opacity: 1; }
723
939
  #tolinku-banner .tolk-icon {
724
- width: 40px; height: 40px; border-radius: 10px; flex-shrink: 0; object-fit: cover;
940
+ width: ${iconSize}px; height: ${iconSize}px; border-radius: ${iconRadius}px;
941
+ flex-shrink: 0; object-fit: cover;
725
942
  }
726
943
  #tolinku-banner .tolk-text { flex: 1; min-width: 0; }
727
944
  #tolinku-banner .tolk-title {
728
- font-size: 14px; font-weight: 600; margin: 0;
945
+ font-size: ${titleSize}px; font-weight: ${titleWeight}; line-height: 1.3;
946
+ color: ${titleColor}; margin: 0;
729
947
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
730
948
  }
731
949
  #tolinku-banner .tolk-body {
732
- font-size: 12px; margin: 0; opacity: 0.75;
950
+ font-size: ${bodySize}px; font-weight: ${bodyWeight}; line-height: 1.3;
951
+ color: ${bodyColor}; margin: 0; opacity: 0.75;
733
952
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
734
953
  }
735
954
  #tolinku-banner .tolk-cta {
736
- display: inline-block; padding: 6px 16px; border-radius: 100px;
737
- font-size: 13px; font-weight: 600; text-decoration: none;
738
- background: ${textColor}; color: ${bgColor}; flex-shrink: 0; text-align: center;
955
+ display: inline-block; padding: 0.5em 1.2em; border-radius: ${ctaRadius}px;
956
+ font-size: ${ctaSize}px; font-weight: ${ctaWeight}; line-height: 1.2;
957
+ text-decoration: none; background: ${ctaBg}; color: ${ctaColor};
958
+ flex-shrink: 0; text-align: center;
739
959
  }
740
960
  `;
741
961
  const inner = document.createElement("div");
742
962
  inner.className = "tolk-inner";
743
- const closeBtn = document.createElement("button");
744
- closeBtn.className = "tolk-close";
745
- closeBtn.setAttribute("aria-label", "Dismiss banner");
746
- closeBtn.textContent = "\xD7";
747
- closeBtn.addEventListener("click", () => {
748
- saveBannerDismissal(banner.id);
749
- this.dismiss();
750
- });
751
- inner.appendChild(closeBtn);
752
- if (config.app_icon && isSafeUrl(config.app_icon)) {
963
+ if (!hideClose) {
964
+ const closeBtn = document.createElement("button");
965
+ closeBtn.className = "tolk-close";
966
+ closeBtn.setAttribute("aria-label", "Dismiss banner");
967
+ closeBtn.textContent = "\xD7";
968
+ closeBtn.addEventListener("click", () => {
969
+ saveBannerDismissal(banner.id);
970
+ this.dismiss();
971
+ });
972
+ inner.appendChild(closeBtn);
973
+ }
974
+ if (!hideIcon && config.app_icon && isSafeUrl(config.app_icon)) {
753
975
  const icon = document.createElement("img");
754
976
  icon.className = "tolk-icon";
755
977
  icon.src = config.app_icon;
@@ -762,7 +984,7 @@ var Banners = class {
762
984
  titleEl.className = "tolk-title";
763
985
  titleEl.textContent = banner.title || config.app_name || "Get the App";
764
986
  textWrap.appendChild(titleEl);
765
- if (banner.body) {
987
+ if (!hideBody && banner.body) {
766
988
  const bodyEl = document.createElement("p");
767
989
  bodyEl.className = "tolk-body";
768
990
  bodyEl.textContent = banner.body;
@@ -776,29 +998,57 @@ var Banners = class {
776
998
  inner.appendChild(cta);
777
999
  container.appendChild(inner);
778
1000
  document.head.appendChild(style);
779
- document.body.appendChild(container);
1001
+ if (stacked) {
1002
+ const anchor = findStackedAnchor(position, options.anchor);
1003
+ if (anchor && anchor.parentNode) {
1004
+ if (position === "top") {
1005
+ anchor.parentNode.insertBefore(container, anchor);
1006
+ } else if (anchor.nextSibling) {
1007
+ anchor.parentNode.insertBefore(container, anchor.nextSibling);
1008
+ } else {
1009
+ anchor.parentNode.appendChild(container);
1010
+ }
1011
+ } else if (document.body) {
1012
+ if (position === "top") {
1013
+ document.body.insertBefore(container, document.body.firstChild);
1014
+ } else {
1015
+ document.body.appendChild(container);
1016
+ }
1017
+ }
1018
+ } else {
1019
+ document.body.appendChild(container);
1020
+ }
780
1021
  this.container = container;
781
1022
  this.styleEl = style;
782
1023
  requestAnimationFrame(() => {
783
1024
  requestAnimationFrame(() => {
784
1025
  container.classList.add("tolk-visible");
785
- const bannerHeight = container.offsetHeight + "px";
786
- if (position === "top") {
787
- document.body.style.paddingTop = bannerHeight;
788
- } else {
789
- document.body.style.paddingBottom = bannerHeight;
1026
+ if (!floating && !stacked) {
1027
+ const bannerHeight = container.offsetHeight + "px";
1028
+ if (position === "top") {
1029
+ document.body.style.paddingTop = bannerHeight;
1030
+ } else {
1031
+ document.body.style.paddingBottom = bannerHeight;
1032
+ }
790
1033
  }
791
1034
  });
792
1035
  });
793
1036
  }
794
1037
  };
795
- function isSafeUrl(url) {
796
- try {
797
- const parsed = new URL(url, window.location.href);
798
- return parsed.protocol === "http:" || parsed.protocol === "https:";
799
- } catch {
800
- return false;
1038
+ function findStackedAnchor(position, explicit) {
1039
+ if (explicit) {
1040
+ try {
1041
+ const el = document.querySelector(explicit);
1042
+ if (el) return el;
1043
+ } catch {
1044
+ }
801
1045
  }
1046
+ const selectors = position === "top" ? ["header", '[role="banner"]', "nav", ".header", "#header", ".navbar"] : ["footer", '[role="contentinfo"]', ".footer", "#footer"];
1047
+ for (const s of selectors) {
1048
+ const el = document.body?.querySelector(s);
1049
+ if (el) return el;
1050
+ }
1051
+ return null;
802
1052
  }
803
1053
 
804
1054
  // src/messages.ts