2
0

♻️ Re-organize workspace folders

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

View File

@ -0,0 +1,58 @@
import { TypingBubble } from '@/components'
import type { AudioBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
url: AudioBubbleContent['url']
onTransitionEnd: () => void
}
const showAnimationDuration = 400
const typingDuration = 500
let typingTimeout: NodeJS.Timeout
export const AudioBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, typingDuration)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<audio
src={props.url}
class={
'z-10 text-fade-in m-2 ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{ height: isTyping() ? '32px' : 'revert' }}
autoplay
controls
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './AudioBubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,60 @@
import { TypingBubble } from '@/components'
import type { EmbedBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
content: EmbedBubbleContent
onTransitionEnd: () => void
}
let typingTimeout: NodeJS.Timeout
export const showAnimationDuration = 400
export const EmbedBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, 2000)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col w-full animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div
class={'flex relative z-10 items-start typebot-host-bubble w-full'}
>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<iframe
id="embed-bubble-content"
src={props.content.url}
class={
'w-full z-20 p-4 text-fade-in rounded-2xl ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping() ? '32px' : `${props.content.height}px`,
}}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export * from './EmbedBubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,73 @@
import { TypingBubble } from '@/components'
import type { ImageBubbleContent } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
type Props = {
url: ImageBubbleContent['url']
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
let typingTimeout: NodeJS.Timeout
export const ImageBubble = (props: Props) => {
let image: HTMLImageElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!image) return
typingTimeout = setTimeout(onTypingEnd, mediaLoadingFallbackTimeout)
image.onload = () => {
clearTimeout(typingTimeout)
onTypingEnd()
}
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() ? <TypingBubble /> : null}
</div>
<figure class="p-4 z-10">
<img
ref={image}
src={props.url}
class={
'text-fade-in w-full rounded-md ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
'max-height': '512px',
height: isTyping() ? '32px' : 'auto',
}}
alt="Bubble image"
/>
</figure>
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,74 @@
import { TypingBubble } from '@/components'
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { computeTypingDuration } from '../utils/computeTypingDuration'
type Props = {
content: Pick<TextBubbleContent, 'html' | 'plainText'>
typingEmulation: TypingEmulation
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
let typingTimeout: NodeJS.Timeout
export const TextBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!isTyping) return
const typingDuration =
props.typingEmulation?.enabled === false
? 0
: computeTypingDuration(
props.content.plainText,
props.typingEmulation ?? defaultTypingEmulation
)
typingTimeout = setTimeout(onTypingEnd, typingDuration)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
data-testid="host-bubble"
>
{isTyping() && <TypingBubble />}
</div>
<p
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis ' +
(isTyping() ? 'opacity-0 h-6' : 'opacity-100 h-full')
}
innerHTML={props.content.html}
/>
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,16 @@
import type { TypingEmulation } from '@typebot.io/schemas'
export const computeTypingDuration = (
bubbleContent: string,
typingSettings: TypingEmulation
) => {
let wordCount = bubbleContent.match(/(\w+)/g)?.length ?? 0
if (wordCount === 0) wordCount = bubbleContent.length
const typedWordsPerMinute = typingSettings.speed
let typingTimeout = typingSettings.enabled
? (wordCount / typedWordsPerMinute) * 60000
: 0
if (typingTimeout > typingSettings.maxDelay * 1000)
typingTimeout = typingSettings.maxDelay * 1000
return typingTimeout
}

View File

@ -0,0 +1,110 @@
import { TypingBubble } from '@/components'
import type { VideoBubbleContent } from '@typebot.io/schemas'
import { VideoBubbleContentType } from '@typebot.io/schemas/features/blocks/bubbles/video/enums'
import { createSignal, Match, onCleanup, onMount, Switch } from 'solid-js'
type Props = {
content: VideoBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
let typingTimeout: NodeJS.Timeout
export const VideoBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
if (!isTyping()) return
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
typingTimeout = setTimeout(onTypingEnd, 2000)
})
onCleanup(() => {
if (typingTimeout) clearTimeout(typingTimeout)
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full items-center">
<div class={'flex relative z-10 items-start typebot-host-bubble'}>
<div
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping() ? '64px' : '100%',
height: isTyping() ? '32px' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<VideoContent content={props.content} isTyping={isTyping()} />
</div>
</div>
</div>
)
}
type VideoContentProps = {
content: VideoBubbleContent
isTyping: boolean
}
const VideoContent = (props: VideoContentProps) => {
return (
<Switch>
<Match
when={
props.content?.type &&
props.content.type === VideoBubbleContentType.URL
}
>
<video
controls
class={
'p-4 focus:outline-none w-full z-10 text-fade-in rounded-md ' +
(props.isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: props.isTyping ? '32px' : 'auto',
'max-height': window.navigator.vendor.match(/apple/i) ? '40vh' : '',
}}
autoplay
>
<source src={props.content.url} type="video/mp4" />
Sorry, your browser doesn&apos;t support embedded videos.
</video>
</Match>
<Match
when={
props.content?.type &&
[
VideoBubbleContentType.VIMEO,
VideoBubbleContentType.YOUTUBE,
].includes(props.content.type)
}
>
<iframe
src={`${
props.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
}/${props.content.id}`}
class={
'w-full p-4 text-fade-in z-10 rounded-md ' +
(props.isTyping ? 'opacity-0' : 'opacity-100')
}
height={props.isTyping ? '32px' : '200px'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</Match>
</Switch>
)
}

View File

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

View File

@ -0,0 +1,82 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { ChoiceInputBlock } from '@typebot.io/schemas'
import { createSignal, For } from 'solid-js'
type Props = {
inputIndex: number
block: ChoiceInputBlock
onSubmit: (value: InputSubmitContent) => void
}
export const ChoiceForm = (props: Props) => {
const [selectedIndices, setSelectedIndices] = createSignal<number[]>([])
const handleClick = (itemIndex: number) => {
if (props.block.options?.isMultipleChoice)
toggleSelectedItemIndex(itemIndex)
else props.onSubmit({ value: props.block.items[itemIndex].content ?? '' })
}
const toggleSelectedItemIndex = (itemIndex: number) => {
const existingIndex = selectedIndices().indexOf(itemIndex)
if (existingIndex !== -1) {
setSelectedIndices((selectedIndices) =>
selectedIndices.filter((index) => index !== itemIndex)
)
} else {
setSelectedIndices((selectedIndices) => [...selectedIndices, itemIndex])
}
}
const handleSubmit = () =>
props.onSubmit({
value: selectedIndices()
.map((itemIndex) => props.block.items[itemIndex].content)
.join(', '),
})
return (
<form class="flex flex-col items-end" onSubmit={handleSubmit}>
<div class="flex flex-wrap justify-end">
<For each={props.block.items}>
{(item, index) => (
<span class="relative inline-flex ml-2 mb-2">
<button
role={
props.block.options?.isMultipleChoice ? 'checkbox' : 'button'
}
type="button"
on:click={() => handleClick(index())}
class={
'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().some(
(selectedIndex) => selectedIndex === index()
) || !props.block.options?.isMultipleChoice
? ''
: 'selectable')
}
data-itemid={item.id}
>
{item.content}
</button>
{props.inputIndex === 0 && props.block.items.length === 1 && (
<span class="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span class="relative inline-flex rounded-full h-3 w-3 brightness-200" />
</span>
)}
</span>
)}
</For>
</div>
<div class="flex">
{selectedIndices().length > 0 && (
<SendButton disableIcon>
{props.block.options?.buttonLabel ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}

View File

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

View File

@ -0,0 +1,112 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { DateInputOptions } from '@typebot.io/schemas'
import { createSignal } from 'solid-js'
import { parseReadableDate } from '../utils/parseReadableDate'
type Props = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
defaultValue?: string
}
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal(
parseDefaultValue(props.defaultValue ?? '')
)
return (
<div class="flex flex-col">
<div class="flex items-center">
<form
class={'flex justify-between rounded-lg typebot-input pr-2 items-end'}
onSubmit={(e) => {
if (inputValues().from === '' && inputValues().to === '') return
e.preventDefault()
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
label: parseReadableDate({
...inputValues(),
hasTime: props.options?.hasTime,
isRange: props.options?.isRange,
}),
})
}}
>
<div class="flex flex-col">
<div
class={
'flex items-center p-4 ' +
(props.options?.isRange ? 'pb-0' : '')
}
>
{props.options?.isRange && (
<p class="font-semibold mr-2">
{props.options.labels?.from ?? 'From:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().from}
type={props.options?.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
from: e.currentTarget.value,
})
}
data-testid="from-date"
/>
</div>
{props.options?.isRange && (
<div class="flex items-center p-4">
{props.options.isRange && (
<p class="font-semibold">
{props.options.labels?.to ?? 'To:'}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().to}
type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
to: e.currentTarget.value,
})
}
data-testid="to-date"
/>
</div>
)}
</div>
<SendButton
isDisabled={inputValues().to === '' && inputValues().from === ''}
class="my-2 ml-2"
>
{props.options?.labels?.button ?? 'Send'}
</SendButton>
</form>
</div>
</div>
)
}
const parseDefaultValue = (defaultValue: string) => {
if (!defaultValue.includes('to')) return { from: defaultValue, to: '' }
const [from, to] = defaultValue.split(' to ')
return { from, to }
}

View File

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

View File

@ -0,0 +1,27 @@
export const parseReadableDate = ({
from,
to,
hasTime,
isRange,
}: {
from: string
to: string
hasTime?: boolean
isRange?: boolean
}) => {
const currentLocale = window.navigator.language
const formatOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: hasTime ? '2-digit' : undefined,
minute: hasTime ? '2-digit' : undefined,
}
const fromReadable = new Date(
hasTime ? from : from.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
const toReadable = new Date(
hasTime ? to : to.replace(/-/g, '/')
).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@ -0,0 +1,66 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { EmailInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: EmailInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your email...'
}
onInput={handleInput}
type="email"
autocomplete="email"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,272 @@
import { SendButton, Spinner } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { guessApiHost } from '@/utils/guessApiHost'
import { FileInputBlock } from '@typebot.io/schemas'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from '@typebot.io/lib'
type Props = {
context: BotContext
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: (label: string) => void
}
export const FileUploadForm = (props: Props) => {
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
const [isUploading, setIsUploading] = createSignal(false)
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
const [errorMessage, setErrorMessage] = createSignal<string>()
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
if (
newFiles.some(
(file) =>
file.size > (props.block.options.sizeLimit ?? 10) * 1024 * 1024
)
)
return setErrorMessage(
`A file is larger than ${props.block.options.sizeLimit ?? 10}MB`
)
if (!props.block.options.isMultipleAllowed && files)
return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles(), ...newFiles])
}
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
if (selectedFiles().length === 0) return
startFilesUpload(selectedFiles())
}
const startSingleFileUpload = async (file: File) => {
if (props.context.isPreview)
return props.onSubmit({
label: `File uploaded`,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
props.context.typebotId
}/blocks/${props.block.id}`,
files: [
{
file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
},
],
})
setIsUploading(false)
if (urls.length)
return props.onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
if (props.context.isPreview)
return props.onSubmit({
label: `${files.length} file${files.length > 1 ? 's' : ''} uploaded`,
value: files
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
.join(', '),
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `${props.context.apiHost ?? guessApiHost()}/api/typebots/${
props.context.typebotId
}/blocks/${props.block.id}`,
files: files.map((file) => ({
file: file,
path: `public/results/${props.context.resultId}/${props.block.id}/${file.name}`,
})),
onUploadProgress: setUploadProgressPercent,
})
setIsUploading(false)
setUploadProgressPercent(0)
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
props.onSubmit({
label: `${urls.length} file${urls.length > 1 ? 's' : ''} uploaded`,
value: urls.join(', '),
})
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDraggingOver(true)
}
const handleDragLeave = () => setIsDraggingOver(false)
const handleDropFile = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer?.files) return
onNewFiles(e.dataTransfer.files)
}
const clearFiles = () => setSelectedFiles([])
return (
<form class="flex flex-col w-full" onSubmit={handleSubmit}>
<label
for="dropzone-file"
class={
'typebot-upload-input py-6 flex flex-col justify-center items-center w-full bg-gray-50 rounded-lg border-2 border-gray-300 border-dashed cursor-pointer hover:bg-gray-100 px-8 mb-2 ' +
(isDraggingOver() ? 'dragging-over' : '')
}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDropFile}
>
<Switch>
<Match when={isUploading()}>
<Show when={selectedFiles().length > 1} fallback={<Spinner />}>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="upload-progress-bar h-2.5 rounded-full"
style={{
width: `${
uploadProgressPercent() > 0 ? uploadProgressPercent : 10
}%`,
transition: 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)',
}}
/>
</div>
</Show>
</Match>
<Match when={!isUploading()}>
<>
<div class="flex flex-col justify-center items-center">
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
<span class="relative">
<FileIcon />
<div
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles().length}
</div>
</span>
</Show>
<p
class="text-sm text-gray-500 text-center"
innerHTML={props.block.options.labels.placeholder}
/>
</div>
<input
id="dropzone-file"
type="file"
class="hidden"
multiple={props.block.options.isMultipleAllowed}
onChange={(e) => {
if (!e.currentTarget.files) return
onNewFiles(e.currentTarget.files)
}}
/>
</>
</Match>
</Switch>
</label>
<Show
when={
selectedFiles().length === 0 &&
props.block.options.isRequired === false
}
>
<div class="flex justify-end">
<button
class={
'py-2 px-4 justify-center 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 '
}
on:click={() =>
props.onSkip(
props.block.options.labels.skip ??
defaultFileInputOptions.labels.skip
)
}
>
{props.block.options.labels.skip ??
defaultFileInputOptions.labels.skip}
</button>
</div>
</Show>
<Show
when={
props.block.options.isMultipleAllowed &&
selectedFiles().length > 0 &&
!isUploading()
}
>
<div class="flex justify-end">
<div class="flex">
<Show when={selectedFiles().length}>
<button
class={
'secondary-button py-2 px-4 justify-center 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 mr-2'
}
on:click={clearFiles}
>
{props.block.options.labels.clear ??
defaultFileInputOptions.labels.clear}
</button>
</Show>
<SendButton type="submit" disableIcon>
{props.block.options.labels.button ===
defaultFileInputOptions.labels.button
? `Upload ${selectedFiles().length} file${
selectedFiles().length > 1 ? 's' : ''
}`
: props.block.options.labels.button}
</SendButton>
</div>
</div>
</Show>
<Show when={errorMessage()}>
<p class="text-red-500 text-sm">{errorMessage()}</p>
</Show>
</form>
)
}
const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
>
<polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" />
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
<polyline points="16 16 12 12 8 16" />
</svg>
)
const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)

View File

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

View File

@ -0,0 +1,69 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { NumberInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
onInput={handleInput}
type="number"
style={{ appearance: 'auto' }}
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? 'any'}
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,23 @@
import { BotContext } from '@/types'
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
import { PaymentProvider } from '@typebot.io/schemas/features/blocks/inputs/payment/enums'
import { Match, Switch } from 'solid-js'
import { StripePaymentForm } from './StripePaymentForm'
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
export const PaymentForm = (props: Props) => (
<Switch>
<Match when={props.options.provider === PaymentProvider.STRIPE}>
<StripePaymentForm
onSuccess={props.onSuccess}
options={props.options}
context={props.context}
/>
</Match>
</Switch>
)

View File

@ -0,0 +1,112 @@
import { SendButton } from '@/components/SendButton'
import { createSignal, onMount, Show } from 'solid-js'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import { BotContext } from '@/types'
import type { PaymentInputOptions, RuntimeOptions } from '@typebot.io/schemas'
import { loadStripe } from '@/lib/stripe'
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
const slotName = 'stripe-payment-form'
let paymentElementSlot: HTMLSlotElement
let stripe: Stripe | null = null
let elements: StripeElements | null = null
export const StripePaymentForm = (props: Props) => {
const [message, setMessage] = createSignal<string>()
const [isMounted, setIsMounted] = createSignal(false)
const [isLoading, setIsLoading] = createSignal(false)
onMount(async () => {
initShadowMountPoint(paymentElementSlot)
stripe = await loadStripe(props.options.publicKey)
if (!stripe) return
elements = stripe.elements({
appearance: {
theme: 'stripe',
variables: {
colorPrimary: getComputedStyle(paymentElementSlot).getPropertyValue(
'--typebot-button-bg-color'
),
},
},
clientSecret: props.options.paymentIntentSecret,
})
const paymentElement = elements.create('payment', {
layout: 'tabs',
})
paymentElement.mount('#payment-element')
setTimeout(() => setIsMounted(true), 1000)
})
const handleSubmit = async (event: Event & { submitter: HTMLElement }) => {
event.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
// TO-DO: Handle redirection correctly.
return_url: props.context.apiHost,
payment_method_data: {
billing_details: {
name: props.options.additionalInformation?.name,
email: props.options.additionalInformation?.email,
phone: props.options.additionalInformation?.phoneNumber,
},
},
},
redirect: 'if_required',
})
setIsLoading(false)
if (error?.type === 'validation_error') return
if (error?.type === 'card_error') return setMessage(error.message)
if (!error && paymentIntent.status === 'succeeded') return props.onSuccess()
}
return (
<form
id="payment-form"
onSubmit={handleSubmit}
class="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
>
<slot name={slotName} ref={paymentElementSlot} />
<Show when={isMounted()}>
<SendButton
isLoading={isLoading()}
class="mt-4 w-full max-w-lg animate-fade-in"
disableIcon
>
{props.options.labels.button} {props.options.amountLabel}
</SendButton>
</Show>
<Show when={message()}>
<div class="typebot-input-error-message mt-4 text-center animate-fade-in">
{message()}
</div>
</Show>
</form>
)
}
const initShadowMountPoint = (element: HTMLElement) => {
const rootNode = element.getRootNode() as ShadowRoot
const host = rootNode.host
const slotPlaceholder = document.createElement('div')
slotPlaceholder.style.width = '100%'
slotPlaceholder.slot = slotName
host.appendChild(slotPlaceholder)
const paymentElementContainer = document.createElement('div')
paymentElementContainer.id = 'payment-element'
slotPlaceholder.appendChild(paymentElementContainer)
}

View File

@ -0,0 +1 @@
export * from './PaymentForm'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,152 @@
import { ShortTextInput } from '@/components'
import { ChevronDownIcon } from '@/components/icons/ChevronDownIcon'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { PhoneNumberInputOptions } from '@typebot.io/schemas'
import { createSignal, For, onMount } from 'solid-js'
import { isEmpty } from '@typebot.io/lib'
import { phoneCountries } from '@typebot.io/lib/phoneCountries'
type PhoneInputProps = Pick<
PhoneNumberInputOptions,
'labels' | 'defaultCountryCode'
> & {
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const PhoneInput = (props: PhoneInputProps) => {
const [selectedCountryCode, setSelectedCountryCode] = createSignal(
isEmpty(props.defaultCountryCode) ? 'INT' : props.defaultCountryCode
)
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string | undefined) => {
setInputValue(inputValue as string)
if (
(inputValue === '' || inputValue === '+') &&
selectedCountryCode() !== 'INT'
)
setSelectedCountryCode('INT')
const matchedCountry =
inputValue?.startsWith('+') &&
inputValue.length > 2 &&
phoneCountries.reduce<typeof phoneCountries[number] | null>(
(matchedCountry, country) => {
if (
!country?.dial_code ||
(matchedCountry !== null && !matchedCountry.dial_code)
) {
return matchedCountry
}
if (
inputValue?.startsWith(country.dial_code) &&
country.dial_code.length > (matchedCountry?.dial_code.length ?? 0)
) {
return country
}
return matchedCountry
},
null
)
if (matchedCountry) setSelectedCountryCode(matchedCountry.code)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
const selectedCountryDialCode = phoneCountries.find(
(country) => country.code === selectedCountryCode()
)?.dial_code
if (checkIfInputIsValid())
props.onSubmit({
value: inputValue().startsWith('+')
? inputValue()
: `${selectedCountryDialCode ?? ''}${inputValue()}`,
})
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
const selectNewCountryCode = (
event: Event & { currentTarget: { value: string } }
) => {
const code = event.currentTarget.value
setSelectedCountryCode(code)
const dial_code = phoneCountries.find(
(country) => country.code === code
)?.dial_code
if (inputValue() === '' && dial_code) setInputValue(dial_code)
inputRef?.focus()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={'flex items-end justify-between rounded-lg pr-2 typebot-input'}
data-testid="input"
style={{
'max-width': '400px',
}}
onKeyDown={submitWhenEnter}
>
<div class="flex">
<div class="relative typebot-country-select flex justify-center items-center rounded-md">
<div class="pl-2 pr-1 flex items-center gap-2">
<span>
{
phoneCountries.find(
(country) => selectedCountryCode() === country.code
)?.flag
}
</span>
<ChevronDownIcon class="w-3" />
</div>
<select
onChange={selectNewCountryCode}
class="absolute top-0 left-0 w-full h-full cursor-pointer opacity-0"
>
<For each={phoneCountries}>
{(country) => (
<option
value={country.code}
selected={country.code === selectedCountryCode()}
>
{country.name}{' '}
{country.dial_code ? `(${country.dial_code})` : ''}
</option>
)}
</For>
</select>
</div>
<ShortTextInput
type="tel"
ref={inputRef}
value={inputValue()}
onInput={handleInput}
placeholder={props.labels.placeholder ?? 'Your phone number...'}
autofocus={!isMobile()}
/>
</div>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,122 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import type { RatingInputBlock, RatingInputOptions } from '@typebot.io/schemas'
import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
type Props = {
block: RatingInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
props.defaultValue ? Number(props.defaultValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
const selectedRating = rating()
if (isNotDefined(selectedRating)) return
props.onSubmit({ value: selectedRating.toString() })
}
const handleClick = (rating: number) => {
if (props.block.options.isOneClickSubmitEnabled)
props.onSubmit({ value: rating.toString() })
setRating(rating)
}
return (
<form class="flex flex-col" onSubmit={handleSubmit}>
{props.block.options.labels.left && (
<span class="text-sm w-full mb-2 rating-label">
{props.block.options.labels.left}
</span>
)}
<div class="flex flex-wrap justify-center">
<For
each={Array.from(
Array(
props.block.options.length +
(props.block.options.buttonType === 'Numbers' ? 1 : 0)
)
)}
>
{(_, idx) => (
<RatingButton
{...props.block.options}
rating={rating()}
idx={
idx() + (props.block.options.buttonType === 'Numbers' ? 0 : 1)
}
onClick={handleClick}
/>
)}
</For>
</div>
{props.block.options.labels.right && (
<span class="text-sm w-full text-right mb-2 pr-2 rating-label">
{props.block.options.labels.right}
</span>
)}
<div class="flex justify-end mr-2">
{isDefined(rating()) && (
<SendButton disableIcon>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
)}
</div>
</form>
)
}
type RatingButtonProps = {
rating?: number
idx: number
onClick: (rating: number) => void
} & RatingInputOptions
const RatingButton = (props: RatingButtonProps) => {
return (
<Switch>
<Match when={props.buttonType === 'Numbers'}>
<button
on:click={(e) => {
e.preventDefault()
props.onClick(props.idx)
}}
class={
'py-2 px-4 mr-2 mb-2 text-left font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none typebot-button ' +
(props.isOneClickSubmitEnabled ||
(isDefined(props.rating) && props.idx <= props.rating)
? ''
: 'selectable')
}
>
{props.idx}
</button>
</Match>
<Match when={props.buttonType !== 'Numbers'}>
<div
class={
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
(isDefined(props.rating) && props.idx <= props.rating
? 'selected'
: '')
}
innerHTML={
props.customIcon.isEnabled && !isEmpty(props.customIcon.svg)
? props.customIcon.svg
: defaultIcon
}
on:click={() => props.onClick(props.idx)}
/>
</Match>
</Switch>
)
}
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`

View File

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

View File

@ -0,0 +1,76 @@
import { Textarea, ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { TextInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: TextInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (props.block.options.isLong) return
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': props.block.options.isLong ? undefined : '350px',
}}
onKeyDown={submitWhenEnter}
>
{props.block.options.isLong ? (
<Textarea
ref={inputRef as HTMLTextAreaElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
) : (
<ShortTextInput
ref={inputRef as HTMLInputElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
)}
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,72 @@
import { ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { UrlInputBlock } from '@typebot.io/schemas'
import { createSignal, onMount } from 'solid-js'
type Props = {
block: UrlInputBlock
defaultValue?: string
onSubmit: (value: InputSubmitContent) => void
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => {
if (!inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
onMount(() => {
if (!isMobile() && inputRef) inputRef.focus()
})
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as HTMLInputElement}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ?? 'Type your URL...'
}
onInput={handleInput}
type="url"
autocomplete="url"
/>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
on:click={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,8 @@
import { executeScript } from '@/features/blocks/logic/script/executeScript'
import type { ScriptToExecute } from '@typebot.io/schemas'
export const executeChatwoot = (chatwoot: {
scriptToExecute: ScriptToExecute
}) => {
executeScript(chatwoot.scriptToExecute)
}

View File

@ -0,0 +1 @@
export * from './executeChatwoot'

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,11 @@
import { sendGaEvent } from '@/lib/gtag'
import type { GoogleAnalyticsOptions } from '@typebot.io/schemas'
export const executeGoogleAnalyticsBlock = async (
options: GoogleAnalyticsOptions
) => {
if (!options?.trackingId) return
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
await initGoogleAnalytics(options.trackingId)
sendGaEvent(options)
}

View File

@ -0,0 +1 @@
export * from './executeGoogleAnalytics'

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,13 @@
import type { RedirectOptions } from '@typebot.io/schemas'
export const executeRedirect = ({
url,
isNewTab,
}: RedirectOptions): { blockedPopupUrl: string } | undefined => {
if (!url) return
const updatedWindow = window.open(url, isNewTab ? '_blank' : '_self')
if (!updatedWindow)
return {
blockedPopupUrl: url,
}
}

View File

@ -0,0 +1 @@
export * from './executeRedirect'

View File

@ -0,0 +1,17 @@
import type { ScriptToExecute } from '@typebot.io/schemas'
export const executeScript = async ({ content, args }: ScriptToExecute) => {
const func = Function(...args.map((arg) => arg.id), parseContent(content))
try {
await func(...args.map((arg) => arg.value))
} catch (err) {
console.error(err)
}
}
const parseContent = (content: string) => {
const contentWithoutScriptTags = content
.replace(/<script>/g, '')
.replace(/<\/script>/g, '')
return contentWithoutScriptTags
}

View File

@ -0,0 +1,7 @@
type Props = {
secondsToWaitFor: number
}
export const executeWait = async ({ secondsToWaitFor }: Props) => {
await new Promise((resolve) => setTimeout(resolve, secondsToWaitFor * 1000))
}

View File

@ -0,0 +1,160 @@
import {
createSignal,
onMount,
Show,
splitProps,
onCleanup,
createEffect,
} from 'solid-js'
import styles from '../../../assets/index.css'
import { CommandData } from '../../commands'
import { BubbleButton } from './BubbleButton'
import { PreviewMessage, PreviewMessageProps } from './PreviewMessage'
import { isDefined } from '@typebot.io/lib'
import { BubbleParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
export type BubbleProps = BotProps &
BubbleParams & {
onOpen?: () => void
onClose?: () => void
onPreviewMessageClick?: () => void
}
export const Bubble = (props: BubbleProps) => {
const [bubbleProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'previewMessage',
'onPreviewMessageClick',
'theme',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isPreviewMessageDisplayed, setIsPreviewMessageDisplayed] =
createSignal(false)
const [previewMessage, setPreviewMessage] = createSignal<
Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
>({
message: bubbleProps.previewMessage?.message ?? '',
avatarUrl: bubbleProps.previewMessage?.avatarUrl,
})
const [isBotOpened, setIsBotOpened] = createSignal(false)
const [isBotStarted, setIsBotStarted] = createSignal(false)
onMount(() => {
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = bubbleProps.previewMessage?.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
showMessage()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
})
createEffect(() => {
if (!props.prefilledVariables) return
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...props.prefilledVariables,
}))
})
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'showPreviewMessage') showMessage(data.message)
if (data.command === 'hidePreviewMessage') hideMessage()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
if (!isBotStarted()) setIsBotStarted(true)
hideMessage()
setIsBotOpened(true)
if (isBotOpened()) bubbleProps.onOpen?.()
}
const closeBot = () => {
setIsBotOpened(false)
if (isBotOpened()) bubbleProps.onClose?.()
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
const handlePreviewMessageClick = () => {
bubbleProps.onPreviewMessageClick?.()
openBot()
}
const showMessage = (
previewMessage?: Pick<PreviewMessageProps, 'avatarUrl' | 'message'>
) => {
if (previewMessage) setPreviewMessage(previewMessage)
if (isBotOpened()) return
setIsPreviewMessageDisplayed(true)
}
const hideMessage = () => {
setIsPreviewMessageDisplayed(false)
}
return (
<>
<style>{styles}</style>
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
previewMessageTheme={bubbleProps.theme?.previewMessage}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
<BubbleButton
{...bubbleProps.theme?.button}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
/>
<div
style={{
height: 'calc(100% - 80px)',
transition:
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
'transform-origin': 'bottom right',
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
'z-index': 42424242,
}}
class={
'fixed bottom-20 sm:right-4 rounded-lg w-full sm:w-[400px] max-h-[704px] ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
>
<Show when={isBotStarted()}>
<Bot
{...botProps}
prefilledVariables={prefilledVariables()}
class="rounded-lg"
/>
</Show>
</div>
</>
)
}

View File

@ -0,0 +1,65 @@
import { Show } from 'solid-js'
import { isNotDefined } from '@typebot.io/lib'
import { ButtonTheme } from '../types'
type Props = ButtonTheme & {
isBotOpened: boolean
toggleBot: () => void
}
const defaultButtonColor = '#0042DA'
const defaultIconColor = 'white'
export const BubbleButton = (props: Props) => {
return (
<button
onClick={() => props.toggleBot()}
class={
'fixed bottom-4 right-4 shadow-md w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center animate-fade-in'
}
style={{
'background-color': props.backgroundColor ?? defaultButtonColor,
'z-index': 42424242,
}}
>
<Show when={isNotDefined(props.customIconSrc)} keyed>
<svg
viewBox="0 0 24 24"
style={{
stroke: props.iconColor ?? defaultIconColor,
}}
class={
`w-7 stroke-2 fill-transparent absolute duration-200 transition ` +
(props.isBotOpened ? 'scale-0 opacity-0' : 'scale-100 opacity-100')
}
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</Show>
<Show when={props.customIconSrc}>
<img
src={props.customIconSrc}
class="w-7 h-7 rounded-full object-cover"
alt="Bubble button icon"
/>
</Show>
<svg
viewBox="0 0 24 24"
style={{ fill: props.iconColor ?? 'white' }}
class={
`w-7 absolute duration-200 transition ` +
(props.isBotOpened
? 'scale-100 rotate-0 opacity-100'
: 'scale-0 -rotate-180 opacity-0')
}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M18.601 8.39897C18.269 8.06702 17.7309 8.06702 17.3989 8.39897L12 13.7979L6.60099 8.39897C6.26904 8.06702 5.73086 8.06702 5.39891 8.39897C5.06696 8.73091 5.06696 9.2691 5.39891 9.60105L11.3989 15.601C11.7309 15.933 12.269 15.933 12.601 15.601L18.601 9.60105C18.9329 9.2691 18.9329 8.73091 18.601 8.39897Z"
/>
</svg>
</button>
)
}

View File

@ -0,0 +1,87 @@
import { createSignal, Show } from 'solid-js'
import { PreviewMessageParams, PreviewMessageTheme } from '../types'
export type PreviewMessageProps = Pick<
PreviewMessageParams,
'avatarUrl' | 'message'
> & {
previewMessageTheme?: PreviewMessageTheme
onClick: () => void
onCloseClick: () => void
}
const defaultBackgroundColor = '#F7F8FF'
const defaultTextColor = '#303235'
export const PreviewMessage = (props: PreviewMessageProps) => {
const [isPreviewMessageHovered, setIsPreviewMessageHovered] =
createSignal(false)
return (
<div
onClick={() => props.onClick()}
class="fixed bottom-20 right-4 max-w-[256px] rounded-md duration-200 flex items-center gap-4 shadow-md animate-fade-in cursor-pointer hover:shadow-lg p-4"
style={{
'background-color':
props.previewMessageTheme?.backgroundColor ?? defaultBackgroundColor,
color: props.previewMessageTheme?.textColor ?? defaultTextColor,
'z-index': 42424242,
}}
onMouseEnter={() => setIsPreviewMessageHovered(true)}
onMouseLeave={() => setIsPreviewMessageHovered(false)}
>
<CloseButton
isHovered={isPreviewMessageHovered()}
previewMessageTheme={props.previewMessageTheme}
onClick={props.onCloseClick}
/>
<Show when={props.avatarUrl} keyed>
{(avatarUrl) => (
<img
src={avatarUrl}
class="rounded-full w-8 h-8 object-cover"
alt="Bot avatar"
/>
)}
</Show>
<p>{props.message}</p>
</div>
)
}
const CloseButton = (props: {
isHovered: boolean
previewMessageTheme?: PreviewMessageTheme
onClick: () => void
}) => (
<button
class={
`absolute -top-2 -right-2 rounded-full w-6 h-6 p-1 hover:brightness-95 active:brightness-90 transition-all border ` +
(props.isHovered ? 'opacity-100' : 'opacity-0')
}
onClick={(e) => {
e.stopPropagation()
return props.onClick()
}}
style={{
'background-color':
props.previewMessageTheme?.closeButtonBackgroundColor ??
defaultBackgroundColor,
color:
props.previewMessageTheme?.closeButtonIconColor ?? defaultTextColor,
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)

View File

@ -0,0 +1 @@
export * from './Bubble'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,33 @@
export type BubbleParams = {
theme?: BubbleTheme
previewMessage?: PreviewMessageParams
}
export type BubbleTheme = {
chatWindow?: ChatWindowTheme
button?: ButtonTheme
previewMessage?: PreviewMessageTheme
}
export type ChatWindowTheme = {
backgroundColor?: string
}
export type ButtonTheme = {
backgroundColor?: string
iconColor?: string
customIconSrc?: string
}
export type PreviewMessageParams = {
avatarUrl?: string
message: string
autoShowDelay?: number
}
export type PreviewMessageTheme = {
backgroundColor?: string
textColor?: string
closeButtonBackgroundColor?: string
closeButtonIconColor?: string
}

View File

@ -0,0 +1,2 @@
export * from './types'
export * from './utils'

View File

@ -0,0 +1,21 @@
import { PreviewMessageParams } from '../bubble/types'
export type CommandData = {
isFromTypebot: boolean
} & (
| {
command: 'open' | 'toggle' | 'close' | 'hidePreviewMessage'
}
| ShowMessageCommandData
| SetPrefilledVariablesCommandData
)
export type ShowMessageCommandData = {
command: 'showPreviewMessage'
message?: Pick<PreviewMessageParams, 'avatarUrl' | 'message'>
}
export type SetPrefilledVariablesCommandData = {
command: 'setPrefilledVariables'
variables: Record<string, string | number | boolean>
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const close = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'close',
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const hidePreviewMessage = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'hidePreviewMessage',
}
window.postMessage(message)
}

View File

@ -0,0 +1,6 @@
export * from './close'
export * from './hidePreviewMessage'
export * from './open'
export * from './setPrefilledVariables'
export * from './showPreviewMessage'
export * from './toggle'

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const open = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'open',
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData } from '../types'
export const setPrefilledVariables = (
variables: Record<string, string | number | boolean>
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'setPrefilledVariables',
variables,
}
window.postMessage(message)
}

View File

@ -0,0 +1,12 @@
import { CommandData, ShowMessageCommandData } from '../types'
export const showPreviewMessage = (
proactiveMessage?: ShowMessageCommandData['message']
) => {
const message: CommandData = {
isFromTypebot: true,
command: 'showPreviewMessage',
message: proactiveMessage,
}
window.postMessage(message)
}

View File

@ -0,0 +1,9 @@
import { CommandData } from '../types'
export const toggle = () => {
const message: CommandData = {
isFromTypebot: true,
command: 'toggle',
}
window.postMessage(message)
}

View File

@ -0,0 +1,134 @@
import styles from '../../../assets/index.css'
import {
createSignal,
onMount,
Show,
splitProps,
onCleanup,
createEffect,
} from 'solid-js'
import { CommandData } from '../../commands'
import { isDefined, isNotDefined } from '@typebot.io/lib'
import { PopupParams } from '../types'
import { Bot, BotProps } from '../../../components/Bot'
export type PopupProps = BotProps &
PopupParams & {
defaultOpen?: boolean
isOpen?: boolean
onOpen?: () => void
onClose?: () => void
}
export const Popup = (props: PopupProps) => {
const [popupProps, botProps] = splitProps(props, [
'onOpen',
'onClose',
'autoShowDelay',
'theme',
'isOpen',
'defaultOpen',
])
const [prefilledVariables, setPrefilledVariables] = createSignal(
// eslint-disable-next-line solid/reactivity
botProps.prefilledVariables
)
const [isBotOpened, setIsBotOpened] = createSignal(
// eslint-disable-next-line solid/reactivity
popupProps.isOpen ?? false
)
onMount(() => {
if (popupProps.defaultOpen) openBot()
window.addEventListener('message', processIncomingEvent)
const autoShowDelay = popupProps.autoShowDelay
if (isDefined(autoShowDelay)) {
setTimeout(() => {
openBot()
}, autoShowDelay)
}
})
onCleanup(() => {
window.removeEventListener('message', processIncomingEvent)
})
createEffect(() => {
if (isNotDefined(props.isOpen) || props.isOpen === isBotOpened()) return
toggleBot()
})
createEffect(() => {
if (!props.prefilledVariables) return
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...props.prefilledVariables,
}))
})
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation()
}
const processIncomingEvent = (event: MessageEvent<CommandData>) => {
const { data } = event
if (!data.isFromTypebot) return
if (data.command === 'open') openBot()
if (data.command === 'close') closeBot()
if (data.command === 'toggle') toggleBot()
if (data.command === 'setPrefilledVariables')
setPrefilledVariables((existingPrefilledVariables) => ({
...existingPrefilledVariables,
...data.variables,
}))
}
const openBot = () => {
setIsBotOpened(true)
popupProps.onOpen?.()
document.body.style.overflow = 'hidden'
document.addEventListener('pointerdown', closeBot)
}
const closeBot = () => {
setIsBotOpened(false)
popupProps.onClose?.()
document.body.style.overflow = 'auto'
document.removeEventListener('pointerdown', closeBot)
}
const toggleBot = () => {
isBotOpened() ? closeBot() : openBot()
}
return (
<Show when={isBotOpened()}>
<style>{styles}</style>
<div
class="relative z-10"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<style>{styles}</style>
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in" />
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
class="relative h-[80vh] transform overflow-hidden rounded-lg text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
style={{
'background-color':
props.theme?.backgroundColor ?? 'transparent',
}}
on:pointerdown={stopPropagation}
>
<Bot {...botProps} prefilledVariables={prefilledVariables()} />
</div>
</div>
</div>
</div>
</Show>
)
}

View File

@ -0,0 +1 @@
export * from './Popup'

View File

@ -0,0 +1 @@
export * from './components'

View File

@ -0,0 +1,7 @@
export type PopupParams = {
autoShowDelay?: number
theme?: {
width?: string
backgroundColor?: string
}
}

View File

@ -0,0 +1,48 @@
import styles from '../../../assets/index.css'
import { Bot, BotProps } from '@/components/Bot'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
const hostElementCss = `
:host {
display: block;
width: 100%;
height: 100%;
overflow-y: hidden;
}
`
export const Standard = (
props: BotProps,
{ element }: { element: HTMLElement }
) => {
const [isBotDisplayed, setIsBotDisplayed] = createSignal(false)
const launchBot = () => {
setIsBotDisplayed(true)
}
const botLauncherObserver = new IntersectionObserver((intersections) => {
if (intersections.some((intersection) => intersection.isIntersecting))
launchBot()
})
onMount(() => {
botLauncherObserver.observe(element)
})
onCleanup(() => {
botLauncherObserver.disconnect()
})
return (
<>
<style>
{styles}
{hostElementCss}
</style>
<Show when={isBotDisplayed()}>
<Bot {...props} />
</Show>
</>
)
}

View File

@ -0,0 +1 @@
export * from './Standard'

View File

@ -0,0 +1 @@
export * from './components'