2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

@ -0,0 +1,58 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { AudioBubbleContent } from 'models'
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 lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
>
{isTyping() && <TypingBubble />}
</div>
<audio
src={props.url}
class={
'z-10 text-fade-in m-2 ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{ height: isTyping() ? '2rem' : '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,54 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { EmbedBubbleContent } from 'models'
import { createSignal, onMount } from 'solid-js'
type Props = {
content: EmbedBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const EmbedBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
onMount(() => {
setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}, 1000)
})
return (
<div class="flex flex-col w-full animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '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() ? '2rem' : `${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,70 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { ImageBubbleContent } from 'models'
import { createSignal, onMount } from 'solid-js'
type Props = {
url: ImageBubbleContent['url']
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = (props: Props) => {
let image: HTMLImageElement | undefined
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!image) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, mediaLoadingFallbackTimeout)
image.onload = () => {
clearTimeout(timeout)
setIsTyping(false)
onTypingEnd()
}
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
>
{isTyping() ? <TypingBubble /> : null}
</div>
<figure class="p-4 z-10">
<img
ref={image}
src={props.url}
class={
'text-fade-in w-auto rounded-md max-w-full ' +
(isTyping() ? 'opacity-0' : 'opacity-100')
}
style={{
'max-height': '32rem',
height: isTyping() ? '2rem' : 'auto',
}}
alt="Bubble image"
/>
</figure>
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,69 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { TextBubbleContent, TypingEmulation } from 'models'
import { createSignal, onMount } from 'solid-js'
import { computeTypingDuration } from '../utils/computeTypingDuration'
type Props = {
content: Pick<TextBubbleContent, 'html' | 'plainText'>
onTransitionEnd: () => void
typingEmulation?: TypingEmulation
}
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
export const TextBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
onMount(() => {
if (!isTyping) return
const typingDuration = computeTypingDuration(
props.content.plainText,
props.typingEmulation ?? defaultTypingEmulation
)
setTimeout(() => {
onTypingEnd()
}, typingDuration)
})
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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '100%',
}}
data-testid="host-bubble"
>
{isTyping() && <TypingBubble />}
</div>
<p
style={{
'text-overflow': 'ellipsis',
}}
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative ' +
(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 { TypingEmulation } from 'models'
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,115 @@
import { TypingBubble } from '@/components/bubbles/TypingBubble'
import { VideoBubbleContent, VideoBubbleContentType } from 'models'
import { createSignal, Match, onMount, Switch } from 'solid-js'
type Props = {
content: VideoBubbleContent
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = (props: Props) => {
const [isTyping, setIsTyping] = createSignal(true)
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
props.onTransitionEnd()
}, showAnimationDuration)
}
const showContentAfterMediaLoad = () => {
setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
}
onMount(() => {
if (!isTyping) return
showContentAfterMediaLoad()
})
return (
<div class="flex flex-col animate-fade-in">
<div class="flex mb-2 w-full lg:w-11/12 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() ? '4rem' : '100%',
height: isTyping() ? '2rem' : '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 &&
[
VideoBubbleContentType.VIMEO,
VideoBubbleContentType.YOUTUBE,
].includes(props.content.type)
}
>
<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 ? '2rem' : '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 ? '2rem' : '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,83 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { ChoiceInputBlock } from 'models'
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) => (e: MouseEvent) => {
e.preventDefault()
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'
}
onClick={(event) => handleClick(index())(event)}
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-testid="button"
data-itemid={item.id}
>
{item.content}
</button>
{props.inputIndex === 0 && (
<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,100 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { DateInputOptions } from 'models'
import { createSignal } from 'solid-js'
import { parseReadableDate } from '../utils/parseReadableDate'
type Props = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
}
export const DateForm = (props: Props) => {
const [inputValues, setInputValues] = createSignal({ from: '', to: '' })
return (
<div class="flex flex-col w-full lg:w-4/6">
<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"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
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"
style={{
'min-height': '2rem',
'min-width': '100px',
'font-size': '16px',
}}
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>
)
}

View File

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

View File

@ -0,0 +1,26 @@
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(from).toLocaleString(
currentLocale,
formatOptions
)
const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions)
return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}`
}

View File

@ -0,0 +1,65 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { EmailInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: EmailInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const EmailInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,258 @@
import { SendButton, Spinner } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { FileInputBlock } from 'models'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { uploadFiles } from 'utils'
type Props = {
context: BotContext
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: () => 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: `/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: `/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 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 '
}
onClick={() => props.onSkip()}
>
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'
}
onClick={clearFiles}
>
Clear
</button>
</Show>
<SendButton type="submit" disableIcon>
{props.block.options.labels.button
? `${props.block.options.labels.button} ${
selectedFiles().length
} file${selectedFiles().length > 1 ? 's' : ''}`
: 'Upload'}
</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"
>
<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
class="mb-3"
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"
>
<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,68 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { NumberInputBlock } from 'models'
import { createSignal } from 'solid-js'
type NumberInputProps = {
block: NumberInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const NumberInput = (props: NumberInputProps) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={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 { PaymentInputOptions, PaymentProvider, RuntimeOptions } from 'models'
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
// eslint-disable-next-line solid/reactivity
onSuccess={props.onSuccess}
options={props.options}
context={props.context}
/>
</Match>
</Switch>
)

View File

@ -0,0 +1,119 @@
import { SendButton } from '@/components/SendButton'
import { createEffect, createSignal, Show } from 'solid-js'
import { Stripe, StripeElements } from '@stripe/stripe-js'
import { BotContext } from '@/types'
import { PaymentInputOptions, RuntimeOptions } from 'models'
import '@power-elements/stripe-elements'
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'stripe-payment-request': unknown
}
}
}
// TODO: Implement support for payment input. (WIP)
type Props = {
context: BotContext
options: PaymentInputOptions & RuntimeOptions
onSuccess: () => void
}
let stripe: Stripe | undefined
let elements: StripeElements | undefined
let ignoreFirstPaymentIntentCall = true
export const StripePaymentForm = (props: Props) => {
const [message, setMessage] = createSignal<string>()
const [isLoading, setIsLoading] = createSignal(false)
createEffect(() => {
if (!stripe) return
if (ignoreFirstPaymentIntentCall)
return (ignoreFirstPaymentIntentCall = false)
stripe
.retrievePaymentIntent(props.options.paymentIntentSecret)
.then(({ paymentIntent }) => {
switch (paymentIntent?.status) {
case 'succeeded':
setMessage('Payment succeeded!')
break
case 'processing':
setMessage('Your payment is processing.')
break
case 'requires_payment_method':
setMessage('Your payment was not successful, please try again.')
break
default:
setMessage('Something went wrong.')
break
}
})
})
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"
>
{/* <stripe-payment-request
publishable-key={props.options.publicKey}
client-secret={props.options.paymentIntentSecret}
generate="source"
amount="125"
label="Double Double"
country="CA"
currency={props.options.currency}
/> */}
<SendButton
isLoading={isLoading() || !elements}
class="mt-4 w-full max-w-lg"
disableIcon
>
{props.options.labels.button} {props.options.amountLabel}
</SendButton>
<Show when={message()}>
<div
id="payment-message"
class="typebot-input-error-message mt-4 text-center"
>
{message()}
</div>
</Show>
</form>
)
}

View File

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

View File

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

View File

@ -0,0 +1,108 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { PhoneNumberInputBlock } from 'models'
import { createSignal, For } from 'solid-js'
import { phoneCountries } from 'utils/phoneCountries'
type PhoneInputProps = {
block: PhoneNumberInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const PhoneInput = (props: PhoneInputProps) => {
const [selectedCountryCode, setSelectedCountryCode] = createSignal('INT')
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
let inputRef: HTMLInputElement | undefined
const handleInput = (inputValue: string | undefined) => {
setInputValue(inputValue as string)
const matchedCountry = phoneCountries.find(
(country) =>
country.dial_code === inputValue &&
country.code !== selectedCountryCode()
)
if (matchedCountry) setSelectedCountryCode(matchedCountry.code)
}
const checkIfInputIsValid = () =>
inputValue() !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) props.onSubmit({ value: inputValue() })
}
const submitWhenEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
const selectNewCountryCode = (
event: Event & { currentTarget: { value: string } }
) => {
setSelectedCountryCode(event.currentTarget.value)
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'max-width': '400px',
}}
onKeyDown={submitWhenEnter}
>
<div class="flex flex-1">
<select
onChange={selectNewCountryCode}
class="w-12 pl-2 focus:outline-none"
>
<option selected>
{
phoneCountries.find(
(country) => selectedCountryCode() === country.code
)?.flag
}
</option>
<For
each={phoneCountries.filter(
(country) => country.code !== selectedCountryCode()
)}
>
{(country) => (
<option value={country.code}>
{country.name} ({country.dial_code})
</option>
)}
</For>
</select>
<ShortTextInput
type="tel"
ref={inputRef}
value={inputValue()}
onInput={handleInput}
placeholder={
props.block.options.labels.placeholder ?? 'Your phone number...'
}
autofocus={!isMobile()}
/>
</div>
<SendButton
type="button"
isDisabled={inputValue() === ''}
class="my-2 ml-2"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,116 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { RatingInputBlock, RatingInputOptions } from 'models'
import { createSignal, For, Match, Switch } from 'solid-js'
import { isDefined, isEmpty, isNotDefined } from 'utils'
type Props = {
block: RatingInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = (props: Props) => {
const [rating, setRating] = createSignal<number | undefined>(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ? Number(props.block.prefilledValue) : undefined
)
const handleSubmit = (e: SubmitEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
props.onSubmit({ value: rating.toString() })
}
const handleClick = (rating: number) => 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
onClick={(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 ' +
(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
}
onClick={() => 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,75 @@
import { Textarea, ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { TextInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: TextInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={submit}
>
{props.block.options?.labels?.button ?? 'Send'}
</SendButton>
</div>
)
}

View File

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

View File

@ -0,0 +1,71 @@
import { ShortTextInput } from '@/components/inputs'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { UrlInputBlock } from 'models'
import { createSignal } from 'solid-js'
type Props = {
block: UrlInputBlock & { prefilledValue?: string }
onSubmit: (value: InputSubmitContent) => void
hasGuestAvatar: boolean
}
export const UrlInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(
// eslint-disable-next-line solid/reactivity
props.block.prefilledValue ?? ''
)
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()
}
return (
<div
class={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
'margin-right': props.hasGuestAvatar ? '50px' : '0.5rem',
'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"
onClick={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,6 @@
import { executeCode } from '@/features/blocks/logic/code'
import { CodeToExecute } from 'models'
export const executeChatwoot = (chatwoot: { codeToExecute: CodeToExecute }) => {
executeCode(chatwoot.codeToExecute)
}

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 { GoogleAnalyticsOptions } from 'models'
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,10 @@
import { CodeToExecute } from 'models'
export const executeCode = async ({ content, args }: CodeToExecute) => {
const func = Function(...args.map((arg) => arg.id), content)
try {
await func(...args.map((arg) => arg.value))
} catch (err) {
console.error(err)
}
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { RedirectOptions } from 'models'
export const executeRedirect = ({ url, isNewTab }: RedirectOptions) => {
if (!url) return
window.open(url, isNewTab ? '_blank' : '_self')
}

View File

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

View File

@ -0,0 +1,63 @@
import styles from '../../../assets/index.css'
import { createSignal } from 'solid-js'
export const Bubble = () => {
const [isBotOpened, setIsBotOpened] = createSignal(false)
const toggleBot = () => {
setIsBotOpened(!isBotOpened())
}
return (
<>
<style>{styles}</style>
<button
onClick={toggleBot}
class="bg-blue-500 text-red-300 absolute bottom-4 right-4 w-12 h-12 rounded-full hover:scale-110 active:scale-95 transition-transform duration-200 flex justify-center items-center"
>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 stroke-white stroke-2 fill-transparent absolute ' +
(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>
<svg
viewBox="0 0 24 24"
style={{ transition: 'transform 200ms, opacity 200ms' }}
class={
'w-7 fill-white absolute ' +
(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>
<div
style={{
width: '400px',
height: 'calc(100% - 104px)',
'max-height': '704px',
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',
}}
class={
'absolute bottom-20 right-4 rounded-2xl ' +
(isBotOpened() ? 'opacity-1' : 'opacity-0 pointer-events-none')
}
/>
</>
)
}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export const Popup = () => {
return <div />
}

View File

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

View File

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