2
0

🚀 Init preview and typebot cotext in editor

This commit is contained in:
Baptiste Arnaud
2021-12-22 14:59:07 +01:00
parent a54e42f255
commit b7cdc0d14a
87 changed files with 4431 additions and 735 deletions

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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} />
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)

View File

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

View File

@ -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>
)
}

View File

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

View 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>
)
}

View File

@ -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>
)
}

View 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>
)

View 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>
)
}