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