@media-suite/reactnative-sdk 0.1.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 ADDED
@@ -0,0 +1,120 @@
1
+ # @mediasuite/react-native-sdk
2
+
3
+ React Native SDK for MediaSuite ad platform. Renders ads, tracks impressions/clicks with IAB/MRC viewability compliance.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @mediasuite/react-native-sdk @react-native-async-storage/async-storage
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### 1. Wrap your app with the Provider
14
+
15
+ ```tsx
16
+ import { MediaSuiteProvider } from '@mediasuite/react-native-sdk';
17
+
18
+ export default function App() {
19
+ return (
20
+ <MediaSuiteProvider
21
+ config={{
22
+ companyId: '507f1f77bcf86cd799439011',
23
+ apiKey: 'your-public-embed-key',
24
+ baseUrl: 'https://api.mediasuite.com', // optional
25
+ }}
26
+ >
27
+ <MyApp />
28
+ </MediaSuiteProvider>
29
+ );
30
+ }
31
+ ```
32
+
33
+ > **Security:** The `apiKey` must be a restricted public embed key with read-only SDK permissions. Never use admin or secret keys in client-side code.
34
+
35
+ ### 2. Render ads anywhere
36
+
37
+ ```tsx
38
+ import { MediaSuiteAd } from '@mediasuite/react-native-sdk';
39
+
40
+ function HomeScreen() {
41
+ return (
42
+ <View>
43
+ <Text>My App</Text>
44
+
45
+ <MediaSuiteAd
46
+ spaceCode="banner-home-principal"
47
+ screenName="HomeScreen"
48
+ style={{ height: 90, marginVertical: 16 }}
49
+ />
50
+
51
+ <MediaSuiteAd
52
+ spaceCode="sidebar-top"
53
+ screenName="HomeScreen"
54
+ style={{ height: 250 }}
55
+ onAdLoaded={(space) => console.log('Ads loaded:', space.ads.length)}
56
+ onError={(err) => console.warn('Ad error:', err)}
57
+ />
58
+ </View>
59
+ );
60
+ }
61
+ ```
62
+
63
+ ## MediaSuiteAd Props
64
+
65
+ | Prop | Type | Required | Description |
66
+ |------|------|----------|-------------|
67
+ | `spaceCode` | `string` | Yes | Ad space code from MediaSuite panel |
68
+ | `screenName` | `string` | No | Screen name for referrer analytics (e.g. `'HomeScreen'`) |
69
+ | `style` | `ViewStyle` | No | Container styles |
70
+ | `onAdLoaded` | `(space) => void` | No | Callback when ads are loaded |
71
+ | `onError` | `(error) => void` | No | Callback on error |
72
+
73
+ ## Features
74
+
75
+ - **IAB/MRC Viewability** — 50% visible for 1 continuous second before counting impression
76
+ - **Ad Rotation** — Automatic rotation when space has multiple ads
77
+ - **Impression Tracking** — Debounced, deduplicated, viewability-gated, with retry on network failure
78
+ - **Click Tracking** — Fire-and-forget tracking + deep link to destination
79
+ - **Visitor Persistence** — Crypto-secure UUID stored in AsyncStorage across sessions
80
+ - **Background Handling** — Pauses tracking when app goes to background
81
+ - **Reactive Device Detection** — Updates on orientation and window size changes
82
+
83
+ ## Requirements
84
+
85
+ - React Native >= 0.72
86
+ - React >= 18
87
+ - @react-native-async-storage/async-storage >= 1.19
88
+
89
+ ## Building
90
+
91
+ ```bash
92
+ npm run build # Compiles TypeScript to dist/
93
+ ```
94
+
95
+ The package ships compiled JS + type declarations via the `dist/` directory.
96
+
97
+ ## Architecture
98
+
99
+ ```
100
+ MediaSuiteProvider (Context)
101
+ ├── Manages config, visitor ID, device detection
102
+ ├── Caches ad space responses
103
+ ├── Retry logic for failed impression tracking
104
+ └── Provides: fetchSpace, trackImpression, getClickUrl
105
+
106
+ MediaSuiteAd (Component)
107
+ ├── Fetches ads for a space code
108
+ ├── Renders Image with touch handling
109
+ ├── IAB viewability: measureInWindow + 200ms polling
110
+ ├── Passes screenName as page_url for referrer metrics
111
+ └── Auto-rotates ads on configurable interval
112
+ ```
113
+
114
+ ## API Endpoints Used
115
+
116
+ | Endpoint | Purpose |
117
+ |----------|---------|
118
+ | `GET /sdk/serve/:companyId` | Fetch ads for spaces |
119
+ | `GET /sdk/impressions` | Register viewable impression |
120
+ | `GET /sdk/click/:placementId` | Track click + redirect |
@@ -0,0 +1,12 @@
1
+ import { type ViewStyle } from 'react-native';
2
+ import type { AdSpace } from './types';
3
+ interface Props {
4
+ spaceCode: string;
5
+ style?: ViewStyle;
6
+ /** Screen name for analytics (e.g. 'HomeScreen'). Sent as page_url for referrer metrics. */
7
+ screenName?: string;
8
+ onAdLoaded?: (space: AdSpace) => void;
9
+ onError?: (error: string) => void;
10
+ }
11
+ export declare function MediaSuiteAd({ spaceCode, style, screenName, onAdLoaded, onError }: Props): import("react/jsx-runtime").JSX.Element | null;
12
+ export {};
@@ -0,0 +1,159 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState, useRef, useCallback } from 'react';
3
+ import { View, Image, TouchableOpacity, Linking, StyleSheet, Dimensions, AppState, } from 'react-native';
4
+ import { useMediaSuite } from './MediaSuiteProvider';
5
+ export function MediaSuiteAd({ spaceCode, style, screenName, onAdLoaded, onError }) {
6
+ const { fetchSpace, trackImpression, getClickUrl, visitorId } = useMediaSuite();
7
+ const [ads, setAds] = useState([]);
8
+ const [index, setIndex] = useState(0);
9
+ const [loaded, setLoaded] = useState(false);
10
+ const rotationMs = useRef(5000);
11
+ const impressionTracked = useRef(false);
12
+ const viewRef = useRef(null);
13
+ const viewabilityTimer = useRef(null);
14
+ const scrollCheckInterval = useRef(null);
15
+ const adsRef = useRef([]);
16
+ const consecutiveFailures = useRef(0);
17
+ // Keep adsRef in sync for use inside interval callbacks
18
+ useEffect(() => { adsRef.current = ads; }, [ads]);
19
+ // Fetch ads — skip until visitorId is ready to avoid false onError
20
+ useEffect(() => {
21
+ if (!visitorId)
22
+ return;
23
+ let cancelled = false;
24
+ (async () => {
25
+ const space = await fetchSpace(spaceCode);
26
+ if (cancelled)
27
+ return;
28
+ if (!space || !space.ads.length) {
29
+ onError?.('No ads for space: ' + spaceCode);
30
+ return;
31
+ }
32
+ rotationMs.current = (space.rotation_seconds || 5) * 1000;
33
+ setAds(space.ads);
34
+ consecutiveFailures.current = 0;
35
+ onAdLoaded?.(space);
36
+ })();
37
+ return () => { cancelled = true; };
38
+ }, [spaceCode, fetchSpace, visitorId, onAdLoaded, onError]);
39
+ // IAB viewability: 50% visible for 1 continuous second
40
+ const cancelViewabilityCheck = useCallback(() => {
41
+ if (viewabilityTimer.current) {
42
+ clearTimeout(viewabilityTimer.current);
43
+ viewabilityTimer.current = null;
44
+ }
45
+ }, []);
46
+ // Rotation — uses adsRef to avoid stale closures
47
+ useEffect(() => {
48
+ if (ads.length <= 1)
49
+ return;
50
+ const timer = setInterval(() => {
51
+ setIndex((prev) => (prev + 1) % adsRef.current.length);
52
+ setLoaded(false);
53
+ impressionTracked.current = false;
54
+ cancelViewabilityCheck();
55
+ }, rotationMs.current);
56
+ return () => clearInterval(timer);
57
+ }, [ads.length, cancelViewabilityCheck]);
58
+ const checkViewability = useCallback(() => {
59
+ // Snapshot the current ad before async measurement
60
+ const currentAd = ads[index];
61
+ if (impressionTracked.current || !viewRef.current || !currentAd)
62
+ return;
63
+ viewRef.current.measureInWindow((x, y, width, height) => {
64
+ if (!width || !height)
65
+ return;
66
+ const screen = Dimensions.get('window');
67
+ const visibleTop = Math.max(0, y);
68
+ const visibleBottom = Math.min(screen.height, y + height);
69
+ const visibleLeft = Math.max(0, x);
70
+ const visibleRight = Math.min(screen.width, x + width);
71
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
72
+ const visibleWidth = Math.max(0, visibleRight - visibleLeft);
73
+ const visibleArea = visibleWidth * visibleHeight;
74
+ const totalArea = width * height;
75
+ const visibleRatio = totalArea > 0 ? visibleArea / totalArea : 0;
76
+ if (visibleRatio >= 0.5) {
77
+ // 50% visible — start 1s timer if not already running
78
+ if (!viewabilityTimer.current) {
79
+ viewabilityTimer.current = setTimeout(() => {
80
+ if (!impressionTracked.current) {
81
+ impressionTracked.current = true;
82
+ trackImpression(currentAd.placement_id, screenName);
83
+ }
84
+ }, 1000);
85
+ }
86
+ }
87
+ else {
88
+ // Less than 50% visible — cancel timer
89
+ cancelViewabilityCheck();
90
+ }
91
+ });
92
+ }, [ads, index, trackImpression, cancelViewabilityCheck, screenName]);
93
+ // Poll viewability every 200ms while image is loaded
94
+ useEffect(() => {
95
+ if (!loaded || !ads.length)
96
+ return;
97
+ checkViewability();
98
+ scrollCheckInterval.current = setInterval(checkViewability, 200);
99
+ return () => {
100
+ if (scrollCheckInterval.current)
101
+ clearInterval(scrollCheckInterval.current);
102
+ cancelViewabilityCheck();
103
+ };
104
+ }, [loaded, ads.length, checkViewability, cancelViewabilityCheck]);
105
+ // Pause when app goes to background
106
+ useEffect(() => {
107
+ const sub = AppState.addEventListener('change', (state) => {
108
+ if (state !== 'active') {
109
+ cancelViewabilityCheck();
110
+ }
111
+ else if (loaded && !impressionTracked.current) {
112
+ checkViewability();
113
+ }
114
+ });
115
+ return () => sub.remove();
116
+ }, [loaded, checkViewability, cancelViewabilityCheck]);
117
+ // Image failure — advance to next ad if available, prevent infinite loop
118
+ const handleImageError = useCallback(() => {
119
+ onError?.('Image failed: ' + ads[index]?.image_url);
120
+ consecutiveFailures.current++;
121
+ if (consecutiveFailures.current < ads.length && ads.length > 1) {
122
+ setIndex((prev) => (prev + 1) % ads.length);
123
+ setLoaded(false);
124
+ impressionTracked.current = false;
125
+ cancelViewabilityCheck();
126
+ }
127
+ }, [ads, index, onError, cancelViewabilityCheck]);
128
+ // Click handler — fire-and-forget tracking + open destination URL
129
+ const handlePress = useCallback(async () => {
130
+ const ad = ads[index];
131
+ if (!ad)
132
+ return;
133
+ const clickUrl = getClickUrl(ad.placement_id, screenName);
134
+ // Fire tracking call (don't wait for response)
135
+ fetch(clickUrl).catch(() => { });
136
+ // Open the destination URL directly
137
+ if (ad.click_url) {
138
+ await Linking.openURL(ad.click_url).catch(() => { });
139
+ }
140
+ }, [ads, index, getClickUrl, screenName]);
141
+ if (!ads.length)
142
+ return null;
143
+ const ad = ads[index];
144
+ return (_jsx(TouchableOpacity, { activeOpacity: 0.9, onPress: handlePress, style: [styles.container, style], accessibilityLabel: ad.alt_text || 'Advertisement', accessibilityRole: "link", children: _jsx(View, { ref: viewRef, collapsable: false, children: _jsx(Image, { source: { uri: ad.image_url }, style: [styles.image, loaded && styles.imageLoaded], resizeMode: "contain", onLoad: () => { setLoaded(true); consecutiveFailures.current = 0; }, onError: handleImageError }, ad.placement_id) }) }));
145
+ }
146
+ const styles = StyleSheet.create({
147
+ container: {
148
+ overflow: 'hidden',
149
+ backgroundColor: 'transparent',
150
+ },
151
+ image: {
152
+ width: '100%',
153
+ aspectRatio: 16 / 9,
154
+ opacity: 0,
155
+ },
156
+ imageLoaded: {
157
+ opacity: 1,
158
+ },
159
+ });
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import type { MediaSuiteConfig, AdSpace } from './types';
3
+ interface ContextValue {
4
+ config: MediaSuiteConfig;
5
+ visitorId: string;
6
+ device: 'web' | 'mobile';
7
+ fetchSpace: (spaceCode: string) => Promise<AdSpace | null>;
8
+ trackImpression: (placementId: string, screenName?: string) => void;
9
+ getClickUrl: (placementId: string, screenName?: string) => string;
10
+ }
11
+ export declare const useMediaSuite: () => ContextValue;
12
+ export declare function MediaSuiteProvider({ config, children, }: {
13
+ config: MediaSuiteConfig;
14
+ children: React.ReactNode;
15
+ }): import("react/jsx-runtime").JSX.Element;
16
+ export {};
@@ -0,0 +1,126 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
3
+ import { useWindowDimensions } from 'react-native';
4
+ import AsyncStorage from '@react-native-async-storage/async-storage';
5
+ function uuid() {
6
+ // Prefer crypto.randomUUID (available since RN 0.73+ with Hermes)
7
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
8
+ return crypto.randomUUID();
9
+ }
10
+ // Fallback: crypto.getRandomValues
11
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
12
+ const buf = new Uint8Array(16);
13
+ crypto.getRandomValues(buf);
14
+ buf[6] = (buf[6] & 0x0f) | 0x40; // version 4
15
+ buf[8] = (buf[8] & 0x3f) | 0x80; // variant 1
16
+ const hex = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
17
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
18
+ }
19
+ // Last resort: Math.random
20
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
21
+ const r = (Math.random() * 16) | 0;
22
+ return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
23
+ });
24
+ }
25
+ const Ctx = createContext(null);
26
+ export const useMediaSuite = () => {
27
+ const ctx = useContext(Ctx);
28
+ if (!ctx)
29
+ throw new Error('Wrap your app with <MediaSuiteProvider>');
30
+ return ctx;
31
+ };
32
+ const MAX_RETRIES = 2;
33
+ const RETRY_DELAY = 3000;
34
+ export function MediaSuiteProvider({ config, children, }) {
35
+ const baseUrl = (config.baseUrl || 'https://api.mediasuite.com').replace(/\/$/, '');
36
+ const [visitorId, setVisitorId] = useState('');
37
+ const sentImpressions = useRef(new Set());
38
+ const pendingImpressions = useRef([]);
39
+ const debounceTimer = useRef(null);
40
+ const cache = useRef({});
41
+ const retryTimer = useRef(null);
42
+ const isMounted = useRef(true);
43
+ const flushImpressionsRef = useRef(null);
44
+ // Reactive device detection — updates on orientation/window changes
45
+ const { width } = useWindowDimensions();
46
+ const device = width <= 768 ? 'mobile' : 'web';
47
+ // Restore or create visitor_id
48
+ useEffect(() => {
49
+ (async () => {
50
+ let vid = await AsyncStorage.getItem('_ms_vid');
51
+ if (!vid) {
52
+ vid = uuid();
53
+ await AsyncStorage.setItem('_ms_vid', vid);
54
+ }
55
+ setVisitorId(vid);
56
+ })();
57
+ }, []);
58
+ // Cleanup all timers on unmount
59
+ useEffect(() => {
60
+ return () => {
61
+ isMounted.current = false;
62
+ if (debounceTimer.current)
63
+ clearTimeout(debounceTimer.current);
64
+ if (retryTimer.current)
65
+ clearTimeout(retryTimer.current);
66
+ };
67
+ }, []);
68
+ const fetchSpace = useCallback(async (spaceCode) => {
69
+ const cacheKey = `${spaceCode}:${device}`;
70
+ if (cache.current[cacheKey])
71
+ return cache.current[cacheKey];
72
+ if (!visitorId)
73
+ return null;
74
+ try {
75
+ const url = `${baseUrl}/sdk/serve/${encodeURIComponent(config.companyId)}?spaces=${encodeURIComponent(spaceCode)}&device=${device}&visitor_id=${encodeURIComponent(visitorId)}`;
76
+ const res = await fetch(url, {
77
+ headers: { 'api-key': config.apiKey },
78
+ });
79
+ if (!res.ok)
80
+ return null;
81
+ const json = await res.json();
82
+ const space = json.data.spaces.find((s) => s.space_code === spaceCode) || null;
83
+ if (space)
84
+ cache.current[cacheKey] = space;
85
+ return space;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }, [baseUrl, config.companyId, config.apiKey, device, visitorId]);
91
+ const flushImpressions = useCallback((screenName, retryCount = 0) => {
92
+ if (!visitorId || !isMounted.current)
93
+ return;
94
+ const ids = [...pendingImpressions.current];
95
+ if (!ids.length)
96
+ return;
97
+ pendingImpressions.current = [];
98
+ const pageUrl = encodeURIComponent(screenName || 'app://unknown');
99
+ const url = `${baseUrl}/sdk/impressions?company=${encodeURIComponent(config.companyId)}&placements=${ids.join(',')}&visitor_id=${encodeURIComponent(visitorId)}&device=${device}&page_url=${pageUrl}`;
100
+ fetch(url, { headers: { 'api-key': config.apiKey } }).catch(() => {
101
+ if (retryCount < MAX_RETRIES && isMounted.current) {
102
+ ids.forEach((id) => sentImpressions.current.delete(id));
103
+ pendingImpressions.current.push(...ids);
104
+ if (retryTimer.current)
105
+ clearTimeout(retryTimer.current);
106
+ retryTimer.current = setTimeout(() => flushImpressionsRef.current?.(screenName, retryCount + 1), RETRY_DELAY);
107
+ }
108
+ });
109
+ }, [baseUrl, config.companyId, config.apiKey, device, visitorId]);
110
+ useEffect(() => { flushImpressionsRef.current = flushImpressions; }, [flushImpressions]);
111
+ const trackImpression = useCallback((placementId, screenName) => {
112
+ if (sentImpressions.current.has(placementId))
113
+ return;
114
+ sentImpressions.current.add(placementId);
115
+ pendingImpressions.current.push(placementId);
116
+ if (debounceTimer.current)
117
+ clearTimeout(debounceTimer.current);
118
+ debounceTimer.current = setTimeout(() => flushImpressions(screenName), 1000);
119
+ }, [flushImpressions]);
120
+ const getClickUrl = useCallback((placementId, screenName) => {
121
+ const pageUrl = encodeURIComponent(screenName || 'app://unknown');
122
+ return `${baseUrl}/sdk/click/${encodeURIComponent(placementId)}?vid=${encodeURIComponent(visitorId)}&device=${device}&page_url=${pageUrl}`;
123
+ }, [baseUrl, visitorId, device]);
124
+ // Render children always — ad components handle the "not ready" state
125
+ return (_jsx(Ctx.Provider, { value: { config, visitorId, device, fetchSpace, trackImpression, getClickUrl }, children: children }));
126
+ }
@@ -0,0 +1,3 @@
1
+ export { MediaSuiteProvider } from './MediaSuiteProvider';
2
+ export { MediaSuiteAd } from './MediaSuiteAd';
3
+ export type { MediaSuiteConfig, AdSpace, Ad } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { MediaSuiteProvider } from './MediaSuiteProvider';
2
+ export { MediaSuiteAd } from './MediaSuiteAd';
@@ -0,0 +1,25 @@
1
+ export interface MediaSuiteConfig {
2
+ /** MongoDB ObjectId of the company */
3
+ companyId: string;
4
+ /** Public embed API key (must be a restricted key with read-only SDK permissions) */
5
+ apiKey: string;
6
+ /** API base URL. Defaults to https://api.mediasuite.com */
7
+ baseUrl?: string;
8
+ }
9
+ export interface Ad {
10
+ placement_id: string;
11
+ image_url: string;
12
+ alt_text?: string;
13
+ click_url?: string;
14
+ }
15
+ export interface AdSpace {
16
+ space_code: string;
17
+ rotation_seconds: number;
18
+ ads: Ad[];
19
+ }
20
+ export interface ServeResponse {
21
+ success: boolean;
22
+ data: {
23
+ spaces: AdSpace[];
24
+ };
25
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@media-suite/reactnative-sdk",
3
+ "version": "0.1.0",
4
+ "description": "MediaSuite Native SDK — React Native SDK for rendering and tracking ads in mobile apps. IAB/MRC viewability standards, impression/click tracking, and ad rotation.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepare": "npm run build"
11
+ },
12
+ "peerDependencies": {
13
+ "@react-native-async-storage/async-storage": ">=1.19.0",
14
+ "react": ">=18.0.0",
15
+ "react-native": ">=0.72.0"
16
+ },
17
+ "keywords": [
18
+ "mediasuite",
19
+ "ads",
20
+ "react-native",
21
+ "sdk",
22
+ "advertising",
23
+ "viewability",
24
+ "IAB"
25
+ ],
26
+ "license": "MIT",
27
+ "files": [
28
+ "dist/"
29
+ ],
30
+ "devDependencies": {
31
+ "@types/react": "^19.2.14",
32
+ "@types/react-native": "^0.72.8",
33
+ "typescript": "^5.9.3"
34
+ }
35
+ }