2
0

Add attachments option to text input (#1608)

Closes #854
This commit is contained in:
Baptiste Arnaud
2024-06-26 10:13:38 +02:00
committed by GitHub
parent 80da7af4f1
commit 6db0464fd7
88 changed files with 2959 additions and 735 deletions

View File

@ -16,7 +16,7 @@ npm install @typebot.io/js
```
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
Typebot.initStandard({
typebot: 'my-typebot',
@ -34,7 +34,7 @@ There, you can change the container dimensions. Here is a code example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
Typebot.initStandard({
typebot: 'my-typebot',
@ -54,7 +54,7 @@ Here is an example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
Typebot.initPopup({
typebot: 'my-typebot',
@ -96,7 +96,7 @@ Here is an example:
```html
<script type="module">
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
Typebot.initBubble({
typebot: 'my-typebot',

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.2.92",
"version": "0.3.0",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",
@ -13,6 +13,7 @@
},
"license": "AGPL-3.0-or-later",
"dependencies": {
"@ark-ui/solid": "3.3.0",
"@stripe/stripe-js": "1.54.1",
"@udecode/plate-common": "30.4.5",
"dompurify": "3.0.6",

View File

@ -20,6 +20,7 @@ const indexConfig = {
file: 'dist/index.js',
format: 'es',
},
onwarn,
plugins: [
resolve({ extensions }),
babel({
@ -56,4 +57,15 @@ const configs = [
},
]
function onwarn(warning, warn) {
if (
warning.code === 'CIRCULAR_DEPENDENCY' &&
warning.ids.some((id) => id.includes('@internationalized+date'))
) {
return
}
warn(warning.message)
}
export default configs

View File

@ -265,6 +265,10 @@ pre {
backdrop-filter: blur(var(--typebot-guest-bubble-blur));
}
.typebot-guest-bubble-image-attachment {
border-radius: var(--typebot-guest-bubble-border-radius);
}
.typebot-input {
color: var(--typebot-input-color);
background-color: rgba(
@ -279,12 +283,17 @@ pre {
border-radius: var(--typebot-input-border-radius);
box-shadow: var(--typebot-input-box-shadow);
backdrop-filter: blur(var(--typebot-input-blur));
transition: filter 100ms ease;
}
.typebot-input-error-message {
color: var(--typebot-input-color);
}
.typebot-input-form .typebot-button {
box-shadow: var(--typebot-input-box-shadow);
}
.typebot-button > .send-icon {
fill: var(--typebot-button-color);
}
@ -446,3 +455,138 @@ select option {
height: 100%;
transition: width 0.25s ease;
}
@keyframes fadeInFromTop {
0% {
opacity: 0;
transform: translateY(-4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutFromTop {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes fadeInFromBottom {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutFromBottom {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(4px);
}
}
[data-scope='menu'][data-part='content'] {
color: var(--typebot-input-color);
background-color: rgba(
var(--typebot-input-bg-rgb),
var(--typebot-input-opacity)
);
border-width: var(--typebot-input-border-width);
border-color: rgba(
var(--typebot-input-border-rgb),
var(--typebot-input-border-opacity)
);
border-radius: var(--typebot-input-border-radius);
box-shadow: var(--typebot-input-box-shadow);
backdrop-filter: blur(var(--typebot-input-blur));
}
[data-scope='menu'][data-part='item'] {
cursor: pointer;
background-color: rgba(
var(--typebot-input-bg-rgb),
var(--typebot-input-opacity)
);
border-radius: var(--typebot-input-border-radius);
}
[data-scope='menu'][data-part='content'][data-state='open'] {
animation: fadeInFromTop 150ms ease-out forwards;
}
[data-scope='menu'][data-part='content'][data-state='closed'] {
animation: fadeOutFromTop 50ms ease-out forwards;
}
[data-scope='toast'][data-part='group'] {
width: 100%;
}
[data-scope='toast'][data-part='root'] {
border-radius: var(--typebot-chat-container-border-radius);
color: var(--typebot-input-color);
background-color: rgba(
var(--typebot-input-bg-rgb),
var(--typebot-input-opacity)
);
box-shadow: var(--typebot-input-box-shadow);
max-width: 60vw;
@apply flex flex-col pl-4 py-4 pr-8 gap-1;
}
[data-scope='toast'][data-part='title'] {
@apply font-semibold;
}
[data-scope='toast'][data-part='description'] {
@apply text-sm;
}
[data-scope='toast'][data-part='root'][data-state='open'] {
animation: fadeInFromBottom 150ms ease-out forwards;
}
[data-scope='toast'][data-part='root'][data-state='closed'] {
animation: fadeOutFromBottom 50ms ease-out forwards;
}
[data-scope='progress'][data-part='root'] {
width: 100%;
height: 100%;
}
[data-scope='progress'][data-part='circle'] {
--size: 40px;
--thickness: 4px;
--radius: calc(40px / 2 - 4px / 2);
--circomference: calc(2 * 3.14159 * calc(40px / 2 - 4px / 2));
}
[data-scope='progress'][data-part='circle-range'] {
stroke: white;
--transition-prop: stroke-dasharray, stroke, stroke-dashoffset;
transition-property: stroke-dasharray, stroke, stroke-dashoffset;
--transition-duration: 0.2s;
transition-duration: 0.2s;
}
[data-scope='progress'][data-part='circle-track'] {
stroke: rgba(0, 0, 0, 0.5);
}

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

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

View File

@ -16,93 +16,94 @@ export const DateForm = (props: Props) => {
parseDefaultValue(props.defaultValue ?? '')
)
const submit = () => {
if (inputValues().from === '' && inputValues().to === '') return
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
})
}
return (
<div class="flex flex-col">
<div class="flex items-center">
<form
class={clsx(
'flex justify-between typebot-input pr-2',
props.options?.isRange ? 'items-end' : 'items-center'
)}
onSubmit={(e) => {
if (inputValues().from === '' && inputValues().to === '') return
e.preventDefault()
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
})
}}
>
<div class="flex flex-col">
<div
class={
'flex items-center p-4 ' +
(props.options?.isRange ? 'pb-0 gap-2' : '')
<div class="typebot-input-form flex gap-2 items-end">
<form
class={clsx(
'flex typebot-input',
props.options?.isRange ? 'items-end' : 'items-center'
)}
onSubmit={(e) => {
e.preventDefault()
submit()
}}
>
<div class="flex flex-col">
<div
class={
'flex items-center p-4 ' +
(props.options?.isRange ? 'pb-0 gap-2' : '')
}
>
{props.options?.isRange && (
<p class="font-semibold">
{props.options.labels?.from ??
defaultDateInputOptions.labels.from}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().from}
type={props.options?.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
from: e.currentTarget.value,
})
}
>
{props.options?.isRange && (
min={props.options?.min}
max={props.options?.max}
data-testid="from-date"
/>
</div>
{props.options?.isRange && (
<div class="flex items-center p-4">
{props.options.isRange && (
<p class="font-semibold">
{props.options.labels?.from ??
defaultDateInputOptions.labels.from}
{props.options.labels?.to ??
defaultDateInputOptions.labels.to}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().from}
type={props.options?.hasTime ? 'datetime-local' : 'date'}
value={inputValues().to}
type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
from: e.currentTarget.value,
to: e.currentTarget.value,
})
}
min={props.options?.min}
max={props.options?.max}
data-testid="from-date"
data-testid="to-date"
/>
</div>
{props.options?.isRange && (
<div class="flex items-center p-4">
{props.options.isRange && (
<p class="font-semibold">
{props.options.labels?.to ??
defaultDateInputOptions.labels.to}
</p>
)}
<input
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
style={{
'min-height': '32px',
'min-width': '100px',
'font-size': '16px',
}}
value={inputValues().to}
type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) =>
setInputValues({
...inputValues(),
to: e.currentTarget.value,
})
}
min={props.options?.min}
max={props.options?.max}
data-testid="to-date"
/>
</div>
)}
</div>
<SendButton class="my-2 ml-2">
{props.options?.labels?.button}
</SendButton>
</form>
</div>
)}
</div>
</form>
<SendButton class="h-[56px]" on:click={submit}>
{props.options?.labels?.button}
</SendButton>
</div>
)
}

View File

@ -49,25 +49,23 @@ export const EmailInput = (props: Props) => {
return (
<div
class={'flex items-end justify-between pr-2 typebot-input w-full'}
data-testid="input"
style={{
'max-width': '350px',
}}
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultEmailInputOptions.labels.placeholder
}
onInput={handleInput}
type="email"
autocomplete="email"
/>
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
<div class={'flex typebot-input w-full'}>
<ShortTextInput
ref={inputRef}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultEmailInputOptions.labels.placeholder
}
onInput={handleInput}
type="email"
autocomplete="email"
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.block.options?.labels?.button}
</SendButton>
</div>

View File

@ -0,0 +1,80 @@
import { FileIcon } from '@/components/icons/FileIcon'
import clsx from 'clsx'
type Props = {
file: { name: string }
}
export const FilePreview = (props: Props) => {
const fileColor = getFileAssociatedColor(props.file)
return (
<div
class={
'flex items-center gap-4 border bg-white border-gray-200 rounded-md p-2 text-gray-900 min-w-[250px]'
}
>
<div
class={clsx(
'rounded-md text-white p-2 flex items-center',
fileColor === 'pink' && 'bg-pink-400',
fileColor === 'blue' && 'bg-blue-400',
fileColor === 'green' && 'bg-green-400',
fileColor === 'gray' && 'bg-gray-400',
fileColor === 'orange' && 'bg-orange-400'
)}
>
<FileIcon class="w-6 h-6" />
</div>
<div class="flex flex-col">
<span class="text-md font-semibold text-sm">{props.file.name}</span>
<span class="text-gray-500 text-xs">
{formatFileExtensionHumanReadable(props.file)}
</span>
</div>
</div>
)
}
const formatFileExtensionHumanReadable = (file: { name: string }) => {
const extension = file.name.split('.').pop()
switch (extension) {
case 'pdf':
return 'PDF'
case 'doc':
case 'docx':
return 'Word'
case 'xls':
case 'xlsx':
case 'csv':
return 'Sheet'
case 'json':
return 'JSON'
case 'md':
return 'Markdown'
default:
return 'DOCUMENT'
}
}
const getFileAssociatedColor = (file: {
name: string
}): 'pink' | 'blue' | 'green' | 'gray' | 'orange' => {
const extension = file.name.split('.').pop()
if (!extension) return 'gray'
switch (extension) {
case 'pdf':
return 'pink'
case 'doc':
case 'docx':
return 'blue'
case 'xls':
case 'xlsx':
case 'csv':
return 'green'
case 'json':
return 'orange'
default:
return 'gray'
}
}

View File

@ -1,14 +1,16 @@
import { SendButton } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types'
import { FileInputBlock } from '@typebot.io/schemas'
import { createSignal, Match, Show, Switch } from 'solid-js'
import { createSignal, Match, Show, Switch, For } from 'solid-js'
import { Button } from '@/components/Button'
import { Spinner } from '@/components/Spinner'
import { uploadFiles } from '../helpers/uploadFiles'
import { guessApiHost } from '@/utils/guessApiHost'
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants'
import { isDefined } from '@typebot.io/lib'
import { SelectedFile } from './SelectedFile'
import { sanitizeNewFile } from '../helpers/sanitizeSelectedFiles'
import { toaster } from '@/utils/toaster'
type Props = {
context: BotContext
@ -22,39 +24,34 @@ export const FileUploadForm = (props: Props) => {
const [isUploading, setIsUploading] = createSignal(false)
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
const [errorMessage, setErrorMessage] = createSignal<string>()
const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files)
const sizeLimit =
props.block.options && 'sizeLimit' in props.block.options
? props.block.options?.sizeLimit ??
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE')
: undefined
if (
sizeLimit &&
newFiles.some((file) => file.size > sizeLimit * 1024 * 1024)
)
return setErrorMessage(`A file is larger than ${sizeLimit}MB`)
if (!props.block.options?.isMultipleAllowed && files)
.map((file) =>
sanitizeNewFile({
existingFiles: selectedFiles(),
newFile: file,
params: {
sizeLimit:
props.block.options && 'sizeLimit' in props.block.options
? props.block.options.sizeLimit
: undefined,
},
onError: ({ description, title }) =>
toaster.create({
title,
description,
}),
})
)
.filter(isDefined)
if (newFiles.length === 0) return
if (!props.block.options?.isMultipleAllowed)
return startSingleFileUpload(newFiles[0])
if (selectedFiles().length === 0) {
setSelectedFiles(newFiles)
return
}
const parsedNewFiles = newFiles.map((newFile) => {
let fileName = newFile.name
let counter = 1
while (selectedFiles().some((file) => file.name === fileName)) {
const dotIndex = newFile.name.lastIndexOf('.')
const extension = dotIndex !== -1 ? newFile.name.slice(dotIndex) : ''
fileName = `${newFile.name.slice(0, dotIndex)}(${counter})${extension}`
counter++
}
return new File([newFile], fileName, { type: newFile.type })
})
setSelectedFiles([...selectedFiles(), ...parsedNewFiles])
setSelectedFiles([...selectedFiles(), ...newFiles])
}
const handleSubmit = async (e: SubmitEvent) => {
@ -64,13 +61,6 @@ export const FileUploadForm = (props: Props) => {
}
const startSingleFileUpload = async (file: File) => {
if (props.context.isPreview || !props.context.resultId)
return props.onSubmit({
label:
props.block.options?.labels?.success?.single ??
defaultFileInputOptions.labels.success.single,
value: 'http://fake-upload-url.com',
})
setIsUploading(true)
const urls = await uploadFiles({
apiHost:
@ -86,31 +76,17 @@ export const FileUploadForm = (props: Props) => {
],
})
setIsUploading(false)
if (urls.length)
if (urls.length && urls[0])
return props.onSubmit({
label:
props.block.options?.labels?.success?.single ??
defaultFileInputOptions.labels.success.single,
value: urls[0] ? encodeUrl(urls[0]) : '',
value: urls[0] ? encodeUrl(urls[0].url) : '',
attachments: [{ type: file.type, url: urls[0]!.url }],
})
setErrorMessage('An error occured while uploading the file')
toaster.create({ description: 'An error occured while uploading the file' })
}
const startFilesUpload = async (files: File[]) => {
const resultId = props.context.resultId
if (props.context.isPreview || !resultId)
return props.onSubmit({
label:
files.length > 1
? (
props.block.options?.labels?.success?.multiple ??
defaultFileInputOptions.labels.success.multiple
).replaceAll('{total}', files.length.toString())
: props.block.options?.labels?.success?.single ??
defaultFileInputOptions.labels.success.single,
value: files
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
.join(', '),
})
setIsUploading(true)
const urls = await uploadFiles({
apiHost:
@ -127,7 +103,9 @@ export const FileUploadForm = (props: Props) => {
setIsUploading(false)
setUploadProgressPercent(0)
if (urls.length !== files.length)
return setErrorMessage('An error occured while uploading the files')
return toaster.create({
description: 'An error occured while uploading the files',
})
props.onSubmit({
label:
urls.length > 1
@ -137,7 +115,11 @@ export const FileUploadForm = (props: Props) => {
).replaceAll('{total}', urls.length.toString())
: props.block.options?.labels?.success?.single ??
defaultFileInputOptions.labels.success.single,
value: urls.filter(isDefined).map(encodeUrl).join(', '),
value: urls
.filter(isDefined)
.map(({ url }) => encodeUrl(url))
.join(', '),
attachments: urls.filter(isDefined),
})
}
@ -162,6 +144,12 @@ export const FileUploadForm = (props: Props) => {
props.block.options?.labels?.skip ?? defaultFileInputOptions.labels.skip
)
const removeSelectedFile = (index: number) => {
setSelectedFiles((selectedFiles) =>
selectedFiles.filter((_, i) => i !== index)
)
}
return (
<form class="flex flex-col w-full gap-2" onSubmit={handleSubmit}>
<label
@ -192,17 +180,24 @@ export const FileUploadForm = (props: Props) => {
</Match>
<Match when={!isUploading()}>
<>
<div class="flex flex-col justify-center items-center">
<div class="flex flex-col justify-center items-center gap-4 max-w-[90%]">
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
<span class="relative">
<FileIcon />
<div
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4"
style={{ bottom: '5px' }}
>
{selectedFiles().length}
</div>
</span>
<div
class="p-4 flex gap-2 border-gray-200 border overflow-auto bg-white rounded-md w-full"
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<For each={selectedFiles()}>
{(file, index) => (
<SelectedFile
file={file}
onRemoveClick={() => removeSelectedFile(index())}
/>
)}
</For>
</div>
</Show>
<p
class="text-sm text-gray-500 text-center"
@ -269,9 +264,6 @@ export const FileUploadForm = (props: Props) => {
</div>
</div>
</Show>
<Show when={errorMessage()}>
<p class="text-red-500 text-sm">{errorMessage()}</p>
</Show>
</form>
)
}
@ -287,7 +279,7 @@ const UploadIcon = () => (
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
class="text-gray-500"
>
<polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" />
@ -296,24 +288,6 @@ const UploadIcon = () => (
</svg>
)
const FileIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-3 text-gray-500"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)
const encodeUrl = (url: string): string => {
const fileName = url.split('/').pop()
if (!fileName) return url

View File

@ -0,0 +1,91 @@
import {
Switch,
Match,
Show,
createSignal,
createEffect,
onCleanup,
} from 'solid-js'
import { CloseIcon } from '@/components/icons/CloseIcon'
import { FilePreview } from './FilePreview'
import { Progress } from '@ark-ui/solid'
import { isDefined } from '@typebot.io/lib'
export const SelectedFile = (props: {
file: File
uploadProgressPercent?: number
onRemoveClick: () => void
}) => {
return (
<div class="relative group flex-shrink-0">
<Switch>
<Match when={props.file.type.startsWith('image')}>
<img
src={URL.createObjectURL(props.file)}
alt={props.file.name}
class="rounded-md object-cover w-[58px] h-[58px]"
/>
</Match>
<Match when={true}>
<FilePreview file={props.file} />
</Match>
</Switch>
<button
class="absolute -right-2 p-0.5 -top-2 rounded-full bg-gray-200 text-black border border-gray-400 opacity-1 sm:opacity-0 group-hover:opacity-100 transition-opacity"
on:click={props.onRemoveClick}
aria-label="Remove attachment"
>
<CloseIcon class="w-4" />
</button>
<Show
when={
isDefined(props.uploadProgressPercent) &&
props.uploadProgressPercent !== 100
}
>
<UploadOverlay progressPercent={props.uploadProgressPercent} />
</Show>
</div>
)
}
const UploadOverlay = (props: { progressPercent?: number }) => {
const [progressPercent, setProgressPercent] = createSignal(
props.progressPercent ?? 0
)
let interval: NodeJS.Timer | undefined
createEffect(() => {
if (props.progressPercent === 20) {
const incrementProgress = () => {
if (progressPercent() < 100) {
setProgressPercent(
(prev) => prev + (Math.floor(Math.random() * 10) + 1)
)
}
}
interval = setInterval(incrementProgress, 1000)
}
})
onCleanup(() => {
clearInterval(interval)
})
return (
<div class="absolute w-full h-full inset-0 bg-black/20 rounded-md">
<Progress.Root
value={progressPercent()}
class="flex items-center justify-center"
>
<Progress.Circle>
<Progress.CircleTrack />
<Progress.CircleRange />
</Progress.Circle>
</Progress.Root>
</div>
)
}

View File

@ -0,0 +1,40 @@
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
type Props = {
newFile: File
existingFiles: File[]
params: {
sizeLimit?: number
}
onError: (message: { title?: string; description: string }) => void
}
export const sanitizeNewFile = ({
newFile,
existingFiles,
params,
onError,
}: Props): File | undefined => {
const sizeLimit =
params.sizeLimit ??
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE')
if (sizeLimit && newFile.size > sizeLimit * 1024 * 1024) {
onError({
title: 'File too large',
description: `${newFile.name} is larger than ${sizeLimit}MB`,
})
return
}
if (existingFiles.length === 0) return newFile
let fileName = newFile.name
let counter = 1
while (existingFiles.some((file) => file.name === fileName)) {
const dotIndex = newFile.name.lastIndexOf('.')
const extension = dotIndex !== -1 ? newFile.name.slice(dotIndex) : ''
fileName = `${newFile.name.slice(0, dotIndex)}(${counter})${extension}`
counter++
}
return new File([newFile], fileName, { type: newFile.type })
}

View File

@ -9,20 +9,24 @@ type UploadFileProps = {
fileName: string
}
}[]
onUploadProgress?: (percent: number) => void
onUploadProgress?: (props: { fileIndex: number; progress: number }) => void
}
type UrlList = (string | null)[]
type UrlList = ({
url: string
type: string
} | null)[]
export const uploadFiles = async ({
apiHost,
files,
onUploadProgress,
}: UploadFileProps): Promise<UrlList> => {
const urls = []
const urls: UrlList = []
let i = 0
for (const { input, file } of files) {
onUploadProgress && onUploadProgress((i / files.length) * 100)
onUploadProgress &&
onUploadProgress({ progress: (i / files.length) * 100, fileIndex: i })
i += 1
const { data } = await sendRequest<{
presignedUrl: string
@ -52,7 +56,7 @@ export const uploadFiles = async ({
if (!upload.ok) continue
urls.push(data.fileUrl)
urls.push({ url: data.fileUrl, type: file.type })
}
}
return urls

View File

@ -52,34 +52,32 @@ export const NumberInput = (props: NumberInputProps) => {
return (
<div
class={'flex items-end justify-between pr-2 typebot-input w-full'}
data-testid="input"
style={{
'max-width': '350px',
}}
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
>
<input
ref={inputRef}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
style={{ 'font-size': '16px', appearance: 'auto' }}
value={staticValue}
// @ts-expect-error not defined
// eslint-disable-next-line solid/jsx-no-undef
use:bindValue
placeholder={
props.block.options?.labels?.placeholder ??
defaultNumberInputOptions.labels.placeholder
}
onInput={(e) => {
setInputValue(targetValue(e.currentTarget))
}}
type="number"
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? 'any'}
/>
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
<div class={'flex typebot-input w-full'}>
<input
ref={inputRef}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
style={{ 'font-size': '16px', appearance: 'auto' }}
value={staticValue}
// @ts-expect-error not defined
// eslint-disable-next-line solid/jsx-no-undef
use:bindValue
placeholder={
props.block.options?.labels?.placeholder ??
defaultNumberInputOptions.labels.placeholder
}
onInput={(e) => {
setInputValue(targetValue(e.currentTarget))
}}
type="number"
min={props.block.options?.min}
max={props.block.options?.max}
step={props.block.options?.step ?? 'any'}
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.block.options?.labels?.button}
</SendButton>
</div>

View File

@ -106,14 +106,10 @@ export const PhoneInput = (props: PhoneInputProps) => {
return (
<div
class={'flex items-end justify-between pr-2 typebot-input'}
data-testid="input"
style={{
'max-width': '400px',
}}
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
>
<div class="flex">
<div class={'flex typebot-input w-full'}>
<div class="relative typebot-country-select flex justify-center items-center">
<div class="pl-2 pr-1 flex items-center gap-2">
<span>
@ -156,8 +152,7 @@ export const PhoneInput = (props: PhoneInputProps) => {
autofocus={!isMobile()}
/>
</div>
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.labels?.button}
</SendButton>
</div>

View File

@ -1,21 +1,35 @@
import { Textarea, ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton'
import { CommandData } from '@/features/commands'
import { InputSubmitContent } from '@/types'
import { Answer, BotContext, InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import type { TextInputBlock } from '@typebot.io/schemas'
import { createSignal, onCleanup, onMount } from 'solid-js'
import { For, Show, createSignal, onCleanup, onMount } from 'solid-js'
import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
import clsx from 'clsx'
import { TextInputAddFileButton } from '@/components/TextInputAddFileButton'
import { SelectedFile } from '../../fileUpload/components/SelectedFile'
import { sanitizeNewFile } from '../../fileUpload/helpers/sanitizeSelectedFiles'
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
import { toaster } from '@/utils/toaster'
import { isDefined } from '@typebot.io/lib'
import { uploadFiles } from '../../fileUpload/helpers/uploadFiles'
import { guessApiHost } from '@/utils/guessApiHost'
type Props = {
block: TextInputBlock
defaultValue?: string
context: BotContext
onSubmit: (value: InputSubmitContent) => void
}
export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
const [uploadProgress, setUploadProgress] = createSignal<
{ fileIndex: number; progress: number } | undefined
>(undefined)
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue)
@ -23,10 +37,30 @@ export const TextInput = (props: Props) => {
const checkIfInputIsValid = () =>
inputRef?.value !== '' && inputRef?.reportValidity()
const submit = () => {
if (checkIfInputIsValid())
props.onSubmit({ value: inputRef?.value ?? inputValue() })
else inputRef?.focus()
const submit = async () => {
if (checkIfInputIsValid()) {
let attachments: Answer['attachments']
if (selectedFiles().length > 0) {
setUploadProgress(undefined)
const urls = await uploadFiles({
apiHost:
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
files: selectedFiles().map((file) => ({
file: file,
input: {
sessionId: props.context.sessionId,
fileName: file.name,
},
})),
onUploadProgress: setUploadProgress,
})
attachments = urls?.filter(isDefined)
}
props.onSubmit({
value: inputRef?.value ?? inputValue(),
attachments,
})
} else inputRef?.focus()
}
const submitWhenEnter = (e: KeyboardEvent) => {
@ -57,41 +91,139 @@ export const TextInput = (props: Props) => {
if (data.command === 'setInputValue') setInputValue(data.value)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
setIsDraggingOver(true)
}
const handleDragLeave = () => setIsDraggingOver(false)
const handleDropFile = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!e.dataTransfer?.files) return
onNewFiles(e.dataTransfer.files)
}
const onNewFiles = (files: FileList) => {
const newFiles = Array.from(files)
.map((file) =>
sanitizeNewFile({
existingFiles: selectedFiles(),
newFile: file,
params: {
sizeLimit: getRuntimeVariable(
'NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE'
),
},
onError: ({ description, title }) => {
toaster.create({
description,
title,
})
},
})
)
.filter(isDefined)
if (newFiles.length === 0) return
setSelectedFiles((selectedFiles) => [...newFiles, ...selectedFiles])
}
const removeSelectedFile = (index: number) => {
setSelectedFiles((selectedFiles) =>
selectedFiles.filter((_, i) => i !== index)
)
}
return (
<div
class={clsx(
'flex justify-between pr-2 typebot-input w-full',
props.block.options?.isLong ? 'items-end' : 'items-center'
)}
data-testid="input"
style={{
'max-width': props.block.options?.isLong ? undefined : '350px',
}}
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
onDrop={handleDropFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{props.block.options?.isLong ? (
<Textarea
ref={inputRef as HTMLTextAreaElement}
onInput={handleInput}
onKeyDown={submitIfCtrlEnter}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultTextInputOptions.labels.placeholder
}
/>
) : (
<ShortTextInput
ref={inputRef as HTMLInputElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultTextInputOptions.labels.placeholder
}
/>
)}
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
<div
class={clsx(
'typebot-input flex-col w-full',
isDraggingOver() && 'filter brightness-95'
)}
>
<Show when={selectedFiles().length}>
<div
class="p-2 flex gap-2 border-gray-100 overflow-auto"
style={{ 'border-bottom-width': '1px' }}
>
<For each={selectedFiles()}>
{(file, index) => (
<SelectedFile
file={file}
uploadProgressPercent={
uploadProgress()
? uploadProgress()?.fileIndex === index()
? 20
: index() < (uploadProgress()?.fileIndex ?? 0)
? 100
: 0
: undefined
}
onRemoveClick={() => removeSelectedFile(index())}
/>
)}
</For>
</div>
</Show>
<div
class={clsx(
'flex justify-between px-2',
props.block.options?.isLong ? 'items-end' : 'items-center'
)}
>
{props.block.options?.isLong ? (
<Textarea
ref={inputRef as HTMLTextAreaElement}
onInput={handleInput}
onKeyDown={submitIfCtrlEnter}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultTextInputOptions.labels.placeholder
}
/>
) : (
<ShortTextInput
ref={inputRef as HTMLInputElement}
onInput={handleInput}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultTextInputOptions.labels.placeholder
}
/>
)}
<Show
when={
(props.block.options?.attachments?.isEnabled ??
defaultTextInputOptions.attachments.isEnabled) &&
props.block.options?.attachments?.saveVariableId
}
>
<TextInputAddFileButton
onNewFiles={onNewFiles}
class={clsx(props.block.options?.isLong ? 'ml-2' : undefined)}
/>
</Show>
</div>
</div>
<SendButton
type="button"
on:click={submit}
isDisabled={Boolean(uploadProgress())}
class="h-[56px]"
>
{props.block.options?.labels?.button}
</SendButton>
</div>

View File

@ -56,25 +56,23 @@ export const UrlInput = (props: Props) => {
return (
<div
class={'flex items-end justify-between pr-2 typebot-input w-full'}
data-testid="input"
style={{
'max-width': '350px',
}}
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
onKeyDown={submitWhenEnter}
>
<ShortTextInput
ref={inputRef as HTMLInputElement}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultUrlInputOptions.labels.placeholder
}
onInput={handleInput}
type="url"
autocomplete="url"
/>
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
<div class={'flex typebot-input w-full'}>
<ShortTextInput
ref={inputRef as HTMLInputElement}
value={inputValue()}
placeholder={
props.block.options?.labels?.placeholder ??
defaultUrlInputOptions.labels.placeholder
}
onInput={handleInput}
type="url"
autocomplete="url"
/>
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.block.options?.labels?.button}
</SendButton>
</div>

View File

@ -19,6 +19,7 @@ import {
removeBotOpenedStateInStorage,
setBotOpenedStateInStorage,
} from '@/utils/storage'
import { EnvironmentProvider } from '@ark-ui/solid'
export type BubbleProps = BotProps &
BubbleParams & {
@ -157,59 +158,65 @@ export const Bubble = (props: BubbleProps) => {
return (
<Show when={isMounted()}>
<style>{styles}</style>
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
placement={bubbleProps.theme?.placement}
previewMessageTheme={bubbleProps.theme?.previewMessage}
buttonSize={buttonSize()}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
<BubbleButton
{...bubbleProps.theme?.button}
placement={bubbleProps.theme?.placement}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
buttonSize={buttonSize()}
/>
<div ref={progressBarContainerRef} />
<div
part="bot"
style={{
height: `calc(100% - ${buttonSize()} - 32px)`,
'max-height': props.theme?.chatWindow?.maxHeight ?? '704px',
'max-width': props.theme?.chatWindow?.maxWidth ?? '400px',
transition:
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
'transform-origin':
props.theme?.placement === 'left' ? 'bottom left' : 'bottom right',
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
'z-index': 42424242,
bottom: `calc(${buttonSize()} + 32px)`,
}}
class={
'fixed rounded-lg w-full' +
(isBotOpened() ? ' opacity-1' : ' opacity-0 pointer-events-none') +
(props.theme?.placement === 'left'
? ' left-5'
: ' sm:right-5 right-0')
}
<EnvironmentProvider
value={document.querySelector('typebot-bubble')?.shadowRoot as Node}
>
<Show when={isBotStarted()}>
<Bot
{...botProps}
onChatStatePersisted={handleOnChatStatePersisted}
prefilledVariables={prefilledVariables()}
class="rounded-lg"
progressBarRef={progressBarContainerRef}
<style>{styles}</style>
<Show when={isPreviewMessageDisplayed()}>
<PreviewMessage
{...previewMessage()}
placement={bubbleProps.theme?.placement}
previewMessageTheme={bubbleProps.theme?.previewMessage}
buttonSize={buttonSize()}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/>
</Show>
</div>
<BubbleButton
{...bubbleProps.theme?.button}
placement={bubbleProps.theme?.placement}
toggleBot={toggleBot}
isBotOpened={isBotOpened()}
buttonSize={buttonSize()}
/>
<div ref={progressBarContainerRef} />
<div
part="bot"
style={{
height: `calc(100% - ${buttonSize()} - 32px)`,
'max-height': props.theme?.chatWindow?.maxHeight ?? '704px',
'max-width': props.theme?.chatWindow?.maxWidth ?? '400px',
transition:
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
'transform-origin':
props.theme?.placement === 'left'
? 'bottom left'
: 'bottom right',
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
'z-index': 42424242,
bottom: `calc(${buttonSize()} + 32px)`,
}}
class={
'fixed rounded-lg w-full' +
(isBotOpened() ? ' opacity-1' : ' opacity-0 pointer-events-none') +
(props.theme?.placement === 'left'
? ' left-5'
: ' sm:right-5 right-0')
}
>
<Show when={isBotStarted()}>
<Bot
{...botProps}
onChatStatePersisted={handleOnChatStatePersisted}
prefilledVariables={prefilledVariables()}
class="rounded-lg"
progressBarRef={progressBarContainerRef}
/>
</Show>
</div>
</EnvironmentProvider>
</Show>
)
}

View File

@ -17,6 +17,7 @@ import {
removeBotOpenedStateInStorage,
setBotOpenedStateInStorage,
} from '@/utils/storage'
import { EnvironmentProvider } from '@ark-ui/solid'
export type PopupProps = BotProps &
PopupParams & {
@ -118,44 +119,48 @@ export const Popup = (props: PopupProps) => {
return (
<Show when={isBotOpened()}>
<style>{styles}</style>
<div
class="relative"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
style={{
'z-index': props.theme?.zIndex ?? 42424242,
}}
<EnvironmentProvider
value={document.querySelector('typebot-popup')?.shadowRoot as Node}
>
<style>{styles}</style>
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in"
part="overlay"
/>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
class={
'relative h-[80vh] transform overflow-hidden rounded-lg text-left transition-all sm:my-8 w-full max-w-lg' +
(props.theme?.backgroundColor ? ' shadow-xl' : '')
}
style={{
'background-color':
props.theme?.backgroundColor ?? 'transparent',
'max-width': props.theme?.width ?? '512px',
}}
on:pointerdown={stopPropagation}
>
<Bot
{...botProps}
prefilledVariables={prefilledVariables()}
onChatStatePersisted={handleOnChatStatePersisted}
/>
class="relative"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
style={{
'z-index': props.theme?.zIndex ?? 42424242,
}}
>
<style>{styles}</style>
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in"
part="overlay"
/>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
class={
'relative h-[80vh] transform overflow-hidden rounded-lg text-left transition-all sm:my-8 w-full max-w-lg' +
(props.theme?.backgroundColor ? ' shadow-xl' : '')
}
style={{
'background-color':
props.theme?.backgroundColor ?? 'transparent',
'max-width': props.theme?.width ?? '512px',
}}
on:pointerdown={stopPropagation}
>
<Bot
{...botProps}
prefilledVariables={prefilledVariables()}
onChatStatePersisted={handleOnChatStatePersisted}
/>
</div>
</div>
</div>
</div>
</div>
</EnvironmentProvider>
</Show>
)
}

View File

@ -2,6 +2,7 @@ import styles from '../../../assets/index.css'
import { Bot, BotProps } from '@/components/Bot'
import { CommandData } from '@/features/commands/types'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import { EnvironmentProvider } from '@ark-ui/solid'
const hostElementCss = `
:host {
@ -42,7 +43,9 @@ export const Standard = (
})
return (
<>
<EnvironmentProvider
value={document.querySelector('typebot-standard')?.shadowRoot as Node}
>
<style>
{styles}
{hostElementCss}
@ -50,6 +53,6 @@ export const Standard = (
<Show when={isBotDisplayed()}>
<Bot {...props} />
</Show>
</>
</EnvironmentProvider>
)
}

View File

@ -3,6 +3,7 @@ import { ContinueChatResponse, StartChatResponse } from '@typebot.io/schemas'
export type InputSubmitContent = {
label?: string
value: string
attachments?: Answer['attachments']
}
export type BotContext = {
@ -36,3 +37,11 @@ export type ChatChunk = Pick<
> & {
streamingMessageId?: string
}
export type Answer = {
text: string
attachments?: {
type: string
url: string
}[]
}

View File

@ -0,0 +1,6 @@
import { createToaster } from '@ark-ui/solid'
export const toaster = createToaster({
placement: 'bottom-end',
gap: 24,
})