♻️ Re-organize workspace folders
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
import { CSSTransition } from 'react-transition-group'
|
||||
import { ResizeObserver } from 'resize-observer'
|
||||
|
||||
type Props = { hostAvatarSrc?: string; keepShowing: boolean }
|
||||
|
||||
export const AvatarSideContainer = forwardRef(function AvatarSideContainer(
|
||||
{ hostAvatarSrc, keepShowing }: Props,
|
||||
ref: ForwardedRef<unknown>
|
||||
) {
|
||||
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(() => {
|
||||
if (!document) return
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAnswers } from '../../../providers/AnswersProvider'
|
||||
import { InputBlock, InputBlockType } from '@typebot.io/schemas'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { byId } from '@typebot.io/lib'
|
||||
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,
|
||||
hasAvatar,
|
||||
hasGuestAvatar,
|
||||
onTransitionEnd,
|
||||
onSkip,
|
||||
}: {
|
||||
block: InputBlock
|
||||
hasGuestAvatar: boolean
|
||||
hasAvatar: boolean
|
||||
onTransitionEnd: (
|
||||
answerContent?: InputSubmitContent,
|
||||
isRetry?: boolean
|
||||
) => void
|
||||
onSkip: () => void
|
||||
}) => {
|
||||
const { typebot, isLoading } = 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(
|
||||
(variable) =>
|
||||
variable.name === typebot.variables.find(byId(variableId))?.name
|
||||
)?.value
|
||||
: undefined
|
||||
|
||||
const handleSubmit = async ({ label, value, itemId }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
const isRetry = !isInputValid(value, block.type)
|
||||
if (!isRetry && addAnswer)
|
||||
await addAnswer(typebot.variables)({
|
||||
blockId: block.id,
|
||||
groupId: block.groupId,
|
||||
content: value,
|
||||
variableId,
|
||||
uploadedFiles: block.type === InputBlockType.FILE,
|
||||
})
|
||||
if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry)
|
||||
setIsEditting(false)
|
||||
}
|
||||
|
||||
if (isLoading) return null
|
||||
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
{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}
|
||||
onSkip={onSkip}
|
||||
defaultValue={defaultValue?.toString()}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Input = ({
|
||||
block,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
defaultValue,
|
||||
hasGuestAvatar,
|
||||
}: {
|
||||
block: InputBlock
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
defaultValue?: string
|
||||
hasGuestAvatar: boolean
|
||||
}) => {
|
||||
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 (
|
||||
<PhoneInput
|
||||
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({ value: block.options.labels.success ?? 'Success' })
|
||||
}
|
||||
/>
|
||||
)
|
||||
case InputBlockType.RATING:
|
||||
return <RatingForm block={block} onSubmit={onSubmit} />
|
||||
case InputBlockType.FILE:
|
||||
return (
|
||||
<FileUploadForm block={block} onSubmit={onSubmit} onSkip={onSkip} />
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
|
||||
export const GuestBubble = ({
|
||||
message,
|
||||
showAvatar,
|
||||
avatarSrc,
|
||||
}: Props): JSX.Element => {
|
||||
const [content] = useState(message)
|
||||
|
||||
return (
|
||||
<CSSTransition classNames="bubble" timeout={1000}>
|
||||
<div
|
||||
className="flex justify-end mb-2 items-end"
|
||||
style={{ marginLeft: '50px' }}
|
||||
>
|
||||
<span
|
||||
className="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble cursor-pointer"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
{showAvatar && <Avatar avatarSrc={avatarSrc} />}
|
||||
</div>
|
||||
</CSSTransition>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AudioBubble } from '@/features/blocks/bubbles/audio'
|
||||
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 '@typebot.io/schemas'
|
||||
|
||||
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} />
|
||||
case BubbleBlockType.AUDIO:
|
||||
return (
|
||||
<AudioBubble
|
||||
url={block.content.url}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
|
||||
import {
|
||||
isBubbleBlock,
|
||||
isBubbleBlockType,
|
||||
isChoiceInput,
|
||||
isDefined,
|
||||
isInputBlock,
|
||||
isIntegrationBlock,
|
||||
isLogicBlock,
|
||||
byId,
|
||||
} from '@typebot.io/lib'
|
||||
import {
|
||||
BubbleBlock,
|
||||
InputBlock,
|
||||
LogicBlockType,
|
||||
PublicTypebot,
|
||||
Block,
|
||||
} from '@typebot.io/schemas'
|
||||
import { HostBubble } from './ChatBlock/bubbles/HostBubble'
|
||||
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'
|
||||
import { PopupBlockedToast } from '../PopupBlockedToast'
|
||||
|
||||
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,
|
||||
parentTypebotIds,
|
||||
onNewLog,
|
||||
injectLinkedTypebot,
|
||||
linkedTypebots,
|
||||
setCurrentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
pushParentTypebotId,
|
||||
} = useTypebot()
|
||||
const { resultValues, updateVariables, resultId } = useAnswers()
|
||||
const { scroll } = useChat()
|
||||
const [processedBlocks, setProcessedBlocks] = useState<Block[]>([])
|
||||
const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
|
||||
const [blockedPopupUrl, setBlockedPopupUrl] = useState<string>()
|
||||
|
||||
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, blockedPopupUrl } = await executeLogic(
|
||||
currentBlock,
|
||||
{
|
||||
isPreview,
|
||||
apiHost,
|
||||
typebot,
|
||||
linkedTypebots,
|
||||
updateVariableValue,
|
||||
updateVariables,
|
||||
injectLinkedTypebot,
|
||||
onNewLog,
|
||||
createEdge,
|
||||
setCurrentTypebotId,
|
||||
pushEdgeIdInLinkedTypebotQueue,
|
||||
currentTypebotId,
|
||||
pushParentTypebotId,
|
||||
}
|
||||
)
|
||||
if (blockedPopupUrl) setBlockedPopupUrl(blockedPopupUrl)
|
||||
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,
|
||||
parentTypebotIds,
|
||||
},
|
||||
})
|
||||
nextEdgeId ? onGroupEnd({ edgeId: nextEdgeId }) : displayNextBlock()
|
||||
}
|
||||
if (currentBlock.type === 'start')
|
||||
onGroupEnd({ edgeId: currentBlock.outgoingEdgeId })
|
||||
}
|
||||
|
||||
const displayNextBlock = (
|
||||
answerContent?: InputSubmitContent,
|
||||
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.value
|
||||
)
|
||||
}
|
||||
const isSingleChoiceBlock =
|
||||
isChoiceInput(currentBlock) && !currentBlock.options.isMultipleChoice
|
||||
if (isSingleChoiceBlock) {
|
||||
const nextEdgeId = currentBlock.items.find(
|
||||
byId(answerContent?.itemId)
|
||||
)?.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}
|
||||
blockedPopupUrl={blockedPopupUrl}
|
||||
onBlockedPopupLinkClick={() => setBlockedPopupUrl(undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
displayChunk: ChatDisplayChunk
|
||||
hostAvatar: { isEnabled: boolean; src?: string }
|
||||
hasGuestAvatar: boolean
|
||||
keepShowingHostAvatar: boolean
|
||||
blockedPopupUrl?: string
|
||||
onBlockedPopupLinkClick: () => void
|
||||
onDisplayNextBlock: (
|
||||
answerContent?: InputSubmitContent,
|
||||
isRetry?: boolean
|
||||
) => void
|
||||
}
|
||||
const ChatChunks = ({
|
||||
displayChunk: { bubbles, input },
|
||||
hostAvatar,
|
||||
hasGuestAvatar,
|
||||
keepShowingHostAvatar,
|
||||
blockedPopupUrl,
|
||||
onBlockedPopupLinkClick,
|
||||
onDisplayNextBlock,
|
||||
}: Props) => {
|
||||
const [isSkipped, setIsSkipped] = useState(false)
|
||||
|
||||
const avatarSideContainerRef = useRef<{ refreshTopOffset: () => void }>()
|
||||
|
||||
useEffect(() => {
|
||||
refreshTopOffset()
|
||||
})
|
||||
|
||||
const skipInput = () => {
|
||||
onDisplayNextBlock()
|
||||
setIsSkipped(true)
|
||||
}
|
||||
|
||||
const refreshTopOffset = () =>
|
||||
avatarSideContainerRef.current?.refreshTopOffset()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
{hostAvatar.isEnabled && bubbles.length > 0 && (
|
||||
<AvatarSideContainer
|
||||
ref={avatarSideContainerRef}
|
||||
hostAvatarSrc={hostAvatar.src}
|
||||
keepShowing={
|
||||
(keepShowingHostAvatar || isDefined(input)) && !isSkipped
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{!isSkipped && (
|
||||
<CSSTransition
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
in={isDefined(input)}
|
||||
>
|
||||
{input ? (
|
||||
<InputChatBlock
|
||||
block={input}
|
||||
onTransitionEnd={onDisplayNextBlock}
|
||||
onSkip={skipInput}
|
||||
hasAvatar={hostAvatar.isEnabled}
|
||||
hasGuestAvatar={hasGuestAvatar}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</CSSTransition>
|
||||
)}
|
||||
{blockedPopupUrl ? (
|
||||
<div className="flex justify-end">
|
||||
<PopupBlockedToast
|
||||
url={blockedPopupUrl}
|
||||
onLinkClick={onBlockedPopupLinkClick}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChatGroup } from './ChatGroup'
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChatGroup } from './ChatGroup'
|
||||
import { useAnswers } from '../providers/AnswersProvider'
|
||||
import {
|
||||
Group,
|
||||
Edge,
|
||||
PublicTypebot,
|
||||
Theme,
|
||||
VariableWithValue,
|
||||
} from '@typebot.io/schemas'
|
||||
import { byId, isDefined, isInputBlock, isNotDefined } from '@typebot.io/lib'
|
||||
import { animateScroll as scroll } from 'react-scroll'
|
||||
import { LinkedTypebot, useTypebot } from '@/providers/TypebotProvider'
|
||||
import { setCssVariablesValue } from '@/features/theme'
|
||||
import { ChatProvider } from '@/providers/ChatProvider'
|
||||
|
||||
type Props = {
|
||||
theme: Theme
|
||||
predefinedVariables?: { [key: string]: string | undefined }
|
||||
startGroupId?: string
|
||||
onNewGroupVisible: (edge: Edge) => void
|
||||
onCompleted: () => void
|
||||
}
|
||||
export const ConversationContainer = ({
|
||||
theme,
|
||||
predefinedVariables,
|
||||
startGroupId,
|
||||
onNewGroupVisible,
|
||||
onCompleted,
|
||||
}: Props) => {
|
||||
const {
|
||||
typebot,
|
||||
updateVariableValue,
|
||||
linkedBotQueue,
|
||||
popEdgeIdFromLinkedTypebotQueue,
|
||||
} = useTypebot()
|
||||
const [displayedGroups, setDisplayedGroups] = useState<
|
||||
{ group: Group; startBlockIndex: number }[]
|
||||
>([])
|
||||
const { updateVariables } = useAnswers()
|
||||
const bottomAnchor = useRef<HTMLDivElement | null>(null)
|
||||
const scrollableContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [hasStarted, setHasStarted] = useState(false)
|
||||
|
||||
const displayNextGroup = ({
|
||||
edgeId,
|
||||
updatedTypebot,
|
||||
groupId,
|
||||
}: {
|
||||
edgeId?: string
|
||||
groupId?: string
|
||||
updatedTypebot?: PublicTypebot | LinkedTypebot
|
||||
}) => {
|
||||
const currentTypebot = updatedTypebot ?? typebot
|
||||
if (groupId) {
|
||||
const nextGroup = currentTypebot.groups.find(byId(groupId))
|
||||
if (!nextGroup) return
|
||||
onNewGroupVisible({
|
||||
id: 'edgeId',
|
||||
from: { groupId: 'block', blockId: 'block' },
|
||||
to: { groupId },
|
||||
})
|
||||
return setDisplayedGroups([
|
||||
...displayedGroups,
|
||||
{ group: nextGroup, startBlockIndex: 0 },
|
||||
])
|
||||
}
|
||||
const nextEdge = currentTypebot.edges.find(byId(edgeId))
|
||||
if (!nextEdge) {
|
||||
if (linkedBotQueue.length > 0) {
|
||||
const nextEdgeId = linkedBotQueue[0].edgeId
|
||||
popEdgeIdFromLinkedTypebotQueue()
|
||||
displayNextGroup({ edgeId: nextEdgeId })
|
||||
}
|
||||
return onCompleted()
|
||||
}
|
||||
const nextGroup = currentTypebot.groups.find(byId(nextEdge.to.groupId))
|
||||
if (!nextGroup) return onCompleted()
|
||||
const startBlockIndex = nextEdge.to.blockId
|
||||
? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId))
|
||||
: 0
|
||||
onNewGroupVisible(nextEdge)
|
||||
setDisplayedGroups([
|
||||
...displayedGroups,
|
||||
{
|
||||
group: nextGroup,
|
||||
startBlockIndex: startBlockIndex === -1 ? 0 : startBlockIndex,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStarted) return
|
||||
if (
|
||||
isDefined(predefinedVariables) &&
|
||||
Object.keys(predefinedVariables).length > 0
|
||||
) {
|
||||
const prefilledVariables = injectPredefinedVariables(predefinedVariables)
|
||||
updateVariables(prefilledVariables)
|
||||
}
|
||||
setHasStarted(true)
|
||||
const startEdge = typebot.groups[0].blocks[0].outgoingEdgeId
|
||||
if (!startEdge && !startGroupId) return
|
||||
displayNextGroup({
|
||||
edgeId: startGroupId ? undefined : startEdge,
|
||||
groupId: startGroupId,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [predefinedVariables])
|
||||
|
||||
const injectPredefinedVariables = (predefinedVariables: {
|
||||
[key: string]: string | undefined
|
||||
}) => {
|
||||
const prefilledVariables: VariableWithValue[] = []
|
||||
Object.keys(predefinedVariables).forEach((key) => {
|
||||
const matchingVariable = typebot.variables.find(
|
||||
(v) => v.name.toLowerCase() === key.toLowerCase()
|
||||
)
|
||||
if (!predefinedVariables || isNotDefined(matchingVariable)) return
|
||||
const value = predefinedVariables[key]
|
||||
if (!value) return
|
||||
updateVariableValue(matchingVariable?.id, value)
|
||||
prefilledVariables.push({ ...matchingVariable, value })
|
||||
})
|
||||
return prefilledVariables
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
setCssVariablesValue(theme, document.body.style)
|
||||
}, [theme])
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
if (!scrollableContainer.current) return
|
||||
setTimeout(() => {
|
||||
scroll.scrollToBottom({
|
||||
duration: 500,
|
||||
container: scrollableContainer.current,
|
||||
})
|
||||
}, 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<ChatProvider onScroll={autoScrollToBottom}>
|
||||
{displayedGroups.map((displayedGroup, idx) => {
|
||||
const groupAfter = displayedGroups[idx + 1]
|
||||
const groupAfterStartsWithInput =
|
||||
groupAfter &&
|
||||
isInputBlock(groupAfter.group.blocks[groupAfter.startBlockIndex])
|
||||
return (
|
||||
<ChatGroup
|
||||
key={displayedGroup.group.id + idx}
|
||||
blocks={displayedGroup.group.blocks}
|
||||
startBlockIndex={displayedGroup.startBlockIndex}
|
||||
onGroupEnd={displayNextGroup}
|
||||
groupTitle={displayedGroup.group.title}
|
||||
keepShowingHostAvatar={
|
||||
idx === displayedGroups.length - 1 || groupAfterStartsWithInput
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ChatProvider>
|
||||
|
||||
{/* We use a block to simulate padding because it makes iOS scroll flicker */}
|
||||
<div className="w-full h-32" ref={bottomAnchor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
packages/deprecated/bot-engine/src/components/LiteBadge.tsx
Normal file
43
packages/deprecated/bot-engine/src/components/LiteBadge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
export const LiteBadge = () => {
|
||||
const liteBadge = useRef<HTMLAnchorElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
const container = document.querySelector(
|
||||
'[data-testid="container"]'
|
||||
) as HTMLDivElement
|
||||
const observer = new MutationObserver(function (mutations_list) {
|
||||
mutations_list.forEach(function (mutation) {
|
||||
mutation.removedNodes.forEach(function (removed_node) {
|
||||
if ((removed_node as HTMLElement).id == 'lite-badge')
|
||||
container.append(liteBadge.current as Node)
|
||||
})
|
||||
})
|
||||
})
|
||||
observer.observe(container, {
|
||||
subtree: false,
|
||||
childList: true,
|
||||
})
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={liteBadge}
|
||||
href={'https://www.typebot.io/?utm_source=litebadge'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
|
||||
style={{ bottom: '20px' }}
|
||||
id="lite-badge"
|
||||
>
|
||||
Made with <span className="text-blue-500">Typebot</span>.
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
type Props = {
|
||||
url: string
|
||||
onLinkClick: () => void
|
||||
}
|
||||
|
||||
export const PopupBlockedToast = ({ url, onLinkClick }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span className="mb-1 text-sm font-semibold text-gray-900">
|
||||
Popup blocked
|
||||
</span>
|
||||
<div className="mb-2 text-sm font-normal">
|
||||
The bot wants to open a new tab but it was blocked by your broswer. It
|
||||
needs a manual approval.
|
||||
</div>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="py-1 px-4 justify-center text-sm 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"
|
||||
rel="noreferrer"
|
||||
onClick={onLinkClick}
|
||||
>
|
||||
Continue in new tab
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
packages/deprecated/bot-engine/src/components/SendButton.tsx
Normal file
62
packages/deprecated/bot-engine/src/components/SendButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { SVGProps } from 'react'
|
||||
import { SendIcon } from './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"
|
||||
data-testid="loading-spinner"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
124
packages/deprecated/bot-engine/src/components/TypebotViewer.tsx
Normal file
124
packages/deprecated/bot-engine/src/components/TypebotViewer.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { CSSProperties, useMemo } from 'react'
|
||||
import { TypebotProvider } from '../providers/TypebotProvider'
|
||||
import styles from '../assets/style.css'
|
||||
import importantStyles from '../assets/importantStyles.css'
|
||||
import phoneSyle from '../assets/phone.css'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import { AnswersProvider } from '../providers/AnswersProvider'
|
||||
import {
|
||||
AnswerInput,
|
||||
BackgroundType,
|
||||
Edge,
|
||||
PublicTypebot,
|
||||
VariableWithValue,
|
||||
} from '@typebot.io/schemas'
|
||||
import { Log } from '@typebot.io/prisma'
|
||||
import { LiteBadge } from './LiteBadge'
|
||||
import { getViewerUrl, isEmpty, isNotEmpty } from '@typebot.io/lib'
|
||||
|
||||
export type TypebotViewerProps = {
|
||||
typebot: Omit<PublicTypebot, 'updatedAt' | 'createdAt'>
|
||||
isPreview?: boolean
|
||||
apiHost?: string
|
||||
style?: CSSProperties
|
||||
predefinedVariables?: { [key: string]: string | undefined }
|
||||
resultId?: string
|
||||
startGroupId?: string
|
||||
isLoading?: boolean
|
||||
onNewGroupVisible?: (edge: Edge) => void
|
||||
onNewAnswer?: (
|
||||
answer: AnswerInput & { uploadedFiles: boolean }
|
||||
) => Promise<void>
|
||||
onNewLog?: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||
onCompleted?: () => void
|
||||
onVariablesUpdated?: (variables: VariableWithValue[]) => void
|
||||
}
|
||||
|
||||
export const TypebotViewer = ({
|
||||
typebot,
|
||||
apiHost = getViewerUrl(),
|
||||
isPreview = false,
|
||||
isLoading = false,
|
||||
style,
|
||||
resultId,
|
||||
startGroupId,
|
||||
predefinedVariables,
|
||||
onNewLog,
|
||||
onNewGroupVisible,
|
||||
onNewAnswer,
|
||||
onCompleted,
|
||||
onVariablesUpdated,
|
||||
}: TypebotViewerProps) => {
|
||||
const containerBgColor = useMemo(
|
||||
() =>
|
||||
typebot?.theme?.general?.background?.type === BackgroundType.COLOR
|
||||
? typebot.theme.general.background.content
|
||||
: 'transparent',
|
||||
[typebot?.theme?.general?.background]
|
||||
)
|
||||
const handleNewGroupVisible = (edge: Edge) =>
|
||||
onNewGroupVisible && onNewGroupVisible(edge)
|
||||
|
||||
const handleNewAnswer = (answer: AnswerInput & { uploadedFiles: boolean }) =>
|
||||
onNewAnswer && onNewAnswer(answer)
|
||||
|
||||
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
|
||||
onNewLog && onNewLog(log)
|
||||
|
||||
const handleCompleted = () => onCompleted && onCompleted()
|
||||
|
||||
if (isEmpty(apiHost))
|
||||
return <p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{phoneSyle}
|
||||
{styles}
|
||||
</style>
|
||||
<style>{typebot.theme?.customCss}</style>
|
||||
<style>{importantStyles}</style>
|
||||
{isNotEmpty(typebot?.theme?.general?.font) && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `@import url('https://fonts.googleapis.com/css2?family=${
|
||||
typebot.theme.general.font ?? 'Open Sans'
|
||||
}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap');`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TypebotProvider
|
||||
typebot={typebot}
|
||||
apiHost={apiHost}
|
||||
isPreview={isPreview}
|
||||
onNewLog={handleNewLog}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<AnswersProvider
|
||||
resultId={resultId}
|
||||
onNewAnswer={handleNewAnswer}
|
||||
onVariablesUpdated={onVariablesUpdated}
|
||||
>
|
||||
<div
|
||||
className="flex text-base overflow-hidden bg-cover h-screen w-screen flex-col items-center typebot-container"
|
||||
style={{
|
||||
// We set this as inline style to avoid color flash for SSR
|
||||
backgroundColor: containerBgColor ?? 'transparent',
|
||||
}}
|
||||
data-testid="container"
|
||||
>
|
||||
<div className="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
theme={typebot.theme}
|
||||
onNewGroupVisible={handleNewGroupVisible}
|
||||
onCompleted={handleCompleted}
|
||||
predefinedVariables={predefinedVariables}
|
||||
startGroupId={startGroupId}
|
||||
/>
|
||||
</div>
|
||||
{typebot.settings.general.isBrandingEnabled && <LiteBadge />}
|
||||
</div>
|
||||
</AnswersProvider>
|
||||
</TypebotProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
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" />
|
||||
<div className="w-2 h-2 rounded-full bubble3" />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from 'react'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const Avatar = ({ avatarSrc }: { avatarSrc?: string }): JSX.Element => {
|
||||
const [currentAvatarSrc] = useState(avatarSrc)
|
||||
|
||||
if (currentAvatarSrc === '') return <></>
|
||||
if (isDefined(currentAvatarSrc))
|
||||
return (
|
||||
<figure
|
||||
className={
|
||||
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-10 xs:h-10 xs:text-xl'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={currentAvatarSrc}
|
||||
alt="Bot avatar"
|
||||
className="rounded-full object-cover w-full h-full"
|
||||
/>
|
||||
</figure>
|
||||
)
|
||||
return <DefaultAvatar />
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
export const DefaultAvatar = (): JSX.Element => {
|
||||
return (
|
||||
<figure
|
||||
className={
|
||||
'flex justify-center items-center rounded-full text-white w-6 h-6 text-sm relative xs:w-10 xs:h-10 xs:text-xl'
|
||||
}
|
||||
data-testid="default-avatar"
|
||||
>
|
||||
<svg
|
||||
width="75"
|
||||
height="75"
|
||||
viewBox="0 0 75 75"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={
|
||||
'absolute top-0 left-0 w-6 h-6 xs:w-full xs:h-full xs:text-xl'
|
||||
}
|
||||
>
|
||||
<mask id="mask0" x="0" y="0" mask-type="alpha">
|
||||
<circle cx="37.5" cy="37.5" r="37.5" fill="#0042DA" />
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<rect x="-30" y="-43" width="131" height="154" fill="#0042DA" />
|
||||
<rect
|
||||
x="2.50413"
|
||||
y="120.333"
|
||||
width="81.5597"
|
||||
height="86.4577"
|
||||
rx="2.5"
|
||||
transform="rotate(-52.6423 2.50413 120.333)"
|
||||
stroke="#FED23D"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
<circle cx="76.5" cy="-1.5" r="29" stroke="#FF8E20" strokeWidth="5" />
|
||||
<path
|
||||
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
|
||||
stroke="#F7F8FF"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
1
packages/deprecated/bot-engine/src/components/global.d.ts
vendored
Normal file
1
packages/deprecated/bot-engine/src/components/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.css'
|
||||
13
packages/deprecated/bot-engine/src/components/icons.tsx
Normal file
13
packages/deprecated/bot-engine/src/components/icons.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
export const SendIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width="19px"
|
||||
color="white"
|
||||
{...props}
|
||||
>
|
||||
<path d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z" />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
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(function ShortTextInput(
|
||||
{ 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -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(function Textarea(
|
||||
{ onChange, ...props }: TextareaProps,
|
||||
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||
) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user