⚡ (chat) Improve chat API compatibility with preview mode
This commit is contained in:
@ -1,83 +1,128 @@
|
||||
import { LiteBadge } from './LiteBadge'
|
||||
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
getViewerUrl,
|
||||
injectCustomHeadCode,
|
||||
isDefined,
|
||||
isNotEmpty,
|
||||
} from 'utils'
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { getViewerUrl, injectCustomHeadCode, isEmpty, isNotEmpty } from 'utils'
|
||||
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import css from '../assets/index.css'
|
||||
import { InitialChatReply, StartParams } from 'models'
|
||||
import { StartParams } from 'models'
|
||||
import { setIsMobile } from '@/utils/isMobileSignal'
|
||||
import { BotContext } from '@/types'
|
||||
import { BotContext, InitialChatReply } from '@/types'
|
||||
import { ErrorMessage } from './ErrorMessage'
|
||||
import {
|
||||
getExistingResultIdFromSession,
|
||||
setResultInSession,
|
||||
} from '@/utils/sessionStorage'
|
||||
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
|
||||
|
||||
export type BotProps = StartParams & {
|
||||
initialChatReply?: InitialChatReply
|
||||
apiHost?: string
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onInit?: () => void
|
||||
onEnd?: () => void
|
||||
}
|
||||
|
||||
export const Bot = (props: BotProps) => {
|
||||
const [initialChatReply, setInitialChatReply] = createSignal<
|
||||
InitialChatReply | undefined
|
||||
>(props.initialChatReply)
|
||||
>()
|
||||
const [error, setError] = createSignal<Error | undefined>(
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
isEmpty(isEmpty(props.apiHost) ? getViewerUrl() : props.apiHost)
|
||||
? new Error('process.env.NEXT_PUBLIC_VIEWER_URL is missing in env')
|
||||
: undefined
|
||||
)
|
||||
|
||||
const initializeBot = async () => {
|
||||
const urlParams = new URLSearchParams(location.search)
|
||||
props.onInit?.()
|
||||
const prefilledVariables: { [key: string]: string } = {}
|
||||
urlParams.forEach((value, key) => {
|
||||
prefilledVariables[key] = value
|
||||
})
|
||||
const { data, error } = await getInitialChatReplyQuery({
|
||||
typebot: props.typebot,
|
||||
apiHost: props.apiHost,
|
||||
isPreview: props.isPreview ?? false,
|
||||
resultId: isNotEmpty(props.resultId)
|
||||
? props.resultId
|
||||
: getExistingResultIdFromSession(),
|
||||
startGroupId: props.startGroupId,
|
||||
prefilledVariables: {
|
||||
...prefilledVariables,
|
||||
...props.prefilledVariables,
|
||||
},
|
||||
})
|
||||
if (error && 'code' in error && typeof error.code === 'string') {
|
||||
if (['BAD_REQUEST', 'FORBIDDEN'].includes(error.code))
|
||||
setError(new Error('This bot is now closed.'))
|
||||
if (error.code === 'NOT_FOUND') setError(new Error('Typebot not found.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!data) return setError(new Error("Couldn't initiate the chat"))
|
||||
|
||||
if (data.resultId) setResultInSession(data.resultId)
|
||||
setInitialChatReply(data)
|
||||
|
||||
if (data.input?.id && props.onNewInputBlock)
|
||||
props.onNewInputBlock({
|
||||
id: data.input.id,
|
||||
groupId: data.input.groupId,
|
||||
})
|
||||
const customHeadCode = data.typebot.settings.metadata.customHeadCode
|
||||
if (customHeadCode) injectCustomHeadCode(customHeadCode)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!props.typebotId) return
|
||||
const initialChatReplyValue = initialChatReply()
|
||||
if (isDefined(initialChatReplyValue)) {
|
||||
const customHeadCode =
|
||||
initialChatReplyValue.typebot.settings.metadata.customHeadCode
|
||||
if (customHeadCode) injectCustomHeadCode(customHeadCode)
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(location.search)
|
||||
const prefilledVariables: { [key: string]: string } = {}
|
||||
urlParams.forEach((value, key) => {
|
||||
prefilledVariables[key] = value
|
||||
})
|
||||
getInitialChatReplyQuery({
|
||||
typebotId: props.typebotId,
|
||||
apiHost: props.apiHost,
|
||||
isPreview: props.isPreview ?? false,
|
||||
resultId: props.resultId,
|
||||
prefilledVariables: {
|
||||
...prefilledVariables,
|
||||
...props.prefilledVariables,
|
||||
},
|
||||
}).then((initialChatReply) => {
|
||||
setInitialChatReply(initialChatReply)
|
||||
})
|
||||
}
|
||||
initializeBot().then()
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
|
||||
fallback={() => (
|
||||
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<style>{css}</style>
|
||||
<Show when={error()} keyed>
|
||||
{(error) => <ErrorMessage error={error} />}
|
||||
</Show>
|
||||
<Show when={initialChatReply()} keyed>
|
||||
{(initialChatReply) => (
|
||||
<BotContent
|
||||
initialChatReply={initialChatReply}
|
||||
initialChatReply={{
|
||||
...initialChatReply,
|
||||
typebot: {
|
||||
...initialChatReply.typebot,
|
||||
settings:
|
||||
typeof props.typebot === 'string'
|
||||
? initialChatReply.typebot?.settings
|
||||
: props.typebot?.settings,
|
||||
theme:
|
||||
typeof props.typebot === 'string'
|
||||
? initialChatReply.typebot?.theme
|
||||
: props.typebot?.theme,
|
||||
},
|
||||
}}
|
||||
context={{
|
||||
apiHost: props.apiHost,
|
||||
isPreview: props.isPreview ?? false,
|
||||
typebotId: props.typebotId as string,
|
||||
typebotId: initialChatReply.typebot.id,
|
||||
resultId: initialChatReply.resultId,
|
||||
}}
|
||||
onNewInputBlock={props.onNewInputBlock}
|
||||
onAnswer={props.onAnswer}
|
||||
onEnd={props.onEnd}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type BotContentProps = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onEnd?: () => void
|
||||
}
|
||||
|
||||
const BotContent = (props: BotContentProps) => {
|
||||
@ -98,38 +143,39 @@ const BotContent = (props: BotContentProps) => {
|
||||
|
||||
onMount(() => {
|
||||
injectCustomFont()
|
||||
if (botContainer) {
|
||||
resizeObserver.observe(botContainer)
|
||||
}
|
||||
if (!botContainer) return
|
||||
resizeObserver.observe(botContainer)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!botContainer) return
|
||||
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (botContainer) {
|
||||
resizeObserver.unobserve(botContainer)
|
||||
}
|
||||
if (!botContainer) return
|
||||
resizeObserver.unobserve(botContainer)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{css}</style>
|
||||
<div
|
||||
ref={botContainer}
|
||||
class="flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
|
||||
>
|
||||
<div class="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
context={props.context}
|
||||
initialChatReply={props.initialChatReply}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
props.initialChatReply.typebot.settings.general.isBrandingEnabled
|
||||
}
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
</Show>
|
||||
<div
|
||||
ref={botContainer}
|
||||
class="relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
|
||||
>
|
||||
<div class="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
context={props.context}
|
||||
initialChatReply={props.initialChatReply}
|
||||
onNewInputBlock={props.onNewInputBlock}
|
||||
onAnswer={props.onAnswer}
|
||||
onEnd={props.onEnd}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<Show
|
||||
when={props.initialChatReply.typebot.settings.general.isBrandingEnabled}
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -10,7 +10,9 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
|
||||
settings: Settings
|
||||
inputIndex: number
|
||||
context: BotContext
|
||||
onScrollToBottom: () => void
|
||||
onSubmit: (input: string) => void
|
||||
onEnd?: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
@ -23,6 +25,9 @@ export const ChatChunk = (props: Props) => {
|
||||
? displayedMessageIndex()
|
||||
: displayedMessageIndex() + 1
|
||||
)
|
||||
props.onScrollToBottom()
|
||||
if (!props.input && displayedMessageIndex() === props.messages.length)
|
||||
return props.onEnd?.()
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -1,31 +1,73 @@
|
||||
import { ChatReply, InitialChatReply } from 'models'
|
||||
import { ChatReply, Theme } from 'models'
|
||||
import { createSignal, For } from 'solid-js'
|
||||
import { sendMessageQuery } from '@/queries/sendMessageQuery'
|
||||
import { ChatChunk } from './ChatChunk'
|
||||
import { BotContext } from '@/types'
|
||||
import { BotContext, InitialChatReply } from '@/types'
|
||||
import { executeIntegrations } from '@/utils/executeIntegrations'
|
||||
import { executeLogic } from '@/utils/executeLogic'
|
||||
|
||||
const parseDynamicTheme = (
|
||||
theme: Theme,
|
||||
dynamicTheme: ChatReply['dynamicTheme']
|
||||
): Theme => ({
|
||||
...theme,
|
||||
chat: {
|
||||
...theme.chat,
|
||||
hostAvatar: theme.chat.hostAvatar
|
||||
? {
|
||||
...theme.chat.hostAvatar,
|
||||
url: dynamicTheme?.hostAvatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
guestAvatar: theme.chat.guestAvatar
|
||||
? {
|
||||
...theme.chat.guestAvatar,
|
||||
url: dynamicTheme?.guestAvatarUrl,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onEnd?: () => void
|
||||
}
|
||||
|
||||
export const ConversationContainer = (props: Props) => {
|
||||
let bottomSpacer: HTMLDivElement | undefined
|
||||
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
|
||||
{
|
||||
input: props.initialChatReply.input,
|
||||
messages: props.initialChatReply.messages,
|
||||
},
|
||||
])
|
||||
const [theme, setTheme] = createSignal(
|
||||
parseDynamicTheme(
|
||||
props.initialChatReply.typebot.theme,
|
||||
props.initialChatReply.dynamicTheme
|
||||
)
|
||||
)
|
||||
|
||||
const sendMessage = async (message: string) => {
|
||||
const currentBlockId = chatChunks().at(-1)?.input?.id
|
||||
if (currentBlockId && props.onAnswer)
|
||||
props.onAnswer({ message, blockId: currentBlockId })
|
||||
const data = await sendMessageQuery({
|
||||
apiHost: props.context.apiHost,
|
||||
sessionId: props.initialChatReply.sessionId,
|
||||
message,
|
||||
})
|
||||
if (!data) return
|
||||
if (data.dynamicTheme) applyDynamicTheme(data.dynamicTheme)
|
||||
if (data.input?.id && props.onNewInputBlock) {
|
||||
props.onNewInputBlock({
|
||||
id: data.input.id,
|
||||
groupId: data.input.groupId,
|
||||
})
|
||||
}
|
||||
if (data.integrations) {
|
||||
executeIntegrations(data.integrations)
|
||||
}
|
||||
@ -41,6 +83,17 @@ export const ConversationContainer = (props: Props) => {
|
||||
])
|
||||
}
|
||||
|
||||
const applyDynamicTheme = (dynamicTheme: ChatReply['dynamicTheme']) => {
|
||||
setTheme((theme) => parseDynamicTheme(theme, dynamicTheme))
|
||||
}
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
if (!bottomSpacer) return
|
||||
setTimeout(() => {
|
||||
bottomSpacer?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view">
|
||||
<For each={chatChunks()}>
|
||||
@ -49,16 +102,26 @@ export const ConversationContainer = (props: Props) => {
|
||||
inputIndex={index()}
|
||||
messages={chatChunk.messages}
|
||||
input={chatChunk.input}
|
||||
theme={props.initialChatReply.typebot.theme}
|
||||
theme={theme()}
|
||||
settings={props.initialChatReply.typebot.settings}
|
||||
onSubmit={sendMessage}
|
||||
onScrollToBottom={autoScrollToBottom}
|
||||
onSkip={() => {
|
||||
// TODO: implement skip
|
||||
}}
|
||||
onEnd={props.onEnd}
|
||||
context={props.context}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BottomSpacer ref={bottomSpacer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type BottomSpacerProps = {
|
||||
ref: HTMLDivElement | undefined
|
||||
}
|
||||
const BottomSpacer = (props: BottomSpacerProps) => {
|
||||
return <div ref={props.ref} class="w-full h-32" />
|
||||
}
|
||||
|
10
packages/js/src/components/ErrorMessage.tsx
Normal file
10
packages/js/src/components/ErrorMessage.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
type Props = {
|
||||
error: Error
|
||||
}
|
||||
export const ErrorMessage = (props: Props) => {
|
||||
return (
|
||||
<div class="h-full flex justify-center items-center flex-col">
|
||||
<p class="text-5xl">{props.error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -44,7 +44,7 @@ export const InputChatBlock = (props: Props) => {
|
||||
|
||||
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
props.onSubmit(value)
|
||||
props.onSubmit(value ?? label)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -96,37 +96,40 @@ const Input = (props: {
|
||||
<Switch>
|
||||
<Match when={props.block.type === InputBlockType.TEXT}>
|
||||
<TextInput
|
||||
block={props.block as TextInputBlock & { prefilledValue?: string }}
|
||||
block={props.block as TextInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.NUMBER}>
|
||||
<NumberInput
|
||||
block={props.block as NumberInputBlock & { prefilledValue?: string }}
|
||||
block={props.block as NumberInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.EMAIL}>
|
||||
<EmailInput
|
||||
block={props.block as EmailInputBlock & { prefilledValue?: string }}
|
||||
block={props.block as EmailInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.URL}>
|
||||
<UrlInput
|
||||
block={props.block as UrlInputBlock & { prefilledValue?: string }}
|
||||
block={props.block as UrlInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.PHONE}>
|
||||
<PhoneInput
|
||||
block={
|
||||
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
|
||||
}
|
||||
block={props.block as PhoneNumberInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
@ -146,7 +149,8 @@ const Input = (props: {
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.RATING}>
|
||||
<RatingForm
|
||||
block={props.block as RatingInputBlock & { prefilledValue?: string }}
|
||||
block={props.block as RatingInputBlock}
|
||||
defaultValue={props.block.prefilledValue}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
|
@ -38,7 +38,7 @@ export const LiteBadge = (props: Props) => {
|
||||
href={'https://www.typebot.io/?utm_source=litebadge'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
|
||||
class="absolute py-1 px-2 bg-white z-50 rounded shadow-md lite-badge text-gray-900"
|
||||
style={{ bottom: '20px' }}
|
||||
id="lite-badge"
|
||||
>
|
||||
|
@ -1,24 +1,25 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Show } from 'solid-js'
|
||||
import { isNotEmpty } from 'utils'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const Avatar = (props: { avatarSrc?: string }) => (
|
||||
<Show when={props.avatarSrc !== ''}>
|
||||
<Show when={props.avatarSrc} keyed fallback={() => <DefaultAvatar />}>
|
||||
{(currentAvatarSrc) => (
|
||||
<figure
|
||||
class={
|
||||
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
|
||||
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={currentAvatarSrc}
|
||||
alt="Bot avatar"
|
||||
class="rounded-full object-cover w-full h-full"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={isNotEmpty(props.avatarSrc)}
|
||||
keyed
|
||||
fallback={() => <DefaultAvatar />}
|
||||
>
|
||||
<figure
|
||||
class={
|
||||
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
|
||||
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.avatarSrc}
|
||||
alt="Bot avatar"
|
||||
class="rounded-full object-cover w-full h-full"
|
||||
/>
|
||||
</figure>
|
||||
</Show>
|
||||
)
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
@ -9,13 +8,13 @@ type ShortTextInputProps = {
|
||||
|
||||
export const ShortTextInput = (props: ShortTextInputProps) => {
|
||||
const [local, others] = splitProps(props, ['ref', 'onInput'])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={local.ref}
|
||||
ref={props.ref}
|
||||
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
type="text"
|
||||
style={{ 'font-size': '16px' }}
|
||||
autofocus={!isMobile()}
|
||||
onInput={(e) => local.onInput(e.currentTarget.value)}
|
||||
{...others}
|
||||
/>
|
||||
|
Reference in New Issue
Block a user