diff --git a/NOTICE b/NOTICE
index 11d32f13cd3..cc292af7ec2 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,4 +1,4 @@
Sim Studio
Copyright 2026 Sim Studio
-This product includes software developed for the Sim project.
\ No newline at end of file
+This product includes software developed for the Sim project.
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ec3d66e756a..7d616a3d937 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -50,9 +50,10 @@
"@sim/tsconfig": "workspace:*",
"@types/micromatch": "4.0.10",
"@types/node": "24.2.1",
- "electron": "43.5.0",
+ "electron": "43.4.1",
"electron-builder": "26.15.3",
"esbuild": "0.28.1",
+ "jsdom": "^26.0.0",
"typescript": "^7.0.2",
"vitest": "^4.1.0"
}
diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css
index 4e493df5ce9..aa2e6f740c8 100644
--- a/apps/docs/app/global.css
+++ b/apps/docs/app/global.css
@@ -54,8 +54,9 @@ body {
nominally references; loading a webfont here would make docs the odd one out, not the
aligned one. If the app ever wires that font up for real, add the var back in both
places at once. */
- --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
- "Courier New", monospace;
+ --font-mono:
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
+ monospace;
}
/* Pure white light mode background */
@@ -243,14 +244,16 @@ body {
/* Font family utilities */
.font-sans {
- font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-family:
+ var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Platform UI font — Season Sans, used by the chip chrome to match the main app */
.font-season {
- font-family: var(--font-season), system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial,
- "Noto Sans", sans-serif;
+ font-family:
+ var(--font-season), system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans",
+ sans-serif;
}
:root {
@@ -445,8 +448,9 @@ html #nd-sidebar button:not([aria-label*="ollapse"]):not([aria-label*="xpand"])
padding: 5px 0.5rem !important; /* 30px tall overall — the app's chip pill, at its px-2 */
font-weight: 400 !important;
border-radius: 0.5rem !important; /* platform rounded-lg */
- font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
+ font-family:
+ var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
/* Sidebar text — platform --text-body */
@@ -904,8 +908,9 @@ video {
#nd-page:has(.api-page-header) div:not(.font-mono),
#nd-page:has(.api-page-header) label:not(.font-mono),
#nd-page:has(.api-page-header) button:not(.font-mono) {
- font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
- "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ font-family:
+ var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Method badge pills — shared background colors (page + sidebar) */
diff --git a/apps/docs/components/workflow-preview/block-preview.tsx b/apps/docs/components/workflow-preview/block-preview.tsx
index 42a8c9c3aba..eeeb9568267 100644
--- a/apps/docs/components/workflow-preview/block-preview.tsx
+++ b/apps/docs/components/workflow-preview/block-preview.tsx
@@ -1,15 +1,15 @@
'use client'
import { useMemo } from 'react'
+import { type NodeTypes, ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { domAnimation, LazyMotion } from 'framer-motion'
-import ReactFlow, { type NodeTypes, ReactFlowProvider } from 'reactflow'
-import 'reactflow/dist/style.css'
+import '@xyflow/react/dist/style.css'
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
import { toReactFlowElements } from '@/components/workflow-preview/workflow-data'
/** The hero mounts the same node type the canvas uses, so it can never drift. */
-const NODE_TYPES: NodeTypes = { previewBlock: DocsBlockNode }
+const NODE_TYPES = { previewBlock: DocsBlockNode } satisfies NodeTypes
const PRO_OPTIONS = { hideAttribution: true }
/** `maxZoom` mirrors the previous hand-rolled hero's 1.3 scale. */
const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1.3 } as const
diff --git a/apps/docs/components/workflow-preview/docs-block-node.tsx b/apps/docs/components/workflow-preview/docs-block-node.tsx
index fac9b1e2660..70ea69da784 100644
--- a/apps/docs/components/workflow-preview/docs-block-node.tsx
+++ b/apps/docs/components/workflow-preview/docs-block-node.tsx
@@ -2,8 +2,8 @@
import { type ComponentType, memo } from 'react'
import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
+import type { Node, NodeProps } from '@xyflow/react'
import { m } from 'framer-motion'
-import type { NodeProps } from 'reactflow'
import { resolveIcon } from '@/components/workflow-preview/block-icons'
import {
BLOCK_STAGGER,
@@ -16,7 +16,7 @@ const EMPTY_ICON: ComponentType<{ className?: string }> = () => null
const RING_STYLES = 'ring-[1.75px] ring-[var(--brand-secondary)]'
-interface DocsBlockData {
+export interface DocsBlockData extends Record {
name: string
blockType: string
bgColor: string
@@ -30,6 +30,8 @@ interface DocsBlockData {
isDimmed?: boolean
}
+export type DocsBlockNodeType = Node
+
/**
* Docs adapter for workflow block nodes: maps the static preview data to the
* shared {@link WorkflowBlockView}'s props. Carries no stores, hooks, or
@@ -38,7 +40,10 @@ interface DocsBlockData {
* `WorkflowPreview` provides the `LazyMotion` feature set). The block's ring is
* driven by `hasRing`/`ringStyles` inside the View.
*/
-export const DocsBlockNode = memo(function DocsBlockNode({ id, data }: NodeProps) {
+export const DocsBlockNode = memo(function DocsBlockNode({
+ id,
+ data,
+}: NodeProps) {
const {
name,
blockType,
diff --git a/apps/docs/components/workflow-preview/docs-container-node.tsx b/apps/docs/components/workflow-preview/docs-container-node.tsx
index b0d27d22ed4..1619b099ca8 100644
--- a/apps/docs/components/workflow-preview/docs-container-node.tsx
+++ b/apps/docs/components/workflow-preview/docs-container-node.tsx
@@ -2,15 +2,17 @@
import { memo } from 'react'
import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
-import type { NodeProps } from 'reactflow'
+import type { Node, NodeProps } from '@xyflow/react'
-interface DocsContainerData {
+export interface DocsContainerData extends Record {
name: string
blockType: string
size?: { width: number; height: number }
parentId?: string
}
+export type DocsContainerNodeType = Node
+
/**
* Docs adapter for loop/parallel container blocks: maps the static preview data
* to {@link SubflowNodeView}'s read-only `isPreview` shape. Carries no stores,
@@ -19,7 +21,7 @@ interface DocsContainerData {
export const DocsContainerNode = memo(function DocsContainerNode({
id,
data,
-}: NodeProps) {
+}: NodeProps) {
const subflowData: SubflowNodeData = {
kind: data.blockType === 'parallel' ? 'parallel' : 'loop',
name: data.name,
diff --git a/apps/docs/components/workflow-preview/workflow-data.ts b/apps/docs/components/workflow-preview/workflow-data.ts
index 4148fccaa70..346ef7fc4c2 100644
--- a/apps/docs/components/workflow-preview/workflow-data.ts
+++ b/apps/docs/components/workflow-preview/workflow-data.ts
@@ -4,7 +4,15 @@ import {
getEdgeZIndex,
getEdgeZIndexForTarget,
} from '@sim/workflow-renderer'
-import { type Edge, type Node, Position } from 'reactflow'
+import { type Edge, Position } from '@xyflow/react'
+import type {
+ DocsBlockData,
+ DocsBlockNodeType,
+} from '@/components/workflow-preview/docs-block-node'
+import type {
+ DocsContainerData,
+ DocsContainerNodeType,
+} from '@/components/workflow-preview/docs-container-node'
/**
* Tool entry displayed as a chip on a block (e.g. an Agent's attached tools).
@@ -51,6 +59,15 @@ export interface PreviewWorkflow {
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string }>
}
+export type PreviewNode = DocsBlockNodeType | DocsContainerNodeType
+
+export interface PreviewEdgeData extends Record {
+ animate: boolean
+ delay: number
+}
+
+export type PreviewFlowEdge = Edge
+
export const BLOCK_STAGGER = 0.12
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
@@ -96,14 +113,14 @@ export function toReactFlowElements(
workflow: PreviewWorkflow,
animate = false,
highlight: HighlightOptions = {}
-): { nodes: Node[]; edges: Edge[] } {
+): { nodes: PreviewNode[]; edges: PreviewFlowEdge[] } {
const { highlightBlock, highlightEdge, selectedBlock } = highlight
const hasHighlight = Boolean(highlightBlock || highlightEdge)
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
const blocksById = new Map(workflow.blocks.map((b) => [b.id, b]))
- const nodes: Node[] = workflow.blocks.map((block, index) => {
+ const nodes: PreviewNode[] = workflow.blocks.map((block, index) => {
const isContainer = Boolean(block.size)
const nestingDepth = getNestingDepth(block, blocksById)
// Nested blocks are authored relative to their container; render them at
@@ -113,13 +130,20 @@ export function toReactFlowElements(
const position = parent
? { x: parent.position.x + block.position.x, y: parent.position.y + block.position.y }
: block.position
- return {
+ const commonNode = {
id: block.id,
- type: isContainer ? 'previewContainer' : 'previewBlock',
position,
zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE,
...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}),
- data: {
+ draggable: true,
+ selectable: false,
+ connectable: false,
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+ }
+
+ if (isContainer) {
+ const data: DocsContainerData = {
name: block.name,
blockType: block.type,
bgColor: block.bgColor,
@@ -133,16 +157,37 @@ export function toReactFlowElements(
animate,
isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
isDimmed: hasHighlight && highlightBlock !== block.id,
- },
- draggable: true,
- selectable: false,
- connectable: false,
- sourcePosition: Position.Right,
- targetPosition: Position.Left,
+ }
+ return {
+ ...commonNode,
+ type: 'previewContainer',
+ data,
+ }
+ }
+
+ const data: DocsBlockData = {
+ name: block.name,
+ blockType: block.type,
+ bgColor: block.bgColor,
+ rows: block.rows,
+ branches: block.branches,
+ tools: block.tools,
+ hideTargetHandle: block.hideTargetHandle,
+ size: block.size,
+ parentId: block.parentId,
+ index,
+ animate,
+ isHighlighted: highlightBlock === block.id || selectedBlock === block.id,
+ isDimmed: hasHighlight && highlightBlock !== block.id,
+ }
+ return {
+ ...commonNode,
+ type: 'previewBlock',
+ data,
}
})
- const edges: Edge[] = workflow.edges.map((e) => {
+ const edges: PreviewFlowEdge[] = workflow.edges.map((e) => {
const sourceIndex = blockIndexMap.get(e.source) ?? 0
const isEdgeHighlight = highlightEdge === e.id
const dimmed = hasHighlight && !isEdgeHighlight
diff --git a/apps/docs/components/workflow-preview/workflow-preview.tsx b/apps/docs/components/workflow-preview/workflow-preview.tsx
index d87a60dabe8..19404e81314 100644
--- a/apps/docs/components/workflow-preview/workflow-preview.tsx
+++ b/apps/docs/components/workflow-preview/workflow-preview.tsx
@@ -2,21 +2,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Expand, X } from '@sim/emcn/icons'
-import { domAnimation, LazyMotion, m } from 'framer-motion'
-import ReactFlow, {
+import {
applyEdgeChanges,
applyNodeChanges,
- type Edge,
type EdgeProps,
type EdgeTypes,
getSmoothStepPath,
- type Node,
type NodeTypes,
type OnEdgesChange,
type OnNodesChange,
+ ReactFlow,
ReactFlowProvider,
-} from 'reactflow'
-import 'reactflow/dist/style.css'
+} from '@xyflow/react'
+import { domAnimation, LazyMotion, m } from 'framer-motion'
+import '@xyflow/react/dist/style.css'
import { BLOCK_DISPLAY_WORKFLOWS } from '@/components/workflow-preview/block-display-workflows'
import { BlockInspector } from '@/components/workflow-preview/block-inspector'
import { DocsBlockNode } from '@/components/workflow-preview/docs-block-node'
@@ -24,6 +23,8 @@ import { DocsContainerNode } from '@/components/workflow-preview/docs-container-
import {
EASE_OUT,
type PreviewBlock,
+ type PreviewFlowEdge,
+ type PreviewNode,
type PreviewWorkflow,
toReactFlowElements,
} from '@/components/workflow-preview/workflow-data'
@@ -50,7 +51,7 @@ function PreviewEdge({
targetPosition,
style,
data,
-}: EdgeProps) {
+}: EdgeProps) {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
@@ -89,11 +90,11 @@ function PreviewEdge({
)
}
-const NODE_TYPES: NodeTypes = {
+const NODE_TYPES = {
previewBlock: DocsBlockNode,
previewContainer: DocsContainerNode,
-}
-const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
+} satisfies NodeTypes
+const EDGE_TYPES = { previewEdge: PreviewEdge } satisfies EdgeTypes
const PRO_OPTIONS = { hideAttribution: true }
const FIT_VIEW_OPTIONS = { padding: 0.25, maxZoom: 1 } as const
const LIGHTBOX_FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1.4 } as const
@@ -176,8 +177,8 @@ function PreviewFlow({
[workflow, animate, highlightBlock, highlightEdge, selectedBlock]
)
- const [nodes, setNodes] = useState(initialNodes)
- const [edges, setEdges] = useState(initialEdges)
+ const [nodes, setNodes] = useState(initialNodes)
+ const [edges, setEdges] = useState(initialEdges)
/**
* Apply data changes (highlight/selection) without discarding positions the
@@ -194,17 +195,17 @@ function PreviewFlow({
setEdges(initialEdges)
}, [initialNodes, initialEdges])
- const onNodesChange: OnNodesChange = useCallback(
+ const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[]
)
- const onEdgesChange: OnEdgesChange = useCallback(
+ const onEdgesChange: OnEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[]
)
return (
-
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 700fdfecbc6..9cbd8b31d9b 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -21,6 +21,7 @@
"@sim/db": "workspace:*",
"@sim/emcn": "workspace:*",
"@sim/workflow-renderer": "workspace:*",
+ "@xyflow/react": "12.11.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
@@ -35,7 +36,6 @@
"remark-breaks": "^4.0.0",
"shiki": "4.3.1",
"tailwind-merge": "^3.0.2",
- "reactflow": "^11.11.4",
"framer-motion": "^12.5.0",
"zod": "4.3.6"
},
diff --git a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
index 9c91f96f92c..561dc9ec207 100644
--- a/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
+++ b/apps/sim/app/(landing)/demo/components/demo-booking/demo-booking.tsx
@@ -5,20 +5,21 @@ import { chipBorderShadowRing, cn } from '@sim/emcn'
import dynamic from 'next/dynamic'
import { preconnect } from 'react-dom'
import { DemoForm, type DemoLead } from '@/app/(landing)/demo/components/demo-form'
+import { CAL_ORIGIN } from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
import { applyLegacyInertFallback } from '@/app/(landing)/demo/components/legacy-inert-fallback'
const importScheduler = () => import('@/app/(landing)/demo/components/demo-scheduler')
/**
* Warm the entire booking path while the visitor fills the form: preconnect to
- * app.cal.com, then load the scheduler chunk, Cal.com's embed.js, and the
+ * the configured Cal origin, then load the scheduler chunk and the
* booker iframe assets (via the embed's `preload` instruction). Fired on first
* form focus so nothing Cal.com-related competes with initial page load — the
* connection handshake overlaps the chunk import, and it all finishes long
* before the visitor submits.
*/
function preloadScheduler() {
- preconnect('https://app.cal.com')
+ preconnect(CAL_ORIGIN)
return importScheduler().then((m) => m.preloadCalEmbed())
}
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts b/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts
new file mode 100644
index 00000000000..666ae445cc0
--- /dev/null
+++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/cal-config.ts
@@ -0,0 +1,31 @@
+const DEFAULT_CAL_ORIGIN = 'https://app.cal.com'
+const DEFAULT_CAL_LINK = 'team/sim/demo'
+
+/** Resolves a hosted or self-hosted Cal event link and rejects non-HTTP embed targets. */
+export function resolveCalLink(configuredLink?: string): URL {
+ const link = configuredLink?.trim() || DEFAULT_CAL_LINK
+ let url: URL
+
+ try {
+ url = new URL(link)
+ } catch {
+ url = new URL(link.replace(/^\/+/, ''), `${DEFAULT_CAL_ORIGIN}/`)
+ }
+
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
+ throw new Error('NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path')
+ }
+
+ url.hash = ''
+ return url
+}
+
+const calLinkUrl = resolveCalLink(process.env.NEXT_PUBLIC_CAL_LINK)
+
+/** Exact origin used for iframe navigation, preconnect, and postMessage validation. */
+export const CAL_ORIGIN = calLinkUrl.origin
+
+/** Returns a fresh URL so callers can safely add embed-specific paths and parameters. */
+export function createConfiguredCalUrl(): URL {
+ return new URL(calLinkUrl)
+}
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx
new file mode 100644
index 00000000000..4258a1fb2c3
--- /dev/null
+++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.test.tsx
@@ -0,0 +1,173 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockConsent, mockTrackGoogleEvent } = vi.hoisted(() => ({
+ mockConsent: { marketing: true, measurement: true },
+ mockTrackGoogleEvent: vi.fn(),
+}))
+
+vi.mock('@/lib/analytics/google', () => ({ trackGoogleEvent: mockTrackGoogleEvent }))
+vi.mock('@/lib/consent/scripts', () => ({ X_DEMO_BOOKED_EVENT_ID: 'demo-booked' }))
+vi.mock('@/lib/consent/tracking-consent', () => ({
+ useTrackingConsent: () => mockConsent,
+}))
+
+import { resolveCalLink } from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
+import {
+ createCalEmbedUrl,
+ DemoScheduler,
+ preloadCalEmbed,
+} from '@/app/(landing)/demo/components/demo-scheduler/demo-scheduler'
+
+const LEAD = {
+ name: 'Ada Lovelace',
+ email: 'ada@example.com',
+ notes: 'Company: Analytical Engines\nTopic: Demo',
+}
+
+describe('DemoScheduler', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ mockConsent.marketing = true
+ mockConsent.measurement = true
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.querySelectorAll('iframe[hidden]').forEach((frame) => {
+ frame.remove()
+ })
+ window.twq = undefined
+ })
+
+ function renderScheduler(): HTMLIFrameElement {
+ act(() => root.render())
+ const frame = container.querySelector('iframe[title="Book a demo"]')
+ if (!frame) throw new Error('Expected the Cal booking iframe to render')
+ return frame
+ }
+
+ it('builds the hosted embed URL with the lead and presentation prefilled', () => {
+ const url = new URL(createCalEmbedUrl(LEAD))
+
+ expect(url.origin).toBe('https://app.cal.com')
+ expect(url.pathname).toBe('/team/sim/demo/embed')
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ embed: 'demo',
+ name: LEAD.name,
+ email: LEAD.email,
+ notes: LEAD.notes,
+ theme: 'light',
+ 'ui.color-scheme': 'light',
+ layout: 'month_view',
+ useSlotsViewOnSmallScreen: 'true',
+ })
+ })
+
+ it('derives the trusted origin from a self-hosted event URL', () => {
+ const url = resolveCalLink('https://calendar.example.com/team/sim/demo')
+
+ expect(url.origin).toBe('https://calendar.example.com')
+ expect(url.pathname).toBe('/team/sim/demo')
+ })
+
+ it('rejects unsafe Cal embed protocols and credential-bearing URLs', () => {
+ expect(() => resolveCalLink('javascript:alert(1)')).toThrow(
+ 'NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path'
+ )
+ expect(() => resolveCalLink('https://user:secret@calendar.example.com/demo')).toThrow(
+ 'NEXT_PUBLIC_CAL_LINK must be an HTTP(S) URL or a Cal.com event path'
+ )
+ })
+
+ it('warms the hosted booker only once while the preload frame remains mounted', () => {
+ preloadCalEmbed()
+ preloadCalEmbed()
+
+ const frames = document.querySelectorAll('iframe[hidden]')
+ expect(frames).toHaveLength(1)
+ expect(frames[0].src).toBe('https://app.cal.com/team/sim/demo?preload=true')
+ })
+
+ it('tracks a booking only when the message comes from the rendered Cal iframe', () => {
+ const trackXEvent = vi.fn()
+ window.twq = trackXEvent
+ const frame = renderScheduler()
+ const frameWindow = frame.contentWindow
+ expect(frameWindow).not.toBeNull()
+
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'https://malicious.example',
+ source: frameWindow,
+ data: { fullType: 'CAL:demo:bookingSuccessfulV2' },
+ })
+ )
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'https://app.cal.com',
+ source: frameWindow,
+ data: { fullType: 'CAL:demo:bookingSuccessfulV2' },
+ })
+ )
+ })
+
+ expect(mockTrackGoogleEvent).toHaveBeenCalledOnce()
+ expect(mockTrackGoogleEvent).toHaveBeenCalledWith('get_a_demo', {
+ page_path: '/demo',
+ form_name: 'sim_demo',
+ booking_status: 'scheduled',
+ })
+ expect(trackXEvent).toHaveBeenCalledOnce()
+ expect(trackXEvent).toHaveBeenCalledWith('event', 'demo-booked', {})
+ })
+
+ it("completes Cal's ready handshake and reapplies the branded UI settings", () => {
+ const frame = renderScheduler()
+ const frameWindow = frame.contentWindow
+ expect(frameWindow).not.toBeNull()
+ if (!frameWindow) return
+ const postMessage = vi.spyOn(frameWindow, 'postMessage')
+
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ origin: 'https://app.cal.com',
+ source: frameWindow,
+ data: { fullType: 'CAL:demo:__iframeReady' },
+ })
+ )
+ })
+
+ expect(postMessage).toHaveBeenNthCalledWith(
+ 1,
+ { originator: 'CAL', method: 'parentKnowsIframeReady' },
+ 'https://app.cal.com'
+ )
+ expect(postMessage).toHaveBeenNthCalledWith(
+ 2,
+ {
+ originator: 'CAL',
+ method: 'ui',
+ arg: {
+ hideEventTypeDetails: true,
+ styles: { branding: { brandColor: '#6f3dfa' } },
+ },
+ },
+ 'https://app.cal.com'
+ )
+ })
+})
diff --git a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
index c09e1a266f2..4a89825c100 100644
--- a/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
+++ b/apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx
@@ -1,67 +1,126 @@
'use client'
-import { useEffect } from 'react'
-import Cal, { getCalApi } from '@calcom/embed-react'
+import { useEffect, useRef } from 'react'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
+import {
+ CAL_ORIGIN,
+ createConfiguredCalUrl,
+} from '@/app/(landing)/demo/components/demo-scheduler/cal-config'
-/** The Cal.com event the demo books - set `NEXT_PUBLIC_CAL_LINK` to override. */
const CAL_NAMESPACE = 'demo'
-const CAL_LINK = process.env.NEXT_PUBLIC_CAL_LINK ?? 'team/sim/demo'
-/**
- * Sim's brand color, matching the `--brand-agent` token. The embed renders in a
- * cross-origin iframe, so it can't read our CSS vars - it needs the literal hex.
- */
+/** Sim's brand color, matching the `--brand-agent` token. */
const CAL_BRAND_COLOR = '#6f3dfa'
+const CAL_IFRAME_READY_EVENT = `CAL:${CAL_NAMESPACE}:__iframeReady`
+const CAL_BOOKING_SUCCESS_EVENT = `CAL:${CAL_NAMESPACE}:bookingSuccessfulV2`
+
interface DemoSchedulerProps {
/** The captured lead used to prefill the Cal.com booking. */
lead: DemoLead
}
-let calEmbedPreloaded = false
+interface CalMessage {
+ fullType: string
+}
+
+let calPreloadFrame: HTMLIFrameElement | null = null
+
+function isCalMessage(data: unknown): data is CalMessage {
+ if (!data || typeof data !== 'object') return false
+ return typeof Reflect.get(data, 'fullType') === 'string'
+}
/**
- * Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
- * issues the embed's `preload` instruction, which fetches the booker in a
- * hidden `?preload=true` iframe so its assets are already cached when the real
- * embed renders on submit. Without this, nothing Cal.com-related starts
- * downloading until the visitor presses Continue, which is why the calendar
- * used to take several seconds to appear. Idempotent — repeat calls no-op
- * while a warm-up is in flight or done, but a failed embed.js load resets the
- * flag so a later focus can retry.
+ * Creates the same hosted booker URL the former Cal React wrapper generated.
+ * Query parameters keep the lead prefill and light, month-view presentation.
+ */
+export function createCalEmbedUrl(lead: DemoLead): string {
+ const url = createConfiguredCalUrl()
+ const normalizedPath = url.pathname.replace(/\/+$/, '')
+ url.pathname = normalizedPath.endsWith('/embed') ? normalizedPath : `${normalizedPath}/embed`
+ url.searchParams.set('embed', CAL_NAMESPACE)
+ url.searchParams.set('name', lead.name)
+ url.searchParams.set('email', lead.email)
+ url.searchParams.set('notes', lead.notes)
+ url.searchParams.set('theme', 'light')
+ url.searchParams.set('ui.color-scheme', 'light')
+ url.searchParams.set('layout', 'month_view')
+ url.searchParams.set('useSlotsViewOnSmallScreen', 'true')
+ return url.toString()
+}
+
+function createCalPreloadUrl(): string {
+ const url = createConfiguredCalUrl()
+ url.searchParams.set('preload', 'true')
+ return url.toString()
+}
+
+/**
+ * Warms Cal.com's booker in a hidden hosted iframe on first form focus. The
+ * frame remains mounted so its browser cache and connection stay available to
+ * the visible scheduler. Repeat calls are idempotent; a failed navigation can
+ * be retried by a later focus.
*/
export function preloadCalEmbed(): void {
- if (calEmbedPreloaded) return
- calEmbedPreloaded = true
- getCalApi({ namespace: CAL_NAMESPACE })
- .then((cal) => {
- cal('preload', { calLink: CAL_LINK })
- })
- .catch(() => {
- calEmbedPreloaded = false
- })
+ if (typeof document === 'undefined' || !document.body || calPreloadFrame?.isConnected) return
+
+ const frame = document.createElement('iframe')
+ calPreloadFrame = frame
+ frame.src = createCalPreloadUrl()
+ frame.hidden = true
+ frame.tabIndex = -1
+ frame.setAttribute('aria-hidden', 'true')
+ frame.addEventListener(
+ 'error',
+ () => {
+ frame.remove()
+ if (calPreloadFrame === frame) calPreloadFrame = null
+ },
+ { once: true }
+ )
+ document.body.append(frame)
}
/**
- * Step 2 of the booking card - the Cal.com scheduler, prefilled from the form's
- * {@link DemoLead}. Rendered inside the card chrome owned by {@link DemoBooking}
- * and lazy-loaded, so the embed script never touches the initial landing bundle.
+ * Step 2 of the booking card - the hosted Cal.com scheduler, prefilled from the
+ * form's {@link DemoLead}. It uses Cal's public iframe protocol directly, which
+ * keeps the same booker while avoiding a client SDK in the landing bundle.
*
- * The embed is pinned to the page's light theme and Sim's brand color, and the
- * captured name/email/notes prefill the booking so the visitor never retypes. It
- * fills the panel (`flex-1`), which the parent sizes to the form's height, so the
- * card stays the same height across the form→calendar transition.
+ * The ready handshake applies the prior light theme, hidden event details, and
+ * brand color. Booking-success messages are accepted only from this iframe and
+ * Cal's expected origin before consent-aware analytics fire.
*/
export function DemoScheduler({ lead }: DemoSchedulerProps) {
const { marketing, measurement } = useTrackingConsent()
+ const frameRef = useRef(null)
useEffect(() => {
- let cancelled = false
- const trackDemoBooked = () => {
+ const handleMessage = (event: MessageEvent) => {
+ const frameWindow = frameRef.current?.contentWindow
+ if (event.origin !== CAL_ORIGIN || !frameWindow || event.source !== frameWindow) return
+ if (!isCalMessage(event.data)) return
+
+ if (event.data.fullType === CAL_IFRAME_READY_EVENT) {
+ frameWindow.postMessage({ originator: 'CAL', method: 'parentKnowsIframeReady' }, CAL_ORIGIN)
+ frameWindow.postMessage(
+ {
+ originator: 'CAL',
+ method: 'ui',
+ arg: {
+ hideEventTypeDetails: true,
+ styles: { branding: { brandColor: CAL_BRAND_COLOR } },
+ },
+ },
+ CAL_ORIGIN
+ )
+ return
+ }
+
+ if (event.data.fullType !== CAL_BOOKING_SUCCESS_EVENT) return
if (measurement) {
trackGoogleEvent('get_a_demo', {
page_path: '/demo',
@@ -71,26 +130,9 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
}
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
}
- const api = getCalApi({ namespace: CAL_NAMESPACE })
- api
- .then((cal) => {
- if (cancelled) return
- cal('ui', {
- hideEventTypeDetails: true,
- styles: { branding: { brandColor: CAL_BRAND_COLOR } },
- })
- if (measurement || marketing) {
- cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
- }
- })
- .catch(() => {})
- return () => {
- cancelled = true
- if (!measurement && !marketing) return
- api
- .then((cal) => cal('off', { action: 'bookingSuccessfulV2', callback: trackDemoBooked }))
- .catch(() => {})
- }
+
+ window.addEventListener('message', handleMessage)
+ return () => window.removeEventListener('message', handleMessage)
}, [marketing, measurement])
return (
@@ -102,19 +144,12 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
Choose a slot that works for your team and we'll send a calendar invite.
-
diff --git a/apps/sim/app/_shell/public-env-script.test.tsx b/apps/sim/app/_shell/public-env-script.test.tsx
index efe90cb73d1..96262f09916 100644
--- a/apps/sim/app/_shell/public-env-script.test.tsx
+++ b/apps/sim/app/_shell/public-env-script.test.tsx
@@ -4,10 +4,19 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
-import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
+import {
+ PublicEnvScript,
+ publicEnvHtmlAttributes,
+ RuntimePublicEnvScript,
+ serializePublicEnv,
+} from '@/app/_shell/public-env-script'
vi.unmock('@/lib/core/config/env')
+const { mockConnection } = vi.hoisted(() => ({ mockConnection: vi.fn() }))
+
+vi.mock('next/server', () => ({ connection: mockConnection }))
+
/**
* Guards the one property that matters: the emitted tag assigns `window.__ENV`
* itself. Next's `beforeInteractive` strategy instead pushes the assignment onto
@@ -29,11 +38,35 @@ describe('PublicEnvScript', () => {
expect(markup).not.toContain('__next_s')
})
+ it('cannot be terminated by a script-like public value', () => {
+ const serialized = serializePublicEnv({
+ NEXT_PUBLIC_SCRIPT_ESCAPE_TEST: '',
+ })
+
+ expect(serialized).not.toContain('')
+ expect(serialized).toContain('\\u003c/script>')
+ })
+
it('passes only NEXT_PUBLIC_ variables through to the browser', () => {
const keys = Object.keys(PublicEnvScript().props.env)
expect(keys.every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
})
+
+ it('reads self-hosted values at request time after opting into dynamic rendering', async () => {
+ const previous = process.env.NEXT_PUBLIC_RUNTIME_ENV_TEST
+ process.env.NEXT_PUBLIC_RUNTIME_ENV_TEST = 'runtime-value'
+
+ try {
+ const script = await RuntimePublicEnvScript()
+ expect(mockConnection).toHaveBeenCalledOnce()
+ expect(script.props.env.NEXT_PUBLIC_RUNTIME_ENV_TEST).toBe('runtime-value')
+ } finally {
+ if (previous === undefined)
+ Reflect.deleteProperty(process.env, 'NEXT_PUBLIC_RUNTIME_ENV_TEST')
+ else process.env.NEXT_PUBLIC_RUNTIME_ENV_TEST = previous
+ }
+ })
})
/**
diff --git a/apps/sim/app/_shell/public-env-script.tsx b/apps/sim/app/_shell/public-env-script.tsx
index a1a97765c7a..ccc39d0f30c 100644
--- a/apps/sim/app/_shell/public-env-script.tsx
+++ b/apps/sim/app/_shell/public-env-script.tsx
@@ -1,9 +1,8 @@
-import { EnvScript } from 'next-runtime-env'
+import { connection } from 'next/server'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
/**
- * Every `NEXT_PUBLIC_*` value currently in `process.env`. Filter matches
- * `next-runtime-env`'s own `getPublicEnv()` exactly.
+ * Every `NEXT_PUBLIC_*` value currently in `process.env`.
*/
function readPublicEnv(): Record {
return Object.fromEntries(
@@ -36,33 +35,44 @@ const HOSTED_PUBLIC_ENV = readPublicEnv()
*
* Read fresh rather than from {@link HOSTED_PUBLIC_ENV} so the one helper serves
* both deployment modes: self-hosted images re-inject env per deploy without a
- * rebuild, and `next-runtime-env`'s script reads `process.env` per request for
- * exactly that reason. On hosted the two reads are the same values, because
- * nothing mutates `process.env` after boot.
+ * rebuild. On hosted the two reads are the same values, because nothing mutates
+ * `process.env` after boot.
*/
export function publicEnvHtmlAttributes(): Record {
return { [PUBLIC_ENV_ATTRIBUTE]: JSON.stringify(readPublicEnv()) }
}
/**
- * Static equivalent of `next-runtime-env`'s `` for the hosted
- * deployment. It renders the library's own ``, so the emitted markup
- * is identical to the self-hosted path - only the env read differs.
- * `` additionally calls `unstable_noStore()`, which opts the
- * entire app into dynamic rendering; that only pays off for self-hosted Docker
- * images that re-inject env per deploy without a rebuild, so hosted reads the
- * env once here and stays static.
- *
- * `disableNextScript` is load-bearing. Without it, `` defaults to
- * Next's `