♻️ Re-organize workspace folders

This commit is contained in:
Baptiste Arnaud
2023-03-15 08:35:16 +01:00
parent 25c367901f
commit cbc8194f19
987 changed files with 2716 additions and 2770 deletions

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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" />
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -0,0 +1 @@
export * from './ConversationContainer'

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)

View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)

View 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>
)
}

View 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>
)

View 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>
)

View 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>
)

View 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>
)
}

View File

@@ -0,0 +1 @@
export * from './SendIcon'

View File

@@ -0,0 +1,3 @@
export * from './SendButton'
export * from './TypingBubble'
export * from './inputs'

View 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}
/>
)
}

View 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}
/>
)
}

View File

@@ -0,0 +1,2 @@
export * from './ShortTextInput'
export * from './Textarea'