♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@@ -0,0 +1,83 @@
import { Flex, HStack, StackProps, Text, Tooltip } from '@chakra-ui/react'
import { BlockType, DraggableBlockType } from 'models'
import { useBlockDnd } from '@/features/graph'
import React, { useEffect, useState } from 'react'
import { BlockIcon } from './BlockIcon'
import { BlockTypeLabel } from './BlockTypeLabel'
export const BlockCard = ({
type,
onMouseDown,
isDisabled = false,
}: {
type: DraggableBlockType
isDisabled?: boolean
onMouseDown: (e: React.MouseEvent, type: DraggableBlockType) => void
}) => {
const { draggedBlockType } = useBlockDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
useEffect(() => {
setIsMouseDown(draggedBlockType === type)
}, [draggedBlockType, type])
const handleMouseDown = (e: React.MouseEvent) => onMouseDown(e, type)
return (
<Tooltip label="Coming soon!" isDisabled={!isDisabled}>
<Flex pos="relative">
<HStack
borderWidth="1px"
borderColor="gray.200"
rounded="lg"
flex="1"
cursor={'grab'}
opacity={isMouseDown || isDisabled ? '0.4' : '1'}
onMouseDown={handleMouseDown}
bgColor="gray.50"
px="4"
py="2"
_hover={{ shadow: 'md' }}
transition="box-shadow 200ms"
pointerEvents={isDisabled ? 'none' : 'auto'}
>
{!isMouseDown ? (
<>
<BlockIcon type={type} />
<BlockTypeLabel type={type} />
</>
) : (
<Text color="white" userSelect="none">
Placeholder
</Text>
)}
</HStack>
</Flex>
</Tooltip>
)
}
export const BlockCardOverlay = ({
type,
...props
}: StackProps & { type: BlockType }) => {
return (
<HStack
borderWidth="1px"
rounded="lg"
cursor={'grabbing'}
w="147px"
transition="none"
pointerEvents="none"
px="4"
py="2"
bgColor="white"
shadow="xl"
zIndex={2}
{...props}
>
<BlockIcon type={type} />
<BlockTypeLabel type={type} />
</HStack>
)
}

View File

@@ -0,0 +1,100 @@
import { IconProps } from '@chakra-ui/react'
import {
BubbleBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
BlockType,
} from 'models'
import React from 'react'
import { TextBubbleIcon } from '@/features/blocks/bubbles/textBubble'
import { ImageBubbleIcon } from '@/features/blocks/bubbles/image'
import { VideoBubbleIcon } from '@/features/blocks/bubbles/video'
import { ChatwootLogo } from '@/features/blocks/integrations/chatwoot'
import { FlagIcon } from '@/components/icons'
import { SendEmailIcon } from '@/features/blocks/integrations/sendEmail'
import { PabblyConnectLogo } from '@/features/blocks/integrations/pabbly'
import { MakeComLogo } from '@/features/blocks/integrations/makeCom'
import { ZapierLogo } from '@/features/blocks/integrations/zapier'
import { WebhookIcon } from '@/features/blocks/integrations/webhook'
import { GoogleSheetsLogo } from '@/features/blocks/integrations/googleSheets'
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink'
import { CodeIcon } from '@/features/blocks/logic/code'
import { RedirectIcon } from '@/features/blocks/logic/redirect'
import { ConditionIcon } from '@/features/blocks/logic/condition'
import { SetVariableIcon } from '@/features/blocks/logic/setVariable'
import { FileInputIcon } from '@/features/blocks/inputs/fileUpload'
import { RatingInputIcon } from '@/features/blocks/inputs/rating'
import { PaymentInputIcon } from '@/features/blocks/inputs/payment'
import { ButtonsInputIcon } from '@/features/blocks/inputs/buttons'
import { PhoneInputIcon } from '@/features/blocks/inputs/phone'
import { DateInputIcon } from '@/features/blocks/inputs/date'
import { UrlInputIcon } from '@/features/blocks/inputs/url'
import { EmailInputIcon } from '@/features/blocks/inputs/emailInput'
import { NumberInputIcon } from '@/features/blocks/inputs/number'
import { TextInputIcon } from '@/features/blocks/inputs/textInput'
import { EmbedBubbleIcon } from '@/features/blocks/bubbles/embed'
import { GoogleAnalyticsLogo } from '@/features/blocks/integrations/googleAnalytics'
type BlockIconProps = { type: BlockType } & IconProps
export const BlockIcon = ({ type, ...props }: BlockIconProps) => {
switch (type) {
case BubbleBlockType.TEXT:
return <TextBubbleIcon {...props} />
case BubbleBlockType.IMAGE:
return <ImageBubbleIcon {...props} />
case BubbleBlockType.VIDEO:
return <VideoBubbleIcon {...props} />
case BubbleBlockType.EMBED:
return <EmbedBubbleIcon color="blue.500" {...props} />
case InputBlockType.TEXT:
return <TextInputIcon color="orange.500" {...props} />
case InputBlockType.NUMBER:
return <NumberInputIcon color="orange.500" {...props} />
case InputBlockType.EMAIL:
return <EmailInputIcon color="orange.500" {...props} />
case InputBlockType.URL:
return <UrlInputIcon color="orange.500" {...props} />
case InputBlockType.DATE:
return <DateInputIcon color="orange.500" {...props} />
case InputBlockType.PHONE:
return <PhoneInputIcon color="orange.500" {...props} />
case InputBlockType.CHOICE:
return <ButtonsInputIcon color="orange.500" {...props} />
case InputBlockType.PAYMENT:
return <PaymentInputIcon color="orange.500" {...props} />
case InputBlockType.RATING:
return <RatingInputIcon color="orange.500" {...props} />
case InputBlockType.FILE:
return <FileInputIcon color="orange.500" {...props} />
case LogicBlockType.SET_VARIABLE:
return <SetVariableIcon color="purple.500" {...props} />
case LogicBlockType.CONDITION:
return <ConditionIcon color="purple.500" {...props} />
case LogicBlockType.REDIRECT:
return <RedirectIcon color="purple.500" {...props} />
case LogicBlockType.CODE:
return <CodeIcon color="purple.500" {...props} />
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkIcon color="purple.500" {...props} />
case IntegrationBlockType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationBlockType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} />
case IntegrationBlockType.WEBHOOK:
return <WebhookIcon {...props} />
case IntegrationBlockType.ZAPIER:
return <ZapierLogo {...props} />
case IntegrationBlockType.MAKE_COM:
return <MakeComLogo {...props} />
case IntegrationBlockType.PABBLY_CONNECT:
return <PabblyConnectLogo {...props} />
case IntegrationBlockType.EMAIL:
return <SendEmailIcon {...props} />
case IntegrationBlockType.CHATWOOT:
return <ChatwootLogo {...props} />
case 'start':
return <FlagIcon {...props} />
}
}

View File

@@ -0,0 +1,103 @@
import { HStack, Text, Tooltip } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import {
BubbleBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
BlockType,
} from 'models'
import React from 'react'
import { isFreePlan, LockTag } from '@/features/billing'
type Props = { type: BlockType }
export const BlockTypeLabel = ({ type }: Props): JSX.Element => {
const { workspace } = useWorkspace()
switch (type) {
case 'start':
return <Text>Start</Text>
case BubbleBlockType.TEXT:
case InputBlockType.TEXT:
return <Text>Text</Text>
case BubbleBlockType.IMAGE:
return <Text>Image</Text>
case BubbleBlockType.VIDEO:
return <Text>Video</Text>
case BubbleBlockType.EMBED:
return (
<Tooltip label="Embed a pdf, an iframe, a website...">
<Text>Embed</Text>
</Tooltip>
)
case InputBlockType.NUMBER:
return <Text>Number</Text>
case InputBlockType.EMAIL:
return <Text>Email</Text>
case InputBlockType.URL:
return <Text>Website</Text>
case InputBlockType.DATE:
return <Text>Date</Text>
case InputBlockType.PHONE:
return <Text>Phone</Text>
case InputBlockType.CHOICE:
return <Text>Button</Text>
case InputBlockType.PAYMENT:
return <Text>Payment</Text>
case InputBlockType.RATING:
return <Text>Rating</Text>
case InputBlockType.FILE:
return (
<Tooltip label="Upload Files">
<HStack>
<Text>File</Text>
{isFreePlan(workspace) && <LockTag plan={Plan.STARTER} />}
</HStack>
</Tooltip>
)
case LogicBlockType.SET_VARIABLE:
return <Text>Set variable</Text>
case LogicBlockType.CONDITION:
return <Text>Condition</Text>
case LogicBlockType.REDIRECT:
return <Text>Redirect</Text>
case LogicBlockType.CODE:
return (
<Tooltip label="Run Javascript code">
<Text>Code</Text>
</Tooltip>
)
case LogicBlockType.TYPEBOT_LINK:
return (
<Tooltip label="Link to another of your typebots">
<Text>Typebot</Text>
</Tooltip>
)
case IntegrationBlockType.GOOGLE_SHEETS:
return (
<Tooltip label="Google Sheets">
<Text>Sheets</Text>
</Tooltip>
)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return (
<Tooltip label="Google Analytics">
<Text>Analytics</Text>
</Tooltip>
)
case IntegrationBlockType.WEBHOOK:
return <Text>Webhook</Text>
case IntegrationBlockType.ZAPIER:
return <Text>Zapier</Text>
case IntegrationBlockType.MAKE_COM:
return <Text>Make.com</Text>
case IntegrationBlockType.PABBLY_CONNECT:
return <Text>Pabbly</Text>
case IntegrationBlockType.EMAIL:
return <Text>Email</Text>
case IntegrationBlockType.CHATWOOT:
return <Text>Chatwoot</Text>
}
}

View File

@@ -0,0 +1,194 @@
import {
Stack,
Text,
SimpleGrid,
useEventListener,
Portal,
Flex,
IconButton,
Tooltip,
Fade,
} from '@chakra-ui/react'
import {
BubbleBlockType,
DraggableBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
} from 'models'
import { useBlockDnd } from '@/features/graph'
import React, { useState } from 'react'
import { BlockCard, BlockCardOverlay } from './BlockCard'
import { LockedIcon, UnlockedIcon } from '@/components/icons'
import { headerHeight } from '../../constants'
export const BlocksSideBar = () => {
const { setDraggedBlockType, draggedBlockType } = useBlockDnd()
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
const [isLocked, setIsLocked] = useState(true)
const [isExtended, setIsExtended] = useState(true)
const handleMouseMove = (event: MouseEvent) => {
if (!draggedBlockType) return
const { clientX, clientY } = event
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
})
}
useEventListener('mousemove', handleMouseMove)
const handleMouseDown = (e: React.MouseEvent, type: DraggableBlockType) => {
const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
setPosition({ x: rect.left, y: rect.top })
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setRelativeCoordinates({ x, y })
setDraggedBlockType(type)
}
const handleMouseUp = () => {
if (!draggedBlockType) return
setDraggedBlockType(undefined)
setPosition({
x: 0,
y: 0,
})
}
useEventListener('mouseup', handleMouseUp)
const handleLockClick = () => setIsLocked(!isLocked)
const handleDockBarEnter = () => setIsExtended(true)
const handleMouseLeave = () => {
if (isLocked) return
setIsExtended(false)
}
return (
<Flex
w="360px"
pos="absolute"
left="0"
h={`calc(100vh - ${headerHeight}px)`}
zIndex="2"
pl="4"
py="4"
onMouseLeave={handleMouseLeave}
transform={isExtended ? 'translateX(0)' : 'translateX(-350px)'}
transition="transform 350ms cubic-bezier(0.075, 0.82, 0.165, 1) 0s"
>
<Stack
w="full"
rounded="lg"
shadow="xl"
borderWidth="1px"
pt="2"
pb="10"
px="4"
bgColor="white"
spacing={6}
userSelect="none"
overflowY="scroll"
className="hide-scrollbar"
>
<Flex justifyContent="flex-end">
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
<IconButton
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
aria-label={isLocked ? 'Unlock' : 'Lock'}
size="sm"
variant="outline"
onClick={handleLockClick}
/>
</Tooltip>
</Flex>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Bubbles
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(BubbleBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Inputs
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(InputBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Logic
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(LogicBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Integrations
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(IntegrationBlockType).map((type) => (
<BlockCard
key={type}
type={type}
onMouseDown={handleMouseDown}
isDisabled={type === IntegrationBlockType.MAKE_COM}
/>
))}
</SimpleGrid>
</Stack>
{draggedBlockType && (
<Portal>
<BlockCardOverlay
type={draggedBlockType}
onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
}}
/>
</Portal>
)}
</Stack>
<Fade in={!isLocked} unmountOnExit>
<Flex
pos="absolute"
h="100%"
right="-50px"
w="50px"
top="0"
justify="center"
align="center"
onMouseEnter={handleDockBarEnter}
>
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
</Flex>
</Fade>
</Flex>
)
}

View File

@@ -0,0 +1 @@
export { BlocksSideBar } from './BlocksSideBar'

View File

@@ -0,0 +1,79 @@
import {
IconButton,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
useDisclosure,
} from '@chakra-ui/react'
import assert from 'assert'
import {
DownloadIcon,
MoreVerticalIcon,
SettingsIcon,
} from '@/components/icons'
import { useTypebot } from '../providers/TypebotProvider'
import { useUser } from '@/features/account'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { isNotDefined } from 'utils'
import { EditorSettingsModal } from './EditorSettingsModal'
import { parseDefaultPublicId } from '@/features/publish'
export const BoardMenuButton = (props: MenuButtonProps) => {
const { query } = useRouter()
const { typebot } = useTypebot()
const { user } = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
useEffect(() => {
if (
user &&
isNotDefined(user.graphNavigation) &&
isNotDefined(query.isFirstBot)
)
onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const downloadFlow = () => {
assert(typebot)
setIsDownloading(true)
const data =
'data:application/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(typebot))
const fileName = `typebot-export-${parseDefaultPublicId(
typebot.name,
typebot.id
)}.json`
const linkElement = document.createElement('a')
linkElement.setAttribute('href', data)
linkElement.setAttribute('download', fileName)
linkElement.click()
setIsDownloading(false)
}
return (
<Menu>
<MenuButton
as={IconButton}
bgColor="white"
icon={<MoreVerticalIcon transform={'rotate(90deg)'} />}
isLoading={isDownloading}
size="sm"
shadow="lg"
{...props}
/>
<MenuList>
<MenuItem icon={<SettingsIcon />} onClick={onOpen}>
Editor settings
</MenuItem>
<MenuItem icon={<DownloadIcon />} onClick={downloadFlow}>
Export flow
</MenuItem>
</MenuList>
<EditorSettingsModal isOpen={isOpen} onClose={onClose} />
</Menu>
)
}

View File

@@ -0,0 +1,69 @@
import { Seo } from '@/components/Seo'
import {
Graph,
GraphDndProvider,
GraphProvider,
GroupsCoordinatesProvider,
} from '@/features/graph'
import { Flex, Spinner } from '@chakra-ui/react'
import {
EditorProvider,
useEditor,
RightPanel as RightPanelEnum,
} from '../providers/EditorProvider'
import { useTypebot } from '../providers/TypebotProvider'
import { BlocksSideBar } from './BlocksSideBar'
import { BoardMenuButton } from './BoardMenuButton'
import { GettingStartedModal } from './GettingStartedModal'
import { PreviewDrawer } from './PreviewDrawer'
import { TypebotHeader } from './TypebotHeader'
export const EditTypebotPage = () => {
const { typebot, isReadOnly } = useTypebot()
return (
<EditorProvider>
<Seo title="Editor" />
<Flex overflow="clip" h="100vh" flexDir="column" id="editor-container">
<GettingStartedModal />
<TypebotHeader />
<Flex
flex="1"
pos="relative"
h="full"
background="#f4f5f8"
backgroundImage="radial-gradient(#c6d0e1 1px, transparent 0)"
backgroundSize="40px 40px"
backgroundPosition="-19px -19px"
>
{typebot ? (
<GraphDndProvider>
<BlocksSideBar />
<GraphProvider isReadOnly={isReadOnly}>
<GroupsCoordinatesProvider groups={typebot.groups}>
<Graph flex="1" typebot={typebot} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</GroupsCoordinatesProvider>
</GraphProvider>
</GraphDndProvider>
) : (
<Flex
justify="center"
align="center"
boxSize="full"
bgColor="rgba(255, 255, 255, 0.5)"
>
<Spinner color="gray" />
</Flex>
)}
</Flex>
</Flex>
</EditorProvider>
)
}
const RightPanel = () => {
const { rightPanel } = useEditor()
return rightPanel === RightPanelEnum.PREVIEW ? <PreviewDrawer /> : <></>
}

View File

@@ -0,0 +1,76 @@
import {
Stack,
Heading,
HStack,
Text,
Radio,
RadioGroup,
VStack,
} from '@chakra-ui/react'
import { MouseIcon, LaptopIcon } from '@/components/icons'
import { useUser } from '@/features/account'
import { GraphNavigation } from 'db'
import React, { useEffect, useState } from 'react'
export const EditorSettingsForm = () => {
const { user, saveUser } = useUser()
const [value, setValue] = useState<string>(
user?.graphNavigation ?? GraphNavigation.TRACKPAD
)
useEffect(() => {
if (user?.graphNavigation === value) return
saveUser({ graphNavigation: value as GraphNavigation }).then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const options = [
{
value: GraphNavigation.MOUSE,
label: 'Mouse',
description:
'Move by dragging the board and zoom in/out using the scroll wheel',
icon: <MouseIcon boxSize="35px" />,
},
{
value: GraphNavigation.TRACKPAD,
label: 'Trackpad',
description: 'Move the board using 2 fingers and zoom in/out by pinching',
icon: <LaptopIcon boxSize="35px" />,
},
]
return (
<Stack spacing={6}>
<Heading size="md">Editor Navigation</Heading>
<RadioGroup onChange={setValue} value={value}>
<HStack spacing={4} w="full" align="stretch">
{options.map((option) => (
<VStack
key={option.value}
as="label"
htmlFor={option.label}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
w="full"
p="6"
spacing={6}
justifyContent="space-between"
>
<VStack spacing={6}>
{option.icon}
<Stack>
<Text fontWeight="bold">{option.label}</Text>
<Text>{option.description}</Text>
</Stack>
</VStack>
<Radio value={option.value} id={option.label} />
</VStack>
))}
</HStack>
</RadioGroup>
</Stack>
)
}

View File

@@ -0,0 +1,28 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalOverlay,
} from '@chakra-ui/react'
import React from 'react'
import { EditorSettingsForm } from './EditorSettingsForm'
type Props = {
isOpen: boolean
onClose: () => void
}
export const EditorSettingsModal = ({ isOpen, onClose }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody pt="12" pb="8" px="8">
<EditorSettingsForm />
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,172 @@
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'
export const GettingStartedModal = () => {
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">Editor basics</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>
The left side bar contains blocks that you can drag and drop
to the board.
</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>
You can group blocks together by dropping them below or above
each other
</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>Connect the groups together</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>
Preview your bot by clicking the preview button on the top
right
</Text>
</HStack>
</List>
</Stack>
<Text>
Feel free to use the bottom-right bubble to reach out if you have
any question. I usually answer within the next 24 hours. 😃
</Text>
<Stack spacing={4}>
<Heading fontSize="xl">See it in action ({`<`} 5 minutes)</Heading>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/jp3ggg_42-M"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Box flex="1" textAlign="left">
Other videos
</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"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/ZuyDwFLRbfQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,134 @@
import {
Box,
Button,
CloseButton,
Fade,
Flex,
FlexProps,
useEventListener,
UseToastOptions,
VStack,
} from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine'
import { useToast } from '@/hooks/useToast'
import { useEditor } from '../providers/EditorProvider'
import { useGraph } from '@/features/graph'
import { useTypebot } from '../providers/TypebotProvider'
import { Log } from 'db'
import React, { useMemo, useState } from 'react'
import { getViewerUrl } from 'utils'
import { headerHeight } from '../constants'
import { parseTypebotToPublicTypebot } from '@/features/publish'
export const PreviewDrawer = () => {
const { typebot } = useTypebot()
const { setRightPanel, startPreviewAtGroup } = useEditor()
const { setPreviewingEdge } = useGraph()
const [isResizing, setIsResizing] = useState(false)
const [width, setWidth] = useState(500)
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
const [restartKey, setRestartKey] = useState(0)
const publicTypebot = useMemo(
() => (typebot ? { ...parseTypebotToPublicTypebot(typebot) } : undefined),
[typebot]
)
const { showToast } = useToast()
const handleMouseDown = () => {
setIsResizing(true)
}
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return
setWidth(width - e.movementX)
}
useEventListener('mousemove', handleMouseMove)
const handleMouseUp = () => {
setIsResizing(false)
}
useEventListener('mouseup', handleMouseUp)
const handleRestartClick = () => setRestartKey((key) => key + 1)
const handleCloseClick = () => {
setPreviewingEdge(undefined)
setRightPanel(undefined)
}
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
showToast(log as UseToastOptions)
return (
<Flex
pos="absolute"
right="0"
top={`0`}
h={`100%`}
w={`${width}px`}
bgColor="white"
shadow="lg"
borderLeftRadius={'lg'}
onMouseOver={() => setIsResizeHandleVisible(true)}
onMouseLeave={() => setIsResizeHandleVisible(false)}
p="6"
zIndex={10}
>
<Fade in={isResizeHandleVisible}>
<ResizeHandle
pos="absolute"
left="-7.5px"
top={`calc(50% - ${headerHeight}px)`}
onMouseDown={handleMouseDown}
/>
</Fade>
<VStack w="full" spacing={4}>
<Flex justifyContent={'space-between'} w="full">
<Button onClick={handleRestartClick}>Restart</Button>
<CloseButton onClick={handleCloseClick} />
</Flex>
{publicTypebot && (
<Flex
borderWidth={'1px'}
borderRadius={'lg'}
h="full"
w="full"
key={restartKey + (startPreviewAtGroup ?? '')}
pointerEvents={isResizing ? 'none' : 'auto'}
>
<TypebotViewer
apiHost={getViewerUrl({ isBuilder: true })}
typebot={publicTypebot}
onNewGroupVisible={setPreviewingEdge}
onNewLog={handleNewLog}
startGroupId={startPreviewAtGroup}
isPreview
/>
</Flex>
)}
</VStack>
</Flex>
)
}
const ResizeHandle = (props: FlexProps) => {
return (
<Flex
w="15px"
h="50px"
borderWidth={'1px'}
bgColor={'white'}
cursor={'col-resize'}
justifyContent={'center'}
align={'center'}
{...props}
>
<Box w="2px" bgColor={'gray.300'} h="70%" mr="0.5" />
<Box w="2px" bgColor={'gray.300'} h="70%" />
</Flex>
)
}

View File

@@ -0,0 +1,37 @@
import {
Editable,
EditablePreview,
EditableInput,
Tooltip,
} from '@chakra-ui/react'
import React, { useEffect, useState } from 'react'
type EditableProps = {
name: string
onNewName: (newName: string) => void
}
export const EditableTypebotName = ({ name, onNewName }: EditableProps) => {
const [localName, setLocalName] = useState(name)
useEffect(() => {
if (name !== localName) setLocalName(name)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name])
return (
<Tooltip label="Rename">
<Editable value={localName} onChange={setLocalName} onSubmit={onNewName}>
<EditablePreview
noOfLines={2}
cursor="pointer"
maxW="150px"
overflow="hidden"
display="flex"
alignItems="center"
fontSize="14px"
/>
<EditableInput fontSize="14px" />
</Editable>
</Tooltip>
)
}

View File

@@ -0,0 +1,214 @@
import {
Flex,
HStack,
Button,
IconButton,
Tooltip,
Spinner,
Text,
} from '@chakra-ui/react'
import {
BuoyIcon,
ChevronLeftIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons'
import { RightPanel, useEditor } from '../../providers/EditorProvider'
import { useTypebot } from '../../providers/TypebotProvider'
import { useRouter } from 'next/router'
import React from 'react'
import { isNotDefined } from 'utils'
import { EditableTypebotName } from './EditableTypebotName'
import { getBubbleActions } from 'typebot-js'
import Link from 'next/link'
import { isCloudProdInstance } from '@/utils/helpers'
import { headerHeight } from '../../constants'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { PublishButton } from '@/features/publish'
import { CollaborationMenuButton } from '@/features/collaboration'
export const TypebotHeader = () => {
const router = useRouter()
const {
typebot,
updateTypebot,
save,
undo,
redo,
canUndo,
canRedo,
isSavingLoading,
} = useTypebot()
const { setRightPanel, rightPanel, setStartPreviewAtGroup } = useEditor()
const handleNameSubmit = (name: string) => updateTypebot({ name })
const handleChangeIcon = (icon: string) => updateTypebot({ icon })
const handlePreviewClick = async () => {
setStartPreviewAtGroup(undefined)
save().then()
setRightPanel(RightPanel.PREVIEW)
}
const handleHelpClick = () => {
isCloudProdInstance()
? getBubbleActions().open()
: window.open('https://docs.typebot.io', '_blank')
}
return (
<Flex
w="full"
borderBottomWidth="1px"
justify="center"
align="center"
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor="white"
flexShrink={0}
>
<HStack
display={['none', 'flex']}
pos={{ base: 'absolute', xl: 'static' }}
right={{ base: 280, xl: 0 }}
>
<Button
as={Link}
href={`/typebots/${typebot?.id}/edit`}
colorScheme={router.pathname.includes('/edit') ? 'blue' : 'gray'}
variant={router.pathname.includes('/edit') ? 'outline' : 'ghost'}
size="sm"
>
Flow
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/theme`}
colorScheme={router.pathname.endsWith('theme') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('theme') ? 'outline' : 'ghost'}
size="sm"
>
Theme
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/settings`}
colorScheme={router.pathname.endsWith('settings') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('settings') ? 'outline' : 'ghost'}
size="sm"
>
Settings
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/share`}
colorScheme={router.pathname.endsWith('share') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('share') ? 'outline' : 'ghost'}
size="sm"
>
Share
</Button>
{typebot?.publishedTypebotId && (
<Button
as={Link}
href={`/typebots/${typebot?.id}/results`}
colorScheme={router.pathname.includes('results') ? 'blue' : 'gray'}
variant={router.pathname.includes('results') ? 'outline' : 'ghost'}
size="sm"
>
Results
</Button>
)}
</HStack>
<HStack
pos="absolute"
left="1rem"
justify="center"
align="center"
spacing="6"
>
<HStack alignItems="center" spacing={3}>
<IconButton
as={Link}
aria-label="Navigate back"
icon={<ChevronLeftIcon fontSize={25} />}
href={
router.query.parentId
? `/typebots/${router.query.parentId}/edit`
: typebot?.folderId
? `/typebots/folders/${typebot.folderId}`
: '/typebots'
}
size="sm"
/>
<HStack spacing={1}>
{typebot && (
<EditableEmojiOrImageIcon
uploadFilePath={`typebots/${typebot.id}/icon`}
icon={typebot?.icon}
onChangeIcon={handleChangeIcon}
/>
)}
{typebot?.name && (
<EditableTypebotName
name={typebot?.name}
onNewName={handleNameSubmit}
/>
)}
</HStack>
<HStack>
<Tooltip label="Undo">
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
<Button leftIcon={<BuoyIcon />} onClick={handleHelpClick} size="sm">
Help
</Button>
</HStack>
{isSavingLoading && (
<HStack>
<Spinner speed="0.7s" size="sm" color="gray.400" />
<Text fontSize="sm" color="gray.400">
Saving...
</Text>
</HStack>
)}
</HStack>
<HStack right="40px" pos="absolute" display={['none', 'flex']}>
<CollaborationMenuButton isLoading={isNotDefined(typebot)} />
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
size="sm"
>
Preview
</Button>
)}
<PublishButton size="sm" />
</HStack>
</Flex>
)
}

View File

@@ -0,0 +1 @@
export * from './TypebotHeader'