mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
fix(settings): update usage data in settings > subs to use reactquery hooks (#1983)
* fix(settings): update usage data in settings > subs to use reactquery hooks * standardize usage pills calculation
This commit is contained in:
@@ -2,14 +2,12 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Badge } from '@/components/emcn'
|
||||
import { calculateFilledPills, USAGE_PILL_COUNT } from '@/lib/subscription/usage-visualization'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const GRADIENT_BADGE_STYLES =
|
||||
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer'
|
||||
|
||||
// Constants matching UsageIndicator
|
||||
const PILL_COUNT = 8
|
||||
|
||||
interface UsageHeaderProps {
|
||||
title: string
|
||||
gradientTitle?: boolean
|
||||
@@ -45,9 +43,9 @@ export function UsageHeader({
|
||||
}: UsageHeaderProps) {
|
||||
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)
|
||||
|
||||
// Calculate filled pills based on usage percentage
|
||||
const filledPillsCount = Math.ceil((progress / 100) * PILL_COUNT)
|
||||
const isAlmostOut = filledPillsCount === PILL_COUNT
|
||||
// Calculate filled pills based on usage percentage using shared utility (fixed 8 pills)
|
||||
const filledPillsCount = calculateFilledPills(progress)
|
||||
const isAlmostOut = filledPillsCount === USAGE_PILL_COUNT
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
@@ -93,9 +91,9 @@ export function UsageHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pills row - matching UsageIndicator */}
|
||||
{/* Pills row - fixed 8 pills with shared heuristic */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: PILL_COUNT }).map((_, i) => {
|
||||
{Array.from({ length: USAGE_PILL_COUNT }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
getVisiblePlans,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription-permissions'
|
||||
import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData, useUsageData, useUsageLimitData } from '@/hooks/queries/subscription'
|
||||
import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription'
|
||||
import { useUpdateWorkspaceSettings, useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
@@ -170,7 +170,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
|
||||
// React Query hooks for data fetching
|
||||
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
|
||||
const { data: usageResponse, isLoading: isUsageLoading } = useUsageData()
|
||||
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
|
||||
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
|
||||
const updateWorkspaceMutation = useUpdateWorkspaceSettings()
|
||||
@@ -188,38 +187,38 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
// Combine all loading states
|
||||
const isLoading =
|
||||
isSubscriptionLoading || isUsageLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
const isLoading = isSubscriptionLoading || isUsageLimitLoading || isWorkspaceLoading
|
||||
|
||||
// Extract subscription status from data
|
||||
// Extract subscription status from subscriptionData.data
|
||||
const subscription = {
|
||||
isFree: subscriptionData?.plan === 'free' || !subscriptionData?.plan,
|
||||
isPro: subscriptionData?.plan === 'pro',
|
||||
isTeam: subscriptionData?.plan === 'team',
|
||||
isEnterprise: subscriptionData?.plan === 'enterprise',
|
||||
isFree: subscriptionData?.data?.plan === 'free' || !subscriptionData?.data?.plan,
|
||||
isPro: subscriptionData?.data?.plan === 'pro',
|
||||
isTeam: subscriptionData?.data?.plan === 'team',
|
||||
isEnterprise: subscriptionData?.data?.plan === 'enterprise',
|
||||
isPaid:
|
||||
subscriptionData?.plan &&
|
||||
['pro', 'team', 'enterprise'].includes(subscriptionData.plan) &&
|
||||
subscriptionData?.status === 'active',
|
||||
plan: subscriptionData?.plan || 'free',
|
||||
status: subscriptionData?.status || 'inactive',
|
||||
seats: subscriptionData?.seats || 1,
|
||||
subscriptionData?.data?.plan &&
|
||||
['pro', 'team', 'enterprise'].includes(subscriptionData.data.plan) &&
|
||||
subscriptionData?.data?.status === 'active',
|
||||
plan: subscriptionData?.data?.plan || 'free',
|
||||
status: subscriptionData?.data?.status || 'inactive',
|
||||
seats: subscriptionData?.data?.seats || 1,
|
||||
}
|
||||
|
||||
// Extract usage data
|
||||
// Extract usage data from subscriptionData.data.usage (same source as panel usage indicator)
|
||||
const usage = {
|
||||
current: usageResponse?.usage?.current || 0,
|
||||
limit: usageResponse?.usage?.limit || 0,
|
||||
percentUsed: usageResponse?.usage?.percentUsed || 0,
|
||||
current: subscriptionData?.data?.usage?.current || 0,
|
||||
limit: subscriptionData?.data?.usage?.limit || 0,
|
||||
percentUsed: subscriptionData?.data?.usage?.percentUsed || 0,
|
||||
}
|
||||
|
||||
// Extract usage limit metadata from usageLimitResponse.data
|
||||
const usageLimitData = {
|
||||
currentLimit: usageLimitResponse?.usage?.limit || 0,
|
||||
minimumLimit: usageLimitResponse?.usage?.minimumLimit || (subscription.isPro ? 20 : 40),
|
||||
currentLimit: usageLimitResponse?.data?.currentLimit || 0,
|
||||
minimumLimit: usageLimitResponse?.data?.minimumLimit || (subscription.isPro ? 20 : 40),
|
||||
}
|
||||
|
||||
// Extract billing status
|
||||
const billingStatus = subscriptionData?.billingBlocked ? 'blocked' : 'ok'
|
||||
const billingStatus = subscriptionData?.data?.billingBlocked ? 'blocked' : 'ok'
|
||||
|
||||
// Extract workspace settings
|
||||
const billedAccountUserId = workspaceData?.settings?.workspace?.billedAccountUserId ?? null
|
||||
@@ -406,20 +405,18 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
? usage.current // placeholder; rightContent will render UsageLimit
|
||||
: usage.limit
|
||||
}
|
||||
isBlocked={Boolean(subscriptionData?.billingBlocked)}
|
||||
isBlocked={Boolean(subscriptionData?.data?.billingBlocked)}
|
||||
status={billingStatus}
|
||||
percentUsed={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalUsageLimit &&
|
||||
organizationBillingData.totalUsageLimit > 0 &&
|
||||
organizationBillingData.totalCurrentUsage !== undefined
|
||||
? Math.round(
|
||||
(organizationBillingData.totalCurrentUsage /
|
||||
organizationBillingData.totalUsageLimit) *
|
||||
100
|
||||
)
|
||||
: Math.round(usage.percentUsed)
|
||||
: Math.round(usage.percentUsed)
|
||||
? (organizationBillingData.totalCurrentUsage /
|
||||
organizationBillingData.totalUsageLimit) *
|
||||
100
|
||||
: usage.percentUsed
|
||||
: usage.percentUsed
|
||||
}
|
||||
onResolvePayment={async () => {
|
||||
try {
|
||||
@@ -467,7 +464,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
progressValue={Math.min(Math.round(usage.percentUsed), 100)}
|
||||
progressValue={Math.min(usage.percentUsed, 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -544,11 +541,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
)}
|
||||
|
||||
{/* Next Billing Date */}
|
||||
{subscription.isPaid && subscriptionData?.periodEnd && (
|
||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||
<div className='mt-4 flex items-center justify-between'>
|
||||
<span className='font-medium text-sm'>Next Billing Date</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{new Date(subscriptionData.periodEnd).toLocaleDateString()}
|
||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -574,8 +571,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
isPaid: subscription.isPaid,
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
|
||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function TeamSeatsOverview({
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-[6px] flex-1 rounded-full transition-colors',
|
||||
isFilled ? 'bg-[#4285F4]' : 'bg-[#2C2C2C]'
|
||||
isFilled ? 'bg-[#34B5FF]' : 'bg-[#2C2C2C]'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -49,8 +49,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
/**
|
||||
* Calculate pill count based on sidebar width
|
||||
* Starts at MIN_PILL_COUNT at minimum width, adds 1 pill per WIDTH_PER_PILL increase
|
||||
* 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
|
||||
@@ -100,6 +100,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
|
||||
const isAlmostOut = filledPillsCount === pillCount
|
||||
|
||||
@@ -34,42 +34,32 @@ export function useSubscriptionData() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user usage data
|
||||
* Fetch user usage limit metadata
|
||||
* Note: This endpoint returns limit information (currentLimit, minimumLimit, canEdit, etc.)
|
||||
* For actual usage data (current, limit, percentUsed), use useSubscriptionData() instead
|
||||
*/
|
||||
async function fetchUsageData() {
|
||||
async function fetchUsageLimitData() {
|
||||
const response = await fetch('/api/usage?context=user')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch usage data')
|
||||
throw new Error('Failed to fetch usage limit data')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base hook to fetch user usage data (single query)
|
||||
* Hook to fetch usage limit metadata
|
||||
* Returns: currentLimit, minimumLimit, canEdit, plan, updatedAt
|
||||
* Use this for editing usage limits, not for displaying current usage
|
||||
*/
|
||||
function useUsageDataBase() {
|
||||
export function useUsageLimitData() {
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.usage(),
|
||||
queryFn: fetchUsageData,
|
||||
queryFn: fetchUsageLimitData,
|
||||
staleTime: 30 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch user usage data
|
||||
*/
|
||||
export function useUsageData() {
|
||||
return useUsageDataBase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch usage limit data
|
||||
*/
|
||||
export function useUsageLimitData() {
|
||||
return useUsageDataBase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage limit mutation
|
||||
*/
|
||||
|
||||
104
apps/sim/lib/subscription/usage-visualization.ts
Normal file
104
apps/sim/lib/subscription/usage-visualization.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Shared utilities for consistent usage visualization across the application.
|
||||
*
|
||||
* This module provides a single source of truth for how usage metrics are
|
||||
* displayed visually through "pills" or progress indicators.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Number of pills to display in usage indicators.
|
||||
*
|
||||
* Using 8 pills provides:
|
||||
* - 12.5% granularity per pill
|
||||
* - Good balance between precision and visual clarity
|
||||
* - Consistent representation across panel and settings
|
||||
*/
|
||||
export const USAGE_PILL_COUNT = 8
|
||||
|
||||
/**
|
||||
* Color values for usage pill states
|
||||
*/
|
||||
export const USAGE_PILL_COLORS = {
|
||||
/** Unfilled pill color (gray) */
|
||||
UNFILLED: '#414141',
|
||||
/** Normal filled pill color (blue) */
|
||||
FILLED: '#34B5FF',
|
||||
/** Warning/limit reached pill color (red) */
|
||||
AT_LIMIT: '#ef4444',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Calculate the number of filled pills based on usage percentage.
|
||||
*
|
||||
* Uses Math.ceil() to ensure even minimal usage (0.01%) shows visual feedback.
|
||||
* This provides better UX by making it clear that there is some usage, even if small.
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100). Can be a decimal (e.g., 0.315 for 0.315%)
|
||||
* @returns Number of pills that should be filled (0 to USAGE_PILL_COUNT)
|
||||
*
|
||||
* @example
|
||||
* calculateFilledPills(0.315) // Returns 1 (shows feedback for 0.315% usage)
|
||||
* calculateFilledPills(50) // Returns 4 (50% of 8 pills)
|
||||
* calculateFilledPills(100) // Returns 8 (completely filled)
|
||||
* calculateFilledPills(150) // Returns 8 (clamped to maximum)
|
||||
*/
|
||||
export function calculateFilledPills(percentUsed: number): number {
|
||||
// Clamp percentage to valid range [0, 100]
|
||||
const safePercent = Math.min(Math.max(percentUsed, 0), 100)
|
||||
|
||||
// Calculate filled pills using ceil to show feedback for any usage
|
||||
return Math.ceil((safePercent / 100) * USAGE_PILL_COUNT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if usage has reached the limit (all pills filled).
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100)
|
||||
* @returns true if all pills should be filled (at or over limit)
|
||||
*/
|
||||
export function isUsageAtLimit(percentUsed: number): boolean {
|
||||
return calculateFilledPills(percentUsed) >= USAGE_PILL_COUNT
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate color for a pill based on its state.
|
||||
*
|
||||
* @param isFilled - Whether this pill should be filled
|
||||
* @param isAtLimit - Whether usage has reached the limit
|
||||
* @returns Hex color string
|
||||
*/
|
||||
export function getPillColor(isFilled: boolean, isAtLimit: boolean): string {
|
||||
if (!isFilled) return USAGE_PILL_COLORS.UNFILLED
|
||||
if (isAtLimit) return USAGE_PILL_COLORS.AT_LIMIT
|
||||
return USAGE_PILL_COLORS.FILLED
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an array of pill states for rendering.
|
||||
*
|
||||
* @param percentUsed - The usage percentage (0-100)
|
||||
* @returns Array of pill states with colors
|
||||
*
|
||||
* @example
|
||||
* const pills = generatePillStates(50)
|
||||
* pills.forEach((pill, index) => (
|
||||
* <Pill key={index} color={pill.color} filled={pill.filled} />
|
||||
* ))
|
||||
*/
|
||||
export function generatePillStates(percentUsed: number): Array<{
|
||||
filled: boolean
|
||||
color: string
|
||||
index: number
|
||||
}> {
|
||||
const filledCount = calculateFilledPills(percentUsed)
|
||||
const atLimit = isUsageAtLimit(percentUsed)
|
||||
|
||||
return Array.from({ length: USAGE_PILL_COUNT }, (_, index) => {
|
||||
const filled = index < filledCount
|
||||
return {
|
||||
filled,
|
||||
color: getPillColor(filled, atLimit),
|
||||
index,
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user