🚸 (onboarding) Introduce new onboarding floating videos mechanism

This commit is contained in:
Baptiste Arnaud
2024-06-04 18:25:46 +02:00
parent 4a45e5e1f1
commit c55973fac0
19 changed files with 412 additions and 234 deletions

View File

@@ -18,27 +18,19 @@ import {
SettingsIcon,
} from '@/components/icons'
import { useTypebot } from '../providers/TypebotProvider'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { EditorSettingsModal } from './EditorSettingsModal'
import { parseDefaultPublicId } from '@/features/publish/helpers/parseDefaultPublicId'
import { useTranslate } from '@tolgee/react'
import { useUser } from '@/features/account/hooks/useUser'
import { useRouter } from 'next/router'
import { RightPanel, useEditor } from '../providers/EditorProvider'
export const BoardMenuButton = (props: StackProps) => {
const { query } = useRouter()
const { typebot, currentUserMode } = useTypebot()
const { user } = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslate()
const { setRightPanel } = useEditor()
useEffect(() => {
if (user && !user.graphNavigation && !query.isFirstBot) onOpen()
}, [onOpen, query.isFirstBot, user, user?.graphNavigation])
const downloadFlow = () => {
assert(typebot)
setIsDownloading(true)

View File

@@ -8,7 +8,6 @@ import {
import { useTypebot } from '../providers/TypebotProvider'
import { BlocksSideBar } from './BlocksSideBar'
import { BoardMenuButton } from './BoardMenuButton'
import { GettingStartedModal } from './GettingStartedModal'
import { PreviewDrawer } from '@/features/preview/components/PreviewDrawer'
import { TypebotHeader } from './TypebotHeader'
import { Graph } from '@/features/graph/components/Graph'
@@ -19,6 +18,7 @@ import { TypebotNotFoundPage } from './TypebotNotFoundPage'
import { SuspectedTypebotBanner } from './SuspectedTypebotBanner'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { VariablesDrawer } from '@/features/preview/components/VariablesDrawer'
import { VideoOnboardingFloatingWindow } from '@/features/onboarding/components/VideoOnboardingFloatingWindow'
export const EditorPage = () => {
const { typebot, currentUserMode, is404 } = useTypebot()
@@ -32,11 +32,12 @@ export const EditorPage = () => {
const isSuspicious = typebot?.riskLevel === 100 && !workspace?.isVerified
if (is404) return <TypebotNotFoundPage />
return (
<EditorProvider>
<Seo title={typebot?.name ? `${typebot.name} | Editor` : 'Editor'} />
<Flex overflow="clip" h="100vh" flexDir="column" id="editor-container">
<GettingStartedModal />
<VideoOnboardingFloatingWindow type="editor" />
{isSuspicious && <SuspectedTypebotBanner typebotId={typebot.id} />}
<TypebotHeader />
<Flex

View File

@@ -1,174 +0,0 @@
import {
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalBody,
Stack,
Heading,
List,
ListItem,
Text,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
HStack,
Flex,
} from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useTranslate } from '@tolgee/react'
export const GettingStartedModal = () => {
const { t } = useTranslate()
const { query } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
useEffect(() => {
if (query.isFirstBot) onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody as={Stack} spacing="8" py="10">
<Stack spacing={4}>
<Heading fontSize="xl">
{t('editor.gettingStartedModal.editorBasics.heading')}
</Heading>
<List spacing={4}>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
1
</Flex>
<Text>
{t('editor.gettingStartedModal.editorBasics.list.one.label')}
</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
fontSize="13px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
>
2
</Flex>
<Text>
{t('editor.gettingStartedModal.editorBasics.list.two.label')}
</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
3
</Flex>
<Text>
{t(
'editor.gettingStartedModal.editorBasics.list.three.label'
)}
</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
4
</Flex>
<Text>
{t('editor.gettingStartedModal.editorBasics.list.four.label')}
</Text>
</HStack>
</List>
</Stack>
<Text>{t('editor.gettingStartedModal.editorBasics.list.label')}</Text>
<Stack spacing={4}>
<Heading fontSize="xl">
{t('editor.gettingStartedModal.seeAction.label')} ({`<`}{' '}
{t('editor.gettingStartedModal.seeAction.time')})
</Heading>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/jp3ggg_42-M"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem', border: 'none' }}
/>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Box flex="1" textAlign="left">
{t('editor.gettingStartedModal.seeAction.item.label')}
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={10} as={Stack} spacing="10">
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/6BudIC4GYNk"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem', border: 'none' }}
/>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/ZuyDwFLRbfQ"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem', border: 'none' }}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -60,7 +60,7 @@ export const TypebotHeader = () => {
justify="center"
align="center"
h={`${headerHeight}px`}
zIndex={100}
zIndex={1}
pos="relative"
bgColor={headerBgColor}
flexShrink={0}

View File

@@ -8,9 +8,8 @@ import { useTypebotDnd } from '../TypebotDndProvider'
export const CreateBotButton = ({
folderId,
isFirstBot,
...props
}: { folderId?: string; isFirstBot: boolean } & ButtonProps) => {
}: { folderId?: string } & ButtonProps) => {
const { t } = useTranslate()
const router = useRouter()
const { draggedTypebot } = useTypebotDnd()
@@ -18,7 +17,6 @@ export const CreateBotButton = ({
const handleClick = () =>
router.push(
`/typebots/create?${stringify({
isFirstBot: !isFirstBot ? undefined : isFirstBot,
folderId,
})}`
)

View File

@@ -187,7 +187,6 @@ export const FolderContent = ({ folder }: Props) => {
<CreateBotButton
folderId={folder?.id}
isLoading={isTypebotLoading}
isFirstBot={typebots?.length === 0 && folder === null}
/>
)}
{isFolderLoading && <ButtonSkeleton />}

View File

@@ -8,17 +8,26 @@ import {
} from '@chakra-ui/react'
import { BlockWithOptions } from '@typebot.io/schemas'
import { getHelpDocUrl } from '@/features/graph/helpers/getHelpDocUrl'
import { useForgedBlock } from '@/features/forge/hooks/useForgedBlock'
import { useTranslate } from '@tolgee/react'
import { VideoOnboardingPopover } from '@/features/onboarding/components/VideoOnboardingPopover'
import { forgedBlocks } from '@typebot.io/forge-repository/definitions'
type Props = {
blockType: BlockWithOptions['type']
blockDef?: (typeof forgedBlocks)[keyof typeof forgedBlocks]
isVideoOnboardingItemDisplayed: boolean
onExpandClick: () => void
onVideoOnboardingClick: () => void
}
export const SettingsHoverBar = ({ blockType, onExpandClick }: Props) => {
export const SettingsHoverBar = ({
blockType,
blockDef,
isVideoOnboardingItemDisplayed,
onExpandClick,
onVideoOnboardingClick,
}: Props) => {
const { t } = useTranslate()
const { blockDef } = useForgedBlock(blockType)
const helpDocUrl = getHelpDocUrl(blockType, blockDef)
return (
<HStack
@@ -43,6 +52,10 @@ export const SettingsHoverBar = ({ blockType, onExpandClick }: Props) => {
as={Link}
leftIcon={<BuoyIcon />}
borderLeftRadius="none"
borderRightRadius={
isVideoOnboardingItemDisplayed ? 'none' : undefined
}
borderRightWidth={isVideoOnboardingItemDisplayed ? '1px' : undefined}
size="xs"
variant="ghost"
href={helpDocUrl}
@@ -51,6 +64,13 @@ export const SettingsHoverBar = ({ blockType, onExpandClick }: Props) => {
{t('help')}
</Button>
)}
{isVideoOnboardingItemDisplayed && (
<VideoOnboardingPopover.TriggerIconButton
onClick={onVideoOnboardingClick}
size="xs"
borderLeftRadius="none"
/>
)}
</HStack>
)
}

View File

@@ -45,6 +45,9 @@ import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integr
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { ForgedBlockSettings } from '../../../../forge/components/ForgedBlockSettings'
import { OpenAISettings } from '@/features/blocks/integrations/openai/components/OpenAISettings'
import { useForgedBlock } from '@/features/forge/hooks/useForgedBlock'
import { VideoOnboardingPopover } from '@/features/onboarding/components/VideoOnboardingPopover'
import { hasOnboardingVideo } from '@/features/onboarding/helpers/hasOnboardingVideo'
type Props = {
block: BlockWithOptions
@@ -56,6 +59,7 @@ type Props = {
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const [isHovering, setIsHovering] = useState(false)
const arrowColor = useColorModeValue('white', 'gray.800')
const { blockDef } = useForgedBlock(props.block.type)
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
@@ -63,39 +67,54 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow bgColor={arrowColor} />
<PopoverBody
py="3"
overflowY="auto"
maxH="400px"
ref={ref}
shadow="lg"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
<VideoOnboardingPopover.Root
type={props.block.type}
blockDef={blockDef}
>
<Stack spacing={3}>
<Flex
w="full"
pos="absolute"
top="-56px"
height="64px"
right={0}
justifyContent="flex-end"
align="center"
{({ onToggle }) => (
<PopoverBody
py="3"
overflowY="auto"
maxH="400px"
ref={ref}
shadow="lg"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<SlideFade in={isHovering} unmountOnExit>
<SettingsHoverBar
onExpandClick={onExpandClick}
blockType={props.block.type}
/>
</SlideFade>
</Flex>
<BlockSettings {...props} />
</Stack>
</PopoverBody>
<Stack spacing={3}>
<Flex
w="full"
pos="absolute"
top="-56px"
height="64px"
right={0}
justifyContent="flex-end"
align="center"
>
<SlideFade in={isHovering} unmountOnExit>
<SettingsHoverBar
onExpandClick={onExpandClick}
onVideoOnboardingClick={onToggle}
blockType={props.block.type}
blockDef={blockDef}
isVideoOnboardingItemDisplayed={hasOnboardingVideo({
blockType: props.block.type,
blockDef,
})}
/>
</SlideFade>
</Flex>
<BlockSettings {...props} />
</Stack>
</PopoverBody>
)}
</VideoOnboardingPopover.Root>
</PopoverContent>
</Portal>
)

View File

@@ -102,7 +102,7 @@ export const OnboardingPage = () => {
setTimeout(() => {
replace({
pathname: '/typebots',
query: { ...query, isFirstBot: true },
query: { ...query },
})
}, 2000)
}

View File

@@ -0,0 +1,68 @@
import { CloseIcon } from '@/components/icons'
import { useUser } from '@/features/account/hooks/useUser'
import {
IconButton,
SlideFade,
Flex,
useColorModeValue,
} from '@chakra-ui/react'
import { useOnboardingDisclosure } from '../hooks/useOnboardingDisclosure'
import { onboardingVideos } from '../data'
import { YoutubeIframe } from './YoutubeIframe'
type Props = {
type: keyof typeof onboardingVideos
}
export const VideoOnboardingFloatingWindow = ({ type }: Props) => {
const { user, updateUser } = useUser()
const { isOpen, onClose } = useOnboardingDisclosure({
key: type,
user,
updateUser,
defaultOpenDelay: 1000,
blockDef: undefined,
})
const bgColor = useColorModeValue('white', 'gray.900')
const closeButtonColorScheme = useColorModeValue('blackAlpha', 'gray')
if (!onboardingVideos[type]) return null
return (
<SlideFade
in={isOpen}
offsetY="20px"
style={{
position: 'fixed',
bottom: '18px',
right: '18px',
zIndex: 42,
}}
unmountOnExit
>
<Flex
p="5"
bgColor={bgColor}
borderWidth="1px"
shadow="xl"
rounded="md"
aspectRatio="1.5"
w="600px"
>
<YoutubeIframe id={onboardingVideos[type]!.youtubeId} />
<IconButton
icon={<CloseIcon />}
aria-label={'Close'}
pos="absolute"
top="-3"
right="-3"
colorScheme={closeButtonColorScheme}
size="sm"
rounded="full"
onClick={onClose}
/>
</Flex>
</SlideFade>
)
}

View File

@@ -0,0 +1,76 @@
import { CloseIcon, VideoPopoverIcon } from '@/components/icons'
import {
PopoverContent,
PopoverArrow,
PopoverBody,
IconButton,
Popover,
PopoverTrigger,
IconButtonProps,
} from '@chakra-ui/react'
import { useOnboardingDisclosure } from '../hooks/useOnboardingDisclosure'
import { onboardingVideos } from '../data'
import { useUser } from '@/features/account/hooks/useUser'
import { ForgedBlockDefinition } from '@typebot.io/forge-repository/types'
import { YoutubeIframe } from './YoutubeIframe'
type Props = {
type: keyof typeof onboardingVideos
defaultIsOpen?: boolean
blockDef?: ForgedBlockDefinition
children: ({ onToggle }: { onToggle: () => void }) => JSX.Element
}
const Root = ({ type, blockDef, children }: Props) => {
const { user, updateUser } = useUser()
const youtubeId =
onboardingVideos[type]?.youtubeId ?? blockDef?.onboarding?.youtubeId
const { isOpen, onClose, onToggle } = useOnboardingDisclosure({
key: type,
updateUser,
user,
blockDef,
})
if (!youtubeId) return children({ onToggle })
return (
<Popover isOpen={isOpen} placement="right" isLazy>
<PopoverTrigger>{children({ onToggle })}</PopoverTrigger>
<PopoverContent aspectRatio="1.5" width="640px">
<PopoverArrow />
<PopoverBody h="full" p="5">
<YoutubeIframe id={youtubeId} />
<IconButton
icon={<CloseIcon />}
aria-label={'Close'}
pos="absolute"
top="-3"
right="-3"
colorScheme="blackAlpha"
size="sm"
rounded="full"
onClick={onClose}
/>
</PopoverBody>
</PopoverContent>
</Popover>
)
}
const TriggerIconButton = (props: Omit<IconButtonProps, 'aria-label'>) => (
<IconButton
size="sm"
icon={<VideoPopoverIcon />}
aria-label={'Open Bubbles help video'}
variant="ghost"
colorScheme="blue"
{...props}
/>
)
export const VideoOnboardingPopover = {
Root,
TriggerIconButton,
}

View File

@@ -0,0 +1,60 @@
import { chakra } from '@chakra-ui/react'
import { useCallback, useEffect } from 'react'
declare const window: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
YT:
| undefined
| {
Player: new (
id: string,
options: { events: { onReady: () => void } }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => any
loaded: boolean
}
onYouTubeIframeAPIReady: () => void
}
type Props = {
id: string
}
export const YoutubeIframe = ({ id }: Props) => {
const initPlayer = useCallback(() => {
if (!window.YT) return
const player = new window.YT.Player(id, {
events: {
onReady: () => {
player.setPlaybackRate(1.2)
player.setPlaybackQuality('')
},
},
})
}, [id])
useEffect(() => {
if (window.YT?.loaded) initPlayer()
initYoutubeIframeApi()
window.onYouTubeIframeAPIReady = initPlayer
}, [initPlayer])
return (
<chakra.iframe
id={id}
src={`https://www.youtube.com/embed/${id}?autoplay=1&enablejsapi=1`}
allowFullScreen
allow="autoplay; fullscreen; picture-in-picture"
boxSize="full"
rounded="md"
/>
)
}
const initYoutubeIframeApi = () => {
if (document.getElementById('youtube-iframe-api')) return
const tag = document.createElement('script')
tag.src = 'https://www.youtube.com/iframe_api'
tag.id = 'youtube-iframe-api'
document.head.appendChild(tag)
}

View File

@@ -0,0 +1,32 @@
import { Block } from '@typebot.io/schemas'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
type Feature = 'editor' | Block['type']
export const onboardingVideos: Partial<
Record<
Feature,
| {
key: string
youtubeId: string
deployedAt: Date
}
| undefined
>
> = {
editor: {
key: 'editor',
youtubeId: 'jp3ggg_42-M',
deployedAt: new Date('2024-06-04'),
},
[IntegrationBlockType.ZAPIER]: {
key: IntegrationBlockType.ZAPIER,
youtubeId: '2ZskGItI_Zo',
deployedAt: new Date('2024-06-04'),
},
[IntegrationBlockType.MAKE_COM]: {
key: IntegrationBlockType.MAKE_COM,
youtubeId: 'V-y1Orys_kY',
deployedAt: new Date('2024-06-04'),
},
}

View File

@@ -0,0 +1,13 @@
import { ForgedBlockDefinition } from '@typebot.io/forge-repository/types'
import { Block } from '@typebot.io/schemas'
import { onboardingVideos } from '../data'
import { isDefined } from '@typebot.io/lib/utils'
type Props = {
blockType: Block['type']
blockDef?: ForgedBlockDefinition
}
export const hasOnboardingVideo = ({ blockType, blockDef }: Props) =>
isDefined(
blockDef?.onboarding?.youtubeId ?? onboardingVideos[blockType]?.youtubeId
)

View File

@@ -0,0 +1,65 @@
import { useDisclosure } from '@chakra-ui/react'
import { onboardingVideos } from '../data'
import { User } from '@typebot.io/schemas'
import { useEffect, useState } from 'react'
import { ForgedBlockDefinition } from '@typebot.io/forge-repository/types'
type Props = {
key?: keyof typeof onboardingVideos
updateUser: (data: Partial<User>) => void
user?: Pick<User, 'createdAt' | 'displayedInAppNotifications'>
defaultOpenDelay?: number
blockDef: ForgedBlockDefinition | undefined
}
export const useOnboardingDisclosure = ({
key,
updateUser,
user,
defaultOpenDelay,
blockDef,
}: Props) => {
const [isInitialized, setIsInitialized] = useState(false)
const { isOpen, onOpen, onClose, onToggle } = useDisclosure({
onOpen: () => {
if (!user || !key || user.displayedInAppNotifications?.[key]) return
updateUser({
displayedInAppNotifications: {
...user.displayedInAppNotifications,
[key]: true,
},
})
},
})
useEffect(() => {
if (isInitialized || !user?.createdAt || !key) return
setIsInitialized(true)
if (
key &&
new Date(user.createdAt) >=
(onboardingVideos[key]
? onboardingVideos[key]!.deployedAt
: blockDef?.onboarding?.deployedAt ?? new Date()) &&
user.displayedInAppNotifications?.[key] === undefined
) {
if (defaultOpenDelay) {
setTimeout(() => {
onOpen()
}, defaultOpenDelay)
} else {
onOpen()
}
}
}, [
blockDef?.onboarding?.deployedAt,
defaultOpenDelay,
isInitialized,
key,
onOpen,
user?.createdAt,
user?.displayedInAppNotifications,
])
return { isOpen, onClose, onToggle }
}

View File

@@ -42,12 +42,6 @@ export const CreateNewTypebotButtons = () => {
onSuccess: (data) => {
router.push({
pathname: `/typebots/${data.typebot.id}/edit`,
query:
router.query.isFirstBot === 'true'
? {
isFirstBot: 'true',
}
: {},
})
},
onSettled: () => {
@@ -68,12 +62,6 @@ export const CreateNewTypebotButtons = () => {
onSuccess: (data) => {
router.push({
pathname: `/typebots/${data.typebot.id}/edit`,
query:
router.query.isFirstBot === 'true'
? {
isFirstBot: 'true',
}
: {},
})
},
onSettled: () => {