mirror of
https://github.com/langgenius/dify.git
synced 2026-07-25 05:28:35 +08:00
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>
806 lines
29 KiB
TypeScript
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>
|
|
)
|
|
}
|