Skip to content

Commit 31c320c

Browse files
committed
Clean up style, ensure minimum width of ads
1 parent e345a1e commit 31c320c

File tree

3 files changed

+46
-142
lines changed

3 files changed

+46
-142
lines changed

cli/src/chat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const Chat = ({
169169
})
170170
const hasSubscription = subscriptionData?.hasSubscription ?? false
171171

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

175175
const handleDisableAds = useCallback(() => {
@@ -1451,6 +1451,7 @@ export const Chat = ({
14511451
ads={adData.ads}
14521452
onDisableAds={handleDisableAds}
14531453
isFreeMode={IS_FREEBUFF || agentMode === 'FREE'}
1454+
onImpression={recordImpression}
14541455
/>
14551456
) : (
14561457
<AdBanner
Lines changed: 41 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
import { TextAttributes } from '@opentui/core'
22
import { safeOpen } from '../utils/open-url'
3-
import React, { useState, useMemo } from 'react'
3+
import React, { useState, useMemo, useEffect } from 'react'
44

55
import { Button } from './button'
6-
import { Clickable } from './clickable'
76
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
87
import { useTheme } from '../hooks/use-theme'
9-
import { IS_FREEBUFF } from '../utils/constants'
108

119
import type { AdResponse } from '../hooks/use-gravity-ad'
1210

1311
interface ChoiceAdBannerProps {
1412
ads: AdResponse[]
15-
onDisableAds: () => void
16-
isFreeMode: boolean
13+
onImpression?: (impUrl: string) => void
1714
}
1815

19-
const CARD_HEIGHT = 5 // border-top + description + spacer + cta row + border-bottom
16+
const CARD_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom
17+
const MAX_DESC_LINES = 2
18+
const MIN_CARD_WIDTH = 60 // Minimum width per ad card to remain readable
19+
20+
function truncateToLines(text: string, lineWidth: number, maxLines: number): string {
21+
if (lineWidth <= 0) return text
22+
const maxChars = lineWidth * maxLines
23+
if (text.length <= maxChars) return text
24+
return text.slice(0, maxChars - 1) + '…'
25+
}
2026

2127
const extractDomain = (url: string): string => {
2228
try {
@@ -37,21 +43,31 @@ function columnWidths(count: number, availableWidth: number): number[] {
3743
return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0))
3844
}
3945

40-
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAds, isFreeMode }) => {
46+
export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onImpression }) => {
4147
const theme = useTheme()
42-
const { separatorWidth, terminalWidth } = useTerminalDimensions()
48+
const { terminalWidth } = useTerminalDimensions()
4349
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)
4850

4951
// Available width for cards (terminal minus left/right margin of 1 each)
5052
const colAvail = terminalWidth - 2
51-
const widths = useMemo(() => columnWidths(ads.length, colAvail), [ads.length, colAvail])
5253

53-
// Sum of all credits across choice ads
54-
const totalCredits = ads.reduce((sum, ad) => sum + (ad.credits ?? 0), 0)
54+
// Only show as many ads as fit with a healthy minimum width; hide the rest
55+
const maxVisible = Math.max(1, Math.floor(colAvail / MIN_CARD_WIDTH))
56+
const visibleAds = useMemo(
57+
() => (ads.length > maxVisible ? ads.slice(0, maxVisible) : ads),
58+
[ads, maxVisible],
59+
)
60+
61+
const widths = useMemo(() => columnWidths(visibleAds.length, colAvail), [visibleAds.length, colAvail])
62+
63+
// Fire impressions only for visible ads
64+
useEffect(() => {
65+
if (onImpression) {
66+
for (const ad of visibleAds) {
67+
onImpression(ad.impUrl)
68+
}
69+
}
70+
}, [visibleAds, onImpression])
5571

5672
// Hover colors
5773
const hoverBorderColor = theme.link
@@ -64,46 +80,6 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAd
6480
flexDirection: 'column',
6581
}}
6682
>
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-
10783
{/* Card columns */}
10884
<box
10985
style={{
@@ -112,7 +88,7 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAd
11288
flexDirection: 'row',
11389
}}
11490
>
115-
{ads.map((ad, i) => {
91+
{visibleAds.map((ad, i) => {
11692
const isHovered = hoveredIndex === i
11793
const domain = extractDomain(ad.url)
11894
const ctaText = ad.cta || ad.title || 'Learn more'
@@ -136,10 +112,14 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAd
136112
backgroundColor: isHovered ? hoverBgColor : undefined,
137113
}}
138114
>
139-
<text style={{ fg: isHovered ? theme.link : theme.muted, flexShrink: 1 }}>
140-
{ad.adText}
141-
</text>
115+
<box style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
116+
<text style={{ fg: theme.muted, flexShrink: 1 }}>
117+
{truncateToLines(ad.adText, widths[i] - 8, MAX_DESC_LINES)}
118+
</text>
119+
<text style={{ fg: theme.muted, flexShrink: 0 }}>{' Ad'}</text>
120+
</box>
142121
<box style={{ flexGrow: 1 }} />
122+
{/* Bottom: CTA + domain */}
143123
<box style={{ flexDirection: 'row', columnGap: 1, alignItems: 'center' }}>
144124
<text
145125
style={{
@@ -153,90 +133,14 @@ export const ChoiceAdBanner: React.FC<ChoiceAdBannerProps> = ({ ads, onDisableAd
153133
<text style={{ fg: theme.muted, attributes: TextAttributes.UNDERLINE }}>
154134
{domain}
155135
</text>
136+
156137
</box>
157138
</Button>
158139
)
159140
})}
141+
160142
</box>
161143

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-
)}
240144
</box>
241145
)
242146
}

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type GravityAdState = {
3737
ad: AdResponse | null
3838
adData: AdData | null
3939
isLoading: boolean
40+
recordImpression: (impUrl: string) => void
4041
}
4142

4243
// Consolidated controller state for the ad rotation logic
@@ -206,13 +207,10 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
206207
recordImpressionOnce(next.impUrl)
207208
}
208209

209-
// Show a choice ad set and fire impressions for all
210+
// Show a choice ad set (impressions are fired by the component for visible ads only)
210211
const showChoiceAds = (ads: AdResponse[]): void => {
211212
setAd(ads[0] ?? null) // Keep backwards compat for ad field
212213
setAdData({ variant: 'choice', ads })
213-
for (const choiceAd of ads) {
214-
recordImpressionOnce(choiceAd.impUrl)
215-
}
216214
}
217215

218216
type FetchAdResult =
@@ -400,6 +398,7 @@ export const useGravityAd = (options?: { enabled?: boolean }): GravityAdState =>
400398
ad: visible ? ad : null,
401399
adData: visible ? adData : null,
402400
isLoading,
401+
recordImpression: recordImpressionOnce,
403402
}
404403
}
405404

0 commit comments

Comments
 (0)