2
0

refactor: ♻️ Rename step to block

This commit is contained in:
Baptiste Arnaud
2022-06-11 07:27:38 +02:00
parent 8751766d0e
commit 2df8338505
297 changed files with 4292 additions and 3989 deletions

View File

@@ -0,0 +1,70 @@
import React, {
ForwardedRef,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { Avatar } from '../avatars/Avatar'
import { useFrame } from 'react-frame-component'
import { CSSTransition } from 'react-transition-group'
import { ResizeObserver } from 'resize-observer'
type Props = { hostAvatarSrc?: string; keepShowing: boolean }
export const AvatarSideContainer = forwardRef(
({ hostAvatarSrc, keepShowing }: Props, ref: ForwardedRef<unknown>) => {
const { document } = useFrame()
const [show, setShow] = useState(false)
const [avatarTopOffset, setAvatarTopOffset] = useState(0)
const refreshTopOffset = () => {
if (!scrollingSideGroupRef.current || !avatarContainer.current) return
const { height } = scrollingSideGroupRef.current.getBoundingClientRect()
const { height: avatarHeight } =
avatarContainer.current.getBoundingClientRect()
setAvatarTopOffset(height - avatarHeight)
}
const scrollingSideGroupRef = useRef<HTMLDivElement>(null)
const avatarContainer = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => ({
refreshTopOffset,
}))
useEffect(() => {
setShow(true)
const resizeObserver = new ResizeObserver(refreshTopOffset)
resizeObserver.observe(document.body)
return () => {
resizeObserver.disconnect()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div
className="flex w-6 xs:w-10 mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container "
ref={scrollingSideGroupRef}
>
<CSSTransition
classNames="bubble"
timeout={500}
in={show && keepShowing}
unmountOnExit
>
<div
className="absolute w-6 xs:w-10 h-6 xs:h-10 mb-4 xs:mb-2 flex items-center top-0"
ref={avatarContainer}
style={{
top: `${avatarTopOffset}px`,
transition: 'top 350ms ease-out, opacity 500ms',
}}
>
<Avatar avatarSrc={hostAvatarSrc} />
</div>
</CSSTransition>
</div>
)
}
)

View File

@@ -0,0 +1,122 @@
import React, { useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
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'
export const InputChatBlock = ({
block,
hasAvatar,
hasGuestAvatar,
onTransitionEnd,
}: {
block: InputBlock
hasGuestAvatar: boolean
hasAvatar: boolean
onTransitionEnd: (answerContent?: string, isRetry?: boolean) => void
}) => {
const { typebot } = useTypebot()
const { addAnswer } = useAnswers()
const [answer, setAnswer] = useState<string>()
const [isEditting, setIsEditting] = useState(false)
const { variableId } = block.options
const defaultValue =
typebot.settings.general.isInputPrefillEnabled ?? true
? variableId && typebot.variables.find(byId(variableId))?.value
: undefined
const handleSubmit = async (content: string) => {
setAnswer(content)
const isRetry = !isInputValid(content, block.type)
if (!isRetry && addAnswer)
await addAnswer({
blockId: block.id,
groupId: block.groupId,
content,
variableId: variableId ?? null,
})
if (!isEditting) onTransitionEnd(content, isRetry)
setIsEditting(false)
}
const handleGuestBubbleClick = () => {
setAnswer(undefined)
setIsEditting(true)
}
if (answer) {
const avatarUrl = typebot.theme.chat.guestAvatar?.url
return (
<GuestBubble
message={answer}
showAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
avatarSrc={avatarUrl && parseVariables(typebot.variables)(avatarUrl)}
onClick={handleGuestBubbleClick}
/>
)
}
return (
<div className="flex">
{hasAvatar && (
<div className="flex w-6 xs:w-10 h-6 xs:h-10 mr-2 mb-2 mt-1 flex-shrink-0 items-center" />
)}
<Input
block={block}
onSubmit={handleSubmit}
defaultValue={defaultValue?.toString()}
hasGuestAvatar={hasGuestAvatar}
/>
</div>
)
}
const Input = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: {
block: InputBlock
onSubmit: (value: string) => void
defaultValue?: string
hasGuestAvatar: boolean
}) => {
switch (block.type) {
case InputBlockType.TEXT:
case InputBlockType.NUMBER:
case InputBlockType.EMAIL:
case InputBlockType.URL:
case InputBlockType.PHONE:
return (
<TextForm
block={block}
onSubmit={onSubmit}
defaultValue={defaultValue}
hasGuestAvatar={hasGuestAvatar}
/>
)
case InputBlockType.DATE:
return <DateForm options={block.options} onSubmit={onSubmit} />
case InputBlockType.CHOICE:
return <ChoiceForm block={block} onSubmit={onSubmit} />
case InputBlockType.PAYMENT:
return (
<PaymentForm
options={block.options}
onSuccess={() => onSubmit(block.options.labels.success ?? 'Success')}
/>
)
case InputBlockType.RATING:
return <RatingForm block={block} onSubmit={onSubmit} />
}
}

View File

@@ -0,0 +1,76 @@
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'
type Props = {
block: EmbedBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const EmbedBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const url = useMemo(
() => parseVariables(typebot.variables)(block.content?.url),
[block.content?.url, typebot.variables]
)
useEffect(() => {
showContentAfterMediaLoad()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const showContentAfterMediaLoad = () => {
setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
}
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}
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 ? <TypingContent /> : <></>}
</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' : block.content.height,
borderRadius: '15px',
}}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Avatar } from 'components/avatars/Avatar'
import React, { useState } from 'react'
import { CSSTransition } from 'react-transition-group'
interface Props {
message: string
showAvatar: boolean
avatarSrc?: string
onClick: () => void
}
export const GuestBubble = ({
message,
showAvatar,
avatarSrc,
onClick,
}: Props): JSX.Element => {
const [content] = useState(message)
return (
<CSSTransition classNames="bubble" timeout={1000}>
<div
className="flex justify-end mb-2 items-end"
onClick={onClick}
style={{ marginLeft: '50px' }}
>
<span
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer hover:brightness-90 active:brightness-75"
data-testid="guest-bubble"
>
{content}
</span>
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
</div>
</CSSTransition>
)
}

View File

@@ -0,0 +1,24 @@
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
onTransitionEnd: () => void
}
export const HostBubble = ({ block, onTransitionEnd }: Props) => {
switch (block.type) {
case BubbleBlockType.TEXT:
return <TextBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.IMAGE:
return <ImageBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.VIDEO:
return <VideoBubble block={block} onTransitionEnd={onTransitionEnd} />
case BubbleBlockType.EMBED:
return <EmbedBubble block={block} onTransitionEnd={onTransitionEnd} />
}
}

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { ImageBubbleBlock } from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
type Props = {
block: ImageBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const ImageBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const image = useRef<HTMLImageElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const url = useMemo(
() => parseVariables(typebot.variables)(block.content?.url),
[block.content?.url, typebot.variables]
)
useEffect(() => {
showContentAfterMediaLoad()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const showContentAfterMediaLoad = () => {
if (!image.current) return
const timeout = setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, mediaLoadingFallbackTimeout)
image.current.onload = () => {
clearTimeout(timeout)
setIsTyping(false)
onTypingEnd()
}
}
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
</div>
<img
ref={image}
src={url}
className={
'p-4 content-opacity z-10 w-auto ' +
(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,80 @@
import React, { useEffect, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { BubbleBlockType, TextBubbleBlock } from 'models'
import { computeTypingTimeout } from 'services/chat'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
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 } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
const [content] = useState(
parseVariables(typebot.variables)(block.content.html)
)
useEffect(() => {
const typingTimeout = computeTypingTimeout(
block.content.plainText,
typebot.settings?.typingEmulation ?? defaultTypingEmulation
)
setTimeout(() => {
onTypingEnd()
}, typingTimeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}
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 ? <TypingContent /> : <></>}
</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,9 @@
import React from 'react'
export const TypingContent = (): JSX.Element => (
<div className="flex items-center">
<div className="w-2 h-2 mr-1 rounded-full bubble1" />
<div className="w-2 h-2 mr-1 rounded-full bubble2" />
<div className="w-2 h-2 rounded-full bubble3" />
</div>
)

View File

@@ -0,0 +1,124 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import {
Variable,
VideoBubbleContent,
VideoBubbleContentType,
VideoBubbleBlock,
} from 'models'
import { TypingContent } from './TypingContent'
import { parseVariables } from 'services/variable'
type Props = {
block: VideoBubbleBlock
onTransitionEnd: () => void
}
export const showAnimationDuration = 400
export const mediaLoadingFallbackTimeout = 5000
export const VideoBubble = ({ block, onTransitionEnd }: Props) => {
const { typebot } = useTypebot()
const messageContainer = useRef<HTMLDivElement | null>(null)
const [isTyping, setIsTyping] = useState(true)
useEffect(() => {
showContentAfterMediaLoad()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const showContentAfterMediaLoad = () => {
setTimeout(() => {
setIsTyping(false)
onTypingEnd()
}, 1000)
}
const onTypingEnd = () => {
setIsTyping(false)
setTimeout(() => {
onTransitionEnd()
}, showAnimationDuration)
}
return (
<div className="flex flex-col" ref={messageContainer}>
<div className="flex mb-2 w-full lg:w-11/12 items-center">
<div className={'flex relative z-10 items-start typebot-host-bubble'}>
<div
className="flex items-center absolute px-4 py-2 rounded-lg bubble-typing z-10 "
style={{
width: isTyping ? '4rem' : '100%',
height: isTyping ? '2rem' : '100%',
}}
>
{isTyping ? <TypingContent /> : <></>}
</div>
<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 = useMemo(
() => parseVariables(variables)(content?.url),
// eslint-disable-next-line react-hooks/exhaustive-deps
[variables]
)
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 { InputChatBlock } from './InputChatBlock'

View File

@@ -0,0 +1,82 @@
import { useAnswers } from 'contexts/AnswersContext'
import { ChoiceInputBlock } from 'models'
import React, { useState } from 'react'
import { SendButton } from './SendButton'
type ChoiceFormProps = {
block: ChoiceInputBlock
onSubmit: (value: string) => void
}
export const ChoiceForm = ({ block, onSubmit }: ChoiceFormProps) => {
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(block.items[itemIndex].content ?? '')
}
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(
selectedIndices
.map((itemIndex) => block.items[itemIndex].content)
.join(', ')
)
const isUniqueFirstButton =
resultValues &&
resultValues.answers.length === 0 &&
block.items.length === 1
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-wrap">
{block.items.map((item, idx) => (
<span key={item.id} className="relative inline-flex mr-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}
>
{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,81 @@
import { DateInputOptions } from 'models'
import React, { useState } from 'react'
import { SendButton } from './SendButton'
type DateInputProps = {
onSubmit: (inputValue: `${string} to ${string}` | string) => 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(
`${inputValues.from}${isRange ? ` to ${inputValues.to}` : ''}`
)
}}
>
<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,15 @@
import { PaymentInputOptions, PaymentProvider } from 'models'
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,195 @@
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 { useFrame } from 'react-frame-component'
import { initStripe } from '../../../../../../lib/stripe'
import { parseVariables } from 'services/variable'
import { useChat } from 'contexts/ChatContext'
type Props = {
options: PaymentInputOptions
onSuccess: () => void
}
export const StripePaymentForm = ({ options, onSuccess }: Props) => {
const {
apiHost,
isPreview,
typebot: { variables },
onNewLog,
} = useTypebot()
const { window: frameWindow, document: frameDocument } = useFrame()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [stripe, setStripe] = useState<any>(null)
const [clientSecret, setClientSecret] = useState('')
const [amountLabel, setAmountLabel] = useState('')
useEffect(() => {
;(async () => {
const { data, error } = await createPaymentIntent({
apiHost,
isPreview,
variables,
inputOptions: options,
})
if (error)
return onNewLog({
status: 'error',
description: error.name + ' ' + error.message,
details: error.message,
})
if (!data) return
await initStripe(frameDocument)
setStripe(frameWindow.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 } = await stripe.confirmPayment({
elements,
confirmParams: {
// Make sure to change this to your payment completion page
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) return onSuccess()
if (error.type === 'validation_error') return
if (error?.type === 'card_error') return setMessage(error.message)
setMessage('An unexpected error occured.')
}
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,108 @@
import { RatingInputOptions, RatingInputBlock } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { isDefined, isEmpty, isNotDefined } from 'utils'
import { SendButton } from './SendButton'
type Props = {
block: RatingInputBlock
onSubmit: (value: string) => void
}
export const RatingForm = ({ block, onSubmit }: Props) => {
const [rating, setRating] = useState<number>()
const scaleElement = useRef<HTMLDivElement | null>(null)
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (isNotDefined(rating)) return
onSubmit(rating.toString())
}
const handleClick = (rating: number) => setRating(rating)
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-col" ref={scaleElement}>
<div className="flex">
{Array.from(Array(block.options.length)).map((_, idx) => (
<RatingButton
{...block.options}
key={idx}
rating={rating}
idx={idx + 1}
onClick={handleClick}
/>
))}
</div>
<div className="flex justify-between mr-2 mb-2">
{<span className="text-sm w-full ">{block.options.labels.left}</span>}
{!isEmpty(block.options.labels.middle) && (
<span className="text-sm w-full text-center">
{block.options.labels.middle}
</span>
)}
{
<span className="text-sm w-full text-right">
{block.options.labels.right}
</span>
}
</div>
</div>
<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,61 @@
import React, { SVGProps } from 'react'
import { SendIcon } from '../../../../assets/icons'
type SendButtonProps = {
label: string
isDisabled?: boolean
isLoading?: boolean
disableIcon?: boolean
} & React.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = ({
label,
isDisabled,
isLoading,
disableIcon,
...props
}: SendButtonProps) => {
return (
<button
type="submit"
disabled={isDisabled || isLoading}
{...props}
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 ' +
props.className
}
>
{isLoading && <Spinner className="text-white" />}
<span className={'xs:flex ' + (disableIcon ? '' : 'hidden')}>
{label}
</span>
<SendIcon
className={'send-icon flex ' + (disableIcon ? 'hidden' : 'xs:hidden')}
/>
</button>
)
}
export const Spinner = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
className={'animate-spin -ml-1 mr-3 h-5 w-5 ' + props.className}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)

View File

@@ -0,0 +1,68 @@
import {
EmailInputBlock,
InputBlockType,
NumberInputBlock,
PhoneNumberInputBlock,
TextInputBlock,
UrlInputBlock,
} from 'models'
import React, { FormEvent, useState } from 'react'
import { SendButton } from '../SendButton'
import { TextInput } from './TextInputContent'
type TextFormProps = {
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
onSubmit: (value: string) => void
defaultValue?: string
hasGuestAvatar: boolean
}
export const TextForm = ({
block,
onSubmit,
defaultValue,
hasGuestAvatar,
}: TextFormProps) => {
const [inputValue, setInputValue] = useState(defaultValue ?? '')
const isLongText = block.type === InputBlockType.TEXT && block.options?.isLong
const handleChange = (inputValue: string) => {
if (block.type === InputBlockType.URL && !inputValue.startsWith('https://'))
return inputValue === 'https:/'
? undefined
: setInputValue(`https://${inputValue}`)
setInputValue(inputValue)
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (inputValue === '') return
onSubmit(inputValue)
}
return (
<form
className="flex items-end justify-between rounded-lg pr-2 typebot-input w-full"
onSubmit={handleSubmit}
data-testid="input"
style={{
marginRight: hasGuestAvatar ? '50px' : '0.5rem',
maxWidth: isLongText ? undefined : '350px',
}}
>
<TextInput block={block} onChange={handleChange} value={inputValue} />
<SendButton
label={block.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
/>
</form>
)
}

View File

@@ -0,0 +1,164 @@
import {
TextInputBlock,
EmailInputBlock,
NumberInputBlock,
InputBlockType,
UrlInputBlock,
PhoneNumberInputBlock,
} from 'models'
import React, {
ChangeEvent,
ChangeEventHandler,
RefObject,
useEffect,
useRef,
} from 'react'
import PhoneInput, { Value, Country } from 'react-phone-number-input'
type TextInputProps = {
block:
| TextInputBlock
| EmailInputBlock
| NumberInputBlock
| UrlInputBlock
| PhoneNumberInputBlock
value: string
onChange: (value: string) => void
}
export const TextInput = ({ block, value, onChange }: TextInputProps) => {
const inputRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null)
useEffect(() => {
if (!inputRef.current) return
inputRef.current.focus()
}, [])
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 as unknown as RefObject<HTMLTextAreaElement>}
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...'
}
name="typebot-short-text"
onChange={handleInputChange}
autoComplete="new-typebot-answer-value"
/>
)
}
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={inputRef as any}
value={value}
onChange={handlePhoneNumberChange}
placeholder={
block.options.labels.placeholder ?? 'Your phone number...'
}
defaultCountry={block.options.defaultCountryCode as Country}
/>
)
}
}
}
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"
required
style={{ fontSize: '16px' }}
{...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' }}
{...props}
/>
)
)

View File

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

View File

@@ -0,0 +1,297 @@
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 {
isBubbleBlock,
isBubbleBlockType,
isChoiceInput,
isDefined,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
} 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,
LogicBlockType,
PublicTypebot,
Block,
} from 'models'
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
import { getLastChatBlockType } from '../../services/chat'
import { useChat } from 'contexts/ChatContext'
import { InputChatBlock } from './ChatBlock'
type ChatGroupProps = {
blocks: Block[]
startBlockIndex: number
groupTitle: string
keepShowingHostAvatar: boolean
onGroupEnd: ({
edgeId,
updatedTypebot,
}: {
edgeId?: string
updatedTypebot?: PublicTypebot | LinkedTypebot
}) => void
}
type ChatDisplayChunk = { bubbles: BubbleBlock[]; input?: InputBlock }
export const ChatGroup = ({
blocks,
startBlockIndex,
groupTitle,
onGroupEnd,
keepShowingHostAvatar,
}: ChatGroupProps) => {
const {
currentTypebotId,
typebot,
updateVariableValue,
createEdge,
apiHost,
isPreview,
onNewLog,
injectLinkedTypebot,
linkedTypebots,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
} = useTypebot()
const { resultValues, updateVariables, resultId } = useAnswers()
const { scroll } = useChat()
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
const insertBlockInStack = (nextBlock: Block) => {
setProcessedBlocks([...processedBlocks, nextBlock])
if (isBubbleBlock(nextBlock)) {
const lastBlockType = getLastChatBlockType(processedBlocks)
lastBlockType && isBubbleBlockType(lastBlockType)
? setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1
? { bubbles: [...c.bubbles, nextBlock] }
: c
)
)
: setDisplayedChunks([...displayedChunks, { bubbles: [nextBlock] }])
}
if (isInputBlock(nextBlock)) {
displayedChunks.length === 0 ||
isDefined(displayedChunks[displayedChunks.length - 1].input)
? setDisplayedChunks([
...displayedChunks,
{ bubbles: [], input: nextBlock },
])
: setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1
? { ...c, input: nextBlock }
: c
)
)
}
}
useEffect(() => {
const nextBlock = blocks[startBlockIndex]
if (nextBlock) insertBlockInStack(nextBlock)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
scroll()
onNewBlockDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processedBlocks])
const onNewBlockDisplayed = async () => {
const currentBlock = [...processedBlocks].pop()
if (!currentBlock) return
if (isLogicBlock(currentBlock)) {
const { nextEdgeId, linkedTypebot } = await executeLogic(currentBlock, {
isPreview,
apiHost,
typebot,
linkedTypebots,
updateVariableValue,
updateVariables,
injectLinkedTypebot,
onNewLog,
createEdge,
setCurrentTypebotId,
pushEdgeIdInLinkedTypebotQueue,
currentTypebotId,
})
const isRedirecting =
currentBlock.type === LogicBlockType.REDIRECT &&
currentBlock.options.isNewTab === false
if (isRedirecting) return
nextEdgeId
? onGroupEnd({ edgeId: nextEdgeId, updatedTypebot: linkedTypebot })
: displayNextBlock()
}
if (isIntegrationBlock(currentBlock)) {
const nextEdgeId = await executeIntegration({
block: currentBlock,
context: {
apiHost,
typebotId: currentTypebotId,
groupId: currentBlock.groupId,
blockId: currentBlock.id,
variables: typebot.variables,
isPreview,
updateVariableValue,
updateVariables,
resultValues,
groups: typebot.groups,
onNewLog,
resultId,
},
})
nextEdgeId ? onGroupEnd({ edgeId: nextEdgeId }) : displayNextBlock()
}
if (currentBlock.type === 'start')
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const displayNextBlock = (answerContent?: string, isRetry?: boolean) => {
scroll()
const currentBlock = [...processedBlocks].pop()
if (currentBlock) {
if (isRetry && blockCanBeRetried(currentBlock))
return insertBlockInStack(
parseRetryBlock(currentBlock, typebot.variables, createEdge)
)
if (
isInputBlock(currentBlock) &&
currentBlock.options?.variableId &&
answerContent
) {
updateVariableValue(currentBlock.options.variableId, answerContent)
}
const isSingleChoiceBlock =
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
if (isSingleChoiceBlock) {
const nextEdgeId = currentBlock.items.find(
(i) => i.content === answerContent
)?.outgoingEdgeId
if (nextEdgeId) return onGroupEnd({ edgeId: nextEdgeId })
}
if (
currentBlock?.outgoingEdgeId ||
processedBlocks.length === blocks.length
)
return onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
}
const nextBlock = blocks[processedBlocks.length + startBlockIndex]
nextBlock ? insertBlockInStack(nextBlock) : onGroupEnd({})
}
const avatarSrc = typebot.theme.chat.hostAvatar?.url
return (
<div className="flex w-full" data-group-name={groupTitle}>
<div className="flex flex-col w-full min-w-0">
{displayedChunks.map((chunk, idx) => (
<ChatChunks
key={idx}
displayChunk={chunk}
hostAvatar={{
isEnabled: typebot.theme.chat.hostAvatar?.isEnabled ?? true,
src: avatarSrc && parseVariables(typebot.variables)(avatarSrc),
}}
hasGuestAvatar={typebot.theme.chat.guestAvatar?.isEnabled ?? false}
onDisplayNextBlock={displayNextBlock}
keepShowingHostAvatar={keepShowingHostAvatar}
/>
))}
</div>
</div>
)
}
type Props = {
displayChunk: ChatDisplayChunk
hostAvatar: { isEnabled: boolean; src?: string }
hasGuestAvatar: boolean
keepShowingHostAvatar: boolean
onDisplayNextBlock: (answerContent?: string, isRetry?: boolean) => void
}
const ChatChunks = ({
displayChunk: { bubbles, input },
hostAvatar,
hasGuestAvatar,
keepShowingHostAvatar,
onDisplayNextBlock,
}: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatarSideContainerRef = useRef<any>()
useEffect(() => {
refreshTopOffset()
})
const refreshTopOffset = () =>
avatarSideContainerRef.current?.refreshTopOffset()
return (
<>
<div className="flex">
{hostAvatar.isEnabled && bubbles.length > 0 && (
<AvatarSideContainer
ref={avatarSideContainerRef}
hostAvatarSrc={hostAvatar.src}
keepShowing={keepShowingHostAvatar || isDefined(input)}
/>
)}
<div
className="flex-1"
style={{ marginRight: hasGuestAvatar ? '50px' : '0.5rem' }}
>
<TransitionGroup>
{bubbles.map((block) => (
<CSSTransition
key={block.id}
classNames="bubble"
timeout={500}
unmountOnExit
>
<HostBubble
block={block}
onTransitionEnd={() => {
onDisplayNextBlock()
refreshTopOffset()
}}
/>
</CSSTransition>
))}
</TransitionGroup>
</div>
</div>
<CSSTransition
classNames="bubble"
timeout={500}
unmountOnExit
in={isDefined(input)}
>
{input && (
<InputChatBlock
block={input}
onTransitionEnd={onDisplayNextBlock}
hasAvatar={hostAvatar.isEnabled}
hasGuestAvatar={hasGuestAvatar}
/>
)}
</CSSTransition>
</>
)
}

View File

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