@thead-vantage/react 2.20.0 → 2.21.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
@@ -154,6 +154,7 @@ The main component for displaying ads from TheAd Vantage.
154
154
  | `clickMetadata` | `Record<string, unknown>` | No | - | Optional metadata to send with click tracking events |
155
155
  | `impressionMetadata` | `Record<string, unknown>` | No | - | Optional metadata to send with impression tracking events |
156
156
  | `showImpressionFinished` | `boolean` | No | `false` | Show spinning checkmark during impression timer (2s), then green checkmark when complete |
157
+ | `development` | `boolean` | No | `false` | Explicitly enable development mode (disables tracking, handles CORS errors gracefully) |
157
158
 
158
159
  **Example**:
159
160
  ```tsx
@@ -262,6 +263,31 @@ The `showImpressionFinished` prop enables a visual indicator that shows when an
262
263
 
263
264
  This feature is useful for debugging and providing visual feedback that impressions are being tracked correctly.
264
265
 
266
+ ### Development Mode
267
+
268
+ The `development` prop explicitly enables development mode, which:
269
+
270
+ - **Disables tracking**: Impressions and clicks are not recorded (even if the API call succeeds)
271
+ - **Handles CORS gracefully**: CORS errors are logged as warnings instead of breaking the component
272
+ - **Prevents error display**: Network errors don't show error messages to users in development mode
273
+
274
+ **Use Case**: When developing locally and you can't configure CORS for localhost, use this prop to prevent CORS errors from breaking your development workflow.
275
+
276
+ **Example**:
277
+ ```tsx
278
+ <AdBanner
279
+ platformId="10"
280
+ apiKey="abc123xyz"
281
+ size="banner"
282
+ development={process.env.NODE_ENV === 'development'}
283
+ />
284
+ ```
285
+
286
+ **Note**: When `development={true}`, the component will:
287
+ - Automatically skip all tracking (impressions and clicks)
288
+ - Log CORS errors as warnings instead of throwing errors
289
+ - Continue to work even if the API is unreachable (won't show error state)
290
+
265
291
  ### Utility Functions
266
292
 
267
293
  You can also use the utility functions directly:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thead-vantage/react",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "React components and utilities for TheAd Vantage ad platform integration",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/postcss": "^4",
63
- "@types/node": "^20",
63
+ "@types/node": "^20.19.27",
64
64
  "@types/react": "^19",
65
65
  "@types/react-dom": "^19",
66
66
  "next": "^16.1.1",
@@ -1,245 +1,288 @@
1
- 'use client';
2
-
3
- import { useEffect, useState, useRef } from 'react';
4
- import Image from 'next/image';
5
- import { fetchAdBanner, trackImpression, trackClick, type Ad } from '../lib/ads';
6
-
7
- export interface AdBannerProps {
8
- platformId: string;
9
- apiKey: string;
10
- size?: 'leaderboard' | 'medium-rectangle' | 'wide-skyscraper' | 'banner';
11
- apiUrl?: string; // Optional explicit API URL override
12
- userId?: string | null;
13
- userSegment?: string | null;
14
- className?: string;
15
- clickMetadata?: Record<string, unknown>; // Optional metadata to send with click events
16
- impressionMetadata?: Record<string, unknown>; // Optional metadata to send with impression events
17
- showImpressionFinished?: boolean; // Show spinning checkmark during impression timer, then green check when complete
18
- }
19
-
20
- export function AdBanner({
21
- platformId,
22
- apiKey,
23
- size = 'banner',
24
- apiUrl,
25
- userId = null,
26
- userSegment = null,
27
- className = '',
28
- clickMetadata,
29
- impressionMetadata,
30
- showImpressionFinished = false,
31
- }: AdBannerProps) {
32
- const [ad, setAd] = useState<Ad | null>(null);
33
- const [loading, setLoading] = useState(true);
34
- const [error, setError] = useState<string | null>(null);
35
- const [devMode, setDevMode] = useState(false);
36
- const [impressionStatus, setImpressionStatus] = useState<'pending' | 'counting' | 'completed'>('pending');
37
- const impressionTimerRef = useRef<NodeJS.Timeout | null>(null);
38
- const hasTrackedImpressionRef = useRef<string | null>(null); // Track which ad ID we've already tracked
39
-
40
- useEffect(() => {
41
- const loadAd = async () => {
42
- try {
43
- setLoading(true);
44
- setError(null);
45
-
46
- const response = await fetchAdBanner({
47
- platformId,
48
- apiKey,
49
- size,
50
- apiUrl,
51
- userId,
52
- userSegment,
53
- });
54
-
55
- // Only log in development mode
56
- if (process.env.NODE_ENV === 'development') {
57
- console.log('[AdBanner] Processed response:', {
58
- success: response.success,
59
- hasAd: !!response.ad,
60
- devMode: response.dev_mode,
61
- message: response.message,
62
- _dev_note: response._dev_note,
63
- fullResponse: response,
64
- });
65
- }
66
-
67
- if (response.success && response.ad) {
68
- if (process.env.NODE_ENV === 'development') {
69
- console.log('[AdBanner] Setting ad:', response.ad);
70
- }
71
- setAd(response.ad);
72
- setDevMode(response.dev_mode || false);
73
-
74
- // Only track impression once per ad - check if we've already tracked this ad ID
75
- if (hasTrackedImpressionRef.current !== response.ad.id) {
76
- hasTrackedImpressionRef.current = response.ad.id;
77
-
78
- // Start impression timer if showImpressionFinished is enabled
79
- if (showImpressionFinished) {
80
- setImpressionStatus('counting');
81
- // Standard impression timer: 2 seconds of view time
82
- impressionTimerRef.current = setTimeout(() => {
83
- setImpressionStatus('completed');
84
- impressionTimerRef.current = null;
85
- }, 2000);
86
-
87
- // Track impression (will be skipped in dev mode)
88
- // Note: We don't wait for the API call to complete - the timer determines when to show green check
89
- trackImpression(response.ad.id, apiKey, apiUrl, impressionMetadata).catch(() => {
90
- // Silently handle tracking errors - timer will still complete
91
- });
92
- } else {
93
- // Track impression without timer UI
94
- trackImpression(response.ad.id, apiKey, apiUrl, impressionMetadata);
95
- }
96
- }
97
- } else {
98
- // Create a more detailed error message
99
- const errorDetails = [];
100
- if (!response.success) errorDetails.push('API returned success=false');
101
- if (!response.ad) errorDetails.push('No ad object in response');
102
- if (response.message) errorDetails.push(`Message: ${response.message}`);
103
- if (response._dev_note) errorDetails.push(`Note: ${response._dev_note}`);
104
-
105
- const errorMsg = errorDetails.length > 0
106
- ? `No ads available: ${errorDetails.join(', ')}`
107
- : 'No ads available';
108
-
109
- console.error('[AdBanner] No ad available - Full details:', {
110
- success: response.success,
111
- hasAd: !!response.ad,
112
- message: response.message,
113
- _dev_note: response._dev_note,
114
- fullResponse: response,
115
- errorDetails,
116
- });
117
- setError(errorMsg);
118
- }
119
- } catch (err) {
120
- console.error('[AdBanner] Error fetching ad:', err);
121
-
122
- // For CORS errors, show user-friendly message but log technical details
123
- if (err instanceof Error && err.message.includes('CORS error')) {
124
- console.error('[AdBanner] CORS Error Details:', {
125
- message: err.message,
126
- note: 'This is a server-side configuration issue. The thead-vantage.com server needs to handle OPTIONS requests without redirecting. See CORS_CONFIGURATION.md for implementation details.',
127
- });
128
- // Show generic message to end users
129
- setError('Ad unavailable');
130
- } else {
131
- // For other errors, show the error message
132
- setError(err instanceof Error ? err.message : 'Failed to fetch ad');
133
- }
134
- } finally {
135
- setLoading(false);
136
- }
137
- };
138
-
139
- loadAd();
140
-
141
- // Cleanup timer on unmount or when dependencies change
142
- return () => {
143
- if (impressionTimerRef.current) {
144
- clearTimeout(impressionTimerRef.current);
145
- impressionTimerRef.current = null;
146
- }
147
- // Reset impression tracking ref when component unmounts or key dependencies change
148
- // This allows re-tracking if the ad ID changes (new ad loaded)
149
- hasTrackedImpressionRef.current = null;
150
- };
151
- }, [platformId, apiKey, size, apiUrl, userId, userSegment, showImpressionFinished]);
152
-
153
- const handleClick = () => {
154
- if (ad) {
155
- trackClick(ad.id, apiKey, apiUrl, clickMetadata);
156
- // The link will handle navigation
157
- }
158
- };
159
-
160
- if (loading) {
161
- return (
162
- <div className={`flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded ${className}`}>
163
- <span className="text-sm text-gray-500">Loading ad...</span>
164
- </div>
165
- );
166
- }
167
-
168
- if (error || !ad) {
169
- return (
170
- <div className={`flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded ${className}`}>
171
- <span className="text-sm text-gray-500">{error || 'Ad unavailable'}</span>
172
- </div>
173
- );
174
- }
175
-
176
- return (
177
- <div className={`relative ${className}`}>
178
- <a
179
- href={ad.targetUrl}
180
- onClick={handleClick}
181
- target="_blank"
182
- rel="noopener noreferrer"
183
- className="block"
184
- >
185
- {(ad.type === 'image' || ad.type === 'standard') && ad.contentUrl ? (
186
- <Image
187
- src={ad.contentUrl}
188
- alt={ad.name}
189
- width={ad.width || 300}
190
- height={ad.height || 250}
191
- className="rounded"
192
- unoptimized
193
- />
194
- ) : (
195
- <div className="flex items-center justify-center bg-gray-200 dark:bg-gray-700 rounded" style={{ width: ad.width || 300, height: ad.height || 250 }}>
196
- <span className="text-sm text-gray-500">Ad content</span>
197
- </div>
198
- )}
199
- </a>
200
- {showImpressionFinished && impressionStatus !== 'pending' && (
201
- <div className="absolute top-2 right-2">
202
- {impressionStatus === 'counting' ? (
203
- <svg
204
- className="w-5 h-5 text-gray-400 animate-spin"
205
- xmlns="http://www.w3.org/2000/svg"
206
- fill="none"
207
- viewBox="0 0 24 24"
208
- >
209
- <circle
210
- className="opacity-25"
211
- cx="12"
212
- cy="12"
213
- r="10"
214
- stroke="currentColor"
215
- strokeWidth="4"
216
- />
217
- <path
218
- className="opacity-75"
219
- fill="currentColor"
220
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
221
- />
222
- </svg>
223
- ) : impressionStatus === 'completed' ? (
224
- <svg
225
- className="w-5 h-5 text-green-500"
226
- xmlns="http://www.w3.org/2000/svg"
227
- viewBox="0 0 20 20"
228
- fill="currentColor"
229
- >
230
- <path
231
- fillRule="evenodd"
232
- d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
233
- clipRule="evenodd"
234
- />
235
- </svg>
236
- ) : null}
237
- </div>
238
- )}
239
- {devMode && (
240
- <p className="text-xs text-gray-500 mt-1">[DEV] No tracking active</p>
241
- )}
242
- </div>
243
- );
244
- }
245
-
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+ import Image from 'next/image';
5
+ import { fetchAdBanner, trackImpression, trackClick, type Ad } from '../lib/ads';
6
+ import { setTheadVantageConfig } from '../lib/thead-vantage-config';
7
+
8
+ export interface AdBannerProps {
9
+ platformId: string;
10
+ apiKey: string;
11
+ size?: 'leaderboard' | 'medium-rectangle' | 'wide-skyscraper' | 'banner';
12
+ apiUrl?: string; // Optional explicit API URL override
13
+ userId?: string | null;
14
+ userSegment?: string | null;
15
+ className?: string;
16
+ clickMetadata?: Record<string, unknown>; // Optional metadata to send with click events
17
+ impressionMetadata?: Record<string, unknown>; // Optional metadata to send with impression events
18
+ showImpressionFinished?: boolean; // Show spinning checkmark during impression timer, then green check when complete
19
+ development?: boolean; // Explicitly enable development mode (disables tracking, handles CORS gracefully)
20
+ }
21
+
22
+ export function AdBanner({
23
+ platformId,
24
+ apiKey,
25
+ size = 'banner',
26
+ apiUrl,
27
+ userId = null,
28
+ userSegment = null,
29
+ className = '',
30
+ clickMetadata,
31
+ impressionMetadata,
32
+ showImpressionFinished = false,
33
+ development = false,
34
+ }: AdBannerProps) {
35
+ const [ad, setAd] = useState<Ad | null>(null);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [devMode, setDevMode] = useState(false);
39
+ const [impressionStatus, setImpressionStatus] = useState<'pending' | 'counting' | 'completed'>('pending');
40
+ const impressionTimerRef = useRef<NodeJS.Timeout | null>(null);
41
+ const hasTrackedImpressionRef = useRef<string | null>(null); // Track which ad ID we've already tracked
42
+
43
+ // Set development mode in config if explicitly enabled
44
+ useEffect(() => {
45
+ if (development) {
46
+ setTheadVantageConfig({ devMode: true });
47
+ }
48
+ }, [development]);
49
+
50
+ useEffect(() => {
51
+ const loadAd = async () => {
52
+ try {
53
+ setLoading(true);
54
+ setError(null);
55
+
56
+ const response = await fetchAdBanner({
57
+ platformId,
58
+ apiKey,
59
+ size,
60
+ apiUrl,
61
+ userId,
62
+ userSegment,
63
+ });
64
+
65
+ // Only log in development mode
66
+ if (process.env.NODE_ENV === 'development') {
67
+ console.log('[AdBanner] Processed response:', {
68
+ success: response.success,
69
+ hasAd: !!response.ad,
70
+ devMode: response.dev_mode,
71
+ message: response.message,
72
+ _dev_note: response._dev_note,
73
+ fullResponse: response,
74
+ });
75
+ }
76
+
77
+ if (response.success && response.ad) {
78
+ if (process.env.NODE_ENV === 'development') {
79
+ console.log('[AdBanner] Setting ad:', response.ad);
80
+ }
81
+ setAd(response.ad);
82
+ setDevMode(response.dev_mode || false);
83
+
84
+ // Only track impression once per ad - check if we've already tracked this ad ID
85
+ if (hasTrackedImpressionRef.current !== response.ad.id) {
86
+ hasTrackedImpressionRef.current = response.ad.id;
87
+
88
+ // Start impression timer if showImpressionFinished is enabled
89
+ if (showImpressionFinished) {
90
+ setImpressionStatus('counting');
91
+ // Standard impression timer: 2 seconds of view time
92
+ impressionTimerRef.current = setTimeout(() => {
93
+ setImpressionStatus('completed');
94
+ impressionTimerRef.current = null;
95
+ }, 2000);
96
+
97
+ // Track impression (will be skipped in dev mode)
98
+ // Note: We don't wait for the API call to complete - the timer determines when to show green check
99
+ trackImpression(response.ad.id, apiKey, apiUrl, impressionMetadata).catch(() => {
100
+ // Silently handle tracking errors - timer will still complete
101
+ });
102
+ } else {
103
+ // Track impression without timer UI
104
+ trackImpression(response.ad.id, apiKey, apiUrl, impressionMetadata);
105
+ }
106
+ }
107
+ } else {
108
+ // Create a more detailed error message
109
+ const errorDetails = [];
110
+ if (!response.success) errorDetails.push('API returned success=false');
111
+ if (!response.ad) errorDetails.push('No ad object in response');
112
+ if (response.message) errorDetails.push(`Message: ${response.message}`);
113
+ if (response._dev_note) errorDetails.push(`Note: ${response._dev_note}`);
114
+
115
+ const errorMsg = errorDetails.length > 0
116
+ ? `No ads available: ${errorDetails.join(', ')}`
117
+ : 'No ads available';
118
+
119
+ console.error('[AdBanner] No ad available - Full details:', {
120
+ success: response.success,
121
+ hasAd: !!response.ad,
122
+ message: response.message,
123
+ _dev_note: response._dev_note,
124
+ fullResponse: response,
125
+ errorDetails,
126
+ });
127
+ setError(errorMsg);
128
+ }
129
+ } catch (err) {
130
+ console.error('[AdBanner] Error fetching ad:', err);
131
+
132
+ // For CORS errors, handle differently in development mode
133
+ if (err instanceof Error && err.message.includes('CORS error')) {
134
+ if (development) {
135
+ // In development mode, don't show error - just log it and show a placeholder ad
136
+ console.warn('[AdBanner] CORS error in development mode - this is expected. Tracking is disabled.');
137
+ // Create a placeholder ad for development so the component still renders
138
+ setAd({
139
+ id: 'dev-placeholder',
140
+ name: 'Development Placeholder Ad',
141
+ type: 'image',
142
+ contentUrl: '/placeholder-ad.png',
143
+ targetUrl: '#',
144
+ width: 300,
145
+ height: 250,
146
+ });
147
+ setDevMode(true);
148
+ setError(null); // Don't show error in dev mode
149
+ } else {
150
+ console.error('[AdBanner] CORS Error Details:', {
151
+ message: err.message,
152
+ note: 'This is a server-side configuration issue. The thead-vantage.com server needs to handle OPTIONS requests without redirecting. See CORS_CONFIGURATION.md for implementation details.',
153
+ });
154
+ // Show generic message to end users
155
+ setError('Ad unavailable');
156
+ }
157
+ } else {
158
+ // For other errors, show the error message (unless in development)
159
+ if (development) {
160
+ console.warn('[AdBanner] Error in development mode:', err);
161
+ // Create a placeholder ad for development
162
+ setAd({
163
+ id: 'dev-placeholder',
164
+ name: 'Development Placeholder Ad',
165
+ type: 'image',
166
+ contentUrl: '/placeholder-ad.png',
167
+ targetUrl: '#',
168
+ width: 300,
169
+ height: 250,
170
+ });
171
+ setDevMode(true);
172
+ setError(null); // Don't show errors in dev mode
173
+ } else {
174
+ setError(err instanceof Error ? err.message : 'Failed to fetch ad');
175
+ }
176
+ }
177
+ } finally {
178
+ setLoading(false);
179
+ }
180
+ };
181
+
182
+ loadAd();
183
+
184
+ // Cleanup timer on unmount or when dependencies change
185
+ return () => {
186
+ if (impressionTimerRef.current) {
187
+ clearTimeout(impressionTimerRef.current);
188
+ impressionTimerRef.current = null;
189
+ }
190
+ // Reset impression tracking ref when component unmounts or key dependencies change
191
+ // This allows re-tracking if the ad ID changes (new ad loaded)
192
+ hasTrackedImpressionRef.current = null;
193
+ };
194
+ }, [platformId, apiKey, size, apiUrl, userId, userSegment, showImpressionFinished, development]);
195
+
196
+ const handleClick = () => {
197
+ if (ad) {
198
+ trackClick(ad.id, apiKey, apiUrl, clickMetadata);
199
+ // The link will handle navigation
200
+ }
201
+ };
202
+
203
+ if (loading) {
204
+ return (
205
+ <div className={`flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded ${className}`}>
206
+ <span className="text-sm text-gray-500">Loading ad...</span>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ if (error || !ad) {
212
+ return (
213
+ <div className={`flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded ${className}`}>
214
+ <span className="text-sm text-gray-500">{error || 'Ad unavailable'}</span>
215
+ </div>
216
+ );
217
+ }
218
+
219
+ return (
220
+ <div className={`relative ${className}`}>
221
+ <a
222
+ href={ad.targetUrl}
223
+ onClick={handleClick}
224
+ target="_blank"
225
+ rel="noopener noreferrer"
226
+ className="block"
227
+ >
228
+ {(ad.type === 'image' || ad.type === 'standard') && ad.contentUrl ? (
229
+ <Image
230
+ src={ad.contentUrl}
231
+ alt={ad.name}
232
+ width={ad.width || 300}
233
+ height={ad.height || 250}
234
+ className="rounded"
235
+ unoptimized
236
+ />
237
+ ) : (
238
+ <div className="flex items-center justify-center bg-gray-200 dark:bg-gray-700 rounded" style={{ width: ad.width || 300, height: ad.height || 250 }}>
239
+ <span className="text-sm text-gray-500">Ad content</span>
240
+ </div>
241
+ )}
242
+ </a>
243
+ {showImpressionFinished && impressionStatus !== 'pending' && (
244
+ <div className="absolute top-2 right-2">
245
+ {impressionStatus === 'counting' ? (
246
+ <svg
247
+ className="w-5 h-5 text-gray-400 animate-spin"
248
+ xmlns="http://www.w3.org/2000/svg"
249
+ fill="none"
250
+ viewBox="0 0 24 24"
251
+ >
252
+ <circle
253
+ className="opacity-25"
254
+ cx="12"
255
+ cy="12"
256
+ r="10"
257
+ stroke="currentColor"
258
+ strokeWidth="4"
259
+ />
260
+ <path
261
+ className="opacity-75"
262
+ fill="currentColor"
263
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
264
+ />
265
+ </svg>
266
+ ) : impressionStatus === 'completed' ? (
267
+ <svg
268
+ className="w-5 h-5 text-green-500"
269
+ xmlns="http://www.w3.org/2000/svg"
270
+ viewBox="0 0 20 20"
271
+ fill="currentColor"
272
+ >
273
+ <path
274
+ fillRule="evenodd"
275
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
276
+ clipRule="evenodd"
277
+ />
278
+ </svg>
279
+ ) : null}
280
+ </div>
281
+ )}
282
+ {devMode && (
283
+ <p className="text-xs text-gray-500 mt-1">[DEV] No tracking active</p>
284
+ )}
285
+ </div>
286
+ );
287
+ }
288
+
@@ -148,8 +148,21 @@ export function isDevelopmentMode(): boolean {
148
148
  * - Running on localhost (platform developers testing locally)
149
149
  * - OR in TheAd Vantage dev mode
150
150
  * - OR in general development mode
151
+ * - OR explicitly set via runtime config (development prop)
151
152
  */
152
153
  export function shouldUseDevFlags(): boolean {
154
+ // Check runtime config first (set via development prop)
155
+ if (typeof window !== 'undefined') {
156
+ const runtimeDevMode = (window as any).__THEAD_VANTAGE_DEV_MODE__;
157
+ if (runtimeDevMode === true) {
158
+ return true;
159
+ }
160
+ }
161
+ // Also check global config
162
+ if (globalConfig.devMode === true) {
163
+ return true;
164
+ }
165
+
153
166
  // TheAd Vantage dev mode always uses dev flags
154
167
  if (isTheadVantageDevMode()) {
155
168
  return true;