@@ -38,6 +38,7 @@ import { CorsError } from '@/utils/CorsError'
|
||||
import { Toaster, Toast } from '@ark-ui/solid'
|
||||
import { CloseIcon } from './icons/CloseIcon'
|
||||
import { toaster } from '@/utils/toaster'
|
||||
import { setBotContainer } from '@/utils/botContainerSignal'
|
||||
|
||||
export type BotProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -285,16 +286,18 @@ const BotContent = (props: BotContentProps) => {
|
||||
key: `typebot-${props.context.typebot.id}-progressValue`,
|
||||
}
|
||||
)
|
||||
let botContainer: HTMLDivElement | undefined
|
||||
let botContainerElement: HTMLDivElement | undefined
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
return setIsMobile(entries[0].target.clientWidth < 400)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!botContainer) return
|
||||
resizeObserver.observe(botContainer)
|
||||
setBotContainerHeight(`${botContainer.clientHeight}px`)
|
||||
if (!botContainerElement) return
|
||||
console.log('yes')
|
||||
setBotContainer(botContainerElement)
|
||||
resizeObserver.observe(botContainerElement)
|
||||
setBotContainerHeight(`${botContainerElement.clientHeight}px`)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -304,22 +307,22 @@ const BotContent = (props: BotContentProps) => {
|
||||
family: defaultFontFamily,
|
||||
}
|
||||
)
|
||||
if (!botContainer) return
|
||||
if (!botContainerElement) return
|
||||
setCssVariablesValue(
|
||||
props.initialChatReply.typebot.theme,
|
||||
botContainer,
|
||||
botContainerElement,
|
||||
props.context.isPreview
|
||||
)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!botContainer) return
|
||||
resizeObserver.unobserve(botContainer)
|
||||
if (!botContainerElement) return
|
||||
resizeObserver.unobserve(botContainerElement)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={botContainer}
|
||||
ref={botContainerElement}
|
||||
class={clsx(
|
||||
'relative flex w-full h-full text-base overflow-hidden flex-col justify-center items-center typebot-container',
|
||||
props.class
|
||||
@@ -358,7 +361,7 @@ const BotContent = (props: BotContentProps) => {
|
||||
props.initialChatReply.typebot.settings.general?.isBrandingEnabled
|
||||
}
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
<LiteBadge botContainer={botContainerElement} />
|
||||
</Show>
|
||||
<Toaster toaster={toaster}>
|
||||
{(toast) => (
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Answer, BotContext, ChatChunk as ChatChunkType } from '@/types'
|
||||
import {
|
||||
InputSubmitContent,
|
||||
BotContext,
|
||||
ChatChunk as ChatChunkType,
|
||||
} from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { ContinueChatResponse, Settings, Theme } from '@typebot.io/schemas'
|
||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
@@ -23,7 +27,7 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
|
||||
isTransitionDisabled?: boolean
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
onScrollToBottom: (ref?: HTMLDivElement, offset?: number) => void
|
||||
onSubmit: (answer?: string, attachments?: Answer['attachments']) => void
|
||||
onSubmit: (answer?: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
onAllBubblesDisplayed: () => void
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Theme,
|
||||
ChatLog,
|
||||
StartChatResponse,
|
||||
Message,
|
||||
} from '@typebot.io/schemas'
|
||||
import {
|
||||
createEffect,
|
||||
@@ -16,9 +17,9 @@ import {
|
||||
import { continueChatQuery } from '@/queries/continueChatQuery'
|
||||
import { ChatChunk } from './ChatChunk'
|
||||
import {
|
||||
Answer,
|
||||
BotContext,
|
||||
ChatChunk as ChatChunkType,
|
||||
InputSubmitContent,
|
||||
OutgoingLog,
|
||||
} from '@/types'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
|
||||
import { HTTPError } from 'ky'
|
||||
import { persist } from '@/utils/persist'
|
||||
import { getAnswerContent } from '@/utils/getAnswerContent'
|
||||
|
||||
const autoScrollBottomToleranceScreenPercent = 0.6
|
||||
const bottomSpacerHeight = 128
|
||||
@@ -142,15 +144,15 @@ export const ConversationContainer = (props: Props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message?: string,
|
||||
attachments?: Answer['attachments']
|
||||
) => {
|
||||
const sendMessage = async (answer?: InputSubmitContent) => {
|
||||
setIsRecovered(false)
|
||||
setHasError(false)
|
||||
const currentInputBlock = [...chatChunks()].pop()?.input
|
||||
if (currentInputBlock?.id && props.onAnswer && message)
|
||||
props.onAnswer({ message, blockId: currentInputBlock.id })
|
||||
if (currentInputBlock?.id && props.onAnswer && answer)
|
||||
props.onAnswer({
|
||||
message: getAnswerContent(answer),
|
||||
blockId: currentInputBlock.id,
|
||||
})
|
||||
const longRequest = setTimeout(() => {
|
||||
setIsSending(true)
|
||||
}, 1000)
|
||||
@@ -158,13 +160,7 @@ export const ConversationContainer = (props: Props) => {
|
||||
const { data, error } = await continueChatQuery({
|
||||
apiHost: props.context.apiHost,
|
||||
sessionId: props.initialChatReply.sessionId,
|
||||
message: message
|
||||
? {
|
||||
type: 'text',
|
||||
text: message,
|
||||
attachedFileUrls: attachments?.map((attachment) => attachment.url),
|
||||
}
|
||||
: undefined,
|
||||
message: convertSubmitContentToMessage(answer),
|
||||
})
|
||||
clearTimeout(longRequest)
|
||||
setIsSending(false)
|
||||
@@ -294,7 +290,11 @@ export const ConversationContainer = (props: Props) => {
|
||||
if (response && 'logs' in response) saveLogs(response.logs)
|
||||
if (response && 'replyToSend' in response) {
|
||||
setIsSending(false)
|
||||
sendMessage(response.replyToSend)
|
||||
sendMessage(
|
||||
response.replyToSend
|
||||
? { type: 'text', value: response.replyToSend }
|
||||
: undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
if (response && 'blockedPopupUrl' in response)
|
||||
@@ -364,3 +364,16 @@ const BottomSpacer = () => (
|
||||
style={{ height: bottomSpacerHeight + 'px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const convertSubmitContentToMessage = (
|
||||
answer: InputSubmitContent | undefined
|
||||
): Message | undefined => {
|
||||
if (!answer) return
|
||||
if (answer.type === 'text')
|
||||
return {
|
||||
type: 'text',
|
||||
text: answer.value,
|
||||
attachedFileUrls: answer.attachments?.map((attachment) => attachment.url),
|
||||
}
|
||||
if (answer.type === 'recording') return { type: 'audio', url: answer.url }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
DateInputBlock,
|
||||
} from '@typebot.io/schemas'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { Answer, BotContext, InputSubmitContent } from '@/types'
|
||||
import { BotContext, InputSubmitContent } from '@/types'
|
||||
import { TextInput } from '@/features/blocks/inputs/textInput'
|
||||
import { NumberInput } from '@/features/blocks/inputs/number'
|
||||
import { EmailInput } from '@/features/blocks/inputs/email'
|
||||
@@ -48,33 +48,24 @@ type Props = {
|
||||
isInputPrefillEnabled: boolean
|
||||
hasError: boolean
|
||||
onTransitionEnd: () => void
|
||||
onSubmit: (answer: string, attachments?: Answer['attachments']) => void
|
||||
onSubmit: (content: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const InputChatBlock = (props: Props) => {
|
||||
const [answer, setAnswer] = persist(createSignal<Answer>(), {
|
||||
const [answer, setAnswer] = persist(createSignal<InputSubmitContent>(), {
|
||||
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
|
||||
storage: props.context.storage,
|
||||
})
|
||||
|
||||
const handleSubmit = async ({
|
||||
label,
|
||||
value,
|
||||
attachments,
|
||||
}: InputSubmitContent & Pick<Answer, 'attachments'>) => {
|
||||
setAnswer({
|
||||
text: props.block.type !== InputBlockType.FILE ? label ?? value : '',
|
||||
attachments,
|
||||
})
|
||||
props.onSubmit(
|
||||
value ?? label,
|
||||
props.block.type === InputBlockType.FILE ? undefined : attachments
|
||||
)
|
||||
const handleSubmit = async (content: InputSubmitContent) => {
|
||||
console.log(content)
|
||||
setAnswer(content)
|
||||
props.onSubmit(content)
|
||||
}
|
||||
|
||||
const handleSkip = (label: string) => {
|
||||
setAnswer({ text: label })
|
||||
setAnswer({ type: 'text', value: label })
|
||||
props.onSkip()
|
||||
}
|
||||
|
||||
@@ -83,14 +74,18 @@ export const InputChatBlock = (props: Props) => {
|
||||
(message) => props.chunkIndex === message.inputIndex
|
||||
)?.formattedMessage
|
||||
if (formattedMessage && props.block.type !== InputBlockType.FILE)
|
||||
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
|
||||
setAnswer((answer) =>
|
||||
answer?.type === 'text'
|
||||
? { ...answer, label: formattedMessage }
|
||||
: answer
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={answer() && !props.hasError}>
|
||||
<GuestBubble
|
||||
message={answer() as Answer}
|
||||
answer={answer()}
|
||||
showAvatar={
|
||||
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
|
||||
}
|
||||
@@ -117,7 +112,9 @@ export const InputChatBlock = (props: Props) => {
|
||||
block={props.block}
|
||||
chunkIndex={props.chunkIndex}
|
||||
isInputPrefillEnabled={props.isInputPrefillEnabled}
|
||||
existingAnswer={props.hasError ? answer()?.text : undefined}
|
||||
existingAnswer={
|
||||
props.hasError ? getAnswerValue(answer()!) : undefined
|
||||
}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
onSubmit={handleSubmit}
|
||||
onSkip={handleSkip}
|
||||
@@ -128,6 +125,11 @@ export const InputChatBlock = (props: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const getAnswerValue = (answer?: InputSubmitContent) => {
|
||||
if (!answer) return
|
||||
return answer.type === 'text' ? answer.value : answer.url
|
||||
}
|
||||
|
||||
const Input = (props: {
|
||||
context: BotContext
|
||||
block: NonNullable<ContinueChatResponse['input']>
|
||||
@@ -146,6 +148,7 @@ const Input = (props: {
|
||||
|
||||
const submitPaymentSuccess = () =>
|
||||
props.onSubmit({
|
||||
type: 'text',
|
||||
value:
|
||||
(props.block.options as PaymentInputBlock['options'])?.labels
|
||||
?.success ?? defaultPaymentInputOptions.labels.success,
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { createSignal, For, Show, Switch, Match } from 'solid-js'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Answer } from '@/types'
|
||||
import {
|
||||
InputSubmitContent,
|
||||
RecordingInputSubmitContent,
|
||||
TextInputSubmitContent,
|
||||
} from '@/types'
|
||||
import { Modal } from '../Modal'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { FilePreview } from '@/features/blocks/inputs/fileUpload/components/FilePreview'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
message: Answer
|
||||
answer?: InputSubmitContent
|
||||
showAvatar: boolean
|
||||
avatarSrc?: string
|
||||
hasHostAvatar: boolean
|
||||
}
|
||||
|
||||
export const GuestBubble = (props: Props) => {
|
||||
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
|
||||
@@ -28,65 +30,87 @@ export const GuestBubble = (props: Props) => {
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<Show when={(props.message.attachments ?? []).length > 0}>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment, idx) => (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={`Attached image ${idx() + 1}`}
|
||||
class={clsx(
|
||||
'typebot-guest-bubble-image-attachment cursor-pointer',
|
||||
props.message.attachments!.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
).length > 1 && 'max-w-[90%]'
|
||||
)}
|
||||
onClick={() => setClickedImageSrc(attachment.url)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter(
|
||||
(attachment) => !attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment) => (
|
||||
<FilePreview
|
||||
file={{
|
||||
name: attachment.url.split('/').at(-1)!,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
<Show when={isNotEmpty(props.message.text)}>
|
||||
<span class="px-[15px] py-[7px]">{props.message.text}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={props.answer?.type === 'text'}>
|
||||
<TextGuestBubble answer={props.answer as TextInputSubmitContent} />
|
||||
</Match>
|
||||
<Match when={props.answer?.type === 'recording'}>
|
||||
<AudioGuestBubble
|
||||
answer={props.answer as RecordingInputSubmitContent}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TextGuestBubble = (props: { answer: TextInputSubmitContent }) => {
|
||||
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<Show when={(props.answer.attachments ?? []).length > 0}>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.answer.attachments?.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment, idx) => (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={`Attached image ${idx() + 1}`}
|
||||
class={clsx(
|
||||
'typebot-guest-bubble-image-attachment cursor-pointer',
|
||||
props.answer.attachments!.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
).length > 1 && 'max-w-[90%]'
|
||||
)}
|
||||
onClick={() => setClickedImageSrc(attachment.url)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.answer.attachments?.filter(
|
||||
(attachment) => !attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment) => (
|
||||
<FilePreview
|
||||
file={{
|
||||
name: attachment.url.split('/').at(-1)!,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
<Show when={isNotEmpty(props.answer.label ?? props.answer.value)}>
|
||||
<span class="px-[15px] py-[7px]">
|
||||
{props.answer.label ?? props.answer.value}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={clickedImageSrc() !== undefined}
|
||||
onClose={() => setClickedImageSrc(undefined)}
|
||||
@@ -97,9 +121,19 @@ export const GuestBubble = (props: Props) => {
|
||||
style={{ 'border-radius': '6px' }}
|
||||
/>
|
||||
</Modal>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AudioGuestBubble = (props: { answer: RecordingInputSubmitContent }) => {
|
||||
return (
|
||||
<div class="flex flex-col gap-1 items-end w-full">
|
||||
<div
|
||||
class="p-2 w-full whitespace-pre-wrap typebot-guest-bubble flex flex-col max-w-[316px]"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
<audio controls src={props.answer.url} class="w-full h-[54px]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CustomEmbedBubble } from '@/features/blocks/bubbles/embed/components/Cu
|
||||
import { ImageBubble } from '@/features/blocks/bubbles/image'
|
||||
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
|
||||
import { VideoBubble } from '@/features/blocks/bubbles/video'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import type {
|
||||
AudioBubbleBlock,
|
||||
ChatMessage,
|
||||
@@ -22,7 +23,7 @@ type Props = {
|
||||
typingEmulation: Settings['typingEmulation']
|
||||
isTypingSkipped: boolean
|
||||
onTransitionEnd?: (ref?: HTMLDivElement) => void
|
||||
onCompleted: (reply?: string) => void
|
||||
onCompleted: (reply?: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const HostBubble = (props: Props) => (
|
||||
|
||||
12
packages/embeds/js/src/components/icons/MicrophoneIcon.tsx
Normal file
12
packages/embeds/js/src/components/icons/MicrophoneIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const MicrophoneIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
viewBox="0 0 384 512"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M192 0C139 0 96 43 96 96l0 160c0 53 43 96 96 96s96-43 96-96l0-160c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 89.1 66.2 162.7 152 174.4l0 33.6-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0 72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-48 0 0-33.6c85.8-11.7 152-85.3 152-174.4l0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 70.7-57.3 128-128 128s-128-57.3-128-128l0-40z" />
|
||||
</svg>
|
||||
)
|
||||
Reference in New Issue
Block a user