dify/web/app/components/step-by-step-tour/mount.tsx
Jingyi cb64446fa3
feat(web): add step-by-step tour shell (#38785)
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-20 09:23:25 +00:00

806 lines
29 KiB
TypeScript

'use client'
import type { StepByStepTourGuide } from './target-registry'
import type {
StepByStepTourGuideGroup,
StepByStepTourTaskId,
StepByStepTourTaskView,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { IS_CLOUD_EDITION } from '@/config'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname, useRouter } from '@/next/navigation'
import { hasPermission } from '@/utils/permission'
import { getStepByStepTourPermissionVariant, trackStepByStepTourEvent } from './analytics'
import { StepByStepTourCoachmark } from './coachmark'
import { FloatingChecklist } from './floating-widget'
import {
activeStepByStepTourGuideGroupAtom,
activeStepByStepTourGuideIndexAtom,
activeStepByStepTourGuideIndexesAtom,
activeStepByStepTourTaskIdAtom,
advanceStepByStepTourGuideAtom,
completedStepByStepTourTaskIdsAtom,
completeStepByStepTourTaskAtom,
resetStepByStepTourSessionAtom,
skipStepByStepTourAtom,
startStepByStepTourTaskAtom,
stepByStepTourEnabledForCurrentWorkspaceAtom,
stepByStepTourFirstWorkspaceIdAtom,
stepByStepTourSkippedAtom,
stepByStepTourSkipRecoveryVisibleAtom,
uncompleteStepByStepTourTaskAtom,
} from './state'
import { useSetStepByStepTourShellMode, useStepByStepTourShellModeValue } from './storage'
import {
getStepByStepTourGuideInteractionPolicy,
getStepByStepTourGuideKind,
getStepByStepTourGuides,
getStepByStepTourTargetSelector,
STEP_BY_STEP_TOUR_TARGETS,
} from './target-registry'
import { STEP_BY_STEP_TOUR_TASKS } from './tasks'
import { useStepByStepTourTarget } from './use-tour-target'
type StepByStepTourTask = (typeof STEP_BY_STEP_TOUR_TASKS)[number]
const hasCompletedAllStepByStepTourTasks = (
completedTaskIds: StepByStepTourTaskId[],
tasks: readonly StepByStepTourTask[],
) => tasks.every((task) => completedTaskIds.includes(task.id))
const isPermissionFallbackGuideGroup = (
guideGroup: StepByStepTourGuideGroup | undefined,
): guideGroup is Extract<
StepByStepTourGuideGroup,
'homeNoCreate' | 'integrationLimitedAccess' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps'
> =>
guideGroup === 'homeNoCreate' ||
guideGroup === 'integrationLimitedAccess' ||
guideGroup === 'studioNoCreateEmpty' ||
guideGroup === 'studioNoCreateWithApps'
const shouldHideOnPathname = (pathname: string) =>
pathname.startsWith('/app/') || pathname.includes('/installed/')
const isGuideEligibleForPlan = (guide: StepByStepTourGuide, canSetPluginPreferences: boolean) =>
guide.target !== STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings || canSetPluginPreferences
const isOptionalGuideTargetAvailable = (guide: StepByStepTourGuide, pathname: string) => {
if (!guide.optional) return true
if (guide.integrationSection && pathname !== buildIntegrationPath(guide.integrationSection))
return true
if (typeof document === 'undefined') return true
return Boolean(document.querySelector(getStepByStepTourTargetSelector(guide.target)))
}
const isElementVerticallyVisible = (element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
return rect.top >= 0 && rect.bottom <= viewportHeight
}
const scrollTourTargetIntoView = (element: HTMLElement) => {
if (isElementVerticallyVisible(element)) return
element.scrollIntoView({
block: 'nearest',
behavior: 'auto',
})
}
const createGuideIndexes = (guides: StepByStepTourGuide[]) => guides.map((_, index) => index)
const getActiveGuideIndexes = (
guides: StepByStepTourGuide[],
guideIndexes: number[] | undefined,
) => {
const fallbackGuideIndexes = createGuideIndexes(guides)
if (!guideIndexes?.length) return fallbackGuideIndexes
const validGuideIndexes = guideIndexes.filter((index) => index >= 0 && index < guides.length)
return validGuideIndexes.length > 0 ? validGuideIndexes : fallbackGuideIndexes
}
type StepByStepTourMountProps = {
className?: string
}
export default function StepByStepTourMount({ className }: StepByStepTourMountProps) {
const router = useRouter()
const pathname = usePathname()
const docLink = useDocLink()
const { t } = useTranslation('common')
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
const { data: systemFeatures } = useQuery(systemFeaturesQueryOptions())
const completedTaskIds = useAtomValue(completedStepByStepTourTaskIdsAtom)
const skipped = useAtomValue(stepByStepTourSkippedAtom)
const firstWorkspaceId = useAtomValue(stepByStepTourFirstWorkspaceIdAtom)
const enabledForCurrentWorkspace = useAtomValue(stepByStepTourEnabledForCurrentWorkspaceAtom)
const activeTaskId = useAtomValue(activeStepByStepTourTaskIdAtom)
const sessionGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom)
const sessionGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom)
const sessionGuideIndexes = useAtomValue(activeStepByStepTourGuideIndexesAtom)
const skipRecoveryVisible = useAtomValue(stepByStepTourSkipRecoveryVisibleAtom)
const setSkipRecoveryVisible = useSetAtom(stepByStepTourSkipRecoveryVisibleAtom)
const advanceGuide = useSetAtom(advanceStepByStepTourGuideAtom)
const completeTask = useSetAtom(completeStepByStepTourTaskAtom)
const resetSession = useSetAtom(resetStepByStepTourSessionAtom)
const patchSkipTour = useSetAtom(skipStepByStepTourAtom)
const startTask = useSetAtom(startStepByStepTourTaskAtom)
const uncompleteTask = useSetAtom(uncompleteStepByStepTourTaskAtom)
const shellMode = useStepByStepTourShellModeValue()
const setShellMode = useSetStepByStepTourShellMode()
const anchorRef = useRef<HTMLDivElement>(null)
const lastRequestedIntegrationRouteRef = useRef<string | undefined>(undefined)
const previousSkippedRef = useRef(skipped)
const permissionFallbackAnalyticsKeyRef = useRef<string | undefined>(undefined)
const shownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const skipTimeoutRef = useRef<number | undefined>(undefined)
const stepShownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const [checklistExiting, setChecklistExiting] = useState(false)
const currentWorkspaceId = currentWorkspace.id
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const homeGuideGroup: Extract<StepByStepTourGuideGroup, 'homeNoCreate'> | undefined = canCreateApp
? undefined
: 'homeNoCreate'
const hasKnowledgeWalkthroughPermissions =
hasPermission(workspacePermissionKeys, 'dataset.create_and_management') &&
hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const canSetPluginPreferences = hasPermission(
workspacePermissionKeys,
'plugin.plugin_preferences',
)
const integrationGuideGroup:
| Extract<StepByStepTourGuideGroup, 'integrationLimitedAccess'>
| undefined = isCurrentWorkspaceManager ? undefined : 'integrationLimitedAccess'
const hasIntegrationWalkthroughPermissions = !integrationGuideGroup
useEffect(
() => () => {
if (skipTimeoutRef.current) window.clearTimeout(skipTimeoutRef.current)
},
[],
)
const learnDifyEnabled = systemFeatures?.enable_learn_app ?? true
const stepByStepTourFeatureEnabled = Boolean(systemFeatures?.enable_step_by_step_tour)
const availableTasks = learnDifyEnabled
? STEP_BY_STEP_TOUR_TASKS
: STEP_BY_STEP_TOUR_TASKS.filter((task) => task.id !== 'home')
const availableTaskIds = availableTasks.map((task) => task.id)
const completedAvailableTaskIds = completedTaskIds.filter((taskId) =>
availableTaskIds.includes(taskId),
)
const allTasksCompleted = hasCompletedAllStepByStepTourTasks(completedTaskIds, availableTasks)
const currentTask = availableTasks.find((task) => !completedTaskIds.includes(task.id))
const activeTask = activeTaskId
? availableTasks.find((task) => task.id === activeTaskId)
: undefined
const activeGuideGroup: StepByStepTourGuideGroup | undefined =
activeTask?.id === 'home'
? homeGuideGroup
: activeTask?.id === 'integration'
? integrationGuideGroup
: sessionGuideGroup
const activeGuides = activeTask ? getStepByStepTourGuides(activeTask.id, activeGuideGroup) : []
const activeGuideIndex = sessionGuideIndex ?? 0
const activeGuide = activeGuides[activeGuideIndex]
const hasActiveGuide = Boolean(activeTask && activeGuide)
const minimized = Boolean(activeTask) || shellMode === 'collapsed'
const activeGuideIndexes =
activeGuides.length > 0
? getActiveGuideIndexes(activeGuides, sessionGuideIndexes)
.filter((index) => isGuideEligibleForPlan(activeGuides[index]!, canSetPluginPreferences))
.filter((index) => isOptionalGuideTargetAvailable(activeGuides[index]!, pathname))
: []
const activeGuidePlanIndex = activeGuideIndexes.findIndex((index) => index === activeGuideIndex)
const activeStepIndex =
activeGuideIndexes.length > 0
? activeGuidePlanIndex === -1
? activeGuideIndexes.filter((index) => index < activeGuideIndex).length
: activeGuidePlanIndex
: activeGuideIndex
const activeStepTotal = activeGuideIndexes.length || activeGuides.length
const activeGuideAnalyticsProperties =
activeTask && activeGuide
? {
task_id: activeTask.id,
guide_id: activeGuide.id,
}
: undefined
const visible =
IS_CLOUD_EDITION &&
stepByStepTourFeatureEnabled &&
enabledForCurrentWorkspace &&
(hasActiveGuide || !shouldHideOnPathname(pathname))
const overlayVisible = visible && !hasBlockingModalOpen
const completionPromptVisible = visible && allTasksCompleted && !activeTask
const checklistMinimized = completionPromptVisible ? false : minimized
const expanded = !checklistMinimized
const activeTargetElement = useStepByStepTourTarget(activeGuide?.target)
const activeGuidePlacement =
activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify
? 'top'
: activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.integration
? 'right'
: 'bottom'
const getPermissionVariant = (taskId: StepByStepTourTaskId) =>
getStepByStepTourPermissionVariant({
canCreateApp,
hasIntegrationWalkthroughPermissions,
hasKnowledgeWalkthroughPermissions,
taskId,
})
const trackTaskCompleted = (
completedTaskIds: StepByStepTourTaskId[],
taskId: StepByStepTourTaskId,
) => {
const completedAvailableTaskIds = completedTaskIds.filter((completedTaskId) =>
availableTaskIds.includes(completedTaskId),
)
trackStepByStepTourEvent({
action: 'task_completed',
task_id: taskId,
completed_task_count: completedAvailableTaskIds.length,
permission_variant: getPermissionVariant(taskId),
task_total: availableTasks.length,
})
if (hasCompletedAllStepByStepTourTasks(completedTaskIds, availableTasks)) {
trackStepByStepTourEvent({
action: 'tour_completed',
completed_task_count: completedAvailableTaskIds.length,
task_total: availableTasks.length,
})
}
}
const trackTourSkipped = (completedTaskIds: StepByStepTourTaskId[]) => {
trackStepByStepTourEvent({
action: 'tour_skipped',
task_id: activeTask?.id,
completed_task_count: completedTaskIds.filter((taskId) => availableTaskIds.includes(taskId))
.length,
})
}
useEffect(() => {
if (activeTask?.id !== 'integration' || !activeGuide?.integrationSection) return
const activeGuideRoute = buildIntegrationPath(activeGuide.integrationSection)
if (pathname === activeGuideRoute) {
lastRequestedIntegrationRouteRef.current = undefined
return
}
if (lastRequestedIntegrationRouteRef.current === activeGuideRoute) return
lastRequestedIntegrationRouteRef.current = activeGuideRoute
router.push(activeGuideRoute)
}, [activeGuide?.integrationSection, activeTask?.id, pathname, router])
useEffect(() => {
if (!activeTargetElement) return
scrollTourTargetIntoView(activeTargetElement)
}, [activeGuide?.target, activeTargetElement])
useEffect(() => {
if (!visible) return
const entryPoint = previousSkippedRef.current
? 'reenabled_after_skip'
: firstWorkspaceId === currentWorkspaceId
? 'first_workspace'
: 'help_menu_enabled'
const shownAnalyticsKey = `${currentWorkspaceId}:${entryPoint}`
if (shownAnalyticsKeyRef.current === shownAnalyticsKey) return
shownAnalyticsKeyRef.current = shownAnalyticsKey
trackStepByStepTourEvent({
action: 'tour_shown',
completed_task_count: completedAvailableTaskIds.length,
entry_point: entryPoint,
task_total: availableTasks.length,
})
}, [
availableTasks.length,
completedAvailableTaskIds.length,
currentWorkspaceId,
firstWorkspaceId,
visible,
])
useEffect(() => {
if (!visible || !activeTask || !activeGuide || !activeTargetElement) return
const guideAnalyticsProperties = { task_id: activeTask.id, guide_id: activeGuide.id }
const stepShownAnalyticsKey = [
currentWorkspaceId,
guideAnalyticsProperties.task_id,
guideAnalyticsProperties.guide_id,
].join(':')
if (stepShownAnalyticsKeyRef.current === stepShownAnalyticsKey) return
stepShownAnalyticsKeyRef.current = stepShownAnalyticsKey
trackStepByStepTourEvent({
action: 'guide_shown',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}, [
activeGuide,
activeGuideGroup,
activeStepIndex,
activeStepTotal,
activeTargetElement,
activeTask,
currentWorkspaceId,
visible,
])
useEffect(() => {
if (!visible) return
if (currentTask?.id === 'knowledge' && !hasKnowledgeWalkthroughPermissions) {
const fallbackAnalyticsKey = `${currentWorkspaceId}:knowledge:no_knowledge_permission`
if (permissionFallbackAnalyticsKeyRef.current === fallbackAnalyticsKey) return
permissionFallbackAnalyticsKeyRef.current = fallbackAnalyticsKey
trackStepByStepTourEvent({
action: 'permission_fallback_shown',
task_id: 'knowledge',
permission_variant: 'no_knowledge_permission',
})
return
}
if (!activeTask) return
if (!isPermissionFallbackGuideGroup(activeGuideGroup)) return
const fallbackAnalyticsKey = `${currentWorkspaceId}:${activeTask.id}:${activeGuideGroup}`
if (permissionFallbackAnalyticsKeyRef.current === fallbackAnalyticsKey) return
permissionFallbackAnalyticsKeyRef.current = fallbackAnalyticsKey
trackStepByStepTourEvent({
action: 'permission_fallback_shown',
task_id: activeTask.id,
permission_variant:
activeTask.id === 'integration' ? 'no_integration_permission' : 'no_create',
})
}, [
activeGuideGroup,
activeTask,
currentTask?.id,
currentWorkspaceId,
hasKnowledgeWalkthroughPermissions,
visible,
])
useEffect(() => {
previousSkippedRef.current = skipped
}, [skipped])
if (!visible && !skipRecoveryVisible) return null
const title = t(($) => $['stepByStepTour.title'])
const taskCopy: Record<
StepByStepTourTaskId,
Pick<StepByStepTourTaskView, 'title' | 'description' | 'primaryActionLabel'>
> = {
home: {
title: canCreateApp
? t(($) => $['stepByStepTour.tasks.home.title'])
: t(($) => $['stepByStepTour.tasks.home.noCreate.title']),
description: canCreateApp
? t(($) => $['stepByStepTour.tasks.home.description'])
: t(($) => $['stepByStepTour.tasks.home.noCreate.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.home.primaryActionLabel']),
},
studio: {
title: canCreateApp
? t(($) => $['stepByStepTour.tasks.studio.title'])
: t(($) => $['stepByStepTour.tasks.studio.noCreate.title']),
description: canCreateApp
? t(($) => $['stepByStepTour.tasks.studio.description'])
: t(($) => $['stepByStepTour.tasks.studio.noCreate.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.studio.primaryActionLabel']),
},
knowledge: {
title: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.title'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.title']),
description: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.description'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.description']),
primaryActionLabel: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.primaryActionLabel'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.primaryActionLabel']),
},
integration: {
title: hasIntegrationWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.integration.title'])
: t(($) => $['stepByStepTour.tasks.integration.noPermission.title']),
description: hasIntegrationWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.integration.description'])
: t(($) => $['stepByStepTour.tasks.integration.noPermission.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.integration.primaryActionLabel']),
},
}
const tasks = availableTasks.map((task): StepByStepTourTaskView => {
const completed = completedTaskIds.includes(task.id)
const knowledgeUnavailable = task.id === 'knowledge' && !hasKnowledgeWalkthroughPermissions
return {
...taskCopy[task.id],
id: task.id,
iconClassName: knowledgeUnavailable ? 'i-ri-lock-line' : task.iconClassName,
status: completed ? 'completed' : task.id === currentTask?.id ? 'current' : 'pending',
canToggleCompletion: false,
}
})
const skipTour = () => {
if (checklistExiting) return
setChecklistExiting(true)
skipTimeoutRef.current = window.setTimeout(() => {
patchSkipTour({
onSuccess: trackTourSkipped,
onError: () => {
setChecklistExiting(false)
setSkipRecoveryVisible(false)
},
})
setChecklistExiting(false)
setSkipRecoveryVisible(true)
}, 160)
}
const skipActiveGuide = () => {
const guideAnalyticsProperties = activeGuideAnalyticsProperties
if (guideAnalyticsProperties) {
trackStepByStepTourEvent({
action: 'guide_skipped',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}
resetSession()
setShellMode('expanded')
}
const getNextVisibleActiveGuideIndex = (startIndex: number) => {
if (activeGuideIndexes.length > 0) {
let nextGuideIndexes = activeGuideIndexes
let nextGuideIndex = nextGuideIndexes.find((index) => index >= startIndex)
while (nextGuideIndex !== undefined) {
if (
isGuideEligibleForPlan(activeGuides[nextGuideIndex]!, canSetPluginPreferences) &&
isOptionalGuideTargetAvailable(activeGuides[nextGuideIndex]!, pathname)
) {
return { activeGuideIndex: nextGuideIndex, activeGuideIndexes: nextGuideIndexes }
}
nextGuideIndexes = nextGuideIndexes.filter((index) => index !== nextGuideIndex)
nextGuideIndex = nextGuideIndexes.find((index) => index >= startIndex)
}
return { activeGuideIndex: -1, activeGuideIndexes: nextGuideIndexes }
}
for (let index = startIndex; index < activeGuides.length; index += 1) {
if (
isGuideEligibleForPlan(activeGuides[index]!, canSetPluginPreferences) &&
isOptionalGuideTargetAvailable(activeGuides[index]!, pathname)
) {
return { activeGuideIndex: index, activeGuideIndexes: undefined }
}
}
return { activeGuideIndex: -1, activeGuideIndexes: undefined }
}
const completeActiveGuide = () => {
if (!activeTask || !activeGuide) return
if (activeGuide.completionMode === 'external') return
const guideAnalyticsProperties = activeGuideAnalyticsProperties
if (guideAnalyticsProperties) {
trackStepByStepTourEvent({
action: 'guide_completed',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}
if (activeGuideIndex < activeGuides.length - 1) {
const nextActiveGuide = getNextVisibleActiveGuideIndex(activeGuideIndex + 1)
if (nextActiveGuide.activeGuideIndex === -1) {
completeTask({
taskId: activeTask.id,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, activeTask.id),
})
resetSession()
setShellMode('expanded')
return
}
advanceGuide({
guideIndex: nextActiveGuide.activeGuideIndex,
guideIndexes: nextActiveGuide.activeGuideIndexes,
})
return
}
completeTask({
taskId: activeTask.id,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, activeTask.id),
})
resetSession()
setShellMode('expanded')
}
const dismissCompletedTour = () => {
patchSkipTour({
onSuccess: trackTourSkipped,
})
resetSession()
setShellMode('expanded')
}
const floatingChecklist = (
<FloatingChecklist
title={title}
duration={t(($) => $['stepByStepTour.duration'])}
minimized={checklistMinimized}
progress={{
ariaValueText: t(($) => $['stepByStepTour.progressAriaValueText'], {
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}),
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}}
completionPrompt={
completionPromptVisible
? {
label: t(($) => $['stepByStepTour.completion.label']),
title: t(($) => $['stepByStepTour.completion.title']),
description: t(($) => $['stepByStepTour.completion.description']),
dismissLabel: t(($) => $['stepByStepTour.completion.dismiss']),
onDismiss: dismissCompletedTour,
}
: undefined
}
tasks={tasks}
skipLabel={t(($) => $['stepByStepTour.skip'])}
minimizeLabel={t(($) => $['stepByStepTour.minimize'])}
restoreLabel={t(($) => $['stepByStepTour.restore'])}
getTaskCompleteLabel={(taskTitle) =>
t(($) => $['stepByStepTour.markTaskComplete'], { title: taskTitle })
}
getTaskIncompleteLabel={(taskTitle) =>
t(($) => $['stepByStepTour.markTaskIncomplete'], { title: taskTitle })
}
onMinimize={() => {
setShellMode('collapsed')
}}
onRestore={() => {
setShellMode('expanded')
}}
onSkip={skipTour}
onCompleteTask={(taskId) => {
const guides = getStepByStepTourGuides(taskId, sessionGuideGroup)
const hasExternalCompletionGuide = guides.some(
(guide) => getStepByStepTourGuideKind(guide) === 'action',
)
if (hasExternalCompletionGuide) return
completeTask({
taskId,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, taskId),
})
}}
onStartTask={(taskId) => {
const task = availableTasks.find((item) => item.id === taskId)
if (!task) return
const guideGroup =
taskId === 'home'
? homeGuideGroup
: taskId === 'integration'
? integrationGuideGroup
: undefined
trackStepByStepTourEvent({
action: 'task_started',
task_id: taskId,
permission_variant: getPermissionVariant(taskId),
})
if (taskId === 'knowledge' && !hasKnowledgeWalkthroughPermissions) {
completeTask({
taskId,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, taskId),
})
resetSession()
setShellMode('expanded')
return
}
const guides = getStepByStepTourGuides(taskId, guideGroup)
const guideIndexes = createGuideIndexes(guides).filter((index) =>
isGuideEligibleForPlan(guides[index]!, canSetPluginPreferences),
)
startTask({
taskId,
guideGroup,
guideIndexes: guideIndexes.length > 0 ? guideIndexes : undefined,
})
router.push(task.route)
}}
onUncompleteTask={(taskId) => {
uncompleteTask({
taskId,
onSuccess: (completedTaskIds) => {
trackStepByStepTourEvent({
action: 'task_reopened',
task_id: taskId,
completed_task_count: completedTaskIds.filter((completedTaskId) =>
availableTaskIds.includes(completedTaskId),
).length,
})
},
})
}}
className={cn(
'transition-opacity duration-150 ease-out motion-reduce:transition-none',
checklistExiting && 'opacity-0',
)}
/>
)
return (
<div className={cn('relative', className)}>
{overlayVisible && !allTasksCompleted && activeTask && activeGuide && activeTargetElement && (
<StepByStepTourCoachmark
guide={{
...activeGuide,
description: t(($) => $[activeGuide.description]),
learnMoreHref: activeGuide.learnMoreDocPath
? docLink(activeGuide.learnMoreDocPath)
: undefined,
learnMoreLabel: t(($) => $[activeGuide.learnMoreLabel]),
primaryActionLabel: t(($) => $[activeGuide.primaryActionLabel]),
title: t(($) => $[activeGuide.title]),
}}
targetElement={activeTargetElement}
placement={activeGuidePlacement}
stepLabel={t(($) => $['stepByStepTour.stepLabel'], {
current: activeStepIndex + 1,
total: activeStepTotal,
})}
skipLabel={t(($) => $['stepByStepTour.skip'])}
interactionPolicy={getStepByStepTourGuideInteractionPolicy(
activeGuide,
activeTask.canClickThrough,
)}
onSkip={skipActiveGuide}
onComplete={completeActiveGuide}
/>
)}
{visible && (!allTasksCompleted || completionPromptVisible) && (
<Popover open={overlayVisible && expanded}>
<div
ref={anchorRef}
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-0 w-full"
/>
{checklistMinimized && floatingChecklist}
{overlayVisible && (
<PopoverContent
placement="top-start"
sideOffset={0}
positionerProps={{
anchor: anchorRef,
collisionPadding: 8,
collisionAvoidance: {
side: 'shift',
align: 'shift',
fallbackAxisSide: 'none',
},
}}
popupClassName="overflow-visible rounded-none border-0 bg-transparent p-0 shadow-none"
>
{floatingChecklist}
</PopoverContent>
)}
</Popover>
)}
{skipRecoveryVisible && (
<SkipRecoveryPrompt
label={t(($) => $['stepByStepTour.skipRecovery.label'])}
message={t(($) => $['stepByStepTour.skipRecovery.message'])}
dismissLabel={t(($) => $['stepByStepTour.skipRecovery.dismiss'])}
onDismiss={() => setSkipRecoveryVisible(false)}
/>
)}
</div>
)
}
function SkipRecoveryPrompt({
dismissLabel,
label,
message,
onDismiss,
}: {
dismissLabel: string
label: string
message: string
onDismiss: () => void
}) {
const dismissRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
dismissRef.current?.focus({ preventScroll: true })
}, [])
return (
<section
aria-label={label}
className="fixed bottom-[76px] left-1.5 z-50 flex w-[260px] max-w-[calc(100vw-12px)] flex-col gap-1 rounded-2xl border-[0.5px] border-state-accent-hover-alt bg-state-accent-hover p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[10px]"
>
<p className="system-sm-regular text-text-secondary">{message}</p>
<div className="flex h-12 items-end justify-end pt-4">
<Button
ref={dismissRef}
variant="primary"
size="medium"
className="w-20"
onClick={onDismiss}
>
{dismissLabel}
</Button>
</div>
<span
aria-hidden
className="absolute top-full left-[214px] h-7 w-0.5 bg-state-accent-hover-alt"
/>
<span
aria-hidden
className="absolute top-[calc(100%+22px)] left-[209px] size-3 rounded-full border-2 border-state-accent-hover bg-state-accent-solid shadow-xs"
/>
</section>
)
}