@adtogether/web-sdk 0.5.0 → 0.5.6

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.5.6
2
+ * **Security**: Build ad-serve URLs with URLSearchParams, discard a response whose ad type differs from the requested type, and stop reporting client-derived country values.
3
+ * **Viewability**: Built-in banner and interstitial components report an impression only after one continuous visible second.
4
+
1
5
  ## 0.5.0
2
6
  * **Sync**: Version parity (0.5.0) across all AdTogether SDKs.
3
7
 
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  This SDK allows web developers (React and Vanilla JS) to easily integrate AdTogether ads into their applications. By displaying ads from other community members, you earn **Ad Credits** that allow your own app's ads to be shown across the AdTogether network.
23
23
 
24
24
  <p align="center">
25
- <img src="https://www.ad-together.org/dashboard.png" width="800" alt="Developer Dashboard">
25
+ <img src="https://www.ad-together.org/dashboard.webp" width="800" alt="Developer Dashboard">
26
26
  </p>
27
27
 
28
28
  ### 🖼️ Visualizing the Experience
@@ -37,6 +37,7 @@ This SDK allows web developers (React and Vanilla JS) to easily integrate AdToge
37
37
  - 🖼️ **Banner Ads** — Native React `AdTogetherBanner` component with automatic impression tracking and dark mode support.
38
38
  - 📺 **Interstitial Ads** — Full-screen `AdTogetherInterstitial` component with a configurable close-button countdown.
39
39
  - 🌑 **Dark Mode Support** — All ad components automatically adapt to system preference, or can be set to `'dark'` / `'light'` explicitly.
40
+ - 👁️ **Viewable Impressions** — Web ads count after at least 50% is visible for one continuous second while the page is in the foreground.
40
41
  - ⚖️ **Fair Exchange** — Automated impression and click tracking ensures fair distribution of ad credits.
41
42
  - 🔌 **Easy Integration** — A single `initialize()` call and one component is all you need to start earning credits and increase conversions.
42
43
  - 🧩 **Vanilla JS Support** — Core logic decoupled for use without React or any framework.
@@ -59,12 +60,12 @@ AdTogether provides an official Model Context Protocol (MCP) server that gives y
59
60
  "mcp-remote@latest",
60
61
  "https://www.ad-together.org/api/mcp",
61
62
  "--header",
62
- "Authorization: Bearer YOUR_APP_ID_HERE"
63
+ "Authorization: Bearer YOUR_FRESH_FIREBASE_ID_TOKEN"
63
64
  ]
64
65
  }
65
66
  }
66
67
  ```
67
- This ensures you have the exact API signatures and security invariants required to integrate AdTogether successfully without hallucinations.
68
+ Use a fresh Firebase ID token from the signed-in account owner; App IDs cannot authenticate MCP requests. This ensures you have the exact API signatures and security invariants required to integrate AdTogether successfully without hallucinations.
68
69
 
69
70
  ---
70
71
 
@@ -93,7 +94,7 @@ AdTogether.initialize({
93
94
 
94
95
  ### Banner Ads
95
96
 
96
- Use the `AdTogetherBanner` component anywhere in your React tree. It fetches an ad, renders it, and automatically tracks impressions when 50% or more of the component is visible.
97
+ Use the `AdTogetherBanner` component anywhere in your React tree. It fetches an ad, renders it, and tracks an impression after at least 50% of the component remains visible for one continuous second while the page is in the foreground.
97
98
 
98
99
  ```jsx
99
100
  import { AdTogetherBanner } from '@adtogether/web-sdk/react';
@@ -46,23 +46,13 @@ var AdTogether = class _AdTogether {
46
46
  }
47
47
  try {
48
48
  const sdk = _AdTogether.shared;
49
- let url = `${sdk.baseUrl}/api/ads/serve?country=global&adUnitId=${adUnitId}&appId=${sdk.appId}`;
50
- if (adType) {
51
- url += `&adType=${adType}`;
52
- }
53
- if (sdk.lastAdId) {
54
- url += `&exclude=${sdk.lastAdId}`;
55
- }
56
- if (sdk.bundleId) {
57
- url += `&bundleId=${sdk.bundleId}`;
58
- }
59
- url += `&allowSelfAds=${sdk.allowSelfAds}`;
60
- if (typeof window !== "undefined") {
61
- url += `&sourceUrl=${encodeURIComponent(window.location.href)}`;
62
- }
49
+ const url = _AdTogether.buildServeUrl(sdk, adUnitId, adType);
63
50
  const response = await fetch(url);
64
51
  if (response.ok) {
65
52
  const ad = await response.json();
53
+ if (adType && ad.adType !== adType) {
54
+ throw new Error("Received an ad with a different type than requested: " + (ad.adType ?? "missing"));
55
+ }
66
56
  sdk.lastAdId = ad.id;
67
57
  return ad;
68
58
  } else if (response.status === 401 || response.status === 403) {
@@ -74,41 +64,37 @@ var AdTogether = class _AdTogether {
74
64
  throw err;
75
65
  }
76
66
  }
77
- static trackImpression(adId, token) {
78
- this.trackEvent("/api/ads/impression", adId, token);
67
+ static buildServeUrl(sdk, adUnitId, adType) {
68
+ const fallbackBaseUrl = typeof window !== "undefined" ? window.location.origin : "https://www.ad-together.org";
69
+ const url = new URL(sdk.baseUrl || fallbackBaseUrl, fallbackBaseUrl);
70
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/api/ads/serve";
71
+ url.search = "";
72
+ url.hash = "";
73
+ url.searchParams.set("adUnitId", adUnitId);
74
+ url.searchParams.set("appId", sdk.appId ?? "");
75
+ url.searchParams.set("allowSelfAds", String(sdk.allowSelfAds));
76
+ url.searchParams.set("platform", "web");
77
+ if (adType) {
78
+ url.searchParams.set("adType", adType);
79
+ }
80
+ if (sdk.lastAdId) {
81
+ url.searchParams.set("exclude", sdk.lastAdId);
82
+ }
83
+ if (sdk.bundleId) {
84
+ url.searchParams.set("bundleId", sdk.bundleId);
85
+ }
86
+ if (typeof window !== "undefined") {
87
+ url.searchParams.set("sourceUrl", window.location.href);
88
+ }
89
+ return url.toString();
90
+ }
91
+ static trackImpression(adId, token, viewability) {
92
+ this.trackEvent("/api/ads/impression", adId, token, viewability);
79
93
  }
80
94
  static trackClick(adId, token) {
81
95
  this.trackEvent("/api/ads/click", adId, token);
82
96
  }
83
- /**
84
- * Detect the user's country code from the browser locale.
85
- * Uses Intl API (most reliable) with navigator.language as fallback.
86
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
87
- */
88
- static detectCountry() {
89
- try {
90
- if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
91
- const resolved = Intl.DateTimeFormat().resolvedOptions();
92
- if (resolved.locale) {
93
- const parts = resolved.locale.split("-");
94
- if (parts.length >= 2) {
95
- const region = parts[parts.length - 1].toUpperCase();
96
- if (region.length === 2) return region;
97
- }
98
- }
99
- }
100
- if (typeof navigator !== "undefined" && navigator.language) {
101
- const parts = navigator.language.split("-");
102
- if (parts.length >= 2) {
103
- const region = parts[parts.length - 1].toUpperCase();
104
- if (region.length === 2) return region;
105
- }
106
- }
107
- } catch (_) {
108
- }
109
- return null;
110
- }
111
- static trackEvent(endpoint, adId, token) {
97
+ static trackEvent(endpoint, adId, token, viewability) {
112
98
  if (!_AdTogether.shared.assertInitialized()) return;
113
99
  fetch(`${_AdTogether.shared.baseUrl}${endpoint}`, {
114
100
  method: "POST",
@@ -120,10 +106,11 @@ var AdTogether = class _AdTogether {
120
106
  token,
121
107
  appId: _AdTogether.shared.appId,
122
108
  ..._AdTogether.shared.bundleId ? { bundleId: _AdTogether.shared.bundleId } : {},
109
+ ...endpoint === "/api/ads/impression" && typeof window !== "undefined" ? { pagePath: window.location.pathname } : {},
110
+ ...endpoint === "/api/ads/impression" && viewability ? viewability : {},
123
111
  // Send platform and environment to match Flutter SDK
124
112
  platform: "web",
125
- environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production",
126
- country: _AdTogether.detectCountry()
113
+ environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production"
127
114
  })
128
115
  }).catch(console.error);
129
116
  }
package/dist/index.d.mts CHANGED
@@ -1,4 +1,8 @@
1
1
  type AdType = 'banner' | 'interstitial';
2
+ interface ViewabilityMetrics {
3
+ visiblePercent: number;
4
+ visibleDurationMs: number;
5
+ }
2
6
  interface AdModel {
3
7
  id: string;
4
8
  title: string;
@@ -31,15 +35,10 @@ declare class AdTogether {
31
35
  assertInitialized(): boolean;
32
36
  private lastAdId?;
33
37
  static fetchAd(adUnitId?: string, adType?: AdType): Promise<AdModel>;
34
- static trackImpression(adId: string, token?: string): void;
38
+ private static buildServeUrl;
39
+ static trackImpression(adId: string, token?: string, viewability?: ViewabilityMetrics): void;
35
40
  static trackClick(adId: string, token?: string): void;
36
- /**
37
- * Detect the user's country code from the browser locale.
38
- * Uses Intl API (most reliable) with navigator.language as fallback.
39
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
40
- */
41
- private static detectCountry;
42
41
  private static trackEvent;
43
42
  }
44
43
 
45
- export { type AdModel, AdTogether, type AdTogetherOptions, type AdType };
44
+ export { type AdModel, AdTogether, type AdTogetherOptions, type AdType, type ViewabilityMetrics };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  type AdType = 'banner' | 'interstitial';
2
+ interface ViewabilityMetrics {
3
+ visiblePercent: number;
4
+ visibleDurationMs: number;
5
+ }
2
6
  interface AdModel {
3
7
  id: string;
4
8
  title: string;
@@ -31,15 +35,10 @@ declare class AdTogether {
31
35
  assertInitialized(): boolean;
32
36
  private lastAdId?;
33
37
  static fetchAd(adUnitId?: string, adType?: AdType): Promise<AdModel>;
34
- static trackImpression(adId: string, token?: string): void;
38
+ private static buildServeUrl;
39
+ static trackImpression(adId: string, token?: string, viewability?: ViewabilityMetrics): void;
35
40
  static trackClick(adId: string, token?: string): void;
36
- /**
37
- * Detect the user's country code from the browser locale.
38
- * Uses Intl API (most reliable) with navigator.language as fallback.
39
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
40
- */
41
- private static detectCountry;
42
41
  private static trackEvent;
43
42
  }
44
43
 
45
- export { type AdModel, AdTogether, type AdTogetherOptions, type AdType };
44
+ export { type AdModel, AdTogether, type AdTogetherOptions, type AdType, type ViewabilityMetrics };
package/dist/index.js CHANGED
@@ -72,23 +72,13 @@ var AdTogether = class _AdTogether {
72
72
  }
73
73
  try {
74
74
  const sdk = _AdTogether.shared;
75
- let url = `${sdk.baseUrl}/api/ads/serve?country=global&adUnitId=${adUnitId}&appId=${sdk.appId}`;
76
- if (adType) {
77
- url += `&adType=${adType}`;
78
- }
79
- if (sdk.lastAdId) {
80
- url += `&exclude=${sdk.lastAdId}`;
81
- }
82
- if (sdk.bundleId) {
83
- url += `&bundleId=${sdk.bundleId}`;
84
- }
85
- url += `&allowSelfAds=${sdk.allowSelfAds}`;
86
- if (typeof window !== "undefined") {
87
- url += `&sourceUrl=${encodeURIComponent(window.location.href)}`;
88
- }
75
+ const url = _AdTogether.buildServeUrl(sdk, adUnitId, adType);
89
76
  const response = await fetch(url);
90
77
  if (response.ok) {
91
78
  const ad = await response.json();
79
+ if (adType && ad.adType !== adType) {
80
+ throw new Error("Received an ad with a different type than requested: " + (ad.adType ?? "missing"));
81
+ }
92
82
  sdk.lastAdId = ad.id;
93
83
  return ad;
94
84
  } else if (response.status === 401 || response.status === 403) {
@@ -100,41 +90,37 @@ var AdTogether = class _AdTogether {
100
90
  throw err;
101
91
  }
102
92
  }
103
- static trackImpression(adId, token) {
104
- this.trackEvent("/api/ads/impression", adId, token);
93
+ static buildServeUrl(sdk, adUnitId, adType) {
94
+ const fallbackBaseUrl = typeof window !== "undefined" ? window.location.origin : "https://www.ad-together.org";
95
+ const url = new URL(sdk.baseUrl || fallbackBaseUrl, fallbackBaseUrl);
96
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/api/ads/serve";
97
+ url.search = "";
98
+ url.hash = "";
99
+ url.searchParams.set("adUnitId", adUnitId);
100
+ url.searchParams.set("appId", sdk.appId ?? "");
101
+ url.searchParams.set("allowSelfAds", String(sdk.allowSelfAds));
102
+ url.searchParams.set("platform", "web");
103
+ if (adType) {
104
+ url.searchParams.set("adType", adType);
105
+ }
106
+ if (sdk.lastAdId) {
107
+ url.searchParams.set("exclude", sdk.lastAdId);
108
+ }
109
+ if (sdk.bundleId) {
110
+ url.searchParams.set("bundleId", sdk.bundleId);
111
+ }
112
+ if (typeof window !== "undefined") {
113
+ url.searchParams.set("sourceUrl", window.location.href);
114
+ }
115
+ return url.toString();
116
+ }
117
+ static trackImpression(adId, token, viewability) {
118
+ this.trackEvent("/api/ads/impression", adId, token, viewability);
105
119
  }
106
120
  static trackClick(adId, token) {
107
121
  this.trackEvent("/api/ads/click", adId, token);
108
122
  }
109
- /**
110
- * Detect the user's country code from the browser locale.
111
- * Uses Intl API (most reliable) with navigator.language as fallback.
112
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
113
- */
114
- static detectCountry() {
115
- try {
116
- if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
117
- const resolved = Intl.DateTimeFormat().resolvedOptions();
118
- if (resolved.locale) {
119
- const parts = resolved.locale.split("-");
120
- if (parts.length >= 2) {
121
- const region = parts[parts.length - 1].toUpperCase();
122
- if (region.length === 2) return region;
123
- }
124
- }
125
- }
126
- if (typeof navigator !== "undefined" && navigator.language) {
127
- const parts = navigator.language.split("-");
128
- if (parts.length >= 2) {
129
- const region = parts[parts.length - 1].toUpperCase();
130
- if (region.length === 2) return region;
131
- }
132
- }
133
- } catch (_) {
134
- }
135
- return null;
136
- }
137
- static trackEvent(endpoint, adId, token) {
123
+ static trackEvent(endpoint, adId, token, viewability) {
138
124
  if (!_AdTogether.shared.assertInitialized()) return;
139
125
  fetch(`${_AdTogether.shared.baseUrl}${endpoint}`, {
140
126
  method: "POST",
@@ -146,10 +132,11 @@ var AdTogether = class _AdTogether {
146
132
  token,
147
133
  appId: _AdTogether.shared.appId,
148
134
  ..._AdTogether.shared.bundleId ? { bundleId: _AdTogether.shared.bundleId } : {},
135
+ ...endpoint === "/api/ads/impression" && typeof window !== "undefined" ? { pagePath: window.location.pathname } : {},
136
+ ...endpoint === "/api/ads/impression" && viewability ? viewability : {},
149
137
  // Send platform and environment to match Flutter SDK
150
138
  platform: "web",
151
- environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production",
152
- country: _AdTogether.detectCountry()
139
+ environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production"
153
140
  })
154
141
  }).catch(console.error);
155
142
  }
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  AdTogether
3
- } from "./chunk-5UAFUJUK.mjs";
3
+ } from "./chunk-NXWVGW5H.mjs";
4
4
  export {
5
5
  AdTogether
6
6
  };
@@ -76,23 +76,13 @@ var AdTogether = class _AdTogether {
76
76
  }
77
77
  try {
78
78
  const sdk = _AdTogether.shared;
79
- let url = `${sdk.baseUrl}/api/ads/serve?country=global&adUnitId=${adUnitId}&appId=${sdk.appId}`;
80
- if (adType) {
81
- url += `&adType=${adType}`;
82
- }
83
- if (sdk.lastAdId) {
84
- url += `&exclude=${sdk.lastAdId}`;
85
- }
86
- if (sdk.bundleId) {
87
- url += `&bundleId=${sdk.bundleId}`;
88
- }
89
- url += `&allowSelfAds=${sdk.allowSelfAds}`;
90
- if (typeof window !== "undefined") {
91
- url += `&sourceUrl=${encodeURIComponent(window.location.href)}`;
92
- }
79
+ const url = _AdTogether.buildServeUrl(sdk, adUnitId, adType);
93
80
  const response = await fetch(url);
94
81
  if (response.ok) {
95
82
  const ad = await response.json();
83
+ if (adType && ad.adType !== adType) {
84
+ throw new Error("Received an ad with a different type than requested: " + (ad.adType ?? "missing"));
85
+ }
96
86
  sdk.lastAdId = ad.id;
97
87
  return ad;
98
88
  } else if (response.status === 401 || response.status === 403) {
@@ -104,41 +94,37 @@ var AdTogether = class _AdTogether {
104
94
  throw err;
105
95
  }
106
96
  }
107
- static trackImpression(adId, token) {
108
- this.trackEvent("/api/ads/impression", adId, token);
97
+ static buildServeUrl(sdk, adUnitId, adType) {
98
+ const fallbackBaseUrl = typeof window !== "undefined" ? window.location.origin : "https://www.ad-together.org";
99
+ const url = new URL(sdk.baseUrl || fallbackBaseUrl, fallbackBaseUrl);
100
+ url.pathname = url.pathname.replace(/\/+$/, "") + "/api/ads/serve";
101
+ url.search = "";
102
+ url.hash = "";
103
+ url.searchParams.set("adUnitId", adUnitId);
104
+ url.searchParams.set("appId", sdk.appId ?? "");
105
+ url.searchParams.set("allowSelfAds", String(sdk.allowSelfAds));
106
+ url.searchParams.set("platform", "web");
107
+ if (adType) {
108
+ url.searchParams.set("adType", adType);
109
+ }
110
+ if (sdk.lastAdId) {
111
+ url.searchParams.set("exclude", sdk.lastAdId);
112
+ }
113
+ if (sdk.bundleId) {
114
+ url.searchParams.set("bundleId", sdk.bundleId);
115
+ }
116
+ if (typeof window !== "undefined") {
117
+ url.searchParams.set("sourceUrl", window.location.href);
118
+ }
119
+ return url.toString();
120
+ }
121
+ static trackImpression(adId, token, viewability) {
122
+ this.trackEvent("/api/ads/impression", adId, token, viewability);
109
123
  }
110
124
  static trackClick(adId, token) {
111
125
  this.trackEvent("/api/ads/click", adId, token);
112
126
  }
113
- /**
114
- * Detect the user's country code from the browser locale.
115
- * Uses Intl API (most reliable) with navigator.language as fallback.
116
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
117
- */
118
- static detectCountry() {
119
- try {
120
- if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
121
- const resolved = Intl.DateTimeFormat().resolvedOptions();
122
- if (resolved.locale) {
123
- const parts = resolved.locale.split("-");
124
- if (parts.length >= 2) {
125
- const region = parts[parts.length - 1].toUpperCase();
126
- if (region.length === 2) return region;
127
- }
128
- }
129
- }
130
- if (typeof navigator !== "undefined" && navigator.language) {
131
- const parts = navigator.language.split("-");
132
- if (parts.length >= 2) {
133
- const region = parts[parts.length - 1].toUpperCase();
134
- if (region.length === 2) return region;
135
- }
136
- }
137
- } catch (_) {
138
- }
139
- return null;
140
- }
141
- static trackEvent(endpoint, adId, token) {
127
+ static trackEvent(endpoint, adId, token, viewability) {
142
128
  if (!_AdTogether.shared.assertInitialized()) return;
143
129
  fetch(`${_AdTogether.shared.baseUrl}${endpoint}`, {
144
130
  method: "POST",
@@ -150,10 +136,11 @@ var AdTogether = class _AdTogether {
150
136
  token,
151
137
  appId: _AdTogether.shared.appId,
152
138
  ..._AdTogether.shared.bundleId ? { bundleId: _AdTogether.shared.bundleId } : {},
139
+ ...endpoint === "/api/ads/impression" && typeof window !== "undefined" ? { pagePath: window.location.pathname } : {},
140
+ ...endpoint === "/api/ads/impression" && viewability ? viewability : {},
153
141
  // Send platform and environment to match Flutter SDK
154
142
  platform: "web",
155
- environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production",
156
- country: _AdTogether.detectCountry()
143
+ environment: typeof process !== "undefined" && process.env?.NODE_ENV === "development" ? "development" : "production"
157
144
  })
158
145
  }).catch(console.error);
159
146
  }
@@ -212,22 +199,59 @@ var AdTogetherBanner = ({
212
199
  };
213
200
  }, [adUnitId, onAdLoaded, onAdFailedToLoad]);
214
201
  (0, import_react.useEffect)(() => {
215
- if (!adData || isLoading || hasError || !containerRef.current) return;
202
+ if (!adData || isLoading || hasError || !isVisible || !containerRef.current) return;
203
+ let visibleTimer = null;
204
+ let visibleSince = null;
205
+ let latestVisibleRatio = 0;
206
+ const clearVisibleTimer = () => {
207
+ if (visibleTimer) clearTimeout(visibleTimer);
208
+ visibleTimer = null;
209
+ visibleSince = null;
210
+ };
211
+ const startVisibleTimer = () => {
212
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== "visible") return;
213
+ visibleSince = Date.now();
214
+ visibleTimer = setTimeout(() => {
215
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
216
+ const visiblePercent = Math.round(latestVisibleRatio * 100);
217
+ visibleTimer = null;
218
+ visibleSince = null;
219
+ if (impressionTrackedRef.current || !isVisible || !container.isConnected || document.visibilityState !== "visible" || visiblePercent < 50 || visibleDurationMs < 1e3) return;
220
+ impressionTrackedRef.current = true;
221
+ AdTogether.trackImpression(adData.id, adData.token, { visiblePercent, visibleDurationMs });
222
+ observer.disconnect();
223
+ }, 1e3);
224
+ };
216
225
  const observer = new IntersectionObserver(
217
226
  (entries) => {
218
- if (entries[0].isIntersecting && entries[0].intersectionRatio >= 0.5 && !impressionTrackedRef.current) {
219
- impressionTrackedRef.current = true;
220
- AdTogether.trackImpression(adData.id, adData.token);
221
- observer.disconnect();
227
+ const entry = entries[0];
228
+ if (!entry) return;
229
+ latestVisibleRatio = entry.intersectionRatio;
230
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && document.visibilityState === "visible") {
231
+ startVisibleTimer();
232
+ } else {
233
+ clearVisibleTimer();
222
234
  }
223
235
  },
224
- { threshold: 0.5 }
236
+ { threshold: [0, 0.5] }
225
237
  );
226
- observer.observe(containerRef.current);
238
+ const container = containerRef.current;
239
+ const handleVisibilityChange = () => {
240
+ clearVisibleTimer();
241
+ if (document.visibilityState === "visible") {
242
+ latestVisibleRatio = 0;
243
+ observer.unobserve(container);
244
+ observer.observe(container);
245
+ }
246
+ };
247
+ document.addEventListener("visibilitychange", handleVisibilityChange);
248
+ observer.observe(container);
227
249
  return () => {
250
+ clearVisibleTimer();
251
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
228
252
  observer.disconnect();
229
253
  };
230
- }, [adData, isLoading, hasError]);
254
+ }, [adData, isLoading, hasError, isVisible]);
231
255
  const handleContainerClick = () => {
232
256
  if (!adData) return;
233
257
  AdTogether.trackClick(adData.id, adData.token);
@@ -429,8 +453,38 @@ var AdTogetherInterstitial = ({
429
453
  }, [isOpen, adData, closeDelay]);
430
454
  (0, import_react2.useEffect)(() => {
431
455
  if (!adData || !isOpen || impressionTrackedRef.current) return;
432
- impressionTrackedRef.current = true;
433
- AdTogether.trackImpression(adData.id, adData.token);
456
+ let visibleTimer = null;
457
+ let visibleSince = null;
458
+ const clearVisibleTimer = () => {
459
+ if (visibleTimer) clearTimeout(visibleTimer);
460
+ visibleTimer = null;
461
+ visibleSince = null;
462
+ };
463
+ const startVisibleTimer = () => {
464
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== "visible") return;
465
+ visibleSince = Date.now();
466
+ visibleTimer = setTimeout(() => {
467
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
468
+ visibleTimer = null;
469
+ visibleSince = null;
470
+ if (impressionTrackedRef.current || document.visibilityState !== "visible" || visibleDurationMs < 1e3) return;
471
+ impressionTrackedRef.current = true;
472
+ AdTogether.trackImpression(adData.id, adData.token, {
473
+ visiblePercent: 100,
474
+ visibleDurationMs
475
+ });
476
+ }, 1e3);
477
+ };
478
+ const handleVisibilityChange = () => {
479
+ if (document.visibilityState === "visible") startVisibleTimer();
480
+ else clearVisibleTimer();
481
+ };
482
+ document.addEventListener("visibilitychange", handleVisibilityChange);
483
+ startVisibleTimer();
484
+ return () => {
485
+ clearVisibleTimer();
486
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
487
+ };
434
488
  }, [adData, isOpen]);
435
489
  const handleAdClick = (0, import_react2.useCallback)(() => {
436
490
  if (!adData) return;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  AdTogether
3
- } from "../chunk-5UAFUJUK.mjs";
3
+ } from "../chunk-NXWVGW5H.mjs";
4
4
 
5
5
  // src/react/AdTogetherBanner.tsx
6
6
  import { useEffect, useRef, useState } from "react";
@@ -56,22 +56,59 @@ var AdTogetherBanner = ({
56
56
  };
57
57
  }, [adUnitId, onAdLoaded, onAdFailedToLoad]);
58
58
  useEffect(() => {
59
- if (!adData || isLoading || hasError || !containerRef.current) return;
59
+ if (!adData || isLoading || hasError || !isVisible || !containerRef.current) return;
60
+ let visibleTimer = null;
61
+ let visibleSince = null;
62
+ let latestVisibleRatio = 0;
63
+ const clearVisibleTimer = () => {
64
+ if (visibleTimer) clearTimeout(visibleTimer);
65
+ visibleTimer = null;
66
+ visibleSince = null;
67
+ };
68
+ const startVisibleTimer = () => {
69
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== "visible") return;
70
+ visibleSince = Date.now();
71
+ visibleTimer = setTimeout(() => {
72
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
73
+ const visiblePercent = Math.round(latestVisibleRatio * 100);
74
+ visibleTimer = null;
75
+ visibleSince = null;
76
+ if (impressionTrackedRef.current || !isVisible || !container.isConnected || document.visibilityState !== "visible" || visiblePercent < 50 || visibleDurationMs < 1e3) return;
77
+ impressionTrackedRef.current = true;
78
+ AdTogether.trackImpression(adData.id, adData.token, { visiblePercent, visibleDurationMs });
79
+ observer.disconnect();
80
+ }, 1e3);
81
+ };
60
82
  const observer = new IntersectionObserver(
61
83
  (entries) => {
62
- if (entries[0].isIntersecting && entries[0].intersectionRatio >= 0.5 && !impressionTrackedRef.current) {
63
- impressionTrackedRef.current = true;
64
- AdTogether.trackImpression(adData.id, adData.token);
65
- observer.disconnect();
84
+ const entry = entries[0];
85
+ if (!entry) return;
86
+ latestVisibleRatio = entry.intersectionRatio;
87
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && document.visibilityState === "visible") {
88
+ startVisibleTimer();
89
+ } else {
90
+ clearVisibleTimer();
66
91
  }
67
92
  },
68
- { threshold: 0.5 }
93
+ { threshold: [0, 0.5] }
69
94
  );
70
- observer.observe(containerRef.current);
95
+ const container = containerRef.current;
96
+ const handleVisibilityChange = () => {
97
+ clearVisibleTimer();
98
+ if (document.visibilityState === "visible") {
99
+ latestVisibleRatio = 0;
100
+ observer.unobserve(container);
101
+ observer.observe(container);
102
+ }
103
+ };
104
+ document.addEventListener("visibilitychange", handleVisibilityChange);
105
+ observer.observe(container);
71
106
  return () => {
107
+ clearVisibleTimer();
108
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
72
109
  observer.disconnect();
73
110
  };
74
- }, [adData, isLoading, hasError]);
111
+ }, [adData, isLoading, hasError, isVisible]);
75
112
  const handleContainerClick = () => {
76
113
  if (!adData) return;
77
114
  AdTogether.trackClick(adData.id, adData.token);
@@ -273,8 +310,38 @@ var AdTogetherInterstitial = ({
273
310
  }, [isOpen, adData, closeDelay]);
274
311
  useEffect2(() => {
275
312
  if (!adData || !isOpen || impressionTrackedRef.current) return;
276
- impressionTrackedRef.current = true;
277
- AdTogether.trackImpression(adData.id, adData.token);
313
+ let visibleTimer = null;
314
+ let visibleSince = null;
315
+ const clearVisibleTimer = () => {
316
+ if (visibleTimer) clearTimeout(visibleTimer);
317
+ visibleTimer = null;
318
+ visibleSince = null;
319
+ };
320
+ const startVisibleTimer = () => {
321
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== "visible") return;
322
+ visibleSince = Date.now();
323
+ visibleTimer = setTimeout(() => {
324
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
325
+ visibleTimer = null;
326
+ visibleSince = null;
327
+ if (impressionTrackedRef.current || document.visibilityState !== "visible" || visibleDurationMs < 1e3) return;
328
+ impressionTrackedRef.current = true;
329
+ AdTogether.trackImpression(adData.id, adData.token, {
330
+ visiblePercent: 100,
331
+ visibleDurationMs
332
+ });
333
+ }, 1e3);
334
+ };
335
+ const handleVisibilityChange = () => {
336
+ if (document.visibilityState === "visible") startVisibleTimer();
337
+ else clearVisibleTimer();
338
+ };
339
+ document.addEventListener("visibilitychange", handleVisibilityChange);
340
+ startVisibleTimer();
341
+ return () => {
342
+ clearVisibleTimer();
343
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
344
+ };
278
345
  }, [adData, isOpen]);
279
346
  const handleAdClick = useCallback(() => {
280
347
  if (!adData) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adtogether/web-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.5.6",
4
4
  "description": "The official AdTogether Web SDK — reciprocal ad exchange to increase conversions and grow your audience.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { AdModel, AdType, AdTogetherOptions } from './types';
1
+ import { AdModel, AdType, AdTogetherOptions, ViewabilityMetrics } from './types';
2
2
 
3
3
  export class AdTogether {
4
4
  private static instance: AdTogether;
@@ -63,26 +63,14 @@ export class AdTogether {
63
63
 
64
64
  try {
65
65
  const sdk = AdTogether.shared;
66
- let url = `${sdk.baseUrl}/api/ads/serve?country=global&adUnitId=${adUnitId}&appId=${sdk.appId}`;
67
- if (adType) {
68
- url += `&adType=${adType}`;
69
- }
70
- if (sdk.lastAdId) {
71
- url += `&exclude=${sdk.lastAdId}`;
72
- }
73
- if (sdk.bundleId) {
74
- url += `&bundleId=${sdk.bundleId}`;
75
- }
76
-
77
- // Pass allowSelfAds and source URL for smart serving
78
- url += `&allowSelfAds=${sdk.allowSelfAds}`;
79
- if (typeof window !== 'undefined') {
80
- url += `&sourceUrl=${encodeURIComponent(window.location.href)}`;
81
- }
66
+ const url = AdTogether.buildServeUrl(sdk, adUnitId, adType);
82
67
 
83
68
  const response = await fetch(url);
84
69
  if (response.ok) {
85
- const ad = await response.json();
70
+ const ad = await response.json() as AdModel;
71
+ if (adType && ad.adType !== adType) {
72
+ throw new Error('Received an ad with a different type than requested: ' + (ad.adType ?? 'missing'));
73
+ }
86
74
  sdk.lastAdId = ad.id;
87
75
  return ad;
88
76
  } else if (response.status === 401 || response.status === 403) {
@@ -96,46 +84,44 @@ export class AdTogether {
96
84
  }
97
85
  }
98
86
 
99
- static trackImpression(adId: string, token?: string) {
100
- this.trackEvent('/api/ads/impression', adId, token);
87
+ private static buildServeUrl(sdk: AdTogether, adUnitId: string, adType?: AdType): string {
88
+ const fallbackBaseUrl = typeof window !== 'undefined'
89
+ ? window.location.origin
90
+ : 'https://www.ad-together.org';
91
+ const url = new URL(sdk.baseUrl || fallbackBaseUrl, fallbackBaseUrl);
92
+ url.pathname = url.pathname.replace(/\/+$/, '') + '/api/ads/serve';
93
+ url.search = '';
94
+ url.hash = '';
95
+
96
+ url.searchParams.set('adUnitId', adUnitId);
97
+ url.searchParams.set('appId', sdk.appId ?? '');
98
+ url.searchParams.set('allowSelfAds', String(sdk.allowSelfAds));
99
+ url.searchParams.set('platform', 'web');
100
+ if (adType) {
101
+ url.searchParams.set('adType', adType);
102
+ }
103
+ if (sdk.lastAdId) {
104
+ url.searchParams.set('exclude', sdk.lastAdId);
105
+ }
106
+ if (sdk.bundleId) {
107
+ url.searchParams.set('bundleId', sdk.bundleId);
108
+ }
109
+ if (typeof window !== 'undefined') {
110
+ url.searchParams.set('sourceUrl', window.location.href);
111
+ }
112
+
113
+ return url.toString();
114
+ }
115
+
116
+ static trackImpression(adId: string, token?: string, viewability?: ViewabilityMetrics) {
117
+ this.trackEvent('/api/ads/impression', adId, token, viewability);
101
118
  }
102
119
 
103
120
  static trackClick(adId: string, token?: string) {
104
121
  this.trackEvent('/api/ads/click', adId, token);
105
122
  }
106
123
 
107
- /**
108
- * Detect the user's country code from the browser locale.
109
- * Uses Intl API (most reliable) with navigator.language as fallback.
110
- * Returns an ISO 3166-1 alpha-2 code like "US", "DE", "JP", or null.
111
- */
112
- private static detectCountry(): string | null {
113
- try {
114
- // Intl API gives the most reliable region
115
- if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
116
- const resolved = Intl.DateTimeFormat().resolvedOptions();
117
- // timeZone is like "America/New_York" — we can map it, but locale is simpler
118
- if (resolved.locale) {
119
- const parts = resolved.locale.split('-');
120
- if (parts.length >= 2) {
121
- const region = parts[parts.length - 1].toUpperCase();
122
- if (region.length === 2) return region;
123
- }
124
- }
125
- }
126
- // Fallback: navigator.language (e.g. "en-US" -> "US")
127
- if (typeof navigator !== 'undefined' && navigator.language) {
128
- const parts = navigator.language.split('-');
129
- if (parts.length >= 2) {
130
- const region = parts[parts.length - 1].toUpperCase();
131
- if (region.length === 2) return region;
132
- }
133
- }
134
- } catch (_) {}
135
- return null;
136
- }
137
-
138
- private static trackEvent(endpoint: string, adId: string, token?: string) {
124
+ private static trackEvent(endpoint: string, adId: string, token?: string, viewability?: ViewabilityMetrics) {
139
125
  if (!AdTogether.shared.assertInitialized()) return;
140
126
 
141
127
  fetch(`${AdTogether.shared.baseUrl}${endpoint}`, {
@@ -148,10 +134,13 @@ export class AdTogether {
148
134
  token,
149
135
  appId: AdTogether.shared.appId,
150
136
  ...(AdTogether.shared.bundleId ? { bundleId: AdTogether.shared.bundleId } : {}),
137
+ ...(endpoint === '/api/ads/impression' && typeof window !== 'undefined'
138
+ ? { pagePath: window.location.pathname }
139
+ : {}),
140
+ ...(endpoint === '/api/ads/impression' && viewability ? viewability : {}),
151
141
  // Send platform and environment to match Flutter SDK
152
142
  platform: 'web',
153
143
  environment: typeof process !== 'undefined' && process.env?.NODE_ENV === 'development' ? 'development' : 'production',
154
- country: AdTogether.detectCountry(),
155
144
  }),
156
145
  }).catch(console.error);
157
146
  }
package/src/core/types.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export type AdType = 'banner' | 'interstitial';
2
2
 
3
+ export interface ViewabilityMetrics {
4
+ visiblePercent: number;
5
+ visibleDurationMs: number;
6
+ }
7
+
3
8
  export interface AdModel {
4
9
  id: string;
5
10
  title: string;
@@ -83,25 +83,77 @@ export const AdTogetherBanner: React.FC<AdTogetherBannerProps> = ({
83
83
 
84
84
  // Impression tracking
85
85
  useEffect(() => {
86
- if (!adData || isLoading || hasError || !containerRef.current) return;
86
+ if (!adData || isLoading || hasError || !isVisible || !containerRef.current) return;
87
+
88
+ let visibleTimer: ReturnType<typeof setTimeout> | null = null;
89
+ let visibleSince: number | null = null;
90
+ let latestVisibleRatio = 0;
91
+
92
+ const clearVisibleTimer = () => {
93
+ if (visibleTimer) clearTimeout(visibleTimer);
94
+ visibleTimer = null;
95
+ visibleSince = null;
96
+ };
97
+
98
+ const startVisibleTimer = () => {
99
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== 'visible') return;
100
+
101
+ visibleSince = Date.now();
102
+ visibleTimer = setTimeout(() => {
103
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
104
+ const visiblePercent = Math.round(latestVisibleRatio * 100);
105
+ visibleTimer = null;
106
+ visibleSince = null;
107
+
108
+ if (
109
+ impressionTrackedRef.current ||
110
+ !isVisible ||
111
+ !container.isConnected ||
112
+ document.visibilityState !== 'visible' ||
113
+ visiblePercent < 50 ||
114
+ visibleDurationMs < 1000
115
+ ) return;
116
+
117
+ impressionTrackedRef.current = true;
118
+ AdTogether.trackImpression(adData.id, adData.token, { visiblePercent, visibleDurationMs });
119
+ observer.disconnect();
120
+ }, 1000);
121
+ };
87
122
 
88
123
  const observer = new IntersectionObserver(
89
124
  (entries) => {
90
- if (entries[0].isIntersecting && entries[0].intersectionRatio >= 0.5 && !impressionTrackedRef.current) {
91
- impressionTrackedRef.current = true;
92
- AdTogether.trackImpression(adData.id, adData.token);
93
- observer.disconnect();
125
+ const entry = entries[0];
126
+ if (!entry) return;
127
+
128
+ latestVisibleRatio = entry.intersectionRatio;
129
+ if (entry.isIntersecting && entry.intersectionRatio >= 0.5 && document.visibilityState === 'visible') {
130
+ startVisibleTimer();
131
+ } else {
132
+ clearVisibleTimer();
94
133
  }
95
134
  },
96
- { threshold: 0.5 }
135
+ { threshold: [0, 0.5] }
97
136
  );
98
137
 
99
- observer.observe(containerRef.current);
138
+ const container = containerRef.current;
139
+ const handleVisibilityChange = () => {
140
+ clearVisibleTimer();
141
+ if (document.visibilityState === 'visible') {
142
+ latestVisibleRatio = 0;
143
+ observer.unobserve(container);
144
+ observer.observe(container);
145
+ }
146
+ };
147
+
148
+ document.addEventListener('visibilitychange', handleVisibilityChange);
149
+ observer.observe(container);
100
150
 
101
151
  return () => {
152
+ clearVisibleTimer();
153
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
102
154
  observer.disconnect();
103
155
  };
104
- }, [adData, isLoading, hasError]);
156
+ }, [adData, isLoading, hasError, isVisible]);
105
157
 
106
158
  const handleContainerClick = () => {
107
159
  if (!adData) return;
@@ -120,8 +120,46 @@ export const AdTogetherInterstitial: React.FC<AdTogetherInterstitialProps> = ({
120
120
  // Impression tracking
121
121
  useEffect(() => {
122
122
  if (!adData || !isOpen || impressionTrackedRef.current) return;
123
- impressionTrackedRef.current = true;
124
- AdTogether.trackImpression(adData.id, adData.token);
123
+
124
+ let visibleTimer: ReturnType<typeof setTimeout> | null = null;
125
+ let visibleSince: number | null = null;
126
+
127
+ const clearVisibleTimer = () => {
128
+ if (visibleTimer) clearTimeout(visibleTimer);
129
+ visibleTimer = null;
130
+ visibleSince = null;
131
+ };
132
+
133
+ const startVisibleTimer = () => {
134
+ if (visibleTimer || impressionTrackedRef.current || document.visibilityState !== 'visible') return;
135
+
136
+ visibleSince = Date.now();
137
+ visibleTimer = setTimeout(() => {
138
+ const visibleDurationMs = visibleSince === null ? 0 : Date.now() - visibleSince;
139
+ visibleTimer = null;
140
+ visibleSince = null;
141
+
142
+ if (impressionTrackedRef.current || document.visibilityState !== 'visible' || visibleDurationMs < 1000) return;
143
+
144
+ impressionTrackedRef.current = true;
145
+ AdTogether.trackImpression(adData.id, adData.token, {
146
+ visiblePercent: 100,
147
+ visibleDurationMs,
148
+ });
149
+ }, 1000);
150
+ };
151
+
152
+ const handleVisibilityChange = () => {
153
+ if (document.visibilityState === 'visible') startVisibleTimer();
154
+ else clearVisibleTimer();
155
+ };
156
+
157
+ document.addEventListener('visibilitychange', handleVisibilityChange);
158
+ startVisibleTimer();
159
+ return () => {
160
+ clearVisibleTimer();
161
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
162
+ };
125
163
  }, [adData, isOpen]);
126
164
 
127
165
  const handleAdClick = useCallback(() => {
package/errors.txt DELETED
@@ -1,2 +0,0 @@
1
- tsconfig.json(15,25): error TS5107: Option 'moduleResolution=node10' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": "6.0"' to silence this error.
2
- Visit https://aka.ms/ts6 for migration information.