11import { TextAttributes } from '@opentui/core'
22import { safeOpen } from '../utils/open-url'
3- import React , { useState , useMemo } from 'react'
3+ import React , { useState , useMemo , useEffect } from 'react'
44
55import { Button } from './button'
6- import { Clickable } from './clickable'
76import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
87import { useTheme } from '../hooks/use-theme'
9- import { IS_FREEBUFF } from '../utils/constants'
108
119import type { AdResponse } from '../hooks/use-gravity-ad'
1210
1311interface 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
2127const 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}
0 commit comments