Skip to content

Commit b29f7a4

Browse files
Gravityclaude
authored andcommitted
feat: add Choice Ad placement type with 50/50 A/B test
Introduces a new "Choice Ad" placement that shows 4 ads as side-by-side cards instead of a single banner. Users are deterministically assigned to either the existing banner or the new choice variant via a hash of their userId (50/50 split). Backend: requests 4 placement IDs (choice-ad-1 through choice-ad-4) from Gravity API for the choice variant. All returned ads are stored to ad_impression and impressions fire for each card. Client: new ChoiceAdBanner component renders equal-width bordered cards with CTA button + domain per card, hover effects, and theme-aware styling. Gracefully handles partial fills (1-4 ads). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f95f9a5 commit b29f7a4

4 files changed

Lines changed: 475 additions & 73 deletions

File tree

cli/src/chat.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useShallow } from 'zustand/react/shallow'
1414
import { getAdsEnabled, handleAdsDisable } from './commands/ads'
1515
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
1616
import { AdBanner } from './components/ad-banner'
17+
import { ChoiceAdBanner } from './components/choice-ad-banner'
1718
import { ChatInputBar } from './components/chat-input-bar'
1819
import { LoadPreviousButton } from './components/load-previous-button'
1920
import { ReviewScreen } from './components/review-screen'
@@ -168,7 +169,7 @@ export const Chat = ({
168169
})
169170
const hasSubscription = subscriptionData?.hasSubscription ?? false
170171

171-
const { ad } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
172+
const { ad, adData } = useGravityAd({ enabled: IS_FREEBUFF || !hasSubscription })
172173
const [adsManuallyDisabled, setAdsManuallyDisabled] = useState(false)
173174

174175
const handleDisableAds = useCallback(() => {
@@ -1445,11 +1446,19 @@ export const Chat = ({
14451446
)}
14461447

14471448
{ad && (IS_FREEBUFF || (!adsManuallyDisabled && getAdsEnabled())) && (
1448-
<AdBanner
1449-
ad={ad}
1450-
onDisableAds={handleDisableAds}
1451-
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1452-
/>
1449+
adData?.variant === 'choice' ? (
1450+
<ChoiceAdBanner
1451+
ads={adData.ads}
1452+
onDisableAds={handleDisableAds}
1453+
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1454+
/>
1455+
) : (
1456+
<AdBanner
1457+
ad={ad}
1458+
onDisableAds={handleDisableAds}
1459+
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1460+
/>
1461+
)
14531462
)}
14541463

14551464
{reviewMode ? (
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { TextAttributes } from '@opentui/core'
2+
import { safeOpen } from '../utils/open-url'
3+
import React, { useState, useMemo } from 'react'
4+
5+
import { Button } from './button'
6+
import { Clickable } from './clickable'
7+
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
8+
import { useTheme } from '../hooks/use-theme'
9+
import { IS_FREEBUFF } from '../utils/constants'
10+
11+
import type { AdResponse } from '../hooks/use-gravity-ad'
12+
13+
interface ChoiceAdBannerProps {
14+
ads: AdResponse[]
15+
onDisableAds: () => void
16+
isFreeMode: boolean
17+
}
18+
19+
const CARD_HEIGHT = 5 // border-top + description + spacer + cta row + border-bottom
20+
21+
const extractDomain = (url: string): string => {
22+
try {
23+
const parsed = new URL(url)
24+
return parsed.hostname.replace(/^www\./, '')
25+
} catch {
26+
return url
27+
}
28+
}
29+
30+
/**
31+
* Calculate evenly distributed column widths that sum exactly to availableWidth.
32+
* Distributes remainder pixels across the first N columns so there's no gap.
33+
*/
34+
function columnWidths(count: number, availableWidth: number): number[] {
35+
const base = Math.floor(availableWidth / count)
36+
const remainder = availableWidth - base * count
37+
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0))
38+
}
39+
40+
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAds, isFreeMode }) => {
41+
const theme = useTheme()
42+
const { separatorWidth, terminalWidth } = useTerminalDimensions()
43+
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
44+
const [showInfoPanel, setShowInfoPanel] = useState(false)
45+
const [isAdLabelHovered, setIsAdLabelHovered] = useState(false)
46+
const [isHideHovered, setIsHideHovered] = useState(false)
47+
const [isCloseHovered, setIsCloseHovered] = useState(false)
48+
49+
// Available width for cards (terminal minus left/right margin of 1 each)
50+
const colAvail = terminalWidth - 2
51+
const widths = useMemo(() => columnWidths(ads.length, colAvail), [ads.length, colAvail])
52+
53+
// Sum of all credits across choice ads
54+
const totalCredits = ads.reduce((sum, ad) => sum + (ad.credits ?? 0), 0)
55+
56+
// Hover colors
57+
const hoverBorderColor = theme.link
58+
const hoverBgColor = theme.name === 'light' ? '#e8f0fe' : '#1a2332'
59+
60+
return (
61+
<box
62+
style={{
63+
width: '100%',
64+
flexDirection: 'column',
65+
}}
66+
>
67+
{/* Horizontal divider line */}
68+
<text style={{ fg: theme.muted }}>{'─'.repeat(terminalWidth)}</text>
69+
70+
{/* Header: "Sponsored picks" + credits + Ad label */}
71+
<box
72+
style={{
73+
width: '100%',
74+
paddingLeft: 1,
75+
paddingRight: 1,
76+
flexDirection: 'row',
77+
justifyContent: 'space-between',
78+
alignItems: 'flex-start',
79+
}}
80+
>
81+
<text style={{ fg: theme.muted, attributes: TextAttributes.BOLD }}>Sponsored picks</text>
82+
<box style={{ flexDirection: 'row', alignItems: 'center', gap: 2, flexShrink: 0 }}>
83+
{!IS_FREEBUFF && totalCredits > 0 && (
84+
<text style={{ fg: theme.muted }}>+{totalCredits} credits</text>
85+
)}
86+
{!IS_FREEBUFF ? (
87+
<Clickable
88+
onMouseDown={() => setShowInfoPanel(true)}
89+
onMouseOver={() => setIsAdLabelHovered(true)}
90+
onMouseOut={() => setIsAdLabelHovered(false)}
91+
>
92+
<text
93+
style={{
94+
fg: isAdLabelHovered && !showInfoPanel ? theme.foreground : theme.muted,
95+
flexShrink: 0,
96+
}}
97+
>
98+
{isAdLabelHovered && !showInfoPanel ? 'Ad ?' : ' Ad'}
99+
</text>
100+
</Clickable>
101+
) : (
102+
<text style={{ fg: theme.muted, flexShrink: 0 }}>{' Ad'}</text>
103+
)}
104+
</box>
105+
</box>
106+
107+
{/* Card columns */}
108+
<box
109+
style={{
110+
marginLeft: 1,
111+
marginRight: 1,
112+
flexDirection: 'row',
113+
}}
114+
>
115+
{ads.map((ad, i) => {
116+
const isHovered = hoveredIndex === i
117+
const domain = extractDomain(ad.url)
118+
const ctaText = ad.cta || ad.title || 'Learn more'
119+
120+
return (
121+
<Button
122+
key={ad.impUrl}
123+
onClick={() => {
124+
if (ad.clickUrl) safeOpen(ad.clickUrl)
125+
}}
126+
onMouseOver={() => setHoveredIndex(i)}
127+
onMouseOut={() => setHoveredIndex(null)}
128+
style={{
129+
width: widths[i],
130+
height: CARD_HEIGHT,
131+
borderStyle: 'single',
132+
borderColor: isHovered ? hoverBorderColor : theme.muted,
133+
paddingLeft: 1,
134+
paddingRight: 1,
135+
flexDirection: 'column',
136+
backgroundColor: isHovered ? hoverBgColor : undefined,
137+
}}
138+
>
139+
<text style={{ fg: isHovered ? theme.link : theme.muted, flexShrink: 1 }}>
140+
{ad.adText}
141+
</text>
142+
<box style={{ flexGrow: 1 }} />
143+
<box style={{ flexDirection: 'row', columnGap: 1, alignItems: 'center' }}>
144+
<text
145+
style={{
146+
fg: theme.name === 'light' ? '#ffffff' : theme.background,
147+
bg: isHovered ? theme.link : theme.muted,
148+
attributes: TextAttributes.BOLD,
149+
}}
150+
>
151+
{` ${ctaText} `}
152+
</text>
153+
<text style={{ fg: theme.muted, attributes: TextAttributes.UNDERLINE }}>
154+
{domain}
155+
</text>
156+
</box>
157+
</Button>
158+
)
159+
})}
160+
</box>
161+
162+
{/* Info panel: shown when Ad label is clicked */}
163+
{showInfoPanel && (
164+
<box
165+
style={{
166+
width: '100%',
167+
flexDirection: 'column',
168+
gap: 0,
169+
}}
170+
>
171+
<text style={{ fg: theme.muted }}>{' ' + '┄'.repeat(separatorWidth - 2)}</text>
172+
<box
173+
style={{
174+
width: '100%',
175+
paddingLeft: 1,
176+
paddingRight: 1,
177+
flexDirection: 'row',
178+
justifyContent: 'space-between',
179+
alignItems: 'flex-start',
180+
}}
181+
>
182+
<text style={{ fg: theme.muted, flexShrink: 1 }}>
183+
{IS_FREEBUFF
184+
? 'Ads help keep Freebuff free.'
185+
: 'Ads are optional and earn you credits on each impression. Feel free to hide them anytime.'}
186+
</text>
187+
<Button
188+
onClick={() => setShowInfoPanel(false)}
189+
onMouseOver={() => setIsCloseHovered(true)}
190+
onMouseOut={() => setIsCloseHovered(false)}
191+
>
192+
<text
193+
style={{
194+
fg: isCloseHovered ? theme.foreground : theme.muted,
195+
flexShrink: 0,
196+
}}
197+
>
198+
{' ✕'}
199+
</text>
200+
</Button>
201+
</box>
202+
<box
203+
style={{
204+
paddingLeft: 1,
205+
paddingRight: 1,
206+
flexDirection: 'row',
207+
alignItems: 'center',
208+
gap: 2,
209+
}}
210+
>
211+
{isFreeMode && !IS_FREEBUFF ? (
212+
<text style={{ fg: theme.muted }}>
213+
Ads are required in Free mode.
214+
</text>
215+
) : (
216+
<>
217+
<Button
218+
onClick={onDisableAds}
219+
onMouseOver={() => setIsHideHovered(true)}
220+
onMouseOut={() => setIsHideHovered(false)}
221+
>
222+
<text
223+
style={{
224+
fg: isHideHovered ? theme.link : theme.muted,
225+
attributes: TextAttributes.UNDERLINE,
226+
}}
227+
>
228+
Hide ads
229+
</text>
230+
</Button>
231+
<text style={{ fg: theme.muted }}>·</text>
232+
<text style={{ fg: theme.muted }}>
233+
Use /ads:enable to show again
234+
</text>
235+
</>
236+
)}
237+
</box>
238+
</box>
239+
)}
240+
</box>
241+
)
242+
}

0 commit comments

Comments
 (0)