Skip to content

Commit 550be1e

Browse files
authored
Add ZeroClick ad fallback (#642)
1 parent eb0ac62 commit 550be1e

11 files changed

Lines changed: 334 additions & 83 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ STRIPE_SUBSCRIPTION_500_PRICE_ID=price_dummy_subscription_500_id
3030
# External Services
3131
LINKUP_API_KEY=dummy_linkup_key
3232
LOOPS_API_KEY=dummy_loops_key
33+
ZEROCLICK_API_KEY=dummy_zeroclick_key
3334

3435
# Discord Integration
3536
DISCORD_PUBLIC_KEY=dummy_discord_public_key

cli/src/chat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export const Chat = ({
177177
const { ads, recordImpression } = useGravityAd({
178178
enabled: IS_FREEBUFF || !hasSubscription,
179179
provider: 'gravity',
180-
fallbackProvider: 'carbon',
180+
fallbackProvider: 'zeroclick',
181181
})
182182

183183
// Set initial mode from CLI flag on mount

cli/src/components/choice-ad-banner.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { AdResponse } from '../hooks/use-gravity-ad'
1111

1212
interface ChoiceAdBannerProps {
1313
ads: AdResponse[]
14-
onImpression?: (impUrl: string) => void
14+
onImpression?: (ad: AdResponse) => void
1515
}
1616

1717
export const CHOICE_AD_BANNER_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom
@@ -82,7 +82,7 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpressio
8282
useEffect(() => {
8383
if (onImpression) {
8484
for (const ad of visibleAds) {
85-
onImpression(ad.impUrl)
85+
onImpression(ad)
8686
}
8787
}
8888
}, [visibleAds, onImpression])

cli/src/components/waiting-room-screen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,12 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
234234
// Always enable ads in the waiting room — this is where monetization lives.
235235
// forceStart bypasses the "wait for first user message" gate inside the hook,
236236
// which would otherwise block ads here since no conversation exists yet.
237-
// Try Gravity first, then fall back to Carbon when Gravity doesn't fill.
237+
// Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill.
238238
const { ads, recordImpression } = useGravityAd({
239239
enabled: true,
240240
forceStart: true,
241241
provider: 'gravity',
242-
fallbackProvider: 'carbon',
242+
fallbackProvider: 'zeroclick',
243243
surface: 'waiting_room',
244244
})
245245

cli/src/hooks/use-gravity-ad.ts

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
1515
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
1616
const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
1717
const 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)
2021
export 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'
3639
export type AdSurface = 'waiting_room'
3740

3841
export 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
5457
function 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 {

packages/internal/src/env-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export const serverEnvSchema = clientEnvSchema.extend({
1616
CONTEXT7_API_KEY: z.string().optional(),
1717
GRAVITY_API_KEY: z.string().min(1),
1818
IPINFO_TOKEN: z.string().min(1),
19+
// ZeroClick tenant API key used for server-side offer fallback requests.
20+
ZEROCLICK_API_KEY: z.string().min(1).optional(),
1921
// BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad.
2022
// Optional: when unset the Carbon provider returns no ad and callers fall
2123
// back to their cached ads / fallback content. `CVADC53U` is the public
@@ -98,6 +100,7 @@ export const serverProcessEnv: ServerInput = {
98100
CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY,
99101
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
100102
IPINFO_TOKEN: process.env.IPINFO_TOKEN,
103+
ZEROCLICK_API_KEY: process.env.ZEROCLICK_API_KEY,
101104
CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY,
102105
PORT: process.env.PORT,
103106

web/src/app/api/v1/ads/_post.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { requireUserFromApiKey } from '../_helpers'
99

1010
import { createCarbonProvider } from '@/lib/ad-providers/carbon'
1111
import { createGravityProvider } from '@/lib/ad-providers/gravity'
12+
import { createZeroClickProvider } from '@/lib/ad-providers/zeroclick'
1213

1314
import type {
1415
AdProvider,
@@ -34,7 +35,9 @@ const deviceSchema = z.object({
3435
locale: z.string().optional(),
3536
})
3637

37-
const providerSchema = z.enum(['gravity', 'carbon']).default('gravity')
38+
const providerSchema = z
39+
.enum(['gravity', 'carbon', 'zeroclick'])
40+
.default('gravity')
3841
const surfaceSchema = z.enum(['waiting_room'])
3942

4043
const bodySchema = z.object({
@@ -50,6 +53,7 @@ const bodySchema = z.object({
5053
export type AdsEnv = {
5154
GRAVITY_API_KEY: string
5255
CARBON_ZONE_KEY?: string
56+
ZEROCLICK_API_KEY?: string
5357
CB_ENVIRONMENT: string
5458
}
5559

@@ -126,6 +130,12 @@ export async function postAds(params: {
126130
return noAdsResponse(providerId)
127131
}
128132
provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY })
133+
} else if (providerId === 'zeroclick') {
134+
if (!serverEnv.ZEROCLICK_API_KEY) {
135+
logger.warn('[ads] ZEROCLICK_API_KEY not configured')
136+
return noAdsResponse(providerId)
137+
}
138+
provider = createZeroClickProvider({ apiKey: serverEnv.ZEROCLICK_API_KEY })
129139
} else {
130140
if (!serverEnv.GRAVITY_API_KEY) {
131141
logger.warn('[ads] GRAVITY_API_KEY not configured')

web/src/app/api/v1/ads/impression/_post.ts

Lines changed: 36 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,8 @@ export async function postAdImpression(params: {
8484
trackEvent: TrackEventFn
8585
fetch: typeof globalThis.fetch
8686
}) {
87-
const {
88-
req,
89-
getUserInfoFromApiKey,
90-
loggerWithContext,
91-
trackEvent,
92-
fetch,
93-
} = params
87+
const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, fetch } =
88+
params
9489
const baseLogger = params.logger
9590

9691
// Parse and validate request body
@@ -179,36 +174,39 @@ export async function postAdImpression(params: {
179174
}
180175

181176
// Fire the primary impression pixel plus any provider-specific extra
182-
// tracking pixels (Carbon returns these via the `pixel` field). Each extra
183-
// pixel may contain `[timestamp]` which we substitute with unix seconds.
184-
const now = Math.floor(Date.now() / 1000).toString()
185-
const extraPixels = (adRecord.extra_pixels ?? []).map((p) =>
186-
p.replaceAll('[timestamp]', now),
187-
)
188-
const pixelUrls = [impUrl, ...extraPixels]
189-
190-
await Promise.all(
191-
pixelUrls.map(async (pixelUrl) => {
192-
try {
193-
await fetch(pixelUrl)
194-
} catch (error) {
195-
logger.warn(
196-
{
197-
pixelUrl,
198-
error:
199-
error instanceof Error
200-
? { name: error.name, message: error.message }
201-
: error,
202-
},
203-
'[ads] Failed to fire impression pixel',
204-
)
205-
}
206-
}),
207-
)
208-
logger.info(
209-
{ userId, provider: adRecord.provider, pixelCount: pixelUrls.length },
210-
'[ads] Fired impression pixels',
211-
)
177+
// tracking pixels (Carbon returns these via the `pixel` field). ZeroClick
178+
// impressions must be reported from the client device, so the CLI handles
179+
// that directly and this endpoint only records our local state.
180+
if (adRecord.provider !== 'zeroclick') {
181+
const now = Math.floor(Date.now() / 1000).toString()
182+
const extraPixels = (adRecord.extra_pixels ?? []).map((p) =>
183+
p.replaceAll('[timestamp]', now),
184+
)
185+
const pixelUrls = [impUrl, ...extraPixels]
186+
187+
await Promise.all(
188+
pixelUrls.map(async (pixelUrl) => {
189+
try {
190+
await fetch(pixelUrl)
191+
} catch (error) {
192+
logger.warn(
193+
{
194+
pixelUrl,
195+
error:
196+
error instanceof Error
197+
? { name: error.name, message: error.message }
198+
: error,
199+
},
200+
'[ads] Failed to fire impression pixel',
201+
)
202+
}
203+
}),
204+
)
205+
logger.info(
206+
{ userId, provider: adRecord.provider, pixelCount: pixelUrls.length },
207+
'[ads] Fired impression pixels',
208+
)
209+
}
212210

213211
// No credits granted for ad impressions
214212
const creditsGranted = 0
@@ -224,10 +222,7 @@ export async function postAdImpression(params: {
224222
})
225223
.where(eq(schema.adImpression.id, adRecord.id))
226224

227-
logger.info(
228-
{ userId, impUrl },
229-
'[ads] Updated ad impression record',
230-
)
225+
logger.info({ userId, impUrl }, '[ads] Updated ad impression record')
231226
} catch (error) {
232227
logger.error(
233228
{

0 commit comments

Comments
 (0)