🚀 Init preview and typebot cotext in editor
This commit is contained in:
13
packages/bot-engine/src/assets/icons.tsx
Normal file
13
packages/bot-engine/src/assets/icons.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
export const SendIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width="19px"
|
||||
{...props}
|
||||
>
|
||||
<title>Send</title>
|
||||
<path d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z" />
|
||||
</svg>
|
||||
)
|
384
packages/bot-engine/src/assets/style.css
Normal file
384
packages/bot-engine/src/assets/style.css
Normal file
@ -0,0 +1,384 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--typebot-container-bg-image: none;
|
||||
--typebot-container-bg-color: #f7f8ff;
|
||||
--typebot-container-font-family: 'Inter';
|
||||
--typebot-chat-view-max-width: 700px;
|
||||
--typebot-chat-view-bg-color: #ffffff;
|
||||
--typebot-chat-view-color: #303235;
|
||||
|
||||
--typebot-button-active-bg-color: #0042da;
|
||||
--typebot-button-active-color: #ffffff;
|
||||
--typebot-button-inactive-bg-color: #edf2f7;
|
||||
--typebot-button-inactive-color: #303235;
|
||||
--typebot-button-border: 1px solid var(--typebot-button-active-bg-color);
|
||||
--typebot-button-shadow: none;
|
||||
|
||||
--typebot-host-bubble-bg-color: #f7f8ff;
|
||||
--typebot-host-bubble-color: #303235;
|
||||
--typebot-host-bubble-border: 1px solid var(--typebot-host-bubble-bg-color);
|
||||
--typebot-host-bubble-shadow: none;
|
||||
|
||||
--typebot-guest-bubble-bg-color: #ff8e21;
|
||||
--typebot-guest-bubble-color: #ffffff;
|
||||
--typebot-guest-bubble-border: 1px solid var(--typebot-guest-bubble-bg-color);
|
||||
--typebot-guest-bubble-shadow: none;
|
||||
|
||||
--typebot-input-bg-color: #ffffff;
|
||||
--typebot-input-color: #303235;
|
||||
--typebot-input-border: 1px solid var(--typebot-input-bg-color);
|
||||
--typebot-input-shadow: 0 2px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--typebot-input-placeholder-color: #9095a0;
|
||||
|
||||
--typebot-header-bg-color: #ffffff;
|
||||
--typebot-header-color: #303235;
|
||||
--typebot-header-border: none;
|
||||
--typebot-header-shadow: none;
|
||||
--typebot-header-max-width: 1000px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.scrollable-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.scrollable-container {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.StripeElement {
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.bubble-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.bubble-enter-active {
|
||||
opacity: 1;
|
||||
transition-property: opacity;
|
||||
transition-duration: 500ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
.bubble-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
.bubble-exit-active {
|
||||
opacity: 0;
|
||||
transition-delay: 0ms !important;
|
||||
transition-property: opacity;
|
||||
transition-duration: 400ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.bubble-typing {
|
||||
transition: width 400ms ease-out, height 400ms ease-out;
|
||||
}
|
||||
|
||||
.content-opacity {
|
||||
transition: opacity 400ms ease-in 200ms;
|
||||
}
|
||||
|
||||
.bubble1,
|
||||
.bubble2,
|
||||
.bubble3 {
|
||||
background-color: var(--typebot-host-bubble-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bubble1 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bubble2 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.bubble3 {
|
||||
animation: chatBubbles 1s ease-in-out infinite;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
@keyframes chatBubbles {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.slate-a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.slate-html-container > div {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.slate-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.slate-italic {
|
||||
font-style: oblique;
|
||||
}
|
||||
|
||||
.slate-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.comp-input::-webkit-input-placeholder {
|
||||
/* Chrome/Opera/Safari */
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.comp-input::-moz-placeholder {
|
||||
/* Firefox 19+ */
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.comp-input::placeholder {
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.PhoneInput {
|
||||
/* This is done to stretch the contents of this component. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.PhoneInput > input {
|
||||
color: var(--typebot-input-color);
|
||||
background-color: var(--typebot-input-bg-color);
|
||||
}
|
||||
|
||||
.PhoneInput > input::placeholder {
|
||||
color: var(--typebot-input-placeholder-color) !important;
|
||||
}
|
||||
|
||||
.PhoneInputInput {
|
||||
/* The phone number input stretches to fill all empty space */
|
||||
flex: 1;
|
||||
/* The phone number input should shrink
|
||||
to make room for the extension input */
|
||||
min-width: 0;
|
||||
padding: 1rem 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.PhoneInputInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon {
|
||||
width: calc(
|
||||
var(--PhoneInputCountryFlag-height) *
|
||||
var(--PhoneInputCountryFlag-aspectRatio)
|
||||
);
|
||||
height: var(--PhoneInputCountryFlag-height);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon--square {
|
||||
width: var(--PhoneInputCountryFlag-height);
|
||||
}
|
||||
|
||||
.PhoneInputCountryIcon--border {
|
||||
/* Removed `background-color` because when an `<img/>` was still loading
|
||||
it would show a dark gray rectangle. */
|
||||
/* For some reason the `<img/>` is not stretched to 100% width and height
|
||||
and sometime there can be seen white pixels of the background at top and bottom. */
|
||||
background-color: var(--PhoneInputCountryFlag-backgroundColor--loading);
|
||||
/* Border is added via `box-shadow` because `border` interferes with `width`/`height`. */
|
||||
/* For some reason the `<img/>` is not stretched to 100% width and height
|
||||
and sometime there can be seen white pixels of the background at top and bottom,
|
||||
so an additional "inset" border is added. */
|
||||
box-shadow: 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor),
|
||||
inset 0 0 0 var(--PhoneInputCountryFlag-borderWidth)
|
||||
var(--PhoneInputCountryFlag-borderColor);
|
||||
}
|
||||
|
||||
.PhoneInputCountryIconImg {
|
||||
/* Fixes weird vertical space above the flag icon. */
|
||||
/* https://gitlab.com/catamphetamine/react-phone-number-input/-/issues/7#note_348586559 */
|
||||
display: block;
|
||||
/* 3rd party <SVG/> flag icons won't stretch if they have `width` and `height`.
|
||||
Also, if an <SVG/> icon's aspect ratio was different, it wouldn't fit too. */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.PhoneInputInternationalIconPhone {
|
||||
opacity: var(--PhoneInputInternationalIconPhone-opacity);
|
||||
}
|
||||
|
||||
.PhoneInputInternationalIconGlobe {
|
||||
opacity: var(--PhoneInputInternationalIconGlobe-opacity);
|
||||
}
|
||||
|
||||
/* Styling native country `<select/>`. */
|
||||
|
||||
.PhoneInputCountry {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelectArrow {
|
||||
display: block;
|
||||
content: '';
|
||||
width: var(--PhoneInputCountrySelectArrow-width);
|
||||
height: var(--PhoneInputCountrySelectArrow-width);
|
||||
margin-left: var(--PhoneInputCountrySelectArrow-marginLeft);
|
||||
border-style: solid;
|
||||
border-color: var(--PhoneInputCountrySelectArrow-color);
|
||||
border-top-width: 0;
|
||||
border-bottom-width: var(--PhoneInputCountrySelectArrow-borderWidth);
|
||||
border-left-width: 0;
|
||||
border-right-width: var(--PhoneInputCountrySelectArrow-borderWidth);
|
||||
transform: var(--PhoneInputCountrySelectArrow-transform);
|
||||
opacity: var(--PhoneInputCountrySelectArrow-opacity);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus
|
||||
+ .PhoneInputCountryIcon
|
||||
+ .PhoneInputCountrySelectArrow {
|
||||
opacity: 1;
|
||||
color: var(--PhoneInputCountrySelectArrow-color--focus);
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus + .PhoneInputCountryIcon--border {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.PhoneInputCountrySelect:focus
|
||||
+ .PhoneInputCountryIcon
|
||||
.PhoneInputInternationalIconGlobe {
|
||||
opacity: 1;
|
||||
color: var(--PhoneInputCountrySelectArrow-color--focus);
|
||||
}
|
||||
|
||||
.typebot-container {
|
||||
background-image: var(--typebot-container-bg-image);
|
||||
background-color: var(--typebot-container-bg-color);
|
||||
font-family: var(--typebot-container-font-family);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
color: var(--typebot-header-color);
|
||||
background-color: var(--typebot-header-bg-color);
|
||||
border-bottom: var(--typebot-header-border);
|
||||
box-shadow: var(--typebot-header-shadow);
|
||||
}
|
||||
|
||||
.custom-header-content {
|
||||
max-width: var(--typebot-header-max-width);
|
||||
}
|
||||
|
||||
.typebot-chat-view {
|
||||
max-width: var(--typebot-chat-view-max-width);
|
||||
background-color: var(--typebot-chat-view-bg-color);
|
||||
}
|
||||
|
||||
.typebot-button.active {
|
||||
color: var(--typebot-button-active-color);
|
||||
background-color: var(--typebot-button-active-bg-color);
|
||||
}
|
||||
|
||||
.typebot-button {
|
||||
color: var(--typebot-button-inactive-color);
|
||||
background-color: var(--typebot-button-inactive-bg-color);
|
||||
border: var(--typebot-button-border);
|
||||
box-shadow: var(--typebot-button-shadow);
|
||||
}
|
||||
|
||||
.typebot-host-bubble {
|
||||
color: var(--typebot-host-bubble-color);
|
||||
}
|
||||
|
||||
.typebot-host-bubble > .bubble-typing {
|
||||
background-color: var(--typebot-host-bubble-bg-color);
|
||||
border: var(--typebot-host-bubble-border);
|
||||
box-shadow: var(--typebot-host-bubble-shadow);
|
||||
}
|
||||
|
||||
.typebot-guest-bubble {
|
||||
color: var(--typebot-guest-bubble-color);
|
||||
background-color: var(--typebot-guest-bubble-bg-color);
|
||||
border: var(--typebot-guest-bubble-border);
|
||||
box-shadow: var(--typebot-guest-bubble-shadow);
|
||||
}
|
||||
|
||||
.typebot-input {
|
||||
color: var(--typebot-input-color);
|
||||
background-color: var(--typebot-input-bg-color);
|
||||
border: var(--typebot-input-border);
|
||||
box-shadow: var(--typebot-input-shadow);
|
||||
}
|
||||
|
||||
.typebot-button > .send-icon {
|
||||
fill: var(--typebot-button-active-color);
|
||||
}
|
||||
|
||||
.text-on-chat {
|
||||
color: var(--typebot-chat-view-color);
|
||||
}
|
||||
|
||||
.star-icon {
|
||||
cursor: pointer;
|
||||
fill: transparent;
|
||||
stroke-width: 30px;
|
||||
stroke: var(--typebot-button-active-bg-color);
|
||||
}
|
||||
|
||||
.star-icon.active {
|
||||
fill: var(--typebot-button-active-bg-color);
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
font-size: 13px;
|
||||
color: var(--typebot-button-active-bg-color);
|
||||
font-weight: 600;
|
||||
padding-right: 0.5rem;
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
38
packages/bot-engine/src/contexts/HostAvatarsContext.tsx
Normal file
38
packages/bot-engine/src/contexts/HostAvatarsContext.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||
|
||||
// This context just keeps track of the top offset of host avatar
|
||||
const hostAvatarsContext = createContext<{
|
||||
lastBubblesTopOffset: number[]
|
||||
addNewAvatarOffset: () => void
|
||||
updateLastAvatarOffset: (newOffset: number) => void
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const HostAvatarsContext = ({ children }: { children: ReactNode }) => {
|
||||
const [lastBubblesTopOffset, setLastBubblesTopOffset] = useState<number[]>([
|
||||
-1,
|
||||
])
|
||||
|
||||
const updateLastAvatarOffset = (newOffset: number) => {
|
||||
const offsets = [...lastBubblesTopOffset]
|
||||
offsets[offsets.length - 1] = newOffset
|
||||
setLastBubblesTopOffset(offsets)
|
||||
}
|
||||
|
||||
const addNewAvatarOffset = () =>
|
||||
setLastBubblesTopOffset([...lastBubblesTopOffset, -1])
|
||||
|
||||
return (
|
||||
<hostAvatarsContext.Provider
|
||||
value={{
|
||||
lastBubblesTopOffset,
|
||||
updateLastAvatarOffset,
|
||||
addNewAvatarOffset,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</hostAvatarsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useHostAvatars = () => useContext(hostAvatarsContext)
|
50
packages/bot-engine/src/contexts/ResultsContext.tsx
Normal file
50
packages/bot-engine/src/contexts/ResultsContext.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Answer, Result } from '../models'
|
||||
import React, {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const resultContext = createContext<{
|
||||
result: Result
|
||||
setResult: Dispatch<SetStateAction<Result>>
|
||||
addAnswer: (answer: Answer) => void
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const ResultContext = ({
|
||||
children,
|
||||
typebotId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
typebotId: string
|
||||
}) => {
|
||||
const [result, setResult] = useState<Result>({
|
||||
id: 'tmp',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
answers: [],
|
||||
typebotId,
|
||||
isCompleted: false,
|
||||
})
|
||||
|
||||
const addAnswer = (answer: Answer) =>
|
||||
setResult({ ...result, answers: [...result.answers, answer] })
|
||||
|
||||
return (
|
||||
<resultContext.Provider
|
||||
value={{
|
||||
result,
|
||||
setResult,
|
||||
addAnswer,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</resultContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useResult = () => useContext(resultContext)
|
27
packages/bot-engine/src/contexts/TypebotContext.tsx
Normal file
27
packages/bot-engine/src/contexts/TypebotContext.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
import { PublicTypebot } from '../models/publicTypebot'
|
||||
|
||||
const typebotContext = createContext<{
|
||||
typebot: PublicTypebot
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
export const TypebotContext = ({
|
||||
children,
|
||||
typebot,
|
||||
}: {
|
||||
children: ReactNode
|
||||
typebot: PublicTypebot
|
||||
}) => {
|
||||
return (
|
||||
<typebotContext.Provider
|
||||
value={{
|
||||
typebot,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</typebotContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTypebot = () => useContext(typebotContext)
|
@ -1 +1,3 @@
|
||||
export * from './typebot'
|
||||
export * from './publicTypebot'
|
||||
export * from './result'
|
||||
|
10
packages/bot-engine/src/models/publicTypebot.ts
Normal file
10
packages/bot-engine/src/models/publicTypebot.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
|
||||
import { Block, StartBlock } from '.'
|
||||
|
||||
export type PublicTypebot = Omit<
|
||||
PublicTypebotFromPrisma,
|
||||
'blocks' | 'startBlock'
|
||||
> & {
|
||||
blocks: Block[]
|
||||
startBlock: StartBlock
|
||||
}
|
11
packages/bot-engine/src/models/result.ts
Normal file
11
packages/bot-engine/src/models/result.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Result as ResultFromPrisma } from 'db'
|
||||
|
||||
export type Result = Omit<ResultFromPrisma, 'answers'> & {
|
||||
answers: Answer[]
|
||||
}
|
||||
|
||||
export type Answer = {
|
||||
blockId: string
|
||||
stepId: string
|
||||
content: string
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Typebot as TypebotFromPrisma } from 'db'
|
||||
|
||||
export type Typebot = TypebotFromPrisma & {
|
||||
export type Typebot = Omit<TypebotFromPrisma, 'blocks' | 'startBlock'> & {
|
||||
blocks: Block[]
|
||||
startBlock: StartBlock
|
||||
}
|
||||
@ -36,38 +36,21 @@ export type Block = {
|
||||
export enum StepType {
|
||||
START = 'start',
|
||||
TEXT = 'text',
|
||||
IMAGE = 'image',
|
||||
BUTTONS = 'buttons',
|
||||
DATE_PICKER = 'date picker',
|
||||
TEXT_INPUT = 'text input',
|
||||
}
|
||||
|
||||
export type Target = { blockId: string; stepId?: string }
|
||||
|
||||
export type Step = { id: string; blockId: string; target?: Target } & (
|
||||
| TextStep
|
||||
| ImageStep
|
||||
| ButtonsStep
|
||||
| DatePickerStep
|
||||
)
|
||||
|
||||
export type TextStep = {
|
||||
export type Step = BubbleStep | InputStep
|
||||
export type BubbleStep = TextStep
|
||||
export type InputStep = TextInputStep
|
||||
export type StepBase = { id: string; blockId: string; target?: Target }
|
||||
export type TextStep = StepBase & {
|
||||
type: StepType.TEXT
|
||||
content: string
|
||||
content: { html: string; richText: unknown[]; plainText: string }
|
||||
}
|
||||
|
||||
export type ImageStep = {
|
||||
type: StepType.IMAGE
|
||||
content: { url: string }
|
||||
}
|
||||
|
||||
export type ButtonsStep = {
|
||||
type: StepType.BUTTONS
|
||||
buttons: Button[]
|
||||
}
|
||||
|
||||
export type DatePickerStep = {
|
||||
type: StepType.DATE_PICKER
|
||||
content: string
|
||||
export type TextInputStep = StepBase & {
|
||||
type: StepType.TEXT_INPUT
|
||||
}
|
||||
|
||||
export type Button = {
|
||||
|
7
packages/bot-engine/src/services/utils.ts
Normal file
7
packages/bot-engine/src/services/utils.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Step, TextStep, StepType, TextInputStep } from '../models'
|
||||
|
||||
export const isTextStep = (step: Step): step is TextStep =>
|
||||
step.type === StepType.TEXT
|
||||
|
||||
export const isTextInputStep = (step: Step): step is TextInputStep =>
|
||||
step.type === StepType.TEXT_INPUT
|
@ -1 +0,0 @@
|
||||
/*! tailwindcss v3.0.5 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input:-ms-input-placeholder,textarea:-ms-input-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}
|
Reference in New Issue
Block a user