fix(usage-indicator): conditional rendering, upgrade, and ui/ux (#2001)

* fix: usage-limit indicator and render conditonally on is billing enabled

* fix: upgrade render
This commit is contained in:
Emir Karabeg
2025-11-14 21:26:40 -08:00
committed by GitHub
parent de91dc97a9
commit ad2a375358
2 changed files with 80 additions and 74 deletions

View File

@@ -16,30 +16,39 @@ import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('UsageIndicator')
/**
* Minimum number of pills to display (at minimum sidebar width)
* Minimum number of pills to display (at minimum sidebar width).
*/
const MIN_PILL_COUNT = 6
/**
* Maximum number of pills to display
* Maximum number of pills to display.
*/
const MAX_PILL_COUNT = 8
/**
* Width increase (in pixels) required to add one additional pill
* Width increase (in pixels) required to add one additional pill.
*/
const WIDTH_PER_PILL = 50
/**
* Animation configuration for usage pills
* Controls how smoothly and quickly the highlight progresses across pills
* Animation tick interval in milliseconds.
* Controls the update frequency of the wave animation.
*/
const PILL_ANIMATION_TICK_MS = 30
/**
* Speed of the wave animation in pills per second.
*/
const PILLS_PER_SECOND = 1.8
/**
* Distance (in pill units) the wave advances per animation tick.
* Derived from {@link PILLS_PER_SECOND} and {@link PILL_ANIMATION_TICK_MS}.
*/
const PILL_STEP_PER_TICK = (PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS) / 1000
/**
* Plan name mapping
* Human-readable plan name labels.
*/
const PLAN_NAMES = {
enterprise: 'Enterprise',
@@ -48,17 +57,37 @@ const PLAN_NAMES = {
free: 'Free',
} as const
/**
* Props for the {@link UsageIndicator} component.
*/
interface UsageIndicatorProps {
/**
* Optional click handler. If provided, overrides the default behavior
* of opening the settings modal to the subscription tab.
*/
onClick?: () => void
}
/**
* Displays a visual usage indicator showing current subscription usage
* with an animated pill bar that responds to hover interactions.
*
* The component shows:
* - Current plan type (Free, Pro, Team, Enterprise)
* - Current usage vs. limit (e.g., $7.00 / $10.00)
* - Visual pill bar representing usage percentage
* - Upgrade button for free plans or when blocked
*
* @param props - Component props
* @returns A usage indicator component with responsive pill visualization
*/
export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const { data: subscriptionData, isLoading } = useSubscriptionData()
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Calculate pill count based on sidebar width (6-8 pills dynamically)
* This provides responsive feedback as the sidebar width changes
* Calculate pill count based on sidebar width (6-8 pills dynamically).
* This provides responsive feedback as the sidebar width changes.
*/
const pillCount = useMemo(() => {
const widthDelta = sidebarWidth - MIN_SIDEBAR_WIDTH
@@ -82,54 +111,56 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const billingStatus = getBillingStatus(subscriptionData?.data)
const isBlocked = billingStatus === 'blocked'
const showUpgradeButton = planType === 'free' || isBlocked
const showUpgradeButton =
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
/**
* Calculate which pills should be filled based on usage percentage
* Uses shared Math.ceil heuristic but with dynamic pill count (6-8)
* This ensures consistent calculation logic while maintaining responsive pill count
* Calculate which pills should be filled based on usage percentage.
* Uses Math.ceil heuristic with dynamic pill count (6-8).
* This ensures consistent calculation logic while maintaining responsive pill count.
*/
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
const isAlmostOut = filledPillsCount === pillCount
const [isHovered, setIsHovered] = useState(false)
const [wavePosition, setWavePosition] = useState<number | null>(null)
const [hasWrapped, setHasWrapped] = useState(false)
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
useEffect(() => {
if (!isHovered || pillCount <= 0) {
const isFreePlan = subscription.isFree
if (!isHovered || pillCount <= 0 || !isFreePlan) {
setWavePosition(null)
setHasWrapped(false)
return
}
const totalSpan = pillCount
let wrapped = false
setHasWrapped(false)
/**
* Maximum distance (in pill units) the wave should travel from
* {@link startAnimationIndex} to the end of the row. The wave stops
* once it reaches the final pill and does not wrap.
*/
const maxDistance = pillCount <= 0 ? 0 : Math.max(0, pillCount - startAnimationIndex)
setWavePosition(0)
const interval = window.setInterval(() => {
setWavePosition((prev) => {
const current = prev ?? 0
const next = current + PILL_STEP_PER_TICK
// Mark as wrapped after first complete cycle
if (next >= totalSpan && !wrapped) {
wrapped = true
setHasWrapped(true)
if (current >= maxDistance) {
return current
}
// Return continuous value, never reset (seamless loop)
return next
const next = current + PILL_STEP_PER_TICK
return next >= maxDistance ? maxDistance : next
})
}, PILL_ANIMATION_TICK_MS)
return () => {
window.clearInterval(interval)
}
}, [isHovered, pillCount, startAnimationIndex])
}, [isHovered, pillCount, startAnimationIndex, subscription.isFree])
if (isLoading) {
return (
@@ -225,58 +256,33 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
let backgroundColor = baseColor
let backgroundImage: string | undefined
if (isHovered && wavePosition !== null && pillCount > 0) {
const totalSpan = pillCount
if (isHovered && wavePosition !== null && pillCount > 0 && subscription.isFree) {
const grayColor = '#414141'
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
if (!hasWrapped) {
// First pass: respect original fill state, start from startAnimationIndex
const headIndex = Math.floor(wavePosition)
const progress = wavePosition - headIndex
/**
* Single-pass wave: travel from {@link startAnimationIndex} to the end
* of the row without wrapping. Previously highlighted pills remain
* filled; the wave only affects pills at or after the start index.
*/
const headIndex = Math.floor(wavePosition)
const progress = wavePosition - headIndex
const pillOffsetFromStart =
i >= startAnimationIndex
? i - startAnimationIndex
: totalSpan - startAnimationIndex + i
const pillOffsetFromStart = i - startAnimationIndex
if (pillOffsetFromStart < headIndex) {
backgroundColor = baseColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
backgroundColor = baseColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${baseColor} ${fillPercent}%, ${baseColor} 100%)`
}
if (pillOffsetFromStart < 0) {
// Before the wave start; keep original baseColor.
} else if (pillOffsetFromStart < headIndex) {
backgroundColor = isFilled ? baseColor : grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (pillOffsetFromStart === headIndex) {
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
backgroundColor = isFilled ? baseColor : grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${
isFilled ? baseColor : grayColor
} ${fillPercent}%, ${isFilled ? baseColor : grayColor} 100%)`
} else {
// Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
const wrappedPosition = wavePosition % totalSpan
const currentHead = Math.floor(wrappedPosition)
const progress = wrappedPosition - currentHead
// Primary wave position
const primaryFilled = i < currentHead
const primaryActive = i === currentHead
// Secondary wave position (one full cycle ahead, wraps to beginning)
const secondaryHead = Math.floor(wavePosition + totalSpan) % totalSpan
const secondaryProgress =
wavePosition + totalSpan - Math.floor(wavePosition + totalSpan)
const secondaryFilled = i < secondaryHead
const secondaryActive = i === secondaryHead
// Render: pill is filled if either wave position has filled it
if (primaryFilled || secondaryFilled) {
backgroundColor = grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
} else if (primaryActive || secondaryActive) {
const activeProgress = primaryActive ? progress : secondaryProgress
const fillPercent = Math.max(0, Math.min(1, activeProgress)) * 100
backgroundColor = grayColor
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${grayColor} ${fillPercent}%, ${grayColor} 100%)`
} else {
backgroundColor = grayColor
}
backgroundColor = isFilled ? baseColor : grayColor
}
}

View File

@@ -5,6 +5,7 @@ import { ArrowDown, Plus, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import {
@@ -32,8 +33,7 @@ import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('SidebarNew')
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
// const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
const isBillingEnabled = true
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
/**
* Sidebar component with resizable width that persists across page refreshes.