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,64 @@
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/providers/TypebotProvider'
import { AudioBubbleContent } from '@typebot.io/schemas'
import { TypingBubble } from '@/components/TypingBubble'
import { parseVariables } from '@/features/variables'
type Props = {
url: AudioBubbleContent['url']
onTransitionEnd: () => void
}
const showAnimationDuration = 400
const typingDuration = 500
export const AudioBubble = ({ url, onTransitionEnd }: Props) => {
const { typebot, isLoading } = useTypebot()
const audio = useRef<HTMLAudioElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [parsedUrl] = useState(parseVariables(typebot.variables)(url))
useEffect(() => {
if (!isTyping || isLoading) return
const typingTimeout = setTimeout(() => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}, typingDuration)
return () => {
clearTimeout(typingTimeout)
}
}, [isLoading, isTyping, onTransitionEnd])
return (
<div className="flex flex-col">
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingBubble /> : null}
</div>
<audio
ref={audio}
src={parsedUrl}
className={
'z-10 content-opacity 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,78 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { EmbedBubbleBlock } from '@typebot.io/schemas'
import { TypingBubble } from '../../../../../components/TypingBubble'
import { parseVariables } from '@/features/variables'
import { useTypebot } from '@/providers/TypebotProvider'
type Props = {
block: EmbedBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot, isLoading } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [url] = useState(parseVariables(typebot.variables)(block.content?.url))
const onTypingEnd = useCallback(() => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}, [onTransitionEnd])
useEffect(() => {
if (!isTyping || isLoading) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
return () => {
clearTimeout(timeout)
}
}, [isLoading, isTyping, onTypingEnd])
const height = block.content.height
? typeof block.content.height === 'string'
? parseVariables(typebot.variables)(block.content.height) + 'px'
: block.content.height
: '2rem'
return (
<div className="flex flex-col w-full" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div
className={
'flex relative z-10 items-start typebot-host-bubble w-full'
}
>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingBubble /> : <></>}
</div>
<iframe
id="embed-bubble-content"
src={url}
className={
'w-full z-20 p-4 content-opacity ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping ? '2rem' : height,
borderRadius: '15px',
}}
/>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/providers/TypebotProvider'
import { ImageBubbleBlock } from '@typebot.io/schemas'
import { TypingBubble } from '@/components/TypingBubble'
import { parseVariables } from '@/features/variables'
type Props = {
block: ImageBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot, isLoading } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const image = useRef<HTMLImageElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [url] = useState(parseVariables(typebot.variables)(block.content?.url))
const onTypingEnd = useCallback(() => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}, [onTransitionEnd])
useEffect(() => {
if (!isTyping || isLoading) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, mediaLoadingFallbackTimeout)
return () => {
clearTimeout(timeout)
}
}, [isLoading, isTyping, onTypingEnd])
useEffect(() => {
const currentImage = image.current
if (!currentImage || isLoading || !isTyping) return
currentImage.onload = () => {
setIsTyping(false)
onTypingEnd()
}
return () => {
currentImage.onload = null
}
}, [isLoading, isTyping, onTypingEnd])
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingBubble /> : null}
</div>
<img
ref={image}
src={url}
className={
'p-4 content-opacity z-10 w-auto rounded-lg ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
maxHeight: '32rem',
height: isTyping ? '2rem' : 'auto',
maxWidth: '100%',
}}
alt="Bubble image"
/>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/providers/TypebotProvider'
import { BubbleBlockType, TextBubbleBlock } from '@typebot.io/schemas'
import { computeTypingDuration } from '../utils/computeTypingDuration'
import { parseVariables } from '@/features/variables'
import { TypingBubble } from '@/components/TypingBubble'
type Props = {
block: TextBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
const defaultTypingEmulation = {
enabled: true,
speed: 300,
maxDelay: 1.5,
}
export const TextBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot, isLoading } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [content] = useState(
parseVariables(typebot.variables)(block.content.html)
)
const onTypingEnd = useCallback(() => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}, [onTransitionEnd])
useEffect(() => {
if (!isTyping || isLoading) return
const typingTimeout = computeTypingDuration(
block.content.plainText,
typebot.settings?.typingEmulation ?? defaultTypingEmulation
)
const timeout = setTimeout(() => {
onTypingEnd()
}, typingTimeout)
return () => {
clearTimeout(timeout)
}
}, [
block.content.plainText,
isLoading,
isTyping,
onTypingEnd,
typebot.settings?.typingEmulation,
])
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full items-center">
<div className={'flex relative items-start typebot-host-bubble'}>
<div
className="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 /> : null}
</div>
{block.type === BubbleBlockType.TEXT && (
<p
style={{
textOverflow: 'ellipsis',
}}
className={
'overflow-hidden content-opacity mx-4 my-2 whitespace-pre-wrap slate-html-container relative ' +
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
}
dangerouslySetInnerHTML={{
__html: content,
}}
/>
)}
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,16 @@
import { 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,123 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/providers/TypebotProvider'
import {
Variable,
VideoBubbleContent,
VideoBubbleContentType,
VideoBubbleBlock,
} from '@typebot.io/schemas'
import { TypingBubble } from '@/components/TypingBubble'
import { parseVariables } from '@/features/variables'
type Props = {
block: VideoBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot, isLoading } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const onTypingEnd = useCallback(() => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}, [onTransitionEnd])
useEffect(() => {
if (!isTyping || isLoading) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
return () => {
clearTimeout(timeout)
}
}, [isLoading, isTyping, onTypingEnd])
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingBubble /> : <></>}
</div>
<VideoContent
content={block.content}
isTyping={isTyping}
variables={typebot.variables}
/>
</div>
</div>
</div>
)
}
const VideoContent = ({
content,
isTyping,
variables,
}: {
content?: VideoBubbleContent
isTyping: boolean
variables: Variable[]
}) => {
const [url] = useState(parseVariables(variables)(content?.url))
if (!content?.type) return <></>
switch (content.type) {
case VideoBubbleContentType.URL: {
const isSafariBrowser = window.navigator.vendor.match(/apple/i)
return (
<video
controls
className={
'p-4 focus:outline-none w-full z-10 content-opacity rounded-md ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
style={{
height: isTyping ? '2rem' : 'auto',
maxHeight: isSafariBrowser ? '40vh' : '',
}}
autoPlay
>
<source src={url} type="video/mp4" />
Sorry, your browser doesn&apos;t support embedded videos.
</video>
)
}
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<iframe
src={`${baseUrl}/${content.id}`}
className={
'w-full p-4 content-opacity z-10 rounded-md ' +
(isTyping ? 'opacity-0' : 'opacity-100')
}
height={isTyping ? '2rem' : '200px'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
)
}
}
}

View File

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

View File

@@ -0,0 +1,94 @@
import { parseVariables } from '@/features/variables'
import { useAnswers } from '@/providers/AnswersProvider'
import { useTypebot } from '@/providers/TypebotProvider'
import { InputSubmitContent } from '@/types'
import { ChoiceInputBlock } from '@typebot.io/schemas'
import React, { useState } from 'react'
import { SendButton } from '../../../../../components/SendButton'
type ChoiceFormProps = {
block: ChoiceInputBlock
onSubmit: (value: InputSubmitContent) => void
}
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
const {
typebot: { variables },
} = useTypebot()
const { resultValues } = useAnswers()
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const handleClick = (itemIndex: number) => (e: React.MouseEvent) => {
e.preventDefault()
if (block.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
else
onSubmit({
value: parseVariables(variables)(block.items[itemIndex].content),
itemId: block.items[itemIndex].id,
})
}
const toggleSelectedItemIndex = (itemIndex: number) => {
const existingIndex = selectedIndices.indexOf(itemIndex)
if (existingIndex !== -1) {
selectedIndices.splice(existingIndex, 1)
setSelectedIndices([...selectedIndices])
} else {
setSelectedIndices([...selectedIndices, itemIndex])
}
}
const handleSubmit = () =>
onSubmit({
value: selectedIndices
.map((itemIndex) =>
parseVariables(variables)(block.items[itemIndex].content)
)
.join(', '),
})
const isUniqueFirstButton =
resultValues &&
resultValues.answers.length === 0 &&
block.items.length === 1
return (
<form className="flex flex-col items-end" onSubmit={handleSubmit}>
<div className="flex flex-wrap justify-end">
{block.items.map((item, idx) => (
<span key={item.id} className="relative inline-flex ml-2 mb-2">
<button
role={block.options?.isMultipleChoice ? 'checkbox' : 'button'}
onClick={handleClick(idx)}
className={
'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.includes(idx) ||
!block.options?.isMultipleChoice
? ''
: 'selectable')
}
data-testid="button"
data-itemid={item.id}
>
{parseVariables(variables)(item.content)}
</button>
{isUniqueFirstButton && (
<span className="flex h-3 w-3 absolute top-0 right-0 -mt-1 -mr-1 ping">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full brightness-225 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 brightness-200" />
</span>
)}
</span>
))}
</div>
<div className="flex">
{selectedIndices.length > 0 && (
<SendButton
label={block.options?.buttonLabel ?? 'Send'}
disableIcon
/>
)}
</div>
</form>
)
}

View File

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

View File

@@ -0,0 +1,86 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { DateInputOptions } from '@typebot.io/schemas'
import { useState } from 'react'
import { parseReadableDate } from '../utils/parseReadableDate'
type DateInputProps = {
onSubmit: (inputValue: InputSubmitContent) => void
options?: DateInputOptions
}
export const DateForm = ({
onSubmit,
options,
}: DateInputProps): JSX.Element => {
const { hasTime, isRange, labels } = options ?? {}
const [inputValues, setInputValues] = useState({ from: '', to: '' })
return (
<div className="flex flex-col w-full lg:w-4/6">
<div className="flex items-center">
<form
className={
'flex justify-between rounded-lg typebot-input pr-2 items-end'
}
onSubmit={(e) => {
if (inputValues.from === '' && inputValues.to === '') return
e.preventDefault()
onSubmit({
value: `${inputValues.from}${
isRange ? ` to ${inputValues.to}` : ''
}`,
label: parseReadableDate({ ...inputValues, hasTime, isRange }),
})
}}
>
<div className="flex flex-col">
<div className={'flex items-center p-4 ' + (isRange ? 'pb-0' : '')}>
{isRange && (
<p className="font-semibold mr-2">{labels?.from ?? 'From:'}</p>
)}
<input
className="focus:outline-none flex-1 w-full text-input"
style={{
minHeight: '2rem',
minWidth: '100px',
fontSize: '16px',
}}
type={hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({ ...inputValues, from: e.target.value })
}
data-testid="from-date"
/>
</div>
{isRange && (
<div className="flex items-center p-4">
{isRange && (
<p className="font-semibold">{labels?.to ?? 'To:'}</p>
)}
<input
className="focus:outline-none flex-1 w-full text-input ml-2"
style={{
minHeight: '2rem',
minWidth: '100px',
fontSize: '16px',
}}
type={hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({ ...inputValues, to: e.target.value })
}
data-testid="to-date"
/>
</div>
)}
</div>
<SendButton
label={labels?.button ?? 'Send'}
isDisabled={inputValues.to === '' && inputValues.from === ''}
className="my-2 ml-2"
/>
</form>
</div>
</div>
)
}

View File

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

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,65 @@
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { EmailInputBlock } from '@typebot.io/schemas'
import React, { MutableRefObject, useRef, useState } from 'react'
type EmailInputProps = {
block: EmailInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const EmailInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: EmailInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const handleChange = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue !== '' && inputRef.current?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
className={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as MutableRefObject<HTMLInputElement>}
value={inputValue}
placeholder={block.options?.labels?.placeholder ?? 'Type your email...'}
onChange={handleChange}
type="email"
autoComplete="email"
/>
<SendButton
type="button"
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
onClick={submit}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,4 @@
const emailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
export const validateEmail = (email: string) => emailRegex.test(email)

View File

@@ -0,0 +1,256 @@
import { Spinner, SendButton } from '@/components/SendButton'
import { useAnswers } from '@/providers/AnswersProvider'
import { useTypebot } from '@/providers/TypebotProvider'
import { InputSubmitContent } from '@/types'
import { defaultFileInputOptions, FileInputBlock } from '@typebot.io/schemas'
import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react'
import { uploadFiles } from '@typebot.io/lib'
type Props = {
block: FileInputBlock
onSubmit: (url: InputSubmitContent) => void
onSkip: () => void
}
export const FileUploadForm = ({
block: {
id,
options: { isMultipleAllowed, labels, sizeLimit, isRequired },
},
onSubmit,
onSkip,
}: Props) => {
const { isPreview, currentTypebotId } = useTypebot()
const { resultId } = useAnswers()
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [isUploading, setIsUploading] = useState(false)
const [uploadProgressPercent, setUploadProgressPercent] = useState(0)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [errorMessage, setErrorMessage] = useState<string>()
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return
onNewFiles(e.target.files)
}
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
if (newFiles.some((file) => file.size > (sizeLimit ?? 10) * 1024 * 1024))
return setErrorMessage(`A file is larger than ${sizeLimit ?? 10}MB`)
if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0])
setSelectedFiles([...selectedFiles, ...newFiles])
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (selectedFiles.length === 0) return
startFilesUpload(selectedFiles)
}
const startSingleFileUpload = async (file: File) => {
if (isPreview)
return onSubmit({
label: `File uploaded`,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
basePath: `/api/typebots/${currentTypebotId}/blocks/${id}`,
files: [
{
file,
path: `public/results/${resultId}/${id}/${file.name}`,
},
],
})
setIsUploading(false)
if (urls.length)
return onSubmit({ label: `File uploaded`, value: urls[0] ?? '' })
setErrorMessage('An error occured while uploading the file')
}
const startFilesUpload = async (files: File[]) => {
if (isPreview)
return 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/${currentTypebotId}/blocks/${id}`,
files: files.map((file) => ({
file: file,
path: `public/results/${resultId}/${id}/${file.name}`,
})),
onUploadProgress: setUploadProgressPercent,
})
setIsUploading(false)
setUploadProgressPercent(0)
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
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<HTMLLabelElement>) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer.files) return
onNewFiles(e.dataTransfer.files)
}
const clearFiles = () => setSelectedFiles([])
return (
<form className="flex flex-col w-full" onSubmit={handleSubmit}>
<label
htmlFor="dropzone-file"
className={
'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}
>
{isUploading ? (
<>
{selectedFiles.length === 1 ? (
<Spinner />
) : (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="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>
)}
</>
) : (
<>
<div className="flex flex-col justify-center items-center">
{selectedFiles.length ? (
<span className="relative">
<FileIcon />
<div
className="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles.length}
</div>
</span>
) : (
<UploadIcon />
)}
<p
className="text-sm text-gray-500 text-center"
dangerouslySetInnerHTML={{ __html: labels.placeholder }}
/>
</div>
<input
id="dropzone-file"
type="file"
className="hidden"
multiple={isMultipleAllowed}
onChange={handleFileChange}
/>
</>
)}
</label>
{selectedFiles.length === 0 && isRequired === false && (
<div className="flex justify-end">
<button
className={
'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={onSkip}
>
{labels.skip ?? defaultFileInputOptions.labels.skip}
</button>
</div>
)}
{isMultipleAllowed && selectedFiles.length > 0 && !isUploading && (
<div className="flex justify-end">
<div className="flex">
{selectedFiles.length && (
<button
className={
'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}
>
{labels.clear ?? defaultFileInputOptions.labels.clear}
</button>
)}
<SendButton
type="submit"
label={
labels.button === defaultFileInputOptions.labels.button
? `${labels.button} ${selectedFiles.length} file${
selectedFiles.length > 1 ? 's' : ''
}`
: labels.button
}
disableIcon
/>
</div>
</div>
)}
{errorMessage && <p className="text-red-500 text-sm">{errorMessage}</p>}
</form>
)
}
const UploadIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-3"
>
<polyline points="16 16 12 12 8 16"></polyline>
<line x1="12" y1="12" x2="12" y2="21"></line>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path>
<polyline points="16 16 12 12 8 16"></polyline>
</svg>
)
const FileIcon = () => (
<svg
className="mb-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
)

View File

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

View File

@@ -0,0 +1,70 @@
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { NumberInputBlock } from '@typebot.io/schemas'
import React, { MutableRefObject, useRef, useState } from 'react'
type NumberInputProps = {
block: NumberInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const NumberInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: NumberInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const handleChange = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue !== '' && inputRef.current?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
className={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as MutableRefObject<HTMLInputElement>}
value={inputValue}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleChange}
type="number"
style={{ appearance: 'auto' }}
min={block.options?.min}
max={block.options?.max}
step={block.options?.step ?? 'any'}
/>
<SendButton
type="button"
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
onClick={submit}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,15 @@
import { PaymentInputOptions, PaymentProvider } from '@typebot.io/schemas'
import React from 'react'
import { StripePaymentForm } from './StripePaymentForm'
type Props = {
onSuccess: () => void
options: PaymentInputOptions
}
export const PaymentForm = ({ onSuccess, options }: Props): JSX.Element => {
switch (options.provider) {
case PaymentProvider.STRIPE:
return <StripePaymentForm onSuccess={onSuccess} options={options} />
}
}

View File

@@ -0,0 +1,193 @@
import React, { FormEvent, useEffect, useState } from 'react'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
import { Elements } from '@stripe/react-stripe-js'
import { PaymentInputOptions, Variable } from '@typebot.io/schemas'
import { SendButton, Spinner } from '@/components/SendButton'
import { initStripe } from '@/lib/stripe'
import { parseVariables } from '@/features/variables'
import { useChat } from '@/providers/ChatProvider'
import { useTypebot } from '@/providers/TypebotProvider'
import { createPaymentIntentQuery } from '../../queries/createPaymentIntentQuery'
import { Stripe } from '@stripe/stripe-js'
type Props = {
options: PaymentInputOptions
onSuccess: () => void
}
export const StripePaymentForm = ({ options, onSuccess }: Props) => {
const {
apiHost,
isPreview,
typebot: { variables },
onNewLog,
} = useTypebot()
const [stripe, setStripe] = useState<Stripe | null>(null)
const [clientSecret, setClientSecret] = useState('')
const [amountLabel, setAmountLabel] = useState('')
useEffect(() => {
;(async () => {
const { data, error } = await createPaymentIntentQuery({
apiHost,
isPreview,
variables,
inputOptions: options,
})
if (error)
return onNewLog({
status: 'error',
description: error.name + ' ' + error.message,
details: error.message,
})
if (!data || !document) return
await initStripe(document)
if (!window?.Stripe) return
setStripe(window.Stripe(data.publicKey))
setClientSecret(data.clientSecret)
setAmountLabel(data.amountLabel)
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!stripe || !clientSecret) return <Spinner className="text-blue-500" />
return (
<Elements stripe={stripe} options={{ clientSecret }}>
<CheckoutForm
onSuccess={onSuccess}
clientSecret={clientSecret}
amountLabel={amountLabel}
options={options}
variables={variables}
viewerHost={apiHost}
/>
</Elements>
)
}
const CheckoutForm = ({
onSuccess,
clientSecret,
amountLabel,
options,
variables,
viewerHost,
}: {
onSuccess: () => void
clientSecret: string
amountLabel: string
options: PaymentInputOptions
variables: Variable[]
viewerHost: string
}) => {
const { scroll } = useChat()
const [ignoreFirstPaymentIntentCall, setIgnoreFirstPaymentIntentCall] =
useState(true)
const stripe = useStripe()
const elements = useElements()
const [message, setMessage] = useState<string>()
const [isLoading, setIsLoading] = useState(false)
const [isPayButtonVisible, setIsPayButtonVisible] = useState(false)
useEffect(() => {
if (!stripe || !clientSecret) return
if (ignoreFirstPaymentIntentCall)
return setIgnoreFirstPaymentIntentCall(false)
stripe.retrievePaymentIntent(clientSecret).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
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stripe, clientSecret])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setIsLoading(true)
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
// TO-DO: Handle redirection correctly.
return_url: viewerHost,
payment_method_data: {
billing_details: {
name: options.additionalInformation?.name
? parseVariables(variables)(options.additionalInformation?.name)
: undefined,
email: options.additionalInformation?.email
? parseVariables(variables)(options.additionalInformation?.email)
: undefined,
phone: options.additionalInformation?.phoneNumber
? parseVariables(variables)(
options.additionalInformation?.phoneNumber
)
: undefined,
},
},
},
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 onSuccess()
}
const showPayButton = () => {
setIsPayButtonVisible(true)
scroll()
}
return (
<form
id="payment-form"
onSubmit={handleSubmit}
className="flex flex-col rounded-lg p-4 typebot-input w-full items-center"
>
<PaymentElement
id="payment-element"
className="w-full"
onReady={showPayButton}
/>
{isPayButtonVisible && (
<SendButton
label={`${options.labels.button} ${amountLabel}`}
isDisabled={isLoading || !stripe || !elements}
isLoading={isLoading}
className="mt-4 w-full max-w-lg"
disableIcon
/>
)}
{message && (
<div
id="payment-message"
className="typebot-input-error-message mt-4 text-center"
>
{message}
</div>
)}
</form>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import { PaymentInputOptions, Variable } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const createPaymentIntentQuery = ({
apiHost,
isPreview,
inputOptions,
variables,
}: {
inputOptions: PaymentInputOptions
apiHost: string
variables: Variable[]
isPreview: boolean
}) =>
sendRequest<{ clientSecret: string; publicKey: string; amountLabel: string }>(
{
url: `${apiHost}/api/integrations/stripe/createPaymentIntent`,
method: 'POST',
body: { inputOptions, isPreview, variables },
}
)

View File

@@ -0,0 +1,68 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/helpers'
import { PhoneNumberInputBlock } from '@typebot.io/schemas'
import React, { useRef, useState } from 'react'
import ReactPhoneNumberInput, { Value, Country } from 'react-phone-number-input'
type PhoneInputProps = {
block: PhoneNumberInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const PhoneInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: PhoneInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputRef = useRef<any>(null)
const handleChange = (inputValue: Value | undefined) =>
setInputValue(inputValue as string)
const checkIfInputIsValid = () =>
inputValue !== '' && inputRef.current?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
className={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: '350px',
}}
onKeyDown={submitWhenEnter}
>
<ReactPhoneNumberInput
ref={inputRef}
value={inputValue}
onChange={handleChange}
placeholder={block.options.labels.placeholder ?? 'Your phone number...'}
defaultCountry={block.options.defaultCountryCode as Country}
autoFocus={!isMobile}
/>
<SendButton
type="button"
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
onClick={submit}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,4 @@
import { isPossiblePhoneNumber } from 'react-phone-number-input'
export const validatePhoneNumber = (phoneNumber: string) =>
isPossiblePhoneNumber(phoneNumber)

View File

@@ -0,0 +1,111 @@
import { InputSubmitContent } from '@/types'
import { RatingInputOptions, RatingInputBlock } from '@typebot.io/schemas'
import React, { FormEvent, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
import { SendButton } from '../../../../../components/SendButton'
type Props = {
block: RatingInputBlock
onSubmit: (value: InputSubmitContent) => void
}
export const RatingForm = ({ block, onSubmit }: Props) => {
const [rating, setRating] = useState<number>()
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
onSubmit({ value: rating.toString() })
}
const handleClick = (rating: number) => {
if (block.options.isOneClickSubmitEnabled)
onSubmit({ value: rating.toString() })
setRating(rating)
}
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
{block.options.labels.left && (
<span className="text-sm w-full mb-2 rating-label">
{block.options.labels.left}
</span>
)}
<div className="flex flex-wrap justify-center">
{Array.from(
Array(
block.options.length +
(block.options.buttonType === 'Numbers' ? 1 : 0)
)
).map((_, idx) => (
<RatingButton
{...block.options}
key={idx}
rating={rating}
idx={idx + (block.options.buttonType === 'Numbers' ? 0 : 1)}
onClick={handleClick}
/>
))}
</div>
{block.options.labels.right && (
<span className="text-sm w-full text-right mb-2 pr-2 rating-label">
{block.options.labels.right}
</span>
)}
<div className="flex justify-end mr-2">
{isDefined(rating) && (
<SendButton
label={block.options?.labels.button ?? 'Send'}
disableIcon
/>
)}
</div>
</form>
)
}
const RatingButton = ({
rating,
idx,
buttonType,
customIcon,
onClick,
}: Pick<RatingInputOptions, 'buttonType' | 'customIcon'> & {
rating: number | undefined
idx: number
onClick: (idx: number) => void
}) => {
if (buttonType === 'Numbers')
return (
<button
onClick={(e) => {
e.preventDefault()
onClick(idx)
}}
className={
'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(rating) && idx <= rating ? '' : 'selectable')
}
>
{idx}
</button>
)
return (
<div
className={
'flex justify-center items-center rating-icon-container cursor-pointer mr-2 mb-2 ' +
(isDefined(rating) && idx <= rating ? 'selected' : '')
}
onClick={() => onClick(idx)}
dangerouslySetInnerHTML={{
__html:
customIcon.isEnabled && !isEmpty(customIcon.svg)
? customIcon.svg
: defaultIcon,
}}
/>
)
}
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,80 @@
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
import { Textarea } from '@/components/inputs/Textarea'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { TextInputBlock } from '@typebot.io/schemas'
import React, { MutableRefObject, useRef, useState } from 'react'
type TextInputProps = {
block: TextInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue: string | undefined
hasGuestAvatar: boolean
}
export const TextInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: TextInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const isLongText = block.options?.isLong
const handleChange = (inputValue: string) => setInputValue(inputValue)
const checkIfInputIsValid = () =>
inputValue !== '' && inputRef.current?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (isLongText) return
if (e.key === 'Enter') submit()
}
return (
<div
className={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: isLongText ? undefined : '350px',
}}
onKeyDown={submitWhenEnter}
>
{isLongText ? (
<Textarea
ref={inputRef as MutableRefObject<HTMLTextAreaElement>}
onChange={handleChange}
value={inputValue}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
) : (
<ShortTextInput
ref={inputRef as MutableRefObject<HTMLInputElement>}
onChange={handleChange}
value={inputValue}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
/>
)}
<SendButton
type="button"
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
onClick={submit}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,71 @@
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { UrlInputBlock } from '@typebot.io/schemas'
import React, { MutableRefObject, useRef, useState } from 'react'
type UrlInputProps = {
block: UrlInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const UrlInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: UrlInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const handleChange = (inputValue: string) => {
if (!inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
const checkIfInputIsValid = () =>
inputValue !== '' && inputRef.current?.reportValidity()
const submit = () => {
if (checkIfInputIsValid()) onSubmit({ value: inputValue })
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') submit()
}
return (
<div
className={
'flex items-end justify-between rounded-lg pr-2 typebot-input w-full'
}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: '350px',
}}
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as MutableRefObject<HTMLInputElement>}
value={inputValue}
placeholder={block.options?.labels?.placeholder ?? 'Type your URL...'}
onChange={handleChange}
type="url"
autoComplete="url"
/>
<SendButton
type="button"
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
onClick={submit}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,4 @@
const urlRegex =
/^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/
export const validateUrl = (url: string) => urlRegex.test(url)

View File

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

View File

@@ -0,0 +1,81 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import { sendEventToParent } from '@/utils/chat'
import { isEmbedded } from '@/utils/helpers'
import { ChatwootBlock, ChatwootOptions } from '@typebot.io/schemas'
const parseSetUserCode = (user: ChatwootOptions['user']) => `
window.$chatwoot.setUser("${user?.id ?? ''}", {
email: ${user?.email ? `"${user.email}"` : 'undefined'},
name: ${user?.name ? `"${user.name}"` : 'undefined'},
avatar_url: ${user?.avatarUrl ? `"${user.avatarUrl}"` : 'undefined'},
phone_number: ${user?.phoneNumber ? `"${user.phoneNumber}"` : 'undefined'},
});
`
const parseChatwootOpenCode = ({
baseUrl,
websiteToken,
user,
}: ChatwootOptions) => `
if (window.$chatwoot) {
if(${Boolean(user)}) {
${parseSetUserCode(user)}
}
window.$chatwoot.toggle("open");
} else {
(function (d, t) {
var BASE_URL = "${baseUrl}";
var g = d.createElement(t),
s = d.getElementsByTagName(t)[0];
g.src = BASE_URL + "/packs/js/sdk.js";
g.defer = true;
g.async = true;
s.parentNode.insertBefore(g, s);
g.onload = function () {
window.chatwootSDK.run({
websiteToken: "${websiteToken}",
baseUrl: BASE_URL,
});
window.addEventListener("chatwoot:ready", function () {
if(${Boolean(user?.id || user?.email)}) {
${parseSetUserCode(user)}
}
window.$chatwoot.toggle("open");
});
};
})(document, "script");
}`
export const executeChatwootBlock = (
block: ChatwootBlock,
{ variables, isPreview, onNewLog }: IntegrationState
) => {
if (isPreview) {
onNewLog({
status: 'info',
description: "Chatwoot won't open in preview mode",
details: null,
})
} else if (isEmbedded) {
sendEventToParent({
closeChatBubble: true,
})
sendEventToParent({
codeToExecute: parseVariables(variables)(
parseChatwootOpenCode(block.options)
),
})
} else {
const func = Function(
parseVariables(variables)(parseChatwootOpenCode(block.options))
)
try {
func()
} catch (err) {
console.error(err)
}
}
return block.outgoingEdgeId
}

View File

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

View File

@@ -0,0 +1,15 @@
import { parseVariablesInObject } from '@/features/variables'
import { sendGaEvent } from '@/lib/gtag'
import { IntegrationState } from '@/types'
import { GoogleAnalyticsBlock } from '@typebot.io/schemas'
export const executeGoogleAnalyticsBlock = async (
block: GoogleAnalyticsBlock,
{ variables }: IntegrationState
) => {
if (!block.options?.trackingId) return block.outgoingEdgeId
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
await initGoogleAnalytics(block.options.trackingId)
sendGaEvent(parseVariablesInObject(block.options, variables))
return block.outgoingEdgeId
}

View File

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

View File

@@ -0,0 +1,175 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import { parseLog } from '@/utils/helpers'
import {
GoogleSheetsBlock,
GoogleSheetsAction,
GoogleSheetsInsertRowOptions,
GoogleSheetsUpdateRowOptions,
GoogleSheetsGetOptions,
VariableWithValue,
Cell,
Variable,
} from '@typebot.io/schemas'
import { sendRequest, byId } from '@typebot.io/lib'
export const executeGoogleSheetBlock = async (
block: GoogleSheetsBlock,
context: IntegrationState
) => {
if (!('action' in block.options)) return block.outgoingEdgeId
switch (block.options.action) {
case GoogleSheetsAction.INSERT_ROW:
insertRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.UPDATE_ROW:
updateRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.GET:
await getRowFromGoogleSheets(block.options, context)
break
}
return block.outgoingEdgeId
}
const insertRowInGoogleSheets = (
options: GoogleSheetsInsertRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationState
) => {
if (!options.cellsToInsert) {
onNewLog({
status: 'warning',
description: 'Cells to insert are undefined',
details: null,
})
return
}
sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
method: 'POST',
body: {
action: GoogleSheetsAction.INSERT_ROW,
credentialsId: options.credentialsId,
resultId,
values: parseCellValues(options.cellsToInsert, variables),
},
}).then(({ error }) => {
onNewLog(
parseLog(
error,
'Succesfully inserted a row in the sheet',
'Failed to insert a row in the sheet'
)
)
})
}
const updateRowInGoogleSheets = (
options: GoogleSheetsUpdateRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationState
) => {
if (!options.cellsToUpsert || !options.referenceCell) return
sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
method: 'POST',
body: {
action: GoogleSheetsAction.UPDATE_ROW,
credentialsId: options.credentialsId,
values: parseCellValues(options.cellsToUpsert, variables),
resultId,
referenceCell: {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
},
},
}).then(({ error }) => {
onNewLog(
parseLog(
error,
'Succesfully updated a row in the sheet',
'Failed to update a row in the sheet'
)
)
})
}
const getRowFromGoogleSheets = async (
options: GoogleSheetsGetOptions,
{
variables,
updateVariableValue,
updateVariables,
apiHost,
onNewLog,
resultId,
}: IntegrationState
) => {
if (!options.cellsToExtract) return
const { data, error } = await sendRequest<{
rows: { [key: string]: string }[]
}>({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}`,
method: 'POST',
body: {
action: GoogleSheetsAction.GET,
credentialsId: options.credentialsId,
referenceCell: options.referenceCell
? {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
}
: undefined,
filter: options.filter
? {
comparisons: options.filter.comparisons.map((comparison) => ({
...comparison,
value: parseVariables(variables)(comparison.value),
})),
logicalOperator: options.filter?.logicalOperator ?? 'AND',
}
: undefined,
columns: options.cellsToExtract.map((cell) => cell.column),
resultId,
},
})
onNewLog(
parseLog(
error,
'Succesfully fetched data from sheet',
'Failed to fetch data from sheet'
)
)
if (!data) return
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const rows = data.rows
const randomRow = rows[Math.floor(Math.random() * rows.length)]
const value = randomRow[cell.column ?? ''] ?? null
if (!existingVariable) return newVariables
updateVariableValue(existingVariable.id, value)
return [
...newVariables,
{
...existingVariable,
value,
},
]
},
[]
)
updateVariables(newVariables)
}
const parseCellValues = (
cells: Cell[],
variables: Variable[]
): { [key: string]: string } =>
cells.reduce((row, cell) => {
return !cell.column || !cell.value
? row
: {
...row,
[cell.column]: parseVariables(variables)(cell.value),
}
}, {})

View File

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

View File

@@ -0,0 +1,53 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import { parseLog } from '@/utils/helpers'
import { SendEmailBlock } from '@typebot.io/schemas'
import { sendRequest, byId } from '@typebot.io/lib'
export const executeSendEmailBlock = (
block: SendEmailBlock,
{
variables,
apiHost,
isPreview,
onNewLog,
resultId,
typebotId,
resultValues,
}: IntegrationState
) => {
if (isPreview) {
onNewLog({
status: 'info',
description: 'Emails are not sent in preview mode',
details: null,
})
return block.outgoingEdgeId
}
const { options } = block
sendRequest({
url: `${apiHost}/api/typebots/${typebotId}/integrations/email?resultId=${resultId}`,
method: 'POST',
body: {
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
body: parseVariables(variables)(options.body ?? ''),
cc: (options.cc ?? []).map(parseVariables(variables)),
bcc: (options.bcc ?? []).map(parseVariables(variables)),
replyTo: options.replyTo
? parseVariables(variables)(options.replyTo)
: undefined,
fileUrls: variables.find(byId(options.attachmentsVariableId))?.value,
isCustomBody: options.isCustomBody,
isBodyCode: options.isBodyCode,
resultValues,
},
}).then(({ error }) => {
onNewLog(
parseLog(error, 'Succesfully sent an email', 'Failed to send an email')
)
})
return block.outgoingEdgeId
}

View File

@@ -0,0 +1 @@
export { executeWebhook } from './utils/executeWebhookBlock'

View File

@@ -0,0 +1,71 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import {
WebhookBlock,
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
} from '@typebot.io/schemas'
import { stringify } from 'qs'
import { sendRequest, byId } from '@typebot.io/lib'
export const executeWebhook = async (
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock,
{
blockId,
variables,
updateVariableValue,
updateVariables,
typebotId,
apiHost,
resultValues,
onNewLog,
resultId,
parentTypebotIds,
}: IntegrationState
) => {
const params = stringify({ resultId })
const { data, error } = await sendRequest({
url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook?${params}`,
method: 'POST',
body: {
variables,
resultValues,
parentTypebotIds,
},
})
const statusCode = (
data as Record<string, string> | undefined
)?.statusCode.toString()
const isError = statusCode
? statusCode?.startsWith('4') || statusCode?.startsWith('5')
: true
onNewLog({
status: error ? 'error' : isError ? 'warning' : 'success',
description: isError
? 'Webhook returned an error'
: 'Webhook successfuly executed',
details: JSON.stringify(error ?? data, null, 2).substring(0, 1000),
})
const newVariables = block.options.responseVariableMapping.reduce<
VariableWithUnknowValue[]
>((newVariables, varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
const existingVariable = variables.find(byId(varMapping.variableId))
if (!existingVariable) return newVariables
const func = Function(
'data',
`return data.${parseVariables(variables)(varMapping?.bodyPath)}`
)
try {
const value: unknown = func(data)
updateVariableValue(existingVariable?.id, value)
return [...newVariables, { ...existingVariable, value }]
} catch (err) {
return newVariables
}
}, [])
updateVariables(newVariables)
return block.outgoingEdgeId
}

View File

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

View File

@@ -0,0 +1,65 @@
import { parseVariables } from '@/features/variables'
import { EdgeId, LogicState } from '@/types'
import {
Comparison,
ComparisonOperators,
ConditionBlock,
LogicalOperator,
Variable,
} from '@typebot.io/schemas'
import { isNotDefined, isDefined } from '@typebot.io/lib'
export const executeCondition = (
block: ConditionBlock,
{ typebot: { variables } }: LogicState
): EdgeId | undefined => {
const passedCondition = block.items.find((item) => {
const { content } = item
const isConditionPassed =
content.logicalOperator === LogicalOperator.AND
? content.comparisons.every(executeComparison(variables))
: content.comparisons.some(executeComparison(variables))
return isConditionPassed
})
return passedCondition ? passedCondition.outgoingEdgeId : block.outgoingEdgeId
}
const executeComparison =
(variables: Variable[]) => (comparison: Comparison) => {
if (!comparison?.variableId) return false
const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
)
.toString()
.trim()
const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value) || !comparison.comparisonOperator) return false
return matchComparison(inputValue, comparison.comparisonOperator, value)
}
const matchComparison = (
inputValue: string,
comparisonOperator: ComparisonOperators,
value: string
) => {
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { parseVariables } from '@/features/variables'
import { EdgeId, LogicState } from '@/types'
import { sendEventToParent } from '@/utils/chat'
import { RedirectBlock } from '@typebot.io/schemas'
import { sanitizeUrl } from '@typebot.io/lib'
export const executeRedirect = (
block: RedirectBlock,
{ typebot: { variables } }: LogicState
): {
nextEdgeId?: EdgeId
blockedPopupUrl?: string
} => {
if (!block.options?.url) return { nextEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
const isEmbedded = window.parent && window.location !== window.top?.location
let newWindow: Window | null = null
if (isEmbedded) {
if (!block.options.isNewTab) {
;(window.top as Window).location.href = formattedUrl
return { nextEdgeId: block.outgoingEdgeId }
}
try {
newWindow = window.open(formattedUrl)
} catch (err) {
sendEventToParent({ redirectUrl: formattedUrl })
}
} else {
newWindow = window.open(
formattedUrl,
block.options.isNewTab ? '_blank' : '_self'
)
}
return {
nextEdgeId: block.outgoingEdgeId,
blockedPopupUrl: newWindow ? undefined : formattedUrl,
}
}

View File

@@ -0,0 +1,29 @@
import { parseVariables, parseCorrectValueType } from '@/features/variables'
import { LogicState } from '@/types'
import { sendEventToParent } from '@/utils/chat'
import { isEmbedded } from '@/utils/helpers'
import { ScriptBlock } from '@typebot.io/schemas'
export const executeScript = async (
block: ScriptBlock,
{ typebot: { variables } }: LogicState
) => {
if (!block.options.content) return
if (block.options.shouldExecuteInParentContext && isEmbedded) {
sendEventToParent({
codeToExecute: parseVariables(variables)(block.options.content),
})
} else {
const func = Function(
...variables.map((v) => v.id),
parseVariables(variables, { fieldToParse: 'id' })(block.options.content)
)
try {
await func(...variables.map((v) => parseCorrectValueType(v.value)))
} catch (err) {
console.error(err)
}
}
return block.outgoingEdgeId
}

View File

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

View File

@@ -0,0 +1,35 @@
import { SetVariableBlock, Variable } from '@typebot.io/schemas'
import { byId } from '@typebot.io/lib'
import { EdgeId, LogicState } from '@/types'
import { parseVariables, parseCorrectValueType } from '@/features/variables'
export const executeSetVariable = (
block: SetVariableBlock,
{ typebot: { variables }, updateVariableValue, updateVariables }: LogicState
): EdgeId | undefined => {
if (!block.options?.variableId) return block.outgoingEdgeId
const evaluatedExpression = block.options.expressionToEvaluate
? evaluateSetVariableExpression(variables)(
block.options.expressionToEvaluate
)
: undefined
const existingVariable = variables.find(byId(block.options.variableId))
if (!existingVariable) return block.outgoingEdgeId
updateVariableValue(existingVariable.id, evaluatedExpression)
updateVariables([{ ...existingVariable, value: evaluatedExpression }])
return block.outgoingEdgeId
}
const evaluateSetVariableExpression =
(variables: Variable[]) =>
(str: string): unknown => {
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
str.includes('return ') ? str : `return ${str}`
)
try {
const func = Function(...variables.map((v) => v.id), evaluating)
return func(...variables.map((v) => parseCorrectValueType(v.value)))
} catch (err) {
return parseVariables(variables)(str)
}
}

View File

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

View File

@@ -0,0 +1,19 @@
import { LinkedTypebot } from '@/providers/TypebotProvider'
import { LogicState } from '@/types'
import { TypebotLinkBlock, Typebot, PublicTypebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const fetchAndInjectTypebot = async (
block: TypebotLinkBlock,
{ apiHost, injectLinkedTypebot, isPreview }: LogicState
): Promise<LinkedTypebot | undefined> => {
const { data, error } = isPreview
? await sendRequest<{ typebot: Typebot }>(
`/api/typebots/${block.options.typebotId}`
)
: await sendRequest<{ typebot: PublicTypebot }>(
`${apiHost}/api/publicTypebots/${block.options.typebotId}`
)
if (!data || error) return
return injectLinkedTypebot(data.typebot)
}

View File

@@ -0,0 +1,69 @@
import { LinkedTypebot } from '@/providers/TypebotProvider'
import { EdgeId, LogicState } from '@/types'
import { TypebotLinkBlock, Edge, PublicTypebot } from '@typebot.io/schemas'
import { fetchAndInjectTypebot } from '../queries/fetchAndInjectTypebotQuery'
export const executeTypebotLink = async (
block: TypebotLinkBlock,
context: LogicState
): Promise<{
nextEdgeId?: EdgeId
linkedTypebot?: PublicTypebot | LinkedTypebot
}> => {
const {
typebot,
linkedTypebots,
onNewLog,
createEdge,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
pushParentTypebotId,
currentTypebotId,
} = context
const linkedTypebot = (
block.options.typebotId === 'current'
? typebot
: [typebot, ...linkedTypebots].find((typebot) =>
'typebotId' in typebot
? typebot.typebotId === block.options.typebotId
: typebot.id === block.options.typebotId
) ?? (await fetchAndInjectTypebot(block, context))
) as PublicTypebot | LinkedTypebot | undefined
if (!linkedTypebot) {
onNewLog({
status: 'error',
description: 'Failed to link typebot',
details: '',
})
return { nextEdgeId: block.outgoingEdgeId }
}
if (block.outgoingEdgeId)
pushEdgeIdInLinkedTypebotQueue({
edgeId: block.outgoingEdgeId,
typebotId: currentTypebotId,
})
pushParentTypebotId(currentTypebotId)
setCurrentTypebotId(
'typebotId' in linkedTypebot ? linkedTypebot.typebotId : linkedTypebot.id
)
const nextGroupId =
block.options.groupId ??
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
?.id
if (!nextGroupId) return { nextEdgeId: block.outgoingEdgeId }
const newEdge: Edge = {
id: (Math.random() * 1000).toString(),
from: { blockId: '', groupId: '' },
to: {
groupId: nextGroupId,
},
}
createEdge(newEdge)
return {
nextEdgeId: newEdge.id,
linkedTypebot: {
...linkedTypebot,
edges: [...linkedTypebot.edges, newEdge],
},
}
}

View File

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

View File

@@ -0,0 +1,19 @@
import { parseVariables } from '@/features/variables'
import { LogicState } from '@/types'
import { WaitBlock } from '@typebot.io/schemas'
export const executeWait = async (
block: WaitBlock,
{ typebot: { variables } }: LogicState
) => {
if (!block.options.secondsToWaitFor) return block.outgoingEdgeId
const parsedSecondsToWaitFor = parseVariables(variables)(
block.options.secondsToWaitFor
)
// @ts-expect-error isNaN can be used with strings
if (isNaN(parsedSecondsToWaitFor)) return block.outgoingEdgeId
await new Promise((resolve) =>
setTimeout(resolve, parseInt(parsedSecondsToWaitFor) * 1000)
)
return block.outgoingEdgeId
}