@@ -15,6 +15,7 @@ const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
1515const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
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
18+ const ZEROCLICK_IMPRESSIONS_URL = 'https://zeroclick.dev/api/v2/impressions'
1819
1920// Ad response type (normalized shape across providers; credits added after impression)
2021export type AdResponse = {
@@ -25,20 +26,22 @@ export type AdResponse = {
2526 favicon : string
2627 clickUrl : string
2728 impUrl : string
29+ provider ?: AdProvider
30+ impressionIds ?: string [ ]
2831 credits ?: number // Set after impression is recorded (in cents)
2932}
3033
3134/**
3235 * Which upstream ad network to query. The server maps each provider onto the
3336 * same normalized response shape, so the rest of the hook is provider-agnostic.
3437 */
35- export type AdProvider = 'gravity' | 'carbon'
38+ export type AdProvider = 'gravity' | 'carbon' | 'zeroclick'
3639export type AdSurface = 'waiting_room'
3740
3841export type GravityAdState = {
3942 ads : AdResponse [ ] | null
4043 isLoading : boolean
41- recordImpression : ( impUrl : string ) => void
44+ recordImpression : ( ad : AdResponse ) => void
4245}
4346
4447// Consolidated controller state for the ad rotation logic
@@ -52,6 +55,10 @@ type GravityController = {
5255
5356// Pure helper: add a choice ad set to the choice cache
5457function addToChoiceCache ( ctrl : GravityController , ads : AdResponse [ ] ) : void {
58+ // ZeroClick offer responses must not be stored for later display. Keep them
59+ // out of the rotation cache and only render them for the live request.
60+ if ( ads . some ( ( ad ) => ad . provider === 'zeroclick' ) ) return
61+
5562 // Deduplicate by checking if any set has the same first impUrl
5663 const key = ads [ 0 ] ?. impUrl
5764 if ( key && ctrl . choiceCache . some ( ( set ) => set [ 0 ] ?. impUrl === key ) ) return
@@ -134,50 +141,89 @@ export const useGravityAd = (options?: {
134141 shouldHideAdsRef . current = shouldHideAds
135142
136143 // Fire impression and update credits (called when showing an ad)
137- const recordImpressionOnce = ( impUrl : string ) : void => {
144+ const recordImpressionOnce = ( ad : AdResponse ) : void => {
138145 // Don't record impressions when ads should be hidden
139146 if ( shouldHideAdsRef . current ) return
140147
141148 const ctrl = ctrlRef . current
149+ const { impUrl } = ad
142150 if ( ctrl . impressionsFired . has ( impUrl ) ) return
143151 ctrl . impressionsFired . add ( impUrl )
144152
145- const authToken = getAuthToken ( )
146- if ( ! authToken ) {
147- logger . warn ( '[ads] No auth token, skipping impression recording' )
148- return
149- }
153+ const recordLocalImpression = async ( ) : Promise < void > => {
154+ const authToken = getAuthToken ( )
155+ if ( ! authToken ) {
156+ logger . warn ( '[ads] No auth token, skipping local impression recording' )
157+ return
158+ }
150159
151- // Include mode in request - Freebuff should not grant credits (no balance concept).
152- const agentMode = useChatStore . getState ( ) . agentMode
160+ // Include mode in request - Freebuff should not grant credits (no balance concept).
161+ const agentMode = useChatStore . getState ( ) . agentMode
153162
154- fetch ( `${ WEBSITE_URL } /api/v1/ads/impression` , {
155- method : 'POST' ,
156- headers : {
157- 'Content-Type' : 'application/json' ,
158- Authorization : `Bearer ${ authToken } ` ,
159- } ,
160- body : JSON . stringify ( { impUrl, mode : agentMode } ) ,
161- } )
162- . then ( ( res ) => res . json ( ) )
163- . then ( ( data ) => {
164- if ( data . creditsGranted > 0 ) {
165- logger . info (
166- { creditsGranted : data . creditsGranted } ,
167- '[ads] Ad impression credits granted' ,
163+ const res = await fetch ( `${ WEBSITE_URL } /api/v1/ads/impression` , {
164+ method : 'POST' ,
165+ headers : {
166+ 'Content-Type' : 'application/json' ,
167+ Authorization : `Bearer ${ authToken } ` ,
168+ } ,
169+ body : JSON . stringify ( { impUrl, mode : agentMode } ) ,
170+ } )
171+
172+ if ( ! res . ok ) {
173+ logger . debug (
174+ { status : res . status } ,
175+ '[ads] Failed to record local ad impression' ,
176+ )
177+ return
178+ }
179+
180+ const data = await res . json ( )
181+ if ( data . creditsGranted > 0 ) {
182+ logger . info (
183+ { creditsGranted : data . creditsGranted } ,
184+ '[ads] Ad impression credits granted' ,
185+ )
186+ // Also update credits in visible ads
187+ setAds ( ( cur ) => {
188+ if ( ! cur ) return cur
189+ return cur . map ( ( a ) =>
190+ a . impUrl === impUrl ? { ...a , credits : data . creditsGranted } : a ,
168191 )
169- // Also update credits in visible ads
170- setAds ( ( cur ) => {
171- if ( ! cur ) return cur
172- return cur . map ( ( a ) =>
173- a . impUrl === impUrl ? { ...a , credits : data . creditsGranted } : a ,
174- )
192+ } )
193+ }
194+ }
195+
196+ if ( ad . provider === 'zeroclick' && ad . impressionIds ?. length ) {
197+ void ( async ( ) => {
198+ try {
199+ const res = await fetch ( ZEROCLICK_IMPRESSIONS_URL , {
200+ method : 'POST' ,
201+ headers : { 'Content-Type' : 'application/json' } ,
202+ body : JSON . stringify ( { ids : ad . impressionIds } ) ,
175203 } )
204+
205+ if ( ! res . ok ) {
206+ logger . debug (
207+ { status : res . status } ,
208+ '[ads] Failed to record ZeroClick impression' ,
209+ )
210+ return
211+ }
212+ } catch ( err ) {
213+ logger . debug ( { err } , '[ads] Failed to record ZeroClick impression' )
214+ return
176215 }
177- } )
178- . catch ( ( err ) => {
179- logger . debug ( { err } , '[ads] Failed to record ad impression' )
180- } )
216+
217+ recordLocalImpression ( ) . catch ( ( err ) => {
218+ logger . debug ( { err } , '[ads] Failed to record local ad impression' )
219+ } )
220+ } ) ( )
221+ return
222+ }
223+
224+ recordLocalImpression ( ) . catch ( ( err ) => {
225+ logger . debug ( { err } , '[ads] Failed to record ad impression' )
226+ } )
181227 }
182228
183229 type FetchAdResult = { ads : AdResponse [ ] } | null
@@ -265,7 +311,12 @@ export const useGravityAd = (options?: {
265311 const data = await response . json ( )
266312
267313 if ( Array . isArray ( data . ads ) && data . ads . length > 0 ) {
268- return { ads : data . ads as AdResponse [ ] }
314+ return {
315+ ads : ( data . ads as AdResponse [ ] ) . map ( ( ad ) => ( {
316+ ...ad ,
317+ provider : data . provider ?? providerToTry ,
318+ } ) ) ,
319+ }
269320 }
270321 } catch ( err ) {
271322 logger . error (
@@ -305,6 +356,8 @@ export const useGravityAd = (options?: {
305356 if ( cachedSet ) {
306357 ctrl . adsShownSinceActivity += 1
307358 setAds ( cachedSet )
359+ } else {
360+ setAds ( ( cur ) => ( cur ?. [ 0 ] ?. provider === 'zeroclick' ? null : cur ) )
308361 }
309362 }
310363 } finally {
0 commit comments