2
0

♻️ (bot) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 14:59:34 +01:00
committed by Baptiste Arnaud
parent a5c8a8a95c
commit 972094425a
92 changed files with 1245 additions and 943 deletions

View File

@ -30,5 +30,19 @@ module.exports = {
'prettier/prettier': 'error',
'react/display-name': [0],
'@next/next/no-img-element': [0],
'no-restricted-imports': [
'error',
{
patterns: [
'*/src/*',
'src/*',
'*/src',
'@/features/*/*',
'@/index',
'!@/features/blocks/*',
'!@/features/*/api',
],
},
],
},
}

View File

@ -1,23 +1,22 @@
import React, { useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
import { useAnswers } from '../../../providers/AnswersProvider'
import { InputBlock, InputBlockType } from 'models'
import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm'
import { byId } from 'utils'
import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm'
import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from '../../../services/variable'
import { isInputValid } from 'services/inputs'
import { PaymentForm } from './inputs/PaymentForm'
import { RatingForm } from './inputs/RatingForm'
import { FileUploadForm } from './inputs/FileUploadForm'
export type InputSubmitContent = {
label?: string
value: string
itemId?: string
}
import { InputSubmitContent } from '@/types'
import { useTypebot } from '@/providers/TypebotProvider'
import { isInputValid } from '@/utils/inputs'
import { parseVariables } from '@/features/variables'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
import { UrlInput } from '@/features/blocks/inputs/url'
import { PhoneInput } from '@/features/blocks/inputs/phone'
import { DateForm } from '@/features/blocks/inputs/date'
import { ChoiceForm } from '@/features/blocks/inputs/buttons'
import { PaymentForm } from '@/features/blocks/inputs/payment'
import { RatingForm } from '@/features/blocks/inputs/rating'
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
export const InputChatBlock = ({
block,
@ -111,12 +110,44 @@ const Input = ({
}) => {
switch (block.type) {
case InputBlockType.TEXT:
return (
<TextInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.NUMBER:
return (
<NumberInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.EMAIL:
return (
<EmailInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.URL:
return (
<UrlInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.PHONE:
return (
<TextForm
<PhoneInput
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}

View File

@ -1,4 +1,4 @@
import { Avatar } from 'components/avatars/Avatar'
import { Avatar } from '@/components/avatars/Avatar'
import React, { useState } from 'react'
import { CSSTransition } from 'react-transition-group'

View File

@ -1,9 +1,9 @@
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import { BubbleBlock, BubbleBlockType } from 'models'
import React from 'react'
import { EmbedBubble } from './EmbedBubble'
import { ImageBubble } from './ImageBubble'
import { TextBubble } from './TextBubble'
import { VideoBubble } from './VideoBubble'
type Props = {
block: BubbleBlock

View File

@ -1,159 +0,0 @@
import {
TextInputBlock,
EmailInputBlock,
NumberInputBlock,
InputBlockType,
UrlInputBlock,
PhoneNumberInputBlock,
} from 'models'
import React, { ChangeEvent, ChangeEventHandler } from 'react'
import PhoneInput, { Value, Country } from 'react-phone-number-input'
import { isMobile } from 'services/utils'
type TextInputProps = {
inputRef: React.RefObject<any>
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
value: string
onChange: (value: string) => void
}
export const TextInput = ({
inputRef,
block,
value,
onChange,
}: TextInputProps) => {
const handleInputChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => onChange(e.target.value)
const handlePhoneNumberChange = (value?: Value | undefined) => {
onChange(value as string)
}
switch (block.type) {
case InputBlockType.TEXT: {
return block.options?.isLong ? (
<LongTextInput
ref={inputRef}
value={value}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleInputChange}
/>
) : (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleInputChange}
// Hack to disable Chrome autocomplete
name="no-name"
/>
)
}
case InputBlockType.EMAIL: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={
block.options?.labels?.placeholder ?? 'Type your email...'
}
onChange={handleInputChange}
type="email"
autoComplete="email"
/>
)
}
case InputBlockType.NUMBER: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={
block.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={handleInputChange}
type="number"
style={{ appearance: 'auto' }}
min={block.options?.min}
max={block.options?.max}
step={block.options?.step ?? 'any'}
/>
)
}
case InputBlockType.URL: {
return (
<ShortTextInput
ref={inputRef}
value={value}
placeholder={block.options?.labels?.placeholder ?? 'Type your URL...'}
onChange={handleInputChange}
type="url"
autoComplete="url"
/>
)
}
case InputBlockType.PHONE: {
return (
<PhoneInput
ref={inputRef}
value={value}
onChange={handlePhoneNumberChange}
placeholder={
block.options.labels.placeholder ?? 'Your phone number...'
}
defaultCountry={block.options.defaultCountryCode as Country}
autoFocus={!isMobile}
/>
)
}
}
}
const ShortTextInput = React.forwardRef(
(
props: React.InputHTMLAttributes<HTMLInputElement>,
ref: React.ForwardedRef<HTMLInputElement>
) => (
<input
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ fontSize: '16px' }}
autoFocus={!isMobile}
{...props}
/>
)
)
const LongTextInput = React.forwardRef(
(
props: {
placeholder: string
value: string
onChange: ChangeEventHandler
},
ref: React.ForwardedRef<HTMLTextAreaElement>
) => (
<textarea
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
rows={6}
data-testid="textarea"
required
style={{ fontSize: '16px' }}
autoFocus={!isMobile}
{...props}
/>
)
)

View File

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

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { AvatarSideContainer } from './AvatarSideContainer'
import { LinkedTypebot, useTypebot } from '../../contexts/TypebotContext'
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
import {
isBubbleBlock,
isBubbleBlockType,
@ -12,11 +12,6 @@ import {
isLogicBlock,
byId,
} from 'utils'
import { executeLogic } from 'services/logic'
import { executeIntegration } from 'services/integration'
import { parseRetryBlock, blockCanBeRetried } from 'services/inputs'
import { parseVariables } from '../../services/variable'
import { useAnswers } from 'contexts/AnswersContext'
import {
BubbleBlock,
InputBlock,
@ -24,10 +19,16 @@ import {
PublicTypebot,
Block,
} from 'models'
import { useChat } from 'contexts/ChatContext'
import { getLastChatBlockType } from 'services/chat'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { InputChatBlock, InputSubmitContent } from './ChatBlock/InputChatBlock'
import { InputChatBlock } from './ChatBlock/InputChatBlock'
import { parseVariables } from '@/features/variables'
import { useAnswers } from '@/providers/AnswersProvider'
import { useChat } from '@/providers/ChatProvider'
import { InputSubmitContent } from '@/types'
import { getLastChatBlockType } from '@/utils/chat'
import { executeIntegration } from '@/utils/executeIntegration'
import { executeLogic } from '@/utils/executeLogic'
import { blockCanBeRetried, parseRetryBlock } from '@/utils/inputs'
type ChatGroupProps = {
blocks: Block[]

View File

@ -1,14 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import { ChatGroup } from './ChatGroup'
import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext'
import { useAnswers } from '../providers/AnswersProvider'
import { Group, Edge, PublicTypebot, Theme, VariableWithValue } from 'models'
import { byId, isDefined, isInputBlock, isNotDefined } from 'utils'
import { animateScroll as scroll } from 'react-scroll'
import { LinkedTypebot, useTypebot } from 'contexts/TypebotContext'
import { ChatContext } from 'contexts/ChatContext'
import { LinkedTypebot, useTypebot } from '@/providers/TypebotProvider'
import { setCssVariablesValue } from '@/features/theme'
import { ChatProvider } from '@/providers/ChatProvider'
type Props = {
theme: Theme
@ -139,7 +138,7 @@ export const ConversationContainer = ({
ref={scrollableContainer}
className="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view"
>
<ChatContext onScroll={autoScrollToBottom}>
<ChatProvider onScroll={autoScrollToBottom}>
{displayedGroups.map((displayedGroup, idx) => {
const groupAfter = displayedGroups[idx + 1]
const groupAfterStartsWithInput =
@ -158,7 +157,7 @@ export const ConversationContainer = ({
/>
)
})}
</ChatContext>
</ChatProvider>
{/* We use a block to simulate padding because it makes iOS scroll flicker */}
<div className="w-full h-32" ref={bottomAnchor} />

View File

@ -1,5 +1,5 @@
import React, { SVGProps } from 'react'
import { SendIcon } from '../../../../assets/icons'
import { SendIcon } from './icons'
type SendButtonProps = {
label: string

View File

@ -1,11 +1,11 @@
import React, { CSSProperties, useMemo } from 'react'
import { TypebotContext } from '../contexts/TypebotContext'
import { TypebotProvider } from '../providers/TypebotProvider'
import Frame from 'react-frame-component'
import styles from '../assets/style.css'
import importantStyles from '../assets/importantStyles.css'
import phoneSyle from '../assets/phone.css'
import { ConversationContainer } from './ConversationContainer'
import { AnswersContext } from '../contexts/AnswersContext'
import { AnswersProvider } from '../providers/AnswersProvider'
import {
Answer,
BackgroundType,
@ -89,14 +89,14 @@ export const TypebotViewer = ({
}:wght@300;400;600&display=swap');`,
}}
/>
<TypebotContext
<TypebotProvider
typebot={typebot}
apiHost={apiHost}
isPreview={isPreview}
onNewLog={handleNewLog}
isLoading={isLoading}
>
<AnswersContext
<AnswersProvider
resultId={resultId}
onNewAnswer={handleNewAnswer}
onVariablesUpdated={onVariablesUpdated}
@ -120,8 +120,8 @@ export const TypebotViewer = ({
</div>
{typebot.settings.general.isBrandingEnabled && <LiteBadge />}
</div>
</AnswersContext>
</TypebotContext>
</AnswersProvider>
</TypebotProvider>
</Frame>
)
}

View File

@ -1,6 +1,6 @@
import React from 'react'
export const TypingContent = (): JSX.Element => (
export const TypingBubble = (): JSX.Element => (
<div className="flex items-center">
<div className="w-2 h-2 mr-1 rounded-full bubble1" />
<div className="w-2 h-2 mr-1 rounded-full bubble2" />

View File

@ -0,0 +1,25 @@
import { isMobile } from '@/utils/helpers'
import React from 'react'
type ShortTextInputProps = {
onChange: (value: string) => void
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>
export const ShortTextInput = React.forwardRef(
(
{ onChange, ...props }: ShortTextInputProps,
ref: React.ForwardedRef<HTMLInputElement>
) => {
return (
<input
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ fontSize: '16px' }}
autoFocus={!isMobile}
onChange={(e) => onChange(e.target.value)}
{...props}
/>
)
}
)

View File

@ -0,0 +1,25 @@
import { isMobile } from '@/utils/helpers'
import React from 'react'
type TextareaProps = {
onChange: (value: string) => void
} & Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'>
export const Textarea = React.forwardRef(
(
{ onChange, ...props }: TextareaProps,
ref: React.ForwardedRef<HTMLTextAreaElement>
) => (
<textarea
ref={ref}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
rows={6}
data-testid="textarea"
required
style={{ fontSize: '16px' }}
autoFocus={!isMobile}
onChange={(e) => onChange(e.target.value)}
{...props}
/>
)
)

View File

@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { EmbedBubbleBlock } from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
import { useTypebot } from 'contexts/TypebotContext'
import { TypingBubble } from '../../../../../components/TypingBubble'
import { parseVariables } from '@/features/variables'
import { useTypebot } from '@/providers/TypebotProvider'
type Props = {
block: EmbedBubbleBlock
@ -56,7 +56,7 @@ export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
{isTyping ? <TypingBubble /> : <></>}
</div>
<iframe
id="embed-bubble-content"

View File

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

View File

@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { useTypebot } from '@/providers/TypebotProvider'
import { ImageBubbleBlock } from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
import { TypingBubble } from '@/components/TypingBubble'
import { parseVariables } from '@/features/variables'
type Props = {
block: ImageBubbleBlock
@ -61,7 +61,7 @@ export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
{isTyping ? <TypingBubble /> : null}
</div>
<img
ref={image}

View File

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

View File

@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { useTypebot } from '@/providers/TypebotProvider'
import { BubbleBlockType, TextBubbleBlock } from 'models'
import { computeTypingTimeout } from 'services/chat'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
import { computeTypingDuration } from '../utils/computeTypingDuration'
import { parseVariables } from '@/features/variables'
import { TypingBubble } from '@/components/TypingBubble'
type Props = {
block: TextBubbleBlock
@ -29,7 +29,7 @@ export const TextBubble = ({ block, onTransitionEnd }: Props) => {
useEffect(() => {
if (!isTyping || isLoading) return
const typingTimeout = computeTypingTimeout(
const typingTimeout = computeTypingDuration(
block.content.plainText,
typebot.settings?.typingEmulation ?? defaultTypingEmulation
)
@ -58,7 +58,7 @@ export const TextBubble = ({ block, onTransitionEnd }: Props) => {
}}
data-testid="host-bubble"
>
{isTyping ? <TypingContent /> : <></>}
{isTyping ? <TypingBubble /> : null}
</div>
{block.type === BubbleBlockType.TEXT && (
<p

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

@ -1,13 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { useTypebot } from '@/providers/TypebotProvider'
import {
Variable,
VideoBubbleContent,
VideoBubbleContentType,
VideoBubbleBlock,
} from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
import { TypingBubble } from '@/components/TypingBubble'
import { parseVariables } from '@/features/variables'
type Props = {
block: VideoBubbleBlock
@ -54,7 +54,7 @@ export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
{isTyping ? <TypingBubble /> : <></>}
</div>
<VideoContent
content={block.content}

View File

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

View File

@ -1,10 +1,10 @@
import { useAnswers } from 'contexts/AnswersContext'
import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from '@/features/variables'
import { useAnswers } from '@/providers/AnswersProvider'
import { useTypebot } from '@/providers/TypebotProvider'
import { InputSubmitContent } from '@/types'
import { ChoiceInputBlock } from 'models'
import React, { useState } from 'react'
import { parseVariables } from 'services/variable'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
import { SendButton } from '../../../../../components/SendButton'
type ChoiceFormProps = {
block: ChoiceInputBlock

View File

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

View File

@ -1,8 +1,8 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { DateInputOptions } from 'models'
import React, { useState } from 'react'
import { parseReadableDate } from 'services/inputs'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
import { parseReadableDate } from '../utils/parseReadableDate'
type DateInputProps = {
onSubmit: (inputValue: InputSubmitContent) => void

View File

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

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/ShortTextInput'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { EmailInputBlock } from 'models'
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

@ -1,10 +1,10 @@
import { useAnswers } from 'contexts/AnswersContext'
import { useTypebot } from 'contexts/TypebotContext'
import { Spinner, SendButton } from '@/components/SendButton'
import { useAnswers } from '@/providers/AnswersProvider'
import { useTypebot } from '@/providers/TypebotProvider'
import { InputSubmitContent } from '@/types'
import { FileInputBlock } from 'models'
import React, { ChangeEvent, FormEvent, useState, DragEvent } from 'react'
import { uploadFiles } from 'utils'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton, Spinner } from './SendButton'
type Props = {
block: FileInputBlock

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 'models'
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

@ -1,14 +1,14 @@
import React, { FormEvent, useEffect, useState } from 'react'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
import { Elements } from '@stripe/react-stripe-js'
import { createPaymentIntent } from 'services/stripe'
import { useTypebot } from 'contexts/TypebotContext'
import { PaymentInputOptions, Variable } from 'models'
import { SendButton, Spinner } from '../SendButton'
import { SendButton, Spinner } from '@/components/SendButton'
import { useFrame } from 'react-frame-component'
import { initStripe } from '../../../../../../lib/stripe'
import { parseVariables } from 'services/variable'
import { useChat } from 'contexts/ChatContext'
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'
type Props = {
options: PaymentInputOptions
@ -30,7 +30,7 @@ export const StripePaymentForm = ({ options, onSuccess }: Props) => {
useEffect(() => {
;(async () => {
const { data, error } = await createPaymentIntent({
const { data, error } = await createPaymentIntentQuery({
apiHost,
isPreview,
variables,

View File

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

View File

@ -1,7 +1,7 @@
import { PaymentInputOptions, Variable } from 'models'
import { sendRequest } from 'utils'
export const createPaymentIntent = ({
export const createPaymentIntentQuery = ({
apiHost,
isPreview,
inputOptions,

View File

@ -0,0 +1,68 @@
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/helpers'
import { PhoneNumberInputBlock } from 'models'
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

@ -1,8 +1,8 @@
import { InputSubmitContent } from '@/types'
import { RatingInputOptions, RatingInputBlock } from 'models'
import React, { FormEvent, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from 'utils'
import { InputSubmitContent } from '../InputChatBlock'
import { SendButton } from './SendButton'
import { SendButton } from '../../../../../components/SendButton'
type Props = {
block: RatingInputBlock

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 'models'
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

@ -1,45 +1,30 @@
import {
EmailInputBlock,
InputBlockType,
NumberInputBlock,
PhoneNumberInputBlock,
TextInputBlock,
UrlInputBlock,
} from 'models'
import React, { useRef, useState } from 'react'
import { InputSubmitContent } from '../../InputChatBlock'
import { SendButton } from '../SendButton'
import { TextInput } from './TextInput'
import { ShortTextInput } from '@/components/inputs/ShortTextInput'
import { SendButton } from '@/components/SendButton'
import { InputSubmitContent } from '@/types'
import { UrlInputBlock } from 'models'
import React, { MutableRefObject, useRef, useState } from 'react'
type TextFormProps = {
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
type UrlInputProps = {
block: UrlInputBlock
onSubmit: (value: InputSubmitContent) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const TextForm = ({
export const UrlInput = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: TextFormProps) => {
}: UrlInputProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const inputRef = useRef<HTMLInputElement | null>(null)
const isLongText = block.type === InputBlockType.TEXT && block.options?.isLong
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const handleChange = (inputValue: string) => {
if (block.type === InputBlockType.URL && !inputValue.startsWith('https://'))
if (!inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
@ -51,7 +36,6 @@ export const TextForm = ({
}
const submitWhenEnter = (e: React.KeyboardEvent) => {
if (block.type === InputBlockType.TEXT && block.options.isLong) return
if (e.key === 'Enter') submit()
}
@ -63,15 +47,17 @@ export const TextForm = ({
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: isLongText ? undefined : '350px',
maxWidth: '350px',
}}
onKeyDown={submitWhenEnter}
>
<TextInput
inputRef={inputRef}
block={block}
onChange={handleChange}
<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"

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

@ -1,8 +1,8 @@
import { parseVariables, parseCorrectValueType } from '@/features/variables'
import { IntegrationState } from '@/types'
import { sendEventToParent } from '@/utils/chat'
import { isEmbedded } from '@/utils/helpers'
import { ChatwootBlock, ChatwootOptions } from 'models'
import { sendEventToParent } from 'services/chat'
import { IntegrationContext } from 'services/integration'
import { isEmbedded } from 'services/utils'
import { parseCorrectValueType, parseVariables } from 'services/variable'
const parseSetUserCode = (user: ChatwootOptions['user']) => `
window.$chatwoot.setUser("${user?.id ?? ''}", {
@ -47,9 +47,9 @@ if (window.$chatwoot) {
})(document, "script");
}`
export const openChatwootWidget = async (
export const executeChatwootBlock = async (
block: ChatwootBlock,
{ variables, isPreview, onNewLog }: IntegrationContext
{ variables, isPreview, onNewLog }: IntegrationState
) => {
if (isPreview) {
onNewLog({

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 'models'
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,159 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import { parseLog } from '@/utils/helpers'
import {
GoogleSheetsBlock,
GoogleSheetsAction,
GoogleSheetsInsertRowOptions,
GoogleSheetsUpdateRowOptions,
GoogleSheetsGetOptions,
VariableWithValue,
Cell,
Variable,
} from 'models'
import { stringify } from 'qs'
import { sendRequest, byId } from 'utils'
export const executeGoogleSheetBlock = async (
block: GoogleSheetsBlock,
context: IntegrationState
) => {
if (!('action' in block.options)) return block.outgoingEdgeId
switch (block.options.action) {
case GoogleSheetsAction.INSERT_ROW:
await insertRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.UPDATE_ROW:
await updateRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.GET:
await getRowFromGoogleSheets(block.options, context)
break
}
return block.outgoingEdgeId
}
const insertRowInGoogleSheets = async (
options: GoogleSheetsInsertRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationState
) => {
if (!options.cellsToInsert) {
onNewLog({
status: 'warning',
description: 'Cells to insert are undefined',
details: null,
})
return
}
const params = stringify({ resultId })
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${params}`,
method: 'POST',
body: {
credentialsId: options.credentialsId,
values: parseCellValues(options.cellsToInsert, variables),
},
})
onNewLog(
parseLog(
error,
'Succesfully inserted a row in the sheet',
'Failed to insert a row in the sheet'
)
)
}
const updateRowInGoogleSheets = async (
options: GoogleSheetsUpdateRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationState
) => {
if (!options.cellsToUpsert || !options.referenceCell) return
const params = stringify({ resultId })
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${params}`,
method: 'PATCH',
body: {
credentialsId: options.credentialsId,
values: parseCellValues(options.cellsToUpsert, variables),
referenceCell: {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
},
},
})
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.referenceCell || !options.cellsToExtract) return
const queryParams = stringify(
{
credentialsId: options.credentialsId,
referenceCell: {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
},
columns: options.cellsToExtract.map((cell) => cell.column),
resultId,
},
{ indices: false }
)
const { data, error } = await sendRequest<{ [key: string]: string }>({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${queryParams}`,
method: 'GET',
})
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 value = data[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,51 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import { parseLog } from '@/utils/helpers'
import { SendEmailBlock } from 'models'
import { sendRequest, byId } from 'utils'
export const executeSendEmailBlock = async (
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
const { error } = await 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,
},
})
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,69 @@
import { parseVariables } from '@/features/variables'
import { IntegrationState } from '@/types'
import {
WebhookBlock,
ZapierBlock,
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
} from 'models'
import { stringify } from 'qs'
import { sendRequest, byId } from 'utils'
export const executeWebhook = async (
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock,
{
blockId,
variables,
updateVariableValue,
updateVariables,
typebotId,
apiHost,
resultValues,
onNewLog,
resultId,
}: IntegrationState
) => {
const params = stringify({ resultId })
const { data, error } = await sendRequest({
url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook?${params}`,
method: 'POST',
body: {
variables,
resultValues,
},
})
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 { executeCode } from './utils/executeCode'

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 { CodeBlock } from 'models'
export const executeCode = async (
block: CodeBlock,
{ 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 { executeCondition } from './utils/executeCondition'

View File

@ -0,0 +1,54 @@
import { parseVariables } from '@/features/variables'
import { EdgeId, LogicState } from '@/types'
import {
Comparison,
ComparisonOperators,
ConditionBlock,
LogicalOperator,
Variable,
} from 'models'
import { isNotDefined, isDefined } from 'utils'
export const executeCondition = (
block: ConditionBlock,
{ typebot: { variables } }: LogicState
): EdgeId | undefined => {
const { content } = block.items[0]
const isConditionPassed =
content.logicalOperator === LogicalOperator.AND
? content.comparisons.every(executeComparison(variables))
: content.comparisons.some(executeComparison(variables))
return isConditionPassed
? block.items[0].outgoingEdgeId
: block.outgoingEdgeId
}
const executeComparison =
(variables: Variable[]) => (comparison: Comparison) => {
if (!comparison?.variableId) return false
const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
).trim()
const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value)) return false
switch (comparison.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 { executeRedirect } from './utils/executeRedirect'

View File

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

View File

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

View File

@ -0,0 +1,36 @@
import { SetVariableBlock, Variable } from 'models'
import { byId } from 'utils'
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) {
console.log(`Evaluating: ${evaluating}`, err)
return 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 'models'
import { sendRequest } from 'utils'
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,65 @@
import { LinkedTypebot } from '@/providers/TypebotProvider'
import { EdgeId, LogicState } from '@/types'
import { TypebotLinkBlock, Edge, PublicTypebot } from 'models'
import { byId } from 'utils'
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,
currentTypebotId,
} = context
const linkedTypebot = (
block.options.typebotId === 'current'
? typebot
: [typebot, ...linkedTypebots].find(byId(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,
})
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

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
export * from './components/TypebotViewer'
export { parseVariables } from './services/variable'
export { parseVariables } from '@/features/variables'
export * from 'util'

View File

@ -1,3 +1,4 @@
import { safeStringify } from '@/features/variables'
import {
Answer,
ResultValues,
@ -5,7 +6,6 @@ import {
VariableWithValue,
} from 'models'
import React, { createContext, ReactNode, useContext, useState } from 'react'
import { safeStringify } from 'services/variable'
const answersContext = createContext<{
resultId?: string
@ -18,7 +18,7 @@ const answersContext = createContext<{
//@ts-ignore
}>({})
export const AnswersContext = ({
export const AnswersProvider = ({
children,
resultId,
onNewAnswer,

View File

@ -6,7 +6,7 @@ const chatContext = createContext<{
//@ts-ignore
}>({})
export const ChatContext = ({
export const ChatProvider = ({
children,
onScroll,
}: {

View File

@ -1,4 +1,6 @@
import { TypebotViewerProps } from 'components/TypebotViewer'
import { TypebotViewerProps } from '@/components/TypebotViewer'
import { safeStringify } from '@/features/variables'
import { sendEventToParent } from '@/utils/chat'
import { Log } from 'db'
import { Edge, PublicTypebot, Typebot } from 'models'
import React, {
@ -8,8 +10,6 @@ import React, {
useEffect,
useState,
} from 'react'
import { sendEventToParent } from 'services/chat'
import { safeStringify } from 'services/variable'
export type LinkedTypebot = Pick<
PublicTypebot | Typebot,
@ -43,7 +43,7 @@ const typebotContext = createContext<{
//@ts-ignore
}>({})
export const TypebotContext = ({
export const TypebotProvider = ({
children,
typebot,
apiHost,

View File

@ -1,334 +0,0 @@
import { Log } from 'db'
import { openChatwootWidget } from 'features/chatwoot'
import {
IntegrationBlock,
IntegrationBlockType,
GoogleSheetsBlock,
GoogleSheetsAction,
GoogleSheetsInsertRowOptions,
Variable,
GoogleSheetsUpdateRowOptions,
Cell,
GoogleSheetsGetOptions,
GoogleAnalyticsBlock,
WebhookBlock,
SendEmailBlock,
ZapierBlock,
ResultValues,
Group,
VariableWithValue,
MakeComBlock,
PabblyConnectBlock,
VariableWithUnknowValue,
} from 'models'
import { stringify } from 'qs'
import { byId, sendRequest } from 'utils'
import { sendGaEvent } from '../../lib/gtag'
import { parseVariables, parseVariablesInObject } from './variable'
export type IntegrationContext = {
apiHost: string
typebotId: string
groupId: string
blockId: string
isPreview: boolean
variables: Variable[]
resultValues: ResultValues
groups: Group[]
resultId?: string
updateVariables: (variables: VariableWithUnknowValue[]) => void
updateVariableValue: (variableId: string, value: unknown) => void
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
}
export const executeIntegration = ({
block,
context,
}: {
block: IntegrationBlock
context: IntegrationContext
}): Promise<string | undefined> => {
switch (block.type) {
case IntegrationBlockType.GOOGLE_SHEETS:
return executeGoogleSheetIntegration(block, context)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsIntegration(block, context)
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.WEBHOOK:
return executeWebhook(block, context)
case IntegrationBlockType.EMAIL:
return sendEmail(block, context)
case IntegrationBlockType.CHATWOOT:
return openChatwootWidget(block, context)
}
}
export const executeGoogleAnalyticsIntegration = async (
block: GoogleAnalyticsBlock,
{ variables }: IntegrationContext
) => {
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
}
const executeGoogleSheetIntegration = async (
block: GoogleSheetsBlock,
context: IntegrationContext
) => {
if (!('action' in block.options)) return block.outgoingEdgeId
switch (block.options.action) {
case GoogleSheetsAction.INSERT_ROW:
await insertRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.UPDATE_ROW:
await updateRowInGoogleSheets(block.options, context)
break
case GoogleSheetsAction.GET:
await getRowFromGoogleSheets(block.options, context)
break
}
return block.outgoingEdgeId
}
const insertRowInGoogleSheets = async (
options: GoogleSheetsInsertRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationContext
) => {
if (!options.cellsToInsert) {
onNewLog({
status: 'warning',
description: 'Cells to insert are undefined',
details: null,
})
return
}
const params = stringify({ resultId })
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${params}`,
method: 'POST',
body: {
credentialsId: options.credentialsId,
values: parseCellValues(options.cellsToInsert, variables),
},
})
onNewLog(
parseLog(
error,
'Succesfully inserted a row in the sheet',
'Failed to insert a row in the sheet'
)
)
}
const updateRowInGoogleSheets = async (
options: GoogleSheetsUpdateRowOptions,
{ variables, apiHost, onNewLog, resultId }: IntegrationContext
) => {
if (!options.cellsToUpsert || !options.referenceCell) return
const params = stringify({ resultId })
const { error } = await sendRequest({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${params}`,
method: 'PATCH',
body: {
credentialsId: options.credentialsId,
values: parseCellValues(options.cellsToUpsert, variables),
referenceCell: {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
},
},
})
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,
}: IntegrationContext
) => {
if (!options.referenceCell || !options.cellsToExtract) return
const queryParams = stringify(
{
credentialsId: options.credentialsId,
referenceCell: {
column: options.referenceCell.column,
value: parseVariables(variables)(options.referenceCell.value ?? ''),
},
columns: options.cellsToExtract.map((cell) => cell.column),
resultId,
},
{ indices: false }
)
const { data, error } = await sendRequest<{ [key: string]: string }>({
url: `${apiHost}/api/integrations/google-sheets/spreadsheets/${options.spreadsheetId}/sheets/${options.sheetId}?${queryParams}`,
method: 'GET',
})
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 value = data[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),
}
}, {})
const executeWebhook = async (
block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock,
{
blockId,
variables,
updateVariableValue,
updateVariables,
typebotId,
apiHost,
resultValues,
onNewLog,
resultId,
}: IntegrationContext
) => {
const params = stringify({ resultId })
const { data, error } = await sendRequest({
url: `${apiHost}/api/typebots/${typebotId}/blocks/${blockId}/executeWebhook?${params}`,
method: 'POST',
body: {
variables,
resultValues,
},
})
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
}
const sendEmail = async (
block: SendEmailBlock,
{
variables,
apiHost,
isPreview,
onNewLog,
resultId,
typebotId,
resultValues,
}: IntegrationContext
) => {
if (isPreview) {
onNewLog({
status: 'info',
description: 'Emails are not sent in preview mode',
details: null,
})
return block.outgoingEdgeId
}
const { options } = block
const { error } = await 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,
},
})
onNewLog(
parseLog(error, 'Succesfully sent an email', 'Failed to send an email')
)
return block.outgoingEdgeId
}
const parseLog = (
error: Error | undefined,
successMessage: string,
errorMessage: string
): Omit<Log, 'id' | 'createdAt' | 'resultId'> => ({
status: error ? 'error' : 'success',
description: error ? errorMessage : successMessage,
details: (error && JSON.stringify(error, null, 2).substring(0, 1000)) ?? null,
})

View File

@ -1,262 +0,0 @@
import { TypebotViewerProps } from 'components/TypebotViewer'
import { LinkedTypebot } from 'contexts/TypebotContext'
import { Log } from 'db'
import {
LogicBlock,
LogicBlockType,
LogicalOperator,
ConditionBlock,
Variable,
ComparisonOperators,
SetVariableBlock,
RedirectBlock,
Comparison,
CodeBlock,
TypebotLinkBlock,
PublicTypebot,
Typebot,
Edge,
VariableWithUnknowValue,
} from 'models'
import { byId, isDefined, isNotDefined, sendRequest } from 'utils'
import { sendEventToParent } from './chat'
import { isEmbedded, sanitizeUrl } from './utils'
import { parseCorrectValueType, parseVariables } from './variable'
type EdgeId = string
type LogicContext = {
isPreview: boolean
apiHost: string
typebot: TypebotViewerProps['typebot']
linkedTypebots: LinkedTypebot[]
currentTypebotId: string
pushEdgeIdInLinkedTypebotQueue: (bot: {
edgeId: string
typebotId: string
}) => void
setCurrentTypebotId: (id: string) => void
updateVariableValue: (variableId: string, value: unknown) => void
updateVariables: (variables: VariableWithUnknowValue[]) => void
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
createEdge: (edge: Edge) => void
}
export const executeLogic = async (
block: LogicBlock,
context: LogicContext
): Promise<{
nextEdgeId?: EdgeId
linkedTypebot?: TypebotViewerProps['typebot'] | LinkedTypebot
}> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return { nextEdgeId: executeSetVariable(block, context) }
case LogicBlockType.CONDITION:
return { nextEdgeId: executeCondition(block, context) }
case LogicBlockType.REDIRECT:
return { nextEdgeId: executeRedirect(block, context) }
case LogicBlockType.CODE:
return { nextEdgeId: await executeCode(block, context) }
case LogicBlockType.TYPEBOT_LINK:
return await executeTypebotLink(block, context)
}
}
const executeSetVariable = (
block: SetVariableBlock,
{ typebot: { variables }, updateVariableValue, updateVariables }: LogicContext
): 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) {
console.log(`Evaluating: ${evaluating}`, err)
return str
}
}
const executeCondition = (
block: ConditionBlock,
{ typebot: { variables } }: LogicContext
): EdgeId | undefined => {
const { content } = block.items[0]
const isConditionPassed =
content.logicalOperator === LogicalOperator.AND
? content.comparisons.every(executeComparison(variables))
: content.comparisons.some(executeComparison(variables))
return isConditionPassed
? block.items[0].outgoingEdgeId
: block.outgoingEdgeId
}
const executeComparison =
(variables: Variable[]) => (comparison: Comparison) => {
if (!comparison?.variableId) return false
const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
).trim()
const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value)) return false
switch (comparison.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
}
}
}
const executeRedirect = (
block: RedirectBlock,
{ typebot: { variables } }: LogicContext
): EdgeId | undefined => {
if (!block.options?.url) return block.outgoingEdgeId
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
const isEmbedded = window.parent && window.location !== window.top?.location
if (isEmbedded) {
if (!block.options.isNewTab)
return ((window.top as Window).location.href = formattedUrl)
try {
window.open(formattedUrl)
} catch (err) {
sendEventToParent({ redirectUrl: formattedUrl })
}
} else {
window.open(formattedUrl, block.options.isNewTab ? '_blank' : '_self')
}
return block.outgoingEdgeId
}
const executeCode = async (
block: CodeBlock,
{ typebot: { variables } }: LogicContext
) => {
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
}
const executeTypebotLink = async (
block: TypebotLinkBlock,
context: LogicContext
): Promise<{
nextEdgeId?: EdgeId
linkedTypebot?: PublicTypebot | LinkedTypebot
}> => {
const {
typebot,
linkedTypebots,
onNewLog,
createEdge,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
currentTypebotId,
} = context
const linkedTypebot = (
block.options.typebotId === 'current'
? typebot
: [typebot, ...linkedTypebots].find(byId(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,
})
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],
},
}
}
const fetchAndInjectTypebot = async (
block: TypebotLinkBlock,
{ apiHost, injectLinkedTypebot, isPreview }: LogicContext
): 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,53 @@
import { Log } from 'db'
import {
Edge,
Group,
PublicTypebot,
ResultValues,
Typebot,
Variable,
VariableWithUnknowValue,
} from 'models'
import { TypebotViewerProps } from './components/TypebotViewer'
import { LinkedTypebot } from './providers/TypebotProvider'
export type InputSubmitContent = {
label?: string
value: string
itemId?: string
}
export type EdgeId = string
export type LogicState = {
isPreview: boolean
apiHost: string
typebot: TypebotViewerProps['typebot']
linkedTypebots: LinkedTypebot[]
currentTypebotId: string
pushEdgeIdInLinkedTypebotQueue: (bot: {
edgeId: string
typebotId: string
}) => void
setCurrentTypebotId: (id: string) => void
updateVariableValue: (variableId: string, value: unknown) => void
updateVariables: (variables: VariableWithUnknowValue[]) => void
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
createEdge: (edge: Edge) => void
}
export type IntegrationState = {
apiHost: string
typebotId: string
groupId: string
blockId: string
isPreview: boolean
variables: Variable[]
resultValues: ResultValues
groups: Group[]
resultId?: string
updateVariables: (variables: VariableWithUnknowValue[]) => void
updateVariableValue: (variableId: string, value: unknown) => void
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
}

View File

@ -4,26 +4,10 @@ import {
InputBlock,
InputBlockType,
Block,
TypingEmulation,
} from 'models'
import { isBubbleBlock, isInputBlock } from 'utils'
import type { TypebotPostMessageData } from 'typebot-js'
export const computeTypingTimeout = (
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
}
export const getLastChatBlockType = (
blocks: Block[]
): BubbleBlockType | InputBlockType | undefined => {

View File

@ -0,0 +1,31 @@
import { executeChatwootBlock } from '@/features/blocks/integrations/chatwoot'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics'
import { executeGoogleSheetBlock } from '@/features/blocks/integrations/googleSheets'
import { executeSendEmailBlock } from '@/features/blocks/integrations/sendEmail'
import { executeWebhook } from '@/features/blocks/integrations/webhook'
import { IntegrationState } from '@/types'
import { IntegrationBlock, IntegrationBlockType } from 'models'
export const executeIntegration = ({
block,
context,
}: {
block: IntegrationBlock
context: IntegrationState
}): Promise<string | undefined> => {
switch (block.type) {
case IntegrationBlockType.GOOGLE_SHEETS:
return executeGoogleSheetBlock(block, context)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsBlock(block, context)
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.WEBHOOK:
return executeWebhook(block, context)
case IntegrationBlockType.EMAIL:
return executeSendEmailBlock(block, context)
case IntegrationBlockType.CHATWOOT:
return executeChatwootBlock(block, context)
}
}

View File

@ -0,0 +1,30 @@
import { TypebotViewerProps } from '@/components/TypebotViewer'
import { executeCode } from '@/features/blocks/logic/code'
import { executeCondition } from '@/features/blocks/logic/condition'
import { executeRedirect } from '@/features/blocks/logic/redirect'
import { executeSetVariable } from '@/features/blocks/logic/setVariable'
import { executeTypebotLink } from '@/features/blocks/logic/typebotLink'
import { LinkedTypebot } from '@/providers/TypebotProvider'
import { EdgeId, LogicState } from '@/types'
import { LogicBlock, LogicBlockType } from 'models'
export const executeLogic = async (
block: LogicBlock,
context: LogicState
): Promise<{
nextEdgeId?: EdgeId
linkedTypebot?: TypebotViewerProps['typebot'] | LinkedTypebot
}> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return { nextEdgeId: executeSetVariable(block, context) }
case LogicBlockType.CONDITION:
return { nextEdgeId: executeCondition(block, context) }
case LogicBlockType.REDIRECT:
return { nextEdgeId: executeRedirect(block, context) }
case LogicBlockType.CODE:
return { nextEdgeId: await executeCode(block, context) }
case LogicBlockType.TYPEBOT_LINK:
return await executeTypebotLink(block, context)
}
}

View File

@ -1,3 +1,5 @@
import { Log } from 'db'
export const sanitizeUrl = (url: string): string =>
url.startsWith('http') ||
url.startsWith('mailto:') ||
@ -14,3 +16,13 @@ export const isEmbedded =
typeof window !== 'undefined' &&
window.parent &&
window.location !== window.top?.location
export const parseLog = (
error: Error | undefined,
successMessage: string,
errorMessage: string
): Omit<Log, 'id' | 'createdAt' | 'resultId'> => ({
status: error ? 'error' : 'success',
description: error ? errorMessage : successMessage,
details: (error && JSON.stringify(error, null, 2).substring(0, 1000)) ?? null,
})

View File

@ -1,3 +1,7 @@
import { validateEmail } from '@/features/blocks/inputs/email'
import { validatePhoneNumber } from '@/features/blocks/inputs/phone'
import { validateUrl } from '@/features/blocks/inputs/url'
import { parseVariables } from '@/features/variables'
import {
BubbleBlock,
BubbleBlockType,
@ -9,14 +13,7 @@ import {
UrlInputBlock,
Variable,
} from 'models'
import { isPossiblePhoneNumber } from 'react-phone-number-input'
import { isInputBlock } from 'utils'
import { parseVariables } from './variable'
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,}))$/
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 isInputValid = (
inputValue: string,
@ -24,11 +21,11 @@ export const isInputValid = (
): boolean => {
switch (type) {
case InputBlockType.EMAIL:
return emailRegex.test(inputValue)
return validateEmail(inputValue)
case InputBlockType.PHONE:
return isPossiblePhoneNumber(inputValue)
return validatePhoneNumber(inputValue)
case InputBlockType.URL:
return urlRegex.test(inputValue)
return validateUrl(inputValue)
}
return true
}
@ -63,30 +60,3 @@ export const parseRetryBlock = (
outgoingEdgeId: newEdge.id,
}
}
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

@ -15,7 +15,10 @@
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true,
"baseUrl": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"downlevelIteration": true
}
}