2
0

🚸 (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

@ -678,3 +678,20 @@ export const BracesIcon = (props: IconProps) => (
<path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" />
</Icon>
)
export const VideoPopoverIcon = (props: IconProps) => (
<Icon viewBox="0 0 21 21" {...featherIconsBaseProps} {...props}>
<path
d="M6.9 18.0079C8.80858 18.9869 11.0041 19.2521 13.0909 18.7556C15.1777 18.2592 17.0186 17.0337 18.2818 15.3C19.545 13.5664 20.1474 11.4386 19.9806 9.30002C19.8137 7.16147 18.8886 5.15283 17.3718 3.63605C15.855 2.11928 13.8464 1.19411 11.7078 1.02728C9.56929 0.860441 7.44147 1.46291 5.70782 2.72611C3.97417 3.98931 2.74869 5.83017 2.25222 7.91697C1.75575 10.0038 2.02094 12.1993 3 14.1079L1 20.0079L6.9 18.0079Z"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 6L15 10L9 14V6Z"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Icon>
)

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,10 +67,17 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow bgColor={arrowColor} />
<VideoOnboardingPopover.Root
type={props.block.type}
blockDef={blockDef}
>
{({ onToggle }) => (
<PopoverBody
py="3"
overflowY="auto"
@ -89,13 +100,21 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
<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: () => {

View File

@ -1,4 +1,4 @@
import { OnboardingPage } from '@/features/auth/components/OnboardingPage'
import { OnboardingPage } from '@/features/onboarding/components/OnboardingPage'
export default function Page() {
return <OnboardingPage />

View File

@ -136,6 +136,10 @@ export type BlockDefinition<
LightLogo: (props: SVGProps<SVGSVGElement>) => JSX.Element
DarkLogo?: (props: SVGProps<SVGSVGElement>) => JSX.Element
docsUrl?: string
onboarding?: {
deployedAt: Date
youtubeId: string
}
auth?: Auth
options?: Options | undefined
fetchers?: FetcherDefinition<Auth, Options>[]