- {/* 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}
-
•
-
-
{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}
+
+
+ {/* 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.
+
+
+
+
+ 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 && (