@@ -16,7 +16,7 @@ const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then p
1616const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
1717const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache
1818
19- // Ad response type (matches Gravity API response, credits added after impression)
19+ // Ad response type (normalized shape across providers; credits added after impression)
2020export type AdResponse = {
2121 adText : string
2222 title : string
@@ -30,6 +30,12 @@ export type AdResponse = {
3030
3131export type AdVariant = 'banner' | 'choice'
3232
33+ /**
34+ * Which upstream ad network to query. The server maps each provider onto the
35+ * same normalized response shape, so the rest of the hook is provider-agnostic.
36+ */
37+ export type AdProvider = 'gravity' | 'carbon'
38+
3339export type AdData =
3440 | { variant : 'banner' ; ad : AdResponse }
3541 | { variant : 'choice' ; ads : AdResponse [ ] }
@@ -102,9 +108,12 @@ export const useGravityAd = (options?: {
102108 /** Skip the "wait for first user message" gate. Used by the freebuff
103109 * waiting room, which has no conversation but still needs ads. */
104110 forceStart ?: boolean
111+ /** Which ad network to query. Defaults to Gravity. */
112+ provider ?: AdProvider
105113} ) : GravityAdState => {
106114 const enabled = options ?. enabled ?? true
107115 const forceStart = options ?. forceStart ?? false
116+ const provider : AdProvider = options ?. provider ?? 'gravity'
108117 const [ ad , setAd ] = useState < AdResponse | null > ( null )
109118 const [ adData , setAdData ] = useState < AdData | null > ( null )
110119 const [ isLoading , setIsLoading ] = useState ( false )
@@ -159,7 +168,7 @@ export const useGravityAd = (options?: {
159168
160169 const authToken = getAuthToken ( )
161170 if ( ! authToken ) {
162- logger . warn ( '[gravity ] No auth token, skipping impression recording' )
171+ logger . warn ( '[ads ] No auth token, skipping impression recording' )
163172 return
164173 }
165174
@@ -179,7 +188,7 @@ export const useGravityAd = (options?: {
179188 if ( data . creditsGranted > 0 ) {
180189 logger . info (
181190 { creditsGranted : data . creditsGranted } ,
182- '[gravity ] Ad impression credits granted' ,
191+ '[ads ] Ad impression credits granted' ,
183192 )
184193 setAd ( ( cur ) =>
185194 cur ?. impUrl === impUrl
@@ -205,7 +214,7 @@ export const useGravityAd = (options?: {
205214 }
206215 } )
207216 . catch ( ( err ) => {
208- logger . debug ( { err } , '[gravity ] Failed to record ad impression' )
217+ logger . debug ( { err } , '[ads ] Failed to record ad impression' )
209218 } )
210219 }
211220
@@ -235,7 +244,7 @@ export const useGravityAd = (options?: {
235244
236245 const authToken = getAuthToken ( )
237246 if ( ! authToken ) {
238- logger . warn ( '[gravity ] No auth token available' )
247+ logger . warn ( '[ads ] No auth token available' )
239248 return null
240249 }
241250
@@ -277,16 +286,21 @@ export const useGravityAd = (options?: {
277286 Authorization : `Bearer ${ authToken } ` ,
278287 } ,
279288 body : JSON . stringify ( {
289+ provider,
280290 messages : adMessages ,
281291 sessionId : useChatStore . getState ( ) . chatSessionId ,
282292 device : getDeviceInfo ( ) ,
293+ // Carbon requires a real browser-ish useragent for targeting/fraud
294+ // detection. Gravity ignores it. We source one centrally so every
295+ // provider that needs it sees the same value.
296+ userAgent : getAdUserAgent ( ) ,
283297 } ) ,
284298 } )
285299
286300 if ( ! response . ok ) {
287301 logger . warn (
288- { status : response . status , response : await response . json ( ) } ,
289- '[gravity ] Web API returned error' ,
302+ { provider , status : response . status , response : await response . json ( ) } ,
303+ '[ads ] Web API returned error' ,
290304 )
291305 return null
292306 }
@@ -304,7 +318,7 @@ export const useGravityAd = (options?: {
304318
305319 return null
306320 } catch ( err ) {
307- logger . error ( { err } , '[gravity ] Failed to fetch ad' )
321+ logger . error ( { err } , '[ads ] Failed to fetch ad' )
308322 return null
309323 }
310324 }
@@ -465,3 +479,22 @@ function getDeviceInfo(): DeviceInfo {
465479
466480 return { os, timezone, locale }
467481}
482+
483+ /**
484+ * Useragent string passed to ad providers. Carbon (BuySellAds) requires a
485+ * plausible browser useragent for targeting and fraud screening. We send a
486+ * stable desktop Chrome-on-{os} UA per platform so targeting is consistent
487+ * across users on the same platform without sharing anything identifying.
488+ *
489+ * Chrome version needs bumping periodically — stale UAs look bot-ish to ad
490+ * networks. Last bumped: 2026-04-21. Revisit roughly every 6 months.
491+ */
492+ const AD_CHROME_VERSION = '124.0.0.0'
493+ function getAdUserAgent ( ) : string {
494+ const osUA : Record < string , string > = {
495+ darwin : `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ AD_CHROME_VERSION } Safari/537.36` ,
496+ win32 : `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ AD_CHROME_VERSION } Safari/537.36` ,
497+ linux : `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ AD_CHROME_VERSION } Safari/537.36` ,
498+ }
499+ return osUA [ process . platform ] ?? osUA . linux
500+ }
0 commit comments