Skip to content

Commit ad2a375

Browse files
authored
fix(usage-indicator): conditional rendering, upgrade, and ui/ux (#2001)
* fix: usage-limit indicator and render conditonally on is billing enabled * fix: upgrade render
1 parent de91dc9 commit ad2a375

File tree

2 files changed

+82
-76
lines changed

2 files changed

+82
-76
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx

Lines changed: 80 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,39 @@ import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
1616
const logger = createLogger('UsageIndicator')
1717

1818
/**
19-
* Minimum number of pills to display (at minimum sidebar width)
19+
* Minimum number of pills to display (at minimum sidebar width).
2020
*/
2121
const MIN_PILL_COUNT = 6
2222

2323
/**
24-
* Maximum number of pills to display
24+
* Maximum number of pills to display.
2525
*/
2626
const MAX_PILL_COUNT = 8
2727

2828
/**
29-
* Width increase (in pixels) required to add one additional pill
29+
* Width increase (in pixels) required to add one additional pill.
3030
*/
3131
const WIDTH_PER_PILL = 50
3232

3333
/**
34-
* Animation configuration for usage pills
35-
* Controls how smoothly and quickly the highlight progresses across pills
34+
* Animation tick interval in milliseconds.
35+
* Controls the update frequency of the wave animation.
3636
*/
3737
const PILL_ANIMATION_TICK_MS = 30
38+
39+
/**
40+
* Speed of the wave animation in pills per second.
41+
*/
3842
const PILLS_PER_SECOND = 1.8
43+
44+
/**
45+
* Distance (in pill units) the wave advances per animation tick.
46+
* Derived from {@link PILLS_PER_SECOND} and {@link PILL_ANIMATION_TICK_MS}.
47+
*/
3948
const PILL_STEP_PER_TICK = (PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS) / 1000
4049

4150
/**
42-
* Plan name mapping
51+
* Human-readable plan name labels.
4352
*/
4453
const PLAN_NAMES = {
4554
enterprise: 'Enterprise',
@@ -48,17 +57,37 @@ const PLAN_NAMES = {
4857
free: 'Free',
4958
} as const
5059

60+
/**
61+
* Props for the {@link UsageIndicator} component.
62+
*/
5163
interface UsageIndicatorProps {
64+
/**
65+
* Optional click handler. If provided, overrides the default behavior
66+
* of opening the settings modal to the subscription tab.
67+
*/
5268
onClick?: () => void
5369
}
5470

71+
/**
72+
* Displays a visual usage indicator showing current subscription usage
73+
* with an animated pill bar that responds to hover interactions.
74+
*
75+
* The component shows:
76+
* - Current plan type (Free, Pro, Team, Enterprise)
77+
* - Current usage vs. limit (e.g., $7.00 / $10.00)
78+
* - Visual pill bar representing usage percentage
79+
* - Upgrade button for free plans or when blocked
80+
*
81+
* @param props - Component props
82+
* @returns A usage indicator component with responsive pill visualization
83+
*/
5584
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
5685
const { data: subscriptionData, isLoading } = useSubscriptionData()
5786
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
5887

5988
/**
60-
* Calculate pill count based on sidebar width (6-8 pills dynamically)
61-
* This provides responsive feedback as the sidebar width changes
89+
* Calculate pill count based on sidebar width (6-8 pills dynamically).
90+
* This provides responsive feedback as the sidebar width changes.
6291
*/
6392
const pillCount = useMemo(() => {
6493
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
@@ -82,54 +111,56 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
82111

83112
const billingStatus = getBillingStatus(subscriptionData?.data)
84113
const isBlocked = billingStatus === 'blocked'
85-
const showUpgradeButton = planType === 'free' || isBlocked
114+
const showUpgradeButton =
115+
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
86116

87117
/**
88-
* Calculate which pills should be filled based on usage percentage
89-
* Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
90-
* This ensures consistent calculation logic while maintaining responsive pill count
118+
* Calculate which pills should be filled based on usage percentage.
119+
* Uses Math.ceil heuristic with dynamic pill count (6-8).
120+
* This ensures consistent calculation logic while maintaining responsive pill count.
91121
*/
92122
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
93123
const isAlmostOut = filledPillsCount === pillCount
94124

95125
const [isHovered, setIsHovered] = useState(false)
96126
const [wavePosition, setWavePosition] = useState<number | null>(null)
97-
const [hasWrapped, setHasWrapped] = useState(false)
98127

99128
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
100129

101130
useEffect(() => {
102-
if (!isHovered || pillCount <= 0) {
131+
const isFreePlan = subscription.isFree
132+
133+
if (!isHovered || pillCount <= 0 || !isFreePlan) {
103134
setWavePosition(null)
104-
setHasWrapped(false)
105135
return
106136
}
107137

108-
const totalSpan = pillCount
109-
let wrapped = false
110-
setHasWrapped(false)
138+
/**
139+
* Maximum distance (in pill units) the wave should travel from
140+
* {@link startAnimationIndex} to the end of the row. The wave stops
141+
* once it reaches the final pill and does not wrap.
142+
*/
143+
const maxDistance = pillCount <= 0 ? 0 : Math.max(0, pillCount - startAnimationIndex)
144+
111145
setWavePosition(0)
112146

113147
const interval = window.setInterval(() => {
114148
setWavePosition((prev) => {
115149
const current = prev ?? 0
116-
const next = current + PILL_STEP_PER_TICK
117150

118-
// Mark as wrapped after first complete cycle
119-
if (next >= totalSpan && !wrapped) {
120-
wrapped = true
121-
setHasWrapped(true)
151+
if (current >= maxDistance) {
152+
return current
122153
}
123154

124-
// Return continuous value, never reset (seamless loop)
125-
return next
155+
const next = current + PILL_STEP_PER_TICK
156+
return next >= maxDistance ? maxDistance : next
126157
})
127158
}, PILL_ANIMATION_TICK_MS)
128159

129160
return () => {
130161
window.clearInterval(interval)
131162
}
132-
}, [isHovered, pillCount, startAnimationIndex])
163+
}, [isHovered, pillCount, startAnimationIndex, subscription.isFree])
133164

134165
if (isLoading) {
135166
return (
@@ -225,58 +256,33 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
225256
let backgroundColor = baseColor
226257
let backgroundImage: string | undefined
227258

228-
if (isHovered && wavePosition !== null && pillCount > 0) {
229-
const totalSpan = pillCount
259+
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
230260
const grayColor = '#414141'
231261
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
232262

233-
if (!hasWrapped) {
234-
// First pass: respect original fill state, start from startAnimationIndex
235-
const headIndex = Math.floor(wavePosition)
236-
const progress = wavePosition - headIndex
237-
238-
const pillOffsetFromStart =
239-
i >= startAnimationIndex
240-
? i - startAnimationIndex
241-
: totalSpan - startAnimationIndex + i
242-
243-
if (pillOffsetFromStart < headIndex) {
244-
backgroundColor = baseColor
245-
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
246-
} else if (pillOffsetFromStart === headIndex) {
247-
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
248-
backgroundColor = baseColor
249-
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${baseColor} ${fillPercent}%, ${baseColor} 100%)`
250-
}
263+
/**
264+
* Single-pass wave: travel from {@link startAnimationIndex} to the end
265+
* of the row without wrapping. Previously highlighted pills remain
266+
* filled; the wave only affects pills at or after the start index.
267+
*/
268+
const headIndex = Math.floor(wavePosition)
269+
const progress = wavePosition - headIndex
270+
271+
const pillOffsetFromStart = i - startAnimationIndex
272+
273+
if (pillOffsetFromStart < 0) {
274+
// Before the wave start; keep original baseColor.
275+
} else if (pillOffsetFromStart < headIndex) {
276+
backgroundColor = isFilled ? baseColor : grayColor
277+
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
278+
} else if (pillOffsetFromStart === headIndex) {
279+
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
280+
backgroundColor = isFilled ? baseColor : grayColor
281+
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${
282+
isFilled ? baseColor : grayColor
283+
} ${fillPercent}%, ${isFilled ? baseColor : grayColor} 100%)`
251284
} else {
252-
// Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
253-
const wrappedPosition = wavePosition % totalSpan
254-
const currentHead = Math.floor(wrappedPosition)
255-
const progress = wrappedPosition - currentHead
256-
257-
// Primary wave position
258-
const primaryFilled = i < currentHead
259-
const primaryActive = i === currentHead
260-
261-
// Secondary wave position (one full cycle ahead, wraps to beginning)
262-
const secondaryHead = Math.floor(wavePosition + totalSpan) % totalSpan
263-
const secondaryProgress =
264-
wavePosition + totalSpan - Math.floor(wavePosition + totalSpan)
265-
const secondaryFilled = i < secondaryHead
266-
const secondaryActive = i === secondaryHead
267-
268-
// Render: pill is filled if either wave position has filled it
269-
if (primaryFilled || secondaryFilled) {
270-
backgroundColor = grayColor
271-
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
272-
} else if (primaryActive || secondaryActive) {
273-
const activeProgress = primaryActive ? progress : secondaryProgress
274-
const fillPercent = Math.max(0, Math.min(1, activeProgress)) * 100
275-
backgroundColor = grayColor
276-
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${grayColor} ${fillPercent}%, ${grayColor} 100%)`
277-
} else {
278-
backgroundColor = grayColor
279-
}
285+
backgroundColor = isFilled ? baseColor : grayColor
280286
}
281287
}
282288

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ArrowDown, Plus, Search } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
66
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
77
import { useSession } from '@/lib/auth-client'
8+
import { getEnv, isTruthy } from '@/lib/env'
89
import { createLogger } from '@/lib/logs/console/logger'
910
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
1011
import {
@@ -32,8 +33,7 @@ import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
3233
const logger = createLogger('SidebarNew')
3334

3435
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
35-
// const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
36-
const isBillingEnabled = true
36+
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
3737

3838
/**
3939
* Sidebar component with resizable width that persists across page refreshes.

0 commit comments

Comments
 (0)