Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ STRIPE_SUBSCRIPTION_500_PRICE_ID=price_dummy_subscription_500_id
# External Services
LINKUP_API_KEY=dummy_linkup_key
LOOPS_API_KEY=dummy_loops_key
ZEROCLICK_API_KEY=dummy_zeroclick_key

# Discord Integration
DISCORD_PUBLIC_KEY=dummy_discord_public_key
Expand Down
2 changes: 1 addition & 1 deletion cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export const Chat = ({
const { ads, recordImpression } = useGravityAd({
enabled: IS_FREEBUFF || !hasSubscription,
provider: 'gravity',
fallbackProvider: 'carbon',
fallbackProvider: 'zeroclick',
})

// Set initial mode from CLI flag on mount
Expand Down
4 changes: 2 additions & 2 deletions cli/src/components/choice-ad-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { AdResponse } from '../hooks/use-gravity-ad'

interface ChoiceAdBannerProps {
ads: AdResponse[]
onImpression?: (impUrl: string) => void
onImpression?: (ad: AdResponse) => void
}

export const CHOICE_AD_BANNER_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom
Expand Down Expand Up @@ -82,7 +82,7 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpressio
useEffect(() => {
if (onImpression) {
for (const ad of visibleAds) {
onImpression(ad.impUrl)
onImpression(ad)
}
}
}, [visibleAds, onImpression])
Expand Down
4 changes: 2 additions & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,12 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
// Always enable ads in the waiting room — this is where monetization lives.
// forceStart bypasses the "wait for first user message" gate inside the hook,
// which would otherwise block ads here since no conversation exists yet.
// Try Gravity first, then fall back to Carbon when Gravity doesn't fill.
// Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill.
const { ads, recordImpression } = useGravityAd({
enabled: true,
forceStart: true,
provider: 'gravity',
fallbackProvider: 'carbon',
fallbackProvider: 'zeroclick',
surface: 'waiting_room',
})

Expand Down
123 changes: 88 additions & 35 deletions cli/src/hooks/use-gravity-ad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad
const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads
const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads
const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache
const ZEROCLICK_IMPRESSIONS_URL = 'https://zeroclick.dev/api/v2/impressions'

// Ad response type (normalized shape across providers; credits added after impression)
export type AdResponse = {
Expand All @@ -25,20 +26,22 @@ export type AdResponse = {
favicon: string
clickUrl: string
impUrl: string
provider?: AdProvider
impressionIds?: string[]
credits?: number // Set after impression is recorded (in cents)
}

/**
* Which upstream ad network to query. The server maps each provider onto the
* same normalized response shape, so the rest of the hook is provider-agnostic.
*/
export type AdProvider = 'gravity' | 'carbon'
export type AdProvider = 'gravity' | 'carbon' | 'zeroclick'
export type AdSurface = 'waiting_room'

export type GravityAdState = {
ads: AdResponse[] | null
isLoading: boolean
recordImpression: (impUrl: string) => void
recordImpression: (ad: AdResponse) => void
}

// Consolidated controller state for the ad rotation logic
Expand All @@ -52,6 +55,10 @@ type GravityController = {

// Pure helper: add a choice ad set to the choice cache
function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void {
// ZeroClick offer responses must not be stored for later display. Keep them
// out of the rotation cache and only render them for the live request.
if (ads.some((ad) => ad.provider === 'zeroclick')) return

// Deduplicate by checking if any set has the same first impUrl
const key = ads[0]?.impUrl
if (key && ctrl.choiceCache.some((set) => set[0]?.impUrl === key)) return
Expand Down Expand Up @@ -134,50 +141,89 @@ export const useGravityAd = (options?: {
shouldHideAdsRef.current = shouldHideAds

// Fire impression and update credits (called when showing an ad)
const recordImpressionOnce = (impUrl: string): void => {
const recordImpressionOnce = (ad: AdResponse): void => {
// Don't record impressions when ads should be hidden
if (shouldHideAdsRef.current) return

const ctrl = ctrlRef.current
const { impUrl } = ad
if (ctrl.impressionsFired.has(impUrl)) return
ctrl.impressionsFired.add(impUrl)

const authToken = getAuthToken()
if (!authToken) {
logger.warn('[ads] No auth token, skipping impression recording')
return
}
const recordLocalImpression = async (): Promise<void> => {
const authToken = getAuthToken()
if (!authToken) {
logger.warn('[ads] No auth token, skipping local impression recording')
return
}

// Include mode in request - Freebuff should not grant credits (no balance concept).
const agentMode = useChatStore.getState().agentMode
// Include mode in request - Freebuff should not grant credits (no balance concept).
const agentMode = useChatStore.getState().agentMode

fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ impUrl, mode: agentMode }),
})
.then((res) => res.json())
.then((data) => {
if (data.creditsGranted > 0) {
logger.info(
{ creditsGranted: data.creditsGranted },
'[ads] Ad impression credits granted',
const res = await fetch(`${WEBSITE_URL}/api/v1/ads/impression`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ impUrl, mode: agentMode }),
})

if (!res.ok) {
logger.debug(
{ status: res.status },
'[ads] Failed to record local ad impression',
)
return
}

const data = await res.json()
if (data.creditsGranted > 0) {
logger.info(
{ creditsGranted: data.creditsGranted },
'[ads] Ad impression credits granted',
)
// Also update credits in visible ads
setAds((cur) => {
if (!cur) return cur
return cur.map((a) =>
a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a,
)
// Also update credits in visible ads
setAds((cur) => {
if (!cur) return cur
return cur.map((a) =>
a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a,
)
})
}
}

if (ad.provider === 'zeroclick' && ad.impressionIds?.length) {
void (async () => {
try {
const res = await fetch(ZEROCLICK_IMPRESSIONS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ad.impressionIds }),
})

if (!res.ok) {
logger.debug(
{ status: res.status },
'[ads] Failed to record ZeroClick impression',
)
return
}
} catch (err) {
logger.debug({ err }, '[ads] Failed to record ZeroClick impression')
return
}
})
.catch((err) => {
logger.debug({ err }, '[ads] Failed to record ad impression')
})

recordLocalImpression().catch((err) => {
logger.debug({ err }, '[ads] Failed to record local ad impression')
})
})()
return
}

recordLocalImpression().catch((err) => {
logger.debug({ err }, '[ads] Failed to record ad impression')
})
}

type FetchAdResult = { ads: AdResponse[] } | null
Expand Down Expand Up @@ -265,7 +311,12 @@ export const useGravityAd = (options?: {
const data = await response.json()

if (Array.isArray(data.ads) && data.ads.length > 0) {
return { ads: data.ads as AdResponse[] }
return {
ads: (data.ads as AdResponse[]).map((ad) => ({
...ad,
provider: data.provider ?? providerToTry,
})),
}
}
} catch (err) {
logger.error(
Expand Down Expand Up @@ -305,6 +356,8 @@ export const useGravityAd = (options?: {
if (cachedSet) {
ctrl.adsShownSinceActivity += 1
setAds(cachedSet)
} else {
setAds((cur) => (cur?.[0]?.provider === 'zeroclick' ? null : cur))
}
}
} finally {
Expand Down
3 changes: 3 additions & 0 deletions packages/internal/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const serverEnvSchema = clientEnvSchema.extend({
CONTEXT7_API_KEY: z.string().optional(),
GRAVITY_API_KEY: z.string().min(1),
IPINFO_TOKEN: z.string().min(1),
// ZeroClick tenant API key used for server-side offer fallback requests.
ZEROCLICK_API_KEY: z.string().min(1).optional(),
// BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad.
// Optional: when unset the Carbon provider returns no ad and callers fall
// back to their cached ads / fallback content. `CVADC53U` is the public
Expand Down Expand Up @@ -98,6 +100,7 @@ export const serverProcessEnv: ServerInput = {
CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY,
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
IPINFO_TOKEN: process.env.IPINFO_TOKEN,
ZEROCLICK_API_KEY: process.env.ZEROCLICK_API_KEY,
CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY,
PORT: process.env.PORT,

Expand Down
12 changes: 11 additions & 1 deletion web/src/app/api/v1/ads/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { requireUserFromApiKey } from '../_helpers'

import { createCarbonProvider } from '@/lib/ad-providers/carbon'
import { createGravityProvider } from '@/lib/ad-providers/gravity'
import { createZeroClickProvider } from '@/lib/ad-providers/zeroclick'

import type {
AdProvider,
Expand All @@ -34,7 +35,9 @@ const deviceSchema = z.object({
locale: z.string().optional(),
})

const providerSchema = z.enum(['gravity', 'carbon']).default('gravity')
const providerSchema = z
.enum(['gravity', 'carbon', 'zeroclick'])
.default('gravity')
const surfaceSchema = z.enum(['waiting_room'])

const bodySchema = z.object({
Expand All @@ -50,6 +53,7 @@ const bodySchema = z.object({
export type AdsEnv = {
GRAVITY_API_KEY: string
CARBON_ZONE_KEY?: string
ZEROCLICK_API_KEY?: string
CB_ENVIRONMENT: string
}

Expand Down Expand Up @@ -126,6 +130,12 @@ export async function postAds(params: {
return noAdsResponse(providerId)
}
provider = createCarbonProvider({ zoneKey: serverEnv.CARBON_ZONE_KEY })
} else if (providerId === 'zeroclick') {
if (!serverEnv.ZEROCLICK_API_KEY) {
logger.warn('[ads] ZEROCLICK_API_KEY not configured')
return noAdsResponse(providerId)
}
provider = createZeroClickProvider({ apiKey: serverEnv.ZEROCLICK_API_KEY })
} else {
if (!serverEnv.GRAVITY_API_KEY) {
logger.warn('[ads] GRAVITY_API_KEY not configured')
Expand Down
77 changes: 36 additions & 41 deletions web/src/app/api/v1/ads/impression/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,8 @@ export async function postAdImpression(params: {
trackEvent: TrackEventFn
fetch: typeof globalThis.fetch
}) {
const {
req,
getUserInfoFromApiKey,
loggerWithContext,
trackEvent,
fetch,
} = params
const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, fetch } =
params
const baseLogger = params.logger

// Parse and validate request body
Expand Down Expand Up @@ -179,36 +174,39 @@ export async function postAdImpression(params: {
}

// Fire the primary impression pixel plus any provider-specific extra
// tracking pixels (Carbon returns these via the `pixel` field). Each extra
// pixel may contain `[timestamp]` which we substitute with unix seconds.
const now = Math.floor(Date.now() / 1000).toString()
const extraPixels = (adRecord.extra_pixels ?? []).map((p) =>
p.replaceAll('[timestamp]', now),
)
const pixelUrls = [impUrl, ...extraPixels]

await Promise.all(
pixelUrls.map(async (pixelUrl) => {
try {
await fetch(pixelUrl)
} catch (error) {
logger.warn(
{
pixelUrl,
error:
error instanceof Error
? { name: error.name, message: error.message }
: error,
},
'[ads] Failed to fire impression pixel',
)
}
}),
)
logger.info(
{ userId, provider: adRecord.provider, pixelCount: pixelUrls.length },
'[ads] Fired impression pixels',
)
// tracking pixels (Carbon returns these via the `pixel` field). ZeroClick
// impressions must be reported from the client device, so the CLI handles
// that directly and this endpoint only records our local state.
if (adRecord.provider !== 'zeroclick') {
const now = Math.floor(Date.now() / 1000).toString()
const extraPixels = (adRecord.extra_pixels ?? []).map((p) =>
p.replaceAll('[timestamp]', now),
)
const pixelUrls = [impUrl, ...extraPixels]

await Promise.all(
pixelUrls.map(async (pixelUrl) => {
try {
await fetch(pixelUrl)
} catch (error) {
logger.warn(
{
pixelUrl,
error:
error instanceof Error
? { name: error.name, message: error.message }
: error,
},
'[ads] Failed to fire impression pixel',
)
}
}),
)
logger.info(
{ userId, provider: adRecord.provider, pixelCount: pixelUrls.length },
'[ads] Fired impression pixels',
)
}

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

logger.info(
{ userId, impUrl },
'[ads] Updated ad impression record',
)
logger.info({ userId, impUrl }, '[ads] Updated ad impression record')
} catch (error) {
logger.error(
{
Expand Down
Loading
Loading