2
0

refactor: ♻️ Rename step to block

This commit is contained in:
Baptiste Arnaud
2022-06-11 07:27:38 +02:00
parent 8751766d0e
commit 2df8338505
297 changed files with 4292 additions and 3989 deletions

View File

@@ -1,24 +0,0 @@
import { BubbleStep, BubbleStepType } from 'models'
import React from 'react'
import { EmbedBubble } from './EmbedBubble'
import { ImageBubble } from './ImageBubble'
import { TextBubble } from './TextBubble'
import { VideoBubble } from './VideoBubble'
type Props = {
step: BubbleStep
onTransitionEnd: () => void
}
export const HostBubble = ({ step, onTransitionEnd }: Props) => {
switch (step.type) {
case BubbleStepType.TEXT:
return <TextBubble step={step} onTransitionEnd={onTransitionEnd} />
case BubbleStepType.IMAGE:
return <ImageBubble step={step} onTransitionEnd={onTransitionEnd} />
case BubbleStepType.VIDEO:
return <VideoBubble step={step} onTransitionEnd={onTransitionEnd} />
case BubbleStepType.EMBED:
return <EmbedBubble step={step} onTransitionEnd={onTransitionEnd} />
}
}

View File

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

View File

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

View File

@@ -20,13 +20,13 @@ export const AvatarSideContainer = forwardRef(
const [avatarTopOffset, setAvatarTopOffset] = useState(0)
const refreshTopOffset = () => {
if (!scrollingSideBlockRef.current || !avatarContainer.current) return
const { height } = scrollingSideBlockRef.current.getBoundingClientRect()
if (!scrollingSideGroupRef.current || !avatarContainer.current) return
const { height } = scrollingSideGroupRef.current.getBoundingClientRect()
const { height: avatarHeight } =
avatarContainer.current.getBoundingClientRect()
setAvatarTopOffset(height - avatarHeight)
}
const scrollingSideBlockRef = useRef<HTMLDivElement>(null)
const scrollingSideGroupRef = useRef<HTMLDivElement>(null)
const avatarContainer = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => ({
refreshTopOffset,
@@ -45,7 +45,7 @@ export const AvatarSideContainer = forwardRef(
return (
<div
className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container "
ref={scrollingSideBlockRef}
ref={scrollingSideGroupRef}
>
<CSSTransition
classNames="bubble"

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
import { InputStep, InputStepType } from 'models'
import { InputBlock, InputBlockType } from 'models'
import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm'
import { byId } from 'utils'
@@ -12,13 +12,13 @@ import { isInputValid } from 'services/inputs'
import { PaymentForm } from './inputs/PaymentForm'
import { RatingForm } from './inputs/RatingForm'
export const InputChatStep = ({
step,
export const InputChatBlock = ({
block,
hasAvatar,
hasGuestAvatar,
onTransitionEnd,
}: {
step: InputStep
block: InputBlock
hasGuestAvatar: boolean
hasAvatar: boolean
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
@@ -28,7 +28,7 @@ export const InputChatStep = ({
const [answer, setAnswer] = useState<string>()
const [isEditting, setIsEditting] = useState(false)
const { variableId } = step.options
const { variableId } = block.options
const defaultValue =
typebot.settings.general.isInputPrefillEnabled ?? true
? variableId && typebot.variables.find(byId(variableId))?.value
@@ -36,11 +36,11 @@ export const InputChatStep = ({
const handleSubmit = async (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, step.type)
const isRetry = !isInputValid(content, block.type)
if (!isRetry && addAnswer)
await addAnswer({
stepId: step.id,
blockId: step.blockId,
blockId: block.id,
groupId: block.groupId,
content,
variableId: variableId ?? null,
})
@@ -71,7 +71,7 @@ export const InputChatStep = ({
<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
step={step}
block={block}
onSubmit={handleSubmit}
defaultValue={defaultValue?.toString()}
hasGuestAvatar={hasGuestAvatar}
@@ -81,42 +81,42 @@ export const InputChatStep = ({
}
const Input = ({
step,
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: {
step: InputStep
block: InputBlock
onSubmit: (value: string) => void
defaultValue?: string
hasGuestAvatar: boolean
}) => {
switch (step.type) {
case InputStepType.TEXT:
case InputStepType.NUMBER:
case InputStepType.EMAIL:
case InputStepType.URL:
case InputStepType.PHONE:
switch (block.type) {
case InputBlockType.TEXT:
case InputBlockType.NUMBER:
case InputBlockType.EMAIL:
case InputBlockType.URL:
case InputBlockType.PHONE:
return (
<TextForm
step={step}
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputStepType.DATE:
return <DateForm options={step.options} onSubmit={onSubmit} />
case InputStepType.CHOICE:
return <ChoiceForm step={step} onSubmit={onSubmit} />
case InputStepType.PAYMENT:
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={step.options}
onSuccess={() => onSubmit(step.options.labels.success ?? 'Success')}
options={block.options}
onSuccess={() => onSubmit(block.options.labels.success ?? 'Success')}
/>
)
case InputStepType.RATING:
return <RatingForm step={step} onSubmit={onSubmit} />
case InputBlockType.RATING:
return <RatingForm block={block} onSubmit={onSubmit} />
}
}

View File

@@ -1,24 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { EmbedBubbleStep } from 'models'
import { EmbedBubbleBlock } from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
import { useTypebot } from 'contexts/TypebotContext'
type Props = {
step: EmbedBubbleStep
block: EmbedBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const EmbedBubble = ({ step, onTransitionEnd }: Props) => {
export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const url = useMemo(
() => parseVariables(typebot.variables)(step.content?.url),
[step.content?.url, typebot.variables]
() => parseVariables(typebot.variables)(block.content?.url),
[block.content?.url, typebot.variables]
)
useEffect(() => {
@@ -65,7 +65,7 @@ export const EmbedBubble = ({ step, onTransitionEnd }: Props) => {
(isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping ? '2rem' : step.content.height,
height: isTyping ? '2rem' : block.content.height,
borderRadius: '15px',
}}
/>

View File

@@ -0,0 +1,24 @@
import { BubbleBlock, BubbleBlockType } from 'models'
import React from 'react'
import { EmbedBubble } from './EmbedBubble'
import { ImageBubble } from './ImageBubble'
import { TextBubble } from './TextBubble'
import { VideoBubble } from './VideoBubble'
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} />
}
}

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { ImageBubbleStep } from 'models'
import { ImageBubbleBlock } from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
type Props = {
step: ImageBubbleStep
block: ImageBubbleBlock
onTransitionEnd: () => void
}
@@ -13,15 +13,15 @@ export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = ({ step, onTransitionEnd }: Props) => {
export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const image = useRef<HTMLImageElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const url = useMemo(
() => parseVariables(typebot.variables)(step.content?.url),
[step.content?.url, typebot.variables]
() => parseVariables(typebot.variables)(block.content?.url),
[block.content?.url, typebot.variables]
)
useEffect(() => {

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { BubbleStepType, TextBubbleStep } from 'models'
import { BubbleBlockType, TextBubbleBlock } from 'models'
import { computeTypingTimeout } from 'services/chat'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
type Props = {
step: TextBubbleStep
block: TextBubbleBlock
onTransitionEnd: () => void
}
@@ -18,18 +18,18 @@ const defaultTypingEmulation = {
maxDelay: 1.5,
}
export const TextBubble = ({ step, onTransitionEnd }: Props) => {
export const TextBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [content] = useState(
parseVariables(typebot.variables)(step.content.html)
parseVariables(typebot.variables)(block.content.html)
)
useEffect(() => {
const typingTimeout = computeTypingTimeout(
step.content.plainText,
block.content.plainText,
typebot.settings?.typingEmulation ?? defaultTypingEmulation
)
setTimeout(() => {
@@ -59,7 +59,7 @@ export const TextBubble = ({ step, onTransitionEnd }: Props) => {
>
{isTyping ? <TypingContent /> : <></>}
</div>
{step.type === BubbleStepType.TEXT && (
{block.type === BubbleBlockType.TEXT && (
<p
style={{
textOverflow: 'ellipsis',

View File

@@ -4,13 +4,13 @@ import {
Variable,
VideoBubbleContent,
VideoBubbleContentType,
VideoBubbleStep,
VideoBubbleBlock,
} from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
type Props = {
step: VideoBubbleStep
block: VideoBubbleBlock
onTransitionEnd: () => void
}
@@ -18,7 +18,7 @@ export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = ({ step, onTransitionEnd }: Props) => {
export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
@@ -56,7 +56,7 @@ export const VideoBubble = ({ step, onTransitionEnd }: Props) => {
{isTyping ? <TypingContent /> : <></>}
</div>
<VideoContent
content={step.content}
content={block.content}
isTyping={isTyping}
variables={typebot.variables}
/>

View File

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

View File

@@ -1,21 +1,21 @@
import { useAnswers } from 'contexts/AnswersContext'
import { ChoiceInputStep } from 'models'
import { ChoiceInputBlock } from 'models'
import React, { useState } from 'react'
import { SendButton } from './SendButton'
type ChoiceFormProps = {
step: ChoiceInputStep
block: ChoiceInputBlock
onSubmit: (value: string) => void
}
export const ChoiceForm = ({ step, onSubmit }: ChoiceFormProps) => {
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
const { resultValues } = useAnswers()
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const handleClick = (itemIndex: number) => (e: React.MouseEvent) => {
e.preventDefault()
if (step.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
else onSubmit(step.items[itemIndex].content ?? '')
if (block.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
else onSubmit(block.items[itemIndex].content ?? '')
}
const toggleSelectedItemIndex = (itemIndex: number) => {
@@ -31,25 +31,27 @@ export const ChoiceForm = ({ step, onSubmit }: ChoiceFormProps) => {
const handleSubmit = () =>
onSubmit(
selectedIndices
.map((itemIndex) => step.items[itemIndex].content)
.map((itemIndex) => block.items[itemIndex].content)
.join(', ')
)
const isUniqueFirstButton =
resultValues && resultValues.answers.length === 0 && step.items.length === 1
resultValues &&
resultValues.answers.length === 0 &&
block.items.length === 1
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-wrap">
{step.items.map((item, idx) => (
{block.items.map((item, idx) => (
<span key={item.id} className="relative inline-flex mr-2 mb-2">
<button
role={step.options?.isMultipleChoice ? 'checkbox' : 'button'}
role={block.options?.isMultipleChoice ? 'checkbox' : 'button'}
onClick={handleClick(idx)}
className={
'py-2 px-4 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(selectedIndices.includes(idx) ||
!step.options?.isMultipleChoice
!block.options?.isMultipleChoice
? ''
: 'selectable')
}
@@ -69,7 +71,10 @@ export const ChoiceForm = ({ step, onSubmit }: ChoiceFormProps) => {
</div>
<div className="flex">
{selectedIndices.length > 0 && (
<SendButton label={step.options?.buttonLabel ?? 'Send'} disableIcon />
<SendButton
label={block.options?.buttonLabel ?? 'Send'}
disableIcon
/>
)}
</div>
</form>

View File

@@ -1,14 +1,14 @@
import { RatingInputOptions, RatingInputStep } from 'models'
import { RatingInputOptions, RatingInputBlock } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from 'utils'
import { SendButton } from './SendButton'
type Props = {
step: RatingInputStep
block: RatingInputBlock
onSubmit: (value: string) => void
}
export const RatingForm = ({ step, onSubmit }: Props) => {
export const RatingForm = ({ block, onSubmit }: Props) => {
const [rating, setRating] = useState<number>()
const scaleElement = useRef<HTMLDivElement | null>(null)
@@ -24,9 +24,9 @@ export const RatingForm = ({ step, onSubmit }: Props) => {
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-col" ref={scaleElement}>
<div className="flex">
{Array.from(Array(step.options.length)).map((_, idx) => (
{Array.from(Array(block.options.length)).map((_, idx) => (
<RatingButton
{...step.options}
{...block.options}
key={idx}
rating={rating}
idx={idx + 1}
@@ -36,15 +36,15 @@ export const RatingForm = ({ step, onSubmit }: Props) => {
</div>
<div className="flex justify-between mr-2 mb-2">
{<span className="text-sm w-full ">{step.options.labels.left}</span>}
{!isEmpty(step.options.labels.middle) && (
{<span className="text-sm w-full ">{block.options.labels.left}</span>}
{!isEmpty(block.options.labels.middle) && (
<span className="text-sm w-full text-center">
{step.options.labels.middle}
{block.options.labels.middle}
</span>
)}
{
<span className="text-sm w-full text-right">
{step.options.labels.right}
{block.options.labels.right}
</span>
}
</div>
@@ -53,7 +53,7 @@ export const RatingForm = ({ step, onSubmit }: Props) => {
<div className="flex justify-end mr-2">
{isDefined(rating) && (
<SendButton
label={step.options?.labels.button ?? 'Send'}
label={block.options?.labels.button ?? 'Send'}
disableIcon
/>
)}

View File

@@ -1,39 +1,39 @@
import {
EmailInputStep,
InputStepType,
NumberInputStep,
PhoneNumberInputStep,
TextInputStep,
UrlInputStep,
EmailInputBlock,
InputBlockType,
NumberInputBlock,
PhoneNumberInputBlock,
TextInputBlock,
UrlInputBlock,
} from 'models'
import React, { FormEvent, useState } from 'react'
import { SendButton } from '../SendButton'
import { TextInput } from './TextInputContent'
type TextFormProps = {
step:
| TextInputStep
| EmailInputStep
| NumberInputStep
| UrlInputStep
| PhoneNumberInputStep
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
onSubmit: (value: string) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const TextForm = ({
step,
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: TextFormProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const isLongText = step.type === InputStepType.TEXT && step.options?.isLong
const isLongText = block.type === InputBlockType.TEXT && block.options?.isLong
const handleChange = (inputValue: string) => {
if (step.type === InputStepType.URL && !inputValue.startsWith('https://'))
if (block.type === InputBlockType.URL && !inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
@@ -57,9 +57,9 @@ export const TextForm = ({
maxWidth: isLongText ? undefined : '350px',
}}
>
<TextInput step={step} onChange={handleChange} value={inputValue} />
<TextInput block={block} onChange={handleChange} value={inputValue} />
<SendButton
label={step.options?.labels?.button ?? 'Send'}
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
/>

View File

@@ -1,10 +1,10 @@
import {
TextInputStep,
EmailInputStep,
NumberInputStep,
InputStepType,
UrlInputStep,
PhoneNumberInputStep,
TextInputBlock,
EmailInputBlock,
NumberInputBlock,
InputBlockType,
UrlInputBlock,
PhoneNumberInputBlock,
} from 'models'
import React, {
ChangeEvent,
@@ -16,17 +16,17 @@ import React, {
import PhoneInput, { Value, Country } from 'react-phone-number-input'
type TextInputProps = {
step:
| TextInputStep
| EmailInputStep
| NumberInputStep
| UrlInputStep
| PhoneNumberInputStep
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
value: string
onChange: (value: string) => void
}
export const TextInput = ({ step, value, onChange }: TextInputProps) => {
export const TextInput = ({ block, value, onChange }: TextInputProps) => {
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
useEffect(() => {
@@ -42,14 +42,14 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
onChange(value as string)
}
switch (step.type) {
case InputStepType.TEXT: {
return step.options?.isLong ? (
switch (block.type) {
case InputBlockType.TEXT: {
return block.options?.isLong ? (
<LongTextInput
ref={inputRef as unknown as RefObject<HTMLTextAreaElement>}
value={value}
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleInputChange}
/>
@@ -58,7 +58,7 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
ref={inputRef}
value={value}
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
block.options?.labels?.placeholder ?? 'Type your answer...'
}
name="typebot-short-text"
onChange={handleInputChange}
@@ -66,13 +66,13 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
/>
)
}
case InputStepType.EMAIL: {
case InputBlockType.EMAIL: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={
step.options?.labels?.placeholder ?? 'Type your email...'
block.options?.labels?.placeholder ?? 'Type your email...'
}
onChange={handleInputChange}
type="email"
@@ -80,36 +80,36 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
/>
)
}
case InputStepType.NUMBER: {
case InputBlockType.NUMBER: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleInputChange}
type="number"
style={{ appearance: 'auto' }}
min={step.options?.min}
max={step.options?.max}
step={step.options?.step ?? 'any'}
min={block.options?.min}
max={block.options?.max}
step={block.options?.step ?? 'any'}
/>
)
}
case InputStepType.URL: {
case InputBlockType.URL: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={step.options?.labels?.placeholder ?? 'Type your URL...'}
placeholder={block.options?.labels?.placeholder ?? 'Type your URL...'}
onChange={handleInputChange}
type="url"
autoComplete="url"
/>
)
}
case InputStepType.PHONE: {
case InputBlockType.PHONE: {
return (
<PhoneInput
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -117,9 +117,9 @@ export const TextInput = ({ step, value, onChange }: TextInputProps) => {
value={value}
onChange={handlePhoneNumberChange}
placeholder={
step.options.labels.placeholder ?? 'Your phone number...'
block.options.labels.placeholder ?? 'Your phone number...'
}
defaultCountry={step.options.defaultCountryCode as Country}
defaultCountry={block.options.defaultCountryCode as Country}
/>
)
}

View File

@@ -3,37 +3,37 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { AvatarSideContainer } from './AvatarSideContainer'
import { LinkedTypebot, useTypebot } from '../../contexts/TypebotContext'
import {
isBubbleStep,
isBubbleStepType,
isBubbleBlock,
isBubbleBlockType,
isChoiceInput,
isDefined,
isInputStep,
isIntegrationStep,
isLogicStep,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
} from 'utils'
import { executeLogic } from 'services/logic'
import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseRetryBlock, blockCanBeRetried } from 'services/inputs'
import { parseVariables } from '../../services/variable'
import { useAnswers } from 'contexts/AnswersContext'
import {
BubbleStep,
InputStep,
LogicStepType,
BubbleBlock,
InputBlock,
LogicBlockType,
PublicTypebot,
Step,
Block,
} from 'models'
import { HostBubble } from './ChatStep/bubbles/HostBubble'
import { InputChatStep } from './ChatStep/InputChatStep'
import { getLastChatStepType } from '../../services/chat'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { getLastChatBlockType } from '../../services/chat'
import { useChat } from 'contexts/ChatContext'
import { InputChatBlock } from './ChatBlock'
type ChatBlockProps = {
steps: Step[]
startStepIndex: number
blockTitle: string
type ChatGroupProps = {
blocks: Block[]
startBlockIndex: number
groupTitle: string
keepShowingHostAvatar: boolean
onBlockEnd: ({
onGroupEnd: ({
edgeId,
updatedTypebot,
}: {
@@ -42,15 +42,15 @@ type ChatBlockProps = {
}) => void
}
type ChatDisplayChunk = { bubbles: BubbleStep[]; input?: InputStep }
type ChatDisplayChunk = { bubbles: BubbleBlock[]; input?: InputBlock }
export const ChatBlock = ({
steps,
startStepIndex,
blockTitle,
onBlockEnd,
export const ChatGroup = ({
blocks,
startBlockIndex,
groupTitle,
onGroupEnd,
keepShowingHostAvatar,
}: ChatBlockProps) => {
}: ChatGroupProps) => {
const {
currentTypebotId,
typebot,
@@ -66,55 +66,57 @@ export const ChatBlock = ({
} = useTypebot()
const { resultValues, updateVariables, resultId } = useAnswers()
const { scroll } = useChat()
const [processedSteps, setProcessedSteps] = useState<Step[]>([])
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
const insertStepInStack = (nextStep: Step) => {
setProcessedSteps([...processedSteps, nextStep])
if (isBubbleStep(nextStep)) {
const lastStepType = getLastChatStepType(processedSteps)
lastStepType && isBubbleStepType(lastStepType)
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, nextStep] }
? { bubbles: [...c.bubbles, nextBlock] }
: c
)
)
: setDisplayedChunks([...displayedChunks, { bubbles: [nextStep] }])
: setDisplayedChunks([...displayedChunks, { bubbles: [nextBlock] }])
}
if (isInputStep(nextStep)) {
if (isInputBlock(nextBlock)) {
displayedChunks.length === 0 ||
isDefined(displayedChunks[displayedChunks.length - 1].input)
? setDisplayedChunks([
...displayedChunks,
{ bubbles: [], input: nextStep },
{ bubbles: [], input: nextBlock },
])
: setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1 ? { ...c, input: nextStep } : c
idx === displayedChunks.length - 1
? { ...c, input: nextBlock }
: c
)
)
}
}
useEffect(() => {
const nextStep = steps[startStepIndex]
if (nextStep) insertStepInStack(nextStep)
const nextBlock = blocks[startBlockIndex]
if (nextBlock) insertBlockInStack(nextBlock)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scroll()
onNewStepDisplayed()
onNewBlockDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processedSteps])
}, [processedBlocks])
const onNewStepDisplayed = async () => {
const currentStep = [...processedSteps].pop()
if (!currentStep) return
if (isLogicStep(currentStep)) {
const { nextEdgeId, linkedTypebot } = await executeLogic(currentStep, {
const onNewBlockDisplayed = async () => {
const currentBlock = [...processedBlocks].pop()
if (!currentBlock) return
if (isLogicBlock(currentBlock)) {
const { nextEdgeId, linkedTypebot } = await executeLogic(currentBlock, {
isPreview,
apiHost,
typebot,
@@ -129,72 +131,75 @@ export const ChatBlock = ({
currentTypebotId,
})
const isRedirecting =
currentStep.type === LogicStepType.REDIRECT &&
currentStep.options.isNewTab === false
currentBlock.type === LogicBlockType.REDIRECT &&
currentBlock.options.isNewTab === false
if (isRedirecting) return
nextEdgeId
? onBlockEnd({ edgeId: nextEdgeId, updatedTypebot: linkedTypebot })
: displayNextStep()
? onGroupEnd({ edgeId: nextEdgeId, updatedTypebot: linkedTypebot })
: displayNextBlock()
}
if (isIntegrationStep(currentStep)) {
if (isIntegrationBlock(currentBlock)) {
const nextEdgeId = await executeIntegration({
step: currentStep,
block: currentBlock,
context: {
apiHost,
typebotId: currentTypebotId,
blockId: currentStep.blockId,
stepId: currentStep.id,
groupId: currentBlock.groupId,
blockId: currentBlock.id,
variables: typebot.variables,
isPreview,
updateVariableValue,
updateVariables,
resultValues,
blocks: typebot.blocks,
groups: typebot.groups,
onNewLog,
resultId,
},
})
nextEdgeId ? onBlockEnd({ edgeId: nextEdgeId }) : displayNextStep()
nextEdgeId ? onGroupEnd({ edgeId: nextEdgeId }) : displayNextBlock()
}
if (currentStep.type === 'start')
onBlockEnd({ edgeId: currentStep.outgoingEdgeId })
if (currentBlock.type === 'start')
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const displayNextStep = (answerContent?: string, isRetry?: boolean) => {
const displayNextBlock = (answerContent?: string, isRetry?: boolean) => {
scroll()
const currentStep = [...processedSteps].pop()
if (currentStep) {
if (isRetry && stepCanBeRetried(currentStep))
return insertStepInStack(
parseRetryStep(currentStep, typebot.variables, createEdge)
const currentBlock = [...processedBlocks].pop()
if (currentBlock) {
if (isRetry && blockCanBeRetried(currentBlock))
return insertBlockInStack(
parseRetryBlock(currentBlock, typebot.variables, createEdge)
)
if (
isInputStep(currentStep) &&
currentStep.options?.variableId &&
isInputBlock(currentBlock) &&
currentBlock.options?.variableId &&
answerContent
) {
updateVariableValue(currentStep.options.variableId, answerContent)
updateVariableValue(currentBlock.options.variableId, answerContent)
}
const isSingleChoiceStep =
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
if (isSingleChoiceStep) {
const nextEdgeId = currentStep.items.find(
const isSingleChoiceBlock =
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
if (isSingleChoiceBlock) {
const nextEdgeId = currentBlock.items.find(
(i) => i.content === answerContent
)?.outgoingEdgeId
if (nextEdgeId) return onBlockEnd({ edgeId: nextEdgeId })
if (nextEdgeId) return onGroupEnd({ edgeId: nextEdgeId })
}
if (currentStep?.outgoingEdgeId || processedSteps.length === steps.length)
return onBlockEnd({ edgeId: currentStep.outgoingEdgeId })
if (
currentBlock?.outgoingEdgeId ||
processedBlocks.length === blocks.length
)
return onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const nextStep = steps[processedSteps.length + startStepIndex]
nextStep ? insertStepInStack(nextStep) : onBlockEnd({})
const nextBlock = blocks[processedBlocks.length + startBlockIndex]
nextBlock ? insertBlockInStack(nextBlock) : onGroupEnd({})
}
const avatarSrc = typebot.theme.chat.hostAvatar?.url
return (
<div className="flex w-full" data-block-name={blockTitle}>
<div className="flex w-full" data-group-name={groupTitle}>
<div className="flex flex-col w-full min-w-0">
{displayedChunks.map((chunk, idx) => (
<ChatChunks
@@ -205,7 +210,7 @@ export const ChatBlock = ({
src: avatarSrc && parseVariables(typebot.variables)(avatarSrc),
}}
hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
onDisplayNextStep={displayNextStep}
onDisplayNextBlock={displayNextBlock}
keepShowingHostAvatar={keepShowingHostAvatar}
/>
))}
@@ -219,14 +224,14 @@ type Props = {
hostAvatar: { isEnabled: boolean; src?: string }
hasGuestAvatar: boolean
keepShowingHostAvatar: boolean
onDisplayNextStep: (answerContent?: string, isRetry?: boolean) => void
onDisplayNextBlock: (answerContent?: string, isRetry?: boolean) => void
}
const ChatChunks = ({
displayChunk: { bubbles, input },
hostAvatar,
hasGuestAvatar,
keepShowingHostAvatar,
onDisplayNextStep,
onDisplayNextBlock,
}: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatarSideContainerRef = useRef<any>()
@@ -253,17 +258,17 @@ const ChatChunks = ({
style={{ marginRight: hasGuestAvatar ? '50px' : '0.5rem' }}
>
<TransitionGroup>
{bubbles.map((step) => (
{bubbles.map((block) => (
<CSSTransition
key={step.id}
key={block.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<HostBubble
step={step}
block={block}
onTransitionEnd={() => {
onDisplayNextStep()
onDisplayNextBlock()
refreshTopOffset()
}}
/>
@@ -279,9 +284,9 @@ const ChatChunks = ({
in={isDefined(input)}
>
{input && (
<InputChatStep
step={input}
onTransitionEnd={onDisplayNextStep}
<InputChatBlock
block={input}
onTransitionEnd={onDisplayNextBlock}
hasAvatar={hostAvatar.isEnabled}
hasGuestAvatar={hasGuestAvatar}
/>

View File

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

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChatBlock } from './ChatBlock/ChatBlock'
import { ChatGroup } from './ChatGroup'
import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext'
import { Block, Edge, PublicTypebot, Theme, VariableWithValue } from 'models'
import { Group, Edge, PublicTypebot, Theme, VariableWithValue } from 'models'
import { byId, isNotDefined } from 'utils'
import { animateScroll as scroll } from 'react-scroll'
import { LinkedTypebot, useTypebot } from 'contexts/TypebotContext'
@@ -13,15 +13,15 @@ import { ChatContext } from 'contexts/ChatContext'
type Props = {
theme: Theme
predefinedVariables?: { [key: string]: string | undefined }
startBlockId?: string
onNewBlockVisible: (edge: Edge) => void
startGroupId?: string
onNewGroupVisible: (edge: Edge) => void
onCompleted: () => void
}
export const ConversationContainer = ({
theme,
predefinedVariables,
startBlockId,
onNewBlockVisible,
startGroupId,
onNewGroupVisible,
onCompleted,
}: Props) => {
const {
@@ -31,34 +31,34 @@ export const ConversationContainer = ({
popEdgeIdFromLinkedTypebotQueue,
} = useTypebot()
const { document: frameDocument } = useFrame()
const [displayedBlocks, setDisplayedBlocks] = useState<
{ block: Block; startStepIndex: number }[]
const [displayedGroups, setDisplayedGroups] = useState<
{ group: Group; startBlockIndex: number }[]
>([])
const { updateVariables } = useAnswers()
const bottomAnchor = useRef<HTMLDivElement | null>(null)
const scrollableContainer = useRef<HTMLDivElement | null>(null)
const displayNextBlock = ({
const displayNextGroup = ({
edgeId,
updatedTypebot,
blockId,
groupId,
}: {
edgeId?: string
blockId?: string
groupId?: string
updatedTypebot?: PublicTypebot | LinkedTypebot
}) => {
const currentTypebot = updatedTypebot ?? typebot
if (blockId) {
const nextBlock = currentTypebot.blocks.find(byId(blockId))
if (!nextBlock) return
onNewBlockVisible({
if (groupId) {
const nextGroup = currentTypebot.groups.find(byId(groupId))
if (!nextGroup) return
onNewGroupVisible({
id: 'edgeId',
from: { blockId: 'block', stepId: 'step' },
to: { blockId },
from: { groupId: 'block', blockId: 'block' },
to: { groupId },
})
return setDisplayedBlocks([
...displayedBlocks,
{ block: nextBlock, startStepIndex: 0 },
return setDisplayedGroups([
...displayedGroups,
{ group: nextGroup, startBlockIndex: 0 },
])
}
const nextEdge = currentTypebot.edges.find(byId(edgeId))
@@ -66,30 +66,30 @@ export const ConversationContainer = ({
if (linkedBotQueue.length > 0) {
const nextEdgeId = linkedBotQueue[0].edgeId
popEdgeIdFromLinkedTypebotQueue()
displayNextBlock({ edgeId: nextEdgeId })
displayNextGroup({ edgeId: nextEdgeId })
}
return onCompleted()
}
const nextBlock = currentTypebot.blocks.find(byId(nextEdge.to.blockId))
if (!nextBlock) return onCompleted()
const startStepIndex = nextEdge.to.stepId
? nextBlock.steps.findIndex(byId(nextEdge.to.stepId))
const nextGroup = currentTypebot.groups.find(byId(nextEdge.to.groupId))
if (!nextGroup) return onCompleted()
const startBlockIndex = nextEdge.to.blockId
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
: 0
onNewBlockVisible(nextEdge)
setDisplayedBlocks([
...displayedBlocks,
{ block: nextBlock, startStepIndex },
onNewGroupVisible(nextEdge)
setDisplayedGroups([
...displayedGroups,
{ group: nextGroup, startBlockIndex },
])
}
useEffect(() => {
const prefilledVariables = injectPredefinedVariables(predefinedVariables)
updateVariables(prefilledVariables)
displayNextBlock({
edgeId: startBlockId
displayNextGroup({
edgeId: startGroupId
? undefined
: typebot.blocks[0].steps[0].outgoingEdgeId,
blockId: startBlockId,
: typebot.groups[0].blocks[0].outgoingEdgeId,
groupId: startGroupId,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -131,14 +131,14 @@ export const ConversationContainer = ({
className="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view"
>
<ChatContext onScroll={autoScrollToBottom}>
{displayedBlocks.map((displayedBlock, idx) => (
<ChatBlock
key={displayedBlock.block.id + idx}
steps={displayedBlock.block.steps}
startStepIndex={displayedBlock.startStepIndex}
onBlockEnd={displayNextBlock}
blockTitle={displayedBlock.block.title}
keepShowingHostAvatar={idx === displayedBlocks.length - 1}
{displayedGroups.map((displayedGroup, idx) => (
<ChatGroup
key={displayedGroup.group.id + idx}
blocks={displayedGroup.group.blocks}
startBlockIndex={displayedGroup.startBlockIndex}
onGroupEnd={displayNextGroup}
groupTitle={displayedGroup.group.title}
keepShowingHostAvatar={idx === displayedGroups.length - 1}
/>
))}
</ChatContext>

View File

@@ -30,8 +30,8 @@ export type TypebotViewerProps = {
style?: CSSProperties
predefinedVariables?: { [key: string]: string | undefined }
resultId?: string
startBlockId?: string
onNewBlockVisible?: (edge: Edge) => void
startGroupId?: string
onNewGroupVisible?: (edge: Edge) => void
onNewAnswer?: (answer: Answer) => Promise<void>
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
onCompleted?: () => void
@@ -44,10 +44,10 @@ export const TypebotViewer = ({
isPreview = false,
style,
resultId,
startBlockId,
startGroupId,
predefinedVariables,
onNewLog,
onNewBlockVisible,
onNewGroupVisible,
onNewAnswer,
onCompleted,
onVariablesUpdated,
@@ -59,8 +59,8 @@ export const TypebotViewer = ({
: 'transparent',
[typebot?.theme?.general?.background]
)
const handleNewBlockVisible = (edge: Edge) =>
onNewBlockVisible && onNewBlockVisible(edge)
const handleNewGroupVisible = (edge: Edge) =>
onNewGroupVisible && onNewGroupVisible(edge)
const handleNewAnswer = (answer: Answer) => onNewAnswer && onNewAnswer(answer)
@@ -115,10 +115,10 @@ export const TypebotViewer = ({
<div className="flex w-full h-full justify-center">
<ConversationContainer
theme={typebot.theme}
onNewBlockVisible={handleNewBlockVisible}
onNewGroupVisible={handleNewGroupVisible}
onCompleted={handleCompleted}
predefinedVariables={predefinedVariables}
startBlockId={startBlockId}
startGroupId={startGroupId}
/>
</div>
{typebot.settings.general.isBrandingEnabled && <LiteBadge />}