diff --git a/apps/sim/app/templates/[id]/layout.tsx b/apps/sim/app/templates/[id]/layout.tsx new file mode 100644 index 0000000000..ed557f2b2f --- /dev/null +++ b/apps/sim/app/templates/[id]/layout.tsx @@ -0,0 +1,44 @@ +import { db } from '@sim/db' +import { permissions, workspace } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface TemplateLayoutProps { + children: React.ReactNode + params: Promise<{ + id: string + }> +} + +/** + * Template detail layout (public scope). + * - If user is authenticated, redirect to workspace-scoped template detail. + * - Otherwise render the public template detail children. + */ +export default async function TemplateDetailLayout({ children, params }: TemplateLayoutProps) { + const { id } = await params + const session = await getSession() + + if (session?.user?.id) { + const userWorkspaces = await db + .select({ + workspace: workspace, + }) + .from(permissions) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) + .orderBy(desc(workspace.createdAt)) + .limit(1) + + if (userWorkspaces.length > 0) { + const firstWorkspace = userWorkspaces[0].workspace + redirect(`/workspace/${firstWorkspace.id}/templates/${id}`) + } + } + + return children +} diff --git a/apps/sim/app/templates/[id]/page.tsx b/apps/sim/app/templates/[id]/page.tsx index f2a3335930..04792164fc 100644 --- a/apps/sim/app/templates/[id]/page.tsx +++ b/apps/sim/app/templates/[id]/page.tsx @@ -1,5 +1,9 @@ import TemplateDetails from './template' +/** + * Public template detail page for unauthenticated users. + * Authenticated-user redirect is handled in templates/[id]/layout.tsx. + */ export default function TemplatePage() { return } diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index 89badf8fb5..1d7ccf5516 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -9,12 +9,20 @@ import { Globe, Linkedin, Mail, + Share2, Star, User, } from 'lucide-react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import ReactMarkdown from 'react-markdown' -import { Button } from '@/components/emcn' +import { + Button, + Copy, + Popover, + PopoverContent, + PopoverItem, + PopoverTrigger, +} from '@/components/emcn' import { DropdownMenu, DropdownMenuContent, @@ -23,6 +31,7 @@ import { } from '@/components/ui/dropdown-menu' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { getBaseUrl } from '@/lib/urls/utils' import { cn } from '@/lib/utils' import type { CredentialRequirement } from '@/lib/workflows/credential-extractor' import type { Template } from '@/app/templates/templates' @@ -63,7 +72,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template >([]) const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false) const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false) - const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false) + const [sharePopoverOpen, setSharePopoverOpen] = useState(false) const currentUserId = session?.user?.id || null @@ -351,8 +360,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template // In workspace context, use current workspace directly if (isWorkspaceContext && workspaceId) { handleWorkspaceSelectForUse(workspaceId) - } else { - setShowWorkspaceSelectorForUse(true) } } @@ -415,7 +422,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template if (isUsing || !template) return setIsUsing(true) - setShowWorkspaceSelectorForUse(false) try { const response = await fetch(`/api/templates/${template.id}/use`, { method: 'POST', @@ -518,6 +524,57 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template } } + /** + * Shares the template to X (Twitter) + */ + const handleShareToTwitter = () => { + if (!template) return + + setSharePopoverOpen(false) + const templateUrl = `${getBaseUrl()}/templates/${template.id}` + + let tweetText = `🚀 Check out this workflow template: ${template.name}` + + if (template.details?.tagline) { + const taglinePreview = + template.details.tagline.length > 100 + ? `${template.details.tagline.substring(0, 100)}...` + : template.details.tagline + tweetText += `\n\n${taglinePreview}` + } + + const maxTextLength = 280 - 23 - 1 + if (tweetText.length > maxTextLength) { + tweetText = `${tweetText.substring(0, maxTextLength - 3)}...` + } + + const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(templateUrl)}` + window.open(twitterUrl, '_blank', 'noopener,noreferrer') + } + + /** + * Shares the template to LinkedIn. + */ + const handleShareToLinkedIn = () => { + if (!template) return + + setSharePopoverOpen(false) + const templateUrl = `${getBaseUrl()}/templates/${template.id}` + const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(templateUrl)}` + window.open(linkedInUrl, '_blank', 'noopener,noreferrer') + } + + const handleCopyLink = async () => { + setSharePopoverOpen(false) + const templateUrl = `${getBaseUrl()}/templates/${template?.id}` + try { + await navigator.clipboard.writeText(templateUrl) + logger.info('Template link copied to clipboard') + } catch (error) { + logger.error('Failed to copy link:', error) + } + } + return (
@@ -530,7 +587,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white' > - Back + More Templates
@@ -622,7 +679,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template <> {!currentUserId ? ( - ) : ( - - - - - - {workspaces.length === 0 ? ( - - No workspaces with write access - - ) : ( - workspaces.map((workspace) => ( - handleWorkspaceSelectForUse(workspace.id)} - className='flex cursor-pointer items-center justify-between' - > -
- {workspace.name} - - {workspace.permissions} access - -
-
- )) - )} -
-
- )} + ) : null} )} + + {/* Share button */} + + + + + + + + Copy link + + + + + + Share on X + + + + Share on LinkedIn + + +
diff --git a/apps/sim/app/templates/components/navigation-tabs.tsx b/apps/sim/app/templates/components/navigation-tabs.tsx deleted file mode 100644 index f3a6271f10..0000000000 --- a/apps/sim/app/templates/components/navigation-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from '@/lib/utils' - -interface NavigationTab { - id: string - label: string - count?: number -} - -interface NavigationTabsProps { - tabs: NavigationTab[] - activeTab?: string - onTabClick?: (tabId: string) => void - className?: string -} - -export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) { - return ( -
- {tabs.map((tab, index) => ( - - ))} -
- ) -} diff --git a/apps/sim/app/templates/components/template-card.tsx b/apps/sim/app/templates/components/template-card.tsx index 2d930f067a..5acafd05b2 100644 --- a/apps/sim/app/templates/components/template-card.tsx +++ b/apps/sim/app/templates/components/template-card.tsx @@ -1,116 +1,14 @@ -import { useState } from 'react' -import { - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - ChartNoAxesColumn, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - Zap, -} from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Star, User } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' -import { Badge } from '@/components/ui/badge' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' import { getBlock } from '@/blocks/registry' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateCard') -// Icon mapping for template icons -const iconMap = { - // Content & Documentation - FileText, - NotebookPen, - BookOpen, - Edit, - - // Analytics & Charts - BarChart3, - LineChart, - TrendingUp, - Target, - - // Database & Storage - Database, - Server, - Cloud, - Folder, - - // Marketing & Communication - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - - // Sales & Finance - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - - // Support & Service - HeadphonesIcon, - User, - Users, - Settings, - Wrench, - - // AI & Technology - Bot, - Brain, - Cpu, - Code, - Zap, - - // Workflow & Process - Workflow, - Search, - Play, - Layers, - - // General - Lightbulb, - Star, - Globe, - Award, -} - interface TemplateCardProps { id: string title: string @@ -119,78 +17,56 @@ interface TemplateCardProps { authorImageUrl?: string | null usageCount: string stars?: number + icon?: React.ReactNode | string + iconColor?: string blocks?: string[] - tags?: string[] + onClick?: () => void className?: string - state?: { - blocks?: Record - } + // Workflow state for rendering preview + state?: WorkflowState isStarred?: boolean + // Optional callback when template is successfully used (for closing modals, etc.) + onTemplateUsed?: () => void + // Callback when star state changes (for parent state updates) onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void + // User authentication status isAuthenticated?: boolean - onTemplateUsed?: () => void - status?: 'pending' | 'approved' | 'rejected' - isSuperUser?: boolean - onApprove?: (templateId: string) => void - onReject?: (templateId: string) => void } -// Skeleton component for loading states +/** + * Skeleton component for loading states + */ export function TemplateCardSkeleton({ className }: { className?: string }) { return ( -
- {/* Left side - Info skeleton */} -
- {/* Top section skeleton */} -
-
-
- {/* Icon skeleton */} -
- {/* Title skeleton */} -
-
- - {/* Star and Use button skeleton */} -
-
-
-
-
- - {/* Description skeleton */} -
-
-
-
-
-
- - {/* Bottom section skeleton */} -
-
-
-
-
-
- {/* Stars section - hidden on smaller screens */} -
-
-
-
-
+
+ {/* Workflow preview skeleton */} +
+ + {/* Title and blocks row skeleton */} +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))}
- {/* Right side - Block Icons skeleton */} -
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} + {/* Creator and stats row skeleton */} +
+
+
+
+
+
+
+
+
+
+
) @@ -211,13 +87,59 @@ const extractBlockTypesFromState = (state?: { return [...new Set(blockTypes)] } -// Utility function to get block display name +// Utility function to get the full block config for colored icon display const getBlockConfig = (blockType: string) => { const block = getBlock(blockType) return block } -export function TemplateCard({ +/** + * Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering. + * Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps. + */ +function normalizeWorkflowState(input?: any): WorkflowState | null { + if (!input || !input.blocks) return null + + const normalizedBlocks: WorkflowState['blocks'] = {} + for (const [id, raw] of Object.entries(input.blocks || {})) { + if (!raw || !raw.type) continue + normalizedBlocks[id] = { + id: raw.id ?? id, + type: raw.type, + name: raw.name ?? raw.type, + position: raw.position ?? { x: 0, y: 0 }, + subBlocks: raw.subBlocks ?? {}, + outputs: raw.outputs ?? {}, + enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true, + horizontalHandles: raw.horizontalHandles, + height: raw.height, + advancedMode: raw.advancedMode, + triggerMode: raw.triggerMode, + data: raw.data ?? {}, + layout: raw.layout, + } + } + + const normalized: WorkflowState = { + blocks: normalizedBlocks, + edges: Array.isArray(input.edges) ? input.edges : [], + loops: input.loops ?? {}, + parallels: input.parallels ?? {}, + lastSaved: input.lastSaved, + lastUpdate: input.lastUpdate, + metadata: input.metadata, + variables: input.variables, + isDeployed: input.isDeployed, + deployedAt: input.deployedAt, + deploymentStatuses: input.deploymentStatuses, + needsRedeployment: input.needsRedeployment, + dragStartPosition: input.dragStartPosition ?? null, + } + + return normalized +} + +function TemplateCardInner({ id, title, description, @@ -225,18 +147,16 @@ export function TemplateCard({ authorImageUrl, usageCount, stars = 0, + icon, + iconColor = 'bg-blue-500', blocks = [], - tags = [], + onClick, className, state, isStarred = false, + onTemplateUsed, onStarChange, isAuthenticated = true, - onTemplateUsed, - status, - isSuperUser, - onApprove, - onReject, }: TemplateCardProps) { const router = useRouter() const params = useParams() @@ -245,17 +165,39 @@ export function TemplateCard({ const [localIsStarred, setLocalIsStarred] = useState(isStarred) const [localStarCount, setLocalStarCount] = useState(stars) const [isStarLoading, setIsStarLoading] = useState(false) - const [isApproving, setIsApproving] = useState(false) - const [isRejecting, setIsRejecting] = useState(false) + + // Memoize normalized workflow state to avoid recalculation on every render + const normalizedState = useMemo(() => normalizeWorkflowState(state), [state]) + + // Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport + const previewRef = useRef(null) + const [isInView, setIsInView] = useState(false) + + useEffect(() => { + if (!previewRef.current) return + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true) + observer.disconnect() + } + }, + { root: null, rootMargin: '200px', threshold: 0 } + ) + observer.observe(previewRef.current) + return () => observer.disconnect() + }, []) // Extract block types from state if provided, otherwise use the blocks prop // Filter out starter blocks in both cases and sort for consistent rendering - const blockTypes = state - ? extractBlockTypesFromState(state) - : blocks.filter((blockType) => blockType !== 'starter').sort() - - // Determine if we're in a workspace context - const workspaceId = params?.workspaceId as string | undefined + // Memoized to prevent recalculation on every render + const blockTypes = useMemo( + () => + state + ? extractBlockTypesFromState(state) + : blocks.filter((blockType) => blockType !== 'starter').sort(), + [state, blocks] + ) // Handle star toggle with optimistic updates const handleStarClick = async (e: React.MouseEvent) => { @@ -311,305 +253,162 @@ export function TemplateCard({ } /** - * Handles template use action - * - In workspace context: Creates workflow instance via API - * - Outside workspace: Navigates to template detail page + * Get the appropriate template detail page URL based on context. + * If we're in a workspace context, navigate to the workspace template page. + * Otherwise, navigate to the global template page. + * Memoized to avoid recalculation on every render. */ - const handleUseClick = async (e: React.MouseEvent) => { - e.stopPropagation() - + const templateUrl = useMemo(() => { + const workspaceId = params?.workspaceId as string | undefined if (workspaceId) { - // Workspace context: Use API to create workflow instance - try { - const response = await fetch(`/api/templates/${id}/use`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workspaceId }), - }) - - if (response.ok) { - const data = await response.json() - logger.info('Template use API response:', data) - - if (!data.workflowId) { - logger.error('No workflowId returned from API:', data) - return - } - - const workflowUrl = `/workspace/${workspaceId}/w/${data.workflowId}` - logger.info('Template used successfully, navigating to:', workflowUrl) - - if (onTemplateUsed) { - onTemplateUsed() - } - - window.location.href = workflowUrl - } else { - const errorText = await response.text() - logger.error('Failed to use template:', response.statusText, errorText) - } - } catch (error) { - logger.error('Error using template:', error) - } - } else { - // Non-workspace context: Navigate to template detail page - router.push(`/templates/${id}`) + return `/workspace/${workspaceId}/templates/${id}` } - } + return `/templates/${id}` + }, [params?.workspaceId, id]) /** - * Handles card click navigation - * - In workspace context: Navigate to workspace template detail - * - Outside workspace: Navigate to global template detail + * Handle use button click - navigate to template detail page */ - const handleCardClick = (e: React.MouseEvent) => { - const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[data-action]')) { - return - } - - if (workspaceId) { - router.push(`/workspace/${workspaceId}/templates/${id}`) - } else { - router.push(`/templates/${id}`) - } - } + const handleUseClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + router.push(templateUrl) + }, + [router, templateUrl] + ) /** - * Handles template approval (super user only) + * Handle card click - navigate to template detail page */ - const handleApprove = async (e: React.MouseEvent) => { - e.stopPropagation() - if (isApproving || !onApprove) return - - setIsApproving(true) - try { - const response = await fetch(`/api/templates/${id}/approve`, { - method: 'POST', - }) - - if (response.ok) { - onApprove(id) - } else { - logger.error('Failed to approve template:', response.statusText) + const handleCardClick = useCallback( + (e: React.MouseEvent) => { + // Don't navigate if clicking on action buttons + const target = e.target as HTMLElement + if (target.closest('button') || target.closest('[data-action]')) { + return } - } catch (error) { - logger.error('Error approving template:', error) - } finally { - setIsApproving(false) - } - } - /** - * Handles template rejection (super user only) - */ - const handleReject = async (e: React.MouseEvent) => { - e.stopPropagation() - if (isRejecting || !onReject) return - - setIsRejecting(true) - try { - const response = await fetch(`/api/templates/${id}/reject`, { - method: 'POST', - }) - - if (response.ok) { - onReject(id) - } else { - logger.error('Failed to reject template:', response.statusText) - } - } catch (error) { - logger.error('Error rejecting template:', error) - } finally { - setIsRejecting(false) - } - } + router.push(templateUrl) + }, + [router, templateUrl] + ) return (
- {/* Left side - Info */} -
- {/* Top section */} -
-
-
- {/* Template name */} -

- {title} -

-
- - {/* Actions */} -
- {/* Super user approval buttons for pending templates */} - {isSuperUser && status === 'pending' ? ( - <> - - - - ) : ( - <> - {/* Star button - only for authenticated users */} - {isAuthenticated && ( - - )} - - - )} -
-
- - {/* Description */} -

- {description} -

- - {/* Tags */} - {tags && tags.length > 0 && ( -
- {tags.slice(0, 3).map((tag, index) => ( - - {tag} - - ))} - {tags.length > 3 && ( - - +{tags.length - 3} - - )} -
- )} -
- - {/* Bottom section */} -
- {authorImageUrl ? ( -
- {author} -
- ) : ( - - )} - {author} - - - {usageCount} - {/* Stars section - hidden on smaller screens when space is constrained */} -
- - - {localStarCount} -
-
+ {/* Workflow Preview */} +
+ {normalizedState && isInView ? ( + + ) : ( +
+ )}
- {/* Right side - Block Icons */} -
- {blockTypes.length > 3 ? ( - <> - {/* Show first 2 blocks when there are more than 3 */} - {blockTypes.slice(0, 2).map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( -
+ {/* Title and Blocks Row */} +
+ {/* Template Name */} +

{title}

+ + {/* Block Icons */} +
+ {blockTypes.length > 4 ? ( + <> + {/* Show first 3 blocks when there are more than 4 */} + {blockTypes.slice(0, 3).map((blockType, index) => { + const blockConfig = getBlockConfig(blockType) + if (!blockConfig) return null + + return (
0 ? '-4px' : '0', }} > - +
-
- ) - })} - {/* Show +n block for remaining blocks */} -
+ ) + })} + {/* Show +n for remaining blocks */}
- +{blockTypes.length - 2} + +{blockTypes.length - 3}
-
- - ) : ( - /* Show all blocks when 3 or fewer */ - blockTypes.map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null + + ) : ( + /* Show all blocks when 4 or fewer */ + blockTypes.map((blockType, index) => { + const blockConfig = getBlockConfig(blockType) + if (!blockConfig) return null - return ( -
+ return (
0 ? '-4px' : '0', }} > - +
-
- ) - }) - )} + ) + }) + )} +
+
+ + {/* Creator and Stats Row */} +
+ {/* Creator Info */} +
+ {authorImageUrl ? ( +
+ {author} +
+ ) : ( +
+ +
+ )} + {author} +
+ + {/* Stats */} +
+ + {usageCount} + + {localStarCount} +
) } + +export const TemplateCard = memo(TemplateCardInner) diff --git a/apps/sim/app/templates/layout-client.tsx b/apps/sim/app/templates/layout-client.tsx new file mode 100644 index 0000000000..7051428881 --- /dev/null +++ b/apps/sim/app/templates/layout-client.tsx @@ -0,0 +1,12 @@ +'use client' + +import { Tooltip } from '@/components/emcn' +import { season } from '@/app/fonts/season/season' + +export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) { + return ( + +
{children}
+
+ ) +} diff --git a/apps/sim/app/templates/layout.tsx b/apps/sim/app/templates/layout.tsx index 306ad11c60..d1078c0793 100644 --- a/apps/sim/app/templates/layout.tsx +++ b/apps/sim/app/templates/layout.tsx @@ -1,12 +1,9 @@ -'use client' - -import { Tooltip } from '@/components/emcn' -import { season } from '@/app/fonts/season/season' +import TemplatesLayoutClient from './layout-client' +/** + * Templates layout - server component wrapper for client layout. + * Redirect logic is handled by individual pages to preserve paths. + */ export default function TemplatesLayout({ children }: { children: React.ReactNode }) { - return ( - -
{children}
-
- ) + return {children} } diff --git a/apps/sim/app/templates/page.tsx b/apps/sim/app/templates/page.tsx index e159b42125..c233949a45 100644 --- a/apps/sim/app/templates/page.tsx +++ b/apps/sim/app/templates/page.tsx @@ -1,99 +1,66 @@ import { db } from '@sim/db' -import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema' -import { and, desc, eq, sql } from 'drizzle-orm' +import { permissions, templateCreators, templates, workspace } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' +import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import type { Template } from '@/app/templates/templates' import Templates from '@/app/templates/templates' +/** + * Public templates list page. + * Redirects authenticated users to their workspace-scoped templates page. + * Allows unauthenticated users to view templates for SEO and discovery. + */ export default async function TemplatesPage() { const session = await getSession() - // Check if user is a super user and if super user mode is enabled - let effectiveSuperUser = false + // Authenticated users: redirect to workspace-scoped templates if (session?.user?.id) { - const currentUser = await db - .select({ isSuperUser: user.isSuperUser }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - const userSettings = await db - .select({ superUserModeEnabled: settings.superUserModeEnabled }) - .from(settings) - .where(eq(settings.userId, session.user.id)) + const userWorkspaces = await db + .select({ + workspace: workspace, + }) + .from(permissions) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) + .orderBy(desc(workspace.createdAt)) .limit(1) - const isSuperUser = currentUser[0]?.isSuperUser || false - const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true - - // Effective super user = database status AND UI mode enabled - effectiveSuperUser = isSuperUser && superUserModeEnabled + if (userWorkspaces.length > 0) { + const firstWorkspace = userWorkspaces[0].workspace + redirect(`/workspace/${firstWorkspace.id}/templates`) + } } - // Fetch templates based on user status - let templatesData - - if (session?.user?.id) { - // Build where condition based on super user status - const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved') - - // Logged-in users: include star status - templatesData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - name: templates.name, - details: templates.details, - creatorId: templates.creatorId, - creator: templateCreators, - views: templates.views, - stars: templates.stars, - status: templates.status, - tags: templates.tags, - requiredCredentials: templates.requiredCredentials, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - isStarred: sql`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, - }) - .from(templates) - .leftJoin( - templateStars, - and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) - ) - .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id)) - .where(whereCondition) - .orderBy(desc(templates.views), desc(templates.createdAt)) - } else { - // Non-logged-in users: only approved templates, no star status - templatesData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - name: templates.name, - details: templates.details, - creatorId: templates.creatorId, - creator: templateCreators, - views: templates.views, - stars: templates.stars, - status: templates.status, - tags: templates.tags, - requiredCredentials: templates.requiredCredentials, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - }) - .from(templates) - .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id)) - .where(eq(templates.status, 'approved')) - .orderBy(desc(templates.views), desc(templates.createdAt)) - .then((rows) => rows.map((row) => ({ ...row, isStarred: false }))) - } + // Unauthenticated users: show public templates + const templatesData = await db + .select({ + id: templates.id, + workflowId: templates.workflowId, + name: templates.name, + details: templates.details, + creatorId: templates.creatorId, + creator: templateCreators, + views: templates.views, + stars: templates.stars, + status: templates.status, + tags: templates.tags, + requiredCredentials: templates.requiredCredentials, + state: templates.state, + createdAt: templates.createdAt, + updatedAt: templates.updatedAt, + }) + .from(templates) + .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id)) + .where(eq(templates.status, 'approved')) + .orderBy(desc(templates.views), desc(templates.createdAt)) + .then((rows) => rows.map((row) => ({ ...row, isStarred: false }))) return ( ) } diff --git a/apps/sim/app/templates/templates.tsx b/apps/sim/app/templates/templates.tsx index eb28fb5373..3ef375716a 100644 --- a/apps/sim/app/templates/templates.tsx +++ b/apps/sim/app/templates/templates.tsx @@ -1,13 +1,12 @@ 'use client' -import { useState } from 'react' -import { ArrowLeft, Search } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { Layout, Search } from 'lucide-react' import { useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' +import { Button } from '@/components/emcn' import { Input } from '@/components/ui/input' import { createLogger } from '@/lib/logs/console/logger' import type { CredentialRequirement } from '@/lib/workflows/credential-extractor' -import { NavigationTabs } from '@/app/templates/components/navigation-tabs' import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card' import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { CreatorProfileDetails } from '@/types/creator-profile' @@ -60,11 +59,30 @@ export default function Templates({ const [templates, setTemplates] = useState(initialTemplates) const [loading, setLoading] = useState(false) - const handleTabClick = (tabId: string) => { - setActiveTab(tabId) - } + // Redirect authenticated users to workspace templates + useEffect(() => { + if (currentUserId) { + const redirectToWorkspace = async () => { + try { + const response = await fetch('/api/workspaces') + if (response.ok) { + const data = await response.json() + const defaultWorkspace = data.workspaces?.[0] + if (defaultWorkspace) { + router.push(`/workspace/${defaultWorkspace.id}/templates`) + } + } + } catch (error) { + logger.error('Error redirecting to workspace:', error) + } + } + redirectToWorkspace() + } + }, [currentUserId, router]) - // Handle star change callback from template card + /** + * Update star status for a template + */ const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => { setTemplates((prevTemplates) => prevTemplates.map((template) => @@ -73,239 +91,137 @@ export default function Templates({ ) } - const matchesSearch = (template: Template) => { - if (!searchQuery) return true + /** + * Filter templates based on active tab and search query + * Memoized to prevent unnecessary recalculations on render + */ + const filteredTemplates = useMemo(() => { const query = searchQuery.toLowerCase() - return ( - template.name.toLowerCase().includes(query) || - template.details?.tagline?.toLowerCase().includes(query) || - template.creator?.name?.toLowerCase().includes(query) - ) - } - - const ownedTemplates = currentUserId - ? templates.filter( - (template) => - template.creator?.referenceType === 'user' && - template.creator?.referenceId === currentUserId - ) - : [] - const starredTemplates = currentUserId - ? templates.filter( - (template) => - template.isStarred && - !( - template.creator?.referenceType === 'user' && - template.creator?.referenceId === currentUserId - ) - ) - : [] - - const filteredOwnedTemplates = ownedTemplates.filter(matchesSearch) - const filteredStarredTemplates = starredTemplates.filter(matchesSearch) - - const galleryTemplates = templates - .filter((template) => template.status === 'approved') - .filter(matchesSearch) - - const pendingTemplates = templates - .filter((template) => template.status === 'pending') - .filter(matchesSearch) - // Helper function to render template cards - const renderTemplateCard = (template: Template) => ( - }} - isStarred={template.isStarred} - onStarChange={handleStarChange} - isAuthenticated={!!currentUserId} - /> - ) - - // Render skeleton cards for loading state - const renderSkeletonCards = () => { - return Array.from({ length: 8 }).map((_, index) => ( - - )) - } - - // Calculate counts for tabs - const yourTemplatesCount = ownedTemplates.length + starredTemplates.length - const galleryCount = templates.filter((template) => template.status === 'approved').length - const pendingCount = templates.filter((template) => template.status === 'pending').length - - // Build tabs based on user status - const navigationTabs = [ - { - id: 'gallery', - label: 'Gallery', - count: galleryCount, - }, - ...(currentUserId - ? [ - { - id: 'your', - label: 'Your Templates', - count: yourTemplatesCount, - }, - ] - : []), - ...(isSuperUser - ? [ - { - id: 'pending', - label: 'Pending', - count: pendingCount, - }, - ] - : []), - ] - - // Show tabs if there's more than one tab - const showTabs = navigationTabs.length > 1 - - const handleBackToWorkspace = async () => { - try { - const response = await fetch('/api/workspaces') - if (response.ok) { - const data = await response.json() - const defaultWorkspace = data.workspaces?.[0] - if (defaultWorkspace) { - router.push(`/workspace/${defaultWorkspace.id}`) - } + return templates.filter((template) => { + // Filter by tab - only gallery and pending for public page + const tabMatch = + activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending' + + if (!tabMatch) return false + + // Filter by search query + if (!query) return true + + const searchableText = [template.name, template.details?.tagline, template.creator?.name] + .filter(Boolean) + .join(' ') + .toLowerCase() + + return searchableText.includes(query) + }) + }, [templates, activeTab, searchQuery]) + + /** + * Get empty state message based on current filters + * Memoized to prevent unnecessary recalculations on render + */ + const emptyState = useMemo(() => { + if (searchQuery) { + return { + title: 'No templates found', + description: 'Try a different search term', } - } catch (error) { - logger.error('Error navigating to workspace:', error) } - } + + const messages = { + pending: { + title: 'No pending templates', + description: 'New submissions will appear here', + }, + gallery: { + title: 'No templates available', + description: 'Templates will appear once approved', + }, + } + + return messages[activeTab as keyof typeof messages] || messages.gallery + }, [searchQuery, activeTab]) return (
-
- {/* Header with Back Button */} -
- {currentUserId && ( - - )} -

- Templates -

-

- Grab a template and start building, or make -
- one from scratch. +

+
+
+
+ +
+

Templates

+
+

+ Grab a template and start building, or make one from scratch.

- {/* Search */} -
-
- +
+
+ setSearchQuery(e.target.value)} - className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' + className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0' />
+
+ + {isSuperUser && ( + + )} +
- {/* Navigation - only show if multiple tabs */} - {showTabs && ( -
- -
- )} +
- {loading ? ( -
- {renderSkeletonCards()} -
- ) : activeTab === 'your' ? ( - filteredOwnedTemplates.length === 0 && filteredStarredTemplates.length === 0 ? ( -
+
+ {loading ? ( + Array.from({ length: 8 }).map((_, index) => ( + + )) + ) : filteredTemplates.length === 0 ? ( +
-

- {searchQuery ? 'No templates found' : 'No templates yet'} -

-

- {searchQuery - ? 'Try a different search term' - : 'Create or star templates to see them here'} -

+

{emptyState.title}

+

{emptyState.description}

) : ( -
- {filteredOwnedTemplates.length > 0 && ( -
-

Your Templates

-
- {filteredOwnedTemplates.map((template) => renderTemplateCard(template))} -
-
- )} - - {filteredStarredTemplates.length > 0 && ( -
-

Starred Templates

-
- {filteredStarredTemplates.map((template) => renderTemplateCard(template))} -
-
- )} -
- ) - ) : ( -
- {(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).length === 0 ? ( -
-
-

- {searchQuery - ? 'No templates found' - : activeTab === 'pending' - ? 'No pending templates' - : 'No templates available'} -

-

- {searchQuery - ? 'Try a different search term' - : activeTab === 'pending' - ? 'New submissions will appear here' - : 'Templates will appear once approved'} -

-
-
- ) : ( - (activeTab === 'gallery' ? galleryTemplates : pendingTemplates).map((template) => - renderTemplateCard(template) - ) - )} -
- )} + filteredTemplates.map((template) => ( + + )) + )} +
diff --git a/apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx index 4ff6373ac0..98408c23dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/[id]/page.tsx @@ -16,12 +16,12 @@ interface TemplatePageProps { * Uses the shared TemplateDetails component with workspace context. */ export default async function TemplatePage({ params }: TemplatePageProps) { - const { workspaceId } = await params + const { workspaceId, id } = await params const session = await getSession() - // Require authentication + // Redirect unauthenticated users to public template detail page if (!session?.user?.id) { - redirect('/login') + redirect(`/templates/${id}`) } // Verify workspace membership diff --git a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx index e87419eee5..3605f98cd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/page.tsx @@ -21,9 +21,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) { const { workspaceId } = await params const session = await getSession() - // Require authentication + // Redirect unauthenticated users to public templates page if (!session?.user?.id) { - redirect('/login') + redirect('/templates') } // Verify workspace membership diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/template-deploy/template-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/template-deploy/template-deploy.tsx index c78c4bf06f..de096f89ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/template-deploy/template-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/template-deploy/template-deploy.tsx @@ -2,11 +2,11 @@ import { useEffect, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' -import { CheckCircle2, Loader2, Plus, Trash2 } from 'lucide-react' +import { CheckCircle2, Loader2, Plus } from 'lucide-react' import { useForm } from 'react-hook-form' import { z } from 'zod' +import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn' import { - Button, Dialog, DialogContent, DialogHeader, @@ -17,13 +17,11 @@ import { FormItem, FormLabel, FormMessage, - Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, - Textarea, } from '@/components/ui' import { TagInput } from '@/components/ui/tag-input' import { useSession } from '@/lib/auth-client' @@ -273,18 +271,23 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep return (
{existingTemplate && ( -
-
- -
- Template Connected +
+
+ +
+ + Template Connected + {existingTemplate.status === 'pending' && ( - + Under Review - + )} {existingTemplate.status === 'approved' && existingTemplate.views > 0 && ( - + • {existingTemplate.views} views {existingTemplate.stars > 0 && ` • ${existingTemplate.stars} stars`} @@ -294,11 +297,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)} @@ -362,8 +364,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
) : ( @@ -432,7 +433,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep )} /> -
+
{existingTemplate && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx index a7e7d1f187..5d1c90cf1c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx @@ -2,12 +2,14 @@ import type { ReactNode } from 'react' import { Badge } from '@/components/emcn' -import { Progress } from '@/components/ui' 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 @@ -43,17 +45,22 @@ 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 + return (
+ {/* Top row */}
{title} @@ -67,25 +74,42 @@ export function UsageHeader({ ({seatsText}) ) : null}
-
+
{isBlocked ? ( - Payment required + Payment required ) : ( <> - ${current.toFixed(2)} - / - {rightContent ?? ${limit}} + + ${current.toFixed(2)} + + / + {rightContent ?? ( + + ${limit} + + )} )}
- + {/* Pills row - matching UsageIndicator */} +
+ {Array.from({ length: PILL_COUNT }).map((_, i) => { + const isFilled = i < filledPillsCount + return ( +
+ ) + })} +
+ {/* Status messages */} {isBlocked && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx index a52bfdecf6..e70e325a6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react' -import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui' +import { Button, Combobox } from '@/components/emcn' +import { Alert, AlertDescription, Input, Label } from '@/components/ui' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' import { isBillingEnabled } from '@/lib/environment' @@ -535,7 +536,7 @@ export function SSO() { // SSO Provider Status View
{providers.map((provider) => ( -
+

Single Sign-On Provider

@@ -545,10 +546,9 @@ export function SSO() {
@@ -609,12 +609,12 @@ export function SSO() { {hasProviders && (
@@ -626,14 +626,14 @@ export function SSO() { {/* Provider Type Selection */}
-
+
+ {showClientSecret ? ( + + ) : ( + + )} +
{showErrors && errors.clientSecret.length > 0 && (
@@ -1011,7 +1012,7 @@ export function SSO() { + {copied ? : } +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index c87d2dcfb5..2e41a0956a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -437,7 +437,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { return (
- {/* Current Plan & Usage Overview - Styled like usage-indicator */} + {/* Current Plan & Usage Overview */}
- {/* Header - clean like account page */} -
-

Invite Team Members

-

- Add new members to your team and optionally give them access to specific workspaces -

-
+
+
+ {/* Header */} +
+

Invite Team Members

+

+ Add new members to your team and optionally give them access to specific workspaces +

+
- {/* Main invitation input - clean layout */} -
-
-
+ {/* Main invitation input */} +
+
-
- {emailError && ( -

- {emailError} -

- )} -
+ {emailError && ( +

+ {emailError} +

+ )}
+ +
- - -
- {showWorkspaceInvite && ( -
-
-
-
Workspace Access
- - Optional - + {/* Workspace selection - collapsible */} + {showWorkspaceInvite && ( +
+
+
+
Workspace Access
+ (Optional) +
+ {selectedCount > 0 && ( + {selectedCount} selected + )}
- {selectedCount > 0 && ( - {selectedCount} selected - )} -
-

- Grant access to specific workspaces. You can modify permissions later. -

- {userWorkspaces.length === 0 ? ( -
-

No workspaces available

-

- You need admin access to workspaces to invite members -

-
- ) : ( -
- {userWorkspaces.map((workspace) => { - const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id) - const selectedWorkspace = selectedWorkspaces.find( - (w) => w.workspaceId === workspace.id - ) + {userWorkspaces.length === 0 ? ( +
+

No workspaces available

+
+ ) : ( +
+ {userWorkspaces.map((workspace) => { + const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id) + const selectedWorkspace = selectedWorkspaces.find( + (w) => w.workspaceId === workspace.id + ) - return ( -
-
-
- { - if (checked) { - onWorkspaceToggle(workspace.id, 'read') - } else { - onWorkspaceToggle(workspace.id, '') - } - }} - disabled={isInviting} - /> - - {workspace.isOwner && ( - +
+
+ { + if (checked) { + onWorkspaceToggle(workspace.id, 'read') + } else { + onWorkspaceToggle(workspace.id, '') + } + }} + disabled={isInviting} + /> + + {workspace.isOwner && ( + + Owner + + )} +
-
- {/* Always reserve space for permission selector to maintain consistent layout */} -
{isSelected && ( )}
-
- ) - })} -
- )} -
- )} + ) + })} +
+ )} +
+ )} - {inviteSuccess && ( - - - - Invitation sent successfully - {selectedCount > 0 && - ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} - - - )} + {/* Success message */} + {inviteSuccess && ( +
+ +

+ Invitation sent successfully + {selectedCount > 0 && + ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} +

+
+ )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-members/team-members.tsx index b4ebe14bdb..99e2d8f386 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-members/team-members.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-members/team-members.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' -import { LogOut, UserX, X } from 'lucide-react' -import { Tooltip } from '@/components/emcn' -import { Button } from '@/components/ui/button' +import { UserX, X } from 'lucide-react' +import { Button, Tooltip } from '@/components/emcn' +import { Button as UIButton } from '@/components/ui/button' +import { UserAvatar } from '@/components/user-avatar/user-avatar' import { createLogger } from '@/lib/logs/console/logger' import type { Invitation, Member, Organization } from '@/stores/organization' @@ -20,6 +21,8 @@ interface BaseItem { name: string email: string avatarInitial: string + avatarUrl?: string | null + userId?: string usage: string } @@ -95,6 +98,8 @@ export function TeamMembers({ name, email: member.user?.email || '', avatarInitial: name.charAt(0).toUpperCase(), + avatarUrl: member.user?.image, + userId: member.user?.id, usage: `$${usageAmount.toFixed(2)}`, role: member.role, member, @@ -118,6 +123,8 @@ export function TeamMembers({ name: emailPrefix, email: invitation.email, avatarInitial: emailPrefix.charAt(0).toUpperCase(), + avatarUrl: null, + userId: invitation.email, // Use email as fallback for color generation usage: '-', invitation, } @@ -163,15 +170,12 @@ export function TeamMembers({ {/* Member info */}
{/* Avatar */} -
- {item.avatarInitial} -
+ {/* Name and email */}
@@ -223,14 +227,14 @@ export function TeamMembers({ item.email !== currentUserEmail && ( - + Remove Member @@ -240,7 +244,7 @@ export function TeamMembers({ {isAdminOrOwner && item.type === 'invitation' && ( - + {cancellingInvitations.has(item.invitation.id) @@ -268,10 +272,9 @@ export function TeamMembers({ {/* Leave Organization button */} {canLeaveOrganization && ( -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx index 2c077ea04e..f748c93463 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats-overview/team-seats-overview.tsx @@ -1,10 +1,11 @@ import { Building2 } from 'lucide-react' +import { Badge } from '@/components/emcn' import { Button } from '@/components/ui/button' -import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' -import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants' import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils' -import { env } from '@/lib/env' +import { cn } from '@/lib/utils' + +const PILL_COUNT = 8 type Subscription = { id: string @@ -93,56 +94,59 @@ export function TeamSeatsOverview({ ) } + const totalSeats = subscriptionData.seats || 0 + const isEnterprise = checkEnterprisePlan(subscriptionData) + return (
- {/* Seats info and usage - matching team usage layout */} + {/* Top row - matching UsageHeader */}
- Seats - {!checkEnterprisePlan(subscriptionData) ? ( - - (${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each) - - ) : null} + Seats + {!isEnterprise && ( + + Add Seats + + )}
-
- {usedSeats} used - / - {subscriptionData.seats || 0} total +
+ + {usedSeats} used + + / + + {totalSeats} total +
- {/* Progress Bar - matching team usage component */} - + {/* Pills row - one pill per seat */} +
+ {Array.from({ length: totalSeats }).map((_, i) => { + const isFilled = i < usedSeats + return ( +
+ ) + })} +
- {/* Action buttons - below the usage display */} - {checkEnterprisePlan(subscriptionData) ? ( -
+ {/* Enterprise message */} + {isEnterprise && ( +

Contact support for enterprise usage limit changes

- ) : ( -
- - -
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx index 59a0a76fe7..5e8b6b2ccf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx @@ -147,7 +147,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) { onLimitUpdated={handleLimitUpdated} /> ) : ( - + ${currentCap.toFixed(0)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx index ee0218c1d9..bf51014391 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx @@ -12,7 +12,6 @@ import { TeamMembers, TeamSeats, TeamSeatsOverview, - TeamUsage, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components' import { generateSlug, useOrganizationStore } from '@/stores/organization' import { useSubscriptionStore } from '@/stores/subscription/store' @@ -275,112 +274,149 @@ export function TeamManagement() { } return ( -
-
+
+
{error && ( - + Error {error} )} - {/* Team Usage Overview */} - - - {/* Team Billing Information (only show for Team Plan, not Enterprise) */} - {hasTeamPlan && !hasEnterprisePlan && ( -
-
-

How Team Billing Works

-
    -
  • - Your team is billed a minimum of $ - {(subscriptionData?.seats || 0) * - (env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)} - /month for {subscriptionData?.seats || 0} licensed seats -
  • -
  • All team member usage is pooled together from a shared limit
  • -
  • - When pooled usage exceeds the limit, all members are blocked from using the - service -
  • -
  • You can increase the usage limit to allow for higher usage
  • -
  • - Any usage beyond the minimum seat cost is billed as overage at the end of the - billing period -
  • -
-
+ {/* Seats Overview - Full Width */} + {adminOrOwner && ( +
+
)} - {/* Team Seats Overview */} - {adminOrOwner && ( - + - )} - - {/* Team Members */} - +
- {/* Single Organization Notice */} + {/* Action: Invite New Members */} {adminOrOwner && ( -
-

- Note: Users can only be part of one organization - at a time. They must leave their current organization before joining another. -

+
+ loadUserWorkspaces(session?.user?.id)} + onWorkspaceToggle={handleWorkspaceToggle} + inviteSuccess={inviteSuccess} + availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)} + maxSeats={subscriptionData?.seats || 0} + />
)} - {/* Member Invitation Card */} - {adminOrOwner && ( - loadUserWorkspaces(session?.user?.id)} - onWorkspaceToggle={handleWorkspaceToggle} - inviteSuccess={inviteSuccess} - availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)} - maxSeats={subscriptionData?.seats || 0} - /> - )} -
- - {/* Team Information Section - pinned to bottom of modal */} -
-
-
- Team ID: - {activeOrganization.id} -
-
- Created: - {new Date(activeOrganization.createdAt).toLocaleDateString()} -
-
- Your Role: - {userRole} -
+ {/* Additional Info - Subtle and collapsed */} +
+ {/* Single Organization Notice */} + {adminOrOwner && ( +
+

+ Note: Users can only be part of one + organization at a time. +

+
+ )} + + {/* Team Information */} +
+ + Team Information + + + + +
+
+ Team ID: + {activeOrganization.id} +
+
+ Created: + {new Date(activeOrganization.createdAt).toLocaleDateString()} +
+
+ Your Role: + {userRole} +
+
+
+ + {/* Team Billing Information (only show for Team Plan, not Enterprise) */} + {hasTeamPlan && !hasEnterprisePlan && ( +
+ + Billing Information + + + + +
+
    +
  • + Your team is billed a minimum of $ + {(subscriptionData?.seats || 0) * + (env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)} + /month for {subscriptionData?.seats || 0} licensed seats +
  • +
  • All team member usage is pooled together from a shared limit
  • +
  • + When pooled usage exceeds the limit, all members are blocked from using the + service +
  • +
  • You can increase the usage limit to allow for higher usage
  • +
  • + Any usage beyond the minimum seat cost is billed as overage at the end of the + billing period +
  • +
+
+
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts index 586a4cbace..6c50bc00f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useRouter } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' import { generateWorkspaceName } from '@/lib/naming' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -32,6 +32,7 @@ export function useWorkspaceManagement({ sessionUserId, }: UseWorkspaceManagementProps) { const router = useRouter() + const pathname = usePathname() const { switchToWorkspace } = useWorkflowRegistry() // Workspace management state @@ -45,12 +46,14 @@ export function useWorkspaceManagement({ // Refs to avoid dependency issues const workspaceIdRef = useRef(workspaceId) const routerRef = useRef>(router) + const pathnameRef = useRef(pathname || null) const activeWorkspaceRef = useRef(null) const isInitializedRef = useRef(false) // Update refs when values change workspaceIdRef.current = workspaceId routerRef.current = router + pathnameRef.current = pathname || null activeWorkspaceRef.current = activeWorkspace /** @@ -189,7 +192,17 @@ export function useWorkspaceManagement({ try { // Switch workspace and update URL await switchToWorkspace(workspace.id) - routerRef.current?.push(`/workspace/${workspace.id}/w`) + const currentPath = pathnameRef.current || '' + // Preserve templates route if user is on templates or template detail + const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/) + if (templateDetailMatch) { + const templateId = templateDetailMatch[1] + routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`) + } else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) { + routerRef.current?.push(`/workspace/${workspace.id}/templates`) + } else { + routerRef.current?.push(`/workspace/${workspace.id}/w`) + } logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`) } catch (error) { logger.error('Error switching workspace:', error) 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 bc5687e6f3..7f6251b6d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -347,13 +347,22 @@ export function Sidebar() { try { // Switch workspace and update URL await switchToWorkspace(workspace.id) - routerRef.current?.push(`/workspace/${workspace.id}/w`) + const currentPath = pathname || '' + const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/) + if (templateDetailMatch) { + const templateId = templateDetailMatch[1] + routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`) + } else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) { + routerRef.current?.push(`/workspace/${workspace.id}/templates`) + } else { + routerRef.current?.push(`/workspace/${workspace.id}/w`) + } logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`) } catch (error) { logger.error('Error switching workspace:', error) } }, - [switchToWorkspace] // Removed activeWorkspace and router dependencies + [switchToWorkspace, pathname] // include pathname ) /** diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index a6606448c3..96efbaeb29 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -439,7 +439,26 @@ const Combobox = forwardRef( } }} > - + { + // Ensure wheel events are captured and don't get blocked by parent handlers + const target = e.currentTarget + const { scrollTop, scrollHeight, clientHeight } = target + const delta = e.deltaY + const isScrollingDown = delta > 0 + const isScrollingUp = delta < 0 + + // Check if we're at scroll boundaries + const isAtTop = scrollTop === 0 + const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1 + + // Only stop propagation if we can scroll in the requested direction + if ((isScrollingDown && !isAtBottom) || (isScrollingUp && !isAtTop)) { + e.stopPropagation() + } + }} + >
{isLoading ? (
diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 0b9972312b..bbe480abf0 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -278,7 +278,7 @@ const PopoverContent = React.forwardRef< sticky='partial' {...restProps} className={cn( - 'z-[9999999] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]', + 'z-[10000001] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]', // If width is constrained by the caller, ensure inner flexible text truncates by default. hasUserWidthConstraint && '[&_.flex-1]:truncate', className diff --git a/apps/sim/components/ui/dialog.tsx b/apps/sim/components/ui/dialog.tsx index 0be3076631..ab299dfc76 100644 --- a/apps/sim/components/ui/dialog.tsx +++ b/apps/sim/components/ui/dialog.tsx @@ -29,7 +29,7 @@ const DialogOverlay = React.forwardRef< { diff --git a/apps/sim/components/user-avatar/user-avatar.tsx b/apps/sim/components/user-avatar/user-avatar.tsx new file mode 100644 index 0000000000..1c57332e93 --- /dev/null +++ b/apps/sim/components/user-avatar/user-avatar.tsx @@ -0,0 +1,66 @@ +'use client' + +import { type CSSProperties, useEffect, useState } from 'react' +import Image from 'next/image' +import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color' + +interface UserAvatarProps { + userId: string + userName?: string | null + avatarUrl?: string | null + size?: number + className?: string +} + +/** + * Reusable user avatar component with error handling for image loading. + * Falls back to colored circle with initials if image fails to load or is not available. + */ +export function UserAvatar({ + userId, + userName, + avatarUrl, + size = 32, + className = '', +}: UserAvatarProps) { + const [imageError, setImageError] = useState(false) + const color = getUserColor(userId) + const initials = userName ? userName.charAt(0).toUpperCase() : '?' + const hasAvatar = Boolean(avatarUrl) && !imageError + + // Reset error state when avatar URL changes + useEffect(() => { + setImageError(false) + }, [avatarUrl]) + + const fontSize = Math.max(10, size / 2.5) + + return ( +
+ {hasAvatar && avatarUrl ? ( + {userName setImageError(true)} + /> + ) : ( + initials + )} +
+ ) +} diff --git a/apps/sim/middleware.ts b/apps/sim/middleware.ts index 0d9e205506..a922ab9feb 100644 --- a/apps/sim/middleware.ts +++ b/apps/sim/middleware.ts @@ -157,6 +157,11 @@ export async function middleware(request: NextRequest) { } if (url.pathname.startsWith('/workspace')) { + // Allow public access to workspace template pages - they handle their own redirects + if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) { + return NextResponse.next() + } + if (!hasActiveSession) { return NextResponse.redirect(new URL('/login', request.url)) } diff --git a/apps/sim/stores/organization/types.ts b/apps/sim/stores/organization/types.ts index e61f7cde2f..333c193ac9 100644 --- a/apps/sim/stores/organization/types.ts +++ b/apps/sim/stores/organization/types.ts @@ -2,6 +2,7 @@ export interface User { name?: string email?: string id?: string + image?: string | null } export interface Member {