@adtogether/react-native-sdk 0.4.1 → 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/src/AdTogether.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdModel, AdType, AdTogetherOptions } from './types';
1
+ import { AdModel, AdType, AdTogetherOptions, ViewabilityMetrics } from './types';
2
2
  import { NativeModules, Platform } from 'react-native';
3
3
 
4
4
  export class AdTogether {
@@ -116,23 +116,14 @@ export class AdTogether {
116
116
 
117
117
  try {
118
118
  const sdk = AdTogether.shared;
119
- let url = `${sdk.baseUrl}/api/ads/serve?country=global&adUnitId=${adUnitId}&appId=${sdk.appId}`;
120
- if (adType) {
121
- url += `&adType=${adType}`;
122
- }
123
- if (sdk.lastAdId) {
124
- url += `&exclude=${sdk.lastAdId}`;
125
- }
126
- if (sdk.bundleId) {
127
- url += `&bundleId=${sdk.bundleId}`;
128
- }
129
-
130
- // Pass allowSelfAds (removed platform from fetch URL to match other SDKs)
131
- url += `&allowSelfAds=${sdk.allowSelfAds}`;
119
+ const url = AdTogether.buildServeUrl(sdk, adUnitId, adType);
132
120
 
133
121
  const response = await fetch(url);
134
122
  if (response.ok) {
135
- const ad = await response.json();
123
+ const ad = await response.json() as AdModel;
124
+ if (adType && ad.adType !== adType) {
125
+ throw new Error('Received an ad with a different type than requested: ' + (ad.adType ?? 'missing'));
126
+ }
136
127
  sdk.lastAdId = ad.id;
137
128
  return ad;
138
129
  } else if (response.status === 401 || response.status === 403) {
@@ -146,76 +137,43 @@ export class AdTogether {
146
137
  }
147
138
  }
148
139
 
149
- static trackImpression(adId: string, token?: string) {
150
- this.trackEvent('/api/ads/impression', adId, token);
151
- }
140
+ private static buildServeUrl(sdk: AdTogether, adUnitId: string, adType?: AdType): string {
141
+ const url = new URL(sdk.baseUrl);
142
+ url.pathname = url.pathname.replace(/\/+$/, '') + '/api/ads/serve';
143
+ url.search = '';
144
+ url.hash = '';
145
+
146
+ url.searchParams.set('adUnitId', adUnitId);
147
+ url.searchParams.set('appId', sdk.appId ?? '');
148
+ url.searchParams.set('allowSelfAds', String(sdk.allowSelfAds));
149
+ url.searchParams.set('platform', Platform.OS);
150
+ if (adType) {
151
+ url.searchParams.set('adType', adType);
152
+ }
153
+ if (sdk.lastAdId) {
154
+ url.searchParams.set('exclude', sdk.lastAdId);
155
+ }
156
+ if (sdk.bundleId) {
157
+ url.searchParams.set('bundleId', sdk.bundleId);
158
+ }
152
159
 
153
- static trackClick(adId: string, token?: string) {
154
- this.trackEvent('/api/ads/click', adId, token);
160
+ return url.toString();
155
161
  }
156
162
 
157
- /**
158
- * Detect country code from the device locale.
159
- * Tries expo-localization first, then NativeModules, then Intl API.
160
- */
161
- private static detectCountry(): string | null {
162
- // 1. Try expo-localization
163
- try {
164
- const ExpoLocalization = require('expo-localization');
165
- if (ExpoLocalization?.region) {
166
- return ExpoLocalization.region; // e.g. "US"
167
- }
168
- // expo-localization v3+ uses getLocales()
169
- if (ExpoLocalization?.getLocales) {
170
- const locales = ExpoLocalization.getLocales();
171
- if (locales?.[0]?.regionCode) {
172
- return locales[0].regionCode;
173
- }
174
- }
175
- } catch (_) {}
176
-
177
- // 2. Try RN NativeModules (I18nManager or SettingsManager)
178
- try {
179
- if (Platform.OS === 'ios') {
180
- const settings = NativeModules.SettingsManager?.settings;
181
- const locale = settings?.AppleLocale || settings?.AppleLanguages?.[0];
182
- if (locale) {
183
- const parts = locale.replace('_', '-').split('-');
184
- if (parts.length >= 2) {
185
- const region = parts[parts.length - 1].toUpperCase();
186
- if (region.length === 2) return region;
187
- }
188
- }
189
- } else if (Platform.OS === 'android') {
190
- const locale = NativeModules.I18nManager?.localeIdentifier;
191
- if (locale) {
192
- const parts = locale.replace('_', '-').split('-');
193
- if (parts.length >= 2) {
194
- const region = parts[parts.length - 1].toUpperCase();
195
- if (region.length === 2) return region;
196
- }
197
- }
198
- }
199
- } catch (_) {}
200
-
201
- // 3. Fallback: Intl API (available in Hermes & JSC)
202
- try {
203
- if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
204
- const resolved = Intl.DateTimeFormat().resolvedOptions();
205
- if (resolved.locale) {
206
- const parts = resolved.locale.split('-');
207
- if (parts.length >= 2) {
208
- const region = parts[parts.length - 1].toUpperCase();
209
- if (region.length === 2) return region;
210
- }
211
- }
212
- }
213
- } catch (_) {}
163
+ static trackImpression(adId: string, token?: string, viewability?: ViewabilityMetrics) {
164
+ this.trackEvent('/api/ads/impression', adId, token, viewability);
165
+ }
214
166
 
215
- return null;
167
+ static trackClick(adId: string, token?: string) {
168
+ this.trackEvent('/api/ads/click', adId, token);
216
169
  }
217
170
 
218
- private static trackEvent(endpoint: string, adId: string, token?: string) {
171
+ private static trackEvent(
172
+ endpoint: string,
173
+ adId: string,
174
+ token?: string,
175
+ viewability?: ViewabilityMetrics
176
+ ) {
219
177
  if (!AdTogether.shared.assertInitialized()) return;
220
178
 
221
179
  fetch(`${AdTogether.shared.baseUrl}${endpoint}`, {
@@ -231,7 +189,7 @@ export class AdTogether {
231
189
  // Send platform and environment to match Flutter SDK
232
190
  platform: Platform.OS,
233
191
  environment: __DEV__ ? 'development' : 'production',
234
- country: AdTogether.detectCountry(),
192
+ ...(viewability ? viewability : {}),
235
193
  }),
236
194
  }).catch(console.error);
237
195
  }
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState, useRef } from 'react';
2
- import { View, Text, Image, TouchableOpacity, StyleSheet, useColorScheme, Linking, Pressable } from 'react-native';
2
+ import { AppState, Dimensions, View, Text, Image, TouchableOpacity, StyleSheet, useColorScheme, Linking, Pressable } from 'react-native';
3
3
  import { AdTogether } from './AdTogether';
4
4
  import { AdModel } from './types';
5
5
 
@@ -32,6 +32,19 @@ export const AdTogetherBanner: React.FC<AdTogetherBannerProps> = ({
32
32
  const systemColorScheme = useColorScheme();
33
33
 
34
34
  const impressionTrackedRef = useRef(false);
35
+ const containerRef = useRef<View>(null);
36
+ const appStateRef = useRef(AppState.currentState);
37
+ const [appState, setAppState] = useState(appStateRef.current);
38
+
39
+ useEffect(() => {
40
+ const subscription = AppState.addEventListener('change', (nextAppState) => {
41
+ // Keep the timer guard current before React renders the state update
42
+ // and cleans up the interval.
43
+ appStateRef.current = nextAppState;
44
+ setAppState(nextAppState);
45
+ });
46
+ return () => subscription.remove();
47
+ }, []);
35
48
 
36
49
  useEffect(() => {
37
50
  let isMounted = true;
@@ -58,13 +71,46 @@ export const AdTogetherBanner: React.FC<AdTogetherBannerProps> = ({
58
71
  };
59
72
  }, [adUnitId, onAdLoaded, onAdFailedToLoad]);
60
73
 
61
- // Track impression when ad is rendered
74
+ // Track an impression only after the banner stays at least half visible for one second.
62
75
  useEffect(() => {
63
- if (adData && !isLoading && !hasError && !impressionTrackedRef.current) {
64
- impressionTrackedRef.current = true;
65
- AdTogether.trackImpression(adData.id, adData.token);
76
+ if (!adData || isLoading || hasError || !isVisible || appState !== 'active' || impressionTrackedRef.current) {
77
+ return;
66
78
  }
67
- }, [adData, isLoading, hasError]);
79
+
80
+ let cancelled = false;
81
+ let visibleSince: number | null = null;
82
+ const checkVisibility = () => {
83
+ containerRef.current?.measureInWindow((x, y, width, height) => {
84
+ if (cancelled || appStateRef.current !== 'active' || impressionTrackedRef.current) return;
85
+ const window = Dimensions.get('window');
86
+ const totalArea = width * height;
87
+ const visibleWidth = Math.max(0, Math.min(x + width, window.width) - Math.max(x, 0));
88
+ const visibleHeight = Math.max(0, Math.min(y + height, window.height) - Math.max(y, 0));
89
+ const visiblePercent = totalArea > 0
90
+ ? Math.round((visibleWidth * visibleHeight / totalArea) * 100)
91
+ : 0;
92
+
93
+ if (visiblePercent < 50) {
94
+ visibleSince = null;
95
+ return;
96
+ }
97
+ const now = Date.now();
98
+ visibleSince ??= now;
99
+ const visibleDurationMs = now - visibleSince;
100
+ if (visibleDurationMs >= 1000) {
101
+ impressionTrackedRef.current = true;
102
+ AdTogether.trackImpression(adData.id, adData.token, { visiblePercent, visibleDurationMs });
103
+ }
104
+ });
105
+ };
106
+
107
+ checkVisibility();
108
+ const interval = setInterval(checkVisibility, 100);
109
+ return () => {
110
+ cancelled = true;
111
+ clearInterval(interval);
112
+ };
113
+ }, [adData, appState, hasError, isLoading, isVisible]);
68
114
 
69
115
  const handlePress = () => {
70
116
  if (!adData) return;
@@ -99,15 +145,16 @@ export const AdTogetherBanner: React.FC<AdTogetherBannerProps> = ({
99
145
  const descColor = isDarkMode ? '#9CA3AF' : '#6B7280';
100
146
 
101
147
  return (
102
- <TouchableOpacity
103
- activeOpacity={0.8}
104
- onPress={handlePress}
105
- style={[
106
- styles.container,
107
- { backgroundColor: bgColor, borderColor },
108
- style,
109
- ]}
110
- >
148
+ <View ref={containerRef} collapsable={false}>
149
+ <TouchableOpacity
150
+ activeOpacity={0.8}
151
+ onPress={handlePress}
152
+ style={[
153
+ styles.container,
154
+ { backgroundColor: bgColor, borderColor },
155
+ style,
156
+ ]}
157
+ >
111
158
  {adData.imageUrl ? (
112
159
  <View style={styles.imageContainer}>
113
160
  <Image
@@ -141,7 +188,8 @@ export const AdTogetherBanner: React.FC<AdTogetherBannerProps> = ({
141
188
  <Text style={styles.closeButtonText}>×</Text>
142
189
  </Pressable>
143
190
  )}
144
- </TouchableOpacity>
191
+ </TouchableOpacity>
192
+ </View>
145
193
  );
146
194
  };
147
195
 
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState, useRef } from 'react';
2
- import { View, Text, Image, TouchableOpacity, StyleSheet, Modal, SafeAreaView, useColorScheme, Linking, useWindowDimensions, ScrollView } from 'react-native';
2
+ import { AppState, View, Text, Image, TouchableOpacity, StyleSheet, Modal, SafeAreaView, useColorScheme, Linking, useWindowDimensions, ScrollView } from 'react-native';
3
3
  import { AdTogether } from './AdTogether';
4
4
  import { AdModel } from './types';
5
5
 
@@ -31,6 +31,18 @@ export const AdTogetherInterstitial: React.FC<AdTogetherInterstitialProps> = ({
31
31
  const isLandscape = width > height;
32
32
 
33
33
  const impressionTrackedRef = useRef(false);
34
+ const appStateRef = useRef(AppState.currentState);
35
+ const [appState, setAppState] = useState(appStateRef.current);
36
+
37
+ useEffect(() => {
38
+ const subscription = AppState.addEventListener('change', (nextAppState) => {
39
+ // Keep the timer guard current before React renders the state update
40
+ // and cleans up the timeout.
41
+ appStateRef.current = nextAppState;
42
+ setAppState(nextAppState);
43
+ });
44
+ return () => subscription.remove();
45
+ }, []);
34
46
 
35
47
  useEffect(() => {
36
48
  if (!isOpen) return;
@@ -64,24 +76,26 @@ export const AdTogetherInterstitial: React.FC<AdTogetherInterstitialProps> = ({
64
76
  };
65
77
  }, [adUnitId, isOpen, closeDelay, onAdLoaded, onAdFailedToLoad, onClose]);
66
78
 
67
- // Track impression and start timer
79
+ // The modal is full-screen; require one continuous second while the app is active.
68
80
  useEffect(() => {
69
- if (isOpen && adData && !isLoading && !hasError && !impressionTrackedRef.current) {
70
- impressionTrackedRef.current = true;
71
- AdTogether.trackImpression(adData.id, adData.token);
72
-
73
- const timer = setInterval(() => {
74
- setTimeLeft((prev) => {
75
- if (prev <= 1) {
76
- clearInterval(timer);
77
- return 0;
78
- }
79
- return prev - 1;
80
- });
81
- }, 1000);
82
-
83
- return () => clearInterval(timer);
81
+ if (!isOpen || !adData || isLoading || hasError || appState !== 'active' || impressionTrackedRef.current) {
82
+ return;
84
83
  }
84
+ const timer = setTimeout(() => {
85
+ if (!impressionTrackedRef.current && appStateRef.current === 'active') {
86
+ impressionTrackedRef.current = true;
87
+ AdTogether.trackImpression(adData.id, adData.token, { visiblePercent: 100, visibleDurationMs: 1000 });
88
+ }
89
+ }, 1000);
90
+ return () => clearTimeout(timer);
91
+ }, [isOpen, adData, isLoading, hasError, appState]);
92
+
93
+ useEffect(() => {
94
+ if (!isOpen || !adData || isLoading || hasError) return;
95
+ const timer = setInterval(() => {
96
+ setTimeLeft((prev) => Math.max(0, prev - 1));
97
+ }, 1000);
98
+ return () => clearInterval(timer);
85
99
  }, [isOpen, adData, isLoading, hasError]);
86
100
 
87
101
  const handlePress = () => {
package/src/types.ts CHANGED
@@ -10,6 +10,11 @@ export interface AdModel {
10
10
  adType?: AdType;
11
11
  }
12
12
 
13
+ export interface ViewabilityMetrics {
14
+ visiblePercent: number;
15
+ visibleDurationMs: number;
16
+ }
17
+
13
18
  export interface AdTogetherOptions {
14
19
  /** The unique identifier for your application. */
15
20
  appId?: string;