From 82bed774e5c0e718ac019bb8972873890b3f13e4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 14 Jul 2026 15:51:08 -0700 Subject: [PATCH 1/9] improvement(chat): show resource-type icons on inline workspace resource links (#5669) * improvement(chat): show resource-type icons on inline workspace resource links * fix(chat): derive wsres click title from markdown label, not DOM textContent * fix(chat): keep dashed-underline affordance for unmapped wsres link types * fix(chat): parse wsres links once, derive file icons from the VFS path --- .../components/chat-content/chat-content.tsx | 71 ++++++++++++++----- .../lib/copilot/tools/client/store-utils.ts | 16 +---- apps/sim/lib/copilot/vfs/path-utils.ts | 12 ++++ 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index 1283dc8617e..f5ffd95f765 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -12,14 +12,16 @@ import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' import '@sim/emcn/components/code/code.css' import { Checkbox, CopyCodeButton, cn, highlight, languages } from '@sim/emcn' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' import { extractTextContent } from '@/lib/core/utils/react-node-text' +import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { type ContentSegment, PendingTagIndicator, parseSpecialTags, SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' -import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import type { ChatContextKind, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' import { useSmoothText } from '@/hooks/use-smooth-text' import { sanitizeChatDisplayContent } from './chat-sanitize' @@ -132,6 +134,31 @@ function appendInlineReferenceMarkdown( type TdProps = ComponentPropsWithoutRef<'td'> type ThProps = ComponentPropsWithoutRef<'th'> +/** + * Maps a `#wsres-{type}-{ref}` link's resource type to the chat-context kind + * whose icon represents it, so inline resource references render the same + * type icon as the user-input context chips. + */ +const WSRES_LINK_KINDS: Record = { + workflow: 'workflow', + table: 'table', + file: 'file', +} + +/** + * Label used to pick a file link's extension-aware document icon. The visible + * link text can be a custom title without an extension, so prefer the file + * name carried in the link's VFS path (its last extension-bearing segment). + */ +function fileIconLabel(ref: string, fallback: string): string { + const segments = ref.split('/').filter(Boolean) + for (let i = segments.length - 1; i >= 0; i--) { + const decoded = decodeVfsSegmentSafe(segments[i]) + if (decoded.includes('.')) return decoded + } + return fallback +} + const MARKDOWN_COMPONENTS = { table({ children }: { children?: React.ReactNode }) { return ( @@ -202,28 +229,40 @@ const MARKDOWN_COMPONENTS = { }, a({ children, href }: { children?: React.ReactNode; href?: string }) { if (href?.startsWith('#wsres-')) { + const match = href.match(/^#wsres-(\w+)-(.+)$/) + const type = match?.[1] + const ref = match?.[2] + const kind = type ? WSRES_LINK_KINDS[type] : undefined + const label = extractTextContent(children) return ( { e.preventDefault() - const match = href.match(/^#wsres-(\w+)-(.+)$/) - if (match) { - const type = match[1] - const ref = match[2] - const linkText = e.currentTarget.textContent || ref - window.dispatchEvent( - new CustomEvent('wsres-click', { - detail: - type === 'file' - ? { type, path: ref, title: linkText } - : { type, id: ref, title: linkText }, - }) - ) - } + if (!type || !ref) return + const linkText = label || ref + window.dispatchEvent( + new CustomEvent('wsres-click', { + detail: + type === 'file' + ? { type, path: ref, title: linkText } + : { type, id: ref, title: linkText }, + }) + ) }} > + {kind && ref && ( + + )} {children} ) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 7364bbc1f87..f143602dcd2 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -6,7 +6,7 @@ import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { decodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' @@ -82,20 +82,6 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta } } -/** - * VFS paths store each segment percent-encoded (see {@link encodeVfsSegment}), so - * a read on "My Report.txt" arrives as "files/My%20Report.txt". Decode for - * display so the user sees the real file name. Falls back to the raw segment when - * it is not valid encoding (e.g. a literal "%" that was never encoded). - */ -function decodeVfsSegmentSafe(segment: string): string { - try { - return decodeVfsSegment(segment) - } catch { - return segment - } -} - function describeReadTarget(path: string | undefined): string | undefined { if (!path) return undefined diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts index 6f88e9507d9..daeb5c0a631 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.ts +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -34,6 +34,18 @@ export function decodeVfsSegment(segment: string): string { } } +/** + * Decodes a VFS path segment for display, falling back to the raw segment when + * it is not valid encoding (e.g. a literal "%" that was never encoded). + */ +export function decodeVfsSegmentSafe(segment: string): string { + try { + return decodeVfsSegment(segment) + } catch { + return segment + } +} + export function encodeVfsPathSegments(segments: string[]): string { return segments.map(encodeVfsSegment).join('/') } From 515dafa274943ac2c5b6b8e6e513c285177f00e2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 14 Jul 2026 16:33:11 -0700 Subject: [PATCH 2/9] feat(billing): allow programmatic workflow execution on the free plan (#5678) * feat(billing): allow programmatic workflow execution on the free plan Remove the free-plan paywall on programmatic execution (API-key/public execute, MCP serve, generic webhooks, cross-origin chat embeds) added in #5036. The gate shipped dark behind FREE_API_DEPLOYMENT_GATE_ENABLED to curb bot abuse; with that abuse handled upstream, free workspaces can use the API again (still subject to the free-tier rate limits). - Delete isWorkspaceApiExecutionEntitled / api-access.ts and the FREE_API_DEPLOYMENT_GATE_ENABLED env flag - Ungate the execute, mcp/serve, and webhooks/trigger routes and the chat embed check (assertChatEmbedAllowed removed) - Remove the deploy-modal upgrade wall (DeployUpgradeGate) and the now unused useWorkspaceOwnerBilling client hook; the owner-billing endpoint stays as reusable billing infrastructure - Restore pre-gate upgrade-page copy (Pro feature line, free API endpoint rate-limit column) * chore(billing): drop orphaned owner-billing endpoint and dead chat logger Self-review follow-ups: the /api/workspaces/[id]/owner-billing route + contract existed solely for the deploy-modal gate's client hook (deleted here); host-context serves the same ownerBilling data server-side, so the standalone endpoint is dead HTTP surface. workspaceOwnerBillingSchema and the WorkspaceOwnerBilling type stay (embedded in workspaceHostContextSchema). Also removes the now-unused ChatAuthUtils logger. * style: biome formatting after gate removal --- .../app/api/chat/[identifier]/route.test.ts | 62 ++--------- apps/sim/app/api/chat/[identifier]/route.ts | 8 +- apps/sim/app/api/chat/utils.test.ts | 103 +----------------- apps/sim/app/api/chat/utils.ts | 61 ----------- .../api/mcp/serve/[serverId]/route.test.ts | 27 ----- .../sim/app/api/mcp/serve/[serverId]/route.ts | 13 --- .../api/webhooks/trigger/[path]/route.test.ts | 72 ------------ .../app/api/webhooks/trigger/[path]/route.ts | 21 ---- .../[id]/execute/response-block.test.ts | 7 -- .../[id]/execute/route.async.test.ts | 7 -- .../app/api/workflows/[id]/execute/route.ts | 26 ----- .../[id]/owner-billing/route.test.ts | 80 -------------- .../workspaces/[id]/owner-billing/route.ts | 35 ------ .../comparison-table/comparison-data.ts | 2 +- .../[workspaceId]/upgrade/plan-configs.ts | 2 +- .../deploy-upgrade-gate.tsx | 51 --------- .../components/deploy-upgrade-gate/index.ts | 1 - .../deploy-modal/components/index.ts | 1 - .../components/deploy-modal/deploy-modal.tsx | 91 +++++----------- apps/sim/hooks/queries/workspace.ts | 33 ------ apps/sim/lib/api/contracts/workspaces.ts | 10 -- apps/sim/lib/billing/core/api-access.test.ts | 87 --------------- apps/sim/lib/billing/core/api-access.ts | 30 ----- apps/sim/lib/billing/index.ts | 1 - apps/sim/lib/core/config/env-flags.ts | 8 -- apps/sim/lib/core/config/env.ts | 1 - 26 files changed, 39 insertions(+), 801 deletions(-) delete mode 100644 apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts delete mode 100644 apps/sim/app/api/workspaces/[id]/owner-billing/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts delete mode 100644 apps/sim/lib/billing/core/api-access.test.ts delete mode 100644 apps/sim/lib/billing/core/api-access.ts diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts index 9eb104547ff..82f3def0220 100644 --- a/apps/sim/app/api/chat/[identifier]/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/route.test.ts @@ -14,7 +14,6 @@ import { workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' -import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -65,19 +64,13 @@ const createMockStream = () => { }) } -const { - mockValidateChatAuth, - mockSetChatAuthCookie, - mockValidateAuthToken, - mockAssertChatEmbedAllowed, - mockProcessChatFiles, -} = vi.hoisted(() => ({ - mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), - mockSetChatAuthCookie: vi.fn(), - mockValidateAuthToken: vi.fn().mockReturnValue(false), - mockAssertChatEmbedAllowed: vi.fn().mockResolvedValue(null), - mockProcessChatFiles: vi.fn(), -})) +const { mockValidateChatAuth, mockSetChatAuthCookie, mockValidateAuthToken, mockProcessChatFiles } = + vi.hoisted(() => ({ + mockValidateChatAuth: vi.fn().mockResolvedValue({ authorized: true }), + mockSetChatAuthCookie: vi.fn(), + mockValidateAuthToken: vi.fn().mockReturnValue(false), + mockProcessChatFiles: vi.fn(), + })) const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse @@ -97,7 +90,6 @@ vi.mock('@/lib/core/security/deployment', () => ({ vi.mock('@/app/api/chat/utils', () => ({ validateChatAuth: mockValidateChatAuth, setChatAuthCookie: mockSetChatAuthCookie, - assertChatEmbedAllowed: mockAssertChatEmbedAllowed, })) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) @@ -245,24 +237,6 @@ describe('Chat Identifier API Route', () => { expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat') }) - it('should return 403 when embedding is blocked for a cross-origin caller', async () => { - mockAssertChatEmbedAllowed.mockResolvedValueOnce( - NextResponse.json( - { error: 'Embedding this chat on external sites requires a paid plan' }, - { - status: 403, - } - ) - ) - - const req = createMockNextRequest('GET', undefined, { origin: 'https://evil.example.com' }) - const params = Promise.resolve({ identifier: 'test-chat' }) - - const response = await GET(req, { params }) - - expect(response.status).toBe(403) - }) - it('should return 404 for non-existent identifier', async () => { dbChainMockFns.select.mockImplementation(() => { return { @@ -335,28 +309,6 @@ describe('Chat Identifier API Route', () => { }) describe('POST endpoint', () => { - it('should return 403 when embedding is blocked for a cross-origin caller', async () => { - mockAssertChatEmbedAllowed.mockResolvedValueOnce( - NextResponse.json( - { error: 'Embedding this chat on external sites requires a paid plan' }, - { - status: 403, - } - ) - ) - - const req = createMockNextRequest( - 'POST', - { input: 'Hello' }, - { origin: 'https://evil.example.com' } - ) - const params = Promise.resolve({ identifier: 'test-chat' }) - - const response = await POST(req, { params }) - - expect(response.status).toBe(403) - }) - it('should return chat config on successful authentication', async () => { const req = createMockNextRequest('POST', { password: 'test-password' }) const params = Promise.resolve({ identifier: 'password-protected-chat' }) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 819c732db5b..578be93c189 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -15,7 +15,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { ChatFiles } from '@/lib/uploads' -import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' +import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatIdentifierAPI') @@ -135,9 +135,6 @@ export const POST = withRouteHandler( return createErrorResponse('This chat is currently unavailable', 403) } - const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) - if (embedBlock) return embedBlock - const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { const response = createErrorResponse( @@ -361,9 +358,6 @@ export const GET = withRouteHandler( return createErrorResponse('This chat is currently unavailable', 403) } - const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) - if (embedBlock) return embedBlock - const cookieName = `chat_auth_${deployment.id}` const authCookie = request.cookies.get(cookieName) diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index a6eea9107e4..4e457c1181d 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -5,7 +5,6 @@ */ import { dbChainMock, - dbChainMockFns, encryptionMock, encryptionMockFns, loggingSessionMock, @@ -22,8 +21,6 @@ const { mockIsEmailAllowed, mockGetSession, mockCheckRateLimitDirect, - mockIsWorkspaceApiExecutionEntitled, - flagState, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), mockMergeSubBlockValues: vi.fn().mockReturnValue({}), @@ -32,16 +29,10 @@ const { mockIsEmailAllowed: vi.fn(), mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), - mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), - flagState: { isBillingEnabled: false, isFreeApiDeploymentGateEnabled: true }, })) vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/billing/core/api-access', () => ({ - isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, -})) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = mockCheckRateLimitDirect @@ -79,28 +70,10 @@ vi.mock('@/lib/core/security/deployment', () => ({ deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isDev: true, - isProd: false, - get isBillingEnabled() { - return flagState.isBillingEnabled - }, - get isFreeApiDeploymentGateEnabled() { - return flagState.isFreeApiDeploymentGateEnabled - }, -})) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -import { NextRequest } from 'next/server' import { decryptSecret } from '@/lib/core/security/encryption' -import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' - -function chatRequest(origin?: string): NextRequest { - return new NextRequest('https://www.sim.ai/api/chat/abc', { - headers: origin ? { origin } : undefined, - }) -} +import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' describe('Chat API Utils', () => { beforeEach(() => { @@ -479,77 +452,3 @@ describe('Chat API Utils', () => { }) }) }) - -describe('assertChatEmbedAllowed', () => { - beforeEach(() => { - vi.clearAllMocks() - flagState.isBillingEnabled = true - flagState.isFreeApiDeploymentGateEnabled = true - mockIsWorkspaceApiExecutionEntitled.mockResolvedValue(true) - dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'ws-1' }]) - }) - - it('returns 403 for a cross-site origin when the owner is on the free plan', async () => { - mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false) - const res = await assertChatEmbedAllowed( - chatRequest('https://evil.example.com'), - 'wf-1', - 'req-1' - ) - expect(res?.status).toBe(403) - }) - - it('allows a cross-site origin when the owner is on a paid plan', async () => { - const res = await assertChatEmbedAllowed( - chatRequest('https://evil.example.com'), - 'wf-1', - 'req-1' - ) - expect(res).toBeNull() - }) - - it('returns 403 for a cross-site origin when the workflow has no active workspace', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([]) - const res = await assertChatEmbedAllowed( - chatRequest('https://evil.example.com'), - 'wf-1', - 'req-1' - ) - expect(res?.status).toBe(403) - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) - - it('allows a first-party *.sim.ai origin without gating', async () => { - const res = await assertChatEmbedAllowed(chatRequest('https://chat.sim.ai'), 'wf-1', 'req-1') - expect(res).toBeNull() - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) - - it('allows requests with no Origin header', async () => { - const res = await assertChatEmbedAllowed(chatRequest(), 'wf-1', 'req-1') - expect(res).toBeNull() - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) - - it('is a no-op when billing is disabled', async () => { - flagState.isBillingEnabled = false - const res = await assertChatEmbedAllowed( - chatRequest('https://evil.example.com'), - 'wf-1', - 'req-1' - ) - expect(res).toBeNull() - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) - - it('is a no-op when the gate feature flag is disabled', async () => { - flagState.isFreeApiDeploymentGateEnabled = false - const res = await assertChatEmbedAllowed( - chatRequest('https://evil.example.com'), - 'wf-1', - 'req-1' - ) - expect(res).toBeNull() - expect(mockIsWorkspaceApiExecutionEntitled).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index c200a47adb5..5b17f3cb6e8 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,20 +1,13 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' -import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' -import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' -import { getEnv } from '@/lib/core/config/env' -import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags' import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { type DeploymentAuthResult, validateDeploymentAuth, } from '@/lib/core/security/deployment-auth' -import { createErrorResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('ChatAuthUtils') export function setChatAuthCookie( response: NextResponse, @@ -25,60 +18,6 @@ export function setChatAuthCookie( setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword) } -/** - * A first-party origin is the app itself or any `*.sim.ai` host (chat subdomains - * + apex). Anything else is a third-party embed. Malformed origins are treated - * as third-party. - */ -function isFirstPartyOrigin(origin: string): boolean { - try { - const host = new URL(origin).hostname.toLowerCase() - if (host === 'sim.ai' || host.endsWith('.sim.ai')) return true - const appUrl = getEnv('NEXT_PUBLIC_APP_URL') - if (appUrl && host === new URL(appUrl).hostname.toLowerCase()) return true - return false - } catch { - return false - } -} - -/** - * Gates cross-origin (embedded) chat requests behind a paid plan on hosted. - * Same-origin / SSR / first-party requests — including the chat page rendered in - * a third-party iframe, which calls the API from a `*.sim.ai` origin — are never - * gated. Returns a 403 response to short-circuit the route, or `null` to allow. - */ -export async function assertChatEmbedAllowed( - request: NextRequest, - workflowId: string, - requestId: string -): Promise { - if (!isBillingEnabled || !isFreeApiDeploymentGateEnabled) return null - - const origin = request.headers.get('origin') - if (!origin || isFirstPartyOrigin(origin)) return null - - const [wf] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) - .limit(1) - - if (!wf?.workspaceId) { - logger.warn( - `[${requestId}] Chat embed blocked: no active workspace for workflow ${workflowId}, origin=${origin}` - ) - return createErrorResponse('This chat is currently unavailable', 403) - } - - if (!(await isWorkspaceApiExecutionEntitled(wf.workspaceId))) { - logger.warn(`[${requestId}] Chat embed blocked: workspace on free plan, origin=${origin}`) - return createErrorResponse('Embedding this chat on external sites requires a paid plan', 403) - } - - return null -} - /** * Check if user has permission to create a chat for a specific workflow */ diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index f0af2021175..cd8ec829cae 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -20,19 +20,12 @@ const { mockResolveBillingAttribution, mockSerializeBillingAttributionHeader, fetchMock, - mockIsWorkspaceApiExecutionEntitled, } = vi.hoisted(() => ({ mockAssertBillingAttributionSnapshot: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveBillingAttribution: vi.fn(), mockSerializeBillingAttributionHeader: vi.fn(), fetchMock: vi.fn(), - mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), -})) - -vi.mock('@/lib/billing/core/api-access', () => ({ - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ @@ -129,26 +122,6 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) - it('returns 402 when the workspace billed account is on the free plan', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([ - { - id: 'server-1', - name: 'Private Server', - workspaceId: 'ws-1', - isPublic: false, - createdBy: 'owner-1', - }, - ]) - mockIsWorkspaceApiExecutionEntitled.mockResolvedValueOnce(false) - - const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { - method: 'POST', - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), - }) - const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) - expect(response.status).toBe(402) - }) - it('returns 401 on GET for private server when auth fails', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5186d6c3410..a5650de1042 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -30,10 +30,6 @@ import { } from '@/lib/api/contracts/mcp' import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' -import { - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, - isWorkspaceApiExecutionEntitled, -} from '@/lib/billing/core/api-access' import { assertBillingAttributionSnapshot, BILLING_ATTRIBUTION_HEADER, @@ -341,15 +337,6 @@ async function authorizeMcpServeRequest( server: WorkflowMcpServeServer, options: { requireAuthForPublic?: boolean } = {} ): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> { - if (!(await isWorkspaceApiExecutionEntitled(server.workspaceId))) { - return { - response: NextResponse.json( - { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, - { status: 402 } - ), - } - } - if (server.isPublic && !options.requireAuthForPublic) return {} const auth = await checkHybridAuth(request, { requireWorkflowId: false }) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 60d0beca8f1..ab090a83b8b 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -116,7 +116,6 @@ const { handleWebhookEventFilterMock, queueWebhookExecutionMock, dispatchResolvedWebhookTargetMock, - isWorkspaceApiExecutionEntitledMock, shouldSkipWebhookEventMock, admissionRejectedResponseMock, tryAdmitMock, @@ -176,17 +175,11 @@ const { return NextResponse.json({ message: 'Webhook processed' }) }), dispatchResolvedWebhookTargetMock: vi.fn(), - isWorkspaceApiExecutionEntitledMock: vi.fn().mockResolvedValue(true), shouldSkipWebhookEventMock: vi.fn().mockReturnValue(false), admissionRejectedResponseMock: vi.fn(), tryAdmitMock: vi.fn<() => { release: () => void } | null>(() => ({ release: vi.fn() })), })) -vi.mock('@/lib/billing/core/api-access', () => ({ - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isWorkspaceApiExecutionEntitled: isWorkspaceApiExecutionEntitledMock, -})) - vi.mock('@/lib/core/admission/gate', () => ({ admissionRejectedResponse: admissionRejectedResponseMock, tryAdmit: tryAdmitMock, @@ -513,7 +506,6 @@ describe('Webhook Trigger API Route', () => { isFromNormalizedTables: true, }) workflowsPersistenceUtilsMockFns.mockBlockExistsInDeployment.mockResolvedValue(true) - isWorkspaceApiExecutionEntitledMock.mockResolvedValue(true) handleWebhookEventFilterMock.mockResolvedValue(null) shouldSkipWebhookEventMock.mockReturnValue(false) @@ -859,70 +851,6 @@ describe('Webhook Trigger API Route', () => { expect(data.message).toBe('Webhook processed') }) - it('blocks a generic webhook when the workspace is on the free plan', async () => { - testData.webhooks.push({ - id: 'generic-webhook-id', - provider: 'generic', - path: 'test-path', - isActive: true, - providerConfig: { requireAuth: false }, - workflowId: 'test-workflow-id', - rateLimitCount: 100, - rateLimitPeriod: 60, - }) - testData.workflows.push({ - id: 'test-workflow-id', - userId: 'test-user-id', - workspaceId: 'test-workspace-id', - }) - isWorkspaceApiExecutionEntitledMock.mockResolvedValueOnce(false) - - const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) - const params = Promise.resolve({ path: 'test-path' }) - - const response = await POST(req, { params }) - - expect(response.status).toBe(402) - }) - - it('returns 402 (not 500) when every webhook in a shared path is generic and free', async () => { - testData.webhooks.push( - { - id: 'generic-webhook-a', - provider: 'generic', - path: 'test-path', - isActive: true, - providerConfig: { requireAuth: false }, - workflowId: 'test-workflow-id', - rateLimitCount: 100, - rateLimitPeriod: 60, - }, - { - id: 'generic-webhook-b', - provider: 'generic', - path: 'test-path', - isActive: true, - providerConfig: { requireAuth: false }, - workflowId: 'test-workflow-id', - rateLimitCount: 100, - rateLimitPeriod: 60, - } - ) - testData.workflows.push({ - id: 'test-workflow-id', - userId: 'test-user-id', - workspaceId: 'test-workspace-id', - }) - isWorkspaceApiExecutionEntitledMock.mockResolvedValue(false) - - const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) - const params = Promise.resolve({ path: 'test-path' }) - - const response = await POST(req, { params }) - - expect(response.status).toBe(402) - }) - it('should authenticate with Bearer token when no custom header is configured', async () => { testData.webhooks.push({ id: 'generic-webhook-id', diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 3779e3b31ea..5fbbb744ecb 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -2,10 +2,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { webhookTriggerGetContract, webhookTriggerPostContract } from '@/lib/api/contracts/webhooks' import { parseRequest } from '@/lib/api/server' -import { - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, - isWorkspaceApiExecutionEntitled, -} from '@/lib/billing/core/api-access' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -134,7 +130,6 @@ async function handleWebhookPost( // Process each webhook matched on this path const responses: NextResponse[] = [] const failures: NextResponse[] = [] - let billingBlocked = false for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { const provider = foundWebhook.provider @@ -152,19 +147,6 @@ async function handleWebhookPost( return missingProviderResponse } - // Generic ("custom") webhooks are an unauthenticated programmatic execution - // surface, so they fall under the same paid-plan gate as the API. Provider - // webhooks (slack, github, ...) are unaffected. - if ( - provider === 'generic' && - !(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId ?? undefined)) - ) { - logger.warn(`[${requestId}] Generic webhook blocked: workspace on free plan`) - billingBlocked = true - if (webhooksForPath.length > 1) continue - return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) - } - const authError = await verifyProviderAuth( foundWebhook, foundWorkflow, @@ -218,9 +200,6 @@ async function handleWebhookPost( } if (responses.length === 0) { - if (billingBlocked) { - return NextResponse.json({ error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, { status: 402 }) - } if (failures.length > 0) { return failures[0] } diff --git a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts index 04aaa4e5e9e..c23bba7d666 100644 --- a/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/response-block.test.ts @@ -21,19 +21,12 @@ const { mockRegisterLargeValueOwner, mockUploadFile, uploadedFiles, - mockIsWorkspaceApiExecutionEntitled, } = vi.hoisted(() => ({ mockAddLargeValueReference: vi.fn(), mockDownloadFile: vi.fn(), mockRegisterLargeValueOwner: vi.fn(), mockUploadFile: vi.fn(), uploadedFiles: new Map(), - mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), -})) - -vi.mock('@/lib/billing/core/api-access', () => ({ - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, })) const MATERIALIZATION_CONTEXT = { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index bbab08761d8..ce55f06ee23 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -31,7 +31,6 @@ const { mockGetWorkspaceBillingSettings, mockHandlePostExecutionPauseState, mockHasDurableExecutionOwner, - mockIsWorkspaceApiExecutionEntitled, mockReleaseExecutionIdClaim, mockReleaseExecutionSlot, mockRequireBillingAttributionHeader, @@ -50,7 +49,6 @@ const { mockGetWorkspaceBillingSettings: vi.fn(), mockHandlePostExecutionPauseState: vi.fn(), mockHasDurableExecutionOwner: vi.fn(), - mockIsWorkspaceApiExecutionEntitled: vi.fn().mockResolvedValue(true), mockReleaseExecutionIdClaim: vi.fn(), mockReleaseExecutionSlot: vi.fn(), mockRequireBillingAttributionHeader: vi.fn(), @@ -59,11 +57,6 @@ const { vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/billing/core/api-access', () => ({ - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE: 'paid plan required', - isWorkspaceApiExecutionEntitled: mockIsWorkspaceApiExecutionEntitled, -})) - vi.mock('@/lib/billing/core/billing-attribution', () => ({ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, requireBillingAttributionHeader: mockRequireBillingAttributionHeader, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index a0ca81c0464..cbc3ea579c8 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -14,10 +14,6 @@ import { } from '@/lib/api/contracts/workflows' import { AuthType, checkHybridAuth, hasExternalApiCredentials } from '@/lib/auth/hybrid' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' -import { - API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE, - isWorkspaceApiExecutionEntitled, -} from '@/lib/billing/core/api-access' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -563,7 +559,6 @@ async function handleExecutePost( let userId: string let isPublicApiAccess = false - let gateWorkspaceId: string | undefined if (!auth.success || !auth.userId) { const hasExplicitCredentials = @@ -598,31 +593,10 @@ async function handleExecutePost( userId = wf.userId isPublicApiAccess = true - gateWorkspaceId = wf.workspaceId } else { userId = auth.userId } - // Programmatic execution (API key or public API) is gated on the workflow's - // workspace billed account — the same entity MCP/webhooks/chat gate on — - // so a paid workspace is never blocked because an individual is on free. - if (auth.authType === AuthType.API_KEY || isPublicApiAccess) { - if (!gateWorkspaceId) { - const [wfRow] = await db - .select({ workspaceId: workflowTable.workspaceId }) - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - gateWorkspaceId = wfRow?.workspaceId ?? undefined - } - if (!(await isWorkspaceApiExecutionEntitled(gateWorkspaceId))) { - return NextResponse.json( - { error: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE }, - { status: 402 } - ) - } - } - let body: any = {} try { body = await readExecuteRequestBody(req) diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts deleted file mode 100644 index 2ae727ae92b..00000000000 --- a/apps/sim/app/api/workspaces/[id]/owner-billing/route.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession, mockGetUserEntityPermissions, mockGetWorkspaceOwnerSubscriptionAccess } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceOwnerSubscriptionAccess: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) - -vi.mock('@/lib/billing/core/workspace-access', () => ({ - getWorkspaceOwnerSubscriptionAccess: mockGetWorkspaceOwnerSubscriptionAccess, -})) - -import { GET } from '@/app/api/workspaces/[id]/owner-billing/route' - -const WORKSPACE_ID = 'ws-1' - -const PAID_ACCESS = { - plan: 'team_25000', - status: 'active', - isPaid: true, - isPro: false, - isTeam: true, - isEnterprise: false, - isOrgScoped: true, - organizationId: 'org-1', -} - -function buildParams() { - return { params: Promise.resolve({ id: WORKSPACE_ID }) } -} - -async function callGet() { - const request = createMockRequest('GET') - const response = await GET(request, buildParams()) - return { status: response.status, body: await response.json() } -} - -describe('GET /api/workspaces/[id]/owner-billing', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'u-1' } }) - mockGetUserEntityPermissions.mockResolvedValue('read') - mockGetWorkspaceOwnerSubscriptionAccess.mockResolvedValue(PAID_ACCESS) - }) - - it('returns 401 when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - const { status } = await callGet() - expect(status).toBe(401) - expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() - }) - - it('returns 404 when the caller has no workspace access', async () => { - mockGetUserEntityPermissions.mockResolvedValue(null) - const { status } = await callGet() - expect(status).toBe(404) - expect(mockGetWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled() - }) - - it('returns the workspace owner subscription access for a member', async () => { - const { status, body } = await callGet() - expect(status).toBe(200) - expect(body).toEqual(PAID_ACCESS) - expect(mockGetWorkspaceOwnerSubscriptionAccess).toHaveBeenCalledWith(WORKSPACE_ID) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts b/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts deleted file mode 100644 index 6766c3351a4..00000000000 --- a/apps/sim/app/api/workspaces/[id]/owner-billing/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { getWorkspaceOwnerBillingContract } from '@/lib/api/contracts/workspaces' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -/** - * Subscription access state of the workspace's billed account — the workspace- - * scoped counterpart to the viewer `/api/billing`. Lets the UI gate workspace - * features (e.g. the deploy modal) on the owner's plan rather than the viewer's, - * so a free member of a paid workspace isn't shown an upgrade wall. - */ -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getWorkspaceOwnerBillingContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const ownerAccess = await getWorkspaceOwnerSubscriptionAccess(workspaceId) - return NextResponse.json(ownerAccess) - } -) diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts index a07ca838a1b..e96410422a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/comparison-table/comparison-data.ts @@ -144,7 +144,7 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ }, { label: 'API endpoint', - values: ['0', '100', '200', 'Custom'], + values: ['30', '100', '200', 'Custom'], }, ], }, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts index 4a524b30fe0..7e6558af26a 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts @@ -29,7 +29,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = { export const PRO_PLAN_FEATURES: readonly string[] = [ `${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`, 'Invite teammates', - 'Deploy workflows as APIs', + 'Higher rate limits', 'Extended run timeouts', 'More storage & tables', ] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx deleted file mode 100644 index 8f580283a51..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/deploy-upgrade-gate.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import { ChipLink } from '@sim/emcn' -import { useQueryClient } from '@tanstack/react-query' -import { ArrowRight } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons' -import { prefetchUpgradeBillingData } from '@/hooks/queries/subscription' -import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace' - -interface DeployUpgradeGateProps { - feature: 'API' | 'MCP' -} - -export function DeployUpgradeGate({ feature }: DeployUpgradeGateProps) { - const router = useRouter() - const queryClient = useQueryClient() - const { workspaceId } = useParams<{ workspaceId: string }>() - const upgradeHref = buildUpgradeHref(workspaceId) - - // Warm the upgrade route + the queries it gates on so the click lands on - // cached data. ChipLink isn't memoized, so no useCallback is needed. - const prefetchUpgrade = () => { - router.prefetch(upgradeHref) - prefetchUpgradeBillingData(queryClient) - prefetchWorkspaceSettings(queryClient, workspaceId) - } - - return ( -
-
-

- {feature} deployment requires a paid plan -

-

- {feature} deployment lets external apps run this workflow programmatically. Upgrade to Pro - or higher to enable it. -

-
- - Explore plans - -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts deleted file mode 100644 index a124dede56d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/deploy-upgrade-gate/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeployUpgradeGate } from './deploy-upgrade-gate' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts index 630f2b53a5a..be8c8f199dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts @@ -1,5 +1,4 @@ export { ApiDeploy } from './api' export { ChatDeploy, type ExistingChat } from './chat' -export { DeployUpgradeGate } from './deploy-upgrade-gate' export { GeneralDeploy } from './general' export { McpDeploy } from './mcp' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 8d6d4f46ea2..68178c9f3c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { type ReactNode, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Badge, Button, @@ -22,7 +22,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' -import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -47,38 +46,18 @@ import { } from '@/hooks/queries/deployments' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' import { useWorkflowMap } from '@/hooks/queries/workflows' -import { useWorkspaceOwnerBilling, useWorkspaceSettings } from '@/hooks/queries/workspace' +import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { - ApiDeploy, - ChatDeploy, - DeployUpgradeGate, - type ExistingChat, - GeneralDeploy, - McpDeploy, -} from './components' +import { ApiDeploy, ChatDeploy, type ExistingChat, GeneralDeploy, McpDeploy } from './components' import { ApiInfoModal } from './components/general/components/api-info-modal' const logger = createLogger('DeployModal') -/** Renders the upgrade prompt in place of a programmatic-deploy tab when gated. */ -function GatedTabContent({ - gated, - feature, - children, -}: { - gated: boolean - feature: 'API' | 'MCP' - children: ReactNode -}) { - return gated ? : <>{children} -} - interface DeployModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -153,16 +132,6 @@ export function DeployModal({ const userPermissions = useUserPermissionsContext() const canManageWorkspaceKeys = userPermissions.canAdmin const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig() - // Gate on the WORKSPACE owner's plan (billed account, rolled up), not the - // viewer's individual plan, so a free member of a paid workspace isn't shown - // the upgrade wall. Keyed on the URL `workspaceId` (available on mount). Uses - // `isPaid` — the same check the server gate runs (any paid plan in an entitled - // status, incl. `past_due`) — rather than `hasUsablePaidAccess`, which would - // reject `past_due`/billing-blocked owners the API still allows. While loading - // the data is undefined → gate stays closed (no flash); only a resolved, - // non-paid owner gates. - const { data: ownerBilling } = useWorkspaceOwnerBilling(workspaceId ?? undefined) - const gateProgrammaticDeploy = isBillingEnabled && !!ownerBilling && !ownerBilling.isPaid const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '') const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings( workflowWorkspaceId || '' @@ -581,17 +550,15 @@ export function DeployModal({ - - - + @@ -611,22 +578,20 @@ export function DeployModal({ - - {workflowId && ( - - )} - + {workflowId && ( + + )} @@ -646,7 +611,7 @@ export function DeployModal({ }} /> )} - {activeTab === 'api' && !gateProgrammaticDeploy && ( + {activeTab === 'api' && (
@@ -698,7 +663,7 @@ export function DeployModal({
)} - {activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && ( + {activeTab === 'mcp' && isDeployed && hasMcpServers && (
diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index c73e56aa60f..5b9831a3f4b 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -8,14 +8,12 @@ import { deleteWorkspaceContract, getWorkspaceContract, getWorkspaceMembersContract, - getWorkspaceOwnerBillingContract, getWorkspacePermissionsContract, listWorkspacesContract, updateWorkspaceContract, type Workspace, type WorkspaceCreationPolicy, type WorkspaceMember, - type WorkspaceOwnerBilling, type WorkspacePermissions, type WorkspaceQueryScope, type WorkspacesResponse, @@ -35,7 +33,6 @@ export const workspaceKeys = { settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const, permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const, members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const, - ownerBilling: (id: string) => [...workspaceKeys.detail(id), 'ownerBilling'] as const, adminLists: () => [...workspaceKeys.all, 'adminList'] as const, adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const, } @@ -117,36 +114,6 @@ export function useWorkspaceCreationPolicy(enabled = true) { }) } -async function fetchWorkspaceOwnerBilling( - workspaceId: string, - signal?: AbortSignal -): Promise { - return requestJson(getWorkspaceOwnerBillingContract, { - params: { id: workspaceId }, - signal, - }) -} - -/** - * Subscription access state of the workspace's billed account (its owner's - * rolled-up plan) — the workspace-scoped counterpart to `useSubscriptionData`. - * Feed the result to `getSubscriptionAccessState` to gate workspace features on - * the owner's plan rather than the viewer's, so a free member of a paid workspace - * isn't gated. - * - * `staleTime: 0` so consumers (e.g. the deploy modal) refetch on mount: a plan - * change happens outside this query's invalidation graph, and the cached value is - * shown during the background refetch (no flash), so gates self-heal on reopen. - */ -export function useWorkspaceOwnerBilling(workspaceId?: string) { - return useQuery({ - queryKey: workspaceKeys.ownerBilling(workspaceId ?? ''), - queryFn: ({ signal }) => fetchWorkspaceOwnerBilling(workspaceId as string, signal), - enabled: Boolean(workspaceId), - staleTime: 0, - }) -} - type CreateWorkspaceParams = Pick, 'name'> /** diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index a6460ce27d8..47567a624ed 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -208,16 +208,6 @@ export const workspaceOwnerBillingSchema = z.object({ export type WorkspaceOwnerBilling = z.output -export const getWorkspaceOwnerBillingContract = defineRouteContract({ - method: 'GET', - path: '/api/workspaces/[id]/owner-billing', - params: workspaceParamsSchema, - response: { - mode: 'json', - schema: workspaceOwnerBillingSchema, - }, -}) - export const workspaceHostContextSchema = z.object({ workspace: z.object({ id: nonEmptyIdSchema, diff --git a/apps/sim/lib/billing/core/api-access.test.ts b/apps/sim/lib/billing/core/api-access.test.ts deleted file mode 100644 index a93fa6b2d9e..00000000000 --- a/apps/sim/lib/billing/core/api-access.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetHighestPrioritySubscription, mockResolveWorkspaceBillingPayer, billingState } = - vi.hoisted(() => ({ - mockGetHighestPrioritySubscription: vi.fn(), - mockResolveWorkspaceBillingPayer: vi.fn(), - billingState: { isBillingEnabled: true, isFreeApiDeploymentGateEnabled: true }, - })) - -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return billingState.isBillingEnabled - }, - get isFreeApiDeploymentGateEnabled() { - return billingState.isFreeApiDeploymentGateEnabled - }, -})) - -vi.mock('@/lib/billing/core/subscription', () => ({ - getHighestPrioritySubscription: mockGetHighestPrioritySubscription, -})) - -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - resolveWorkspaceBillingPayer: mockResolveWorkspaceBillingPayer, -})) - -import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access' - -describe('isWorkspaceApiExecutionEntitled', () => { - beforeEach(() => { - vi.clearAllMocks() - billingState.isBillingEnabled = true - billingState.isFreeApiDeploymentGateEnabled = true - }) - - it('is false when the exact workspace payer is free', async () => { - mockResolveWorkspaceBillingPayer.mockResolvedValue({ - billedAccountUserId: 'owner-1', - organizationId: 'org-1', - payerSubscription: { plan: 'free' }, - }) - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false) - expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() - }) - - it('is true when the exact workspace payer is paid', async () => { - mockResolveWorkspaceBillingPayer.mockResolvedValue({ - billedAccountUserId: 'owner-1', - organizationId: 'org-1', - payerSubscription: { plan: 'team_6000' }, - }) - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) - expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() - }) - - it('does not substitute another subscription held by the billed account owner', async () => { - mockResolveWorkspaceBillingPayer.mockResolvedValue({ - billedAccountUserId: 'owner-1', - organizationId: 'free-org', - payerSubscription: null, - }) - mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'enterprise' }) - - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false) - expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled() - }) - - it('is false when the workspace has no resolvable payer', async () => { - mockResolveWorkspaceBillingPayer.mockResolvedValue(null) - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(false) - }) - - it('skips the billed-account lookup on self-hosted', async () => { - billingState.isBillingEnabled = false - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) - expect(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled() - }) - - it('skips the lookup (gate off) when the feature flag is disabled', async () => { - billingState.isFreeApiDeploymentGateEnabled = false - expect(await isWorkspaceApiExecutionEntitled('ws-1')).toBe(true) - expect(mockResolveWorkspaceBillingPayer).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/billing/core/api-access.ts b/apps/sim/lib/billing/core/api-access.ts deleted file mode 100644 index af96119817c..00000000000 --- a/apps/sim/lib/billing/core/api-access.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution' -import { isPaid } from '@/lib/billing/plan-helpers' -import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags' - -/** The programmatic-execution paywall is active only when billing is enforced AND the gate flag is on. */ -function isApiExecutionGateActive(): boolean { - return isBillingEnabled && isFreeApiDeploymentGateEnabled -} - -/** - * Message for the 402 returned when a free-plan account attempts programmatic - * workflow execution (API key, public API, or MCP server). - */ -export const API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE = - 'Programmatic workflow execution requires a paid plan. Upgrade to Pro or higher to use the API.' - -/** - * Whether workflows in `workspaceId` may run programmatically, gated on the - * workspace-selected payer's exact subscription. Always allowed when billing - * enforcement is off (self-hosted / `BILLING_ENABLED` unset); short-circuits - * before any DB lookup. - */ -export async function isWorkspaceApiExecutionEntitled( - workspaceId: string | undefined -): Promise { - if (!isApiExecutionGateActive() || !workspaceId) return true - - const payer = await resolveWorkspaceBillingPayer(workspaceId, { onMissing: 'return-null' }) - return isPaid(payer?.payerSubscription?.plan) -} diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index 8c5c8b36d45..68cfa2fea66 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -4,7 +4,6 @@ */ export * from '@/lib/billing/calculations/usage-monitor' -export * from '@/lib/billing/core/api-access' export * from '@/lib/billing/core/billing' export * from '@/lib/billing/core/organization' export * from '@/lib/billing/core/subscription' diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 2df2aeae735..eaeeaabcac8 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -59,14 +59,6 @@ export const isBillingEnabled = ? isTruthy(env.BILLING_ENABLED) : isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) -/** - * Block free-plan accounts from programmatic workflow execution (API key, public - * API, MCP server, generic webhooks, cross-origin chat embeds). - * Gated behind {@link isBillingEnabled}; off by default so the paywall can ship - * dark and be enabled per-deployment once verified. - */ -export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED) - /** * Is email verification enabled */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 6fcd4dd7c75..785237b0bb8 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -87,7 +87,6 @@ export const env = createEnv({ BILLING_CONCURRENCY_LIMIT_TEAM: z.string().optional(), // In-flight executions per Max-tier billing account (Max and Max for Teams) BILLING_CONCURRENCY_LIMIT_ENTERPRISE: z.string().optional(), // In-flight executions per Enterprise billing account (metadata-overridable) BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking - FREE_API_DEPLOYMENT_GATE_ENABLED: z.boolean().optional(), // Block free-plan accounts from programmatic execution (API/MCP/A2A/generic webhooks/chat embeds). Requires BILLING_ENABLED. Off by default for dark rollout TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI PII_GRANULAR_REDACTION: z.boolean().optional(), // Expose the execution-altering PII redaction stages (redact workflow input + block outputs in-flight) in the Data Retention config; layered on top of PII_REDACTION From c203f51545c3bf35fcc5b38cbe42ff81833f2109 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 14 Jul 2026 16:38:42 -0700 Subject: [PATCH 3/9] fix(tools): resolve {{ENV_VAR}} references in user-only params for copilot tool executions (#5679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tools): resolve {{ENV_VAR}} references in user-only params for copilot tool executions Chat-invoked integration tools with API-key auth have been broken since the mothership tool-dispatch rewrite (#4090) removed the orchestrator's env reference resolution. Agents pass {{VAR}} references (the VFS exposes env var names only), and the literal placeholder was sent to the provider, producing auth failures like Sentry's 401 Invalid token. Resolution is deliberately narrower than the removed deep resolver: only whole-value references on params declared visibility user-only, gated to copilot executions, resolving against the same personal+workspace env merge workflow runs use. LLM-writable params (urls, headers, bodies) never resolve, so references cannot be used to extract secrets. Missing variables fail fast with an actionable error instead of a provider-side 401. * refactor(tools): delegate copilot env reference resolution to the shared executor resolver Replaces the hand-rolled exact-match regex with resolveEnvVarReferences (allowEmbedded: false), the same resolver used by workflow runs, MCP config, and webhooks — one set of reference semantics instead of two that can drift. Behavior is identical; all existing tests pass unchanged. * fix(tools): fail fast on env references without user context, clarify personal-only scope errors Addresses review: a copilot execution missing userId now errors explicitly instead of forwarding the literal placeholder upstream, and a missing-variable error without a workspace context explains that only personal variables are in scope there (matching workflow-run resolution semantics). --- apps/sim/tools/index.test.ts | 204 +++++++++++++++++++++++++++++++++++ apps/sim/tools/index.ts | 67 ++++++++++++ 2 files changed, 271 insertions(+) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index b898543d029..1f5a13b5737 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -31,6 +31,7 @@ const { mockGetCustomToolByIdOrTitle, mockGenerateInternalToken, mockResolveWorkspaceFileReference, + mockGetEffectiveDecryptedEnv, } = vi.hoisted(() => ({ mockIsHosted: { value: false }, mockEnv: { NEXT_PUBLIC_APP_URL: 'http://localhost:3000' } as Record, @@ -46,6 +47,7 @@ const { mockGetCustomToolByIdOrTitle: vi.fn(), mockGenerateInternalToken: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockGetEffectiveDecryptedEnv: vi.fn(), })) const mockSecureFetchWithPinnedIP = inputValidationMockFns.mockSecureFetchWithPinnedIP @@ -107,6 +109,10 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({ getHostedKeyRateLimiter: () => mockRateLimiterFns, })) +vi.mock('@/lib/environment/utils', () => ({ + getEffectiveDecryptedEnv: (...args: unknown[]) => mockGetEffectiveDecryptedEnv(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), })) @@ -234,6 +240,26 @@ vi.mock('@/tools/registry', () => { return { success: true, output: data } }, }, + test_env_ref_tool: { + id: 'test_env_ref_tool', + name: 'Test Env Reference Tool', + description: 'Accepts a user-only API key and an llm-writable note', + version: '1.0.0', + params: { + apiKey: { type: 'string', required: true, visibility: 'user-only' }, + note: { type: 'string', required: false, visibility: 'user-or-llm' }, + }, + request: { + url: '/api/tools/test/env-ref', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (p: any) => ({ apiKey: p.apiKey, note: p.note }), + }, + transformResponse: async (response: any) => { + const data = await response.json() + return { success: true, output: data } + }, + }, test_file_array_tool: { id: 'test_file_array_tool', name: 'Test File Array Tool', @@ -1259,6 +1285,184 @@ describe('Copilot OAuth Credential Enforcement', () => { }) }) +describe('Copilot Env Variable Reference Resolution', () => { + let cleanupEnvVars: () => void + + function mockJsonFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve(JSON.stringify({ ok: true })), + clone: vi.fn().mockReturnThis(), + }) + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof fetch + return fetchMock + } + + function sentRequestBody(fetchMock: ReturnType): Record { + return JSON.parse(fetchMock.mock.calls[0][1]?.body as string) + } + + const copilotContext = () => + createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: 'user-123', + copilotToolExecution: true, + } as any) + + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + cleanupEnvVars = setupEnvVars({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) + mockGetEffectiveDecryptedEnv.mockReset() + mockGetEffectiveDecryptedEnv.mockResolvedValue({ SENTRY_AUTH_TOKEN: 'sntrys_real_token' }) + }) + + afterEach(() => { + vi.resetAllMocks() + cleanupEnvVars() + }) + + it('resolves a whole-value {{VAR}} reference in a user-only param', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', 'workspace-456') + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('trims whitespace inside the braces like the executor resolver', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{ SENTRY_AUTH_TOKEN }}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('sntrys_real_token') + }) + + it('never resolves references in llm-writable (user-or-llm) params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}', note: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).note).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('leaves embedded references untouched in user-only params', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: 'Bearer {{SENTRY_AUTH_TOKEN}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(true) + expect(sentRequestBody(fetchMock).apiKey).toBe('Bearer {{SENTRY_AUTH_TOKEN}}') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + }) + + it('fails with a clear error before any request when the variable is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { executionContext: copilotContext() } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('MISSING_VAR') + expect(result.error).toContain('apiKey') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails fast instead of forwarding the placeholder when user context is missing', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: 'workspace-456', + userId: undefined, + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('authenticated user context') + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('explains the personal-only scope when a variable is missing without a workspace context', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{MISSING_VAR}}' }, + { + executionContext: createToolExecutionContext({ + workspaceId: undefined, + userId: 'user-123', + copilotToolExecution: true, + } as any), + } + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('only personal variables are available') + expect(mockGetEffectiveDecryptedEnv).toHaveBeenCalledWith('user-123', undefined) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('does not resolve references outside copilot execution', async () => { + const fetchMock = mockJsonFetch() + + const result = await executeTool( + 'test_env_ref_tool', + { apiKey: '{{SENTRY_AUTH_TOKEN}}' }, + { executionContext: createToolExecutionContext({ userId: 'user-123' } as any) } + ) + + expect(result.success).toBe(true) + expect(mockGetEffectiveDecryptedEnv).not.toHaveBeenCalled() + expect(sentRequestBody(fetchMock).apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) + + it('never mutates the caller-owned params object (log-leak guard)', async () => { + mockJsonFetch() + const callerParams = { apiKey: '{{SENTRY_AUTH_TOKEN}}' } + + const result = await executeTool('test_env_ref_tool', callerParams, { + executionContext: copilotContext(), + }) + + expect(result.success).toBe(true) + expect(callerParams.apiKey).toBe('{{SENTRY_AUTH_TOKEN}}') + }) +}) + describe('Centralized Error Handling', () => { let cleanupEnvVars: () => void diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1602aa1cb79..5e597c53422 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -33,6 +33,7 @@ import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-c import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' +import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { @@ -183,6 +184,71 @@ async function normalizeCopilotFileParams( } } +/** + * Resolves whole-value {{ENV_VAR}} references in user-only params for copilot + * tool executions. Chat agents never see secret values (the workspace VFS + * exposes env var names only), so they pass references; workflow runs resolve + * these in the executor, and this is the equivalent step for direct tool + * calls, delegating to the executor's resolver so both paths share one set of + * reference semantics. Resolution is deliberately restricted to params + * declared `visibility: 'user-only'` (API keys and other operator-supplied + * secrets) and to values that are exactly one reference, so LLM-writable + * params (URLs, headers, bodies) can never be used to extract secret values. + * + * Mutates only the given params object — callers pass the per-execution copy, + * never the copilot-side tool-call state, so decrypted values cannot leak + * into failure logs or persisted chat state. + */ +async function resolveCopilotEnvReferences( + tool: ToolConfig, + params: Record, + scope: ToolExecutionScope +): Promise { + if (!scope.copilotToolExecution) { + return + } + + const pending: Array<{ paramId: string; value: string }> = [] + for (const [paramId, paramDef] of Object.entries(tool.params || {})) { + if (paramDef?.visibility !== 'user-only') continue + const value = params[paramId] + if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) { + pending.push({ paramId, value }) + } + } + + if (pending.length === 0) { + return + } + + if (!scope.userId) { + throw new Error( + `Cannot resolve environment variable reference in parameter "${pending[0].paramId}" without an authenticated user context.` + ) + } + + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId) + + for (const { paramId, value } of pending) { + const missingKeys: string[] = [] + const resolved = resolveEnvVarReferences(value, envVars, { + allowEmbedded: false, + missingKeys, + }) + if (missingKeys.length > 0) { + const scopeHint = scope.workspaceId + ? '' + : ' (no workspace context — only personal variables are available here)' + throw new Error( + `Environment variable "${missingKeys[0]}" referenced by parameter "${paramId}" was not found${scopeHint}. ` + + `Check environment/variables.json for available variable names.` + ) + } + params[paramId] = resolved as string + } +} + function readExplicitCredentialSelector(params: Record): string | undefined { for (const key of ['credentialId', 'oauthCredential', 'credential'] as const) { const value = params[key] @@ -1025,6 +1091,7 @@ export async function executeTool( await normalizeCopilotFileParams(tool, contextParams, scope) normalizeCopilotCredentialParams(contextParams) enforceCopilotCredentialSelection(toolId, tool, contextParams, scope) + await resolveCopilotEnvReferences(tool, contextParams, scope) // Inject hosted API key if tool supports it and user didn't provide one const hostedKeyInfo = await injectHostedKeyIfNeeded( From 516dd7ed0ff76e8aa03b9ea344702756719fef89 Mon Sep 17 00:00:00 2001 From: andres Date: Tue, 14 Jul 2026 20:38:19 -0400 Subject: [PATCH 4/9] feat(landing): extend enterprise product-preview design to solutions and workflows pages (#5672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(landing): extend enterprise product-preview design to solutions and workflows pages Parametrize enterprise feature graphics with content props and retell each solutions page's feature blocs for its domain (engineering, it, compliance, finance, hr) plus the workflows platform page. Add five new graphics in the same visual vocabulary, animated platform-UI heroes with domain-specific loops on all six pages, hero category tags, container-query proportional tile scaling with wide/2x2 layout options, and a shared pre-footer CTA band. Enterprise page renders identically. Co-authored-by: Cursor * feat(landing): surface Platform and Solutions menus in top nav Renders PLATFORM_MENU and SOLUTIONS_MENU alongside Resources (Platform, Solutions, Resources trigger order), drops the internal-only Mothership item, centers the five-item Platform panel's two-tile last row, and lets the mobile sheet scroll now that all three sections outgrow a phone viewport. Co-authored-by: Cursor * feat(landing): add scheduled tasks page and nav item Adds the /scheduled-tasks landing page (SolutionsPage consumer with a chat-free schedule-trigger editor hero and enterprise feature tiles retold for recurring runs) and a sixth Scheduled Tasks item in the Platform menu, restoring the clean 3x2 grid. Co-authored-by: Cursor * feat(landing): add knowledge, tables, files, and logs platform pages Completes the Platform nav dropdown — its knowledge, tables, files, and logs items previously 404'd. Each page is a SolutionsPage consumer with a product-UI hero loop, enterprise-style feature tiles, CTA band, and SEO metadata. Removes the dead platform-page component family (workflows was its last consumer) and adds the new routes to the sitemap. Co-authored-by: Cursor * refactor(landing): consolidate hero-loop engine and code-window graphics, drop dead variant surface - Extract shared useDesignScale + useMotionSafeCycle hooks and a HeroLoopShell so all seven hero loops share one scale/reduced-motion clock engine - Merge workflows-editor-loop and scheduled-tasks-hero-loop into one parameterized shared EditorLoop (content-injection, same pattern as EnterpriseLoopContent) - Consolidate the four identical code-window feature graphics into one shared CodeWindowGraphic + single CSS module; pages keep thin data wrappers - Remove the unreachable 'split'/'standard'/align variant surface from SolutionsPage/SolutionsCardRow/SolutionsCard and trim unused barrel re-exports - Enterprise hero now consumes the shared PlatformHeroVisual instead of an inline byte-identical copy - Add /scheduled-tasks to the sitemap - a11y: closed mobile-nav sheet is now invisible (out of tab order) with aria-controls wiring; decorative tables-hero checkboxes are no longer keyboard-focusable - memo() the static EnterpriseSidebar; hoist per-render header arrays; remove comments that restated component TSDoc * fix(landing): highlight Scheduled tasks in the scheduled-tasks hero sidebar Thread activeNav through the shared EditorLoop content so the scheduled-tasks hero highlights its own module row, matching the other platform heroes. * chore(landing): drop spacing constants orphaned by the variant removal Remove the now-unconsumed cardRowHeaderStack/cardRowHeaderCtaGap/ cardTextToVisual/rowSubtitle constants, fix a stale cardVariant TSDoc reference, and restore import order in the workflows editor-loop wrapper. * chore(landing): single-source RESET_FADE_MS and drop the unused card frame size RESET_FADE_MS now lives only in the shared loop-engine hook module; the enterprise stage-data copy is removed. SolutionsVisualFrame loses its size prop and cardHeight constant - only the 16:9 hero preset remained in use after the split-variant removal. * fix(landing): make the knowledge-answer question bubble visible on light tiles The frameless vignette sits directly on the tile's --surface-3 fill, so the build tile's --surface-3 bubble treatment vanished. The question chip now uses the graphic family's white card chrome (--white fill, --border-1 hairline), matching its own source card. Also restores biome's import order in the workflows editor-loop wrapper (fixes the failing lint:check CI step). * chore(landing): share one ZipIcon across the files preview and files hero Extract the duplicated zip-archive SVG into components/shared/zip-icon and consume it from both the homepage files preview and the files hero loop. --------- Co-authored-by: andresdjasso Co-authored-by: Cursor Co-authored-by: Waleed Latif --- apps/sim/app/(landing)/CLAUDE.md | 10 +- .../hero-workflow-stage.tsx | 15 + apps/sim/app/(landing)/components/index.ts | 17 +- .../landing-preview-files.tsx | 24 +- .../components/mobile-nav/mobile-nav.tsx | 22 +- .../components/nav-menu-chip/constants.ts | 24 +- .../nav-menu-chip/nav-menu-chip.tsx | 15 +- .../components/platform-hero-visual/index.ts | 1 + .../platform-hero-visual.tsx | 38 ++ .../platform-page/components/index.ts | 4 - .../platform-card-row/components/index.ts | 2 - .../components/platform-card/index.ts | 1 - .../platform-card/platform-card.tsx | 42 -- .../components/platform-pill-cta/index.ts | 1 - .../platform-pill-cta/platform-pill-cta.tsx | 47 -- .../components/platform-card-row/index.ts | 1 - .../platform-card-row/platform-card-row.tsx | 72 ---- .../components/platform-hero/index.ts | 1 - .../platform-hero/platform-hero.tsx | 61 --- .../components/platform-logos-row/index.ts | 1 - .../platform-logos-row/platform-logos-row.tsx | 30 -- .../platform-structured-data/index.ts | 1 - .../platform-structured-data.tsx | 68 --- .../components/platform-visual-frame/index.ts | 1 - .../platform-visual-frame.tsx | 41 -- .../components/platform-page/constants.ts | 60 --- .../components/platform-page/index.ts | 8 - .../platform-page/platform-page.tsx | 59 --- .../components/platform-page/types.ts | 96 ----- .../code-window-graphic.module.css | 62 +++ .../code-window-graphic.tsx | 107 +++++ .../shared/code-window-graphic/index.ts | 1 + .../shared/editor-loop/editor-loop.tsx | 120 ++++++ .../components/shared/editor-loop/index.ts | 1 + .../hero-loop-shell/hero-loop-shell.tsx | 60 +++ .../shared/hero-loop-shell/index.ts | 1 + .../components/shared/zip-icon/index.ts | 1 + .../components/shared/zip-icon/zip-icon.tsx | 28 ++ .../solutions-card-row-header.tsx | 56 +-- .../solutions-card/solutions-card.tsx | 136 +++--- .../solutions-card-row/solutions-card-row.tsx | 37 +- .../solutions-hero/solutions-hero.tsx | 2 +- .../solutions-visual-frame.tsx | 14 +- .../components/solutions-page/constants.ts | 55 ++- .../solutions-page/solutions-page.tsx | 62 +-- .../components/solutions-page/types.ts | 13 +- .../enterprise-feature-grid.tsx | 3 +- .../enterprise-home-stage.tsx | 41 +- .../enterprise-platform-loop.tsx | 217 ++++------ .../enterprise-sidebar.tsx | 45 +- .../enterprise-platform-loop/stage-data.ts | 78 +++- .../access-control-graphic.tsx | 11 +- .../feature-graphics/audit-trail-graphic.tsx | 25 +- .../feature-graphics/deploy-graphic.tsx | 50 ++- .../feature-graphic-shell.tsx | 14 +- .../it-platform-teams-graphic.tsx | 47 +- .../operations-teams-graphic.tsx | 43 +- .../run-monitoring-graphic.tsx | 129 ++++-- .../feature-graphics/staging-graphic.tsx | 63 ++- .../feature-graphics/standards-graphic.tsx | 38 +- .../technical-teams-graphic.tsx | 51 ++- .../app/(landing)/enterprise/enterprise.tsx | 40 +- .../file-library-graphic.module.css | 49 +++ .../feature-graphics/file-library-graphic.tsx | 147 +++++++ .../feature-graphics/files-sdk-graphic.tsx | 60 +++ .../files/components/files-hero-loop.tsx | 319 ++++++++++++++ apps/sim/app/(landing)/files/files.tsx | 171 ++++++++ apps/sim/app/(landing)/files/page.tsx | 20 + .../app/(landing)/hooks/use-design-scale.ts | 40 ++ .../(landing)/hooks/use-motion-safe-cycle.ts | 66 +++ .../connector-sync-graphic.module.css | 63 +++ .../connector-sync-graphic.tsx | 126 ++++++ .../knowledge-query-graphic.tsx | 58 +++ .../components/knowledge-hero-loop.tsx | 281 ++++++++++++ .../sim/app/(landing)/knowledge/knowledge.tsx | 175 ++++++++ apps/sim/app/(landing)/knowledge/page.tsx | 20 + .../failure-alert-graphic.module.css | 60 +++ .../failure-alert-graphic.tsx | 96 +++++ .../filter-runs-graphic.module.css | 42 ++ .../feature-graphics/filter-runs-graphic.tsx | 106 +++++ .../logs/components/feature-graphics/index.ts | 3 + .../run-trace-graphic.module.css | 54 +++ .../feature-graphics/run-trace-graphic.tsx | 151 +++++++ .../logs/components/logs-hero-loop.tsx | 394 +++++++++++++++++ apps/sim/app/(landing)/logs/logs.tsx | 174 ++++++++ apps/sim/app/(landing)/logs/page.tsx | 20 + .../components/scheduled-tasks-hero-loop.tsx | 117 +++++ .../app/(landing)/scheduled-tasks/page.tsx | 20 + .../scheduled-tasks/scheduled-tasks.tsx | 192 +++++++++ .../solutions/compliance/compliance.tsx | 87 +++- .../components/compliance-hero-loop.tsx | 117 +++++ .../document-draft-graphic.module.css | 74 ++++ .../document-draft-graphic.tsx | 106 +++++ .../components/feature-graphics/index.ts | 2 + .../knowledge-answer-graphic.module.css | 62 +++ .../knowledge-answer-graphic.tsx | 105 +++++ .../components/engineering-hero-loop.tsx | 119 +++++ .../solutions/engineering/engineering.tsx | 96 ++++- .../reconcile-graphic.module.css | 69 +++ .../feature-graphics/reconcile-graphic.tsx | 121 ++++++ .../finance/components/finance-hero-loop.tsx | 116 +++++ .../(landing)/solutions/finance/finance.tsx | 120 +++++- .../solutions/hr/components/hr-hero-loop.tsx | 118 +++++ apps/sim/app/(landing)/solutions/hr/hr.tsx | 108 ++++- .../solutions/it/components/it-hero-loop.tsx | 118 +++++ apps/sim/app/(landing)/solutions/it/it.tsx | 81 +++- .../enrichment-fill-graphic.module.css | 49 +++ .../enrichment-fill-graphic.tsx | 97 +++++ .../components/feature-graphics/index.ts | 3 + .../table-grid-graphic.module.css | 55 +++ .../feature-graphics/table-grid-graphic.tsx | 119 +++++ .../feature-graphics/table-query-graphic.tsx | 62 +++ .../components/tables-hero-loop.module.css | 45 ++ .../tables/components/tables-hero-loop.tsx | 406 ++++++++++++++++++ apps/sim/app/(landing)/tables/page.tsx | 20 + apps/sim/app/(landing)/tables/tables.tsx | 166 +++++++ .../feature-graphics/agent-code-graphic.tsx | 62 +++ .../components/feature-graphics/index.ts | 2 + .../workflow-canvas-graphic.module.css | 88 ++++ .../workflow-canvas-graphic.tsx | 122 ++++++ .../components/workflows-editor-loop.tsx | 129 ++++++ .../sim/app/(landing)/workflows/workflows.tsx | 115 ++++- apps/sim/app/sitemap.ts | 15 + 123 files changed, 7381 insertions(+), 1231 deletions(-) create mode 100644 apps/sim/app/(landing)/components/platform-hero-visual/index.ts create mode 100644 apps/sim/app/(landing)/components/platform-hero-visual/platform-hero-visual.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/platform-card.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/platform-pill-cta.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-card-row/platform-card-row.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-hero/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/platform-structured-data.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/platform-visual-frame.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/constants.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/index.ts delete mode 100644 apps/sim/app/(landing)/components/platform-page/platform-page.tsx delete mode 100644 apps/sim/app/(landing)/components/platform-page/types.ts create mode 100644 apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.module.css create mode 100644 apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.tsx create mode 100644 apps/sim/app/(landing)/components/shared/code-window-graphic/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx create mode 100644 apps/sim/app/(landing)/components/shared/editor-loop/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx create mode 100644 apps/sim/app/(landing)/components/shared/hero-loop-shell/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/zip-icon/index.ts create mode 100644 apps/sim/app/(landing)/components/shared/zip-icon/zip-icon.tsx create mode 100644 apps/sim/app/(landing)/files/components/feature-graphics/file-library-graphic.module.css create mode 100644 apps/sim/app/(landing)/files/components/feature-graphics/file-library-graphic.tsx create mode 100644 apps/sim/app/(landing)/files/components/feature-graphics/files-sdk-graphic.tsx create mode 100644 apps/sim/app/(landing)/files/components/files-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/files/files.tsx create mode 100644 apps/sim/app/(landing)/files/page.tsx create mode 100644 apps/sim/app/(landing)/hooks/use-design-scale.ts create mode 100644 apps/sim/app/(landing)/hooks/use-motion-safe-cycle.ts create mode 100644 apps/sim/app/(landing)/knowledge/components/feature-graphics/connector-sync-graphic.module.css create mode 100644 apps/sim/app/(landing)/knowledge/components/feature-graphics/connector-sync-graphic.tsx create mode 100644 apps/sim/app/(landing)/knowledge/components/feature-graphics/knowledge-query-graphic.tsx create mode 100644 apps/sim/app/(landing)/knowledge/components/knowledge-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/knowledge/knowledge.tsx create mode 100644 apps/sim/app/(landing)/knowledge/page.tsx create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/failure-alert-graphic.module.css create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/failure-alert-graphic.tsx create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/filter-runs-graphic.module.css create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/filter-runs-graphic.tsx create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/index.ts create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/run-trace-graphic.module.css create mode 100644 apps/sim/app/(landing)/logs/components/feature-graphics/run-trace-graphic.tsx create mode 100644 apps/sim/app/(landing)/logs/components/logs-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/logs/logs.tsx create mode 100644 apps/sim/app/(landing)/logs/page.tsx create mode 100644 apps/sim/app/(landing)/scheduled-tasks/components/scheduled-tasks-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/scheduled-tasks/page.tsx create mode 100644 apps/sim/app/(landing)/scheduled-tasks/scheduled-tasks.tsx create mode 100644 apps/sim/app/(landing)/solutions/compliance/components/compliance-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.module.css create mode 100644 apps/sim/app/(landing)/solutions/components/feature-graphics/document-draft-graphic.tsx create mode 100644 apps/sim/app/(landing)/solutions/components/feature-graphics/index.ts create mode 100644 apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.module.css create mode 100644 apps/sim/app/(landing)/solutions/components/feature-graphics/knowledge-answer-graphic.tsx create mode 100644 apps/sim/app/(landing)/solutions/engineering/components/engineering-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.module.css create mode 100644 apps/sim/app/(landing)/solutions/finance/components/feature-graphics/reconcile-graphic.tsx create mode 100644 apps/sim/app/(landing)/solutions/finance/components/finance-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/solutions/hr/components/hr-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/solutions/it/components/it-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.module.css create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/enrichment-fill-graphic.tsx create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/index.ts create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.module.css create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/table-grid-graphic.tsx create mode 100644 apps/sim/app/(landing)/tables/components/feature-graphics/table-query-graphic.tsx create mode 100644 apps/sim/app/(landing)/tables/components/tables-hero-loop.module.css create mode 100644 apps/sim/app/(landing)/tables/components/tables-hero-loop.tsx create mode 100644 apps/sim/app/(landing)/tables/page.tsx create mode 100644 apps/sim/app/(landing)/tables/tables.tsx create mode 100644 apps/sim/app/(landing)/workflows/components/feature-graphics/agent-code-graphic.tsx create mode 100644 apps/sim/app/(landing)/workflows/components/feature-graphics/index.ts create mode 100644 apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.module.css create mode 100644 apps/sim/app/(landing)/workflows/components/feature-graphics/workflow-canvas-graphic.tsx create mode 100644 apps/sim/app/(landing)/workflows/components/workflows-editor-loop.tsx diff --git a/apps/sim/app/(landing)/CLAUDE.md b/apps/sim/app/(landing)/CLAUDE.md index 6943cd7fdff..5d96ffdf9ed 100644 --- a/apps/sim/app/(landing)/CLAUDE.md +++ b/apps/sim/app/(landing)/CLAUDE.md @@ -85,13 +85,13 @@ Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI works │ ├── landing-shell/ # light wrapper + skip link + Navbar(stars) + Footer; wraps every page │ ├── hero-cta/ # the one email-capture + Sign-up CTA (hero + every platform hero) │ └── logos/ # the one customer-logo set; layout='grid' (hero) | 'row' (platform) - └── platform-page/ # the reusable platform-page layout (Workflows, Tables, Files, …) - ├── platform-page.tsx, index.ts, types.ts, constants.ts # PlatformPage + the content contract + spacing - └── components/ # platform-hero, platform-logos-row, platform-card-row (→ card, pill-cta), - # platform-visual-frame, platform-structured-data + └── solutions-page/ # the reusable solutions/platform layout (Workflows, Knowledge, Tables, Files, Logs, solutions/*) + ├── solutions-page.tsx, index.ts, types.ts, constants.ts # SolutionsPage + the content contract + spacing + └── components/ # solutions-hero, solutions-logos-row, solutions-card-row (→ card, pill-cta), + # solutions-visual-frame, solutions-structured-data ``` -Each section component's TSDoc carries its layout spec - read it before implementing. Section components own their landmark (Navbar → `
`, Footer → `
`, the rest → `
`); the shared `LandingShell` owns the page frame (light wrapper, skip link, navbar, footer, GitHub stars via `@/lib/github/stars` - fetched at build/revalidate time, never client-fetched), and the page's `
` owns the section order and rhythm. Platform routes consume `PlatformPage` with a single content `config` - see `platform-page/CLAUDE`-level TSDoc on `platform-page.tsx`. Sub-components of a section go in `components/
/components/`. +Each section component's TSDoc carries its layout spec - read it before implementing. Section components own their landmark (Navbar → `
`, Footer → `
`, the rest → `
`); the shared `LandingShell` owns the page frame (light wrapper, skip link, navbar, footer, GitHub stars via `@/lib/github/stars` - fetched at build/revalidate time, never client-fetched), and the page's `
` owns the section order and rhythm. Platform and solutions routes consume `SolutionsPage` with a single content `config` - see the TSDoc on `solutions-page.tsx`. Sub-components of a section go in `components/
/components/`. Absolute imports only in component code (`@/app/(landing)/components/...`); `index.ts` barrels use relative re-exports (`export { X } from './x'`), matching the workspace convention. Props interfaces for every component. No `utils.ts` until two files share a helper. diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx index 20d1ffbb057..3164086c537 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage.tsx @@ -29,6 +29,13 @@ interface HeroWorkflowStageProps { edges?: ReadonlyArray /** Design-space bounding box of the block layout. Defaults with them. */ canvas?: { width: number; height: number } + /** + * Block to dress with the selection ring - graphite (`--text-secondary`) + * rather than the real canvas's blue, per the landing pages' grayscale + * language - the workflows hero uses this for its "being edited" beat. + * Off by default, so existing stages are unchanged. + */ + selectedId?: string } /** @@ -55,6 +62,7 @@ export function HeroWorkflowStage({ blocks = STAGE_BLOCKS, edges = STAGE_EDGES, canvas = STAGE_CANVAS, + selectedId, }: HeroWorkflowStageProps) { const containerRef = useRef(null) const [scale, setScale] = useState(MAX_STAGE_SCALE) @@ -154,6 +162,13 @@ export function HeroWorkflowStage({ style={{ left: block.x, top: block.y, width: BLOCK_WIDTH }} > +
) })} diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts index 91c35fb1e93..4181286a3a5 100644 --- a/apps/sim/app/(landing)/components/index.ts +++ b/apps/sim/app/(landing)/components/index.ts @@ -18,22 +18,9 @@ export { Lightbox } from './lightbox' export { LogoShell } from './logo-shell' export { Mothership } from './mothership/mothership' export { Navbar } from './navbar' -export type { - PlatformCardConfig, - PlatformCardRowConfig, - PlatformHeroConfig, - PlatformPageConfig, - PlatformPillCta, -} from './platform-page' -export { PlatformPage } from './platform-page' +export { PlatformHeroVisual } from './platform-hero-visual' export { ProductDemo } from './product-demo' export { ShareButton } from './share-button' export { SiteStructuredData } from './site-structured-data' -export type { - SolutionsCardConfig, - SolutionsCardRowConfig, - SolutionsHeroConfig, - SolutionsPageConfig, - SolutionsPillCta, -} from './solutions-page' +export type { SolutionsPageConfig } from './solutions-page' export { SolutionsPage } from './solutions-page' diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx index f7fb7d1baa3..4ae90baf5bc 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-files/landing-preview-files.tsx @@ -6,8 +6,9 @@ import type { } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource' import { LandingPreviewResource } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/landing-preview-resource' import { ownerCell } from '@/app/(landing)/components/landing-preview/components/landing-preview-resource/utils' +import { ZipIcon } from '@/app/(landing)/components/shared/zip-icon' -/** Generic audio/zip icon using basic SVG since no dedicated component exists */ +/** Generic audio icon using basic SVG since no dedicated component exists */ function AudioIcon({ className }: { className?: string }) { return ( - - - - - - - - ) -} - const COLUMNS: PreviewColumn[] = [ { id: 'name', header: 'Name' }, { id: 'size', header: 'Size' }, diff --git a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx index 7be454b18ae..f0963c229d4 100644 --- a/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/mobile-nav/mobile-nav.tsx @@ -23,8 +23,12 @@ import { DEMO_HREF, SIGNUP_HREF } from '@/app/(landing)/constants' * leaf hydrates; the desktop nav stays server-rendered. * * The sheet locks body scroll while open and closes on route change intent - * (any link tap) and on `Escape`. Motion is a short token-driven - * transform/opacity that collapses under `prefers-reduced-motion`. + * (any link tap) and on `Escape`. With all three mega-menus expanded the + * sections outgrow a phone viewport, so the sheet caps its height at the + * space under the bar (`100dvh` minus the bar's 62px) and scrolls internally + * (`overscroll-contain` keeps the locked page from rubber-banding behind it). + * Motion is a short token-driven transform/opacity that collapses under + * `prefers-reduced-motion`. */ interface MobileNavProps { @@ -35,9 +39,9 @@ interface MobileNavProps { /** * Standalone top-level routes shown in the sheet alongside the expanded mega-menu * sections. Pricing is a standalone link on the desktop nav (not a mega-menu), so - * it stays a single row here too. Restoring `PLATFORM_MENU`/`SOLUTIONS_MENU` to - * {@link NAV_MENUS} automatically expands them here as grouped sections too - the - * sheet mirrors the desktop nav's information architecture with no extra edit. + * it stays a single row here too. Every menu in {@link NAV_MENUS} expands here as + * a grouped section automatically - the sheet mirrors the desktop nav's + * information architecture with no extra edit. */ const STANDALONE_LINKS = [ { label: 'Enterprise', href: '/enterprise' }, @@ -78,6 +82,7 @@ export function MobileNav({ stars }: MobileNavProps) { type='button' aria-label={open ? 'Close menu' : 'Open menu'} aria-expanded={open} + aria-controls='mobile-nav-sheet' onClick={() => setOpen((v) => !v)} className='flex size-[30px] items-center justify-center rounded-lg border border-[var(--border-1)] text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-hover)]' > @@ -95,12 +100,13 @@ export function MobileNav({ stars }: MobileNavProps) { ) : null}
diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts index fc8bee83018..9a74b6142c4 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts @@ -7,11 +7,6 @@ import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-m export const PLATFORM_MENU: NavMenu = { label: 'Platform', items: [ - { - title: 'Mothership', - description: 'Build and command agents in natural language', - href: '/', - }, { title: 'Workflows', description: 'Design agent logic visually', @@ -32,6 +27,11 @@ export const PLATFORM_MENU: NavMenu = { description: 'One file store for team and agents', href: '/files', }, + { + title: 'Scheduled Tasks', + description: 'Run agents on a cadence', + href: '/scheduled-tasks', + }, { title: 'Logs', description: 'Trace every agent decision', @@ -121,13 +121,9 @@ export const SOLUTIONS_MENU: NavMenu = { } /** - * Navbar mega-menus that are currently rendered, in trigger order - shared by - * desktop and mobile nav (the desktop bar maps this list; mobile nav derives - * its visible sections from these labels). - * - * `PLATFORM_MENU` and `SOLUTIONS_MENU` are intentionally omitted for now to hide - * those sections from navigation while their pages stay live and reachable by - * direct URL. The menu definitions above are kept intact - to restore a section, - * add its constant back to this list. + * Navbar mega-menus in trigger order - shared by desktop and mobile nav (the + * desktop bar maps this list; mobile nav derives its visible sections from + * these labels). Platform leads with the product modules, Solutions follows + * with the per-team pages, and Resources closes with learning surfaces. */ -export const NAV_MENUS = [RESOURCES_MENU] as const +export const NAV_MENUS = [PLATFORM_MENU, SOLUTIONS_MENU, RESOURCES_MENU] as const diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx index 286ed38b041..3036158a3a4 100644 --- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/nav-menu-chip.tsx @@ -30,6 +30,12 @@ import type { NavMenu } from '@/app/(landing)/components/navbar/components/nav-m * The panel chrome replicates the `ChipModal` framed-card look exactly: an outer * `--surface-4` ring (`p-[3px]`, overlay shadow) wrapping an inner `--bg` * surface, with the item grid padded inside. + * + * The grid renders three visual columns on six tracks (each tile spans two), + * which keeps six-item menus pixel-identical to a plain three-column grid while + * letting a five-item menu center its two-tile last row - the second-to-last + * tile starts on track two, so the short row sits symmetrically instead of + * leaving a hole in the corner. */ interface NavMenuChipProps { @@ -74,7 +80,14 @@ export function NavMenuChip({ menu }: NavMenuChipProps) {
-
+
*]:col-span-2', + items.length % 3 === 2 && '[&>*:nth-last-child(2)]:col-start-2' + )} + role='group' + aria-label={label} + > {items.map((item) => ( ))} diff --git a/apps/sim/app/(landing)/components/platform-hero-visual/index.ts b/apps/sim/app/(landing)/components/platform-hero-visual/index.ts new file mode 100644 index 00000000000..862981f2db2 --- /dev/null +++ b/apps/sim/app/(landing)/components/platform-hero-visual/index.ts @@ -0,0 +1 @@ +export { PlatformHeroVisual } from '@/app/(landing)/components/platform-hero-visual/platform-hero-visual' diff --git a/apps/sim/app/(landing)/components/platform-hero-visual/platform-hero-visual.tsx b/apps/sim/app/(landing)/components/platform-hero-visual/platform-hero-visual.tsx new file mode 100644 index 00000000000..46048f236f6 --- /dev/null +++ b/apps/sim/app/(landing)/components/platform-hero-visual/platform-hero-visual.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import Image from 'next/image' + +interface PlatformHeroVisualProps { + /** The live platform interior - a platform loop or editor loop island. */ + children: ReactNode +} + +/** + * The enterprise hero's visual composition, shared: the architectural backdrop + * (`enterprise-hero-background.webp`) with the white demo window framed in + * front of it, ready to hold a live platform interior (a chat platform loop or + * the workflows editor loop). Extracted so every hero - the enterprise page + * included - reuses the exact backdrop, window geometry (`aspect-[1080/620]` + * at 83.08% width), and shadow treatment the enterprise page established. + * + * Renders inside a hero visual slot (the `variant='home'` media frame or the + * standard {@link SolutionsVisualFrame}); the outer `relative` wrapper anchors + * the `fill` image in frames that don't position themselves. + */ +export function PlatformHeroVisual({ children }: PlatformHeroVisualProps) { + return ( +
+ +
+
{children}
+
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/platform-page/components/index.ts b/apps/sim/app/(landing)/components/platform-page/components/index.ts deleted file mode 100644 index e261492a35e..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { PlatformCardRow } from './platform-card-row' -export { PlatformHero } from './platform-hero' -export { PlatformLogosRow } from './platform-logos-row' -export { PlatformStructuredData } from './platform-structured-data' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/index.ts deleted file mode 100644 index ac4e7512c5d..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PlatformCard } from './platform-card' -export { PlatformPillCta } from './platform-pill-cta' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/index.ts deleted file mode 100644 index 12f5a41938c..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformCard } from './platform-card' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/platform-card.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/platform-card.tsx deleted file mode 100644 index 8fa1120158f..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-card/platform-card.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from '@sim/emcn' -import { PlatformVisualFrame } from '@/app/(landing)/components/platform-page/components/platform-visual-frame' -import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants' -import type { PlatformCardConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * A single platform card - an `
` with an `

` title, a body-color - * description, and a reserved visual panel beneath. Text sits directly on the - * canvas (matching the hero and every other landing section); only the visual - * carries the `--surface-2` panel chrome, so the product mock reads as the one - * elevated surface and never blends into a competing card fill. - * - * The card owns the gap between its text and visual (`cardTextToVisual`) and the - * title→description stack (`cardTextStack`) - both from named spacing constants. - * The text block grows (`flex-1`) so the visual pins to the bottom of the - * grid-stretched cell: every card's visual aligns on one baseline regardless of - * description length. The visual lands in a fixed-height {@link PlatformVisualFrame} - * so the row stays uniform and CLS is zero. - * - * A content unit only: it accepts copy and a visual node, never any layout knob. - */ - -interface PlatformCardProps { - card: PlatformCardConfig - /** Stable id wiring the `

` into the page outline. */ - headingId: string -} - -export function PlatformCard({ card, headingId }: PlatformCardProps) { - return ( -
-
-

- {card.title} -

-

{card.description}

-
- - {card.visual} -
- ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/index.ts deleted file mode 100644 index 3fa8f722324..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformPillCta } from './platform-pill-cta' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/platform-pill-cta.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/platform-pill-cta.tsx deleted file mode 100644 index 10447067389..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/components/platform-pill-cta/platform-pill-cta.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client' - -import { ChipLink } from '@sim/emcn' -import { ArrowRight } from 'lucide-react' -import type { PlatformPillCta as PlatformPillCtaConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * The card-row pill CTA - a single primary `ChipLink` with a trailing arrow, - * matching the reference image's "Learn about automations →". The chip owns its - * own chrome and spacing; this component only wires the label, the href, and the - * arrow icon, exposing no layout knobs. - * - * Client leaf: `ChipLink` is a Client Component and its `rightIcon` is a - * component reference (`ArrowRight`), which cannot cross the server→client - * boundary as a prop - so the icon must be wired from client code, exactly as the - * navbar's `NavMenuChip` does. The props it receives ({@link PlatformPillCtaConfig}) - * are plain serializable data, so the surrounding layout stays Server Components. - * - * Link safety: an external href (absolute `http(s)://`) renders with - * `rel='noopener noreferrer'` and `target='_blank'`; an internal href routes - * through the Next `` that `ChipLink` is built on - so every link is - * crawlable and safe with no per-page ceremony. - */ - -interface PlatformPillCtaProps { - cta: PlatformPillCtaConfig -} - -/** Returns true for absolute external URLs (http/https), which need rel/target hardening. */ -function isExternalHref(href: string): boolean { - return /^https?:\/\//.test(href) -} - -export function PlatformPillCta({ cta }: PlatformPillCtaProps) { - const external = isExternalHref(cta.href) - - return ( - - {cta.label} - - ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/index.ts deleted file mode 100644 index 3410c5a77eb..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformCardRow } from './platform-card-row' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/platform-card-row.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/platform-card-row.tsx deleted file mode 100644 index 373a19f6780..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-card-row/platform-card-row.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { cn } from '@sim/emcn' -import { - PlatformCard, - PlatformPillCta, -} from '@/app/(landing)/components/platform-page/components/platform-card-row/components' -import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants' -import type { PlatformCardRowConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * A card row - the core repeating unit of a platform page. A header block (an - * `

` title, a body-color subtitle, and a single pill CTA) sits above a grid - * of cards. The grid column count is derived from `cards.length` - 3 cards render - * `grid-cols-3`, 4 render `grid-cols-4` - so the page never specifies layout. - * - * Rendered as a labelled `
` for a clean, crawlable landmark; each card - * is an `
` with an `

`, keeping the strict H2 → H3 hierarchy. Every - * gap (header sub-stack, header-to-grid, and inter-card) is owned by named - * spacing constants; this component exposes no layout prop. - */ - -interface PlatformCardRowProps { - row: PlatformCardRowConfig -} - -/** Maps a supported card count to its grid column class; anything else falls back to three-up. */ -const GRID_COLS: Record = { - 3: 'grid-cols-3', - 4: 'grid-cols-4', -} - -export function PlatformCardRow({ row }: PlatformCardRowProps) { - const headingId = `platform-row-${row.id}-heading` - const gridCols = GRID_COLS[row.cards.length] ?? GRID_COLS[3] - - return ( -
-
-

- {row.title} -

-

- {row.subtitle} -

- -
- -
- {row.cards.map((card, index) => ( - - ))} -
-
- ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-hero/index.ts deleted file mode 100644 index a9b33df303b..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformHero } from './platform-hero' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx deleted file mode 100644 index 5dabef8f612..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-hero/platform-hero.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { cn } from '@sim/emcn' -import { HeroCta } from '@/app/(landing)/components/hero-cta' -import { LANDING_HERO_CTA_GAP } from '@/app/(landing)/components/landing-layout' -import { PlatformVisualFrame } from '@/app/(landing)/components/platform-page/components/platform-visual-frame' -import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants' -import type { PlatformHeroConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * Platform hero - the only `

` on a platform page. Left-aligned header copy - * (headline + supporting description) sits above the same CTA as the landing - * hero ({@link HeroCta}, the single source of truth), then a full-width platform - * visual underneath. - * - * The header column and the visual are stacked in one flex column; the header's - * own sub-stack (headline → description → CTA) and the gap down to the visual are - * both owned by named spacing constants, so a consumer page passes only copy and - * a visual node - never any spacing. The visual renders into a reserved-aspect - * {@link PlatformVisualFrame} (CLS = 0). - * - * Carries the page's sr-only ~50-word product summary for AI citation (GEO). The - * section's horizontal gutter is owned by `PlatformPage`; this component sets none. - */ - -interface PlatformHeroProps { - hero: PlatformHeroConfig -} - -export function PlatformHero({ hero }: PlatformHeroProps) { - return ( -
-

{hero.summary}

- -
-

- {hero.heading} -

- -

- {hero.description} -

- -
- -
-
- - {hero.visual} -
- ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/index.ts deleted file mode 100644 index 58d96294a12..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformLogosRow } from './platform-logos-row' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx deleted file mode 100644 index 0a173ba0231..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-logos-row/platform-logos-row.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Logos } from '@/app/(landing)/components/logos' - -/** - * Platform logos row - the same customer wordmarks as the landing hero, in a - * single horizontally-centered row at the shared `gap-x-24` rhythm (owned by the - * shared {@link Logos} component, `row` layout). Takes no props and exposes no - * spacing: its horizontal gutter comes from `PlatformPage` and its inter-section - * spacing from the page's `
` gap. - * - * Wrapped as a labelled `
` so it is a discrete, crawlable landmark; the - * heading is sr-only because the logos are a proof band rather than a content - * section, but the H2 keeps the page's heading hierarchy intact. The section is - * `relative` so the `sr-only` (`position: absolute`) heading is contained by it - * rather than falling back to the document root - matching {@link Features}'s - * `relative` wrapper for its own `sr-only` heading, and avoiding a phantom root - * scrollbar (the heading's un-offset static position would otherwise inflate - * `document.documentElement`'s scroll height by its own position in the page). - */ -export function PlatformLogosRow() { - return ( -
-

- Companies building AI agents with Sim -

-
- -
-
- ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/index.ts deleted file mode 100644 index 56ed2d88cdf..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformStructuredData } from './platform-structured-data' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/platform-structured-data.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/platform-structured-data.tsx deleted file mode 100644 index c74139762c2..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-structured-data/platform-structured-data.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { SITE_URL } from '@/lib/core/utils/urls' -import { JsonLd } from '@/app/(landing)/components/json-ld' -import type { PlatformPageConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * JSON-LD for a platform page - a `WebPage` (about a `WebApplication`) plus a - * `BreadcrumbList`, rendered server-side before any visible content so crawlers - * and AI answer engines read the structured data first. - * - * Everything is derived from the same {@link PlatformPageConfig} that drives the - * visible sections, so the structured data can never drift from the page: the - * `WebApplication.featureList` is the deduped set of card titles actually - * rendered, the page name/description come from the hero, and the breadcrumb - * from the module + path. The page author maintains zero schema by hand. - * - * Server Component; no client cost. Internal to the platform layout - emitted by - * `PlatformPage`, never rendered by a consumer directly. - */ - -interface PlatformStructuredDataProps { - config: PlatformPageConfig -} - -export function PlatformStructuredData({ config }: PlatformStructuredDataProps) { - const { module, path, hero, rows } = config - const url = `${SITE_URL}${path}` - const featureList = Array.from( - new Set(rows.flatMap((row) => row.cards.map((card) => card.title))) - ) - - const jsonLd = { - '@context': 'https://schema.org', - '@graph': [ - { - '@type': 'WebPage', - '@id': `${url}#webpage`, - url, - name: hero.heading, - description: hero.summary, - isPartOf: { '@id': `${SITE_URL}#website` }, - about: { '@id': `${url}#application` }, - breadcrumb: { '@id': `${url}#breadcrumb` }, - inLanguage: 'en-US', - }, - { - '@type': 'BreadcrumbList', - '@id': `${url}#breadcrumb`, - itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Home', item: SITE_URL }, - { '@type': 'ListItem', position: 2, name: module, item: url }, - ], - }, - { - '@type': 'WebApplication', - '@id': `${url}#application`, - name: `Sim ${module}`, - description: hero.summary, - applicationCategory: 'BusinessApplication', - operatingSystem: 'Web', - url, - featureList, - offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, - }, - ], - } - - return -} diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/index.ts b/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/index.ts deleted file mode 100644 index 6826e81f33d..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PlatformVisualFrame } from './platform-visual-frame' diff --git a/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/platform-visual-frame.tsx b/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/platform-visual-frame.tsx deleted file mode 100644 index 56c89024a73..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/components/platform-visual-frame/platform-visual-frame.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactNode } from 'react' -import { cn } from '@sim/emcn' -import { PLATFORM_VISUAL } from '@/app/(landing)/components/platform-page/constants' - -/** - * The one escape hatch in the platform layout - a fixed-dimension frame that - * holds a page-supplied visual `ReactNode`. The frame owns its chrome (the - * hero-visual family: `--surface-2` fill, `--border-1` hairline, `rounded-lg`, - * `overflow-hidden`) and, crucially, its dimensions: a `hero` frame reserves a - * 16:9 aspect ratio, a `card` frame a fixed height. Because the size is reserved - * before paint and the node fills `h-full w-full` inside, a dropped-in node can - * neither shift surrounding layout (CLS = 0) nor change the frame's own padding. - * - * The frame is decorative chrome around product visuals, so it is `aria-hidden`; - * the page's meaning lives in the adjacent headings and copy. - */ - -interface PlatformVisualFrameProps { - /** - * Reserved-dimension preset. - * - `hero` - full-width 16:9 frame for the platform hero visual. - * - `card` - fixed-height frame for a card's visual panel. - */ - size: 'hero' | 'card' - /** The page-supplied visual island or static panel. Fills the frame; owns no chrome. */ - children: ReactNode -} - -export function PlatformVisualFrame({ size, children }: PlatformVisualFrameProps) { - return ( - - ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/constants.ts b/apps/sim/app/(landing)/components/platform-page/constants.ts deleted file mode 100644 index c64bcbef36b..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/constants.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Platform-layout spacing - the single source of truth for every gutter, gap, - * inset, and reserved dimension used across the platform-page components. - * - * The padding fortress lives here. No platform component accepts a `className`, - * `style`, or any layout-override prop, and none hard-codes a spacing value - * inline. Every measurement a reviewer might want to audit - the horizontal - * gutter, the inter-section rhythm, the card-grid gaps, the card stack, and the - * fixed visual-slot dimensions - is named in this one file. To change spacing - * you edit a constant here; a consumer page literally cannot reach it. - * - * All values are Tailwind class fragments (not raw numbers) so they compose - * directly into `className` strings inside the components and stay legible. - */ -export const PLATFORM_SPACING = { - /** - * The one horizontal gutter, owned solely by `PlatformPage`. Matches the - * navbar and landing hero exactly (`px-20 max-lg:px-8 max-sm:px-5`) so platform - * content starts on the wordmark's vertical line at every breakpoint. Sections - * and cards never set their own gutter. - */ - gutter: 'px-20 max-lg:px-8 max-sm:px-5', - /** - * Inter-section vertical rhythm - the gap of the `
` flex column that - * `PlatformPage` owns. Sections carry no vertical margin/padding of their own, - * so this is the only knob for the space between the hero, logos, and every - * card row. Tightens on smaller screens in lockstep with the landing `
` - * (`gap-[120px] max-lg:gap-[88px] max-sm:gap-16`). - */ - sectionRhythm: 'gap-[120px] max-lg:gap-[88px] max-sm:gap-16', - /** Hero text top padding, matching the landing hero (`pt-[112px] max-sm:pt-12`). */ - heroTopPadding: 'pt-[112px] max-sm:pt-12', - /** Vertical stack gap inside the hero header column (headline → description → CTA). */ - heroStack: 'gap-[22px]', - /** Gap between the hero header column and the full-width hero visual beneath it. */ - heroToVisual: 'gap-12', - /** Gap between a card row's header block and the card grid beneath it. */ - cardRowHeaderToGrid: 'gap-12', - /** Vertical stack gap inside a card row's header (title → subtitle → CTA). */ - cardRowHeaderStack: 'gap-5', - /** Gap between cards within a card-row grid (both axes). */ - cardGridGap: 'gap-8', - /** Minimum gap between a card's text block and its visual panel. */ - cardTextToVisual: 'gap-5', - /** Vertical stack gap inside a card's text block (title → description). */ - cardTextStack: 'gap-2', -} as const - -/** - * Reserved fixed dimensions for the component-owned visual frames. A dropped-in - * `ReactNode` renders into a frame of exactly these dimensions, so it can never - * shift surrounding layout (CLS = 0) nor change its own frame padding. The node - * fills `h-full w-full` inside; it owns nothing about the frame. - */ -export const PLATFORM_VISUAL = { - /** Full-width hero visual aspect ratio - reserves height before paint. */ - heroAspect: 'aspect-[16/9]', - /** Fixed height of a card's visual panel - uniform across every card. */ - cardHeight: 'h-[240px]', -} as const diff --git a/apps/sim/app/(landing)/components/platform-page/index.ts b/apps/sim/app/(landing)/components/platform-page/index.ts deleted file mode 100644 index dc84d3ecb5d..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { PlatformPage } from './platform-page' -export type { - PlatformCardConfig, - PlatformCardRowConfig, - PlatformHeroConfig, - PlatformPageConfig, - PlatformPillCta, -} from './types' diff --git a/apps/sim/app/(landing)/components/platform-page/platform-page.tsx b/apps/sim/app/(landing)/components/platform-page/platform-page.tsx deleted file mode 100644 index 4749b46e623..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/platform-page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { cn } from '@sim/emcn' -import { - PlatformCardRow, - PlatformHero, - PlatformLogosRow, - PlatformStructuredData, -} from '@/app/(landing)/components/platform-page/components' -import { PLATFORM_SPACING } from '@/app/(landing)/components/platform-page/constants' -import type { PlatformPageConfig } from '@/app/(landing)/components/platform-page/types' - -/** - * The reusable platform-page content stack - the single component six routes - * (Workflows, Tables, Files, Knowledge Base, Scheduled Tasks, Logs) consume with - * near-zero ceremony. A route renders the shared shell and drops in one - * ``. - * - * This component owns the entire `
`: the shared `max-w-[1460px]` content - * column (centered with `mx-auto`, matching the navbar and landing sections), the - * one horizontal gutter (`PLATFORM_SPACING.gutter`), and the inter-section - * vertical rhythm (`PLATFORM_SPACING.sectionRhythm`, the `
` flex gap). The - * hero, the logos row, and every card row carry no gutter and no inter-section - * margin of their own, so spacing is uniform and unreachable from a consumer - * page - the config is pure content (strings + `ReactNode` visual slots), with no - * layout knob anywhere in its tree. - * - * The order is fixed: structured data first (before visible content, derived - * from the same config so it never drifts) → platform hero (the page's only - * `

`) → centered logos row → the configured card rows in array order. The - * heading outline is strict H1 → H2 (per card row) → H3 (per card), never - * skipped. Server Component only - no client island lives here; the page - * supplies its own islands through the `visual` slots in the config. - */ - -interface PlatformPageProps { - /** The complete page content - identity, hero, and ordered card rows. */ - config: PlatformPageConfig -} - -export function PlatformPage({ config }: PlatformPageProps) { - return ( - <> - -
- - - {config.rows.map((row) => ( - - ))} -
- - ) -} diff --git a/apps/sim/app/(landing)/components/platform-page/types.ts b/apps/sim/app/(landing)/components/platform-page/types.ts deleted file mode 100644 index 1f5815dd224..00000000000 --- a/apps/sim/app/(landing)/components/platform-page/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { ReactNode } from 'react' - -/** - * Platform-page configuration - the entire content contract a route passes to - * {@link PlatformPage}. Every field is content-only: strings for copy, `ReactNode` - * exclusively for the designated visual/animation slots, and typed config arrays - * for card rows. There is deliberately no `className`, `style`, width, height, - * padding, margin, or any other layout knob anywhere in this tree - spacing - * lives entirely inside the components (see `PLATFORM_SPACING`). A page describes - * WHAT to show; the layout decides WHERE and with how much space. - */ - -/** A single pill call-to-action - label plus destination, used by card rows. */ -export interface PlatformPillCta { - /** Visible link text. A trailing arrow is added by the component. */ - label: string - /** Destination href. Internal hrefs render as Next ``; external as a safe anchor. */ - href: string -} - -/** The platform hero - header copy, the shared CTA, and a full-width visual. */ -export interface PlatformHeroConfig { - /** - * The page's single `

`. Per the constitution it should name the module and - * "Sim"/"AI workspace" (e.g. "Workflows - the visual builder in Sim, the AI workspace"). - */ - heading: string - /** Supporting description beneath the heading, in the body color. */ - description: string - /** - * ~50-word sr-only atomic summary for AI citation (GEO). Names "Sim" explicitly - * and states what the module is, who it's for, and what it does. - */ - summary: string - /** - * The full-width hero visual - a page-supplied client island or static panel. - * Renders into a component-owned frame with reserved aspect ratio (CLS = 0) and - * is marked `aria-hidden`; it owns nothing about the frame's chrome or spacing. - */ - visual: ReactNode -} - -/** A single card - text plus a reserved visual panel. Rendered as an `
`. */ -export interface PlatformCardConfig { - /** The card's `

` title. */ - title: string - /** - * Supporting description beneath the title, in the body color. Self-contained - * and names "Sim" - never "the platform" or a bare pronoun - so each card is an - * independently quotable answer block. - */ - description: string - /** - * The card's visual/animation - a page-supplied node. Renders into a - * component-owned, fixed-height frame (CLS = 0), marked `aria-hidden`. The card - * owns the spacing around both the text and this frame. - */ - visual: ReactNode -} - -/** - * A card row - the core repeating unit. A header (title + subtitle + CTA) above a - * grid of 3 or 4 cards. The grid column count is derived from `cards.length`, so - * the page never specifies layout. - */ -export interface PlatformCardRowConfig { - /** - * Stable section id for the `
` landmark and `aria-labelledby` wiring. - * Must be unique within the page (e.g. `'build'`). - */ - id: string - /** The row's `

` title, in the headline color - reads as an answer to a user question. */ - title: string - /** Supporting subtitle beneath the title, in the body color, naming "Sim". */ - subtitle: string - /** The row's single pill CTA. */ - cta: PlatformPillCta - /** The cards in this row - 3 or 4. The grid derives its columns from this length. */ - cards: PlatformCardConfig[] -} - -/** - * The complete platform-page content: page identity (for structured data), one - * hero, plus ordered card rows. A route passes exactly this object to - * {@link PlatformPage} and nothing else. - */ -export interface PlatformPageConfig { - /** Module name, e.g. "Workflows" - used in the breadcrumb and schema.org name. */ - module: string - /** Canonical path, e.g. "/workflows" - used to build the JSON-LD `url`/breadcrumb. */ - path: string - /** The hero (the page's only `

`). */ - hero: PlatformHeroConfig - /** Card rows rendered in order beneath the logos row. */ - rows: PlatformCardRowConfig[] -} diff --git a/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.module.css b/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.module.css new file mode 100644 index 00000000000..392d31f5a79 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.module.css @@ -0,0 +1,62 @@ +/** + * The CodeWindowGraphic's motion: the code lines stamp in top to bottom + * one after another — the audit tile's one-shot settle, never re-played; + * the caret blink is Tailwind's `animate-pulse`, disabled through its + * own `motion-reduce:animate-none`. Under prefers-reduced-motion the + * lines render fully settled. + */ + +.line0, +.line1, +.line2, +.line3, +.line4, +.line5 { + animation: code-window-line-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +.line0 { + animation-delay: 0.35s; +} + +.line1 { + animation-delay: 0.5s; +} + +.line2 { + animation-delay: 0.65s; +} + +.line3 { + animation-delay: 0.8s; +} + +.line4 { + animation-delay: 0.95s; +} + +.line5 { + animation-delay: 1.1s; +} + +@keyframes code-window-line-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .line0, + .line1, + .line2, + .line3, + .line4, + .line5 { + animation: none; + } +} diff --git a/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.tsx b/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.tsx new file mode 100644 index 00000000000..1c74b99654d --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/code-window-graphic/code-window-graphic.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import styles from '@/app/(landing)/components/shared/code-window-graphic/code-window-graphic.module.css' +import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics' + +/** One inline run of code text inside a {@link CodeWindowGraphic} line. */ +export interface CodeSegment { + /** Segment text. */ + text: string + /** Ink treatment on the dark tile: `muted` scaffolding or `primary` payload. */ + tone?: 'muted' | 'primary' +} + +interface CodeWindowGraphicProps { + /** + * Title-bar mark, rendered inside the outlined `size-6` icon box - pass a + * `size-[14px]` icon in `--text-muted-inverse` to match the family. + */ + icon: ReactNode + /** Filename shown beside the icon in the title bar. */ + filename: string + /** Code excerpt, one segment array per line - at most six lines animate. */ + lines: readonly (readonly CodeSegment[])[] +} + +/** Per-line stamp-in classes - the stagger order is baked into each class's delay. */ +const LINE_STEP_CLASSES = [ + styles.line0, + styles.line1, + styles.line2, + styles.line3, + styles.line4, + styles.line5, +] as const + +/** Segment inks on the dark tile - the deploy tile's dark-surface palette. */ +const SEGMENT_TONE_CLASS = { + muted: 'text-[var(--text-muted-inverse)]', + primary: 'text-[var(--text-inverse)]', +} as const + +/** Shared hairline ink for the window outline, header rule, and icon box. */ +const OUTLINE_INK = 'border-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]' + +/** + * The dark-tile outlined editor window shared by the platform pages' "from + * code" feature graphics (Workflows, Knowledge, Tables, Files): the + * agent-code tile's exact slot geometry (`top-5`, `left-0`, bleeding off + * the right and bottom edges, `rounded-tl-xl`) drawn as an outlined shell - + * faint `--text-muted-inverse` hairlines with the dark tile showing + * through, the deploy tile's browser-window vocabulary. Its `h-12` title + * bar pairs the caller's icon (in an outlined `size-6` icon box, the + * lifecycle header's treatment in dark ink) with the caller's filename + * over a hairline rule. + * + * Inside, the caller's code excerpt sits settled in mono - scaffolding in + * `--text-muted-inverse`, payload in `--text-inverse` - with a blinking + * caret holding the last line. The lines stamp in top to bottom once (from + * `code-window-graphic.module.css`, the audit tile's one-shot settle); + * under `prefers-reduced-motion` the excerpt renders fully settled with no + * caret blink. + */ +export function CodeWindowGraphic({ icon, filename, lines }: CodeWindowGraphicProps) { + return ( + + + + ) +} diff --git a/apps/sim/app/(landing)/components/shared/code-window-graphic/index.ts b/apps/sim/app/(landing)/components/shared/code-window-graphic/index.ts new file mode 100644 index 00000000000..e60af359512 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/code-window-graphic/index.ts @@ -0,0 +1 @@ +export { type CodeSegment, CodeWindowGraphic } from './code-window-graphic' diff --git a/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx b/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx new file mode 100644 index 00000000000..ecbe334e528 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/editor-loop/editor-loop.tsx @@ -0,0 +1,120 @@ +'use client' + +import { useState } from 'react' +import { cn } from '@sim/emcn' +import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage' +import type { BlockDef } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' +import { HeroLoopShell } from '@/app/(landing)/components/shared/hero-loop-shell' +import type { EnterpriseSidebarProps } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar' +import { RESET_FADE_MS } from '@/app/(landing)/hooks/use-design-scale' +import { useMotionSafeCycle } from '@/app/(landing)/hooks/use-motion-safe-cycle' + +/** The empty canvas holds this long before the first block lands. */ +const IDLE_HOLD_MS = 900 +/** Block N (build order) pops in at IDLE_HOLD_MS + N * BUILD_STEP_MS. */ +const BUILD_STEP_MS = 620 +/** The story block gets its selection ring this long after the last block. */ +const SELECT_AFTER_MS = 700 +/** The finished, selected canvas holds this long before the fade. */ +const SELECTED_HOLD_MS = 5200 + +/** Domain content one editor-loop hero replays - sidebar identity + staged flow. */ +export interface EditorLoopContent { + /** Recent-chat entries in the sidebar - four fill the design height. */ + sidebarChats: readonly string[] + /** Deployed workflows in the sidebar - five fill the design height. */ + sidebarWorkflows: readonly string[] + /** Canvas blocks, ordered by build sequence. */ + blocks: BlockDef[] + /** Source → target pairs, drawn in order as their endpoints land on canvas. */ + edges: ReadonlyArray + /** Design-space bounding box of the block layout. */ + canvas: { width: number; height: number } + /** The block the "editing" beat selects once the flow is assembled. */ + selectedBlockId: string + /** Workspace-nav row to highlight in the sidebar; unset keeps New chat active. */ + activeNav?: EnterpriseSidebarProps['activeNav'] +} + +interface EditorLoopProps { + /** The page's sidebar identity and staged workflow. */ + content: EditorLoopContent +} + +/** + * The chat-free sibling of the enterprise platform loop, shared by the + * workflows and scheduled-tasks heroes. Same architecture (the + * {@link HeroLoopShell}'s fixed 1280x735 design-space layer scaled to the + * window, a parent-owned clock driving a presentational stage, reduced-motion + * showing the finished frame) and the same live sidebar, but the workspace + * pane is the editor canvas itself: the content's workflow assembles block by + * block (edges stroke-draw as endpoints land), then the story block picks up + * the real editor's selection ring - the "being edited" beat - before the + * scene fades and the cycle restarts. + * + * Everything is `pointer-events-none` decorative, matching each hero's + * `aria-hidden` frame. Under `prefers-reduced-motion` the loop never starts: + * the fully-built, selected canvas renders statically. + */ +export function EditorLoop({ content }: EditorLoopProps) { + const [builtCount, setBuiltCount] = useState(0) + const [selected, setSelected] = useState(false) + const [fading, setFading] = useState(false) + const [cycleId, setCycleId] = useState(0) + + useMotionSafeCycle( + { + scheduleCycle: () => { + setFading(false) + setBuiltCount(0) + setSelected(false) + setCycleId((c) => c + 1) + const selectAt = + IDLE_HOLD_MS + (content.blocks.length - 1) * BUILD_STEP_MS + SELECT_AFTER_MS + const totalMs = selectAt + SELECTED_HOLD_MS + return { + timers: [ + ...content.blocks.map((_, i) => + setTimeout(() => setBuiltCount(i + 1), IDLE_HOLD_MS + i * BUILD_STEP_MS) + ), + setTimeout(() => setSelected(true), selectAt), + setTimeout(() => setFading(true), totalMs - RESET_FADE_MS), + ], + totalMs, + } + }, + showFinished: () => { + setFading(false) + setBuiltCount(content.blocks.length) + setSelected(true) + }, + }, + [content] + ) + + return ( + +
+
+ +
+
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/shared/editor-loop/index.ts b/apps/sim/app/(landing)/components/shared/editor-loop/index.ts new file mode 100644 index 00000000000..b1baae8c9a3 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/editor-loop/index.ts @@ -0,0 +1 @@ +export { EditorLoop, type EditorLoopContent } from './editor-loop' diff --git a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx new file mode 100644 index 00000000000..5e83f083f01 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx @@ -0,0 +1,60 @@ +'use client' + +import type { ReactNode } from 'react' +import { + EnterpriseSidebar, + type EnterpriseSidebarProps, +} from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar' +import { DESIGN, useDesignScale } from '@/app/(landing)/hooks/use-design-scale' + +interface HeroLoopShellProps { + /** Workspace name in the sidebar header chip. */ + workspaceName?: string + /** Recent-chat entries in the sidebar - four fill the design height. */ + chats: readonly string[] + /** Deployed-workflow entries in the sidebar - five fill the design height. */ + workflows: readonly string[] + /** Workspace-nav row to highlight; unset keeps New chat active. */ + activeNav?: EnterpriseSidebarProps['activeNav'] + /** The workspace pane's contents, rendered inside the inset pane gutter. */ + children: ReactNode +} + +/** + * The platform heroes' shared scaled stage: a `pointer-events-none` region + * whose fixed 1280x735 design-space layer is fitted to the rendered width via + * {@link useDesignScale} (`ResizeObserver` + `transform: scale`), holding the + * live {@link EnterpriseSidebar} beside the workspace pane each loop supplies + * as children. Purely presentational - the hero that renders it owns the + * `aria-hidden` frame and the animation clock. + */ +export function HeroLoopShell({ + workspaceName = 'Brightwave', + chats, + workflows, + activeNav, + children, +}: HeroLoopShellProps) { + const { regionRef, scale } = useDesignScale() + + return ( +
+
+ +
{children}
+
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/shared/hero-loop-shell/index.ts b/apps/sim/app/(landing)/components/shared/hero-loop-shell/index.ts new file mode 100644 index 00000000000..92081add7ad --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/hero-loop-shell/index.ts @@ -0,0 +1 @@ +export { HeroLoopShell } from './hero-loop-shell' diff --git a/apps/sim/app/(landing)/components/shared/zip-icon/index.ts b/apps/sim/app/(landing)/components/shared/zip-icon/index.ts new file mode 100644 index 00000000000..b7cefcad470 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/zip-icon/index.ts @@ -0,0 +1 @@ +export { ZipIcon } from './zip-icon' diff --git a/apps/sim/app/(landing)/components/shared/zip-icon/zip-icon.tsx b/apps/sim/app/(landing)/components/shared/zip-icon/zip-icon.tsx new file mode 100644 index 00000000000..3ae18082677 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/zip-icon/zip-icon.tsx @@ -0,0 +1,28 @@ +import type { SVGProps } from 'react' + +/** + * Generic zip-archive glyph shared by the landing files surfaces (the + * homepage files preview and the files hero loop) - no dedicated zip + * component exists in the icon set. Inherits `currentColor` and sizes via + * className, matching the icon-set convention. + */ +export function ZipIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx index c6b7f0ac6b7..c430eed9f7f 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card-row-header/solutions-card-row-header.tsx @@ -1,9 +1,5 @@ -import { cn } from '@sim/emcn' import { SolutionsPillCta } from '@/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-pill-cta' -import { - SOLUTIONS_SPACING, - SOLUTIONS_TEXT_MEASURE, -} from '@/app/(landing)/components/solutions-page/constants' +import { SOLUTIONS_SPACING } from '@/app/(landing)/components/solutions-page/constants' import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solutions-page/types' /** @@ -11,67 +7,27 @@ import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solution * a single pill CTA, stacked with named spacing constants. Extracted from * {@link SolutionsCardRow} so layouts that place row headers inside a shared * grid (the enterprise feature grid) render the exact same header chrome. - * - * Only the row header stack can opt into centered text; the `feature` variant - * is the tighter, smaller treatment used by feature-tile pages. */ interface SolutionsCardRowHeaderProps { row: SolutionsCardRowConfig /** Stable id wiring the `

` into the page outline. */ headingId: string - /** Header stack alignment. Defaults to the original left-aligned layout. */ - align?: 'left' | 'center' - /** Header typography treatment. Defaults to the original larger solutions header. */ - variant?: 'standard' | 'feature' } -export function SolutionsCardRowHeader({ - row, - headingId, - align = 'left', - variant = 'standard', -}: SolutionsCardRowHeaderProps) { - const centered = align === 'center' - const featureHeader = variant === 'feature' - +export function SolutionsCardRowHeader({ row, headingId }: SolutionsCardRowHeaderProps) { return ( -
+

{row.title}

-

+

{row.subtitle}

-
+
diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx index 2dd29e9ca1f..1d03f102f88 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx @@ -1,5 +1,4 @@ import { cn } from '@sim/emcn' -import { SolutionsVisualFrame } from '@/app/(landing)/components/solutions-page/components/solutions-visual-frame' import { SOLUTIONS_FEATURE_TILE_TONE, SOLUTIONS_SPACING, @@ -10,16 +9,20 @@ import type { SolutionsCardConfig } from '@/app/(landing)/components/solutions-p /** * A single solutions card - an `
` with an `

` title, a body-color - * description, and a reserved visual area. The default split variant keeps copy - * on the page canvas with a framed visual beneath it. The feature-tile variant - * moves copy and the visual slot into one larger bordered surface for pages that - * need a unified callout card. + * description, and a reserved visual area, all inside one larger bordered + * feature-tile surface. * - * The card owns the gap between its text and visual (`cardTextToVisual`) and the - * title→description stack (`cardTextStack`) - both from named spacing constants. - * The split variant lands the visual in a fixed-height - * {@link SolutionsVisualFrame}; the feature-tile variant reserves a larger - * flexible slot inside its own frame for future UI. + * The card owns the title→description stack (`cardTextStack`) from named + * spacing constants and reserves a larger flexible visual slot inside its own + * frame. + * + * Feature tiles scale proportionally: the tile's content (copy and graphic + * together) is authored on a design-space canvas and the whole bloc zooms + * down uniformly when its grid column is narrower than the 352px design + * width, so intermediate breakpoints render a smaller copy of the desktop + * tile instead of a squished one. See `SOLUTIONS_VISUAL` for the container + * query + `tan(atan2())` mechanics; at or above the design width the tile + * renders fluid at scale 1, byte-identical to the pre-scaler layout. * * A content unit only: it accepts copy and a visual node plus a controlled visual * treatment, never arbitrary spacing or class overrides. @@ -29,71 +32,88 @@ interface SolutionsCardProps { card: SolutionsCardConfig /** Stable id wiring the `

` into the page outline. */ headingId: string - /** Visual treatment. Defaults to the original split text + framed visual layout. */ - variant?: 'split' | 'featureTile' + /** + * Set by the row on the third card of a 3-card feature-tile row. In the + * two-column band (`sm`..`lg`) the card spans both grid columns instead of + * sitting orphaned beside an empty cell, and the tile switches to a wide + * side-by-side treatment: the copy block sits vertically centered in a left + * column while the visual slot takes the remaining width at a shorter + * 360px tile height, so the row reads as designed-for-wide rather than a + * stretched portrait tile. Graphics detect the wide tile themselves via a + * container query (the tile is ≥500px only when spanned in this band) and + * relax their column caps to match. No effect at `lg`+ or below `sm`. + */ + tabletSpan?: boolean } -export function SolutionsCard({ card, headingId, variant = 'split' }: SolutionsCardProps) { - const featureTile = variant === 'featureTile' +export function SolutionsCard({ card, headingId, tabletSpan = false }: SolutionsCardProps) { const featureTileTone = SOLUTIONS_FEATURE_TILE_TONE[card.featureTileTone ?? 'light'] const featureTileDescription = card.featureTileDescriptionTone === 'soft' && card.featureTileTone === 'dark' ? SOLUTIONS_FEATURE_TILE_TONE.dark.descriptionSoft : featureTileTone.description - const textBlock = ( -
-

- {card.title} -

-

- {card.description} -

-
- ) + const wide = tabletSpan - if (featureTile) { - return ( + return ( +
- {textBlock} -
- ) - } - - return ( -
-
{textBlock}
- - {card.visual} -
+
) } diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/solutions-card-row.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/solutions-card-row.tsx index cf6819e766a..426ed71700b 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/solutions-card-row.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/solutions-card-row.tsx @@ -10,7 +10,15 @@ import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solution * A card row - the core repeating unit of a solutions page. A header block (an * `

` title, a body-color subtitle, and a single pill CTA) sits above a grid * of cards. The grid column count is derived from `cards.length` - 3 cards render - * `grid-cols-3`, 4 render `grid-cols-4` - so the page never specifies layout. + * `grid-cols-3`, 4 render `grid-cols-4` - so the page never specifies layout. A + * row can opt down to a denser wrap via `row.columns` (e.g. 4 cards as a 2×2 + * grid); the grid's own gap keeps the wrapped rows at the standard inter-tile + * rhythm. + * + * In the two-column band (`sm`..`lg`) a 3-card feature-tile row would leave its + * third card orphaned beside an empty cell, so that card spans both columns and + * switches to the tile's wide side-by-side treatment (copy left, graphic + * right) - see {@link SolutionsCard}'s `tabletSpan`. * * Rendered as a labelled `
` for a clean, crawlable landmark; each card * is an `
` with an `

`, keeping the strict H2 → H3 hierarchy. Every @@ -21,28 +29,18 @@ import type { SolutionsCardRowConfig } from '@/app/(landing)/components/solution interface SolutionsCardRowProps { row: SolutionsCardRowConfig - /** Header stack alignment. Defaults to the original left-aligned layout. */ - align?: 'left' | 'center' - /** Card treatment. Defaults to the original split copy + visual layout. */ - cardVariant?: 'split' | 'featureTile' - /** Header typography treatment. Defaults to the original larger solutions header. */ - headerVariant?: 'standard' | 'feature' } -/** Maps a supported card count to its grid column class; anything else falls back to three-up. */ +/** Maps a supported column count to its grid class; anything else falls back to three-up. */ const GRID_COLS: Record = { + 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', } -export function SolutionsCardRow({ - row, - align = 'left', - cardVariant = 'split', - headerVariant = 'standard', -}: SolutionsCardRowProps) { +export function SolutionsCardRow({ row }: SolutionsCardRowProps) { const headingId = `solutions-row-${row.id}-heading` - const gridCols = GRID_COLS[row.cards.length] ?? GRID_COLS[3] + const gridCols = GRID_COLS[row.columns ?? row.cards.length] ?? GRID_COLS[3] return (
- +
))}
diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx index 400832024f8..1c40d68d4e8 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-hero/solutions-hero.tsx @@ -121,7 +121,7 @@ export function SolutionsHero({ hero, align = 'left', variant = 'solutions' }: S

- {hero.visual} + {hero.visual}

) } diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/solutions-visual-frame.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/solutions-visual-frame.tsx index ccca20012f5..d5d9fce410f 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/solutions-visual-frame.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-visual-frame/solutions-visual-frame.tsx @@ -6,8 +6,8 @@ import { SOLUTIONS_VISUAL } from '@/app/(landing)/components/solutions-page/cons * The one escape hatch in the solutions layout - a fixed-dimension frame that * holds a page-supplied visual `ReactNode`. The frame owns its chrome (the * hero-visual family: `--surface-2` fill, `--border-1` hairline, `rounded-lg`, - * `overflow-hidden`) and, crucially, its dimensions: a `hero` frame reserves a - * 16:9 aspect ratio, a `card` frame a fixed height. Because the size is reserved + * `overflow-hidden`) and, crucially, its dimensions: it reserves a full-width + * 16:9 aspect ratio for the solutions hero visual. Because the size is reserved * before paint and the node fills `h-full w-full` inside, a dropped-in node can * neither shift surrounding layout (CLS = 0) nor change the frame's own padding. * @@ -16,23 +16,17 @@ import { SOLUTIONS_VISUAL } from '@/app/(landing)/components/solutions-page/cons */ interface SolutionsVisualFrameProps { - /** - * Reserved-dimension preset. - * - `hero` - full-width 16:9 frame for the solutions hero visual. - * - `card` - fixed-height frame for a card's visual panel. - */ - size: 'hero' | 'card' /** The page-supplied visual island or static panel. Fills the frame; owns no chrome. */ children: ReactNode } -export function SolutionsVisualFrame({ size, children }: SolutionsVisualFrameProps) { +export function SolutionsVisualFrame({ children }: SolutionsVisualFrameProps) { return ( {row.cards.map((card, cardIndex) => (
))} diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage.tsx index 329cbaa7851..638af760c23 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage.tsx +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { ChevronDown, cn } from '@sim/emcn' import { ArrowRight, @@ -27,8 +27,6 @@ import { SUGGESTED_ACTIONS, } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data' -const REPLY_WORDS = ENTERPRISE_REPLY.split(' ') - /** * Reveals an incrementing count (typed chars, streamed words) at a fixed * step interval while `active`, deriving progress from ELAPSED time so a @@ -139,6 +137,16 @@ interface EnterpriseHomeStageProps { phase: EnterpriseLoopPhase /** True during the brief fade-out before the cycle restarts. */ fading: boolean + /** Personalized new-chat greeting. Defaults to the enterprise copy. */ + greeting?: string + /** Composer placeholder before typing starts. Defaults to the enterprise copy. */ + placeholder?: string + /** The prompt the loop types out. Defaults to the enterprise copy. */ + prompt?: string + /** Sim's streamed reply. Defaults to the enterprise copy. */ + reply?: string + /** Suggested-action rows under the composer. Defaults to the enterprise set. */ + suggestedActions?: readonly [string, string, string, string] } /** @@ -156,15 +164,24 @@ interface EnterpriseHomeStageProps { * ELAPSED time so throttled background tabs catch up instead of stalling * mid-sentence. */ -export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps) { +export function EnterpriseHomeStage({ + phase, + fading, + greeting = ENTERPRISE_GREETING, + placeholder = COMPOSER_PLACEHOLDER, + prompt = ENTERPRISE_PROMPT, + reply = ENTERPRISE_REPLY, + suggestedActions = SUGGESTED_ACTIONS, +}: EnterpriseHomeStageProps) { const isTyping = phase === 'typing' const isReply = phase === 'reply' const inConversation = phase === 'dispatch' || isReply const promptDone = phase === 'typed' || inConversation - const typedChars = useElapsedReveal(isTyping, PROMPT_CHAR_MS, ENTERPRISE_PROMPT.length) - const revealedWords = useElapsedReveal(isReply, REPLY_WORD_MS, REPLY_WORDS.length) + const replyWords = useMemo(() => reply.split(' '), [reply]) + const typedChars = useElapsedReveal(isTyping, PROMPT_CHAR_MS, prompt.length) + const revealedWords = useElapsedReveal(isReply, REPLY_WORD_MS, replyWords.length) - const visiblePrompt = promptDone ? ENTERPRISE_PROMPT : ENTERPRISE_PROMPT.slice(0, typedChars) + const visiblePrompt = promptDone ? prompt : prompt.slice(0, typedChars) const hasText = visiblePrompt.length > 0 return ( @@ -182,7 +199,7 @@ export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps) inConversation ? 'pointer-events-none opacity-0' : 'opacity-100' )} > -

{ENTERPRISE_GREETING}

+

{greeting}

@@ -192,7 +209,7 @@ export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps) {isTyping && } ) : ( - {COMPOSER_PLACEHOLDER} + {placeholder} )} @@ -208,7 +225,7 @@ export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps)
- {SUGGESTED_ACTIONS.map((action, i) => { + {suggestedActions.map((action, i) => { const Icon = ACTION_ICONS[i] return (
- {ENTERPRISE_PROMPT} + {prompt}
{phase === 'dispatch' && ( @@ -253,7 +270,7 @@ export function EnterpriseHomeStage({ phase, fading }: EnterpriseHomeStageProps) isReply ? 'opacity-100' : 'opacity-0' )} > - {REPLY_WORDS.slice(0, revealedWords).join(' ')} + {replyWords.slice(0, revealedWords).join(' ')}

diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx index 3a34086dba2..5823c8091e9 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx @@ -1,27 +1,29 @@ 'use client' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { cn } from '@sim/emcn' import { HeroWorkflowStage } from '@/app/(landing)/components/hero/components/hero-platform-loop/hero-workflow-stage' +import { HeroLoopShell } from '@/app/(landing)/components/shared/hero-loop-shell' import { EnterpriseHomeStage } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-home-stage' -import { EnterpriseSidebar } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar' import { BUILD_STEP_MS, - ENTERPRISE_STAGE_BLOCKS, - ENTERPRISE_STAGE_CANVAS, - ENTERPRISE_STAGE_EDGES, + buildLoopTimeline, + ENTERPRISE_LOOP_CONTENT, + type EnterpriseLoopContent, type EnterpriseLoopPhase, - LOOP_TIMELINE, - RESET_FADE_MS, } from '@/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data' +import { RESET_FADE_MS } from '@/app/(landing)/hooks/use-design-scale' +import { useMotionSafeCycle } from '@/app/(landing)/hooks/use-motion-safe-cycle' -/** - * The window interior's design space, matching the homepage loop's capture - * geometry (the 2560x1470 shot is a 1280x735 CSS layout shown in the 1080x620 - * window, so the app's native type reads at the same ~84.4% "mini app" scale): - * the sidebar column is 249px, and the workspace container is inset 7-8px. - */ -const DESIGN = { width: 1280, height: 735 } as const +interface EnterprisePlatformLoopProps { + /** + * Domain content the loop replays - sidebar identity, chat exchange, and + * staged workflow. Defaults to the enterprise page's own content, so + * existing consumers render exactly as before; the solutions pages pass + * their per-domain content through the same shape. + */ + content?: EnterpriseLoopContent +} /** * The enterprise hero's platform loop - a sibling of the homepage @@ -37,133 +39,100 @@ const DESIGN = { width: 1280, height: 735 } as const * Timeline (see `stage-data.ts` - later stages append beats there): idle * new-chat view → prompt types out → send arms → dispatch (user bubble + * thinking, full-width) → the stage pane slides in from the right (the real - * `MothershipView` `w-0 ↔ w-1/2` width transition) → the invoice workflow - * assembles block by block (the shared {@link HeroWorkflowStage}, staged with - * the enterprise flow) → the reply streams in → hold → fade → restart. + * `MothershipView` `w-0 ↔ w-1/2` width transition) → the staged workflow + * assembles block by block (the shared {@link HeroWorkflowStage}) → the reply + * streams in → hold → fade → restart. * * Everything is `pointer-events-none` decorative, matching the hero's * `aria-hidden` frame. Under `prefers-reduced-motion` the loop never starts: * the finished exchange, open stage, and fully-built workflow render * statically. */ -export function EnterprisePlatformLoop() { - const regionRef = useRef(null) +export function EnterprisePlatformLoop({ + content = ENTERPRISE_LOOP_CONTENT, +}: EnterprisePlatformLoopProps = {}) { const [phase, setPhase] = useState('idle') const [stageOpen, setStageOpen] = useState(false) const [builtCount, setBuiltCount] = useState(0) const [fading, setFading] = useState(false) const [cycleId, setCycleId] = useState(0) - const [scale, setScale] = useState(1) - - // Track the rendered region width and scale the design-space layer to fill - // it, keeping the live layer's proportions locked to the window's. - useLayoutEffect(() => { - const el = regionRef.current - if (!el) return - const measure = () => { - const w = el.getBoundingClientRect().width - if (w > 40) setScale(w / DESIGN.width) - } - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() - }, []) - - useEffect(() => { - const media = window.matchMedia('(prefers-reduced-motion: reduce)') - let timers: ReturnType[] = [] - const clearScheduled = () => { - timers.forEach(clearTimeout) - timers = [] - } + const timeline = useMemo(() => buildLoopTimeline(content), [content]) + const blockCount = content.stageBlocks.length - const showFinished = () => { - clearScheduled() - setFading(false) - setPhase('reply') - setStageOpen(true) - setBuiltCount(ENTERPRISE_STAGE_BLOCKS.length) - } - - const runCycle = () => { - setFading(false) - setPhase('idle') - setStageOpen(false) - setBuiltCount(0) - setCycleId((c) => c + 1) - timers = [ - setTimeout(() => setPhase('typing'), LOOP_TIMELINE.typing), - setTimeout(() => setPhase('typed'), LOOP_TIMELINE.typed), - setTimeout(() => setPhase('dispatch'), LOOP_TIMELINE.dispatch), - setTimeout(() => setStageOpen(true), LOOP_TIMELINE.stageOpen), - ...ENTERPRISE_STAGE_BLOCKS.map((_, i) => - setTimeout(() => setBuiltCount(i + 1), LOOP_TIMELINE.buildStart + i * BUILD_STEP_MS) - ), - setTimeout(() => setPhase('reply'), LOOP_TIMELINE.reply), - setTimeout(() => setFading(true), LOOP_TIMELINE.total - RESET_FADE_MS), - setTimeout(runCycle, LOOP_TIMELINE.total), - ] - } - - const syncMotionPreference = () => { - clearScheduled() - if (media.matches) { - showFinished() - return - } - runCycle() - } - - syncMotionPreference() - media.addEventListener('change', syncMotionPreference) - return () => { - media.removeEventListener('change', syncMotionPreference) - clearScheduled() - } - }, []) + useMotionSafeCycle( + { + scheduleCycle: () => { + setFading(false) + setPhase('idle') + setStageOpen(false) + setBuiltCount(0) + setCycleId((c) => c + 1) + return { + timers: [ + setTimeout(() => setPhase('typing'), timeline.typing), + setTimeout(() => setPhase('typed'), timeline.typed), + setTimeout(() => setPhase('dispatch'), timeline.dispatch), + setTimeout(() => setStageOpen(true), timeline.stageOpen), + ...Array.from({ length: blockCount }, (_, i) => + setTimeout(() => setBuiltCount(i + 1), timeline.buildStart + i * BUILD_STEP_MS) + ), + setTimeout(() => setPhase('reply'), timeline.reply), + setTimeout(() => setFading(true), timeline.total - RESET_FADE_MS), + ], + totalMs: timeline.total, + } + }, + showFinished: () => { + setFading(false) + setPhase('reply') + setStageOpen(true) + setBuiltCount(blockCount) + }, + }, + [timeline, blockCount] + ) return ( -
-
- -
-
-
- -
-
-
- -
-
+ +
+
+ +
+
+
+
-
+ ) } diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx index ba70b41c8ab..c77deff231c 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-sidebar.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { ChevronDown, cn, Home, Library } from '@sim/emcn' import { Calendar, @@ -71,6 +72,21 @@ function SectionLabel({ label, actions }: { label: string; actions?: boolean }) ) } +export interface EnterpriseSidebarProps { + /** Workspace name in the header chip. Defaults to the enterprise workspace. */ + workspaceName?: string + /** Recent-chat entries - four fill the design height. Defaults enterprise. */ + chats?: readonly string[] + /** Deployed-workflow entries - five fill the design height. Defaults enterprise. */ + workflows?: readonly string[] + /** + * Workspace-nav row to render active (e.g. `'Tables'`) - the platform pages + * highlight their own module instead of New chat. Unset keeps the enterprise + * default (New chat active). + */ + activeNav?: (typeof WORKSPACE_NAV)[number]['label'] +} + /** * The Brightwave workspace sidebar, rendered live (the homepage loop keeps its * baked-screenshot sidebar; the enterprise loop draws its own so the content @@ -78,8 +94,18 @@ function SectionLabel({ label, actions }: { label: string; actions?: boolean }) * Search / Integrations, a filled-out Chats history, the Workspace nav, a full * Workflows section, and the Help / Settings footer. Purely decorative - * hover/click behavior is owned by the parent's `pointer-events-none` frame. + * The workspace name and the chat / workflow entries are injectable so each + * solutions hero can read like that team's workspace; defaults keep the + * enterprise page exactly as it renders today. Memoized - the sidebar is + * fully static per props, and every consuming loop re-renders on each clock + * tick with stable sidebar props. */ -export function EnterpriseSidebar() { +export const EnterpriseSidebar = memo(function EnterpriseSidebar({ + workspaceName = 'Brightwave', + chats = SIDEBAR_CHATS, + workflows = SIDEBAR_WORKFLOWS, + activeNav, +}: EnterpriseSidebarProps = {}) { return (
{/* Workspace header, matching the real product's WorkspaceHeader chip @@ -99,14 +125,14 @@ export function EnterpriseSidebar() { height={16} className='size-[16px] flex-shrink-0 rounded-sm' /> - Brightwave + {workspaceName}
- +
@@ -114,7 +140,7 @@ export function EnterpriseSidebar() {
- {SIDEBAR_CHATS.map((chat) => ( + {chats.map((chat) => ( ))}
@@ -124,7 +150,12 @@ export function EnterpriseSidebar() {
{WORKSPACE_NAV.map((item) => ( - + ))}
@@ -132,7 +163,7 @@ export function EnterpriseSidebar() {
- {SIDEBAR_WORKFLOWS.map((workflow) => ( + {workflows.map((workflow) => ( ))}
@@ -144,4 +175,4 @@ export function EnterpriseSidebar() {
) -} +}) diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts index 497881d33e5..a0eebb24f2c 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/stage-data.ts @@ -146,6 +146,53 @@ export const ENTERPRISE_STAGE_CANVAS = { width: 560, height: 680 } as const /** Where the main pane is within one loop pass. */ export type EnterpriseLoopPhase = 'idle' | 'typing' | 'typed' | 'dispatch' | 'reply' +/** + * Everything domain-specific the platform loop replays - the sidebar's + * workspace identity, the chat exchange, and the staged workflow. The + * solutions pages supply their own content through this shape; the enterprise + * page renders {@link ENTERPRISE_LOOP_CONTENT} by default, so its hero stays + * byte-for-byte what it was before the loop was parametrized. + */ +export interface EnterpriseLoopContent { + /** Workspace name shown in the sidebar header. */ + workspaceName: string + /** The new-chat greeting, personalized like the real workspace Home. */ + greeting: string + /** Composer placeholder shown before the prompt types out. */ + placeholder: string + /** The prompt the loop types - concise enough to type on screen. */ + prompt: string + /** Sim's reply, streamed word by word once the workflow finishes building. */ + reply: string + /** Recent chats in the sidebar - exactly four fill the design height. */ + sidebarChats: readonly [string, string, string, string] + /** Deployed workflows in the sidebar - exactly five fill the design height. */ + sidebarWorkflows: readonly [string, string, string, string, string] + /** Suggested actions under the composer - one per leading icon. */ + suggestedActions: readonly [string, string, string, string] + /** The workflow the chat "builds" on the stage pane, in build order. */ + stageBlocks: BlockDef[] + /** Source → target pairs among {@link stageBlocks}, drawn in build order. */ + stageEdges: ReadonlyArray + /** Design-space bounding box of the staged block layout. */ + stageCanvas: { width: number; height: number } +} + +/** The enterprise hero's own loop content - the parametrized loop's default. */ +export const ENTERPRISE_LOOP_CONTENT: EnterpriseLoopContent = { + workspaceName: 'Brightwave', + greeting: ENTERPRISE_GREETING, + placeholder: COMPOSER_PLACEHOLDER, + prompt: ENTERPRISE_PROMPT, + reply: ENTERPRISE_REPLY, + sidebarChats: SIDEBAR_CHATS, + sidebarWorkflows: SIDEBAR_WORKFLOWS, + suggestedActions: SUGGESTED_ACTIONS, + stageBlocks: ENTERPRISE_STAGE_BLOCKS, + stageEdges: ENTERPRISE_STAGE_EDGES, + stageCanvas: ENTERPRISE_STAGE_CANVAS, +} + /** The idle new-chat view holds this long before typing starts. */ const IDLE_HOLD_MS = 1400 /** Rest on the fully-typed prompt before "send". */ @@ -160,21 +207,32 @@ export const BUILD_STEP_MS = 620 const REPLY_AFTER_MS = 500 /** The finished scene (reply + built canvas) holds this long. */ const REPLY_HOLD_MS = 4800 -/** Fade-out length before the cycle restarts. */ -export const RESET_FADE_MS = 300 + +/** Derived phase starts for one loop pass. */ +export interface LoopTimeline { + typing: number + typed: number + dispatch: number + stageOpen: number + buildStart: number + reply: number + total: number +} /** - * Derived phase starts. Later stages (tables, files, knowledge base, logs) - * slot in after `reply` by extending {@link EnterpriseLoopPhase} and appending - * starts here. + * Derives the phase starts for a given loop content - the typing beat scales + * with the prompt's length and the build window with the staged block count, + * so every domain's pass keeps the enterprise pacing. Later stages (tables, + * files, knowledge base, logs) slot in after `reply` by extending + * {@link EnterpriseLoopPhase} and appending starts here. */ -export const LOOP_TIMELINE = (() => { +export function buildLoopTimeline(content: EnterpriseLoopContent): LoopTimeline { const typing = IDLE_HOLD_MS - const typed = typing + ENTERPRISE_PROMPT.length * PROMPT_CHAR_MS + const typed = typing + content.prompt.length * PROMPT_CHAR_MS const dispatch = typed + TYPED_HOLD_MS const stageOpen = dispatch + STAGE_OPEN_AFTER_MS const buildStart = stageOpen + BUILD_START_AFTER_MS - const reply = buildStart + (ENTERPRISE_STAGE_BLOCKS.length - 1) * BUILD_STEP_MS + REPLY_AFTER_MS + const reply = buildStart + (content.stageBlocks.length - 1) * BUILD_STEP_MS + REPLY_AFTER_MS const total = reply + REPLY_HOLD_MS - return { typing, typed, dispatch, stageOpen, buildStart, reply, total } as const -})() + return { typing, typed, dispatch, stageOpen, buildStart, reply, total } +} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx index 677de12d91f..a24147065c1 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx @@ -96,11 +96,10 @@ function edgePath(edge: Edge): string { * shrink at narrow tile widths its absolutely-positioned columns (fixed * `left-*` coordinates) would drift right of the box's true midline; * instead it keeps its geometry and overflows both edges equally, so the - * Support → Review axis always sits on the tile's center. On the narrow - * grid bands where the tile drops below the canvas width (small two-column - * screens and the 3-up row just past `lg`) the whole canvas scales down via - * transform — preserving that centering — so the outer team labels are - * never cropped. + * Support → Review axis always sits on the tile's center. Narrow grid + * columns are handled by the feature tile itself, which zooms its whole + * design-space canvas down proportionally (see `SOLUTIONS_VISUAL`), so + * this graphic never needs its own breakpoint scaling. */ export function AccessControlGraphic() { return ( @@ -109,7 +108,7 @@ export function AccessControlGraphic() { aria-hidden='true' className='absolute inset-0 flex items-center justify-center pr-8 max-lg:pr-6' > -
+