mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(tour): added product tour (#3703)
* feat: add product tour * chore: updated modals * chore: fix the tour * chore: Tour Updates * chore: fix review changes * chore: fix review changes * chore: fix review changes * chore: fix review changes * chore: fix review changes * minor improvements * chore(tour): address PR review comments - Extract shared TourState, TourStateContext, mapPlacement, and TourTooltipAdapter into tour-shared.tsx, eliminating ~100 lines of duplication between product-tour.tsx and workflow-tour.tsx - Fix stale closure in handleStartTour — add isOnWorkflowPage to useCallback deps so Take a tour dispatches the correct event after navigation * chore(tour): address remaining PR review comments - Remove unused logger import and instance in product-tour.tsx - Remove unused tour-tooltip-fade animation from tailwind config - Remove unnecessary overflow-hidden wrapper around WorkflowTour - Add border stroke to arrow SVG in tour-tooltip for visual consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(tour): address second round of PR review comments - Remove unnecessary 'use client' from workflow layout (children are already client components) - Fix ref guard timing issue in TourTooltipAdapter that could prevent Joyride from tracking tooltip on subsequent steps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(tour): extract shared Joyride config, fix popover arrow overflow - Extract duplicated Joyride floaterProps/styles into getSharedJoyrideProps() in tour-shared.tsx, parameterized by spotlightBorderRadius - Fix showArrow disabling content scrolling in PopoverContent by wrapping children in a scrollable div when arrow is visible Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * lint * fix(tour): stop running tour when disabled becomes true Prevents nav and workflow tours from overlapping. When a user navigates to a workflow page while the nav tour is running, the disabled flag now stops the nav tour instead of just suppressing auto-start. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tour): move auto-start flag into timer, fix truncate selector conflict - Move hasAutoStarted flag inside setTimeout callback so it's only set when the timer fires, allowing retry if disabled changes during delay - Add data-popover-scroll attribute to showArrow scroll wrapper and exclude it from the flex-1 truncate selector to prevent overflow conflict Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tour): remove duplicate overlay on center-placed tour steps Joyride's spotlight already renders a full-screen overlay via boxShadow. The centered TourTooltip was adding its own bg-black/55 overlay on top, causing double-darkened backgrounds. Removed the redundant overlay div. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move docs link from settings to help dropdown The Docs link (https://docs.sim.ai) was buried in settings navigation. Moved it to the Help dropdown in the sidebar for better discoverability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Adithya Krishna <aadithya794@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
|
||||
export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const navTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-item-id="home"]',
|
||||
title: 'Home',
|
||||
content:
|
||||
'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="search"]',
|
||||
title: 'Search',
|
||||
content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="tables"]',
|
||||
title: 'Tables',
|
||||
content:
|
||||
'Store and query structured data. Your workflows can read and write to tables directly.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="files"]',
|
||||
title: 'Files',
|
||||
content: 'Upload and manage files that your workflows can process, transform, or reference.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="knowledge-base"]',
|
||||
title: 'Knowledge Base',
|
||||
content:
|
||||
'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="scheduled-tasks"]',
|
||||
title: 'Scheduled Tasks',
|
||||
content:
|
||||
'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-item-id="logs"]',
|
||||
title: 'Logs',
|
||||
content:
|
||||
'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '.tasks-section',
|
||||
title: 'Tasks',
|
||||
content:
|
||||
'Tasks that work for you. Mothership can create, edit, and delete resource throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '.workflows-section',
|
||||
title: 'Workflows',
|
||||
content:
|
||||
'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const NAV_TOUR_STORAGE_KEY = 'sim-nav-tour-completed-v1'
|
||||
export const START_NAV_TOUR_EVENT = 'start-nav-tour'
|
||||
|
||||
export function NavTour() {
|
||||
const pathname = usePathname()
|
||||
const isWorkflowPage = /\/w\/[^/]+/.test(pathname)
|
||||
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: navTourSteps,
|
||||
storageKey: NAV_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 1200,
|
||||
resettable: true,
|
||||
triggerEvent: START_NAV_TOUR_EVENT,
|
||||
tourName: 'Navigation tour',
|
||||
disabled: isWorkflowPage,
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: navTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={navTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={4}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 8 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import type { TooltipRenderProps } from 'react-joyride'
|
||||
import { TourTooltip } from '@/components/emcn'
|
||||
|
||||
/** Shared state passed from the tour component to the tooltip adapter via context */
|
||||
export interface TourState {
|
||||
isTooltipVisible: boolean
|
||||
isEntrance: boolean
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export const TourStateContext = createContext<TourState>({
|
||||
isTooltipVisible: true,
|
||||
isEntrance: true,
|
||||
totalSteps: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Maps Joyride placement strings to TourTooltip placement values.
|
||||
*/
|
||||
function mapPlacement(placement?: string): 'top' | 'right' | 'bottom' | 'left' | 'center' {
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
case 'top-start':
|
||||
case 'top-end':
|
||||
return 'top'
|
||||
case 'right':
|
||||
case 'right-start':
|
||||
case 'right-end':
|
||||
return 'right'
|
||||
case 'bottom':
|
||||
case 'bottom-start':
|
||||
case 'bottom-end':
|
||||
return 'bottom'
|
||||
case 'left':
|
||||
case 'left-start':
|
||||
case 'left-end':
|
||||
return 'left'
|
||||
case 'center':
|
||||
return 'center'
|
||||
default:
|
||||
return 'bottom'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that bridges Joyride's tooltip render props to the EMCN TourTooltip component.
|
||||
* Reads transition state from TourStateContext to coordinate fade animations.
|
||||
*/
|
||||
export function TourTooltipAdapter({
|
||||
step,
|
||||
index,
|
||||
isLastStep,
|
||||
tooltipProps,
|
||||
primaryProps,
|
||||
backProps,
|
||||
closeProps,
|
||||
}: TooltipRenderProps) {
|
||||
const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
|
||||
const [targetEl, setTargetEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const { target } = step
|
||||
if (typeof target === 'string') {
|
||||
setTargetEl(document.querySelector<HTMLElement>(target))
|
||||
} else if (target instanceof HTMLElement) {
|
||||
setTargetEl(target)
|
||||
} else {
|
||||
setTargetEl(null)
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const refCallback = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (tooltipProps.ref) {
|
||||
;(tooltipProps.ref as React.RefCallback<HTMLDivElement>)(node)
|
||||
}
|
||||
},
|
||||
[tooltipProps.ref]
|
||||
)
|
||||
|
||||
const placement = mapPlacement(step.placement)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={refCallback}
|
||||
role={tooltipProps.role}
|
||||
aria-modal={tooltipProps['aria-modal']}
|
||||
style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 0, height: 0 }}
|
||||
/>
|
||||
<TourTooltip
|
||||
title={step.title as string}
|
||||
description={step.content}
|
||||
step={index + 1}
|
||||
totalSteps={totalSteps}
|
||||
placement={placement}
|
||||
targetEl={targetEl}
|
||||
isFirst={index === 0}
|
||||
isLast={isLastStep}
|
||||
isVisible={isTooltipVisible}
|
||||
isEntrance={isEntrance && index === 0}
|
||||
onNext={primaryProps.onClick as () => void}
|
||||
onBack={backProps.onClick as () => void}
|
||||
onClose={closeProps.onClick as () => void}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SPOTLIGHT_TRANSITION =
|
||||
'top 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), left 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
|
||||
/**
|
||||
* Returns the shared Joyride floaterProps and styles config used by both tours.
|
||||
* Only `spotlightPadding` and spotlight `borderRadius` differ between tours.
|
||||
*/
|
||||
export function getSharedJoyrideProps(overrides: { spotlightBorderRadius: number }) {
|
||||
return {
|
||||
floaterProps: {
|
||||
disableAnimation: true,
|
||||
hideArrow: true,
|
||||
styles: {
|
||||
floater: {
|
||||
filter: 'none',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
options: {
|
||||
zIndex: 10000,
|
||||
},
|
||||
spotlight: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: overrides.spotlightBorderRadius,
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.55)',
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
transition: SPOTLIGHT_TRANSITION,
|
||||
},
|
||||
overlay: {
|
||||
backgroundColor: 'transparent',
|
||||
mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
|
||||
position: 'fixed' as React.CSSProperties['position'],
|
||||
height: '100%',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
|
||||
|
||||
const logger = createLogger('useTour')
|
||||
|
||||
/** Transition delay before updating step index (ms) */
|
||||
const FADE_OUT_MS = 80
|
||||
|
||||
interface UseTourOptions {
|
||||
/** Tour step definitions */
|
||||
steps: Step[]
|
||||
/** localStorage key for completion persistence */
|
||||
storageKey: string
|
||||
/** Delay before auto-starting the tour (ms) */
|
||||
autoStartDelay?: number
|
||||
/** Whether this tour can be reset/retriggered */
|
||||
resettable?: boolean
|
||||
/** Custom event name to listen for manual triggers */
|
||||
triggerEvent?: string
|
||||
/** Identifier for logging */
|
||||
tourName?: string
|
||||
/** When true, suppresses auto-start (e.g. to avoid overlapping with another active tour) */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface UseTourReturn {
|
||||
/** Whether the tour is currently running */
|
||||
run: boolean
|
||||
/** Current step index */
|
||||
stepIndex: number
|
||||
/** Key to force Joyride remount on retrigger */
|
||||
tourKey: number
|
||||
/** Whether the tooltip is visible (false during step transitions) */
|
||||
isTooltipVisible: boolean
|
||||
/** Whether this is the initial entrance animation */
|
||||
isEntrance: boolean
|
||||
/** Joyride callback handler */
|
||||
handleCallback: (data: CallBackProps) => void
|
||||
}
|
||||
|
||||
function isTourCompleted(storageKey: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markTourCompleted(storageKey: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, 'true')
|
||||
} catch {
|
||||
logger.warn('Failed to persist tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
function clearTourCompletion(storageKey: string): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
logger.warn('Failed to clear tour completion', { storageKey })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for managing product tour state with smooth transitions.
|
||||
*
|
||||
* Handles auto-start on first visit, localStorage persistence,
|
||||
* manual triggering via custom events, and coordinated fade
|
||||
* transitions between steps to prevent layout shift.
|
||||
*/
|
||||
export function useTour({
|
||||
steps,
|
||||
storageKey,
|
||||
autoStartDelay = 1200,
|
||||
resettable = false,
|
||||
triggerEvent,
|
||||
tourName = 'tour',
|
||||
disabled = false,
|
||||
}: UseTourOptions): UseTourReturn {
|
||||
const [run, setRun] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [tourKey, setTourKey] = useState(0)
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(true)
|
||||
const [isEntrance, setIsEntrance] = useState(true)
|
||||
|
||||
const hasAutoStarted = useRef(false)
|
||||
const retriggerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
markTourCompleted(storageKey)
|
||||
}, [storageKey])
|
||||
|
||||
/** Transition to a new step with a coordinated fade-out/fade-in */
|
||||
const transitionToStep = useCallback(
|
||||
(newIndex: number) => {
|
||||
if (newIndex < 0 || newIndex >= steps.length) {
|
||||
stopTour()
|
||||
return
|
||||
}
|
||||
|
||||
/** Hide tooltip during transition */
|
||||
setIsTooltipVisible(false)
|
||||
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
}
|
||||
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
transitionTimerRef.current = null
|
||||
setStepIndex(newIndex)
|
||||
setIsEntrance(false)
|
||||
|
||||
/**
|
||||
* Wait for the browser to process the Radix Popover repositioning
|
||||
* before showing the tooltip at the new position.
|
||||
*/
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}, FADE_OUT_MS)
|
||||
},
|
||||
[steps.length, stopTour]
|
||||
)
|
||||
|
||||
/** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
|
||||
useEffect(() => {
|
||||
if (disabled && run) {
|
||||
setRun(false)
|
||||
setIsTooltipVisible(true)
|
||||
setIsEntrance(true)
|
||||
logger.info(`${tourName} paused — disabled became true`)
|
||||
}
|
||||
}, [disabled, run, tourName])
|
||||
|
||||
/** Auto-start on first visit */
|
||||
useEffect(() => {
|
||||
if (disabled || hasAutoStarted.current) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
hasAutoStarted.current = true
|
||||
if (!isTourCompleted(storageKey)) {
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`Auto-starting ${tourName}`)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
}, autoStartDelay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [storageKey, autoStartDelay, tourName, disabled])
|
||||
|
||||
/** Listen for manual trigger events */
|
||||
useEffect(() => {
|
||||
if (!triggerEvent || !resettable) return
|
||||
|
||||
const handleTrigger = () => {
|
||||
setRun(false)
|
||||
clearTourCompletion(storageKey)
|
||||
setTourKey((k) => k + 1)
|
||||
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start with the tooltip hidden so Joyride can mount, find the
|
||||
* target element, and position its overlay/spotlight before the
|
||||
* tooltip card appears.
|
||||
*/
|
||||
retriggerTimerRef.current = setTimeout(() => {
|
||||
retriggerTimerRef.current = null
|
||||
setStepIndex(0)
|
||||
setIsEntrance(true)
|
||||
setIsTooltipVisible(false)
|
||||
setRun(true)
|
||||
logger.info(`${tourName} triggered via event`)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsTooltipVisible(true)
|
||||
})
|
||||
})
|
||||
}, 50)
|
||||
}
|
||||
|
||||
window.addEventListener(triggerEvent, handleTrigger)
|
||||
return () => {
|
||||
window.removeEventListener(triggerEvent, handleTrigger)
|
||||
if (retriggerTimerRef.current) {
|
||||
clearTimeout(retriggerTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [triggerEvent, resettable, storageKey, tourName])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) {
|
||||
clearTimeout(transitionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCallback = useCallback(
|
||||
(data: CallBackProps) => {
|
||||
const { action, index, status, type } = data
|
||||
|
||||
if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} ended`, { status })
|
||||
return
|
||||
}
|
||||
|
||||
if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
|
||||
if (action === ACTIONS.CLOSE) {
|
||||
stopTour()
|
||||
logger.info(`${tourName} closed by user`)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)
|
||||
|
||||
if (type === EVENTS.TARGET_NOT_FOUND) {
|
||||
logger.info(`${tourName} step target not found, skipping`, {
|
||||
stepIndex: index,
|
||||
target: steps[index]?.target,
|
||||
})
|
||||
}
|
||||
|
||||
transitionToStep(nextIndex)
|
||||
}
|
||||
},
|
||||
[stopTour, transitionToStep, steps, tourName]
|
||||
)
|
||||
|
||||
return {
|
||||
run,
|
||||
stepIndex,
|
||||
tourKey,
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
handleCallback,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Step } from 'react-joyride'
|
||||
|
||||
export const workflowTourSteps: Step[] = [
|
||||
{
|
||||
target: '[data-tour="canvas"]',
|
||||
title: 'The Canvas',
|
||||
content:
|
||||
'This is where you build visually. Drag blocks onto the canvas and connect them to create AI workflows.',
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tab-button="copilot"]',
|
||||
title: 'AI Copilot',
|
||||
content:
|
||||
'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tab-button="toolbar"]',
|
||||
title: 'Block Library',
|
||||
content:
|
||||
'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
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,
|
||||
spotlightPadding: 0,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="deploy-run"]',
|
||||
title: 'Deploy & Run',
|
||||
content:
|
||||
'Deploy your workflow as an API, webhook, schedule, or chat widget. Then hit Run to test it out.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="workflow-controls"]',
|
||||
title: 'Canvas Controls',
|
||||
content:
|
||||
'Switch between pointer and hand mode, undo or redo changes, and fit the canvas to your view.',
|
||||
placement: 'top',
|
||||
spotlightPadding: 0,
|
||||
disableBeacon: true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import {
|
||||
getSharedJoyrideProps,
|
||||
TourStateContext,
|
||||
TourTooltipAdapter,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
|
||||
import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
|
||||
import { workflowTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps'
|
||||
|
||||
const Joyride = dynamic(() => import('react-joyride'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const WORKFLOW_TOUR_STORAGE_KEY = 'sim-workflow-tour-completed-v1'
|
||||
export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
|
||||
|
||||
/**
|
||||
* Workflow tour that covers the canvas, blocks, copilot, and deployment.
|
||||
* Runs on first workflow visit and can be retriggered via "Take a tour".
|
||||
*/
|
||||
export function WorkflowTour() {
|
||||
const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
|
||||
steps: workflowTourSteps,
|
||||
storageKey: WORKFLOW_TOUR_STORAGE_KEY,
|
||||
autoStartDelay: 800,
|
||||
resettable: true,
|
||||
triggerEvent: START_WORKFLOW_TOUR_EVENT,
|
||||
tourName: 'Workflow tour',
|
||||
})
|
||||
|
||||
const tourState = useMemo<TourState>(
|
||||
() => ({
|
||||
isTooltipVisible,
|
||||
isEntrance,
|
||||
totalSteps: workflowTourSteps.length,
|
||||
}),
|
||||
[isTooltipVisible, isEntrance]
|
||||
)
|
||||
|
||||
return (
|
||||
<TourStateContext.Provider value={tourState}>
|
||||
<Joyride
|
||||
key={tourKey}
|
||||
steps={workflowTourSteps}
|
||||
run={run}
|
||||
stepIndex={stepIndex}
|
||||
callback={handleCallback}
|
||||
continuous
|
||||
disableScrolling
|
||||
disableScrollParentFix
|
||||
disableOverlayClose
|
||||
spotlightPadding={1}
|
||||
tooltipComponent={TourTooltipAdapter}
|
||||
{...getSharedJoyrideProps({ spotlightBorderRadius: 6 })}
|
||||
/>
|
||||
</TourStateContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ToastProvider } from '@/components/emcn'
|
||||
import { NavTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -21,6 +22,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<NavTour />
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Card,
|
||||
Connections,
|
||||
HexSimple,
|
||||
@@ -38,7 +37,6 @@ export type SettingsSection =
|
||||
| 'skills'
|
||||
| 'workflow-mcp-servers'
|
||||
| 'inbox'
|
||||
| 'docs'
|
||||
| 'admin'
|
||||
| 'recently-deleted'
|
||||
|
||||
@@ -156,14 +154,6 @@ export const allNavigationItems: NavigationItem[] = [
|
||||
requiresEnterprise: true,
|
||||
selfHostedOverride: isSSOEnabled,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
icon: BookOpen,
|
||||
section: 'system',
|
||||
requiresHosted: true,
|
||||
externalUrl: 'https://docs.sim.ai',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
label: 'Admin',
|
||||
|
||||
@@ -179,6 +179,7 @@ export function CommandList() {
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-tour='command-list'
|
||||
className='pointer-events-auto flex flex-col gap-[8px]'
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -608,7 +608,7 @@ export const Panel = memo(function Panel() {
|
||||
<div className='flex gap-[6px]'>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]'>
|
||||
<Button className='h-[30px] w-[30px] rounded-[5px]' data-tour='panel-menu'>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -668,10 +668,11 @@ export const Panel = memo(function Panel() {
|
||||
</div>
|
||||
|
||||
{/* Deploy and Run */}
|
||||
<div className='flex gap-[6px]'>
|
||||
<div className='flex gap-[6px]' data-tour='deploy-run'>
|
||||
<Deploy activeWorkflowId={activeWorkflowId} userPermissions={userPermissions} />
|
||||
<Button
|
||||
className='h-[30px] gap-[8px] px-[10px]'
|
||||
data-tour='run-button'
|
||||
variant={isExecuting ? 'active' : 'tertiary'}
|
||||
onClick={isExecuting ? cancelWorkflow : () => runWorkflow()}
|
||||
disabled={!isExecuting && isButtonDisabled}
|
||||
|
||||
@@ -83,13 +83,14 @@ export const WorkflowControls = memo(function WorkflowControls() {
|
||||
}
|
||||
|
||||
if (!showWorkflowControls) {
|
||||
return null
|
||||
return <div data-tour='workflow-controls' className='hidden' />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='absolute bottom-[16px] left-[16px] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]'
|
||||
data-tour='workflow-controls'
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Canvas Mode Selector */}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { WorkflowTour } from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error'
|
||||
|
||||
export default function WorkflowLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className='flex h-full flex-1 flex-col overflow-hidden'>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<WorkflowTour />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3909,7 +3909,11 @@ const WorkflowContent = React.memo(
|
||||
return (
|
||||
<div className='flex h-full w-full overflow-hidden'>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div ref={canvasContainerRef} className='relative flex-1 overflow-hidden'>
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className='relative flex-1 overflow-hidden'
|
||||
data-tour='canvas'
|
||||
>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import { Compass, MoreHorizontal } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Database,
|
||||
File,
|
||||
@@ -36,6 +37,10 @@ import {
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
START_NAV_TOUR_EVENT,
|
||||
START_WORKFLOW_TOUR_EVENT,
|
||||
} from '@/app/workspace/[workspaceId]/components/product-tour'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
@@ -596,12 +601,6 @@ export const Sidebar = memo(function Sidebar() {
|
||||
|
||||
const footerItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'help',
|
||||
label: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: () => setIsHelpModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
@@ -618,6 +617,12 @@ export const Sidebar = memo(function Sidebar() {
|
||||
[workspaceId, navigateToSettings, getSettingsHref, isCollapsed, setSidebarWidth]
|
||||
)
|
||||
|
||||
const handleStartTour = useCallback(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(isOnWorkflowPage ? START_WORKFLOW_TOUR_EVENT : START_NAV_TOUR_EVENT)
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
|
||||
const { data: fetchedTasks = [], isLoading: tasksLoading } = useTasks(workspaceId)
|
||||
|
||||
useTaskEvents(workspaceId)
|
||||
@@ -1134,7 +1139,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
)}
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='flex flex-shrink-0 flex-col'>
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
{!isCollapsed && (
|
||||
@@ -1390,6 +1395,49 @@ export const Sidebar = memo(function Sidebar() {
|
||||
!hasOverflowBottom && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Help dropdown */}
|
||||
<DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
data-item-id='help'
|
||||
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<HelpCircle className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='sidebar-collapse-hide truncate font-base text-[var(--text-body)]'>
|
||||
Help
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
</DropdownMenuTrigger>
|
||||
{showCollapsedContent && (
|
||||
<Tooltip.Content side='right'>
|
||||
<p>Help</p>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent align='start' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
window.open('https://docs.sim.ai', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
Docs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setIsHelpModalOpen(true)}>
|
||||
<HelpCircle className='h-[14px] w-[14px]' />
|
||||
Report an issue
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleStartTour}>
|
||||
<Compass className='h-[14px] w-[14px]' />
|
||||
Take a tour
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{footerItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={`${item.id}-${isCollapsed}`}
|
||||
|
||||
@@ -147,3 +147,10 @@ export { TimePicker, type TimePickerProps, timePickerVariants } from './time-pic
|
||||
export { CountdownRing } from './toast/countdown-ring'
|
||||
export { ToastProvider, toast, useToast } from './toast/toast'
|
||||
export { Tooltip } from './tooltip/tooltip'
|
||||
export {
|
||||
TourCard,
|
||||
type TourCardProps,
|
||||
TourTooltip,
|
||||
type TourTooltipPlacement,
|
||||
type TourTooltipProps,
|
||||
} from './tour-tooltip/tour-tooltip'
|
||||
|
||||
@@ -414,6 +414,18 @@ export interface PopoverContentProps
|
||||
* @default true
|
||||
*/
|
||||
avoidCollisions?: boolean
|
||||
/**
|
||||
* Show an arrow pointing toward the anchor element.
|
||||
* The arrow color matches the popover background based on the current color scheme.
|
||||
* @default false
|
||||
*/
|
||||
showArrow?: boolean
|
||||
/**
|
||||
* Custom className for the arrow element.
|
||||
* Overrides the default color-scheme-based fill when provided.
|
||||
* Useful when the popover background is overridden via className.
|
||||
*/
|
||||
arrowClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,6 +450,8 @@ const PopoverContent = React.forwardRef<
|
||||
collisionPadding = 8,
|
||||
border = false,
|
||||
avoidCollisions = true,
|
||||
showArrow = false,
|
||||
arrowClassName,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...restProps
|
||||
@@ -592,10 +606,12 @@ const PopoverContent = React.forwardRef<
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
{...restProps}
|
||||
className={cn(
|
||||
'z-[10000200] flex flex-col overflow-auto outline-none will-change-transform',
|
||||
'z-[10000200] flex flex-col outline-none will-change-transform',
|
||||
showArrow ? 'overflow-visible' : 'overflow-auto',
|
||||
STYLES.colorScheme[colorScheme].content,
|
||||
STYLES.content,
|
||||
hasUserWidthConstraint && '[&_.flex-1]:truncate [&_[data-popover-section]]:truncate',
|
||||
hasUserWidthConstraint &&
|
||||
'[&_.flex-1:not([data-popover-scroll])]:truncate [&_[data-popover-section]]:truncate',
|
||||
border && 'border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
@@ -613,7 +629,34 @@ const PopoverContent = React.forwardRef<
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{showArrow ? (
|
||||
<div data-popover-scroll className='min-h-0 flex-1 overflow-auto'>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{showArrow && (
|
||||
<PopoverPrimitive.Arrow width={14} height={7} asChild>
|
||||
<svg
|
||||
width={14}
|
||||
height={7}
|
||||
viewBox='0 0 14 7'
|
||||
preserveAspectRatio='none'
|
||||
className={
|
||||
arrowClassName ??
|
||||
cn(
|
||||
colorScheme === 'inverted'
|
||||
? 'fill-[#242424] stroke-[#363636] dark:fill-[var(--surface-3)] dark:stroke-[var(--border-1)]'
|
||||
: 'fill-[var(--surface-3)] stroke-[var(--border-1)] dark:fill-[var(--surface-3)]'
|
||||
)
|
||||
}
|
||||
>
|
||||
<polygon points='0,0 14,0 7,7' className='stroke-none' />
|
||||
<polyline points='0,0 7,7 14,0' fill='none' strokeWidth={1} />
|
||||
</svg>
|
||||
</PopoverPrimitive.Arrow>
|
||||
)}
|
||||
</PopoverPrimitive.Content>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import type * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/emcn/components/button/button'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
type TourTooltipPlacement = 'top' | 'right' | 'bottom' | 'left' | 'center'
|
||||
|
||||
interface TourCardProps {
|
||||
/** Title displayed in the card header */
|
||||
title: string
|
||||
/** Description text in the card body */
|
||||
description: React.ReactNode
|
||||
/** Current step number (1-based) */
|
||||
step: number
|
||||
/** Total number of steps in the tour */
|
||||
totalSteps: number
|
||||
/** Whether this is the first step (hides Back button) */
|
||||
isFirst?: boolean
|
||||
/** Whether this is the last step (changes Next to Done) */
|
||||
isLast?: boolean
|
||||
/** Called when the user clicks Next or Done */
|
||||
onNext?: () => void
|
||||
/** Called when the user clicks Back */
|
||||
onBack?: () => void
|
||||
/** Called when the user dismisses the tour */
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
function TourCard({
|
||||
title,
|
||||
description,
|
||||
step,
|
||||
totalSteps,
|
||||
isFirst,
|
||||
isLast,
|
||||
onNext,
|
||||
onBack,
|
||||
onClose,
|
||||
}: TourCardProps) {
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-2 px-4 pt-4 pb-2'>
|
||||
<h3 className='min-w-0 font-medium text-[var(--text-primary)] text-sm leading-none'>
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] flex-shrink-0 p-0'
|
||||
onClick={onClose}
|
||||
aria-label='Close tour'
|
||||
>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='px-4 pt-1 pb-3'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)] leading-[1.6]'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-[var(--border)] border-t px-4 py-3'>
|
||||
<span className='text-[11px] text-[var(--text-muted)] [font-variant-numeric:tabular-nums]'>
|
||||
{step} / {totalSteps}
|
||||
</span>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<div className={cn(isFirst && 'pointer-events-none opacity-0')}>
|
||||
<Button variant='default' size='sm' onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant='tertiary' size='sm' onClick={onNext}>
|
||||
{isLast ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface TourTooltipProps extends TourCardProps {
|
||||
/** Placement relative to the target element */
|
||||
placement?: TourTooltipPlacement
|
||||
/** Target DOM element to anchor the tooltip to */
|
||||
targetEl: HTMLElement | null
|
||||
/** Controls tooltip visibility for smooth transitions */
|
||||
isVisible?: boolean
|
||||
/** Whether this is the initial entrance (plays full entrance animation) */
|
||||
isEntrance?: boolean
|
||||
/** Additional class names for the tooltip card */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PLACEMENT_TO_SIDE: Record<
|
||||
Exclude<TourTooltipPlacement, 'center'>,
|
||||
'top' | 'right' | 'bottom' | 'left'
|
||||
> = {
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
left: 'left',
|
||||
}
|
||||
|
||||
/**
|
||||
* A positioned tooltip component for guided product tours.
|
||||
*
|
||||
* Anchors to a target DOM element using Radix Popover primitives for
|
||||
* collision-aware positioning. Supports centered placement for overlay steps.
|
||||
* The card surface matches the emcn Modal / DropdownMenu conventions.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TourTooltip
|
||||
* title="Welcome"
|
||||
* description="This is your dashboard."
|
||||
* step={1}
|
||||
* totalSteps={5}
|
||||
* placement="bottom"
|
||||
* targetEl={document.querySelector('[data-tour="home"]')}
|
||||
* onNext={handleNext}
|
||||
* onClose={handleClose}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function TourTooltip({
|
||||
title,
|
||||
description,
|
||||
step,
|
||||
totalSteps,
|
||||
placement = 'bottom',
|
||||
targetEl,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
isVisible = true,
|
||||
isEntrance = false,
|
||||
onNext,
|
||||
onBack,
|
||||
onClose,
|
||||
className,
|
||||
}: TourTooltipProps) {
|
||||
if (typeof document === 'undefined') return null
|
||||
if (!isVisible) return null
|
||||
|
||||
const isCentered = placement === 'center'
|
||||
|
||||
const cardClasses = cn(
|
||||
'w-[260px] overflow-hidden rounded-[8px] bg-[var(--bg)]',
|
||||
isEntrance && 'animate-tour-tooltip-in motion-reduce:animate-none',
|
||||
className
|
||||
)
|
||||
|
||||
const cardContent = (
|
||||
<TourCard
|
||||
title={title}
|
||||
description={description}
|
||||
step={step}
|
||||
totalSteps={totalSteps}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isCentered) {
|
||||
return createPortal(
|
||||
<div className='fixed inset-0 z-[10000300] flex items-center justify-center'>
|
||||
<div
|
||||
className={cn(
|
||||
cardClasses,
|
||||
'pointer-events-auto relative border border-[var(--border)] shadow-sm'
|
||||
)}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
if (!targetEl) return null
|
||||
|
||||
return createPortal(
|
||||
<PopoverPrimitive.Root open>
|
||||
<PopoverPrimitive.Anchor virtualRef={{ current: targetEl }} />
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
side={PLACEMENT_TO_SIDE[placement] || 'bottom'}
|
||||
sideOffset={10}
|
||||
collisionPadding={12}
|
||||
avoidCollisions
|
||||
className='z-[10000300] outline-none'
|
||||
style={{
|
||||
filter: 'drop-shadow(0 0 0.5px var(--border)) drop-shadow(0 1px 2px rgba(0,0,0,0.1))',
|
||||
}}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={cardClasses}>{cardContent}</div>
|
||||
<PopoverPrimitive.Arrow width={14} height={7} asChild>
|
||||
<svg
|
||||
width={14}
|
||||
height={7}
|
||||
viewBox='0 0 14 7'
|
||||
preserveAspectRatio='none'
|
||||
className='-mt-px fill-[var(--bg)] stroke-[var(--border)]'
|
||||
>
|
||||
<polygon points='0,0 14,0 7,7' className='stroke-none' />
|
||||
<polyline points='0,0 7,7 14,0' fill='none' strokeWidth={1} />
|
||||
</svg>
|
||||
</PopoverPrimitive.Arrow>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export { TourCard, TourTooltip }
|
||||
export type { TourCardProps, TourTooltipPlacement, TourTooltipProps }
|
||||
@@ -154,6 +154,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-joyride": "2.9.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-window": "2.2.3",
|
||||
@@ -226,6 +227,10 @@
|
||||
"next": "16.1.6",
|
||||
"@next/env": "16.1.6",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"postgres": "^3.4.5"
|
||||
"postgres": "^3.4.5",
|
||||
"react-floater": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,10 @@ export default {
|
||||
from: { opacity: '0', transform: 'translateY(20px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'tour-tooltip-in': {
|
||||
from: { opacity: '0', transform: 'scale(0.96) translateY(4px)' },
|
||||
to: { opacity: '1', transform: 'scale(1) translateY(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
@@ -193,6 +197,7 @@ export default {
|
||||
'thinking-block': 'thinking-block 1.6s ease-in-out infinite',
|
||||
'slide-in-right': 'slide-in-right 350ms ease-out forwards',
|
||||
'slide-in-bottom': 'slide-in-bottom 400ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'tour-tooltip-in': 'tour-tooltip-in 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
40
bun.lock
40
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
@@ -178,6 +179,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-joyride": "2.9.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"react-window": "2.2.3",
|
||||
@@ -714,6 +716,8 @@
|
||||
|
||||
"@fumari/stf": ["@fumari/stf@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-AsrCbI0pfnxRGeXLeaC6tpMXi7NLHU+l9dfjenCgyCaLFGW16sAccznAgcsAO2sRIH4qXa/D8TLrbHB+hhytfg=="],
|
||||
|
||||
"@gilbarbara/deep-equal": ["@gilbarbara/deep-equal@0.3.1", "", {}, "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="],
|
||||
|
||||
"@google-cloud/precise-date": ["@google-cloud/precise-date@4.0.0", "", {}, "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA=="],
|
||||
|
||||
"@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="],
|
||||
@@ -2034,6 +2038,8 @@
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-diff": ["deep-diff@1.0.2", "", {}, "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
@@ -2502,6 +2508,8 @@
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-lite": ["is-lite@1.2.1", "", {}, "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
@@ -2672,6 +2680,8 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
@@ -3084,6 +3094,8 @@
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"popper.js": ["popper.js@1.16.1", "", {}, "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
@@ -3130,6 +3142,8 @@
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
@@ -3176,8 +3190,16 @@
|
||||
|
||||
"react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="],
|
||||
|
||||
"react-floater": ["react-floater@0.7.9", "", { "dependencies": { "deepmerge": "^4.3.1", "is-lite": "^0.8.2", "popper.js": "^1.16.0", "prop-types": "^15.8.1", "tree-changes": "^0.9.1" }, "peerDependencies": { "react": "15 - 18", "react-dom": "15 - 18" } }, "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.72.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw=="],
|
||||
|
||||
"react-innertext": ["react-innertext@1.1.5", "", { "peerDependencies": { "@types/react": ">=0.0.0 <=99", "react": ">=0.0.0 <=99" } }, "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-joyride": ["react-joyride@2.9.3", "", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "deep-diff": "^1.0.2", "deepmerge": "^4.3.1", "is-lite": "^1.2.1", "react-floater": "^0.7.9", "react-innertext": "^1.1.5", "react-is": "^16.13.1", "scroll": "^3.0.1", "scrollparent": "^2.1.0", "tree-changes": "^0.11.2", "type-fest": "^4.27.0" }, "peerDependencies": { "react": "15 - 18", "react-dom": "15 - 18" } }, "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-medium-image-zoom": ["react-medium-image-zoom@5.4.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g=="],
|
||||
@@ -3310,8 +3332,12 @@
|
||||
|
||||
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
||||
|
||||
"scroll": ["scroll@3.0.1", "", {}, "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="],
|
||||
|
||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"scrollparent": ["scrollparent@2.1.0", "", {}, "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="],
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
@@ -3544,6 +3570,8 @@
|
||||
|
||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"tree-changes": ["tree-changes@0.11.3", "", { "dependencies": { "@gilbarbara/deep-equal": "^0.3.1", "is-lite": "^1.2.1" } }, "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -3574,7 +3602,7 @@
|
||||
|
||||
"twilio": ["twilio@5.9.0", "", { "dependencies": { "axios": "^1.11.0", "dayjs": "^1.11.9", "https-proxy-agent": "^5.0.0", "jsonwebtoken": "^9.0.2", "qs": "^6.9.4", "scmp": "^2.1.0", "xmlbuilder": "^13.0.2" } }, "sha512-Ij+xT9MZZSjP64lsy+x6vYsCCb5m2Db9KffkMXBrN3zWbG3rbkXxl+MZVVzrvpwEdSbQD0vMuin+TTlQ6kR6Xg=="],
|
||||
|
||||
"type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
@@ -4114,6 +4142,8 @@
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
@@ -4256,6 +4286,8 @@
|
||||
|
||||
"log-update/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
@@ -4338,6 +4370,10 @@
|
||||
|
||||
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"react-floater/is-lite": ["is-lite@0.8.2", "", {}, "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw=="],
|
||||
|
||||
"react-floater/tree-changes": ["tree-changes@0.9.3", "", { "dependencies": { "@gilbarbara/deep-equal": "^0.1.1", "is-lite": "^0.8.2" } }, "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ=="],
|
||||
|
||||
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
@@ -4838,6 +4874,8 @@
|
||||
|
||||
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"react-floater/tree-changes/@gilbarbara/deep-equal": ["@gilbarbara/deep-equal@0.1.2", "", {}, "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA=="],
|
||||
|
||||
"readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
Reference in New Issue
Block a user