♻️ Re-organize workspace folders
This commit is contained in:
211
packages/embeds/js/src/components/Bot.tsx
Normal file
211
packages/embeds/js/src/components/Bot.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { LiteBadge } from './LiteBadge'
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { injectCustomHeadCode, isNotEmpty } from '@typebot.io/lib'
|
||||
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import { setIsMobile } from '@/utils/isMobileSignal'
|
||||
import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
|
||||
import { ErrorMessage } from './ErrorMessage'
|
||||
import {
|
||||
getExistingResultIdFromSession,
|
||||
setResultInSession,
|
||||
} from '@/utils/sessionStorage'
|
||||
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
|
||||
import immutableCss from '../assets/immutable.css'
|
||||
|
||||
export type BotProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typebot: string | any
|
||||
isPreview?: boolean
|
||||
resultId?: string
|
||||
startGroupId?: string
|
||||
prefilledVariables?: Record<string, unknown>
|
||||
apiHost?: string
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onInit?: () => void
|
||||
onEnd?: () => void
|
||||
onNewLogs?: (logs: OutgoingLog[]) => void
|
||||
}
|
||||
|
||||
export const Bot = (props: BotProps & { class?: string }) => {
|
||||
const [initialChatReply, setInitialChatReply] = createSignal<
|
||||
InitialChatReply | undefined
|
||||
>()
|
||||
const [customCss, setCustomCss] = createSignal('')
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
const [error, setError] = createSignal<Error | undefined>()
|
||||
|
||||
const initializeBot = async () => {
|
||||
setIsInitialized(true)
|
||||
const urlParams = new URLSearchParams(location.search)
|
||||
props.onInit?.()
|
||||
const prefilledVariables: { [key: string]: string } = {}
|
||||
urlParams.forEach((value, key) => {
|
||||
prefilledVariables[key] = value
|
||||
})
|
||||
const typebotIdFromProps =
|
||||
typeof props.typebot === 'string' ? props.typebot : undefined
|
||||
const { data, error } = await getInitialChatReplyQuery({
|
||||
typebot: props.typebot,
|
||||
apiHost: props.apiHost,
|
||||
isPreview: props.isPreview ?? false,
|
||||
resultId: isNotEmpty(props.resultId)
|
||||
? props.resultId
|
||||
: getExistingResultIdFromSession(typebotIdFromProps),
|
||||
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("The bot you're looking for doesn't exist."))
|
||||
return
|
||||
}
|
||||
|
||||
if (!data) return setError(new Error("Error! Couldn't initiate the chat."))
|
||||
|
||||
if (data.resultId && typebotIdFromProps)
|
||||
setResultInSession(typebotIdFromProps, data.resultId)
|
||||
setInitialChatReply(data)
|
||||
setCustomCss(data.typebot.theme.customCss ?? '')
|
||||
|
||||
if (data.input?.id && props.onNewInputBlock)
|
||||
props.onNewInputBlock({
|
||||
id: data.input.id,
|
||||
groupId: data.input.groupId,
|
||||
})
|
||||
if (data.logs) props.onNewLogs?.(data.logs)
|
||||
const customHeadCode = data.typebot.settings.metadata.customHeadCode
|
||||
if (customHeadCode) injectCustomHeadCode(customHeadCode)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.typebot || isInitialized()) return
|
||||
initializeBot().then()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof props.typebot === 'string') return
|
||||
setCustomCss(props.typebot.theme.customCss ?? '')
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
setIsInitialized(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{customCss()}</style>
|
||||
<style>{immutableCss}</style>
|
||||
<Show when={error()} keyed>
|
||||
{(error) => <ErrorMessage error={error} />}
|
||||
</Show>
|
||||
<Show when={initialChatReply()} keyed>
|
||||
{(initialChatReply) => (
|
||||
<BotContent
|
||||
class={props.class}
|
||||
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:
|
||||
typeof props.typebot !== 'string' || (props.isPreview ?? false),
|
||||
typebotId: initialChatReply.typebot.id,
|
||||
resultId: initialChatReply.resultId,
|
||||
}}
|
||||
onNewInputBlock={props.onNewInputBlock}
|
||||
onNewLogs={props.onNewLogs}
|
||||
onAnswer={props.onAnswer}
|
||||
onEnd={props.onEnd}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type BotContentProps = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
class?: string
|
||||
onNewInputBlock?: (block: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onEnd?: () => void
|
||||
onNewLogs?: (logs: OutgoingLog[]) => void
|
||||
}
|
||||
|
||||
const BotContent = (props: BotContentProps) => {
|
||||
let botContainer: HTMLDivElement | undefined
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
return setIsMobile(entries[0].target.clientWidth < 400)
|
||||
})
|
||||
|
||||
const injectCustomFont = () => {
|
||||
const font = document.createElement('link')
|
||||
font.href = `https://fonts.googleapis.com/css2?family=${
|
||||
props.initialChatReply.typebot?.theme?.general?.font ?? 'Open Sans'
|
||||
}:ital,wght@0,300;0,400;0,600;1,300;1,400;1,600&display=swap');')`
|
||||
font.rel = 'stylesheet'
|
||||
document.head.appendChild(font)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
injectCustomFont()
|
||||
if (!botContainer) return
|
||||
resizeObserver.observe(botContainer)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!botContainer) return
|
||||
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (!botContainer) return
|
||||
resizeObserver.unobserve(botContainer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={botContainer}
|
||||
class={
|
||||
'relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container ' +
|
||||
props.class
|
||||
}
|
||||
>
|
||||
<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}
|
||||
onNewLogs={props.onNewLogs}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={props.initialChatReply.typebot.settings.general.isBrandingEnabled}
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
|
||||
type Props = { hostAvatarSrc?: string }
|
||||
|
||||
export const AvatarSideContainer = (props: Props) => {
|
||||
let avatarContainer: HTMLDivElement | undefined
|
||||
const [top, setTop] = createSignal<number>(0)
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) =>
|
||||
setTop(entries[0].target.clientHeight - (isMobile() ? 24 : 40))
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
if (avatarContainer) {
|
||||
resizeObserver.observe(avatarContainer)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (avatarContainer) {
|
||||
resizeObserver.unobserve(avatarContainer)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={avatarContainer}
|
||||
class={
|
||||
'flex mr-2 mb-2 flex-shrink-0 items-center relative typebot-avatar-container ' +
|
||||
(isMobile() ? 'w-6' : 'w-10')
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={
|
||||
'absolute mb-2 flex items-center top-0 ' +
|
||||
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
|
||||
}
|
||||
style={{
|
||||
top: `${top()}px`,
|
||||
transition: 'top 350ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Avatar initialAvatarSrc={props.hostAvatarSrc} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BotContext } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { ChatReply, Settings, Theme } from '@typebot.io/schemas'
|
||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { HostBubble } from '../bubbles/HostBubble'
|
||||
import { InputChatBlock } from '../InputChatBlock'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
|
||||
type Props = Pick<ChatReply, 'messages' | 'input'> & {
|
||||
theme: Theme
|
||||
settings: Settings
|
||||
inputIndex: number
|
||||
context: BotContext
|
||||
isLoadingBubbleDisplayed: boolean
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
onScrollToBottom: () => void
|
||||
onSubmit: (input: string) => void
|
||||
onSkip: () => void
|
||||
onAllBubblesDisplayed: () => void
|
||||
}
|
||||
|
||||
export const ChatChunk = (props: Props) => {
|
||||
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
|
||||
|
||||
onMount(() => {
|
||||
if (props.messages.length === 0) {
|
||||
props.onAllBubblesDisplayed()
|
||||
}
|
||||
props.onScrollToBottom()
|
||||
})
|
||||
|
||||
const displayNextMessage = async () => {
|
||||
const lastBubbleBlockId = props.messages[displayedMessageIndex()].id
|
||||
await props.onNewBubbleDisplayed(lastBubbleBlockId)
|
||||
setDisplayedMessageIndex(
|
||||
displayedMessageIndex() === props.messages.length
|
||||
? displayedMessageIndex()
|
||||
: displayedMessageIndex() + 1
|
||||
)
|
||||
props.onScrollToBottom()
|
||||
if (displayedMessageIndex() === props.messages.length) {
|
||||
props.onAllBubblesDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-col w-full min-w-0">
|
||||
<div class="flex">
|
||||
<Show
|
||||
when={
|
||||
props.theme.chat.hostAvatar?.isEnabled &&
|
||||
props.messages.length > 0
|
||||
}
|
||||
>
|
||||
<AvatarSideContainer
|
||||
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex-1"
|
||||
style={{
|
||||
'margin-right': props.theme.chat.guestAvatar?.isEnabled
|
||||
? isMobile()
|
||||
? '32px'
|
||||
: '48px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
|
||||
{(message) => (
|
||||
<HostBubble
|
||||
message={message}
|
||||
typingEmulation={props.settings.typingEmulation}
|
||||
onTransitionEnd={displayNextMessage}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
{props.input && displayedMessageIndex() === props.messages.length && (
|
||||
<InputChatBlock
|
||||
block={props.input}
|
||||
inputIndex={props.inputIndex}
|
||||
onSubmit={props.onSubmit}
|
||||
onSkip={props.onSkip}
|
||||
hasHostAvatar={props.theme.chat.hostAvatar?.isEnabled ?? false}
|
||||
guestAvatar={props.theme.chat.guestAvatar}
|
||||
context={props.context}
|
||||
isInputPrefillEnabled={
|
||||
props.settings.general.isInputPrefillEnabled ?? true
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ChatReply, Theme } from '@typebot.io/schemas'
|
||||
import { createEffect, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import { sendMessageQuery } from '@/queries/sendMessageQuery'
|
||||
import { ChatChunk } from './ChatChunk'
|
||||
import { BotContext, InitialChatReply, OutgoingLog } from '@/types'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { executeClientSideAction } from '@/utils/executeClientSideActions'
|
||||
import { LoadingChunk } from './LoadingChunk'
|
||||
import { PopupBlockedToast } from './PopupBlockedToast'
|
||||
|
||||
const parseDynamicTheme = (
|
||||
initialTheme: Theme,
|
||||
dynamicTheme: ChatReply['dynamicTheme']
|
||||
): Theme => ({
|
||||
...initialTheme,
|
||||
chat: {
|
||||
...initialTheme.chat,
|
||||
hostAvatar:
|
||||
initialTheme.chat.hostAvatar && dynamicTheme?.hostAvatarUrl
|
||||
? {
|
||||
...initialTheme.chat.hostAvatar,
|
||||
url: dynamicTheme.hostAvatarUrl,
|
||||
}
|
||||
: initialTheme.chat.hostAvatar,
|
||||
guestAvatar:
|
||||
initialTheme.chat.guestAvatar && dynamicTheme?.guestAvatarUrl
|
||||
? {
|
||||
...initialTheme.chat.guestAvatar,
|
||||
url: dynamicTheme?.guestAvatarUrl,
|
||||
}
|
||||
: initialTheme.chat.guestAvatar,
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
|
||||
onAnswer?: (answer: { message: string; blockId: string }) => void
|
||||
onEnd?: () => void
|
||||
onNewLogs?: (logs: OutgoingLog[]) => void
|
||||
}
|
||||
|
||||
export const ConversationContainer = (props: Props) => {
|
||||
let chatContainer: HTMLDivElement | undefined
|
||||
let bottomSpacer: HTMLDivElement | undefined
|
||||
const [chatChunks, setChatChunks] = createSignal<
|
||||
Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
|
||||
>([
|
||||
{
|
||||
input: props.initialChatReply.input,
|
||||
messages: props.initialChatReply.messages,
|
||||
clientSideActions: props.initialChatReply.clientSideActions,
|
||||
},
|
||||
])
|
||||
const [dynamicTheme, setDynamicTheme] = createSignal<
|
||||
ChatReply['dynamicTheme']
|
||||
>(props.initialChatReply.dynamicTheme)
|
||||
const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme)
|
||||
const [isSending, setIsSending] = createSignal(false)
|
||||
const [blockedPopupUrl, setBlockedPopupUrl] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
;(async () => {
|
||||
const initialChunk = chatChunks()[0]
|
||||
if (initialChunk.clientSideActions) {
|
||||
const actionsBeforeFirstBubble = initialChunk.clientSideActions.filter(
|
||||
(action) => isNotDefined(action.lastBubbleBlockId)
|
||||
)
|
||||
for (const action of actionsBeforeFirstBubble) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setTheme(
|
||||
parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme())
|
||||
)
|
||||
})
|
||||
|
||||
const sendMessage = async (message: string | undefined) => {
|
||||
const currentBlockId = [...chatChunks()].pop()?.input?.id
|
||||
if (currentBlockId && props.onAnswer && message)
|
||||
props.onAnswer({ message, blockId: currentBlockId })
|
||||
const longRequest = setTimeout(() => {
|
||||
setIsSending(true)
|
||||
}, 1000)
|
||||
const data = await sendMessageQuery({
|
||||
apiHost: props.context.apiHost,
|
||||
sessionId: props.initialChatReply.sessionId,
|
||||
message,
|
||||
})
|
||||
clearTimeout(longRequest)
|
||||
setIsSending(false)
|
||||
if (!data) return
|
||||
if (data.logs) props.onNewLogs?.(data.logs)
|
||||
if (data.dynamicTheme) setDynamicTheme(data.dynamicTheme)
|
||||
if (data.input?.id && props.onNewInputBlock) {
|
||||
props.onNewInputBlock({
|
||||
id: data.input.id,
|
||||
groupId: data.input.groupId,
|
||||
})
|
||||
}
|
||||
if (data.clientSideActions) {
|
||||
const actionsBeforeFirstBubble = data.clientSideActions.filter((action) =>
|
||||
isNotDefined(action.lastBubbleBlockId)
|
||||
)
|
||||
for (const action of actionsBeforeFirstBubble) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
setChatChunks((displayedChunks) => [
|
||||
...displayedChunks,
|
||||
{
|
||||
input: data.input,
|
||||
messages: data.messages,
|
||||
clientSideActions: data.clientSideActions,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
if (!bottomSpacer) return
|
||||
setTimeout(() => {
|
||||
chatContainer?.scrollTo(0, chatContainer.scrollHeight)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleAllBubblesDisplayed = async () => {
|
||||
const lastChunk = [...chatChunks()].pop()
|
||||
if (!lastChunk) return
|
||||
if (isNotDefined(lastChunk.input)) {
|
||||
props.onEnd?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewBubbleDisplayed = async (blockId: string) => {
|
||||
const lastChunk = [...chatChunks()].pop()
|
||||
if (!lastChunk) return
|
||||
if (lastChunk.clientSideActions) {
|
||||
const actionsToExecute = lastChunk.clientSideActions.filter(
|
||||
(action) => action.lastBubbleBlockId === blockId
|
||||
)
|
||||
for (const action of actionsToExecute) {
|
||||
const response = await executeClientSideAction(action)
|
||||
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => sendMessage(undefined)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatContainer}
|
||||
class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth"
|
||||
>
|
||||
<For each={chatChunks()}>
|
||||
{(chatChunk, index) => (
|
||||
<ChatChunk
|
||||
inputIndex={index()}
|
||||
messages={chatChunk.messages}
|
||||
input={chatChunk.input}
|
||||
theme={theme()}
|
||||
settings={props.initialChatReply.typebot.settings}
|
||||
isLoadingBubbleDisplayed={isSending()}
|
||||
onNewBubbleDisplayed={handleNewBubbleDisplayed}
|
||||
onAllBubblesDisplayed={handleAllBubblesDisplayed}
|
||||
onSubmit={sendMessage}
|
||||
onScrollToBottom={autoScrollToBottom}
|
||||
onSkip={handleSkip}
|
||||
context={props.context}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={isSending()}>
|
||||
<LoadingChunk theme={theme()} />
|
||||
</Show>
|
||||
<Show when={blockedPopupUrl()} keyed>
|
||||
{(blockedPopupUrl) => (
|
||||
<div class="flex justify-end">
|
||||
<PopupBlockedToast
|
||||
url={blockedPopupUrl}
|
||||
onLinkClick={() => setBlockedPopupUrl(undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<BottomSpacer ref={bottomSpacer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type BottomSpacerProps = {
|
||||
ref: HTMLDivElement | undefined
|
||||
}
|
||||
const BottomSpacer = (props: BottomSpacerProps) => {
|
||||
return <div ref={props.ref} class="w-full h-32" />
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Theme } from '@typebot.io/schemas'
|
||||
import { Show } from 'solid-js'
|
||||
import { LoadingBubble } from '../bubbles/LoadingBubble'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
|
||||
type Props = {
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export const LoadingChunk = (props: Props) => (
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-col w-full min-w-0">
|
||||
<div class="flex">
|
||||
<Show when={props.theme.chat.hostAvatar?.isEnabled}>
|
||||
<AvatarSideContainer
|
||||
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
|
||||
/>
|
||||
</Show>
|
||||
<LoadingBubble />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
type Props = {
|
||||
url: string
|
||||
onLinkClick: () => void
|
||||
}
|
||||
|
||||
export const PopupBlockedToast = (props: Props) => {
|
||||
return (
|
||||
<div
|
||||
class="w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow flex flex-col gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<span class="mb-1 text-sm font-semibold text-gray-900">
|
||||
Popup blocked
|
||||
</span>
|
||||
<div class="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={props.url}
|
||||
target="_blank"
|
||||
class="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={() => props.onLinkClick()}
|
||||
>
|
||||
Continue in new tab
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ConversationContainer'
|
||||
10
packages/embeds/js/src/components/ErrorMessage.tsx
Normal file
10
packages/embeds/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-2xl text-center">{props.error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
packages/embeds/js/src/components/InputChatBlock.tsx
Normal file
195
packages/embeds/js/src/components/InputChatBlock.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import type {
|
||||
ChatReply,
|
||||
ChoiceInputBlock,
|
||||
DateInputOptions,
|
||||
EmailInputBlock,
|
||||
FileInputBlock,
|
||||
NumberInputBlock,
|
||||
PaymentInputOptions,
|
||||
PhoneNumberInputBlock,
|
||||
RatingInputBlock,
|
||||
RuntimeOptions,
|
||||
TextInputBlock,
|
||||
Theme,
|
||||
UrlInputBlock,
|
||||
} from '@typebot.io/schemas'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
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'
|
||||
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 { RatingForm } from '@/features/blocks/inputs/rating'
|
||||
import { FileUploadForm } from '@/features/blocks/inputs/fileUpload'
|
||||
import { createSignal, Switch, Match } from 'solid-js'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { PaymentForm } from '@/features/blocks/inputs/payment'
|
||||
|
||||
type Props = {
|
||||
block: NonNullable<ChatReply['input']>
|
||||
hasHostAvatar: boolean
|
||||
guestAvatar?: Theme['chat']['guestAvatar']
|
||||
inputIndex: number
|
||||
context: BotContext
|
||||
isInputPrefillEnabled: boolean
|
||||
onSubmit: (answer: string) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const InputChatBlock = (props: Props) => {
|
||||
const [answer, setAnswer] = createSignal<string>()
|
||||
|
||||
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
props.onSubmit(value ?? label)
|
||||
}
|
||||
|
||||
const handleSkip = (label: string) => {
|
||||
setAnswer(label)
|
||||
props.onSkip()
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={answer()} keyed>
|
||||
{(answer) => (
|
||||
<GuestBubble
|
||||
message={answer}
|
||||
showAvatar={props.guestAvatar?.isEnabled ?? false}
|
||||
avatarSrc={props.guestAvatar?.url && props.guestAvatar.url}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={isNotDefined(answer())}>
|
||||
<div class="flex justify-end animate-fade-in">
|
||||
{props.hasHostAvatar && (
|
||||
<div
|
||||
class={
|
||||
'flex mr-2 mb-2 mt-1 flex-shrink-0 items-center ' +
|
||||
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
context={props.context}
|
||||
block={props.block}
|
||||
inputIndex={props.inputIndex}
|
||||
isInputPrefillEnabled={props.isInputPrefillEnabled}
|
||||
onSubmit={handleSubmit}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
const Input = (props: {
|
||||
context: BotContext
|
||||
block: NonNullable<ChatReply['input']>
|
||||
inputIndex: number
|
||||
isInputPrefillEnabled: boolean
|
||||
onSubmit: (answer: InputSubmitContent) => void
|
||||
onSkip: (label: string) => void
|
||||
}) => {
|
||||
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
|
||||
|
||||
const getPrefilledValue = () =>
|
||||
props.isInputPrefillEnabled ? props.block.prefilledValue : undefined
|
||||
|
||||
const submitPaymentSuccess = () =>
|
||||
props.onSubmit({
|
||||
value:
|
||||
(props.block.options as PaymentInputOptions).labels.success ??
|
||||
'Success',
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.block.type === InputBlockType.TEXT}>
|
||||
<TextInput
|
||||
block={props.block as TextInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.NUMBER}>
|
||||
<NumberInput
|
||||
block={props.block as NumberInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.EMAIL}>
|
||||
<EmailInput
|
||||
block={props.block as EmailInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.URL}>
|
||||
<UrlInput
|
||||
block={props.block as UrlInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.PHONE}>
|
||||
<PhoneInput
|
||||
labels={(props.block as PhoneNumberInputBlock).options.labels}
|
||||
defaultCountryCode={
|
||||
(props.block as PhoneNumberInputBlock).options.defaultCountryCode
|
||||
}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.DATE}>
|
||||
<DateForm
|
||||
options={props.block.options as DateInputOptions}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.CHOICE}>
|
||||
<ChoiceForm
|
||||
inputIndex={props.inputIndex}
|
||||
block={props.block as ChoiceInputBlock}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.RATING}>
|
||||
<RatingForm
|
||||
block={props.block as RatingInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.FILE}>
|
||||
<FileUploadForm
|
||||
context={props.context}
|
||||
block={props.block as FileInputBlock}
|
||||
onSubmit={onSubmit}
|
||||
onSkip={props.onSkip}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.PAYMENT}>
|
||||
<PaymentForm
|
||||
context={props.context}
|
||||
options={
|
||||
{
|
||||
...props.block.options,
|
||||
...props.block.runtimeOptions,
|
||||
} as PaymentInputOptions & RuntimeOptions
|
||||
}
|
||||
onSuccess={submitPaymentSuccess}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
53
packages/embeds/js/src/components/LiteBadge.tsx
Normal file
53
packages/embeds/js/src/components/LiteBadge.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { onCleanup, onMount } from 'solid-js'
|
||||
import { TypebotLogo } from './icons/TypebotLogo'
|
||||
|
||||
type Props = {
|
||||
botContainer: HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
export const LiteBadge = (props: Props) => {
|
||||
let liteBadge: HTMLAnchorElement | undefined
|
||||
let observer: MutationObserver | undefined
|
||||
|
||||
const appendBadgeIfNecessary = (mutations: MutationRecord[]) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.removedNodes.forEach((removedNode) => {
|
||||
if (
|
||||
'id' in removedNode &&
|
||||
liteBadge &&
|
||||
removedNode.id == 'lite-badge'
|
||||
) {
|
||||
console.log("Sorry, you can't remove the brand 😅")
|
||||
props.botContainer?.append(liteBadge)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!document || !props.botContainer) return
|
||||
observer = new MutationObserver(appendBadgeIfNecessary)
|
||||
observer.observe(props.botContainer, {
|
||||
subtree: false,
|
||||
childList: true,
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (observer) observer.disconnect()
|
||||
})
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={liteBadge}
|
||||
href={'https://www.typebot.io/?utm_source=litebadge'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="lite-badge"
|
||||
id="lite-badge"
|
||||
>
|
||||
<TypebotLogo />
|
||||
<span>Made with Typebot</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
59
packages/embeds/js/src/components/SendButton.tsx
Normal file
59
packages/embeds/js/src/components/SendButton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Show } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
import { SendIcon } from './icons'
|
||||
|
||||
type SendButtonProps = {
|
||||
isDisabled?: boolean
|
||||
isLoading?: boolean
|
||||
disableIcon?: boolean
|
||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export const SendButton = (props: SendButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={props.isDisabled || props.isLoading}
|
||||
{...props}
|
||||
class={
|
||||
'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.class
|
||||
}
|
||||
>
|
||||
<Show when={!props.isLoading} fallback={<Spinner class="text-white" />}>
|
||||
{isMobile() && !props.disableIcon ? (
|
||||
<SendIcon
|
||||
class={'send-icon flex ' + (props.disableIcon ? 'hidden' : '')}
|
||||
/>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const Spinner = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
class={'animate-spin -ml-1 mr-3 h-5 w-5 ' + props.class}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
data-testid="loading-spinner"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="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"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
7
packages/embeds/js/src/components/TypingBubble.tsx
Normal file
7
packages/embeds/js/src/components/TypingBubble.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const TypingBubble = () => (
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 mr-1 rounded-full bubble1" />
|
||||
<div class="w-2 h-2 mr-1 rounded-full bubble2" />
|
||||
<div class="w-2 h-2 rounded-full bubble3" />
|
||||
</div>
|
||||
)
|
||||
37
packages/embeds/js/src/components/avatars/Avatar.tsx
Normal file
37
packages/embeds/js/src/components/avatars/Avatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { createEffect, createSignal, Show } from 'solid-js'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { DefaultAvatar } from './DefaultAvatar'
|
||||
|
||||
export const Avatar = (props: { initialAvatarSrc?: string }) => {
|
||||
const [avatarSrc, setAvatarSrc] = createSignal(props.initialAvatarSrc)
|
||||
|
||||
createEffect(() => {
|
||||
if (
|
||||
avatarSrc()?.startsWith('{{') &&
|
||||
props.initialAvatarSrc?.startsWith('http')
|
||||
)
|
||||
setAvatarSrc(props.initialAvatarSrc)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isNotEmpty(avatarSrc())}
|
||||
keyed
|
||||
fallback={() => <DefaultAvatar />}
|
||||
>
|
||||
<figure
|
||||
class={
|
||||
'flex justify-center items-center rounded-full text-white relative animate-fade-in flex-shrink-0 ' +
|
||||
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={avatarSrc()}
|
||||
alt="Bot avatar"
|
||||
class="rounded-full object-cover w-full h-full"
|
||||
/>
|
||||
</figure>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
54
packages/embeds/js/src/components/avatars/DefaultAvatar.tsx
Normal file
54
packages/embeds/js/src/components/avatars/DefaultAvatar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
|
||||
export const DefaultAvatar = () => {
|
||||
return (
|
||||
<figure
|
||||
class={
|
||||
'flex justify-center items-center rounded-full text-white relative ' +
|
||||
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 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"
|
||||
class={
|
||||
'absolute top-0 left-0 ' +
|
||||
(isMobile() ? ' w-6 h-6 text-sm' : 'w-full h-full 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"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle
|
||||
cx="76.5"
|
||||
cy="-1.5"
|
||||
r="29"
|
||||
stroke="#FF8E20"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<path
|
||||
d="M-49.8224 22L-15.5 -40.7879L18.8224 22H-49.8224Z"
|
||||
stroke="#F7F8FF"
|
||||
stroke-width="5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
25
packages/embeds/js/src/components/bubbles/GuestBubble.tsx
Normal file
25
packages/embeds/js/src/components/bubbles/GuestBubble.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
showAvatar: boolean
|
||||
avatarSrc?: string
|
||||
}
|
||||
|
||||
export const GuestBubble = (props: Props) => (
|
||||
<div
|
||||
class="flex justify-end mb-2 items-end animate-fade-in"
|
||||
style={{ 'margin-left': '50px' }}
|
||||
>
|
||||
<span
|
||||
class="px-4 py-2 rounded-lg mr-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
{props.message}
|
||||
</span>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
64
packages/embeds/js/src/components/bubbles/HostBubble.tsx
Normal file
64
packages/embeds/js/src/components/bubbles/HostBubble.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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 type {
|
||||
AudioBubbleContent,
|
||||
ChatMessage,
|
||||
EmbedBubbleContent,
|
||||
ImageBubbleContent,
|
||||
TextBubbleContent,
|
||||
TypingEmulation,
|
||||
VideoBubbleContent,
|
||||
} from '@typebot.io/schemas'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/enums'
|
||||
import { Match, Switch } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
message: ChatMessage
|
||||
typingEmulation: TypingEmulation
|
||||
onTransitionEnd: () => void
|
||||
}
|
||||
|
||||
export const HostBubble = (props: Props) => {
|
||||
const onTransitionEnd = () => {
|
||||
props.onTransitionEnd()
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.message.type === BubbleBlockType.TEXT}>
|
||||
<TextBubble
|
||||
content={props.message.content as Omit<TextBubbleContent, 'richText'>}
|
||||
typingEmulation={props.typingEmulation}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.IMAGE}>
|
||||
<ImageBubble
|
||||
url={(props.message.content as ImageBubbleContent).url}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.VIDEO}>
|
||||
<VideoBubble
|
||||
content={props.message.content as VideoBubbleContent}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.EMBED}>
|
||||
<EmbedBubble
|
||||
content={props.message.content as EmbedBubbleContent}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.AUDIO}>
|
||||
<AudioBubble
|
||||
url={(props.message.content as AudioBubbleContent).url}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
25
packages/embeds/js/src/components/bubbles/LoadingBubble.tsx
Normal file
25
packages/embeds/js/src/components/bubbles/LoadingBubble.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
|
||||
export const LoadingBubble = () => (
|
||||
<div class="flex flex-col animate-fade-in">
|
||||
<div class="flex mb-2 w-full items-center">
|
||||
<div class={'flex relative items-start typebot-host-bubble'}>
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 rounded-lg bubble-typing "
|
||||
style={{
|
||||
width: '64px',
|
||||
height: '32px',
|
||||
}}
|
||||
data-testid="host-bubble"
|
||||
>
|
||||
<TypingBubble />
|
||||
</div>
|
||||
<p
|
||||
class={
|
||||
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative opacity-0 h-6 text-ellipsis'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
16
packages/embeds/js/src/components/icons/ChevronDownIcon.tsx
Normal file
16
packages/embeds/js/src/components/icons/ChevronDownIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const ChevronDownIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2px"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)
|
||||
13
packages/embeds/js/src/components/icons/SendIcon.tsx
Normal file
13
packages/embeds/js/src/components/icons/SendIcon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const SendIcon = (props: JSX.SvgSVGAttributes<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>
|
||||
)
|
||||
37
packages/embeds/js/src/components/icons/TypebotLogo.tsx
Normal file
37
packages/embeds/js/src/components/icons/TypebotLogo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
export const TypebotLogo = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 800 800" width={16}>
|
||||
<rect width="800" height="800" rx="80" fill="#0042DA" />
|
||||
<rect
|
||||
x="650"
|
||||
y="293"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(90 650 293)"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M192.735 378.47C216.337 378.47 235.47 359.337 235.47 335.735C235.47 312.133 216.337 293 192.735 293C169.133 293 150 312.133 150 335.735C150 359.337 169.133 378.47 192.735 378.47Z"
|
||||
fill="#FF8E20"
|
||||
/>
|
||||
<rect
|
||||
x="150"
|
||||
y="506.677"
|
||||
width="85.4704"
|
||||
height="384.617"
|
||||
rx="20"
|
||||
transform="rotate(-90 150 506.677)"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M607.265 421.206C583.663 421.206 564.53 440.34 564.53 463.942C564.53 487.544 583.663 506.677 607.265 506.677C630.867 506.677 650 487.544 650 463.942C650 440.34 630.867 421.206 607.265 421.206Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
1
packages/embeds/js/src/components/icons/index.ts
Normal file
1
packages/embeds/js/src/components/icons/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SendIcon'
|
||||
3
packages/embeds/js/src/components/index.ts
Normal file
3
packages/embeds/js/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './SendButton'
|
||||
export * from './TypingBubble'
|
||||
export * from './inputs'
|
||||
22
packages/embeds/js/src/components/inputs/ShortTextInput.tsx
Normal file
22
packages/embeds/js/src/components/inputs/ShortTextInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { splitProps } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
type ShortTextInputProps = {
|
||||
ref: HTMLInputElement | undefined
|
||||
onInput: (value: string) => void
|
||||
} & Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput'>
|
||||
|
||||
export const ShortTextInput = (props: ShortTextInputProps) => {
|
||||
const [local, others] = splitProps(props, ['ref', 'onInput'])
|
||||
|
||||
return (
|
||||
<input
|
||||
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' }}
|
||||
onInput={(e) => local.onInput(e.currentTarget.value)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
26
packages/embeds/js/src/components/inputs/Textarea.tsx
Normal file
26
packages/embeds/js/src/components/inputs/Textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
type TextareaProps = {
|
||||
ref: HTMLTextAreaElement | undefined
|
||||
onInput: (value: string) => void
|
||||
} & Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onInput'>
|
||||
|
||||
export const Textarea = (props: TextareaProps) => {
|
||||
const [local, others] = splitProps(props, ['ref', 'onInput'])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={local.ref}
|
||||
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
rows={6}
|
||||
data-testid="textarea"
|
||||
required
|
||||
style={{ 'font-size': '16px' }}
|
||||
autofocus={!isMobile()}
|
||||
onInput={(e) => local.onInput(e.currentTarget.value)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
2
packages/embeds/js/src/components/inputs/index.ts
Normal file
2
packages/embeds/js/src/components/inputs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ShortTextInput'
|
||||
export * from './Textarea'
|
||||
Reference in New Issue
Block a user