@adtogether/react-native-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 +4 -0
- package/README.md +8 -4
- package/lib/commonjs/AdTogether.js +28 -77
- package/lib/commonjs/AdTogether.js.map +1 -1
- package/lib/commonjs/AdTogetherBanner.js +100 -53
- package/lib/commonjs/AdTogetherBanner.js.map +1 -1
- package/lib/commonjs/AdTogetherInterstitial.js +31 -14
- package/lib/commonjs/AdTogetherInterstitial.js.map +1 -1
- package/lib/module/AdTogether.js +28 -77
- package/lib/module/AdTogether.js.map +1 -1
- package/lib/module/AdTogetherBanner.js +101 -54
- package/lib/module/AdTogetherBanner.js.map +1 -1
- package/lib/module/AdTogetherInterstitial.js +32 -15
- package/lib/module/AdTogetherInterstitial.js.map +1 -1
- package/lib/typescript/AdTogether.d.ts +3 -7
- package/lib/typescript/AdTogether.d.ts.map +1 -1
- package/lib/typescript/AdTogetherBanner.d.ts.map +1 -1
- package/lib/typescript/AdTogetherInterstitial.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +4 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/AdTogether.ts +38 -80
- package/src/AdTogetherBanner.tsx +64 -16
- package/src/AdTogetherInterstitial.tsx +31 -17
- package/src/types.ts +5 -0
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
|
-
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
this.trackEvent('/api/ads/click', adId, token);
|
|
160
|
+
return url.toString();
|
|
155
161
|
}
|
|
156
162
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
+
static trackClick(adId: string, token?: string) {
|
|
168
|
+
this.trackEvent('/api/ads/click', adId, token);
|
|
216
169
|
}
|
|
217
170
|
|
|
218
|
-
private static trackEvent(
|
|
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
|
-
|
|
192
|
+
...(viewability ? viewability : {}),
|
|
235
193
|
}),
|
|
236
194
|
}).catch(console.error);
|
|
237
195
|
}
|
package/src/AdTogetherBanner.tsx
CHANGED
|
@@ -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
|
|
74
|
+
// Track an impression only after the banner stays at least half visible for one second.
|
|
62
75
|
useEffect(() => {
|
|
63
|
-
if (adData
|
|
64
|
-
|
|
65
|
-
AdTogether.trackImpression(adData.id, adData.token);
|
|
76
|
+
if (!adData || isLoading || hasError || !isVisible || appState !== 'active' || impressionTrackedRef.current) {
|
|
77
|
+
return;
|
|
66
78
|
}
|
|
67
|
-
|
|
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
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
+
// The modal is full-screen; require one continuous second while the app is active.
|
|
68
80
|
useEffect(() => {
|
|
69
|
-
if (isOpen
|
|
70
|
-
|
|
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;
|