mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user