133
packages/js/src/components/Bot.tsx
Normal file
133
packages/js/src/components/Bot.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { LiteBadge } from './LiteBadge'
|
||||
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { getViewerUrl, isDefined, isNotEmpty } from 'utils'
|
||||
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
|
||||
import { ConversationContainer } from './ConversationContainer'
|
||||
import css from '../assets/index.css'
|
||||
import { InitialChatReply, StartParams } from 'models'
|
||||
import { setIsMobile } from '@/utils/isMobileSignal'
|
||||
import { BotContext } from '@/types'
|
||||
import { injectHeadCode } from '@/utils/injectHeadCode'
|
||||
|
||||
export type BotProps = StartParams & {
|
||||
initialChatReply?: InitialChatReply
|
||||
apiHost?: string
|
||||
}
|
||||
|
||||
export const Bot = (props: BotProps) => {
|
||||
const [initialChatReply, setInitialChatReply] = createSignal<
|
||||
InitialChatReply | undefined
|
||||
>(props.initialChatReply)
|
||||
|
||||
onMount(() => {
|
||||
if (!props.typebotId) return
|
||||
const initialChatReplyValue = initialChatReply()
|
||||
if (isDefined(initialChatReplyValue)) {
|
||||
const customHeadCode =
|
||||
initialChatReplyValue.typebot.settings.metadata.customHeadCode
|
||||
if (customHeadCode) {
|
||||
injectHeadCode(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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
|
||||
fallback={() => (
|
||||
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
|
||||
)}
|
||||
>
|
||||
<Show when={initialChatReply()} keyed>
|
||||
{(initialChatReply) => (
|
||||
<BotContent
|
||||
initialChatReply={initialChatReply}
|
||||
context={{
|
||||
apiHost: props.apiHost,
|
||||
isPreview: props.isPreview ?? false,
|
||||
typebotId: props.typebotId as string,
|
||||
resultId: initialChatReply.resultId,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
type BotContentProps = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
}
|
||||
|
||||
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'
|
||||
}:wght@300;400;600&display=swap')`
|
||||
font.rel = 'stylesheet'
|
||||
document.head.appendChild(font)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
injectCustomFont()
|
||||
if (botContainer) {
|
||||
resizeObserver.observe(botContainer)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (botContainer) {
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { Avatar } from '@/components/avatars/Avatar'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
|
||||
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 w-10 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 avatarSrc={props.hostAvatarSrc} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
import { BotContext } from '@/types'
|
||||
import { ChatReply, Settings, Theme } from 'models'
|
||||
import { createSignal, For, 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
|
||||
onSubmit: (input: string) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const ChatChunk = (props: Props) => {
|
||||
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
|
||||
|
||||
const displayNextMessage = () => {
|
||||
setDisplayedMessageIndex(
|
||||
displayedMessageIndex() === props.messages.length
|
||||
? displayedMessageIndex()
|
||||
: displayedMessageIndex() + 1
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
? '50px'
|
||||
: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<For each={props.messages.slice(0, displayedMessageIndex() + 1)}>
|
||||
{(message) => (
|
||||
<HostBubble
|
||||
message={message}
|
||||
onTransitionEnd={displayNextMessage}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
{props.input && displayedMessageIndex() === props.messages.length && (
|
||||
<InputChatBlock
|
||||
block={props.input}
|
||||
inputIndex={props.inputIndex}
|
||||
onSubmit={props.onSubmit}
|
||||
onSkip={props.onSkip}
|
||||
guestAvatar={props.theme.chat.guestAvatar}
|
||||
context={props.context}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import { ChatReply, InitialChatReply } from 'models'
|
||||
import { createSignal, For } from 'solid-js'
|
||||
import { sendMessageQuery } from '@/queries/sendMessageQuery'
|
||||
import { ChatChunk } from './ChatChunk'
|
||||
import { BotContext } from '@/types'
|
||||
import { executeIntegrations } from '@/utils/executeIntegrations'
|
||||
import { executeLogic } from '@/utils/executeLogic'
|
||||
|
||||
type Props = {
|
||||
initialChatReply: InitialChatReply
|
||||
context: BotContext
|
||||
}
|
||||
|
||||
export const ConversationContainer = (props: Props) => {
|
||||
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
|
||||
{
|
||||
input: props.initialChatReply.input,
|
||||
messages: props.initialChatReply.messages,
|
||||
},
|
||||
])
|
||||
|
||||
const sendMessage = async (message: string) => {
|
||||
const data = await sendMessageQuery({
|
||||
apiHost: props.context.apiHost,
|
||||
sessionId: props.initialChatReply.sessionId,
|
||||
message,
|
||||
})
|
||||
if (!data) return
|
||||
if (data.integrations) {
|
||||
executeIntegrations(data.integrations)
|
||||
}
|
||||
if (data.logic) {
|
||||
await executeLogic(data.logic)
|
||||
}
|
||||
setChatChunks((displayedChunks) => [
|
||||
...displayedChunks,
|
||||
{
|
||||
input: data.input,
|
||||
messages: data.messages,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="overflow-y-scroll w-full lg:w-3/4 min-h-full rounded lg:px-5 px-3 pt-10 relative scrollable-container typebot-chat-view">
|
||||
<For each={chatChunks()}>
|
||||
{(chatChunk, index) => (
|
||||
<ChatChunk
|
||||
inputIndex={index()}
|
||||
messages={chatChunk.messages}
|
||||
input={chatChunk.input}
|
||||
theme={props.initialChatReply.typebot.theme}
|
||||
settings={props.initialChatReply.typebot.settings}
|
||||
onSubmit={sendMessage}
|
||||
onSkip={() => {
|
||||
// TODO: implement skip
|
||||
}}
|
||||
context={props.context}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './ConversationContainer'
|
182
packages/js/src/components/InputChatBlock.tsx
Normal file
182
packages/js/src/components/InputChatBlock.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import {
|
||||
ChatReply,
|
||||
ChoiceInputBlock,
|
||||
DateInputOptions,
|
||||
EmailInputBlock,
|
||||
FileInputBlock,
|
||||
InputBlockType,
|
||||
NumberInputBlock,
|
||||
PaymentInputOptions,
|
||||
PhoneNumberInputBlock,
|
||||
RatingInputBlock,
|
||||
RuntimeOptions,
|
||||
TextInputBlock,
|
||||
Theme,
|
||||
UrlInputBlock,
|
||||
} from 'models'
|
||||
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 'utils'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { PaymentForm } from '@/features/blocks/inputs/payment'
|
||||
|
||||
type Props = {
|
||||
block: NonNullable<ChatReply['input']>
|
||||
guestAvatar?: Theme['chat']['guestAvatar']
|
||||
inputIndex: number
|
||||
context: BotContext
|
||||
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)
|
||||
}
|
||||
|
||||
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.guestAvatar?.isEnabled && (
|
||||
<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}
|
||||
onSubmit={handleSubmit}
|
||||
onSkip={() => props.onSkip()}
|
||||
hasGuestAvatar={props.guestAvatar?.isEnabled ?? false}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
const Input = (props: {
|
||||
context: BotContext
|
||||
block: NonNullable<ChatReply['input']>
|
||||
inputIndex: number
|
||||
hasGuestAvatar: boolean
|
||||
onSubmit: (answer: InputSubmitContent) => void
|
||||
onSkip: () => void
|
||||
}) => {
|
||||
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.block.type === InputBlockType.TEXT}>
|
||||
<TextInput
|
||||
block={props.block as TextInputBlock & { prefilledValue?: string }}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.NUMBER}>
|
||||
<NumberInput
|
||||
block={props.block as NumberInputBlock & { prefilledValue?: string }}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.EMAIL}>
|
||||
<EmailInput
|
||||
block={props.block as EmailInputBlock & { prefilledValue?: string }}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.URL}>
|
||||
<UrlInput
|
||||
block={props.block as UrlInputBlock & { prefilledValue?: string }}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.PHONE}>
|
||||
<PhoneInput
|
||||
block={
|
||||
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
hasGuestAvatar={props.hasGuestAvatar}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.DATE}>
|
||||
<DateForm
|
||||
options={props.block.options as DateInputOptions}
|
||||
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 & { prefilledValue?: string }}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.block.type === InputBlockType.FILE}>
|
||||
<FileUploadForm
|
||||
context={props.context}
|
||||
block={props.block as FileInputBlock}
|
||||
onSubmit={onSubmit}
|
||||
// eslint-disable-next-line solid/reactivity
|
||||
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={() =>
|
||||
props.onSubmit({
|
||||
value:
|
||||
(props.block.options as PaymentInputOptions).labels.success ??
|
||||
'Success',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
48
packages/js/src/components/LiteBadge.tsx
Normal file
48
packages/js/src/components/LiteBadge.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { onCleanup, onMount } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
botContainer: HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
export const LiteBadge = (props: Props) => {
|
||||
let liteBadge: HTMLAnchorElement | undefined
|
||||
let observer: MutationObserver | undefined
|
||||
|
||||
onMount(() => {
|
||||
if (!document || !props.botContainer) return
|
||||
observer = new MutationObserver(function (mutations_list) {
|
||||
mutations_list.forEach(function (mutation) {
|
||||
mutation.removedNodes.forEach(function (removed_node) {
|
||||
if (
|
||||
'id' in removed_node &&
|
||||
liteBadge &&
|
||||
removed_node.id == 'lite-badge'
|
||||
)
|
||||
props.botContainer?.append(liteBadge)
|
||||
})
|
||||
})
|
||||
})
|
||||
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="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
|
||||
style={{ bottom: '20px' }}
|
||||
id="lite-badge"
|
||||
>
|
||||
Made with <span class="text-blue-500">Typebot</span>.
|
||||
</a>
|
||||
)
|
||||
}
|
59
packages/js/src/components/SendButton.tsx
Normal file
59
packages/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>
|
||||
)
|
24
packages/js/src/components/avatars/Avatar.tsx
Normal file
24
packages/js/src/components/avatars/Avatar.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Show } from 'solid-js'
|
||||
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>
|
||||
)
|
54
packages/js/src/components/avatars/DefaultAvatar.tsx
Normal file
54
packages/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>
|
||||
)
|
||||
}
|
26
packages/js/src/components/bubbles/GuestBubble.tsx
Normal file
26
packages/js/src/components/bubbles/GuestBubble.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { isDefined } from 'utils'
|
||||
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 cursor-pointer"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
{props.message}
|
||||
</span>
|
||||
<Show when={isDefined(props.avatarSrc)}>
|
||||
<Avatar avatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
61
packages/js/src/components/bubbles/HostBubble.tsx
Normal file
61
packages/js/src/components/bubbles/HostBubble.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
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 {
|
||||
AudioBubbleContent,
|
||||
BubbleBlockType,
|
||||
ChatMessage,
|
||||
EmbedBubbleContent,
|
||||
ImageBubbleContent,
|
||||
TextBubbleContent,
|
||||
VideoBubbleContent,
|
||||
} from 'models'
|
||||
import { Match, Switch } from 'solid-js'
|
||||
|
||||
type Props = {
|
||||
message: ChatMessage
|
||||
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'>}
|
||||
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>
|
||||
)
|
||||
}
|
7
packages/js/src/components/bubbles/TypingBubble.tsx
Normal file
7
packages/js/src/components/bubbles/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>
|
||||
)
|
13
packages/js/src/components/icons/SendIcon.tsx
Normal file
13
packages/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>
|
||||
)
|
1
packages/js/src/components/icons/index.ts
Normal file
1
packages/js/src/components/icons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './SendIcon'
|
23
packages/js/src/components/inputs/ShortTextInput.tsx
Normal file
23
packages/js/src/components/inputs/ShortTextInput.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
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={local.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}
|
||||
/>
|
||||
)
|
||||
}
|
26
packages/js/src/components/inputs/Textarea.tsx
Normal file
26
packages/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/js/src/components/inputs/index.ts
Normal file
2
packages/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