@tolinku/web-sdk 0.2.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/README.md CHANGED
@@ -121,12 +121,17 @@ if (link) {
121
121
  console.log(link.deep_link_path); // e.g. "/merchant/xyz"
122
122
  }
123
123
 
124
- // Claim by device signal matching
124
+ // Claim by device signal matching. Timezone, language, screen size and pixel ratio
125
+ // are detected for you.
125
126
  const link = await tolinku.deferred.claimBySignals({
126
- appspaceId: 'your_appspace_id',
127
+ appspaceId: '64f0a1b2c3d4e5f60718',
127
128
  });
128
129
  ```
129
130
 
131
+ `appspaceId` is your Appspace ID, not your subdomain or slug. Copy it from the dashboard
132
+ under **Integrate** or **Settings**. It looks like `64f0a1b2c3d4e5f60718`.
133
+
134
+
130
135
  ### In-App Messages
131
136
 
132
137
  Display visually rich, server-configured messages as modal overlays. Create and manage messages from the Tolinku dashboard without shipping app updates.
package/dist/index.d.mts CHANGED
@@ -98,6 +98,8 @@ interface ClaimBySignalsOptions {
98
98
  language?: string;
99
99
  screenWidth?: number;
100
100
  screenHeight?: number;
101
+ /** Defaults to `window.devicePixelRatio`. Override only if you report a custom screen size. */
102
+ devicePixelRatio?: number;
101
103
  }
102
104
  /** Banner config returned by the API */
103
105
  interface BannerConfig {
@@ -118,13 +120,47 @@ interface BannerItem {
118
120
  text_color: string;
119
121
  cta_text: string | null;
120
122
  position: string | null;
123
+ style: string | null;
124
+ shadow: string | null;
125
+ radius: number | null;
126
+ margin: number | null;
121
127
  dismiss_days: number | null;
122
128
  priority: number;
123
129
  }
124
- /** Options for showing a banner */
130
+ /** Options for showing a banner. All fields are optional.
131
+ * Resolution chain per value: option → server config → theme → built-in default. */
125
132
  interface ShowBannerOptions {
126
133
  position?: 'top' | 'bottom';
134
+ style?: 'pinned' | 'floating' | 'stacked';
135
+ animation?: 'slide' | 'fade' | 'pop' | 'none';
136
+ theme?: 'light' | 'dark';
127
137
  label?: string;
138
+ delay?: number;
139
+ /** Stacked-mode only: CSS selector for the element to inject before (top) or after (bottom).
140
+ * Falls back to auto-detect when omitted. */
141
+ anchor?: string;
142
+ bg?: string;
143
+ border?: string;
144
+ radius?: number;
145
+ margin?: number;
146
+ shadow?: 'none' | 'sm' | 'md' | 'lg';
147
+ titleColor?: string;
148
+ titleSize?: number;
149
+ titleWeight?: 400 | 500 | 600 | 700;
150
+ bodyColor?: string;
151
+ bodySize?: number;
152
+ bodyWeight?: 400 | 500 | 600 | 700;
153
+ ctaBg?: string;
154
+ ctaColor?: string;
155
+ ctaSize?: number;
156
+ ctaWeight?: 400 | 500 | 600 | 700;
157
+ ctaRadius?: number;
158
+ iconSize?: number;
159
+ iconRadius?: number;
160
+ hideIcon?: boolean;
161
+ hideClose?: boolean;
162
+ hideBody?: boolean;
163
+ customClass?: string;
128
164
  }
129
165
  /** In-app message from the API */
130
166
  interface Message {
package/dist/index.d.ts CHANGED
@@ -98,6 +98,8 @@ interface ClaimBySignalsOptions {
98
98
  language?: string;
99
99
  screenWidth?: number;
100
100
  screenHeight?: number;
101
+ /** Defaults to `window.devicePixelRatio`. Override only if you report a custom screen size. */
102
+ devicePixelRatio?: number;
101
103
  }
102
104
  /** Banner config returned by the API */
103
105
  interface BannerConfig {
@@ -118,13 +120,47 @@ interface BannerItem {
118
120
  text_color: string;
119
121
  cta_text: string | null;
120
122
  position: string | null;
123
+ style: string | null;
124
+ shadow: string | null;
125
+ radius: number | null;
126
+ margin: number | null;
121
127
  dismiss_days: number | null;
122
128
  priority: number;
123
129
  }
124
- /** Options for showing a banner */
130
+ /** Options for showing a banner. All fields are optional.
131
+ * Resolution chain per value: option → server config → theme → built-in default. */
125
132
  interface ShowBannerOptions {
126
133
  position?: 'top' | 'bottom';
134
+ style?: 'pinned' | 'floating' | 'stacked';
135
+ animation?: 'slide' | 'fade' | 'pop' | 'none';
136
+ theme?: 'light' | 'dark';
127
137
  label?: string;
138
+ delay?: number;
139
+ /** Stacked-mode only: CSS selector for the element to inject before (top) or after (bottom).
140
+ * Falls back to auto-detect when omitted. */
141
+ anchor?: string;
142
+ bg?: string;
143
+ border?: string;
144
+ radius?: number;
145
+ margin?: number;
146
+ shadow?: 'none' | 'sm' | 'md' | 'lg';
147
+ titleColor?: string;
148
+ titleSize?: number;
149
+ titleWeight?: 400 | 500 | 600 | 700;
150
+ bodyColor?: string;
151
+ bodySize?: number;
152
+ bodyWeight?: 400 | 500 | 600 | 700;
153
+ ctaBg?: string;
154
+ ctaColor?: string;
155
+ ctaSize?: number;
156
+ ctaWeight?: 400 | 500 | 600 | 700;
157
+ ctaRadius?: number;
158
+ iconSize?: number;
159
+ iconRadius?: number;
160
+ hideIcon?: boolean;
161
+ hideClose?: boolean;
162
+ hideBody?: boolean;
163
+ customClass?: string;
128
164
  }
129
165
  /** In-app message from the API */
130
166
  interface Message {
package/dist/index.js CHANGED
@@ -548,9 +548,24 @@ var Deferred = class {
548
548
  timezone: options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
549
549
  language: options.language || navigator.language,
550
550
  screen_width: options.screenWidth || window.screen.width,
551
- 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
552
554
  });
553
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
+ }
554
569
  console.warn("[Tolinku] Failed to claim deferred link by signals:", err);
555
570
  return null;
556
571
  }
@@ -640,11 +655,56 @@ function sanitizeCssColor(value) {
640
655
  }
641
656
 
642
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
+ }
643
700
  var Banners = class {
644
701
  constructor(client) {
645
702
  this.client = client;
646
703
  this.container = null;
647
704
  this.styleEl = null;
705
+ this.currentAnimation = "slide";
706
+ this.currentStyle = "pinned";
707
+ this.currentPosition = "top";
648
708
  }
649
709
  /** Fetch banner config and show the highest-priority banner */
650
710
  async show(options = {}, userId) {
@@ -662,94 +722,200 @@ var Banners = class {
662
722
  }
663
723
  }
664
724
  if (!banner) return;
665
- 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
+ }
666
731
  }
667
- /** Remove the banner from the DOM */
732
+ /** Remove the banner from the DOM, respecting the active animation */
668
733
  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();
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) {
676
745
  this.container = null;
677
746
  this.styleEl = null;
678
- }, 400);
747
+ }
748
+ };
749
+ if (this.currentAnimation === "none") {
750
+ cleanup();
751
+ } else {
752
+ setTimeout(cleanup, 400);
679
753
  }
680
754
  }
681
755
  render(config, banner, options) {
682
756
  if (this.container) {
683
757
  this.container.remove();
684
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;
685
773
  }
686
- const position = options.position || banner.position || "top";
687
- const bgColor = sanitizeCssColor(banner.background_color) || "#ffffff";
688
- 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);
689
816
  const ctaText = banner.cta_text || "Open";
690
- const baseUrl = this.client.baseUrl;
691
- const installUrl = banner.action_url || baseUrl + (config.install_url || "/install");
817
+ const installUrl = banner.action_url || this.client.baseUrl + (config.install_url || "/install");
692
818
  const safeTop = position === "top" ? "padding-top: env(safe-area-inset-top, 0px);" : "";
693
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
+ }
694
850
  const container = document.createElement("div");
695
851
  container.id = "tolinku-banner";
696
852
  container.setAttribute("role", "banner");
697
853
  container.setAttribute("aria-live", "polite");
698
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;` : "";
699
858
  const style = document.createElement("style");
700
859
  style.textContent = `
701
860
  #tolinku-banner {
702
- position: fixed;
703
- ${position}: 0;
704
- left: 0; right: 0;
861
+ ${containerPosition}
862
+ ${containerInsets}
705
863
  z-index: 999999;
706
864
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
707
- transform: translateY(${position === "top" ? "-100%" : "100%"});
708
- transition: transform 0.35s ease;
865
+ ${hiddenCss}
866
+ ${transitionCss}
709
867
  ${safeTop}${safeBottom}
710
868
  }
711
- #tolinku-banner.tolk-visible { transform: translateY(0); }
869
+ #tolinku-banner.tolk-visible { ${visibleCss} }
712
870
  #tolinku-banner .tolk-inner {
713
871
  display: flex; align-items: center; gap: 10px;
714
872
  padding: 10px 14px;
715
- background: ${bgColor}; color: ${textColor};
716
- box-shadow: 0 2px 8px rgba(0,0,0,0.15);
873
+ background: ${bg};
874
+ ${borderCss}
875
+ ${radiusCss}
876
+ box-shadow: ${shadow};
717
877
  }
718
878
  #tolinku-banner .tolk-close {
719
879
  background: none; border: none; font-size: 20px; line-height: 1;
720
- 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;
721
881
  }
722
882
  #tolinku-banner .tolk-close:hover { opacity: 1; }
723
883
  #tolinku-banner .tolk-icon {
724
- 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;
725
886
  }
726
887
  #tolinku-banner .tolk-text { flex: 1; min-width: 0; }
727
888
  #tolinku-banner .tolk-title {
728
- 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;
729
891
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
730
892
  }
731
893
  #tolinku-banner .tolk-body {
732
- 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;
733
896
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
734
897
  }
735
898
  #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;
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;
739
903
  }
740
904
  `;
741
905
  const inner = document.createElement("div");
742
906
  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)) {
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)) {
753
919
  const icon = document.createElement("img");
754
920
  icon.className = "tolk-icon";
755
921
  icon.src = config.app_icon;
@@ -762,7 +928,7 @@ var Banners = class {
762
928
  titleEl.className = "tolk-title";
763
929
  titleEl.textContent = banner.title || config.app_name || "Get the App";
764
930
  textWrap.appendChild(titleEl);
765
- if (banner.body) {
931
+ if (!hideBody && banner.body) {
766
932
  const bodyEl = document.createElement("p");
767
933
  bodyEl.className = "tolk-body";
768
934
  bodyEl.textContent = banner.body;
@@ -776,29 +942,57 @@ var Banners = class {
776
942
  inner.appendChild(cta);
777
943
  container.appendChild(inner);
778
944
  document.head.appendChild(style);
779
- 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
+ }
780
965
  this.container = container;
781
966
  this.styleEl = style;
782
967
  requestAnimationFrame(() => {
783
968
  requestAnimationFrame(() => {
784
969
  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;
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
+ }
790
977
  }
791
978
  });
792
979
  });
793
980
  }
794
981
  };
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;
982
+ function findStackedAnchor(position, explicit) {
983
+ if (explicit) {
984
+ try {
985
+ const el = document.querySelector(explicit);
986
+ if (el) return el;
987
+ } catch {
988
+ }
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;
801
994
  }
995
+ return null;
802
996
  }
803
997
 
804
998
  // src/messages.ts