@@ -30,6 +30,9 @@ import {
|
||||
defaultProgressBarPosition,
|
||||
} from '@typebot.io/schemas/features/typebot/theme/constants'
|
||||
import { CorsError } from '@/utils/CorsError'
|
||||
import { Toaster, Toast } from '@ark-ui/solid'
|
||||
import { CloseIcon } from './icons/CloseIcon'
|
||||
import { toaster } from '@/utils/toaster'
|
||||
|
||||
export type BotProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -335,6 +338,17 @@ const BotContent = (props: BotContentProps) => {
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
</Show>
|
||||
<Toaster toaster={toaster}>
|
||||
{(toast) => (
|
||||
<Toast.Root>
|
||||
<Toast.Title>{toast().title}</Toast.Title>
|
||||
<Toast.Description>{toast().description}</Toast.Description>
|
||||
<Toast.CloseTrigger class="absolute right-2 top-2">
|
||||
<CloseIcon class="w-4 h-4" />
|
||||
</Toast.CloseTrigger>
|
||||
</Toast.Root>
|
||||
)}
|
||||
</Toaster>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
formattedMessages,
|
||||
setFormattedMessages,
|
||||
} from '@/utils/formattedMessagesSignal'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
|
||||
import { HTTPError } from 'ky'
|
||||
import { persist } from '@/utils/persist'
|
||||
@@ -147,13 +146,6 @@ export const ConversationContainer = (props: Props) => {
|
||||
const currentInputBlock = [...chatChunks()].pop()?.input
|
||||
if (currentInputBlock?.id && props.onAnswer && message)
|
||||
props.onAnswer({ message, blockId: currentInputBlock.id })
|
||||
if (currentInputBlock?.type === InputBlockType.FILE)
|
||||
props.onNewLogs?.([
|
||||
{
|
||||
description: 'Files are not uploaded in preview mode',
|
||||
status: 'info',
|
||||
},
|
||||
])
|
||||
const longRequest = setTimeout(() => {
|
||||
setIsSending(true)
|
||||
}, 1000)
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
DateInputBlock,
|
||||
} from '@typebot.io/schemas'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { BotContext, InputSubmitContent } from '@/types'
|
||||
import { Answer, BotContext, InputSubmitContent } from '@/types'
|
||||
import { TextInput } from '@/features/blocks/inputs/textInput'
|
||||
import { NumberInput } from '@/features/blocks/inputs/number'
|
||||
import { EmailInput } from '@/features/blocks/inputs/email'
|
||||
@@ -48,24 +48,33 @@ type Props = {
|
||||
isInputPrefillEnabled: boolean
|
||||
hasError: boolean
|
||||
onTransitionEnd: () => void
|
||||
onSubmit: (answer: string) => void
|
||||
onSubmit: (answer: string, attachments?: Answer['attachments']) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const InputChatBlock = (props: Props) => {
|
||||
const [answer, setAnswer] = persist(createSignal<string>(), {
|
||||
const [answer, setAnswer] = persist(createSignal<Answer>(), {
|
||||
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
|
||||
storage: props.context.storage,
|
||||
})
|
||||
const [formattedMessage, setFormattedMessage] = createSignal<string>()
|
||||
|
||||
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
props.onSubmit(value ?? label)
|
||||
const handleSubmit = async ({
|
||||
label,
|
||||
value,
|
||||
attachments,
|
||||
}: InputSubmitContent & Pick<Answer, 'attachments'>) => {
|
||||
setAnswer({
|
||||
text: props.block.type !== InputBlockType.FILE ? label ?? value : '',
|
||||
attachments,
|
||||
})
|
||||
props.onSubmit(
|
||||
value ?? label,
|
||||
props.block.type === InputBlockType.FILE ? undefined : attachments
|
||||
)
|
||||
}
|
||||
|
||||
const handleSkip = (label: string) => {
|
||||
setAnswer(label)
|
||||
setAnswer({ text: label })
|
||||
props.onSkip()
|
||||
}
|
||||
|
||||
@@ -73,14 +82,15 @@ export const InputChatBlock = (props: Props) => {
|
||||
const formattedMessage = formattedMessages().findLast(
|
||||
(message) => props.chunkIndex === message.inputIndex
|
||||
)?.formattedMessage
|
||||
if (formattedMessage) setFormattedMessage(formattedMessage)
|
||||
if (formattedMessage && props.block.type !== InputBlockType.FILE)
|
||||
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={answer() && !props.hasError}>
|
||||
<GuestBubble
|
||||
message={formattedMessage() ?? (answer() as string)}
|
||||
message={answer() as Answer}
|
||||
showAvatar={
|
||||
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
|
||||
}
|
||||
@@ -107,7 +117,7 @@ export const InputChatBlock = (props: Props) => {
|
||||
block={props.block}
|
||||
chunkIndex={props.chunkIndex}
|
||||
isInputPrefillEnabled={props.isInputPrefillEnabled}
|
||||
existingAnswer={props.hasError ? answer() : undefined}
|
||||
existingAnswer={props.hasError ? answer()?.text : undefined}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
onSubmit={handleSubmit}
|
||||
onSkip={handleSkip}
|
||||
@@ -147,6 +157,7 @@ const Input = (props: {
|
||||
<TextInput
|
||||
block={props.block as TextInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
context={props.context}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
27
packages/embeds/js/src/components/Modal.tsx
Normal file
27
packages/embeds/js/src/components/Modal.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Dialog } from '@ark-ui/solid'
|
||||
import { JSX } from 'solid-js'
|
||||
import { CloseIcon } from './icons/CloseIcon'
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
children: JSX.Element
|
||||
}
|
||||
export const Modal = (props: Props) => {
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={props.isOpen}
|
||||
lazyMount
|
||||
unmountOnExit
|
||||
onOpenChange={(e) => (!e.open ? props.onClose?.() : undefined)}
|
||||
>
|
||||
<Dialog.Backdrop class="fixed inset-0 bg-[rgba(0,0,0,0.5)] h-screen z-50" />
|
||||
<Dialog.Positioner class="fixed inset-0 z-50 flex items-center justify-center px-2">
|
||||
<Dialog.Content>{props.children}</Dialog.Content>
|
||||
<Dialog.CloseTrigger class="fixed top-2 right-2 z-50 rounded-md bg-white p-2 text-black">
|
||||
<CloseIcon class="w-6 h-6" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,41 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { splitProps, Switch, Match } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
import { SendIcon } from './icons'
|
||||
import { Button } from './Button'
|
||||
import { isEmpty } from '@typebot.io/lib'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SendButtonProps = {
|
||||
isDisabled?: boolean
|
||||
isLoading?: boolean
|
||||
disableIcon?: boolean
|
||||
class?: string
|
||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export const SendButton = (props: SendButtonProps) => {
|
||||
const [local, others] = splitProps(props, ['disableIcon'])
|
||||
const showIcon =
|
||||
(isMobile() && !local.disableIcon) ||
|
||||
!props.children ||
|
||||
(typeof props.children === 'string' && isEmpty(props.children))
|
||||
return (
|
||||
<Button type="submit" {...others}>
|
||||
{(isMobile() && !local.disableIcon) ||
|
||||
!props.children ||
|
||||
(typeof props.children === 'string' && isEmpty(props.children)) ? (
|
||||
<SendIcon
|
||||
class={'send-icon flex ' + (local.disableIcon ? 'hidden' : '')}
|
||||
/>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
{...others}
|
||||
class={clsx('flex items-center', props.class)}
|
||||
aria-label={showIcon ? 'Send' : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={showIcon}>
|
||||
<SendIcon
|
||||
class={
|
||||
'send-icon flex w-6 h-6 ' + (local.disableIcon ? 'hidden' : '')
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!showIcon}>{props.children}</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
103
packages/embeds/js/src/components/TextInputAddFileButton.tsx
Normal file
103
packages/embeds/js/src/components/TextInputAddFileButton.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { Menu } from '@ark-ui/solid'
|
||||
import { CameraIcon } from './icons/CameraIcon'
|
||||
import { FileIcon } from './icons/FileIcon'
|
||||
import { PictureIcon } from './icons/PictureIcon'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { PaperClipIcon } from './icons/PaperClipIcon'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
onNewFiles: (files: FileList) => void
|
||||
class?: string
|
||||
}
|
||||
export const TextInputAddFileButton = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
id="document-upload"
|
||||
multiple
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id="photos-upload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<input
|
||||
type="file"
|
||||
id="camera-upload"
|
||||
class="hidden"
|
||||
multiple
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
class={clsx(
|
||||
'filter data-[state=open]:backdrop-brightness-90 hover:backdrop-brightness-95 transition rounded-md p-2 focus:outline-none',
|
||||
props.class
|
||||
)}
|
||||
aria-label="Add attachments"
|
||||
>
|
||||
<PaperClipIcon class="w-5" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content class="p-3 gap-2 focus:outline-none">
|
||||
<Menu.Item
|
||||
value="document"
|
||||
asChild={(props) => (
|
||||
<label
|
||||
{...props()}
|
||||
for="document-upload"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<FileIcon class="w-4" /> Document
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="photos"
|
||||
asChild={(props) => (
|
||||
<label
|
||||
{...props()}
|
||||
for="photos-upload"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<PictureIcon class="w-4" /> Photos & videos
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<Menu.Item
|
||||
value="camera"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<CameraIcon class="w-4" />
|
||||
Camera
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,105 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Answer } from '@/types'
|
||||
import { Modal } from '../Modal'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { FilePreview } from '@/features/blocks/inputs/fileUpload/components/FilePreview'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
message: Answer
|
||||
showAvatar: boolean
|
||||
avatarSrc?: string
|
||||
hasHostAvatar: boolean
|
||||
}
|
||||
|
||||
export const GuestBubble = (props: Props) => (
|
||||
<div
|
||||
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
|
||||
style={{
|
||||
'margin-left': props.hasHostAvatar
|
||||
? isMobile()
|
||||
? '28px'
|
||||
: '50px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="px-4 py-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
|
||||
data-testid="guest-bubble"
|
||||
export const GuestBubble = (props: Props) => {
|
||||
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
|
||||
style={{
|
||||
'margin-left': props.hasHostAvatar
|
||||
? isMobile()
|
||||
? '28px'
|
||||
: '50px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{props.message}
|
||||
</span>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<Show when={(props.message.attachments ?? []).length > 0}>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment, idx) => (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={`Attached image ${idx() + 1}`}
|
||||
class={clsx(
|
||||
'typebot-guest-bubble-image-attachment cursor-pointer',
|
||||
props.message.attachments!.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
).length > 1 && 'max-w-[90%]'
|
||||
)}
|
||||
onClick={() => setClickedImageSrc(attachment.url)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter(
|
||||
(attachment) => !attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment) => (
|
||||
<FilePreview
|
||||
file={{
|
||||
name: attachment.url.split('/').at(-1)!,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
<Show when={isNotEmpty(props.message.text)}>
|
||||
<span class="px-[15px] py-[7px]">{props.message.text}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={clickedImageSrc() !== undefined}
|
||||
onClose={() => setClickedImageSrc(undefined)}
|
||||
>
|
||||
<img
|
||||
src={clickedImageSrc()}
|
||||
alt="Attachment"
|
||||
style={{ 'border-radius': '6px' }}
|
||||
/>
|
||||
</Modal>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
packages/embeds/js/src/components/icons/CameraIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/CameraIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const CameraIcon = (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}
|
||||
>
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
)
|
||||
17
packages/embeds/js/src/components/icons/FileIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/FileIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const FileIcon = (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}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)
|
||||
18
packages/embeds/js/src/components/icons/PaperClipIcon.tsx
Normal file
18
packages/embeds/js/src/components/icons/PaperClipIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PaperClipIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
packages/embeds/js/src/components/icons/PictureIcon.tsx
Normal file
19
packages/embeds/js/src/components/icons/PictureIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PictureIcon = (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}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
||||
</svg>
|
||||
)
|
||||
17
packages/embeds/js/src/components/icons/PlusIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/PlusIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PlusIcon = (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}
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
)
|
||||
Reference in New Issue
Block a user