From e735255760a432d0a732b833ff4490d2e59cf7e1 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 14 Nov 2025 21:06:35 -0800 Subject: [PATCH 1/2] fix: usage-limit indicator and render conditonally on is billing enabled --- .../usage-indicator/usage-indicator.tsx | 151 +++++++++--------- .../w/components/sidebar/sidebar-new.tsx | 4 +- 2 files changed, 80 insertions(+), 75 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index 71707dfbef..b9e7be252e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -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 @@ -85,51 +114,52 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const showUpgradeButton = planType === 'free' || isBlocked /** - * 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(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 +255,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 - - const pillOffsetFromStart = - i >= startAnimationIndex - ? i - startAnimationIndex - : totalSpan - startAnimationIndex + i - - 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%)` - } + /** + * 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 + + 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 } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx index 4639864655..61cb9eea5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new.tsx @@ -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. From c7baa806d4cee0219f2547601e378548c63f75a0 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 14 Nov 2025 21:12:19 -0800 Subject: [PATCH 2/2] fix: upgrade render --- .../sidebar/components-new/usage-indicator/usage-indicator.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index b9e7be252e..6ecd1e385d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -111,7 +111,8 @@ 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.