Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add product tour
  • Loading branch information
adithyaakrishna authored and waleedlatif1 committed Mar 24, 2026
commit 68751f2eeebda5b45f1e76f8b3f3bdd728799431
9 changes: 9 additions & 0 deletions apps/sim/app/_styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -920,3 +920,12 @@ input[type="search"]::-ms-clear {
.react-flow__node[data-parent-node-id] .react-flow__handle {
z-index: 30;
}

.__floater__arrow > span > svg {
fill: #30d158 !important;
}

.__floater__arrow > span > svg > path {
fill: #30d158 !important;
}

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ProductTour, resetTourCompletion, START_TOUR_EVENT } from './product-tour'
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import dynamic from 'next/dynamic'
import { ACTIONS, type CallBackProps, EVENTS, STATUS } from 'react-joyride'
import { tourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/tour-steps'
import { TourTooltip } from '@/app/workspace/[workspaceId]/components/product-tour/tour-tooltip'

const logger = createLogger('ProductTour')

const Joyride = dynamic(() => import('react-joyride'), {
ssr: false,
})

const TOUR_STORAGE_KEY = 'sim-tour-completed-v1'
export const START_TOUR_EVENT = 'start-product-tour'

function isTourCompleted(): boolean {
try {
return localStorage.getItem(TOUR_STORAGE_KEY) === 'true'
} catch {
return false
}
}

function markTourCompleted(): void {
try {
localStorage.setItem(TOUR_STORAGE_KEY, 'true')
} catch {
logger.warn('Failed to persist tour completion to localStorage')
}
}

export function resetTourCompletion(): void {
try {
localStorage.removeItem(TOUR_STORAGE_KEY)
} catch {
logger.warn('Failed to reset tour completion in localStorage')
}
}

export function ProductTour() {
const [run, setRun] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
const [tourKey, setTourKey] = useState(0)

const hasAutoStarted = useRef(false)

useEffect(() => {
if (hasAutoStarted.current) return
hasAutoStarted.current = true

const timer = setTimeout(() => {
if (!isTourCompleted()) {
setStepIndex(0)
setRun(true)
logger.info('Auto-starting product tour for first-time user')
}
}, 1200)

return () => clearTimeout(timer)
}, [])

useEffect(() => {
const handleStartTour = () => {
setRun(false)
resetTourCompletion()

setTourKey((k) => k + 1)
setTimeout(() => {
setStepIndex(0)
setRun(true)
logger.info('Product tour triggered via custom event')
}, 50)
}

window.addEventListener(START_TOUR_EVENT, handleStartTour)
return () => window.removeEventListener(START_TOUR_EVENT, handleStartTour)
}, [])

const stopTour = useCallback(() => {
setRun(false)
markTourCompleted()
}, [])

const handleCallback = useCallback(
(data: CallBackProps) => {
const { action, index, status, type } = data

if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
stopTour()
logger.info('Product tour ended', { status })
return
}

if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
if (action === ACTIONS.CLOSE) {
stopTour()
logger.info('Product tour closed by user')
return
}

const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)

if (type === EVENTS.TARGET_NOT_FOUND) {
logger.info('Tour step target not found, skipping', {
stepIndex: index,
target: tourSteps[index]?.target,
})
}

if (nextIndex >= tourSteps.length || nextIndex < 0) {
stopTour()
return
}

setStepIndex(nextIndex)
}
},
[stopTour]
)

return (
<Joyride
key={tourKey}
steps={tourSteps}
run={run}
stepIndex={stepIndex}
callback={handleCallback}
continuous
showSkipButton
showProgress
disableScrolling
disableOverlayClose
spotlightPadding={6}
tooltipComponent={TourTooltip}
floaterProps={{
disableAnimation: true,
styles: {
floater: {
filter: 'none',
},
},
}}
styles={{
options: {
zIndex: 10000,
overlayColor: 'rgba(0, 0, 0, 0.5)',
},
spotlight: {
backgroundColor: 'transparent',
border: '1.5px solid rgba(255, 255, 255, 0.15)',
borderRadius: 8,
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
},
overlay: {
backgroundColor: 'transparent',
mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
},
}}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Step } from 'react-joyride'

export const tourSteps: Step[] = [
{
target: '[data-tour="home-greeting"]',
title: 'Welcome to Sim',
content:
'This is your home base. From here you can describe what you want to build in plain language, or pick a template to get started.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tour="home-chat-input"]',
title: 'Describe your workflow',
content:
'Type what you want to automate — like "monitor my inbox and summarize new emails." Sim will build an AI workflow for you.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tour="home-templates"]',
title: 'Start from a template',
content:
'Or pick one of these pre-built templates to ship your agent in minutes. Click any card to get started.',
placement: 'top',
disableBeacon: true,
},
{
target: '.sidebar-container',
title: 'Sidebar navigation',
content:
'Access everything from here — workflows, tables, files, knowledge base, and logs. This stays with you across all pages.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-item-id="search"]',
title: 'Search anything',
content: 'Use search (or Cmd+K) to quickly find workflows, blocks, tools, and more.',
placement: 'right',
disableBeacon: true,
},
{
target: '.workflows-section',
title: 'Your workflows',
content:
'All your workflows live here. Create new ones with the + button, organize with folders, and switch between them.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tour="canvas"]',
title: 'The workflow canvas',
content:
'This is where you build visually. Drag blocks onto the canvas and connect them together to create AI workflows.',
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="command-list"]',
title: 'Quick actions',
content:
'Use these keyboard shortcuts to get started fast. Try Cmd+K to search for blocks, or Cmd+Y to browse templates.',
placement: 'right',
disableBeacon: true,
},
{
target: '[data-tab-button="toolbar"]',
title: 'Block library',
content:
'The Toolbar is your block library. Drag triggers and blocks onto the canvas to build your workflow step by step.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tab-button="copilot"]',
title: 'AI Copilot',
content:
'Copilot helps you build and debug workflows using natural language. Describe what you want and it creates blocks for you.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tab-button="editor"]',
title: 'Block editor',
content:
'Click any block on the canvas to configure it here — set inputs, credentials, and fine-tune behavior.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tour="deploy-run"]',
title: 'Run and deploy',
content:
'Hit Run to execute your workflow and see results in the terminal below. When ready, Deploy as an API, webhook, schedule, or chat widget.',
placement: 'bottom',
disableBeacon: true,
},
{
target: '[data-tour="workflow-controls"]',
title: 'Canvas controls',
content:
'Switch between pointer and hand mode, undo/redo changes, and fit your canvas to view.',
placement: 'top',
disableBeacon: true,
},
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client'

import type { TooltipRenderProps } from 'react-joyride'
import { cn } from '@/lib/core/utils/cn'

export function TourTooltip({
continuous,
index,
step,
backProps,
closeProps,
primaryProps,
skipProps,
isLastStep,
tooltipProps,
}: TooltipRenderProps) {
return (
<div
{...tooltipProps}
className={cn(
'w-[340px] rounded-[10px] border border-[var(--border-1)]',
'bg-[var(--surface-1)] shadow-[0_8px_30px_rgba(0,0,0,0.3)]'
)}
>
<div className='px-[20px] pt-[20px] pb-[4px]'>
{step.title && (
<h3 className='font-[480] font-season text-[16px] text-[var(--text-primary)] leading-[120%] tracking-[-0.02em]'>
{step.title as string}
</h3>
)}
</div>
<div className='px-[20px] pt-[8px] pb-[16px]'>
<p className='text-[13.5px] text-[var(--text-secondary)] leading-[160%]'>{step.content}</p>
</div>
<div className='flex items-center justify-between border-[var(--border)] border-t px-[16px] py-[12px]'>
<div className='flex items-center'>
{!isLastStep && (
<button
{...skipProps}
type='button'
className='cursor-pointer text-[12.5px] text-[var(--text-muted)] transition-colors hover:text-[var(--text-secondary)]'
>
Skip tour
</button>
)}
</div>
<div className='flex items-center gap-[6px]'>
{index > 0 && (
<button
{...backProps}
type='button'
className='h-[30px] cursor-pointer rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-3)] px-[12px] text-[12.5px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-primary)]'
>
Back
</button>
)}
{continuous ? (
<button
{...primaryProps}
type='button'
className='h-[30px] cursor-pointer rounded-[6px] bg-[var(--brand-tertiary-2,#33c482)] px-[12px] font-medium text-[12.5px] text-white transition-colors hover:opacity-90'
>
{isLastStep ? 'Done' : 'Next'}
</button>
) : (
<button
{...closeProps}
type='button'
className='h-[30px] cursor-pointer rounded-[6px] bg-[var(--brand-tertiary-2,#33c482)] px-[12px] font-medium text-[12.5px] text-white transition-colors hover:opacity-90'
>
Close
</button>
)}
</div>
</div>
</div>
)
}
8 changes: 6 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,14 @@ export function Home({ chatId }: HomeProps = {}) {
return (
<div className='h-full overflow-y-auto bg-[var(--bg)] [scrollbar-gutter:stable]'>
<div className='flex min-h-full flex-col items-center justify-center px-[24px] pb-[2vh]'>
<h1 className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'>
<h1
data-tour='home-greeting'
className='mb-[24px] max-w-[42rem] font-[430] font-season text-[32px] text-[var(--text-primary)] tracking-[-0.02em]'
>
What should we get done
{session?.user?.name ? `, ${session.user.name.split(' ')[0]}` : ''}?
</h1>
<div ref={initialViewInputRef} className='w-full'>
<div ref={initialViewInputRef} className='w-full' data-tour='home-chat-input'>
<UserInput
defaultValue={initialPrompt}
onSubmit={handleSubmit}
Expand All @@ -339,6 +342,7 @@ export function Home({ chatId }: HomeProps = {}) {
</div>
<div
ref={templateRef}
data-tour='home-templates'
className='-mt-[30vh] mx-auto w-full max-w-[68rem] px-[16px] pb-[32px] sm:px-[24px] lg:px-[40px]'
>
<TemplatePrompts onSelect={handleSubmit} />
Expand Down
Loading