🚀 Init preview and typebot cotext in editor
This commit is contained in:
@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTypebot } from '../../contexts/TypebotContext'
|
||||
import { HostAvatar } from '../avatars/HostAvatar'
|
||||
import { useFrame } from 'react-frame-component'
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group'
|
||||
import { useHostAvatars } from '../../contexts/HostAvatarsContext'
|
||||
|
||||
export const AvatarSideContainer = () => {
|
||||
const { lastBubblesTopOffset } = useHostAvatars()
|
||||
const { typebot } = useTypebot()
|
||||
const { window, document } = useFrame()
|
||||
const [marginBottom, setMarginBottom] = useState(
|
||||
window.innerWidth < 400 ? 38 : 48
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const isMobile = window.innerWidth < 400
|
||||
setMarginBottom(isMobile ? 38 : 48)
|
||||
})
|
||||
resizeObserver.observe(document.body)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex w-6 xs:w-10 mr-2 flex-shrink-0 items-center">
|
||||
<TransitionGroup>
|
||||
{lastBubblesTopOffset
|
||||
.filter((n) => n !== -1)
|
||||
.map((topOffset, idx) => (
|
||||
<CSSTransition
|
||||
key={idx}
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
>
|
||||
<div
|
||||
className="fixed w-6 h-6 xs:w-10 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
|
||||
style={{
|
||||
top: `calc(${topOffset}px - ${marginBottom}px)`,
|
||||
transition: 'top 500ms ease-out',
|
||||
}}
|
||||
>
|
||||
<HostAvatar typebotName={typebot.name} />
|
||||
</div>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
61
packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx
Normal file
61
packages/bot-engine/src/components/ChatBlock/ChatBlock.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Block, Step } from '../../models'
|
||||
import { animateScroll as scroll } from 'react-scroll'
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
||||
import { ChatStep } from './ChatStep'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
|
||||
|
||||
type ChatBlockProps = {
|
||||
block: Block
|
||||
onBlockEnd: (nextBlockId: string) => void
|
||||
}
|
||||
|
||||
export const ChatBlock = ({ block, onBlockEnd }: ChatBlockProps) => {
|
||||
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedSteps([block.steps[0]])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoScrollToBottom()
|
||||
}, [displayedSteps])
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
scroll.scrollToBottom({
|
||||
duration: 500,
|
||||
containerId: 'scrollable-container',
|
||||
})
|
||||
}
|
||||
|
||||
const displayNextStep = () => {
|
||||
const currentStep = [...displayedSteps].pop()
|
||||
if (currentStep?.target?.blockId)
|
||||
return onBlockEnd(currentStep?.target?.blockId)
|
||||
const nextStep = block.steps[displayedSteps.length]
|
||||
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<HostAvatarsContext>
|
||||
<AvatarSideContainer />
|
||||
<div className="flex flex-col w-full">
|
||||
<TransitionGroup>
|
||||
{displayedSteps.map((step) => (
|
||||
<CSSTransition
|
||||
key={step.id}
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
>
|
||||
<ChatStep step={step} onTransitionEnd={displayNextStep} />
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</HostAvatarsContext>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
|
||||
import { Step } from '../../../models'
|
||||
import { isTextInputStep, isTextStep } from '../../../services/utils'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { HostMessageBubble } from './bubbles/HostMessageBubble'
|
||||
import { TextInput } from './inputs/TextInput'
|
||||
|
||||
export const ChatStep = ({
|
||||
step,
|
||||
onTransitionEnd,
|
||||
}: {
|
||||
step: Step
|
||||
onTransitionEnd: () => void
|
||||
}) => {
|
||||
if (isTextStep(step))
|
||||
return <HostMessageBubble step={step} onTransitionEnd={onTransitionEnd} />
|
||||
if (isTextInputStep(step)) return <InputChatStep onSubmit={onTransitionEnd} />
|
||||
return <span>No step</span>
|
||||
}
|
||||
|
||||
const InputChatStep = ({ onSubmit }: { onSubmit: () => void }) => {
|
||||
const { addNewAvatarOffset } = useHostAvatars()
|
||||
const [answer, setAnswer] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
addNewAvatarOffset()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (value: string) => {
|
||||
setAnswer(value)
|
||||
onSubmit()
|
||||
}
|
||||
|
||||
if (answer) {
|
||||
return <GuestBubble message={answer} />
|
||||
}
|
||||
return <TextInput onSubmit={handleSubmit} />
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { CSSTransition } from 'react-transition-group'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const GuestBubble = ({ message }: Props): JSX.Element => {
|
||||
return (
|
||||
<CSSTransition classNames="bubble" timeout={1000}>
|
||||
<div className="flex justify-end mb-2 items-center">
|
||||
<div className="flex items-end w-11/12 lg:w-4/6 justify-end">
|
||||
<div className="inline-flex px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
)
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useHostAvatars } from '../../../../contexts/HostAvatarsContext'
|
||||
import { StepType, TextStep } from '../../../../models'
|
||||
import { TypingContent } from './TypingContent'
|
||||
|
||||
type HostMessageBubbleProps = {
|
||||
step: TextStep
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const mediaLoadingFallbackTimeout = 5000
|
||||
|
||||
export const HostMessageBubble = ({
|
||||
step,
|
||||
onTransitionEnd,
|
||||
}: HostMessageBubbleProps) => {
|
||||
const { updateLastAvatarOffset } = useHostAvatars()
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const wordCount = step.content.plainText.match(/(\w+)/g)?.length ?? 0
|
||||
const typedWordsPerMinute = 250
|
||||
const typingTimeout = (wordCount / typedWordsPerMinute) * 60000
|
||||
sendAvatarOffset()
|
||||
setTimeout(() => {
|
||||
onTypingEnd()
|
||||
}, typingTimeout)
|
||||
}, [])
|
||||
|
||||
const onTypingEnd = () => {
|
||||
setIsTyping(false)
|
||||
setTimeout(() => {
|
||||
sendAvatarOffset()
|
||||
onTransitionEnd()
|
||||
}, showAnimationDuration)
|
||||
}
|
||||
|
||||
const sendAvatarOffset = () => {
|
||||
if (!messageContainer.current) return
|
||||
const containerDimensions = messageContainer.current.getBoundingClientRect()
|
||||
updateLastAvatarOffset(containerDimensions.top + containerDimensions.height)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" ref={messageContainer}>
|
||||
<div className="flex mb-2 w-full lg:w-11/12 items-center">
|
||||
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
|
||||
<div
|
||||
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping ? '4rem' : '100%',
|
||||
height: isTyping ? '2rem' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping ? <TypingContent /> : <></>}
|
||||
</div>
|
||||
{step.type === StepType.TEXT && (
|
||||
<p
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
className={
|
||||
'overflow-hidden content-opacity z-50 mx-4 my-2 whitespace-pre-wrap slate-html-container ' +
|
||||
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
||||
}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: step.content.html,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
export const TypingContent = (): JSX.Element => (
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 mr-1 rounded-full bubble1" />
|
||||
<div className="w-2 h-2 mr-1 rounded-full bubble2" />
|
||||
<div className="w-2 h-2 rounded-full bubble3" />
|
||||
</div>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { ChatStep } from './ChatStep'
|
@ -0,0 +1,46 @@
|
||||
import React, { FormEvent, useRef, useState } from 'react'
|
||||
import { SendIcon } from '../../../../assets/icons'
|
||||
|
||||
type TextInputProps = {
|
||||
onSubmit: (value: string) => void
|
||||
}
|
||||
|
||||
export const TextInput = ({ onSubmit }: TextInputProps) => {
|
||||
const inputRef = useRef(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (inputValue === '') return
|
||||
onSubmit(inputValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full lg:w-4/6">
|
||||
<div className="flex items-center">
|
||||
<form
|
||||
className="flex items-center justify-between rounded-lg pr-2 typebot-input"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
|
||||
type="text"
|
||||
placeholder={'Type your answer...'}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={
|
||||
'py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button active'
|
||||
}
|
||||
>
|
||||
<span className="hidden xs:flex">Submit</span>
|
||||
<SendIcon className="send-icon flex xs:hidden" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
packages/bot-engine/src/components/ChatBlock/index.tsx
Normal file
1
packages/bot-engine/src/components/ChatBlock/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ChatBlock } from './ChatBlock'
|
54
packages/bot-engine/src/components/ConversationContainer.tsx
Normal file
54
packages/bot-engine/src/components/ConversationContainer.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { PublicTypebot } from '..'
|
||||
|
||||
import { Block } from '..'
|
||||
import { ChatBlock } from './ChatBlock/ChatBlock'
|
||||
|
||||
export const ConversationContainer = ({
|
||||
typebot,
|
||||
onNewBlockVisisble,
|
||||
}: {
|
||||
typebot: PublicTypebot
|
||||
onNewBlockVisisble: (blockId: string) => void
|
||||
}) => {
|
||||
const [displayedBlocks, setDisplayedBlocks] = useState<Block[]>([])
|
||||
|
||||
const [isConversationEnded, setIsConversationEnded] = useState(false)
|
||||
const bottomAnchor = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const displayNextBlock = (blockId: string) => {
|
||||
const nextBlock = typebot.blocks.find((b) => b.id === blockId)
|
||||
if (!nextBlock) return
|
||||
onNewBlockVisisble(blockId)
|
||||
setDisplayedBlocks([...displayedBlocks, nextBlock])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const firstBlockId = typebot.startBlock.steps[0].target?.blockId
|
||||
if (firstBlockId) displayNextBlock(firstBlockId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
id="scrollable-container"
|
||||
>
|
||||
{displayedBlocks.map((block, idx) => (
|
||||
<ChatBlock
|
||||
key={block.id + idx}
|
||||
block={block}
|
||||
onBlockEnd={displayNextBlock}
|
||||
/>
|
||||
))}
|
||||
{/* We use a block to simulate padding because it makes iOS scroll flicker */}
|
||||
<div
|
||||
className="w-full"
|
||||
ref={bottomAnchor}
|
||||
style={{
|
||||
transition: isConversationEnded ? 'height 1s' : '',
|
||||
height: isConversationEnded ? '5%' : '20%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,6 +1,38 @@
|
||||
import React from 'react'
|
||||
import { PublicTypebot } from 'db'
|
||||
import { PublicTypebot } from '../models'
|
||||
import { TypebotContext } from '../contexts/TypebotContext'
|
||||
import Frame from 'react-frame-component'
|
||||
//@ts-ignore
|
||||
import style from '../assets/style.css'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import { ResultContext } from '../contexts/ResultsContext'
|
||||
|
||||
export const TypebotViewer = (props: PublicTypebot) => {
|
||||
return <div>{props.name}</div>
|
||||
export type TypebotViewerProps = {
|
||||
typebot: PublicTypebot
|
||||
onNewBlockVisisble: (blockId: string) => void
|
||||
}
|
||||
export const TypebotViewer = ({
|
||||
typebot,
|
||||
onNewBlockVisisble,
|
||||
}: TypebotViewerProps) => {
|
||||
return (
|
||||
<Frame
|
||||
id="typebot-iframe"
|
||||
head={<style>{style}</style>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TypebotContext typebot={typebot}>
|
||||
<ResultContext typebotId={typebot.id}>
|
||||
<div className="flex text-base overflow-hidden bg-cover h-screen w-screen typebot-container flex-col items-center">
|
||||
<div className="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
typebot={typebot}
|
||||
onNewBlockVisisble={onNewBlockVisisble}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResultContext>
|
||||
</TypebotContext>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
60
packages/bot-engine/src/components/avatars/DefaultAvatar.tsx
Normal file
60
packages/bot-engine/src/components/avatars/DefaultAvatar.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
|
||||
type DefaultAvatarProps = {
|
||||
displayName?: string
|
||||
size?: 'extra-small' | 'small' | 'medium' | 'large' | 'full'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DefaultAvatar = ({
|
||||
displayName,
|
||||
}: DefaultAvatarProps): JSX.Element => {
|
||||
return (
|
||||
<figure
|
||||
className={
|
||||
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-full xs:h-full xs:text-xl'
|
||||
}
|
||||
>
|
||||
<Background
|
||||
className={
|
||||
'absolute top-0 left-0 w-6 h-6 xs:w-full xs:h-full xs:text-xl'
|
||||
}
|
||||
/>
|
||||
<p style={{ zIndex: 0 }}>{displayName && displayName[0].toUpperCase()}</p>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
const Background = ({ className }: { className: string }) => (
|
||||
<svg
|
||||
width="75"
|
||||
height="75"
|
||||
viewBox="0 0 75 75"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<mask id="mask0" x="0" y="0" mask-type="alpha">
|
||||
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
|
||||
<rect
|
||||
x="2.50413"
|
||||
y="120.333"
|
||||
width="81.5597"
|
||||
height="86.4577"
|
||||
rx="2.5"
|
||||
transform="rotate(-52.6423 2.50413 120.333)"
|
||||
stroke="#FED23D"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
<circle cx="76.5" cy="-1.5" r="29" stroke="#FF8E20" strokeWidth="5" />
|
||||
<path
|
||||
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
|
||||
stroke="#F7F8FF"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
14
packages/bot-engine/src/components/avatars/HostAvatar.tsx
Normal file
14
packages/bot-engine/src/components/avatars/HostAvatar.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const HostAvatar = ({
|
||||
typebotName,
|
||||
}: {
|
||||
typebotName: string
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className="w-full h-full rounded-full text-2xl md:text-4xl text-center xs:w-10 xs:h-10">
|
||||
<DefaultAvatar displayName={typebotName} />
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user