diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator-context-menu.tsx new file mode 100644 index 0000000000..c1b64d6a07 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator-context-menu.tsx @@ -0,0 +1,189 @@ +'use client' + +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' + +interface UsageIndicatorContextMenuProps { + /** + * Whether the context menu is open + */ + isOpen: boolean + /** + * Position of the context menu + */ + position: { x: number; y: number } + /** + * Ref for the menu element + */ + menuRef: React.RefObject + /** + * Callback when menu should close + */ + onClose: () => void + /** + * Menu items configuration based on plan and permissions + */ + menuItems: UsageMenuItems +} + +interface UsageMenuItems { + /** + * Show "Set usage limit" option + */ + showSetLimit: boolean + /** + * Show "Upgrade to Pro" option (free users) + */ + showUpgradeToPro: boolean + /** + * Show "Upgrade to Team" option (free or pro users) + */ + showUpgradeToTeam: boolean + /** + * Show "Manage seats" option (team admins) + */ + showManageSeats: boolean + /** + * Show "Upgrade to Enterprise" option + */ + showUpgradeToEnterprise: boolean + /** + * Show "Contact support" option (enterprise users) + */ + showContactSupport: boolean + /** + * Callbacks + */ + onSetLimit?: () => void + onUpgradeToPro?: () => void + onUpgradeToTeam?: () => void + onManageSeats?: () => void + onUpgradeToEnterprise?: () => void + onContactSupport?: () => void +} + +/** + * Context menu component for usage indicator. + * Displays plan-appropriate options in a popover at the right-click position. + */ +export function UsageIndicatorContextMenu({ + isOpen, + position, + menuRef, + onClose, + menuItems, +}: UsageIndicatorContextMenuProps) { + const { + showSetLimit, + showUpgradeToPro, + showUpgradeToTeam, + showManageSeats, + showUpgradeToEnterprise, + showContactSupport, + onSetLimit, + onUpgradeToPro, + onUpgradeToTeam, + onManageSeats, + onUpgradeToEnterprise, + onContactSupport, + } = menuItems + + const hasLimitSection = showSetLimit + const hasUpgradeSection = + showUpgradeToPro || showUpgradeToTeam || showUpgradeToEnterprise || showContactSupport + const hasTeamSection = showManageSeats + + return ( + !open && onClose()} + variant='secondary' + size='sm' + colorScheme='inverted' + > + + + {/* Limit management section */} + {showSetLimit && onSetLimit && ( + { + onSetLimit() + onClose() + }} + > + Set usage limit + + )} + + {/* Team management section */} + {hasLimitSection && hasTeamSection && } + {showManageSeats && onManageSeats && ( + { + onManageSeats() + onClose() + }} + > + Manage seats + + )} + + {/* Upgrade section */} + {(hasLimitSection || hasTeamSection) && hasUpgradeSection && } + {showUpgradeToPro && onUpgradeToPro && ( + { + onUpgradeToPro() + onClose() + }} + > + Upgrade to Pro + + )} + {showUpgradeToTeam && onUpgradeToTeam && ( + { + onUpgradeToTeam() + onClose() + }} + > + Upgrade to Team + + )} + {showUpgradeToEnterprise && onUpgradeToEnterprise && ( + { + onUpgradeToEnterprise() + onClose() + }} + > + Upgrade to Enterprise + + )} + {showContactSupport && onContactSupport && ( + { + onContactSupport() + onClose() + }} + > + Contact support + + )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 68872c735f..b244359241 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -1,20 +1,23 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { Badge } from '@/components/emcn' import { Skeleton } from '@/components/ui' +import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade' import { getFilledPillColor, USAGE_PILL_COLORS, USAGE_THRESHOLDS, } from '@/lib/billing/client/usage-visualization' import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/billing/client/utils' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useSocket } from '@/app/workspace/providers/socket-provider' import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useSidebarStore } from '@/stores/sidebar/store' +import { UsageIndicatorContextMenu } from './usage-indicator-context-menu' const logger = createLogger('UsageIndicator') @@ -188,6 +191,8 @@ interface UsageIndicatorProps { onClick?: () => void } +const TYPEFORM_ENTERPRISE_URL = 'https://form.typeform.com/to/jqCO12pF' + /** * Displays a visual usage indicator with animated pill bar. */ @@ -196,6 +201,15 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const sidebarWidth = useSidebarStore((state) => state.sidebarWidth) const { onOperationConfirmed } = useSocket() const queryClient = useQueryClient() + const { handleUpgrade } = useSubscriptionUpgrade() + + const { + isOpen: isContextMenuOpen, + position: contextMenuPosition, + menuRef: contextMenuRef, + handleContextMenu, + closeMenu: closeContextMenu, + } = useContextMenu() useEffect(() => { const handleOperationConfirmed = () => { @@ -266,6 +280,96 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount) const filledColor = getFilledPillColor(isCritical, isWarning) + const isFree = planType === 'free' + const isPro = planType === 'pro' + const isTeam = planType === 'team' + const isEnterprise = planType === 'enterprise' + + const handleUpgradeToPro = useCallback(async () => { + try { + await handleUpgrade('pro') + } catch (error) { + logger.error('Failed to upgrade to Pro', { error }) + } + }, [handleUpgrade]) + + const handleUpgradeToTeam = useCallback(async () => { + try { + await handleUpgrade('team') + } catch (error) { + logger.error('Failed to upgrade to Team', { error }) + } + }, [handleUpgrade]) + + const handleSetLimit = useCallback(() => { + window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } })) + }, []) + + const handleManageSeats = useCallback(() => { + window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'team' } })) + }, []) + + const handleUpgradeToEnterprise = useCallback(() => { + window.open(TYPEFORM_ENTERPRISE_URL, '_blank') + }, []) + + const handleContactSupport = useCallback(() => { + window.dispatchEvent(new CustomEvent('open-help-modal')) + }, []) + + const contextMenuItems = useMemo( + () => ({ + // Set limit: Only for Pro and Team admins (not free, not enterprise) + showSetLimit: (isPro || (isTeam && userCanManageBilling)) && !isEnterprise, + // Upgrade to Pro: Only for free users + showUpgradeToPro: isFree, + // Upgrade to Team: Free users and Pro users with billing permission + showUpgradeToTeam: isFree || (isPro && userCanManageBilling), + // Manage seats: Only for Team admins + showManageSeats: isTeam && userCanManageBilling, + // Upgrade to Enterprise: Only for Team admins (not free, not pro, not enterprise) + showUpgradeToEnterprise: isTeam && userCanManageBilling, + // Contact support: Only for Enterprise admins + showContactSupport: isEnterprise && userCanManageBilling, + onSetLimit: handleSetLimit, + onUpgradeToPro: handleUpgradeToPro, + onUpgradeToTeam: handleUpgradeToTeam, + onManageSeats: handleManageSeats, + onUpgradeToEnterprise: handleUpgradeToEnterprise, + onContactSupport: handleContactSupport, + }), + [ + isFree, + isPro, + isTeam, + isEnterprise, + userCanManageBilling, + handleSetLimit, + handleUpgradeToPro, + handleUpgradeToTeam, + handleManageSeats, + handleUpgradeToEnterprise, + handleContactSupport, + ] + ) + + // Check if any context menu items will be visible + const hasContextMenuItems = + contextMenuItems.showSetLimit || + contextMenuItems.showUpgradeToPro || + contextMenuItems.showUpgradeToTeam || + contextMenuItems.showManageSeats || + contextMenuItems.showUpgradeToEnterprise || + contextMenuItems.showContactSupport + + const handleContextMenuWithCheck = useCallback( + (e: React.MouseEvent) => { + if (!hasContextMenuItems) return + handleContextMenu(e) + }, + [hasContextMenuItems, handleContextMenu] + ) + const [isHovered, setIsHovered] = useState(false) const [wavePosition, setWavePosition] = useState(null) @@ -359,82 +463,93 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { } return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Top row */} -
-
- {showPlanText && ( - <> - - {PLAN_NAMES[planType]} - -
- - )} -
- {statusText.isError ? ( - - {statusText.text} - - ) : ( + <> +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Top row */} +
+
+ {showPlanText && ( <> - - ${usage.current.toFixed(2)} - - / - - ${usage.limit.toFixed(2)} + + {PLAN_NAMES[planType]} +
)} +
+ {statusText.isError ? ( + + {statusText.text} + + ) : ( + <> + + ${usage.current.toFixed(2)} + + / + + ${usage.limit.toFixed(2)} + + + )} +
+ {badgeConfig.show && ( + + {badgeConfig.label} + + )}
- {badgeConfig.show && ( - - {badgeConfig.label} - - )} -
- {/* Pills row */} -
- {Array.from({ length: pillCount }).map((_, i) => { - const isFilled = i < filledPillsCount - const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED - - const backgroundColor = baseColor - let backgroundImage: string | undefined - - if (isHovered && wavePosition !== null) { - const headIndex = Math.floor(wavePosition) - const pillOffsetFromStart = i - startAnimationIndex - - if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) { - backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})` - } else if (pillOffsetFromStart === headIndex) { - const fillPercent = (wavePosition - headIndex) * 100 - backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)` + {/* Pills row */} +
+ {Array.from({ length: pillCount }).map((_, i) => { + const isFilled = i < filledPillsCount + const baseColor = isFilled ? filledColor : USAGE_PILL_COLORS.UNFILLED + + const backgroundColor = baseColor + let backgroundImage: string | undefined + + if (isHovered && wavePosition !== null) { + const headIndex = Math.floor(wavePosition) + const pillOffsetFromStart = i - startAnimationIndex + + if (pillOffsetFromStart >= 0 && pillOffsetFromStart < headIndex) { + backgroundImage = `linear-gradient(to right, ${filledColor}, ${filledColor})` + } else if (pillOffsetFromStart === headIndex) { + const fillPercent = (wavePosition - headIndex) * 100 + backgroundImage = `linear-gradient(to right, ${filledColor} ${fillPercent}%, ${baseColor} ${fillPercent}%)` + } } - } - return ( -
- ) - })} + return ( +
+ ) + })} +
-
+ + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/empty-area-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/empty-area-context-menu.tsx new file mode 100644 index 0000000000..16b32c9d50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/empty-area-context-menu.tsx @@ -0,0 +1,93 @@ +'use client' + +import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn' + +interface EmptyAreaContextMenuProps { + /** + * Whether the context menu is open + */ + isOpen: boolean + /** + * Position of the context menu + */ + position: { x: number; y: number } + /** + * Ref for the menu element + */ + menuRef: React.RefObject + /** + * Callback when menu should close + */ + onClose: () => void + /** + * Callback when create workflow is clicked + */ + onCreateWorkflow: () => void + /** + * Callback when create folder is clicked + */ + onCreateFolder: () => void + /** + * Whether create workflow is disabled + */ + disableCreateWorkflow?: boolean + /** + * Whether create folder is disabled + */ + disableCreateFolder?: boolean +} + +/** + * Context menu component for sidebar empty area. + * Displays options to create a workflow or folder when right-clicking on empty space. + */ +export function EmptyAreaContextMenu({ + isOpen, + position, + menuRef, + onClose, + onCreateWorkflow, + onCreateFolder, + disableCreateWorkflow = false, + disableCreateFolder = false, +}: EmptyAreaContextMenuProps) { + return ( + !open && onClose()} + variant='secondary' + size='sm' + colorScheme='inverted' + > + + + { + onCreateWorkflow() + onClose() + }} + > + Create workflow + + { + onCreateFolder() + onClose() + }} + > + Create folder + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/index.ts new file mode 100644 index 0000000000..aac2288b7d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu/index.ts @@ -0,0 +1 @@ +export { EmptyAreaContextMenu } from './empty-area-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index 501ec347b5..8d3e7fa769 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -3,9 +3,11 @@ import { memo, useCallback, useEffect, useMemo } from 'react' import clsx from 'clsx' import { useParams, usePathname } from 'next/navigation' +import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu' import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item' import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item' import { + useContextMenu, useDragDrop, useWorkflowSelection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -36,6 +38,9 @@ interface WorkflowListProps { handleFileChange: (event: React.ChangeEvent) => void fileInputRef: React.RefObject scrollContainerRef: React.RefObject + onCreateWorkflow?: () => void + onCreateFolder?: () => void + disableCreate?: boolean } const DropIndicatorLine = memo(function DropIndicatorLine({ @@ -63,6 +68,9 @@ export function WorkflowList({ handleFileChange, fileInputRef, scrollContainerRef, + onCreateWorkflow, + onCreateFolder, + disableCreate = false, }: WorkflowListProps) { const pathname = usePathname() const params = useParams() @@ -72,6 +80,14 @@ export function WorkflowList({ const { isLoading: foldersLoading } = useFolders(workspaceId) const { getFolderTree, expandedFolders, getFolderPath, setExpanded } = useFolderStore() + const { + isOpen: isEmptyAreaMenuOpen, + position: emptyAreaMenuPosition, + menuRef: emptyAreaMenuRef, + handleContextMenu: handleEmptyAreaContextMenu, + closeMenu: closeEmptyAreaMenu, + } = useContextMenu() + const { dropIndicator, isDragging, @@ -351,36 +367,71 @@ export function WorkflowList({ [workflowId] ) + const handleContainerContextMenu = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement + const isOnEmptyArea = + target === e.currentTarget || + target.classList.contains('space-y-[2px]') || + target.closest('[data-empty-area]') + if (!isOnEmptyArea) return + if (!onCreateWorkflow && !onCreateFolder) return + handleEmptyAreaContextMenu(e) + }, + [handleEmptyAreaContextMenu, onCreateWorkflow, onCreateFolder] + ) + return ( -
+ <>
- {/* Root drop target highlight overlay */}
-
- {rootItems.map((item) => - item.type === 'folder' - ? renderFolderSection(item.data as FolderTreeNode, 0, null) - : renderWorkflowItem(item.data as WorkflowMetadata, 0, null) - )} + className={clsx('relative flex-1 rounded-[4px]', !hasRootItems && 'min-h-[26px]')} + {...rootDropZoneHandlers} + data-empty-area + > + {/* Root drop target highlight overlay */} +
+
+ {rootItems.map((item) => + item.type === 'folder' + ? renderFolderSection(item.data as FolderTreeNode, 0, null) + : renderWorkflowItem(item.data as WorkflowMetadata, 0, null) + )} +
+ +
- -
+ {onCreateWorkflow && onCreateFolder && ( + + )} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index a97638a975..e310d92b62 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -639,6 +639,9 @@ export function Sidebar() { handleFileChange={handleImportFileChange} fileInputRef={fileInputRef} scrollContainerRef={scrollContainerRef} + onCreateWorkflow={handleCreateWorkflow} + onCreateFolder={handleCreateFolder} + disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder} />