♻️ Re-organize workspace folders

This commit is contained in:
Baptiste Arnaud
2023-03-15 08:35:16 +01:00
parent 25c367901f
commit cbc8194f19
987 changed files with 2716 additions and 2770 deletions

View File

@@ -0,0 +1,70 @@
import React, {
ForwardedRef,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { Avatar } from '../avatars/Avatar'
import { CSSTransition } from 'react-transition-group'
import { ResizeObserver } from 'resize-observer'
type Props = { hostAvatarSrc?: string; keepShowing: boolean }
export const AvatarSideContainer = forwardRef(function AvatarSideContainer(
{ hostAvatarSrc, keepShowing }: Props,
ref: ForwardedRef<unknown>
) {
const [show, setShow] = useState(false)
const [avatarTopOffset, setAvatarTopOffset] = useState(0)
const refreshTopOffset = () => {
if (!scrollingSideGroupRef.current || !avatarContainer.current) return
const { height } = scrollingSideGroupRef.current.getBoundingClientRect()
const { height: avatarHeight } =
avatarContainer.current.getBoundingClientRect()
setAvatarTopOffset(height - avatarHeight)
}
const scrollingSideGroupRef = useRef<HTMLDivElement>(null)
const avatarContainer = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => ({
refreshTopOffset,
}))
useEffect(() => {
if (!document) return
setShow(true)
const resizeObserver = new ResizeObserver(refreshTopOffset)
resizeObserver.observe(document.body)
return () => {
resizeObserver.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container "
ref={scrollingSideGroupRef}
>
<CSSTransition
classNames="bubble"
timeout={500}
in={show && keepShowing}
unmountOnExit
>
<div
className="absolute w-6 xs:w-10 h-6 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
ref={avatarContainer}
style={{
top: `${avatarTopOffset}px`,
transition: 'top 350ms ease-out, opacity 500ms',
}}
>
<Avatar avatarSrc={hostAvatarSrc} />
</div>
</CSSTransition>
</div>
)
})

View File

@@ -0,0 +1,174 @@
import React, { useState } from 'react'
import { useAnswers } from '../../../providers/AnswersProvider'
import { InputBlock, InputBlockType } from '@typebot.io/schemas'
import { GuestBubble } from './bubbles/GuestBubble'
import { byId } from '@typebot.io/lib'
import { InputSubmitContent } from '@/types'
import { useTypebot } from '@/providers/TypebotProvider'
import { isInputValid } from '@/utils/inputs'
import { parseVariables } from '@/features/variables'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
import { UrlInput } from '@/features/blocks/inputs/url'
import { PhoneInput } from '@/features/blocks/inputs/phone'
import { DateForm } from '@/features/blocks/inputs/date'
import { ChoiceForm } from '@/features/blocks/inputs/buttons'
import { PaymentForm } from '@/features/blocks/inputs/payment'
import { RatingForm } from '@/features/blocks/inputs/rating'
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
export const InputChatBlock = ({
block,
hasAvatar,
hasGuestAvatar,
onTransitionEnd,
onSkip,
}: {
block: InputBlock
hasGuestAvatar: boolean
hasAvatar: boolean
onTransitionEnd: (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => void
onSkip: () => void
}) => {
const { typebot, isLoading } = useTypebot()
const { addAnswer } = useAnswers()
const [answer, setAnswer] = useState<string>()
const [isEditting, setIsEditting] = useState(false)
const { variableId } = block.options
const defaultValue =
(typebot.settings.general.isInputPrefillEnabled ?? true) && variableId
? typebot.variables.find(
(variable) =>
variable.name === typebot.variables.find(byId(variableId))?.name
)?.value
: undefined
const handleSubmit = async ({ label, value, itemId }: InputSubmitContent) => {
setAnswer(label ?? value)
const isRetry = !isInputValid(value, block.type)
if (!isRetry && addAnswer)
await addAnswer(typebot.variables)({
blockId: block.id,
groupId: block.groupId,
content: value,
variableId,
uploadedFiles: block.type === InputBlockType.FILE,
})
if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry)
setIsEditting(false)
}
if (isLoading) return null
if (answer) {
const avatarUrl = typebot.theme.chat.guestAvatar?.url
return (
<GuestBubble
message={answer}
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
avatarSrc={avatarUrl && parseVariables(typebot.variables)(avatarUrl)}
/>
)
}
return (
<div className="flex justify-end">
{hasAvatar && (
<div className="flex w-6 xs:w-10 h-6 xs:h-10 mr-2 mb-2 mt-1 flex-shrink-0 items-center" />
)}
<Input
block={block}
onSubmit={handleSubmit}
onSkip={onSkip}
defaultValue={defaultValue?.toString()}
hasGuestAvatar={hasGuestAvatar}
/>
</div>
)
}
const Input = ({
block,
onSubmit,
onSkip,
defaultValue,
hasGuestAvatar,
}: {
block: InputBlock
onSubmit: (value: InputSubmitContent) => void
onSkip: () => void
defaultValue?: string
hasGuestAvatar: boolean
}) => {
switch (block.type) {
case InputBlockType.TEXT:
return (
<TextInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.NUMBER:
return (
<NumberInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.EMAIL:
return (
<EmailInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.URL:
return (
<UrlInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.PHONE:
return (
<PhoneInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.DATE:
return <DateForm options={block.options} onSubmit={onSubmit} />
case InputBlockType.CHOICE:
return <ChoiceForm block={block} onSubmit={onSubmit} />
case InputBlockType.PAYMENT:
return (
<PaymentForm
options={block.options}
onSuccess={() =>
onSubmit({ value: block.options.labels.success ?? 'Success' })
}
/>
)
case InputBlockType.RATING:
return <RatingForm block={block} onSubmit={onSubmit} />
case InputBlockType.FILE:
return (
<FileUploadForm block={block} onSubmit={onSubmit} onSkip={onSkip} />
)
}
}

View File

@@ -0,0 +1,34 @@
import { Avatar } from '@/components/avatars/Avatar'
import React, { useState } from 'react'
import { CSSTransition } from 'react-transition-group'
interface Props {
message: string
showAvatar: boolean
avatarSrc?: string
}
export const GuestBubble = ({
message,
showAvatar,
avatarSrc,
}: Props): JSX.Element => {
const [content] = useState(message)
return (
<CSSTransition classNames="bubble" timeout={1000}>
<div
className="flex justify-end mb-2 items-end"
style={{ marginLeft: '50px' }}
>
<span
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer"
data-testid="guest-bubble"
>
{content}
</span>
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
</div>
</CSSTransition>
)
}

View File

@@ -0,0 +1,31 @@
import { AudioBubble } from '@/features/blocks/bubbles/audio'
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import { BubbleBlock, BubbleBlockType } from '@typebot.io/schemas'
type Props = {
block: BubbleBlock
onTransitionEnd: () => void
}
export const HostBubble = ({ block, onTransitionEnd }: Props) => {
switch (block.type) {
case BubbleBlockType.TEXT:
return <TextBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.IMAGE:
return <ImageBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.VIDEO:
return <VideoBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.EMBED:
return <EmbedBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.AUDIO:
return (
<AudioBubble
url={block.content.url}
onTransitionEnd={onTransitionEnd}
/>
)
}
}

View File

@@ -0,0 +1,345 @@
import { useEffect, useRef, useState } from 'react'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { AvatarSideContainer } from './AvatarSideContainer'
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
import {
isBubbleBlock,
isBubbleBlockType,
isChoiceInput,
isDefined,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
byId,
} from '@typebot.io/lib'
import {
BubbleBlock,
InputBlock,
LogicBlockType,
PublicTypebot,
Block,
} from '@typebot.io/schemas'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { InputChatBlock } from './ChatBlock/InputChatBlock'
import { parseVariables } from '@/features/variables'
import { useAnswers } from '@/providers/AnswersProvider'
import { useChat } from '@/providers/ChatProvider'
import { InputSubmitContent } from '@/types'
import { getLastChatBlockType } from '@/utils/chat'
import { executeIntegration } from '@/utils/executeIntegration'
import { executeLogic } from '@/utils/executeLogic'
import { blockCanBeRetried, parseRetryBlock } from '@/utils/inputs'
import { PopupBlockedToast } from '../PopupBlockedToast'
type ChatGroupProps = {
blocks: Block[]
startBlockIndex: number
groupTitle: string
keepShowingHostAvatar: boolean
onGroupEnd: ({
edgeId,
updatedTypebot,
}: {
edgeId?: string
updatedTypebot?: PublicTypebot | LinkedTypebot
}) => void
}
type ChatDisplayChunk = { bubbles: BubbleBlock[]; input?: InputBlock }
export const ChatGroup = ({
blocks,
startBlockIndex,
groupTitle,
onGroupEnd,
keepShowingHostAvatar,
}: ChatGroupProps) => {
const {
currentTypebotId,
typebot,
updateVariableValue,
createEdge,
apiHost,
isPreview,
parentTypebotIds,
onNewLog,
injectLinkedTypebot,
linkedTypebots,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
pushParentTypebotId,
} = useTypebot()
const { resultValues, updateVariables, resultId } = useAnswers()
const { scroll } = useChat()
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
const [blockedPopupUrl, setBlockedPopupUrl] = useState<string>()
const insertBlockInStack = (nextBlock: Block) => {
setProcessedBlocks([...processedBlocks, nextBlock])
if (isBubbleBlock(nextBlock)) {
const lastBlockType = getLastChatBlockType(processedBlocks)
lastBlockType && isBubbleBlockType(lastBlockType)
? setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1
? { bubbles: [...c.bubbles, nextBlock] }
: c
)
)
: setDisplayedChunks([...displayedChunks, { bubbles: [nextBlock] }])
}
if (isInputBlock(nextBlock)) {
displayedChunks.length === 0 ||
isDefined(displayedChunks[displayedChunks.length - 1].input)
? setDisplayedChunks([
...displayedChunks,
{ bubbles: [], input: nextBlock },
])
: setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1
? { ...c, input: nextBlock }
: c
)
)
}
}
useEffect(() => {
const nextBlock = blocks[startBlockIndex]
if (nextBlock) insertBlockInStack(nextBlock)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scroll()
onNewBlockDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processedBlocks])
const onNewBlockDisplayed = async () => {
const currentBlock = [...processedBlocks].pop()
if (!currentBlock) return
if (isLogicBlock(currentBlock)) {
const { nextEdgeId, linkedTypebot, blockedPopupUrl } = await executeLogic(
currentBlock,
{
isPreview,
apiHost,
typebot,
linkedTypebots,
updateVariableValue,
updateVariables,
injectLinkedTypebot,
onNewLog,
createEdge,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
currentTypebotId,
pushParentTypebotId,
}
)
if (blockedPopupUrl) setBlockedPopupUrl(blockedPopupUrl)
const isRedirecting =
currentBlock.type === LogicBlockType.REDIRECT &&
currentBlock.options.isNewTab === false
if (isRedirecting) return
nextEdgeId
? onGroupEnd({ edgeId: nextEdgeId, updatedTypebot: linkedTypebot })
: displayNextBlock()
}
if (isIntegrationBlock(currentBlock)) {
const nextEdgeId = await executeIntegration({
block: currentBlock,
context: {
apiHost,
typebotId: currentTypebotId,
groupId: currentBlock.groupId,
blockId: currentBlock.id,
variables: typebot.variables,
isPreview,
updateVariableValue,
updateVariables,
resultValues,
groups: typebot.groups,
onNewLog,
resultId,
parentTypebotIds,
},
})
nextEdgeId ? onGroupEnd({ edgeId: nextEdgeId }) : displayNextBlock()
}
if (currentBlock.type === 'start')
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const displayNextBlock = (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => {
scroll()
const currentBlock = [...processedBlocks].pop()
if (currentBlock) {
if (isRetry && blockCanBeRetried(currentBlock))
return insertBlockInStack(
parseRetryBlock(currentBlock, typebot.variables, createEdge)
)
if (
isInputBlock(currentBlock) &&
currentBlock.options?.variableId &&
answerContent
) {
updateVariableValue(
currentBlock.options.variableId,
answerContent.value
)
}
const isSingleChoiceBlock =
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
if (isSingleChoiceBlock) {
const nextEdgeId = currentBlock.items.find(
byId(answerContent?.itemId)
)?.outgoingEdgeId
if (nextEdgeId) return onGroupEnd({ edgeId: nextEdgeId })
}
if (
currentBlock?.outgoingEdgeId ||
processedBlocks.length === blocks.length
)
return onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const nextBlock = blocks[processedBlocks.length + startBlockIndex]
nextBlock ? insertBlockInStack(nextBlock) : onGroupEnd({})
}
const avatarSrc = typebot.theme.chat.hostAvatar?.url
return (
<div className="flex w-full" data-group-name={groupTitle}>
<div className="flex flex-col w-full min-w-0">
{displayedChunks.map((chunk, idx) => (
<ChatChunks
key={idx}
displayChunk={chunk}
hostAvatar={{
isEnabled: typebot.theme.chat.hostAvatar?.isEnabled ?? true,
src: avatarSrc && parseVariables(typebot.variables)(avatarSrc),
}}
hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
onDisplayNextBlock={displayNextBlock}
keepShowingHostAvatar={keepShowingHostAvatar}
blockedPopupUrl={blockedPopupUrl}
onBlockedPopupLinkClick={() => setBlockedPopupUrl(undefined)}
/>
))}
</div>
</div>
)
}
type Props = {
displayChunk: ChatDisplayChunk
hostAvatar: { isEnabled: boolean; src?: string }
hasGuestAvatar: boolean
keepShowingHostAvatar: boolean
blockedPopupUrl?: string
onBlockedPopupLinkClick: () => void
onDisplayNextBlock: (
answerContent?: InputSubmitContent,
isRetry?: boolean
) => void
}
const ChatChunks = ({
displayChunk: { bubbles, input },
hostAvatar,
hasGuestAvatar,
keepShowingHostAvatar,
blockedPopupUrl,
onBlockedPopupLinkClick,
onDisplayNextBlock,
}: Props) => {
const [isSkipped, setIsSkipped] = useState(false)
const avatarSideContainerRef = useRef<{ refreshTopOffset: () => void }>()
useEffect(() => {
refreshTopOffset()
})
const skipInput = () => {
onDisplayNextBlock()
setIsSkipped(true)
}
const refreshTopOffset = () =>
avatarSideContainerRef.current?.refreshTopOffset()
return (
<>
<div className="flex">
{hostAvatar.isEnabled && bubbles.length > 0 && (
<AvatarSideContainer
ref={avatarSideContainerRef}
hostAvatarSrc={hostAvatar.src}
keepShowing={
(keepShowingHostAvatar || isDefined(input)) && !isSkipped
}
/>
)}
<div
className="flex-1"
style={{ marginRight: hasGuestAvatar ? '50px' : '0.5rem' }}
>
<TransitionGroup>
{bubbles.map((block) => (
<CSSTransition
key={block.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<HostBubble
block={block}
onTransitionEnd={() => {
onDisplayNextBlock()
refreshTopOffset()
}}
/>
</CSSTransition>
))}
</TransitionGroup>
</div>
</div>
{!isSkipped && (
<CSSTransition
classNames="bubble"
timeout={500}
unmountOnExit
in={isDefined(input)}
>
{input ? (
<InputChatBlock
block={input}
onTransitionEnd={onDisplayNextBlock}
onSkip={skipInput}
hasAvatar={hostAvatar.isEnabled}
hasGuestAvatar={hasGuestAvatar}
/>
) : (
<div />
)}
</CSSTransition>
)}
{blockedPopupUrl ? (
<div className="flex justify-end">
<PopupBlockedToast
url={blockedPopupUrl}
onLinkClick={onBlockedPopupLinkClick}
/>
</div>
) : null}
</>
)
}

View File

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