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

@@ -56,7 +56,6 @@
"@uiw/codemirror-theme-tokyo-night": "4.21.24", "@uiw/codemirror-theme-tokyo-night": "4.21.24",
"@uiw/react-codemirror": "4.21.24", "@uiw/react-codemirror": "4.21.24",
"@upstash/ratelimit": "0.4.3", "@upstash/ratelimit": "0.4.3",
"@upstash/redis": "1.22.0",
"@use-gesture/react": "10.2.27", "@use-gesture/react": "10.2.27",
"browser-image-compression": "2.0.2", "browser-image-compression": "2.0.2",
"canvas-confetti": "1.6.0", "canvas-confetti": "1.6.0",
@@ -69,6 +68,7 @@
"google-auth-library": "8.9.0", "google-auth-library": "8.9.0",
"google-spreadsheet": "4.1.1", "google-spreadsheet": "4.1.1",
"immer": "10.0.2", "immer": "10.0.2",
"ioredis": "^5.4.1",
"isolated-vm": "4.7.2", "isolated-vm": "4.7.2",
"jsonwebtoken": "9.0.1", "jsonwebtoken": "9.0.1",
"ky": "1.2.4", "ky": "1.2.4",

View File

@@ -8,15 +8,17 @@ import {
useColorModeValue, useColorModeValue,
Portal, Portal,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import React from 'react' import React, { RefObject } from 'react'
import { EmojiOrImageIcon } from './EmojiOrImageIcon' import { EmojiOrImageIcon } from './EmojiOrImageIcon'
import { ImageUploadContent } from './ImageUploadContent' import { ImageUploadContent } from './ImageUploadContent'
import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl' import { FilePathUploadProps } from '@/features/upload/api/generateUploadUrl'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
type Props = { type Props = {
uploadFileProps: FilePathUploadProps uploadFileProps: FilePathUploadProps
icon?: string | null icon?: string | null
parentModalRef?: RefObject<HTMLElement | null> | undefined
onChangeIcon: (icon: string) => void onChangeIcon: (icon: string) => void
boxSize?: string boxSize?: string
} }
@@ -28,6 +30,7 @@ export const EditableEmojiOrImageIcon = ({
boxSize, boxSize,
}: Props) => { }: Props) => {
const { t } = useTranslate() const { t } = useTranslate()
const { ref: parentModalRef } = useParentModal()
const bg = useColorModeValue('gray.100', 'gray.700') const bg = useColorModeValue('gray.100', 'gray.700')
return ( return (
@@ -56,7 +59,7 @@ export const EditableEmojiOrImageIcon = ({
</PopoverTrigger> </PopoverTrigger>
</Flex> </Flex>
</Tooltip> </Tooltip>
<Portal> <Portal containerRef={parentModalRef}>
<PopoverContent p="2"> <PopoverContent p="2">
<ImageUploadContent <ImageUploadContent
uploadFileProps={uploadFileProps} uploadFileProps={uploadFileProps}

View File

@@ -24,7 +24,7 @@ test.describe('Date input block', () => {
'date' 'date'
) )
await page.locator('[data-testid="from-date"]').fill('2021-01-01') await page.locator('[data-testid="from-date"]').fill('2021-01-01')
await page.locator('form').getByRole('button').click() await page.getByLabel('Send').click()
await expect(page.locator('text="01/01/2021"')).toBeVisible() await expect(page.locator('text="01/01/2021"')).toBeVisible()
await page.click(`text=Pick a date`) await page.click(`text=Pick a date`)

View File

@@ -27,7 +27,9 @@ test('options should work', async ({ page }) => {
await page await page
.locator(`input[type="file"]`) .locator(`input[type="file"]`)
.setInputFiles([getTestAsset('avatar.jpg')]) .setInputFiles([getTestAsset('avatar.jpg')])
await expect(page.locator(`text=File uploaded`)).toBeVisible() await expect(
page.getByRole('img', { name: 'Attached image 1' })
).toBeVisible()
await page.click('text="Collect file"') await page.click('text="Collect file"')
await page.click('text="Required?"') await page.click('text="Required?"')
await page.click('text="Allow multiple files?"') await page.click('text="Allow multiple files?"')
@@ -46,9 +48,11 @@ test('options should work', async ({ page }) => {
getTestAsset('avatar.jpg'), getTestAsset('avatar.jpg'),
getTestAsset('avatar.jpg'), getTestAsset('avatar.jpg'),
]) ])
await expect(page.locator(`text="3"`)).toBeVisible() await expect(page.getByRole('img', { name: 'avatar.jpg' })).toHaveCount(3)
await page.locator('text="Go"').click() await page.locator('text="Go"').click()
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible() await expect(
page.getByRole('img', { name: 'Attached image 1' })
).toBeVisible()
}) })
test.describe('Free workspace', () => { test.describe('Free workspace', () => {

View File

@@ -1,25 +1,48 @@
import React from 'react' import React from 'react'
import { Text } from '@chakra-ui/react' import { Stack, Text } from '@chakra-ui/react'
import { WithVariableContent } from '@/features/graph/components/nodes/block/WithVariableContent' import { WithVariableContent } from '@/features/graph/components/nodes/block/WithVariableContent'
import { TextInputBlock } from '@typebot.io/schemas' import { TextInputBlock } from '@typebot.io/schemas'
import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants' import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { SetVariableLabel } from '@/components/SetVariableLabel'
type Props = { type Props = {
options: TextInputBlock['options'] options: TextInputBlock['options']
} }
export const TextInputNodeContent = ({ options }: Props) => { export const TextInputNodeContent = ({ options }: Props) => {
const { typebot } = useTypebot()
const attachmentVariableId =
typebot &&
options?.attachments?.isEnabled &&
options?.attachments.saveVariableId
if (options?.variableId) if (options?.variableId)
return ( return (
<WithVariableContent <Stack w="calc(100% - 25px)">
variableId={options?.variableId} <WithVariableContent
h={options.isLong ? '100px' : 'auto'} variableId={options?.variableId}
/> h={options.isLong ? '100px' : 'auto'}
/>
{attachmentVariableId && (
<SetVariableLabel
variables={typebot.variables}
variableId={attachmentVariableId}
/>
)}
</Stack>
) )
return ( return (
<Text color={'gray.500'} h={options?.isLong ? '100px' : 'auto'}> <Stack>
{options?.labels?.placeholder ?? <Text color={'gray.500'} h={options?.isLong ? '100px' : 'auto'}>
defaultTextInputOptions.labels.placeholder} {options?.labels?.placeholder ??
</Text> defaultTextInputOptions.labels.placeholder}
</Text>
{attachmentVariableId && (
<SetVariableLabel
variables={typebot.variables}
variableId={attachmentVariableId}
/>
)}
</Stack>
) )
} }

View File

@@ -1,9 +1,12 @@
import { DropdownList } from '@/components/DropdownList'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
import { TextInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react' import { FormLabel, Stack } from '@chakra-ui/react'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import { TextInputBlock, Variable } from '@typebot.io/schemas' import { TextInputBlock, Variable } from '@typebot.io/schemas'
import { fileVisibilityOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants'
import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants' import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
import React from 'react' import React from 'react'
@@ -14,21 +17,44 @@ type Props = {
export const TextInputSettings = ({ options, onOptionsChange }: Props) => { export const TextInputSettings = ({ options, onOptionsChange }: Props) => {
const { t } = useTranslate() const { t } = useTranslate()
const handlePlaceholderChange = (placeholder: string) => const updatePlaceholder = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } }) onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
const updateButtonLabel = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } }) onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleLongChange = (isLong: boolean) =>
const updateIsLong = (isLong: boolean) =>
onOptionsChange({ ...options, isLong }) onOptionsChange({ ...options, isLong })
const handleVariableChange = (variable?: Variable) =>
const updateVariableId = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id }) onOptionsChange({ ...options, variableId: variable?.id })
const updateAttachmentsEnabled = (isEnabled: boolean) =>
onOptionsChange({
...options,
attachments: { ...options?.attachments, isEnabled },
})
const updateAttachmentsSaveVariableId = (variable?: Pick<Variable, 'id'>) =>
onOptionsChange({
...options,
attachments: { ...options?.attachments, saveVariableId: variable?.id },
})
const updateVisibility = (
visibility: (typeof fileVisibilityOptions)[number]
) =>
onOptionsChange({
...options,
attachments: { ...options?.attachments, visibility },
})
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<SwitchWithLabel <SwitchWithLabel
label={t('blocks.inputs.text.settings.longText.label')} label={t('blocks.inputs.text.settings.longText.label')}
initialValue={options?.isLong ?? defaultTextInputOptions.isLong} initialValue={options?.isLong ?? defaultTextInputOptions.isLong}
onCheckChange={handleLongChange} onCheckChange={updateIsLong}
/> />
<TextInput <TextInput
label={t('blocks.inputs.settings.placeholder.label')} label={t('blocks.inputs.settings.placeholder.label')}
@@ -36,22 +62,50 @@ export const TextInputSettings = ({ options, onOptionsChange }: Props) => {
options?.labels?.placeholder ?? options?.labels?.placeholder ??
defaultTextInputOptions.labels.placeholder defaultTextInputOptions.labels.placeholder
} }
onChange={handlePlaceholderChange} onChange={updatePlaceholder}
/> />
<TextInput <TextInput
label={t('blocks.inputs.settings.button.label')} label={t('blocks.inputs.settings.button.label')}
defaultValue={ defaultValue={
options?.labels?.button ?? defaultTextInputOptions.labels.button options?.labels?.button ?? defaultTextInputOptions.labels.button
} }
onChange={handleButtonLabelChange} onChange={updateButtonLabel}
/> />
<SwitchWithRelatedSettings
label={'Allow attachments'}
initialValue={
options?.attachments?.isEnabled ??
defaultTextInputOptions.attachments.isEnabled
}
onCheckChange={updateAttachmentsEnabled}
>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save the URLs in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.attachments?.saveVariableId}
onSelectVariable={updateAttachmentsSaveVariableId}
/>
</Stack>
<DropdownList
label="Visibility:"
moreInfoTooltip='This setting determines who can see the uploaded files. "Public" means that anyone who has the link can see the files. "Private" means that only a members of this workspace can see the files.'
currentItem={
options?.attachments?.visibility ??
defaultTextInputOptions.attachments.visibility
}
onItemSelect={updateVisibility}
items={fileVisibilityOptions}
/>
</SwitchWithRelatedSettings>
<Stack> <Stack>
<FormLabel mb="0" htmlFor="variable"> <FormLabel mb="0" htmlFor="variable">
{t('blocks.inputs.settings.saveAnswer.label')} {t('blocks.inputs.settings.saveAnswer.label')}
</FormLabel> </FormLabel>
<VariableSearchInput <VariableSearchInput
initialVariableId={options?.variableId} initialVariableId={options?.variableId}
onSelectVariable={handleVariableChange} onSelectVariable={updateVariableId}
/> />
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -4,6 +4,7 @@ import { parseDefaultGroupWithBlock } from '@typebot.io/playwright/databaseHelpe
import { createId } from '@paralleldrive/cuid2' import { createId } from '@paralleldrive/cuid2'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants' import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants' import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.parallel('Text input block', () => { test.describe.parallel('Text input block', () => {
test('options should work', async ({ page }) => { test('options should work', async ({ page }) => {
@@ -37,4 +38,48 @@ test.describe.parallel('Text input block', () => {
).toBeVisible() ).toBeVisible()
await expect(page.getByRole('button', { name: 'Go' })).toBeVisible() await expect(page.getByRole('button', { name: 'Go' })).toBeVisible()
}) })
test('hey boy', async ({ page }) => {
const typebotId = createId()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click(`text=${defaultTextInputOptions.labels.placeholder}`)
await page.getByText('Allow attachments').click()
await page.locator('[data-testid="variables-input"]').first().click()
await page.getByText('var1').click()
await page.getByRole('button', { name: 'Test' }).click()
await page
.getByPlaceholder('Type your answer...')
.fill('Help me with these')
await page.getByLabel('Add attachments').click()
await expect(page.getByRole('menuitem', { name: 'Document' })).toBeVisible()
await expect(
page.getByRole('menuitem', { name: 'Photos & videos' })
).toBeVisible()
await page
.locator('#document-upload')
.setInputFiles(getTestAsset('typebots/theme.json'))
await expect(page.getByText('theme.json')).toBeVisible()
await page
.locator('#photos-upload')
.setInputFiles([getTestAsset('avatar.jpg'), getTestAsset('avatar.jpg')])
await expect(page.getByRole('img', { name: 'avatar.jpg' })).toHaveCount(2)
await page.getByRole('img', { name: 'avatar.jpg' }).first().hover()
await page.getByLabel('Remove attachment').first().click()
await expect(page.getByRole('img', { name: 'avatar.jpg' })).toHaveCount(1)
await page.getByLabel('Send').click()
await expect(
page.getByRole('img', { name: 'Attached image 1' })
).toBeVisible()
await expect(page.getByText('Help me with these')).toBeVisible()
})
}) })

View File

@@ -19,9 +19,9 @@ test.describe.parallel('Google sheets integration', () => {
await page.click('text=Add a value') await page.click('text=Add a value')
await page.click('text=Select a column') await page.click('text=Select a column')
await page.click('button >> text="Email"') await page.getByRole('menuitem', { name: 'Email' }).click()
await page.click('[aria-label="Insert a variable"]') await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1') await page.getByRole('menuitem', { name: 'Email' }).last().click()
await page.click('text=Add a value') await page.click('text=Add a value')
await page.click('text=Select a column') await page.click('text=Select a column')
@@ -61,11 +61,11 @@ test.describe.parallel('Google sheets integration', () => {
await page.getByRole('button', { name: 'Row(s) to update' }).click() await page.getByRole('button', { name: 'Row(s) to update' }).click()
await page.getByRole('button', { name: 'Add filter rule' }).click() await page.getByRole('button', { name: 'Add filter rule' }).click()
await page.click('text=Select a column') await page.click('text=Select a column')
await page.click('button >> text="Email"') await page.getByRole('menuitem', { name: 'Email' }).click()
await page.getByRole('button', { name: 'Select an operator' }).click() await page.getByRole('button', { name: 'Select an operator' }).click()
await page.getByRole('menuitem', { name: 'Equal to' }).click() await page.getByRole('menuitem', { name: 'Equal to' }).click()
await page.click('[aria-label="Insert a variable"]') await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1') await page.getByRole('menuitem', { name: 'Email' }).last().click()
await page.getByRole('button', { name: 'Cells to update' }).click() await page.getByRole('button', { name: 'Cells to update' }).click()
await page.click('text=Add a value') await page.click('text=Add a value')
@@ -106,11 +106,11 @@ test.describe.parallel('Google sheets integration', () => {
await page.getByRole('button', { name: 'Select row(s)' }).click() await page.getByRole('button', { name: 'Select row(s)' }).click()
await page.getByRole('button', { name: 'Add filter rule' }).click() await page.getByRole('button', { name: 'Add filter rule' }).click()
await page.click('text=Select a column') await page.click('text=Select a column')
await page.click('button >> text="Email"') await page.getByRole('menuitem', { name: 'Email' }).click()
await page.getByRole('button', { name: 'Select an operator' }).click() await page.getByRole('button', { name: 'Select an operator' }).click()
await page.getByRole('menuitem', { name: 'Equal to' }).click() await page.getByRole('menuitem', { name: 'Equal to' }).click()
await page.click('[aria-label="Insert a variable"]') await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1') await page.getByRole('menuitem', { name: 'Email' }).last().click()
await page.getByRole('button', { name: 'Add filter rule' }).click() await page.getByRole('button', { name: 'Add filter rule' }).click()
await page.getByRole('button', { name: 'AND', exact: true }).click() await page.getByRole('button', { name: 'AND', exact: true }).click()

View File

@@ -20,7 +20,7 @@ test.describe('Condition block', () => {
'input[placeholder="Search for a variable"] >> nth=-1', 'input[placeholder="Search for a variable"] >> nth=-1',
'Age' 'Age'
) )
await page.click('button:has-text("Age")') await page.getByRole('menuitem', { name: 'Age' }).click()
await page.click('button:has-text("Select an operator")') await page.click('button:has-text("Select an operator")')
await page.click('button:has-text("Greater than")', { force: true }) await page.click('button:has-text("Greater than")', { force: true })
await page.fill('input[placeholder="Type a number..."]', '80') await page.fill('input[placeholder="Type a number..."]', '80')
@@ -31,7 +31,7 @@ test.describe('Condition block', () => {
':nth-match(input[placeholder="Search for a variable"], 2)', ':nth-match(input[placeholder="Search for a variable"], 2)',
'Age' 'Age'
) )
await page.click('button:has-text("Age")') await page.getByRole('menuitem', { name: 'Age' }).click()
await page.click('button:has-text("Select an operator")') await page.click('button:has-text("Select an operator")')
await page.click('button:has-text("Less than")', { force: true }) await page.click('button:has-text("Less than")', { force: true })
await page.fill( await page.fill(
@@ -44,7 +44,7 @@ test.describe('Condition block', () => {
'input[placeholder="Search for a variable"] >> nth=-1', 'input[placeholder="Search for a variable"] >> nth=-1',
'Age' 'Age'
) )
await page.click('button:has-text("Age")') await page.getByRole('menuitem', { name: 'Age' }).click()
await page.click('button:has-text("Select an operator")') await page.click('button:has-text("Select an operator")')
await page.click('button:has-text("Greater than")', { force: true }) await page.click('button:has-text("Greater than")', { force: true })
await page.fill('input[placeholder="Type a number..."]', '20') await page.fill('input[placeholder="Type a number..."]', '20')

View File

@@ -23,10 +23,7 @@ test.describe('Set variable block', () => {
await page.click('text=Click to edit... >> nth = 0') await page.click('text=Click to edit... >> nth = 0')
await page.fill('input[placeholder="Select a variable"] >> nth=-1', 'Total') await page.fill('input[placeholder="Select a variable"] >> nth=-1', 'Total')
await page.getByRole('menuitem', { name: 'Create Total' }).click() await page.getByRole('menuitem', { name: 'Create Total' }).click()
await page await page.locator('textarea').fill('1000 * {{Num}}')
.getByTestId('code-editor')
.getByRole('textbox')
.fill('1000 * {{Num}}')
await page.click('text=Click to edit...', { force: true }) await page.click('text=Click to edit...', { force: true })
await expect(page.getByText('Save in results?')).toBeHidden() await expect(page.getByText('Save in results?')).toBeHidden()
@@ -39,10 +36,7 @@ test.describe('Set variable block', () => {
await expect( await expect(
page.getByRole('group').nth(1).locator('.chakra-switch') page.getByRole('group').nth(1).locator('.chakra-switch')
).not.toHaveAttribute('data-checked') ).not.toHaveAttribute('data-checked')
await page await page.locator('textarea').fill('Custom value')
.getByTestId('code-editor')
.getByRole('textbox')
.fill('Custom value')
await page.click('text=Click to edit...', { force: true }) await page.click('text=Click to edit...', { force: true })
await page.fill( await page.fill(
@@ -50,10 +44,7 @@ test.describe('Set variable block', () => {
'Addition' 'Addition'
) )
await page.getByRole('menuitem', { name: 'Create Addition' }).click() await page.getByRole('menuitem', { name: 'Create Addition' }).click()
await page await page.locator('textarea').fill('1000 + {{Total}}')
.getByTestId('code-editor')
.getByRole('textbox')
.fill('1000 + {{Total}}')
await page.click('text=Test') await page.click('text=Test')
await page await page
@@ -94,14 +85,14 @@ test.describe('Set variable block', () => {
await page.getByRole('button', { name: 'Test' }).click() await page.getByRole('button', { name: 'Test' }).click()
await page.getByRole('button', { name: 'There is a bug 🐛' }).click() await page.getByRole('button', { name: 'There is a bug 🐛' }).click()
await page.getByTestId('textarea').fill('Hello!!') await page.getByTestId('textarea').fill('Hello!!')
await page.getByTestId('input').getByRole('button').click() await page.getByLabel('Send').click()
await page await page
.locator('typebot-standard') .locator('typebot-standard')
.getByRole('button', { name: 'Restart' }) .getByRole('button', { name: 'Restart' })
.click() .click()
await page.getByRole('button', { name: 'I have a question 💭' }).click() await page.getByRole('button', { name: 'I have a question 💭' }).click()
await page.getByTestId('textarea').fill('How are you?') await page.getByTestId('textarea').fill('How are you?')
await page.getByTestId('input').getByRole('button').click() await page.getByLabel('Send').click()
await page.getByRole('button', { name: 'Transcription' }).click() await page.getByRole('button', { name: 'Transcription' }).click()
await expect( await expect(

View File

@@ -46,8 +46,8 @@ test('should be configurable', async ({ page }) => {
await page.getByLabel('Clear').click() await page.getByLabel('Clear').click()
await page.click('text=Test') await page.click('text=Test')
await page.locator('typebot-standard').locator('input').fill('Hello there!') await page.getByPlaceholder('Type your answer...').fill('Hello there!')
await page.locator('typebot-standard').locator('input').press('Enter') await page.getByPlaceholder('Type your answer...').press('Enter')
await expect( await expect(
page.locator('typebot-standard').locator('text=Hello there!') page.locator('typebot-standard').locator('text=Hello there!')
).toBeVisible() ).toBeVisible()

View File

@@ -9,6 +9,7 @@ import { useTranslate } from '@tolgee/react'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider' import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { WorkspaceDropdown } from '@/features/workspace/components/WorkspaceDropdown' import { WorkspaceDropdown } from '@/features/workspace/components/WorkspaceDropdown'
import { WorkspaceSettingsModal } from '@/features/workspace/components/WorkspaceSettingsModal' import { WorkspaceSettingsModal } from '@/features/workspace/components/WorkspaceSettingsModal'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
export const DashboardHeader = () => { export const DashboardHeader = () => {
const { t } = useTranslate() const { t } = useTranslate()
@@ -38,12 +39,14 @@ export const DashboardHeader = () => {
</Link> </Link>
<HStack> <HStack>
{user && workspace && !workspace.isPastDue && ( {user && workspace && !workspace.isPastDue && (
<WorkspaceSettingsModal <ParentModalProvider>
isOpen={isOpen} <WorkspaceSettingsModal
onClose={onClose} isOpen={isOpen}
user={user} onClose={onClose}
workspace={workspace} user={user}
/> workspace={workspace}
/>
</ParentModalProvider>
)} )}
{!workspace?.isPastDue && ( {!workspace?.isPastDue && (
<Button <Button

View File

@@ -171,15 +171,10 @@ const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
const newBlock = { ...block } const newBlock = { ...block }
const blockId = createId() const blockId = createId()
oldToNewIdsMapping.set(newBlock.id, blockId) oldToNewIdsMapping.set(newBlock.id, blockId)
console.log(JSON.stringify(newBlock), blockHasOptions(newBlock))
if (blockHasOptions(newBlock) && newBlock.options) { if (blockHasOptions(newBlock) && newBlock.options) {
const variableIdsToReplace = extractVariableIdsFromObject( const variableIdsToReplace = extractVariableIdsFromObject(
newBlock.options newBlock.options
).filter((v) => oldToNewIdsMapping.has(v)) ).filter((v) => oldToNewIdsMapping.has(v))
console.log(
JSON.stringify(newBlock.options),
variableIdsToReplace
)
if (variableIdsToReplace.length > 0) { if (variableIdsToReplace.length > 0) {
let optionsStr = JSON.stringify(newBlock.options) let optionsStr = JSON.stringify(newBlock.options)
variableIdsToReplace.forEach((variableId) => { variableIdsToReplace.forEach((variableId) => {

View File

@@ -43,7 +43,7 @@ export const parseReactBotProps = ({ typebot, apiHost }: BotProps) => {
} }
export const typebotImportCode = isCloudProdInstance() export const typebotImportCode = isCloudProdInstance()
? `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'`
: `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@${packageJson.version}/dist/web.js'` : `import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@${packageJson.version}/dist/web.js'`
export const parseInlineScript = (script: string) => export const parseInlineScript = (script: string) =>

View File

@@ -108,7 +108,6 @@ export const startWhatsAppPreview = authenticatedProcedure
setVariableHistory, setVariableHistory,
} = await startSession({ } = await startSession({
version: 2, version: 2,
message: undefined,
startParams: { startParams: {
isOnlyRegistering: !canSendDirectMessagesToUser, isOnlyRegistering: !canSendDirectMessagesToUser,
type: 'preview', type: 'preview',

View File

@@ -25,6 +25,7 @@ import { UserPreferencesForm } from '@/features/account/components/UserPreferenc
import { MyAccountForm } from '@/features/account/components/MyAccountForm' import { MyAccountForm } from '@/features/account/components/MyAccountForm'
import { BillingSettingsLayout } from '@/features/billing/components/BillingSettingsLayout' import { BillingSettingsLayout } from '@/features/billing/components/BillingSettingsLayout'
import { useTranslate } from '@tolgee/react' import { useTranslate } from '@tolgee/react'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
type Props = { type Props = {
isOpen: boolean isOpen: boolean
@@ -47,6 +48,7 @@ export const WorkspaceSettingsModal = ({
onClose, onClose,
}: Props) => { }: Props) => {
const { t } = useTranslate() const { t } = useTranslate()
const { ref } = useParentModal()
const { currentRole } = useWorkspace() const { currentRole } = useWorkspace()
const [selectedTab, setSelectedTab] = useState<SettingsTab>('my-account') const [selectedTab, setSelectedTab] = useState<SettingsTab>('my-account')
@@ -55,7 +57,7 @@ export const WorkspaceSettingsModal = ({
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl"> <Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay /> <ModalOverlay />
<ModalContent minH="600px" flexDir="row"> <ModalContent minH="600px" flexDir="row" ref={ref}>
<Stack <Stack
spacing={8} spacing={8}
w="180px" w="180px"

View File

@@ -16,20 +16,36 @@ import { mockedUser } from '@typebot.io/lib/mockedUser'
import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations' import { getNewUserInvitations } from '@/features/auth/helpers/getNewUserInvitations'
import { sendVerificationRequest } from '@/features/auth/helpers/sendVerificationRequest' import { sendVerificationRequest } from '@/features/auth/helpers/sendVerificationRequest'
import { Ratelimit } from '@upstash/ratelimit' import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis/nodejs'
import ky from 'ky' import ky from 'ky'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import * as Sentry from '@sentry/nextjs' import * as Sentry from '@sentry/nextjs'
import { getIp } from '@typebot.io/lib/getIp' import { getIp } from '@typebot.io/lib/getIp'
import { trackEvents } from '@typebot.io/telemetry/trackEvents' import { trackEvents } from '@typebot.io/telemetry/trackEvents'
import Redis from 'ioredis'
const providers: Provider[] = [] const providers: Provider[] = []
let rateLimit: Ratelimit | undefined let emailSignInRateLimiter: Ratelimit | undefined
if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { if (env.REDIS_URL) {
rateLimit = new Ratelimit({ const redis = new Redis(env.REDIS_URL)
redis: Redis.fromEnv(), const rateLimitCompatibleRedis = {
sadd: <TData>(key: string, ...members: TData[]) =>
redis.sadd(key, ...members.map((m) => String(m))),
eval: async <TArgs extends unknown[], TData = unknown>(
script: string,
keys: string[],
args: TArgs
) =>
redis.eval(
script,
keys.length,
...keys,
...(args ?? []).map((a) => String(a))
) as Promise<TData>,
}
emailSignInRateLimiter = new Ratelimit({
redis: rateLimitCompatibleRedis,
limiter: Ratelimit.slidingWindow(1, '60 s'), limiter: Ratelimit.slidingWindow(1, '60 s'),
}) })
} }
@@ -229,13 +245,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let restricted: 'rate-limited' | undefined let restricted: 'rate-limited' | undefined
if ( if (
rateLimit && emailSignInRateLimiter &&
req.url?.startsWith('/api/auth/signin/email') && req.url?.startsWith('/api/auth/signin/email') &&
req.method === 'POST' req.method === 'POST'
) { ) {
const ip = getIp(req) const ip = getIp(req)
if (ip) { if (ip) {
const { success } = await rateLimit.limit(ip) const { success } = await emailSignInRateLimiter.limit(ip)
if (!success) restricted = 'rate-limited' if (!success) restricted = 'rate-limited'
} }
} }

View File

@@ -0,0 +1,62 @@
import prisma from '@typebot.io/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import {
methodNotAllowed,
notAuthenticated,
notFound,
} from '@typebot.io/lib/api'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { downloadMedia } from '@typebot.io/bot-engine/whatsapp/downloadMedia'
import { env } from '@typebot.io/env'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
if (!env.META_SYSTEM_USER_TOKEN)
return res
.status(400)
.json({ error: 'Meta system user token is not set' })
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)
const typebotId = req.query.typebotId as string
const typebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
whatsAppCredentialsId: true,
workspace: {
select: {
members: {
select: {
userId: true,
},
},
},
},
},
})
if (!typebot?.workspace || isReadWorkspaceFobidden(typebot.workspace, user))
return notFound(res, 'Workspace not found')
if (!typebot) return notFound(res, 'Typebot not found')
const mediaId = req.query.mediaId as string
const { file, mimeType } = await downloadMedia({
mediaId,
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
})
res.setHeader('Content-Type', mimeType)
res.setHeader('Cache-Control', 'public, max-age=86400')
return res.send(file)
}
return methodNotAllowed(res)
}
export default handler

View File

@@ -10,7 +10,7 @@ There, you can change the container dimensions. Here is a code example:
```html ```html
<script type="module"> <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.initStandard({
typebot: 'my-typebot', typebot: 'my-typebot',
@@ -28,7 +28,7 @@ If you have different bots on the same page you will have to make them distinct
```html ```html
<script type="module"> <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.initStandard({
id: 'bot1' id: 'bot1'
@@ -60,7 +60,7 @@ Here is an example:
```html ```html
<script type="module"> <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.initPopup({
typebot: 'my-typebot', typebot: 'my-typebot',
@@ -80,7 +80,7 @@ Here is an example:
```html ```html
<script type="module"> <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.initBubble({
typebot: 'my-typebot', typebot: 'my-typebot',

View File

@@ -24,7 +24,7 @@ It should look like:
```html ```html
<script type="module"> <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.initPopup({
typebot: 'my-typebot', typebot: 'my-typebot',

View File

@@ -11157,6 +11157,12 @@
}, },
"content": { "content": {
"type": "string" "type": "string"
},
"attachedFileUrls": {
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"required": [ "required": [
@@ -11452,6 +11458,12 @@
}, },
"content": { "content": {
"type": "string" "type": "string"
},
"attachedFileUrls": {
"type": "array",
"items": {
"type": "string"
}
} }
}, },
"required": [ "required": [
@@ -14664,6 +14676,9 @@
"properties": { "properties": {
"id": { "id": {
"type": "string" "type": "string"
},
"caption": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -14698,6 +14713,9 @@
"properties": { "properties": {
"id": { "id": {
"type": "string" "type": "string"
},
"caption": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -14766,6 +14784,9 @@
"properties": { "properties": {
"id": { "id": {
"type": "string" "type": "string"
},
"caption": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -15605,6 +15626,25 @@
}, },
"isLong": { "isLong": {
"type": "boolean" "type": "boolean"
},
"attachments": {
"type": "object",
"properties": {
"isEnabled": {
"type": "boolean"
},
"saveVariableId": {
"type": "string"
},
"visibility": {
"type": "string",
"enum": [
"Auto",
"Public",
"Private"
]
}
}
} }
} }
} }

View File

@@ -174,14 +174,12 @@ The Authorization callback URL should be `$NEXTAUTH_URL/api/auth/callback/azure-
Used for authenticating with Keycloak. Used for authenticating with Keycloak.
Follow the official Keycloak guide for creating OAuth2 applications [here](https://www.keycloak.org/). Follow the official Keycloak guide for creating OAuth2 applications [here](https://www.keycloak.org/).
| Parameter | Default | Description | | Parameter | Default | Description |
| ------------------------ | ------------------ | ------------------------------------------------------------------------------------ | | ---------------------- | ------- | --------------------------------- |
| KEYCLOAK_CLIENT_ID | | Application client ID. | | KEYCLOAK_CLIENT_ID | | Application client ID. |
| KEYCLOAK_CLIENT_SECRET | | Application secret | | KEYCLOAK_CLIENT_SECRET | | Application secret |
| KEYCLOAK_REALM | | Your Keycloak Realm | | KEYCLOAK_REALM | | Your Keycloak Realm |
| KEYCLOAK_BASE_URL | | Base URL of the Keycloak instance | | KEYCLOAK_BASE_URL | | Base URL of the Keycloak instance |
## Custom OAuth Provider (Auth) ## Custom OAuth Provider (Auth)
@@ -306,6 +304,17 @@ In order to be able to test your bot on WhatsApp from the Preview drawer, you ne
| WHATSAPP_CLOUD_API_URL | https://graph.facebook.com | The WhatsApp Cloud API base URL | | WHATSAPP_CLOUD_API_URL | https://graph.facebook.com | The WhatsApp Cloud API base URL |
| WHATSAPP_INTERACTIVE_GROUP_SIZE | 3 | The array size of items to send to API on choice input. You can't choose a number higher than 3 if you are using the official cloud API URL. | | WHATSAPP_INTERACTIVE_GROUP_SIZE | 3 | The array size of items to send to API on choice input. You can't choose a number higher than 3 if you are using the official cloud API URL. |
## Redis
In Typebot, Redis is optional and is used to:
- Rate limit the sign in requests based on user IP
- Enable multiple media upload on WhatsApp
| Parameter | Default | Description |
| --------- | ------- | -------------------------------------------------------------------- |
| REDIS_URL | | The database URL. i.e. `redis://<username>:<password>@<host>:<port>` |
## Others ## Others
The [official Typebot managed service](https://app.typebot.io/) uses other services such as [Stripe](https://stripe.com/) for processing payments, [Sentry](https://sentry.io/) for tracking bugs and [Sleekplan](https://sleekplan.com/) for user feedbacks. The [official Typebot managed service](https://app.typebot.io/) uses other services such as [Stripe](https://stripe.com/) for processing payments, [Sentry](https://sentry.io/) for tracking bugs and [Sleekplan](https://sleekplan.com/) for user feedbacks.

View File

@@ -1,5 +1,8 @@
import { publicProcedure } from '@/helpers/server/trpc' import { publicProcedure } from '@/helpers/server/trpc'
import { continueChatResponseSchema } from '@typebot.io/schemas/features/chat/schema' import {
continueChatResponseSchema,
messageSchema,
} from '@typebot.io/schemas/features/chat/schema'
import { z } from 'zod' import { z } from 'zod'
import { continueChat as continueChatFn } from '@typebot.io/bot-engine/apiHandlers/continueChat' import { continueChat as continueChatFn } from '@typebot.io/bot-engine/apiHandlers/continueChat'
@@ -13,7 +16,7 @@ export const continueChat = publicProcedure
}) })
.input( .input(
z.object({ z.object({
message: z.string().optional(), message: messageSchema.optional(),
sessionId: z sessionId: z
.string() .string()
.describe( .describe(

View File

@@ -91,7 +91,9 @@ export const sendMessageV1 = publicProcedure
typeof startParams.typebot === 'string' typeof startParams.typebot === 'string'
? undefined ? undefined
: startParams.typebot, : startParams.typebot,
message, message: message
? { type: 'text', text: message }
: undefined,
userId: user?.id, userId: user?.id,
textBubbleContentFormat: 'richText', textBubbleContentFormat: 'richText',
} }
@@ -102,10 +104,11 @@ export const sendMessageV1 = publicProcedure
publicId: startParams.typebot, publicId: startParams.typebot,
prefilledVariables: startParams.prefilledVariables, prefilledVariables: startParams.prefilledVariables,
resultId: startParams.resultId, resultId: startParams.resultId,
message, message: message
? { type: 'text', text: message }
: undefined,
textBubbleContentFormat: 'richText', textBubbleContentFormat: 'richText',
}, },
message,
}) })
if (startParams.isPreview || typeof startParams.typebot !== 'string') { if (startParams.isPreview || typeof startParams.typebot !== 'string') {
@@ -185,11 +188,14 @@ export const sendMessageV1 = publicProcedure
lastMessageNewFormat, lastMessageNewFormat,
visitedEdges, visitedEdges,
setVariableHistory, setVariableHistory,
} = await continueBotFlow(message, { } = await continueBotFlow(
version: 1, message ? { type: 'text', text: message } : undefined,
state: session.state, {
textBubbleContentFormat: 'richText', version: 1,
}) state: session.state,
textBubbleContentFormat: 'richText',
}
)
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs

View File

@@ -91,7 +91,9 @@ export const sendMessageV2 = publicProcedure
typeof startParams.typebot === 'string' typeof startParams.typebot === 'string'
? undefined ? undefined
: startParams.typebot, : startParams.typebot,
message, message: message
? { type: 'text', text: message }
: undefined,
userId: user?.id, userId: user?.id,
textBubbleContentFormat: 'richText', textBubbleContentFormat: 'richText',
} }
@@ -102,10 +104,11 @@ export const sendMessageV2 = publicProcedure
publicId: startParams.typebot, publicId: startParams.typebot,
prefilledVariables: startParams.prefilledVariables, prefilledVariables: startParams.prefilledVariables,
resultId: startParams.resultId, resultId: startParams.resultId,
message, message: message
? { type: 'text', text: message }
: undefined,
textBubbleContentFormat: 'richText', textBubbleContentFormat: 'richText',
}, },
message,
}) })
if (startParams.isPreview || typeof startParams.typebot !== 'string') { if (startParams.isPreview || typeof startParams.typebot !== 'string') {
@@ -184,11 +187,14 @@ export const sendMessageV2 = publicProcedure
lastMessageNewFormat, lastMessageNewFormat,
visitedEdges, visitedEdges,
setVariableHistory, setVariableHistory,
} = await continueBotFlow(message, { } = await continueBotFlow(
version: 2, message ? { type: 'text', text: message } : undefined,
state: session.state, {
textBubbleContentFormat: 'richText', version: 2,
}) state: session.state,
textBubbleContentFormat: 'richText',
}
)
const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs const allLogs = clientLogs ? [...(logs ?? []), ...clientLogs] : logs

View File

@@ -5,9 +5,14 @@ import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresigne
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma' import prisma from '@typebot.io/lib/prisma'
import { getSession } from '@typebot.io/bot-engine/queries/getSession' import { getSession } from '@typebot.io/bot-engine/queries/getSession'
import { parseGroups } from '@typebot.io/schemas' import {
FileInputBlock,
parseGroups,
TextInputBlock,
} from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants' import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { getBlockById } from '@typebot.io/schemas/helpers' import { getBlockById } from '@typebot.io/schemas/helpers'
import { PublicTypebot } from '@typebot.io/prisma'
export const generateUploadUrl = publicProcedure export const generateUploadUrl = publicProcedure
.meta({ .meta({
@@ -50,27 +55,18 @@ export const generateUploadUrl = publicProcedure
const typebotId = session.state.typebotsQueue[0].typebot.id const typebotId = session.state.typebotsQueue[0].typebot.id
const publicTypebot = await prisma.publicTypebot.findFirst({ const isPreview = session.state.typebotsQueue[0].resultId
where: {
typebotId,
},
select: {
version: true,
groups: true,
typebot: {
select: {
workspaceId: true,
},
},
},
})
const workspaceId = publicTypebot?.typebot.workspaceId const typebot = session.state.typebotsQueue[0].resultId
? await getAndParsePublicTypebot(
session.state.typebotsQueue[0].typebot.id
)
: session.state.typebotsQueue[0].typebot
if (!workspaceId) if (!typebot?.version)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: "Can't find workspaceId", message: "Can't find typebot",
}) })
if (session.state.currentBlockId === undefined) if (session.state.currentBlockId === undefined)
@@ -79,42 +75,93 @@ export const generateUploadUrl = publicProcedure
message: "Can't find currentBlockId in session state", message: "Can't find currentBlockId in session state",
}) })
const { block: fileUploadBlock } = getBlockById( const { block } = getBlockById(
session.state.currentBlockId, session.state.currentBlockId,
parseGroups(publicTypebot.groups, { parseGroups(typebot.groups, {
typebotVersion: publicTypebot.version, typebotVersion: typebot.version,
}) })
) )
if (fileUploadBlock?.type !== InputBlockType.FILE) if (
block?.type !== InputBlockType.FILE &&
(block.type !== InputBlockType.TEXT ||
!block.options?.attachments?.isEnabled)
)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: "Can't find file upload block", message: 'Current block does not expect file upload',
}) })
const { visibility, maxFileSize } = parseFileUploadParams(block)
const resultId = session.state.typebotsQueue[0].resultId const resultId = session.state.typebotsQueue[0].resultId
const filePath = `${ const filePath =
fileUploadBlock.options?.visibility === 'Private' ? 'private' : 'public' 'workspaceId' in typebot && typebot.workspaceId
}/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${fileName}` ? `${visibility === 'Private' ? 'private' : 'public'}/workspaces/${
typebot.workspaceId
}/typebots/${typebotId}/results/${resultId}/${fileName}`
: `/public/tmp/${typebotId}/${fileName}`
const presignedPostPolicy = await generatePresignedPostPolicy({ const presignedPostPolicy = await generatePresignedPostPolicy({
fileType, fileType,
filePath, filePath,
maxFileSize: maxFileSize,
fileUploadBlock.options && 'sizeLimit' in fileUploadBlock.options
? (fileUploadBlock.options.sizeLimit as number)
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
}) })
return { return {
presignedUrl: presignedPostPolicy.postURL, presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData, formData: presignedPostPolicy.formData,
fileUrl: fileUrl:
fileUploadBlock.options?.visibility === 'Private' visibility === 'Private' && !isPreview
? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}` ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}`
: env.S3_PUBLIC_CUSTOM_DOMAIN : env.S3_PUBLIC_CUSTOM_DOMAIN
? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
} }
}) })
const getAndParsePublicTypebot = async (typebotId: string) => {
const publicTypebot = (await prisma.publicTypebot.findFirst({
where: {
typebotId,
},
select: {
version: true,
groups: true,
typebot: {
select: {
workspaceId: true,
},
},
},
})) as (PublicTypebot & { typebot: { workspaceId: string } }) | null
return {
...publicTypebot,
workspaceId: publicTypebot?.typebot.workspaceId,
}
}
const parseFileUploadParams = (
block: FileInputBlock | TextInputBlock
): { visibility: 'Public' | 'Private'; maxFileSize: number | undefined } => {
if (block.type === InputBlockType.FILE) {
return {
visibility:
block.options?.visibility === 'Private' ? 'Private' : 'Public',
maxFileSize:
block.options && 'sizeLimit' in block.options
? (block.options.sizeLimit as number)
: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
}
}
return {
visibility:
block.options?.attachments?.visibility === 'Private'
? 'Private'
: 'Public',
maxFileSize: env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE,
}
}

View File

@@ -13,7 +13,7 @@ import { StartChatInput, StartPreviewChatInput } from '@typebot.io/schemas'
test.afterEach(async () => { test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id']) await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot']) await deleteTypebots(['chat-sub-bot', 'starting-with-input'])
}) })
test('API chat execution should work on preview bot', async ({ request }) => { test('API chat execution should work on preview bot', async ({ request }) => {
@@ -108,6 +108,13 @@ test('API chat execution should work on published bot', async ({ request }) => {
id: 'chat-sub-bot', id: 'chat-sub-bot',
publicId: 'chat-sub-bot-public', publicId: 'chat-sub-bot-public',
}) })
await importTypebotInDatabase(
getTestAsset('typebots/chat/startingWithInput.json'),
{
id: 'starting-with-input',
publicId: 'starting-with-input-public',
}
)
await createWebhook(typebotId, { await createWebhook(typebotId, {
id: 'chat-webhook-id', id: 'chat-webhook-id',
method: HttpMethod.GET, method: HttpMethod.GET,
@@ -296,11 +303,12 @@ test('API chat execution should work on published bot', async ({ request }) => {
expect(messages[2].content.richText.length).toBeGreaterThan(0) expect(messages[2].content.richText.length).toBeGreaterThan(0)
}) })
await test.step('Starting with a message when typebot starts with input should proceed', async () => { await test.step('Starting with a message when typebot starts with input should proceed', async () => {
const { messages } = await ( const response = await (
await request.post( await request.post(
`/api/v1/typebots/starting-with-input-public/startChat`, `/api/v1/typebots/starting-with-input-public/startChat`,
{ {
data: { data: {
//@ts-expect-error We want to test if message is correctly preprocessed by zod
message: 'Hey', message: 'Hey',
isStreamEnabled: false, isStreamEnabled: false,
isOnlyRegistering: false, isOnlyRegistering: false,
@@ -309,7 +317,7 @@ test('API chat execution should work on published bot', async ({ request }) => {
} }
) )
).json() ).json()
expect(messages[0].content.richText).toStrictEqual([ expect(response.messages[0].content.richText).toStrictEqual([
{ {
children: [ children: [
{ {

View File

@@ -14,11 +14,11 @@ test('Transcript set variable should be correctly computed', async ({
await page.goto(`/${typebotId}-public`) await page.goto(`/${typebotId}-public`)
await page.getByPlaceholder('Type your answer...').fill('hey') await page.getByPlaceholder('Type your answer...').fill('hey')
await page.getByRole('button').click() await page.getByLabel('Send').click()
await page.getByPlaceholder('Type your answer...').fill('hey 2') await page.getByPlaceholder('Type your answer...').fill('hey 2')
await page.getByRole('button').click() await page.getByLabel('Send').click()
await page.getByPlaceholder('Type your answer...').fill('hey 3') await page.getByPlaceholder('Type your answer...').fill('hey 3')
await page.getByRole('button').click() await page.getByLabel('Send').click()
await expect( await expect(
page.getByText('Assistant: "How are you? You said "') page.getByText('Assistant: "How are you? You said "')
).toBeVisible() ).toBeVisible()

View File

@@ -31,8 +31,8 @@ test.beforeAll(async () => {
test('should work as expected', async ({ page }) => { test('should work as expected', async ({ page }) => {
await page.goto(`/${typebotId}-public`) await page.goto(`/${typebotId}-public`)
await page.locator('input').fill('Hello there!') await page.getByPlaceholder('Type your answer...').fill('Hello there!')
await page.locator('input').press('Enter') await page.getByPlaceholder('Type your answer...').press('Enter')
await expect(page.getByText('Cheers!')).toBeVisible() await expect(page.getByText('Cheers!')).toBeVisible()
await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`) await page.goto(`${env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible() await expect(page.locator('text=Hello there!')).toBeVisible()
@@ -41,8 +41,8 @@ test('should work as expected', async ({ page }) => {
test.describe('Merge disabled', () => { test.describe('Merge disabled', () => {
test('should work as expected', async ({ page }) => { test('should work as expected', async ({ page }) => {
await page.goto(`/${typebotWithMergeDisabledId}-public`) await page.goto(`/${typebotWithMergeDisabledId}-public`)
await page.locator('input').fill('Hello there!') await page.getByPlaceholder('Type your answer...').fill('Hello there!')
await page.locator('input').press('Enter') await page.getByPlaceholder('Type your answer...').press('Enter')
await expect(page.getByText('Cheers!')).toBeVisible() await expect(page.getByText('Cheers!')).toBeVisible()
await page.goto( await page.goto(
`${process.env.NEXTAUTH_URL}/typebots/${typebotWithMergeDisabledId}/results` `${process.env.NEXTAUTH_URL}/typebots/${typebotWithMergeDisabledId}/results`

View File

@@ -7,10 +7,11 @@ import { parseDynamicTheme } from '../parseDynamicTheme'
import { saveStateToDatabase } from '../saveStateToDatabase' import { saveStateToDatabase } from '../saveStateToDatabase'
import { computeCurrentProgress } from '../computeCurrentProgress' import { computeCurrentProgress } from '../computeCurrentProgress'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { Message } from '@typebot.io/schemas'
type Props = { type Props = {
origin: string | undefined origin: string | undefined
message?: string message?: Message
sessionId: string sessionId: string
textBubbleContentFormat: 'richText' | 'markdown' textBubbleContentFormat: 'richText' | 'markdown'
} }

View File

@@ -1,14 +1,14 @@
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { Message } from '@typebot.io/schemas'
import { computeCurrentProgress } from '../computeCurrentProgress' import { computeCurrentProgress } from '../computeCurrentProgress'
import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs' import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs'
import { restartSession } from '../queries/restartSession' import { restartSession } from '../queries/restartSession'
import { saveStateToDatabase } from '../saveStateToDatabase' import { saveStateToDatabase } from '../saveStateToDatabase'
import { startSession } from '../startSession' import { startSession } from '../startSession'
import { isNotEmpty } from '@typebot.io/lib'
type Props = { type Props = {
origin: string | undefined origin: string | undefined
message?: string message?: Message
isOnlyRegistering: boolean isOnlyRegistering: boolean
publicId: string publicId: string
isStreamEnabled: boolean isStreamEnabled: boolean
@@ -48,8 +48,8 @@ export const startChat = async ({
prefilledVariables, prefilledVariables,
resultId: startResultId, resultId: startResultId,
textBubbleContentFormat, textBubbleContentFormat,
message,
}, },
message,
}) })
let corsOrigin let corsOrigin

View File

@@ -1,4 +1,4 @@
import { StartFrom, StartTypebot } from '@typebot.io/schemas' import { Message, StartFrom, StartTypebot } from '@typebot.io/schemas'
import { restartSession } from '../queries/restartSession' import { restartSession } from '../queries/restartSession'
import { saveStateToDatabase } from '../saveStateToDatabase' import { saveStateToDatabase } from '../saveStateToDatabase'
import { startSession } from '../startSession' import { startSession } from '../startSession'
@@ -6,7 +6,7 @@ import { computeCurrentProgress } from '../computeCurrentProgress'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
type Props = { type Props = {
message?: string message?: Message
isOnlyRegistering: boolean isOnlyRegistering: boolean
isStreamEnabled: boolean isStreamEnabled: boolean
startFrom?: StartFrom startFrom?: StartFrom
@@ -53,8 +53,8 @@ export const startChatPreview = async ({
prefilledVariables, prefilledVariables,
sessionId, sessionId,
textBubbleContentFormat, textBubbleContentFormat,
message,
}, },
message,
}) })
const session = isOnlyRegistering const session = isOnlyRegistering

View File

@@ -23,6 +23,7 @@ import {
} from '@typebot.io/schemas/features/blocks/logic/setVariable/constants' } from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
import { createCodeRunner } from '@typebot.io/variables/codeRunners' import { createCodeRunner } from '@typebot.io/variables/codeRunners'
import { stringifyError } from '@typebot.io/lib/stringifyError' import { stringifyError } from '@typebot.io/lib/stringifyError'
import { AnswerV2 } from '@typebot.io/prisma'
export const executeSetVariable = async ( export const executeSetVariable = async (
state: SessionState, state: SessionState,
@@ -246,7 +247,7 @@ const toISOWithTz = (date: Date, timeZone: string) => {
} }
type ParsedTranscriptProps = { type ParsedTranscriptProps = {
answers: Pick<Answer, 'blockId' | 'content'>[] answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
setVariableHistory: Pick< setVariableHistory: Pick<
SetVariableHistoryItem, SetVariableHistoryItem,
'blockId' | 'variableId' | 'value' 'blockId' | 'variableId' | 'value'
@@ -273,6 +274,10 @@ const parsePreviewTranscriptProps = async (
} }
} }
type UnifiedAnswersFromDB = (ParsedTranscriptProps['answers'][number] & {
createdAt: Date
})[]
const parseResultTranscriptProps = async ( const parseResultTranscriptProps = async (
state: SessionState state: SessionState
): Promise<ParsedTranscriptProps | undefined> => { ): Promise<ParsedTranscriptProps | undefined> => {
@@ -299,6 +304,7 @@ const parseResultTranscriptProps = async (
blockId: true, blockId: true,
content: true, content: true,
createdAt: true, createdAt: true,
attachedFileUrls: true,
}, },
}, },
setVariableHistory: { setVariableHistory: {
@@ -313,8 +319,8 @@ const parseResultTranscriptProps = async (
}) })
if (!result) return if (!result) return
return { return {
answers: result.answersV2 answers: (result.answersV2 as UnifiedAnswersFromDB)
.concat(result.answers) .concat(result.answers as UnifiedAnswersFromDB)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()), .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()),
setVariableHistory: ( setVariableHistory: (
result.setVariableHistory as SetVariableHistoryItem[] result.setVariableHistory as SetVariableHistoryItem[]

View File

@@ -4,11 +4,12 @@ import {
ContinueChatResponse, ContinueChatResponse,
Group, Group,
InputBlock, InputBlock,
Message,
SessionState, SessionState,
SetVariableHistoryItem, SetVariableHistoryItem,
Variable, Variable,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { byId } from '@typebot.io/lib' import { byId, isDefined } from '@typebot.io/lib'
import { isInputBlock } from '@typebot.io/schemas/helpers' import { isInputBlock } from '@typebot.io/schemas/helpers'
import { executeGroup, parseInput } from './executeGroup' import { executeGroup, parseInput } from './executeGroup'
import { getNextGroup } from './getNextGroup' import { getNextGroup } from './getNextGroup'
@@ -42,10 +43,9 @@ import { ForgedBlock } from '@typebot.io/forge-repository/types'
import { forgedBlocks } from '@typebot.io/forge-repository/definitions' import { forgedBlocks } from '@typebot.io/forge-repository/definitions'
import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion' import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { downloadMedia } from './whatsapp/downloadMedia'
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
import { isURL } from '@typebot.io/lib/validators/isURL' import { isURL } from '@typebot.io/lib/validators/isURL'
import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers' import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers'
import { resetSessionState } from './resetSessionState'
type Params = { type Params = {
version: 1 | 2 version: 1 | 2
@@ -69,7 +69,11 @@ export const continueBotFlow = async (
const setVariableHistory: SetVariableHistoryItem[] = [] const setVariableHistory: SetVariableHistoryItem[] = []
if (!newSessionState.currentBlockId) if (!newSessionState.currentBlockId)
return startBotFlow({ state, version, textBubbleContentFormat }) return startBotFlow({
state: resetSessionState(newSessionState),
version,
textBubbleContentFormat,
})
const { block, group, blockIndex } = getBlockById( const { block, group, blockIndex } = getBlockById(
newSessionState.currentBlockId, newSessionState.currentBlockId,
@@ -161,7 +165,7 @@ export const continueBotFlow = async (
newVariables: [ newVariables: [
{ {
...variableToUpdate, ...variableToUpdate,
value: safeJsonParse(reply as string), value: reply?.text ? safeJsonParse(reply?.text) : undefined,
}, },
], ],
}) })
@@ -187,7 +191,14 @@ export const continueBotFlow = async (
formattedReply = formattedReply =
'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined 'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined
newSessionState = await processAndSaveAnswer(state, block)(formattedReply) newSessionState = await processAndSaveAnswer(
state,
block
)(
isDefined(formattedReply)
? { ...reply, type: 'text', text: formattedReply }
: undefined
)
} }
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1 const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
@@ -267,37 +278,92 @@ export const continueBotFlow = async (
const processAndSaveAnswer = const processAndSaveAnswer =
(state: SessionState, block: InputBlock) => (state: SessionState, block: InputBlock) =>
async (reply: string | undefined): Promise<SessionState> => { async (reply: Message | undefined): Promise<SessionState> => {
if (!reply) return state if (!reply) return state
let newState = await saveAnswerInDb(state, block)(reply) return saveAnswerInDb(state, block)(reply)
return newState
} }
const saveVariableValueIfAny = const saveVariablesValueIfAny =
(state: SessionState, block: InputBlock) => (state: SessionState, block: InputBlock) =>
(reply: string): SessionState => { (reply: Message): SessionState => {
if (!block.options?.variableId) return state if (!block.options?.variableId) return state
const foundVariable = state.typebotsQueue[0].typebot.variables.find( const newSessionState = saveAttachmentsVarIfAny({ block, reply, state })
(variable) => variable.id === block.options?.variableId return saveInputVarIfAny({ block, reply, state: newSessionState })
)
if (!foundVariable) return state
const { updatedState } = updateVariablesInSession({
newVariables: [
{
...foundVariable,
value: Array.isArray(foundVariable.value)
? foundVariable.value.concat(reply)
: reply,
},
],
currentBlockId: undefined,
state,
})
return updatedState
} }
const saveAttachmentsVarIfAny = ({
block,
reply,
state,
}: {
block: InputBlock
reply: Message
state: SessionState
}): SessionState => {
if (
block.type !== InputBlockType.TEXT ||
!block.options?.attachments?.isEnabled ||
!block.options?.attachments?.saveVariableId ||
!reply.attachedFileUrls ||
reply.attachedFileUrls.length === 0
)
return state
const variable = state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options?.attachments?.saveVariableId
)
if (!variable) return state
const { updatedState } = updateVariablesInSession({
newVariables: [
{
id: variable.id,
name: variable.name,
value: Array.isArray(variable.value)
? variable.value.concat(reply.attachedFileUrls)
: reply.attachedFileUrls.length === 1
? reply.attachedFileUrls[0]
: reply.attachedFileUrls,
},
],
currentBlockId: undefined,
state,
})
return updatedState
}
const saveInputVarIfAny = ({
block,
reply,
state,
}: {
block: InputBlock
reply: Message
state: SessionState
}): SessionState => {
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options?.variableId
)
if (!foundVariable) return state
const { updatedState } = updateVariablesInSession({
newVariables: [
{
...foundVariable,
value:
Array.isArray(foundVariable.value) && reply.text
? foundVariable.value.concat(reply.text)
: reply.text,
},
],
currentBlockId: undefined,
state,
})
return updatedState
}
const parseRetryMessage = const parseRetryMessage =
(state: SessionState) => (state: SessionState) =>
async ( async (
@@ -346,26 +412,27 @@ const parseDefaultRetryMessage = (block: InputBlock): string => {
const saveAnswerInDb = const saveAnswerInDb =
(state: SessionState, block: InputBlock) => (state: SessionState, block: InputBlock) =>
async (reply: string): Promise<SessionState> => { async (reply: Message): Promise<SessionState> => {
let newSessionState = state let newSessionState = state
await saveAnswer({ await saveAnswer({
answer: { answer: {
blockId: block.id, blockId: block.id,
content: reply, content: reply.text,
attachedFileUrls: reply.attachedFileUrls,
}, },
reply,
state, state,
}) })
newSessionState = { newSessionState = {
...saveVariableValueIfAny(newSessionState, block)(reply), ...saveVariablesValueIfAny(newSessionState, block)(reply),
previewMetadata: state.typebotsQueue[0].resultId previewMetadata: state.typebotsQueue[0].resultId
? newSessionState.previewMetadata ? newSessionState.previewMetadata
: { : {
...newSessionState.previewMetadata, ...newSessionState.previewMetadata,
answers: (newSessionState.previewMetadata?.answers ?? []).concat({ answers: (newSessionState.previewMetadata?.answers ?? []).concat({
blockId: block.id, blockId: block.id,
content: reply, content: reply.text,
attachedFileUrls: reply.attachedFileUrls,
}), }),
}, },
} }
@@ -378,7 +445,10 @@ const saveAnswerInDb =
return setNewAnswerInState(newSessionState)({ return setNewAnswerInState(newSessionState)({
key: key ?? block.id, key: key ?? block.id,
value: reply, value:
(reply.attachedFileUrls ?? []).length > 0
? `${reply.attachedFileUrls!.join(', ')}\n\n${reply.text}`
: reply.text,
}) })
} }
@@ -465,41 +535,17 @@ const getOutgoingEdgeId =
const parseReply = const parseReply =
(state: SessionState) => (state: SessionState) =>
async (reply: Reply, block: InputBlock): Promise<ParsedReply> => { async (reply: Reply, block: InputBlock): Promise<ParsedReply> => {
if (reply && typeof reply !== 'string') {
if (block.type !== InputBlockType.FILE) return { status: 'fail' }
if (block.options?.visibility !== 'Public') {
return {
status: 'success',
reply:
env.NEXTAUTH_URL +
`/api/typebots/${state.typebotsQueue[0].typebot.id}/whatsapp/media/${reply.mediaId}`,
}
}
const { file, mimeType } = await downloadMedia({
mediaId: reply.mediaId,
systemUserAccessToken: reply.accessToken,
})
const url = await uploadFileToBucket({
file,
key: `public/workspaces/${reply.workspaceId}/typebots/${state.typebotsQueue[0].typebot.id}/results/${state.typebotsQueue[0].resultId}/${reply.mediaId}`,
mimeType,
})
return {
status: 'success',
reply: url,
}
}
switch (block.type) { switch (block.type) {
case InputBlockType.EMAIL: { case InputBlockType.EMAIL: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
const formattedEmail = formatEmail(reply) const formattedEmail = formatEmail(reply.text)
if (!formattedEmail) return { status: 'fail' } if (!formattedEmail) return { status: 'fail' }
return { status: 'success', reply: formattedEmail } return { status: 'success', reply: formattedEmail }
} }
case InputBlockType.PHONE: { case InputBlockType.PHONE: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
const formattedPhone = formatPhoneNumber( const formattedPhone = formatPhoneNumber(
reply, reply.text,
block.options?.defaultCountryCode block.options?.defaultCountryCode
) )
if (!formattedPhone) return { status: 'fail' } if (!formattedPhone) return { status: 'fail' }
@@ -507,58 +553,60 @@ const parseReply =
} }
case InputBlockType.URL: { case InputBlockType.URL: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = isURL(reply, { require_protocol: false }) const isValid = isURL(reply.text, { require_protocol: false })
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: reply } return { status: 'success', reply: reply.text }
} }
case InputBlockType.CHOICE: { case InputBlockType.CHOICE: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parseButtonsReply(state)(reply, block) return parseButtonsReply(state)(reply.text, block)
} }
case InputBlockType.NUMBER: { case InputBlockType.NUMBER: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateNumber(reply, { const isValid = validateNumber(reply.text, {
options: block.options, options: block.options,
variables: state.typebotsQueue[0].typebot.variables, variables: state.typebotsQueue[0].typebot.variables,
}) })
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: parseNumber(reply) } return { status: 'success', reply: parseNumber(reply.text) }
} }
case InputBlockType.DATE: { case InputBlockType.DATE: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parseDateReply(reply, block) return parseDateReply(reply.text, block)
} }
case InputBlockType.FILE: { case InputBlockType.FILE: {
if (!reply) if (!reply)
return block.options?.isRequired ?? defaultFileInputOptions.isRequired return block.options?.isRequired ?? defaultFileInputOptions.isRequired
? { status: 'fail' } ? { status: 'fail' }
: { status: 'skip' } : { status: 'skip' }
const urls = reply.split(', ') const urls = reply.text.split(', ')
const status = urls.some((url) => const status = urls.some((url) =>
isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' }) isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' })
) )
? 'success' ? 'success'
: 'fail' : 'fail'
return { status, reply: reply } if (!block.options?.isMultipleAllowed && urls.length > 1)
return { status, reply: reply.text.split(',')[0] }
return { status, reply: reply.text }
} }
case InputBlockType.PAYMENT: { case InputBlockType.PAYMENT: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
if (reply === 'fail') return { status: 'fail' } if (reply.text === 'fail') return { status: 'fail' }
return { status: 'success', reply: reply } return { status: 'success', reply: reply.text }
} }
case InputBlockType.RATING: { case InputBlockType.RATING: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateRatingReply(reply, block) const isValid = validateRatingReply(reply.text, block)
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: reply } return { status: 'success', reply: reply.text }
} }
case InputBlockType.PICTURE_CHOICE: { case InputBlockType.PICTURE_CHOICE: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parsePictureChoicesReply(state)(reply, block) return parsePictureChoicesReply(state)(reply.text, block)
} }
case InputBlockType.TEXT: { case InputBlockType.TEXT: {
if (!reply) return { status: 'fail' } if (!reply) return { status: 'fail' }
return { status: 'success', reply: reply } return { status: 'success', reply: reply.text }
} }
} }
} }

View File

@@ -4,7 +4,6 @@ import { SessionState } from '@typebot.io/schemas'
type Props = { type Props = {
answer: Omit<Prisma.AnswerV2CreateManyInput, 'resultId'> answer: Omit<Prisma.AnswerV2CreateManyInput, 'resultId'>
reply: string
state: SessionState state: SessionState
} }
export const saveAnswer = async ({ answer, state }: Props) => { export const saveAnswer = async ({ answer, state }: Props) => {

View File

@@ -0,0 +1,20 @@
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
export const resetSessionState = (state: SessionState): SessionState => ({
...state,
currentSetVariableHistoryIndex: undefined,
currentVisitedEdgeIndex: undefined,
previewMetadata: undefined,
progressMetadata: undefined,
typebotsQueue: state.typebotsQueue.map((queueItem) => ({
...queueItem,
answers: [],
typebot: {
...queueItem.typebot,
variables: queueItem.typebot.variables.map((variable) => ({
...variable,
value: undefined,
})),
},
})),
})

View File

@@ -61,14 +61,12 @@ type StartParams =
type Props = { type Props = {
version: 1 | 2 version: 1 | 2
message: Reply
startParams: StartParams startParams: StartParams
initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'> initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'>
} }
export const startSession = async ({ export const startSession = async ({
version, version,
message,
startParams, startParams,
initialSessionState, initialSessionState,
}: Props): Promise< }: Props): Promise<
@@ -188,7 +186,7 @@ export const startSession = async ({
}) })
// If params has message and first block is an input block, we can directly continue the bot flow // If params has message and first block is an input block, we can directly continue the bot flow
if (message) { if (startParams.message) {
const firstEdgeId = getFirstEdgeId({ const firstEdgeId = getFirstEdgeId({
typebot: chatReply.newSessionState.typebotsQueue[0].typebot, typebot: chatReply.newSessionState.typebotsQueue[0].typebot,
startEventId: startEventId:
@@ -213,7 +211,7 @@ export const startSession = async ({
resultId, resultId,
typebot: newSessionState.typebotsQueue[0].typebot, typebot: newSessionState.typebotsQueue[0].typebot,
}) })
chatReply = await continueBotFlow(message, { chatReply = await continueBotFlow(startParams.message, {
version, version,
state: { state: {
...newSessionState, ...newSessionState,

View File

@@ -1,6 +1,7 @@
import { import {
ContinueChatResponse, ContinueChatResponse,
CustomEmbedBubble, CustomEmbedBubble,
Message,
SessionState, SessionState,
SetVariableHistoryItem, SetVariableHistoryItem,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
@@ -21,14 +22,7 @@ export type ExecuteIntegrationResponse = {
newSetVariableHistory?: SetVariableHistoryItem[] newSetVariableHistory?: SetVariableHistoryItem[]
} & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'> } & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'>
type WhatsAppMediaMessage = { export type Reply = Message | undefined
type: 'whatsapp media'
mediaId: string
workspaceId?: string
accessToken: string
}
export type Reply = string | WhatsAppMediaMessage | undefined
export type ParsedReply = export type ParsedReply =
| { status: 'success'; reply: string } | { status: 'success'; reply: string }

View File

@@ -1,4 +1,4 @@
import { SessionState } from '@typebot.io/schemas' import { Block, SessionState } from '@typebot.io/schemas'
import { import {
WhatsAppCredentials, WhatsAppCredentials,
WhatsAppIncomingMessage, WhatsAppIncomingMessage,
@@ -15,6 +15,13 @@ import { isDefined } from '@typebot.io/lib/utils'
import { Reply } from '../types' import { Reply } from '../types'
import { setIsReplyingInChatSession } from '../queries/setIsReplyingInChatSession' import { setIsReplyingInChatSession } from '../queries/setIsReplyingInChatSession'
import { removeIsReplyingInChatSession } from '../queries/removeIsReplyingInChatSession' import { removeIsReplyingInChatSession } from '../queries/removeIsReplyingInChatSession'
import redis from '@typebot.io/lib/redis'
import { downloadMedia } from './downloadMedia'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
import { getBlockById } from '@typebot.io/schemas/helpers'
const incomingMessageDebounce = 3000
type Props = { type Props = {
receivedMessage: WhatsAppIncomingMessage receivedMessage: WhatsAppIncomingMessage
@@ -61,33 +68,59 @@ export const resumeWhatsAppFlow = async ({
} }
} }
const reply = await getIncomingMessageContent({
message: receivedMessage,
workspaceId,
accessToken: credentials?.systemUserAccessToken,
})
const session = await getSession(sessionId) const session = await getSession(sessionId)
const { incomingMessages, isReplyingWasSet } =
await aggregateParallelMediaMessagesIfRedisEnabled({
receivedMessage,
existingSessionId: session?.id,
newSessionId: sessionId,
})
if (incomingMessages.length === 0) {
if (isReplyingWasSet) await removeIsReplyingInChatSession(sessionId)
return {
message: 'Message received',
}
}
const isSessionExpired = const isSessionExpired =
session && session &&
isDefined(session.state.expiryTimeout) && isDefined(session.state.expiryTimeout) &&
session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now() session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
if (session?.isReplying) { if (!isReplyingWasSet) {
if (!isSessionExpired) { if (session?.isReplying) {
console.log('Is currently replying, skipping...') if (!isSessionExpired) {
return { console.log('Is currently replying, skipping...')
message: 'Message received', return {
message: 'Message received',
}
} }
} else {
await setIsReplyingInChatSession({
existingSessionId: session?.id,
newSessionId: sessionId,
})
} }
} else {
await setIsReplyingInChatSession({
existingSessionId: session?.id,
newSessionId: sessionId,
})
} }
const currentTypebot = session?.state.typebotsQueue[0].typebot
const { block } =
(currentTypebot && session?.state.currentBlockId
? getBlockById(session.state.currentBlockId, currentTypebot.groups)
: undefined) ?? {}
const reply = await getIncomingMessageContent({
messages: incomingMessages,
workspaceId,
accessToken: credentials?.systemUserAccessToken,
typebotId: currentTypebot?.id,
resultId: session?.state.typebotsQueue[0].resultId,
block,
})
const resumeResponse = const resumeResponse =
session && !isSessionExpired session && !isSessionExpired
? await continueBotFlow(reply, { ? await continueBotFlow(reply, {
@@ -155,35 +188,107 @@ export const resumeWhatsAppFlow = async ({
} }
const getIncomingMessageContent = async ({ const getIncomingMessageContent = async ({
message, messages,
workspaceId, workspaceId,
accessToken, accessToken,
typebotId,
resultId,
block,
}: { }: {
message: WhatsAppIncomingMessage messages: WhatsAppIncomingMessage[]
workspaceId?: string workspaceId?: string
accessToken: string accessToken: string
typebotId?: string
resultId?: string
block?: Block
}): Promise<Reply> => { }): Promise<Reply> => {
switch (message.type) { let text: string = ''
case 'text': const attachedFileUrls: string[] = []
return message.text.body for (const message of messages) {
case 'button': switch (message.type) {
return message.button.text case 'text': {
case 'interactive': { if (text !== '') text += `\n\n${message.text.body}`
return message.interactive.button_reply.id else text = message.text.body
break
}
case 'button': {
if (text !== '') text += `\n\n${message.button.text}`
else text = message.button.text
break
}
case 'interactive': {
if (text !== '') text += `\n\n${message.interactive.button_reply.id}`
else text = message.interactive.button_reply.id
break
}
case 'document':
case 'audio':
case 'video':
case 'image': {
let mediaId: string | undefined
if (message.type === 'video') mediaId = message.video.id
if (message.type === 'image') mediaId = message.image.id
if (message.type === 'audio') mediaId = message.audio.id
if (message.type === 'document') mediaId = message.document.id
if (!mediaId) return
const fileVisibility =
block?.type === InputBlockType.FILE
? block.options?.visibility
: block?.type === InputBlockType.TEXT
? block.options?.attachments?.visibility
: undefined
let fileUrl
if (fileVisibility !== 'Public') {
fileUrl =
env.NEXTAUTH_URL +
`/api/typebots/${typebotId}/whatsapp/media/${
workspaceId ? `` : 'preview/'
}${mediaId}`
} else {
const { file, mimeType } = await downloadMedia({
mediaId,
systemUserAccessToken: accessToken,
})
const url = await uploadFileToBucket({
file,
key:
resultId && workspaceId && typebotId
? `public/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${mediaId}`
: `tmp/whatsapp/media/${mediaId}`,
mimeType,
})
fileUrl = url
}
if (block?.type === InputBlockType.FILE) {
if (text !== '') text += `, ${fileUrl}`
else text = fileUrl
} else if (block?.type === InputBlockType.TEXT) {
let caption: string | undefined
if (message.type === 'document' && message.document.caption) {
if (!/^[\w,\s-]+\.[A-Za-z]{3}$/.test(message.document.caption))
caption = message.document.caption
} else if (message.type === 'image' && message.image.caption)
caption = message.image.caption
else if (message.type === 'video' && message.video.caption)
caption = message.video.caption
if (caption) text = text === '' ? caption : `${text}\n\n${caption}`
attachedFileUrls.push(fileUrl)
}
break
}
case 'location': {
const location = `${message.location.latitude}, ${message.location.longitude}`
if (text !== '') text += `\n\n${location}`
else text = location
break
}
} }
case 'document': }
case 'audio':
case 'video': return {
case 'image': type: 'text',
let mediaId: string | undefined text,
if (message.type === 'video') mediaId = message.video.id attachedFileUrls,
if (message.type === 'image') mediaId = message.image.id
if (message.type === 'audio') mediaId = message.audio.id
if (message.type === 'document') mediaId = message.document.id
if (!mediaId) return
return { type: 'whatsapp media', mediaId, workspaceId, accessToken }
case 'location':
return `${message.location.latitude}, ${message.location.longitude}`
} }
} }
@@ -227,3 +332,57 @@ const getCredentials = async ({
phoneNumberId: data.phoneNumberId, phoneNumberId: data.phoneNumberId,
} }
} }
const aggregateParallelMediaMessagesIfRedisEnabled = async ({
receivedMessage,
existingSessionId,
newSessionId,
}: {
receivedMessage: WhatsAppIncomingMessage
existingSessionId?: string
newSessionId: string
}): Promise<{
isReplyingWasSet: boolean
incomingMessages: WhatsAppIncomingMessage[]
}> => {
if (redis && ['document', 'video', 'image'].includes(receivedMessage.type)) {
const redisKey = `wasession:${newSessionId}`
try {
const len = await redis.rpush(redisKey, JSON.stringify(receivedMessage))
if (len === 1) {
await setIsReplyingInChatSession({
existingSessionId,
newSessionId,
})
}
await new Promise((resolve) =>
setTimeout(resolve, incomingMessageDebounce)
)
const newMessagesResponse = await redis.lrange(redisKey, 0, -1)
if (!newMessagesResponse || newMessagesResponse.length > len) {
// Current message was aggregated with other messages another webhook handler. Skipping...
return { isReplyingWasSet: true, incomingMessages: [] }
}
redis.del(redisKey).then()
return {
isReplyingWasSet: true,
incomingMessages: newMessagesResponse.map((msgStr) =>
JSON.parse(msgStr)
),
}
} catch (error) {
console.error('Failed to process webhook event:', error, receivedMessage)
}
}
return {
isReplyingWasSet: false,
incomingMessages: [receivedMessage],
}
}

View File

@@ -58,11 +58,16 @@ export const sendChatReplyToWhatsApp = async ({
const result = await executeClientSideAction({ to, credentials })(action) const result = await executeClientSideAction({ to, credentials })(action)
if (!result) continue if (!result) continue
const { input, newSessionState, messages, clientSideActions } = const { input, newSessionState, messages, clientSideActions } =
await continueBotFlow(result.replyToSend, { await continueBotFlow(
version: 2, result.replyToSend
state, ? { type: 'text', text: result.replyToSend }
textBubbleContentFormat: 'richText', : undefined,
}) {
version: 2,
state,
textBubbleContentFormat: 'richText',
}
)
return sendChatReplyToWhatsApp({ return sendChatReplyToWhatsApp({
to, to,
@@ -128,11 +133,16 @@ export const sendChatReplyToWhatsApp = async ({
) )
if (!result) continue if (!result) continue
const { input, newSessionState, messages, clientSideActions } = const { input, newSessionState, messages, clientSideActions } =
await continueBotFlow(result.replyToSend, { await continueBotFlow(
version: 2, result.replyToSend
state, ? { type: 'text', text: result.replyToSend }
textBubbleContentFormat: 'richText', : undefined,
}) {
version: 2,
state,
textBubbleContentFormat: 'richText',
}
)
return sendChatReplyToWhatsApp({ return sendChatReplyToWhatsApp({
to, to,

View File

@@ -68,7 +68,7 @@ export const startWhatsAppSession = async ({
(publicTypebot.settings.whatsApp?.startCondition?.comparisons.length ?? (publicTypebot.settings.whatsApp?.startCondition?.comparisons.length ??
0) > 0 && 0) > 0 &&
messageMatchStartCondition( messageMatchStartCondition(
incomingMessage ?? '', incomingMessage ?? { type: 'text', text: '' },
publicTypebot.settings.whatsApp?.startCondition publicTypebot.settings.whatsApp?.startCondition
) )
) )
@@ -90,13 +90,13 @@ export const startWhatsAppSession = async ({
return startSession({ return startSession({
version: 2, version: 2,
message: incomingMessage,
startParams: { startParams: {
type: 'live', type: 'live',
publicId: publicTypebot.typebot.publicId as string, publicId: publicTypebot.typebot.publicId as string,
isOnlyRegistering: false, isOnlyRegistering: false,
isStreamEnabled: false, isStreamEnabled: false,
textBubbleContentFormat: 'richText', textBubbleContentFormat: 'richText',
message: incomingMessage,
}, },
initialSessionState: { initialSessionState: {
whatsApp: { whatsApp: {

View File

@@ -16,7 +16,7 @@ npm install @typebot.io/js
``` ```
<script type="module"> <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.initStandard({
typebot: 'my-typebot', typebot: 'my-typebot',
@@ -34,7 +34,7 @@ There, you can change the container dimensions. Here is a code example:
```html ```html
<script type="module"> <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.initStandard({
typebot: 'my-typebot', typebot: 'my-typebot',
@@ -54,7 +54,7 @@ Here is an example:
```html ```html
<script type="module"> <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.initPopup({
typebot: 'my-typebot', typebot: 'my-typebot',
@@ -96,7 +96,7 @@ Here is an example:
```html ```html
<script type="module"> <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.initBubble({
typebot: 'my-typebot', typebot: 'my-typebot',

View File

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

View File

@@ -20,6 +20,7 @@ const indexConfig = {
file: 'dist/index.js', file: 'dist/index.js',
format: 'es', format: 'es',
}, },
onwarn,
plugins: [ plugins: [
resolve({ extensions }), resolve({ extensions }),
babel({ 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 export default configs

View File

@@ -265,6 +265,10 @@ pre {
backdrop-filter: blur(var(--typebot-guest-bubble-blur)); backdrop-filter: blur(var(--typebot-guest-bubble-blur));
} }
.typebot-guest-bubble-image-attachment {
border-radius: var(--typebot-guest-bubble-border-radius);
}
.typebot-input { .typebot-input {
color: var(--typebot-input-color); color: var(--typebot-input-color);
background-color: rgba( background-color: rgba(
@@ -279,12 +283,17 @@ pre {
border-radius: var(--typebot-input-border-radius); border-radius: var(--typebot-input-border-radius);
box-shadow: var(--typebot-input-box-shadow); box-shadow: var(--typebot-input-box-shadow);
backdrop-filter: blur(var(--typebot-input-blur)); backdrop-filter: blur(var(--typebot-input-blur));
transition: filter 100ms ease;
} }
.typebot-input-error-message { .typebot-input-error-message {
color: var(--typebot-input-color); color: var(--typebot-input-color);
} }
.typebot-input-form .typebot-button {
box-shadow: var(--typebot-input-box-shadow);
}
.typebot-button > .send-icon { .typebot-button > .send-icon {
fill: var(--typebot-button-color); fill: var(--typebot-button-color);
} }
@@ -446,3 +455,138 @@ select option {
height: 100%; height: 100%;
transition: width 0.25s ease; 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, defaultProgressBarPosition,
} from '@typebot.io/schemas/features/typebot/theme/constants' } from '@typebot.io/schemas/features/typebot/theme/constants'
import { CorsError } from '@/utils/CorsError' 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 = { export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -335,6 +338,17 @@ const BotContent = (props: BotContentProps) => {
> >
<LiteBadge botContainer={botContainer} /> <LiteBadge botContainer={botContainer} />
</Show> </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> </div>
) )
} }

View File

@@ -29,7 +29,6 @@ import {
formattedMessages, formattedMessages,
setFormattedMessages, setFormattedMessages,
} from '@/utils/formattedMessagesSignal' } from '@/utils/formattedMessagesSignal'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery' import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
import { HTTPError } from 'ky' import { HTTPError } from 'ky'
import { persist } from '@/utils/persist' import { persist } from '@/utils/persist'
@@ -147,13 +146,6 @@ export const ConversationContainer = (props: Props) => {
const currentInputBlock = [...chatChunks()].pop()?.input const currentInputBlock = [...chatChunks()].pop()?.input
if (currentInputBlock?.id && props.onAnswer && message) if (currentInputBlock?.id && props.onAnswer && message)
props.onAnswer({ message, blockId: currentInputBlock.id }) 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(() => { const longRequest = setTimeout(() => {
setIsSending(true) setIsSending(true)
}, 1000) }, 1000)

View File

@@ -15,7 +15,7 @@ import type {
DateInputBlock, DateInputBlock,
} from '@typebot.io/schemas' } from '@typebot.io/schemas'
import { GuestBubble } from './bubbles/GuestBubble' import { GuestBubble } from './bubbles/GuestBubble'
import { BotContext, InputSubmitContent } from '@/types' import { Answer, BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput' import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number' import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email' import { EmailInput } from '@/features/blocks/inputs/email'
@@ -48,24 +48,33 @@ type Props = {
isInputPrefillEnabled: boolean isInputPrefillEnabled: boolean
hasError: boolean hasError: boolean
onTransitionEnd: () => void onTransitionEnd: () => void
onSubmit: (answer: string) => void onSubmit: (answer: string, attachments?: Answer['attachments']) => void
onSkip: () => void onSkip: () => void
} }
export const InputChatBlock = (props: Props) => { 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}`, key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
storage: props.context.storage, storage: props.context.storage,
}) })
const [formattedMessage, setFormattedMessage] = createSignal<string>()
const handleSubmit = async ({ label, value }: InputSubmitContent) => { const handleSubmit = async ({
setAnswer(label ?? value) label,
props.onSubmit(value ?? 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) => { const handleSkip = (label: string) => {
setAnswer(label) setAnswer({ text: label })
props.onSkip() props.onSkip()
} }
@@ -73,14 +82,15 @@ export const InputChatBlock = (props: Props) => {
const formattedMessage = formattedMessages().findLast( const formattedMessage = formattedMessages().findLast(
(message) => props.chunkIndex === message.inputIndex (message) => props.chunkIndex === message.inputIndex
)?.formattedMessage )?.formattedMessage
if (formattedMessage) setFormattedMessage(formattedMessage) if (formattedMessage && props.block.type !== InputBlockType.FILE)
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
}) })
return ( return (
<Switch> <Switch>
<Match when={answer() && !props.hasError}> <Match when={answer() && !props.hasError}>
<GuestBubble <GuestBubble
message={formattedMessage() ?? (answer() as string)} message={answer() as Answer}
showAvatar={ showAvatar={
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
} }
@@ -107,7 +117,7 @@ export const InputChatBlock = (props: Props) => {
block={props.block} block={props.block}
chunkIndex={props.chunkIndex} chunkIndex={props.chunkIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled} isInputPrefillEnabled={props.isInputPrefillEnabled}
existingAnswer={props.hasError ? answer() : undefined} existingAnswer={props.hasError ? answer()?.text : undefined}
onTransitionEnd={props.onTransitionEnd} onTransitionEnd={props.onTransitionEnd}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onSkip={handleSkip} onSkip={handleSkip}
@@ -147,6 +157,7 @@ const Input = (props: {
<TextInput <TextInput
block={props.block as TextInputBlock} block={props.block as TextInputBlock}
defaultValue={getPrefilledValue()} defaultValue={getPrefilledValue()}
context={props.context}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</Match> </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 { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js' import { splitProps, Switch, Match } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime' import { JSX } from 'solid-js/jsx-runtime'
import { SendIcon } from './icons' import { SendIcon } from './icons'
import { Button } from './Button' import { Button } from './Button'
import { isEmpty } from '@typebot.io/lib' import { isEmpty } from '@typebot.io/lib'
import clsx from 'clsx'
type SendButtonProps = { type SendButtonProps = {
isDisabled?: boolean isDisabled?: boolean
isLoading?: boolean isLoading?: boolean
disableIcon?: boolean disableIcon?: boolean
class?: string
} & JSX.ButtonHTMLAttributes<HTMLButtonElement> } & JSX.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = (props: SendButtonProps) => { export const SendButton = (props: SendButtonProps) => {
const [local, others] = splitProps(props, ['disableIcon']) const [local, others] = splitProps(props, ['disableIcon'])
const showIcon =
(isMobile() && !local.disableIcon) ||
!props.children ||
(typeof props.children === 'string' && isEmpty(props.children))
return ( return (
<Button type="submit" {...others}> <Button
{(isMobile() && !local.disableIcon) || type="submit"
!props.children || {...others}
(typeof props.children === 'string' && isEmpty(props.children)) ? ( class={clsx('flex items-center', props.class)}
<SendIcon aria-label={showIcon ? 'Send' : undefined}
class={'send-icon flex ' + (local.disableIcon ? 'hidden' : '')} >
/> <Switch>
) : ( <Match when={showIcon}>
props.children <SendIcon
)} class={
'send-icon flex w-6 h-6 ' + (local.disableIcon ? 'hidden' : '')
}
/>
</Match>
<Match when={!showIcon}>{props.children}</Match>
</Switch>
</Button> </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 { Avatar } from '../avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal' 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 = { type Props = {
message: string message: Answer
showAvatar: boolean showAvatar: boolean
avatarSrc?: string avatarSrc?: string
hasHostAvatar: boolean hasHostAvatar: boolean
} }
export const GuestBubble = (props: Props) => ( export const GuestBubble = (props: Props) => {
<div const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
style={{ return (
'margin-left': props.hasHostAvatar <div
? isMobile() class="flex justify-end items-end animate-fade-in gap-2 guest-container"
? '28px' style={{
: '50px' 'margin-left': props.hasHostAvatar
: undefined, ? isMobile()
}} ? '28px'
> : '50px'
<span : undefined,
class="px-4 py-2 whitespace-pre-wrap max-w-full typebot-guest-bubble" }}
data-testid="guest-bubble"
> >
{props.message} <div class="flex flex-col gap-1 items-end">
</span> <Show when={(props.message.attachments ?? []).length > 0}>
<Show when={props.showAvatar}> <div
<Avatar initialAvatarSrc={props.avatarSrc} /> class={clsx(
</Show> 'flex gap-1 overflow-auto max-w-[350px]',
</div> 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 ?? '') parseDefaultValue(props.defaultValue ?? '')
) )
const submit = () => {
if (inputValues().from === '' && inputValues().to === '') return
props.onSubmit({
value: `${inputValues().from}${
props.options?.isRange ? ` to ${inputValues().to}` : ''
}`,
})
}
return ( return (
<div class="flex flex-col"> <div class="typebot-input-form flex gap-2 items-end">
<div class="flex items-center"> <form
<form class={clsx(
class={clsx( 'flex typebot-input',
'flex justify-between typebot-input pr-2', props.options?.isRange ? 'items-end' : 'items-center'
props.options?.isRange ? 'items-end' : 'items-center' )}
)} onSubmit={(e) => {
onSubmit={(e) => { e.preventDefault()
if (inputValues().from === '' && inputValues().to === '') return submit()
e.preventDefault() }}
props.onSubmit({ >
value: `${inputValues().from}${ <div class="flex flex-col">
props.options?.isRange ? ` to ${inputValues().to}` : '' <div
}`, class={
}) 'flex items-center p-4 ' +
}} (props.options?.isRange ? 'pb-0 gap-2' : '')
> }
<div class="flex flex-col"> >
<div {props.options?.isRange && (
class={ <p class="font-semibold">
'flex items-center p-4 ' + {props.options.labels?.from ??
(props.options?.isRange ? 'pb-0 gap-2' : '') 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,
})
} }
> min={props.options?.min}
{props.options?.isRange && ( 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"> <p class="font-semibold">
{props.options.labels?.from ?? {props.options.labels?.to ??
defaultDateInputOptions.labels.from} defaultDateInputOptions.labels.to}
</p> </p>
)} )}
<input <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={{ style={{
'min-height': '32px', 'min-height': '32px',
'min-width': '100px', 'min-width': '100px',
'font-size': '16px', 'font-size': '16px',
}} }}
value={inputValues().from} value={inputValues().to}
type={props.options?.hasTime ? 'datetime-local' : 'date'} type={props.options.hasTime ? 'datetime-local' : 'date'}
onChange={(e) => onChange={(e) =>
setInputValues({ setInputValues({
...inputValues(), ...inputValues(),
from: e.currentTarget.value, to: e.currentTarget.value,
}) })
} }
min={props.options?.min} min={props.options?.min}
max={props.options?.max} max={props.options?.max}
data-testid="from-date" data-testid="to-date"
/> />
</div> </div>
{props.options?.isRange && ( )}
<div class="flex items-center p-4"> </div>
{props.options.isRange && ( </form>
<p class="font-semibold"> <SendButton class="h-[56px]" on:click={submit}>
{props.options.labels?.to ?? {props.options?.labels?.button}
defaultDateInputOptions.labels.to} </SendButton>
</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> </div>
) )
} }

View File

@@ -49,25 +49,23 @@ export const EmailInput = (props: Props) => {
return ( return (
<div <div
class={'flex items-end justify-between pr-2 typebot-input w-full'} class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
data-testid="input"
style={{
'max-width': '350px',
}}
onKeyDown={submitWhenEnter} onKeyDown={submitWhenEnter}
> >
<ShortTextInput <div class={'flex typebot-input w-full'}>
ref={inputRef} <ShortTextInput
value={inputValue()} ref={inputRef}
placeholder={ value={inputValue()}
props.block.options?.labels?.placeholder ?? placeholder={
defaultEmailInputOptions.labels.placeholder props.block.options?.labels?.placeholder ??
} defaultEmailInputOptions.labels.placeholder
onInput={handleInput} }
type="email" onInput={handleInput}
autocomplete="email" type="email"
/> autocomplete="email"
<SendButton type="button" class="my-2 ml-2" on:click={submit}> />
</div>
<SendButton type="button" class="h-[56px]" on:click={submit}>
{props.block.options?.labels?.button} {props.block.options?.labels?.button}
</SendButton> </SendButton>
</div> </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 { SendButton } from '@/components/SendButton'
import { BotContext, InputSubmitContent } from '@/types' import { BotContext, InputSubmitContent } from '@/types'
import { FileInputBlock } from '@typebot.io/schemas' 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 { Button } from '@/components/Button'
import { Spinner } from '@/components/Spinner' import { Spinner } from '@/components/Spinner'
import { uploadFiles } from '../helpers/uploadFiles' import { uploadFiles } from '../helpers/uploadFiles'
import { guessApiHost } from '@/utils/guessApiHost' import { guessApiHost } from '@/utils/guessApiHost'
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants' import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants'
import { isDefined } from '@typebot.io/lib' import { isDefined } from '@typebot.io/lib'
import { SelectedFile } from './SelectedFile'
import { sanitizeNewFile } from '../helpers/sanitizeSelectedFiles'
import { toaster } from '@/utils/toaster'
type Props = { type Props = {
context: BotContext context: BotContext
@@ -22,39 +24,34 @@ export const FileUploadForm = (props: Props) => {
const [isUploading, setIsUploading] = createSignal(false) const [isUploading, setIsUploading] = createSignal(false)
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0) const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
const [isDraggingOver, setIsDraggingOver] = createSignal(false) const [isDraggingOver, setIsDraggingOver] = createSignal(false)
const [errorMessage, setErrorMessage] = createSignal<string>()
const onNewFiles = (files: FileList) => { const onNewFiles = (files: FileList) => {
setErrorMessage(undefined)
const newFiles = Array.from(files) const newFiles = Array.from(files)
const sizeLimit = .map((file) =>
props.block.options && 'sizeLimit' in props.block.options sanitizeNewFile({
? props.block.options?.sizeLimit ?? existingFiles: selectedFiles(),
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE') newFile: file,
: undefined params: {
if ( sizeLimit:
sizeLimit && props.block.options && 'sizeLimit' in props.block.options
newFiles.some((file) => file.size > sizeLimit * 1024 * 1024) ? props.block.options.sizeLimit
) : undefined,
return setErrorMessage(`A file is larger than ${sizeLimit}MB`) },
if (!props.block.options?.isMultipleAllowed && files) onError: ({ description, title }) =>
toaster.create({
title,
description,
}),
})
)
.filter(isDefined)
if (newFiles.length === 0) return
if (!props.block.options?.isMultipleAllowed)
return startSingleFileUpload(newFiles[0]) return startSingleFileUpload(newFiles[0])
if (selectedFiles().length === 0) {
setSelectedFiles(newFiles) setSelectedFiles([...selectedFiles(), ...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])
} }
const handleSubmit = async (e: SubmitEvent) => { const handleSubmit = async (e: SubmitEvent) => {
@@ -64,13 +61,6 @@ export const FileUploadForm = (props: Props) => {
} }
const startSingleFileUpload = async (file: File) => { 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) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
apiHost: apiHost:
@@ -86,31 +76,17 @@ export const FileUploadForm = (props: Props) => {
], ],
}) })
setIsUploading(false) setIsUploading(false)
if (urls.length) if (urls.length && urls[0])
return props.onSubmit({ return props.onSubmit({
label: label:
props.block.options?.labels?.success?.single ?? props.block.options?.labels?.success?.single ??
defaultFileInputOptions.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 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) setIsUploading(true)
const urls = await uploadFiles({ const urls = await uploadFiles({
apiHost: apiHost:
@@ -127,7 +103,9 @@ export const FileUploadForm = (props: Props) => {
setIsUploading(false) setIsUploading(false)
setUploadProgressPercent(0) setUploadProgressPercent(0)
if (urls.length !== files.length) 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({ props.onSubmit({
label: label:
urls.length > 1 urls.length > 1
@@ -137,7 +115,11 @@ export const FileUploadForm = (props: Props) => {
).replaceAll('{total}', urls.length.toString()) ).replaceAll('{total}', urls.length.toString())
: props.block.options?.labels?.success?.single ?? : props.block.options?.labels?.success?.single ??
defaultFileInputOptions.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 props.block.options?.labels?.skip ?? defaultFileInputOptions.labels.skip
) )
const removeSelectedFile = (index: number) => {
setSelectedFiles((selectedFiles) =>
selectedFiles.filter((_, i) => i !== index)
)
}
return ( return (
<form class="flex flex-col w-full gap-2" onSubmit={handleSubmit}> <form class="flex flex-col w-full gap-2" onSubmit={handleSubmit}>
<label <label
@@ -192,17 +180,24 @@ export const FileUploadForm = (props: Props) => {
</Match> </Match>
<Match when={!isUploading()}> <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 />}> <Show when={selectedFiles().length} fallback={<UploadIcon />}>
<span class="relative"> <div
<FileIcon /> class="p-4 flex gap-2 border-gray-200 border overflow-auto bg-white rounded-md w-full"
<div on:click={(e) => {
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4" e.preventDefault()
style={{ bottom: '5px' }} e.stopPropagation()
> }}
{selectedFiles().length} >
</div> <For each={selectedFiles()}>
</span> {(file, index) => (
<SelectedFile
file={file}
onRemoveClick={() => removeSelectedFile(index())}
/>
)}
</For>
</div>
</Show> </Show>
<p <p
class="text-sm text-gray-500 text-center" class="text-sm text-gray-500 text-center"
@@ -269,9 +264,6 @@ export const FileUploadForm = (props: Props) => {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={errorMessage()}>
<p class="text-red-500 text-sm">{errorMessage()}</p>
</Show>
</form> </form>
) )
} }
@@ -287,7 +279,7 @@ const UploadIcon = () => (
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="mb-3 text-gray-500" class="text-gray-500"
> >
<polyline points="16 16 12 12 8 16" /> <polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" /> <line x1="12" y1="12" x2="12" y2="21" />
@@ -296,24 +288,6 @@ const UploadIcon = () => (
</svg> </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 encodeUrl = (url: string): string => {
const fileName = url.split('/').pop() const fileName = url.split('/').pop()
if (!fileName) return url 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 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 ({ export const uploadFiles = async ({
apiHost, apiHost,
files, files,
onUploadProgress, onUploadProgress,
}: UploadFileProps): Promise<UrlList> => { }: UploadFileProps): Promise<UrlList> => {
const urls = [] const urls: UrlList = []
let i = 0 let i = 0
for (const { input, file } of files) { for (const { input, file } of files) {
onUploadProgress && onUploadProgress((i / files.length) * 100) onUploadProgress &&
onUploadProgress({ progress: (i / files.length) * 100, fileIndex: i })
i += 1 i += 1
const { data } = await sendRequest<{ const { data } = await sendRequest<{
presignedUrl: string presignedUrl: string
@@ -52,7 +56,7 @@ export const uploadFiles = async ({
if (!upload.ok) continue if (!upload.ok) continue
urls.push(data.fileUrl) urls.push({ url: data.fileUrl, type: file.type })
} }
} }
return urls return urls

View File

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

View File

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

View File

@@ -1,21 +1,35 @@
import { Textarea, ShortTextInput } from '@/components' import { Textarea, ShortTextInput } from '@/components'
import { SendButton } from '@/components/SendButton' import { SendButton } from '@/components/SendButton'
import { CommandData } from '@/features/commands' import { CommandData } from '@/features/commands'
import { InputSubmitContent } from '@/types' import { Answer, BotContext, InputSubmitContent } from '@/types'
import { isMobile } from '@/utils/isMobileSignal' import { isMobile } from '@/utils/isMobileSignal'
import type { TextInputBlock } from '@typebot.io/schemas' 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 { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
import clsx from 'clsx' 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 = { type Props = {
block: TextInputBlock block: TextInputBlock
defaultValue?: string defaultValue?: string
context: BotContext
onSubmit: (value: InputSubmitContent) => void onSubmit: (value: InputSubmitContent) => void
} }
export const TextInput = (props: Props) => { export const TextInput = (props: Props) => {
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '') 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 let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const handleInput = (inputValue: string) => setInputValue(inputValue) const handleInput = (inputValue: string) => setInputValue(inputValue)
@@ -23,10 +37,30 @@ export const TextInput = (props: Props) => {
const checkIfInputIsValid = () => const checkIfInputIsValid = () =>
inputRef?.value !== '' && inputRef?.reportValidity() inputRef?.value !== '' && inputRef?.reportValidity()
const submit = () => { const submit = async () => {
if (checkIfInputIsValid()) if (checkIfInputIsValid()) {
props.onSubmit({ value: inputRef?.value ?? inputValue() }) let attachments: Answer['attachments']
else inputRef?.focus() 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) => { const submitWhenEnter = (e: KeyboardEvent) => {
@@ -57,41 +91,139 @@ export const TextInput = (props: Props) => {
if (data.command === 'setInputValue') setInputValue(data.value) 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 ( return (
<div <div
class={clsx( class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
'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',
}}
onKeyDown={submitWhenEnter} onKeyDown={submitWhenEnter}
onDrop={handleDropFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
> >
{props.block.options?.isLong ? ( <div
<Textarea class={clsx(
ref={inputRef as HTMLTextAreaElement} 'typebot-input flex-col w-full',
onInput={handleInput} isDraggingOver() && 'filter brightness-95'
onKeyDown={submitIfCtrlEnter} )}
value={inputValue()} >
placeholder={ <Show when={selectedFiles().length}>
props.block.options?.labels?.placeholder ?? <div
defaultTextInputOptions.labels.placeholder class="p-2 flex gap-2 border-gray-100 overflow-auto"
} style={{ 'border-bottom-width': '1px' }}
/> >
) : ( <For each={selectedFiles()}>
<ShortTextInput {(file, index) => (
ref={inputRef as HTMLInputElement} <SelectedFile
onInput={handleInput} file={file}
value={inputValue()} uploadProgressPercent={
placeholder={ uploadProgress()
props.block.options?.labels?.placeholder ?? ? uploadProgress()?.fileIndex === index()
defaultTextInputOptions.labels.placeholder ? 20
} : index() < (uploadProgress()?.fileIndex ?? 0)
/> ? 100
)} : 0
<SendButton type="button" class="my-2 ml-2" on:click={submit}> : 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} {props.block.options?.labels?.button}
</SendButton> </SendButton>
</div> </div>

View File

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

View File

@@ -19,6 +19,7 @@ import {
removeBotOpenedStateInStorage, removeBotOpenedStateInStorage,
setBotOpenedStateInStorage, setBotOpenedStateInStorage,
} from '@/utils/storage' } from '@/utils/storage'
import { EnvironmentProvider } from '@ark-ui/solid'
export type BubbleProps = BotProps & export type BubbleProps = BotProps &
BubbleParams & { BubbleParams & {
@@ -157,59 +158,65 @@ export const Bubble = (props: BubbleProps) => {
return ( return (
<Show when={isMounted()}> <Show when={isMounted()}>
<style>{styles}</style> <EnvironmentProvider
<Show when={isPreviewMessageDisplayed()}> value={document.querySelector('typebot-bubble')?.shadowRoot as Node}
<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')
}
> >
<Show when={isBotStarted()}> <style>{styles}</style>
<Bot <Show when={isPreviewMessageDisplayed()}>
{...botProps} <PreviewMessage
onChatStatePersisted={handleOnChatStatePersisted} {...previewMessage()}
prefilledVariables={prefilledVariables()} placement={bubbleProps.theme?.placement}
class="rounded-lg" previewMessageTheme={bubbleProps.theme?.previewMessage}
progressBarRef={progressBarContainerRef} buttonSize={buttonSize()}
onClick={handlePreviewMessageClick}
onCloseClick={hideMessage}
/> />
</Show> </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> </Show>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/nextjs", "name": "@typebot.io/nextjs",
"version": "0.2.92", "version": "0.3.0",
"description": "Convenient library to display typebots on your Next.js website", "description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/react", "name": "@typebot.io/react",
"version": "0.2.92", "version": "0.3.0",
"description": "Convenient library to display typebots on your React app", "description": "Convenient library to display typebots on your React app",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

9
packages/env/env.ts vendored
View File

@@ -341,10 +341,9 @@ const whatsAppEnv = {
}, },
} }
const upstashRedis = { const redisEnv = {
server: { server: {
UPSTASH_REDIS_REST_URL: z.string().url().optional(), REDIS_URL: z.string().url().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
}, },
} }
@@ -425,13 +424,13 @@ export const env = createEnv({
...vercelEnv.server, ...vercelEnv.server,
...sleekPlanEnv.server, ...sleekPlanEnv.server,
...whatsAppEnv.server, ...whatsAppEnv.server,
...upstashRedis.server, ...redisEnv.server,
...gitlabEnv.server, ...gitlabEnv.server,
...azureEnv.server, ...azureEnv.server,
...customOAuthEnv.server, ...customOAuthEnv.server,
...sentryEnv.server, ...sentryEnv.server,
...telemetryEnv.server, ...telemetryEnv.server,
...keycloakEnv.server ...keycloakEnv.server,
}, },
client: { client: {
...baseEnv.client, ...baseEnv.client,

View File

@@ -39,6 +39,7 @@
"escape-html": "1.0.3", "escape-html": "1.0.3",
"google-auth-library": "8.9.0", "google-auth-library": "8.9.0",
"ky": "1.2.4", "ky": "1.2.4",
"ioredis": "5.4.1",
"minio": "7.1.3", "minio": "7.1.3",
"posthog-node": "3.1.1", "posthog-node": "3.1.1",
"remark-parse": "11.0.0", "remark-parse": "11.0.0",

16
packages/lib/redis.ts Normal file
View File

@@ -0,0 +1,16 @@
import { env } from '@typebot.io/env'
import Redis from 'ioredis'
declare const global: { redis: Redis | undefined }
let redis: Redis | undefined
if (env.NODE_ENV === 'production' && !process.versions.bun && env.REDIS_URL) {
redis = new Redis(env.REDIS_URL)
} else if (env.REDIS_URL) {
if (!global.redis) {
global.redis = new Redis(env.REDIS_URL)
}
redis = global.redis
}
export default redis

View File

@@ -10,7 +10,6 @@ import {
import { SetVariableHistoryItem } from '@typebot.io/schemas/features/result' import { SetVariableHistoryItem } from '@typebot.io/schemas/features/result'
import { isBubbleBlock, isInputBlock } from '@typebot.io/schemas/helpers' import { isBubbleBlock, isInputBlock } from '@typebot.io/schemas/helpers'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants' import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { convertRichTextToMarkdown } from '@typebot.io/lib/markdown/convertRichTextToMarkdown'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants' import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { createId } from '@typebot.io/lib/createId' import { createId } from '@typebot.io/lib/createId'
import { executeCondition } from './executeCondition' import { executeCondition } from './executeCondition'
@@ -56,7 +55,7 @@ export const computeResultTranscript = ({
stopAtBlockId, stopAtBlockId,
}: { }: {
typebot: TypebotInSession typebot: TypebotInSession
answers: Pick<Answer, 'blockId' | 'content'>[] answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
setVariableHistory: Pick< setVariableHistory: Pick<
SetVariableHistoryItem, SetVariableHistoryItem,
'blockId' | 'variableId' | 'value' 'blockId' | 'variableId' | 'value'
@@ -120,7 +119,7 @@ const executeGroup = ({
typebot: TypebotInSession typebot: TypebotInSession
resumeEdgeId?: string resumeEdgeId?: string
}[] }[]
answers: Pick<Answer, 'blockId' | 'content'>[] answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
setVariableHistory: Pick< setVariableHistory: Pick<
SetVariableHistoryItem, SetVariableHistoryItem,
'blockId' | 'variableId' | 'value' 'blockId' | 'variableId' | 'value'
@@ -157,20 +156,50 @@ const executeGroup = ({
const answer = answers.shift() const answer = answers.shift()
if (!answer) break if (!answer) break
if (block.options?.variableId) { if (block.options?.variableId) {
const variable = typebotsQueue[0].typebot.variables.find( const replyVariable = typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options?.variableId (variable) => variable.id === block.options?.variableId
) )
if (replyVariable) {
typebotsQueue[0].typebot.variables =
typebotsQueue[0].typebot.variables.map((v) =>
v.id === replyVariable.id ? { ...v, value: answer.content } : v
)
}
}
if (
block.type === InputBlockType.TEXT &&
block.options?.attachments?.isEnabled &&
block.options?.attachments?.saveVariableId &&
answer.attachedFileUrls &&
answer.attachedFileUrls?.length > 0
) {
const variable = typebotsQueue[0].typebot.variables.find(
(variable) =>
variable.id === block.options?.attachments?.saveVariableId
)
if (variable) { if (variable) {
typebotsQueue[0].typebot.variables = typebotsQueue[0].typebot.variables =
typebotsQueue[0].typebot.variables.map((v) => typebotsQueue[0].typebot.variables.map((v) =>
v.id === variable.id ? { ...v, value: answer.content } : v v.id === variable.id
? {
...v,
value: Array.isArray(variable.value)
? variable.value.concat(answer.attachedFileUrls!)
: answer.attachedFileUrls!.length === 1
? answer.attachedFileUrls![0]
: answer.attachedFileUrls,
}
: v
) )
} }
} }
currentTranscript.push({ currentTranscript.push({
role: 'user', role: 'user',
type: 'text', type: 'text',
text: answer.content, text:
(answer.attachedFileUrls?.length ?? 0) > 0
? `${answer.attachedFileUrls?.join(', ')}\n\n${answer.content}`
: answer.content,
}) })
const outgoingEdge = getOutgoingEdgeId({ const outgoingEdge = getOutgoingEdgeId({
block, block,

View File

@@ -320,12 +320,13 @@ model Answer {
} }
model AnswerV2 { model AnswerV2 {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
blockId String blockId String
content String @db.Text content String @db.Text
resultId String attachedFileUrls Json?
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade) resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
@@index([resultId]) @@index([resultId])
@@index([blockId]) @@index([blockId])

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AnswerV2" ADD COLUMN "attachedFileUrls" JSONB;

View File

@@ -300,12 +300,13 @@ model Answer {
} }
model AnswerV2 { model AnswerV2 {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
blockId String blockId String
content String content String
resultId String attachedFileUrls Json?
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade) resultId String
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
@@index([blockId]) @@index([blockId])
} }

View File

@@ -13,6 +13,7 @@ const answerV1Schema = z.object({
export const answerSchema = z.object({ export const answerSchema = z.object({
blockId: z.string(), blockId: z.string(),
content: z.string(), content: z.string(),
attachedFileUrls: z.array(z.string()).optional(),
}) })
export const answerInputSchema = answerV1Schema export const answerInputSchema = answerV1Schema

View File

@@ -4,4 +4,8 @@ import { TextInputBlock } from './schema'
export const defaultTextInputOptions = { export const defaultTextInputOptions = {
isLong: false, isLong: false,
labels: { button: defaultButtonLabel, placeholder: 'Type your answer...' }, labels: { button: defaultButtonLabel, placeholder: 'Type your answer...' },
attachments: {
isEnabled: false,
visibility: 'Auto',
},
} as const satisfies TextInputBlock['options'] } as const satisfies TextInputBlock['options']

View File

@@ -1,6 +1,7 @@
import { z } from '../../../../zod' import { z } from '../../../../zod'
import { optionBaseSchema, blockBaseSchema } from '../../shared' import { optionBaseSchema, blockBaseSchema } from '../../shared'
import { InputBlockType } from '../constants' import { InputBlockType } from '../constants'
import { fileVisibilityOptions } from '../file/constants'
export const textInputOptionsBaseSchema = z.object({ export const textInputOptionsBaseSchema = z.object({
labels: z labels: z
@@ -16,6 +17,13 @@ export const textInputOptionsSchema = textInputOptionsBaseSchema
.merge( .merge(
z.object({ z.object({
isLong: z.boolean().optional(), isLong: z.boolean().optional(),
attachments: z
.object({
isEnabled: z.boolean().optional(),
saveVariableId: z.string().optional(),
visibility: z.enum(fileVisibilityOptions).optional(),
})
.optional(),
}) })
) )

View File

@@ -29,6 +29,23 @@ import { BubbleBlockType } from '../blocks/bubbles/constants'
import { clientSideActionSchema } from './clientSideAction' import { clientSideActionSchema } from './clientSideAction'
import { ChatSession as ChatSessionFromPrisma } from '@typebot.io/prisma' import { ChatSession as ChatSessionFromPrisma } from '@typebot.io/prisma'
export const messageSchema = z.preprocess(
(val) => (typeof val === 'string' ? { type: 'text', text: val } : val),
z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
text: z.string(),
attachedFileUrls: z
.array(z.string())
.optional()
.describe(
'Can only be provided if current input block is a text input block that allows attachments'
),
}),
])
)
export type Message = z.infer<typeof messageSchema>
const chatSessionSchema = z.object({ const chatSessionSchema = z.object({
id: z.string(), id: z.string(),
createdAt: z.date(), createdAt: z.date(),
@@ -183,8 +200,7 @@ export const startChatInputSchema = z.object({
.describe( .describe(
"[Where to find my bot's public ID?](../how-to#how-to-find-my-publicid)" "[Where to find my bot's public ID?](../how-to#how-to-find-my-publicid)"
), ),
message: z message: messageSchema
.string()
.optional() .optional()
.describe( .describe(
"Only provide it if your flow starts with an input block and you'd like to directly provide an answer to it." "Only provide it if your flow starts with an input block and you'd like to directly provide an answer to it."
@@ -242,7 +258,7 @@ export const startPreviewChatInputSchema = z.object({
"[Where to find my bot's ID?](../how-to#how-to-find-my-typebotid)" "[Where to find my bot's ID?](../how-to#how-to-find-my-typebotid)"
), ),
isStreamEnabled: z.boolean().optional().default(false), isStreamEnabled: z.boolean().optional().default(false),
message: z.string().optional(), message: messageSchema.optional(),
isOnlyRegistering: z isOnlyRegistering: z
.boolean() .boolean()
.optional() .optional()

View File

@@ -115,13 +115,13 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
z.object({ z.object({
from: z.string(), from: z.string(),
type: z.literal('image'), type: z.literal('image'),
image: z.object({ id: z.string() }), image: z.object({ id: z.string(), caption: z.string().optional() }),
timestamp: z.string(), timestamp: z.string(),
}), }),
z.object({ z.object({
from: z.string(), from: z.string(),
type: z.literal('video'), type: z.literal('video'),
video: z.object({ id: z.string() }), video: z.object({ id: z.string(), caption: z.string().optional() }),
timestamp: z.string(), timestamp: z.string(),
}), }),
z.object({ z.object({
@@ -133,7 +133,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
z.object({ z.object({
from: z.string(), from: z.string(),
type: z.literal('document'), type: z.literal('document'),
document: z.object({ id: z.string() }), document: z.object({ id: z.string(), caption: z.string().optional() }),
timestamp: z.string(), timestamp: z.string(),
}), }),
z.object({ z.object({

784
pnpm-lock.yaml generated
View File

@@ -158,9 +158,6 @@ importers:
'@upstash/ratelimit': '@upstash/ratelimit':
specifier: 0.4.3 specifier: 0.4.3
version: 0.4.3 version: 0.4.3
'@upstash/redis':
specifier: 1.22.0
version: 1.22.0
'@use-gesture/react': '@use-gesture/react':
specifier: 10.2.27 specifier: 10.2.27
version: 10.2.27(react@18.2.0) version: 10.2.27(react@18.2.0)
@@ -197,6 +194,9 @@ importers:
immer: immer:
specifier: 10.0.2 specifier: 10.0.2
version: 10.0.2 version: 10.0.2
ioredis:
specifier: ^5.4.1
version: 5.4.1
isolated-vm: isolated-vm:
specifier: 4.7.2 specifier: 4.7.2
version: 4.7.2 version: 4.7.2
@@ -1001,6 +1001,9 @@ importers:
packages/embeds/js: packages/embeds/js:
dependencies: dependencies:
'@ark-ui/solid':
specifier: 3.3.0
version: 3.3.0(@internationalized/date@3.5.4)(solid-js@1.7.8)
'@stripe/stripe-js': '@stripe/stripe-js':
specifier: 1.54.1 specifier: 1.54.1
version: 1.54.1 version: 1.54.1
@@ -1709,6 +1712,9 @@ importers:
google-auth-library: google-auth-library:
specifier: 8.9.0 specifier: 8.9.0
version: 8.9.0 version: 8.9.0
ioredis:
specifier: 5.4.1
version: 5.4.1
ky: ky:
specifier: 1.2.4 specifier: 1.2.4
version: 1.2.4 version: 1.2.4
@@ -2250,6 +2256,99 @@ packages:
openapi-types: 12.1.3 openapi-types: 12.1.3
dev: true dev: true
/@ark-ui/anatomy@3.3.1(@internationalized/date@3.5.4):
resolution: {integrity: sha512-4cLS0C0wSthdPVH9MdOurDzgTMdq2mx1Goj86/XKPkzr6zlIydNWbNfkbyO+pd/8CyYEzHvkc3joE6NRUK/QQw==}
dependencies:
'@zag-js/accordion': 0.56.1
'@zag-js/anatomy': 0.56.1
'@zag-js/avatar': 0.56.1
'@zag-js/carousel': 0.56.1
'@zag-js/checkbox': 0.56.1
'@zag-js/clipboard': 0.56.1
'@zag-js/collapsible': 0.56.1
'@zag-js/color-picker': 0.56.1
'@zag-js/color-utils': 0.56.1
'@zag-js/combobox': 0.56.1
'@zag-js/date-picker': 0.56.1
'@zag-js/date-utils': 0.56.1(@internationalized/date@3.5.4)
'@zag-js/dialog': 0.56.1
'@zag-js/editable': 0.56.1
'@zag-js/file-upload': 0.56.1
'@zag-js/hover-card': 0.56.1
'@zag-js/menu': 0.56.1
'@zag-js/number-input': 0.56.1
'@zag-js/pagination': 0.56.1
'@zag-js/pin-input': 0.56.1
'@zag-js/popover': 0.56.1
'@zag-js/presence': 0.56.1
'@zag-js/progress': 0.56.1
'@zag-js/qr-code': 0.56.1
'@zag-js/radio-group': 0.56.1
'@zag-js/rating-group': 0.56.1
'@zag-js/select': 0.56.1
'@zag-js/signature-pad': 0.56.1
'@zag-js/slider': 0.56.1
'@zag-js/splitter': 0.56.1
'@zag-js/switch': 0.56.1
'@zag-js/tabs': 0.56.1
'@zag-js/tags-input': 0.56.1
'@zag-js/toast': 0.56.1
'@zag-js/toggle-group': 0.56.1
'@zag-js/tooltip': 0.56.1
'@zag-js/tree-view': 0.56.1
transitivePeerDependencies:
- '@internationalized/date'
dev: false
/@ark-ui/solid@3.3.0(@internationalized/date@3.5.4)(solid-js@1.7.8):
resolution: {integrity: sha512-jeJ2ez/TxH7aPX14uy+koeX8JN6tGjr+ZAGK2s9jeLfSMkBMvtgUeMjjm0zDRVhEPK54SlTF4wO8NPYL4I+DLw==}
peerDependencies:
solid-js: '>=1.6.0'
dependencies:
'@ark-ui/anatomy': 3.3.1(@internationalized/date@3.5.4)
'@zag-js/accordion': 0.56.1
'@zag-js/avatar': 0.56.1
'@zag-js/carousel': 0.56.1
'@zag-js/checkbox': 0.56.1
'@zag-js/clipboard': 0.56.1
'@zag-js/collapsible': 0.56.1
'@zag-js/color-picker': 0.56.1
'@zag-js/combobox': 0.56.1
'@zag-js/date-picker': 0.56.1
'@zag-js/dialog': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/editable': 0.56.1
'@zag-js/file-upload': 0.56.1
'@zag-js/file-utils': 0.56.1
'@zag-js/hover-card': 0.56.1
'@zag-js/i18n-utils': 0.56.1
'@zag-js/menu': 0.56.1
'@zag-js/number-input': 0.56.1
'@zag-js/pagination': 0.56.1
'@zag-js/pin-input': 0.56.1
'@zag-js/popover': 0.56.1
'@zag-js/presence': 0.56.1
'@zag-js/progress': 0.56.1
'@zag-js/qr-code': 0.56.1
'@zag-js/radio-group': 0.56.1
'@zag-js/rating-group': 0.56.1
'@zag-js/select': 0.56.1
'@zag-js/slider': 0.56.1
'@zag-js/solid': 0.56.1(solid-js@1.7.8)
'@zag-js/splitter': 0.56.1
'@zag-js/switch': 0.56.1
'@zag-js/tabs': 0.56.1
'@zag-js/tags-input': 0.56.1
'@zag-js/toast': 0.56.1
'@zag-js/toggle-group': 0.56.1
'@zag-js/tooltip': 0.56.1
'@zag-js/tree-view': 0.56.1
'@zag-js/types': 0.56.1
solid-js: 1.7.8
transitivePeerDependencies:
- '@internationalized/date'
dev: false
/@babel/code-frame@7.23.5: /@babel/code-frame@7.23.5:
resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -5953,6 +6052,13 @@ packages:
'@floating-ui/utils': 0.2.1 '@floating-ui/utils': 0.2.1
dev: false dev: false
/@floating-ui/dom@1.6.5:
resolution: {integrity: sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==}
dependencies:
'@floating-ui/core': 1.6.0
'@floating-ui/utils': 0.2.1
dev: false
/@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0): /@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==}
peerDependencies: peerDependencies:
@@ -6264,6 +6370,22 @@ packages:
dev: true dev: true
optional: true optional: true
/@internationalized/date@3.5.4:
resolution: {integrity: sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==}
dependencies:
'@swc/helpers': 0.5.10
dev: false
/@internationalized/number@3.5.3:
resolution: {integrity: sha512-rd1wA3ebzlp0Mehj5YTuTI50AQEx80gWFyHcQu+u91/5NgdwBecO8BH6ipPfE+lmQ9d63vpB3H9SHoIUiupllw==}
dependencies:
'@swc/helpers': 0.5.10
dev: false
/@ioredis/commands@1.2.0:
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
dev: false
/@isaacs/cliui@8.0.2: /@isaacs/cliui@8.0.2:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -10296,20 +10418,596 @@ packages:
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
dev: false dev: false
/@zag-js/accordion@0.56.1:
resolution: {integrity: sha512-ylaVNTdqf5sORTUXK+9bg57fLrpA7eZkhuffPRgqw+1Di/VRCRnLjFpd0VBdzkVKvgurUF/YyewNCouVsW8TjQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/anatomy@0.56.1:
resolution: {integrity: sha512-4n7WKZ2YEYIsKSV7rEZcx6pGKhIJ6EHSn0KrC8BNI+0roJfYCfQbggih2f4E3JFT29pUFaSXIpRJa7XYyClQMw==}
dev: false
/@zag-js/aria-hidden@0.56.1:
resolution: {integrity: sha512-GFUsxLZUINkBmzafUI1WlBsqld+JxECJ2+t1CFlTap3ROOoTpqGg69XnldnK+l2qEJVS5+9nAHZA5oL2491ipw==}
dependencies:
'@zag-js/dom-query': 0.56.1
dev: false
/@zag-js/auto-resize@0.56.1:
resolution: {integrity: sha512-MHvBL5cPeI1uNjJ7n3lap0w0m1xtxN7VcwVoLqNI44Wcf9/DmDPA+EhUQTA0koTLEI7gf+dxiRcdpnsFWpXyqQ==}
dependencies:
'@zag-js/dom-query': 0.56.1
dev: false
/@zag-js/avatar@0.56.1:
resolution: {integrity: sha512-cUhoSuLfHZg8g/oy+eSi8cBbGL+xsKw8VBYq7dYgYB+DSuT7WMQkT9XDEa+KxHxnF0oDDKRmCcCHJTykpbzOOQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/carousel@0.56.1:
resolution: {integrity: sha512-1DnDrDxH9yUU7pA6EmGptVxNE19nLlRPHL/jpKojAYNk/D0Xsv9siJqX7+YZT1F8x4fISL8vsfjO47kHby2GfQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/checkbox@0.56.1:
resolution: {integrity: sha512-LblpuBui+1mG+pSIHySJpg0C4qgB1AM3vf3287AOa/ah9JrwqUSSLhz5Oo9/NBtnK7NDjBNS4cNX/vkDAqreuA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/clipboard@0.56.1:
resolution: {integrity: sha512-xpGf3cziVhWI/AUmR8fBb5l6TeiYvK0+rUzxbsIN6A8vZYtA+iJO7BOJxCwobC02gbLLFV4dUMve5ff+MQ8oIw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/collapsible@0.56.1:
resolution: {integrity: sha512-wJuzrgCwW+wwuWSx0GwlCyCbCDvWjFAHknkuzo3rfuKjSRl0f19QuSvfju+Ie9CcFIj/1QwN1vA/h15mmJQGqQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/collection@0.56.1:
resolution: {integrity: sha512-aA0cCQS9XeSBIg7ivJXlVFFl74ljQIeKD/TAfVoc07fLXS2cjdmR58g0xYd84twawTEYUgQYg+A3hBDFx1uPhQ==}
dev: false
/@zag-js/color-picker@0.56.1:
resolution: {integrity: sha512-GDAo+kUKAX5C9qvae11sML2RmsKxIGQeVkoKsUGy74BX9IzH7tEQfxejuG49Zn+4YNg/+Q+dzaar7WmTS7jqQQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/color-utils': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/text-selection': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/color-utils@0.56.1:
resolution: {integrity: sha512-masYXZXn/gMbNdri/ZBk53IKvEd2TjlXpW+wzawZ5BbZjjqrCpSsvOM1lMmPvCyrJmhVQrvIZEnQXfDxfb5TSw==}
dependencies:
'@zag-js/numeric-range': 0.56.1
dev: false
/@zag-js/combobox@0.56.1:
resolution: {integrity: sha512-NKHl2buWNE38BSU+3ZBbz9XY9ASsayO2xJQ4rU3xLEM9uUHG/Q8pP8+uaxP5x1oEaLBznzVnS/6Le7x8uRWg8A==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/aria-hidden': 0.56.1
'@zag-js/collection': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/core@0.56.1:
resolution: {integrity: sha512-3Iwum6+UmDxBqJIT4t2wSnr5hz83c6oRDS8j7I0hgV4U2Xt/IJMPedZt/KmV8ghjai/BlETskHUW1JvwZ4jaYA==}
dependencies:
'@zag-js/store': 0.56.1
klona: 2.0.6
dev: false
/@zag-js/date-picker@0.56.1:
resolution: {integrity: sha512-C62WGpTzQeH1IsnNVANF4JBYldXxvHpRerRhiAKA/x2bMZlyIxSre5n+O3YBSHUimDPbXBWBmqoO8PgBgtciBQ==}
dependencies:
'@internationalized/date': 3.5.4
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/date-utils': 0.56.1(@internationalized/date@3.5.4)
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/live-region': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/text-selection': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/date-utils@0.56.1(@internationalized/date@3.5.4):
resolution: {integrity: sha512-n71r6L4MV5V6YP0lwVPMsy2IV0EwnXYca+b6hSogQ2PAOwgB2JpxEBQ/F4ItPTC/yVqoEKgcQUniSTf67F+dBQ==}
peerDependencies:
'@internationalized/date': '>=3.0.0'
dependencies:
'@internationalized/date': 3.5.4
dev: false
/@zag-js/dialog@0.56.1:
resolution: {integrity: sha512-dD4A457wW5Lnz2nm9vVvDQs2H3z/CTVS6ujCq/9W61Eo3LmhCPJQOHbr1QDB8kssYYdZn6eJZsbO5PSt7gCO1w==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/aria-hidden': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/remove-scroll': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
focus-trap: 7.5.4
dev: false
/@zag-js/dismissable@0.56.1:
resolution: {integrity: sha512-jG3KMdhazYgcoW83Aay/VCKg/VParejXhKqZn+7ze5doqRXklL2LsuwSd2ecWuqX9ckB0ppd63w0IN7xAL1I8Q==}
dependencies:
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/interact-outside': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/dom-event@0.56.1:
resolution: {integrity: sha512-OUh6ELiN6RIkSFt6pQ+Ct7jdH5XoonBThBBLUKoUd5qr49bIUw+CIaJyVrngUaPSCTqpzIqS3lL3uvVbMN0W+g==}
dependencies:
'@zag-js/dom-query': 0.56.1
'@zag-js/text-selection': 0.56.1
'@zag-js/types': 0.56.1
dev: false
/@zag-js/dom-query@0.16.0: /@zag-js/dom-query@0.16.0:
resolution: {integrity: sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==} resolution: {integrity: sha512-Oqhd6+biWyKnhKwFFuZrrf6lxBz2tX2pRQe6grUnYwO6HJ8BcbqZomy2lpOdr+3itlaUqx+Ywj5E5ZZDr/LBfQ==}
dev: false dev: false
/@zag-js/dom-query@0.56.1:
resolution: {integrity: sha512-mxUa7vzI+NhaMpf0D3cci0kmRuCPBkZCvoV+jsokkLfFKEUvAMVftnzxMxeydAEK6LFZL6PK6icOXP5FxbzzLA==}
dev: false
/@zag-js/editable@0.56.1:
resolution: {integrity: sha512-zVoGRDotNwTehsLaKvPTPTMSXEkYvoUJfYd3hliYUQgo738uQTqj8TXDU8emW/WKJbHISlChiLIK05XupSMxbQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/interact-outside': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/element-rect@0.56.1:
resolution: {integrity: sha512-WPg+7sbHZnXDWczWfO1aBESQYzQhwD//yB3UtXUCSfknEdU1ZeTixLxujKgPrd/a+BlqluNbFUkI7kYRUrXsrw==}
dev: false
/@zag-js/element-size@0.10.5: /@zag-js/element-size@0.10.5:
resolution: {integrity: sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==} resolution: {integrity: sha512-uQre5IidULANvVkNOBQ1tfgwTQcGl4hliPSe69Fct1VfYb2Fd0jdAcGzqQgPhfrXFpR62MxLPB7erxJ/ngtL8w==}
dev: false dev: false
/@zag-js/element-size@0.56.1:
resolution: {integrity: sha512-gwxMUkGMbQa/lB+boFxpsGI0eyOMRa0bLsz9BFrIiUTrYWMV576Le4QbFQA6UjsymlzR4WnCWEkiF5TLnCA8eQ==}
dev: false
/@zag-js/file-upload@0.56.1:
resolution: {integrity: sha512-g/QTQjm3ynBTPCKpYwvtwTYcbvzpWAqIjowSVtCupZVd6po+feA7aYAzfJVywR6sqQZwiLPyZR+wD2IpO7fYcg==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/file-utils': 0.56.1
'@zag-js/i18n-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/file-utils@0.56.1:
resolution: {integrity: sha512-RQbvfNMTyKCBwKKzmBZcPAfk/L7HLLhr6kQvV82Dop8fKxdu4u6cdpYUwRtDn/eX4QcHJ1kQ8PpzcrVIhEggLw==}
dependencies:
'@zag-js/i18n-utils': 0.56.1
dev: false
/@zag-js/focus-visible@0.16.0: /@zag-js/focus-visible@0.16.0:
resolution: {integrity: sha512-a7U/HSopvQbrDU4GLerpqiMcHKEkQkNPeDZJWz38cw/6Upunh41GjHetq5TB84hxyCaDzJ6q2nEdNoBQfC0FKA==} resolution: {integrity: sha512-a7U/HSopvQbrDU4GLerpqiMcHKEkQkNPeDZJWz38cw/6Upunh41GjHetq5TB84hxyCaDzJ6q2nEdNoBQfC0FKA==}
dependencies: dependencies:
'@zag-js/dom-query': 0.16.0 '@zag-js/dom-query': 0.16.0
dev: false dev: false
/@zag-js/form-utils@0.56.1:
resolution: {integrity: sha512-7u3PjOl21muU89Ungl9bjBVqsHNNqlOnsl/2ws8t/1qkQbcGJnzAPG/AkM4njj0A5IXO9X6MqIa+9qgEYMviCQ==}
dev: false
/@zag-js/hover-card@0.56.1:
resolution: {integrity: sha512-cXias9qoUC2+YeZVM2Crx2WS/gio76SiKcWvg6040KvoYxNNNTOh5IqMIC+EjR9iaBdiIKPaS5HD92kGU0qbdw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/i18n-utils@0.56.1:
resolution: {integrity: sha512-vLxGFsDYABn7DOYDlob4nvwr3ZaWoYJc7P51oo+xd3khmLyL2Mcq50LiH/fPKLxX+EFrnSMEjovFPHJm9rCPBQ==}
dependencies:
'@zag-js/dom-query': 0.56.1
dev: false
/@zag-js/interact-outside@0.56.1:
resolution: {integrity: sha512-yy9Vg4vU5P0xbmJ2vKGfk09lCLM8UFECBAlLq8GNHzjj+rpi7tb4BW4AFCMcADwsilV7elB2i98r8e5/wwnbbQ==}
dependencies:
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/live-region@0.56.1:
resolution: {integrity: sha512-hZANI+2d079O3u+GfqWgHkngMrJbQNC0PLwLEoikhx3ruD8oXG1GX/fw+dQLpzFxvvFgkn/ABlVspmAAFuUXsw==}
dev: false
/@zag-js/menu@0.56.1:
resolution: {integrity: sha512-9V8STio6PGAAoKLnRuQ4ygUCgk6NzQQ3ThMaIwwH2tCSRlam/HwP3ld9W8nnbiOrqn9RzU9XSj7ZvS567xqyzQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/rect-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/number-input@0.56.1:
resolution: {integrity: sha512-/Y5gan//XjJZKwW7EQm3tEBeDZvI4XXje5/5JVlSgZkvWNeYhlJ2xxosYR1GUvOzWV8Xp/2buaylPvNJdKtt8A==}
dependencies:
'@internationalized/number': 3.5.3
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/number-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/number-utils@0.56.1:
resolution: {integrity: sha512-oz7hnTeK5NNffP3rF7K9ytkPoMSrpu15NFGDfis6oIkFx10WQn73+FqDq9c11HlkOKRUhPyYLggLgLgu0VSjjg==}
dev: false
/@zag-js/numeric-range@0.56.1:
resolution: {integrity: sha512-26bVw4JMyZoO4M7Lc2wGEd2HV+cnDGN79RSjTpb2hjGcLfyxPWNDYcOjROX7CGFm3tcTq5RAvHfu6cPXYkMeng==}
dev: false
/@zag-js/pagination@0.56.1:
resolution: {integrity: sha512-trO621AuQNsrFc+DfSj0tmA8xvWO/8m1jyW1ePRQCVii1XVtlOQLUAkRUGoY2gGNL1aBr68JX3oij1ZLAlcOLw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/pin-input@0.56.1:
resolution: {integrity: sha512-8w5GvmZA/XDr5qO8e92Fpr12mjbtSv4PJPwLtWS1uTD8EXzGQbHCNxNpVbnPjva5eiX+z+RY7O/wMVr78Bk6PQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/popover@0.56.1:
resolution: {integrity: sha512-cRqjN32Kzt6xjd9E1+rF4LE4i0hzZvJQVRhahFXLsFO2r+AuK6tK4CNWZWX14BatngPm7DKI2CJei5c7UvdAeg==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/aria-hidden': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/remove-scroll': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
focus-trap: 7.5.4
dev: false
/@zag-js/popper@0.56.1:
resolution: {integrity: sha512-dAkIbVlicG78zWXezjh3qsFTtdC+nw8wkNGJv5MajDaMbhSApE/Fe1ogDbReAbMQ43DGNWpSRnU9o+DxQgoZZg==}
dependencies:
'@floating-ui/dom': 1.6.5
'@zag-js/dom-query': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/presence@0.56.1:
resolution: {integrity: sha512-x7Cb+XrZp7KWZ+3p66OQzIIa9/cVNYO/YzLovYlvdC+CcX8WykG1VtUikTbrqKGLkrjPqHksFKseIfX/BaXRHQ==}
dependencies:
'@zag-js/core': 0.56.1
'@zag-js/types': 0.56.1
dev: false
/@zag-js/progress@0.56.1:
resolution: {integrity: sha512-g/voxjIjvclT6gKblv6FueqEm2Rack46eDIDIzqPvBskS+p4QPG4y53qGZQRU6Jw5u/fKREerKCVaA2aXo4Dug==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/qr-code@0.56.1:
resolution: {integrity: sha512-OZrDqVyGr3gd4FA5DmOQU3f3J/z3WPHxmSSt/mW/pw8hNQf+xL2YpnmfpaJAk7fUJ9B+hPN24ACXYkGQVy6rIA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
proxy-memoize: 3.0.0
uqr: 0.1.2
dev: false
/@zag-js/radio-group@0.56.1:
resolution: {integrity: sha512-lY9Ezto4Ve0pF1u8azrypyxZ8IAZ4P5XFK9r+JKsubj00EeOFwU/5t7bseKuQtXLXuL/NDLS16GvIlVCHqePAw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/element-rect': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/rating-group@0.56.1:
resolution: {integrity: sha512-ihr/cr1e3DClrwmEKZxyk5LQPop6NFv9ElbKz4b5nQUICFCKHSAQuIuPaJbnoM4TCQWDO39manh+o7W2IhHfog==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/rect-utils@0.56.1:
resolution: {integrity: sha512-x3uUJvr2IsKk9xMdOE/IqCPxFwg6GO+s8in6cvJV0n+tePmTWfMxhIqRm/o9kftA/UZt2/LcOnK0YOkfeEBWmA==}
dev: false
/@zag-js/remove-scroll@0.56.1:
resolution: {integrity: sha512-nWUEXJb2gzzcWNZiH8sydavefC79VPSBtSc1bXDlAXI3y6swY0rp/0hP20+WvCpwMI9I2o/zKTA4lY9WY3y9Kw==}
dependencies:
'@zag-js/dom-query': 0.56.1
dev: false
/@zag-js/select@0.56.1:
resolution: {integrity: sha512-V5EwfHGDUx4Itmuxvvlths1BTkePcs3wibywU8S3CShciLH3LlMz0EI33fgFeqjBDhtBAI8smi1jx9iT56YDVw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/collection': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/signature-pad@0.56.1:
resolution: {integrity: sha512-qOh1iUPnyZr0oAiwMzKQqGo6UgS9yFRdbTHFyN+DiRYwb9I5R71laIupETnvInLQv90wuesCBxBezNoEfZYYZw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
perfect-freehand: 1.2.2
dev: false
/@zag-js/slider@0.56.1:
resolution: {integrity: sha512-qx10xyvTIfF5eTYGrXJf+4Ix3ul6Pn//V0EtRizo141briwAzaL+sZ/hig5wqXnV1ZcfX8n0STBmls9//z35bQ==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/element-size': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/numeric-range': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/solid@0.56.1(solid-js@1.7.8):
resolution: {integrity: sha512-l8p618IJLZL9rVlc398zOT0uDemgpsfOEJW14aVDeb1yXQHGV9chcRX2m8YRADGAOu1jv0/HpKk6n2sUwIaYEQ==}
peerDependencies:
solid-js: '>=1.1.3'
dependencies:
'@zag-js/core': 0.56.1
'@zag-js/store': 0.56.1
'@zag-js/types': 0.56.1
solid-js: 1.7.8
dev: false
/@zag-js/splitter@0.56.1:
resolution: {integrity: sha512-KkYpydjdL9hGqnsWL0atrDtUvn14lYfWXhUqH+pQwtvlc3qIZtTl5c9bLdgHcXNiN6Y/BLRqN3y9aJZzUJmYnA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/number-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/store@0.56.1:
resolution: {integrity: sha512-hc7lwqhor+WZXXXuCEV889QEHX7hBpTN0NUS+pHa8fED7QeJN+EhJZMwC3g6YLVde4w3MlxB6I92O5lruNNyEg==}
dependencies:
proxy-compare: 3.0.0
dev: false
/@zag-js/switch@0.56.1:
resolution: {integrity: sha512-pvQo89TwYzjsUmgzY2ogK6JwiVwb1bzRlZ3H3pV764WfgXwZYhCq7BfcR5gLpS0Tv4jhaBCMP7Fh38SWSRePxA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/tabs@0.56.1:
resolution: {integrity: sha512-sLWeDb+PDujQ6q98staAqHDom1o4flzu8MsqgEjLoAe2hkJnYdQ/lOhmrNCu5I4+kDklBb8B9Dm0s5457EfAfA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/element-rect': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/tags-input@0.56.1:
resolution: {integrity: sha512-9merflXwcmV6ArVQQFUjWJ2WkkUeLCJll14gJj2BNf1HLUFnOX2b7Ud6e4F2Mzn5oBmM19cuoEsOLFNwCHeZzA==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/auto-resize': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/form-utils': 0.56.1
'@zag-js/interact-outside': 0.56.1
'@zag-js/live-region': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/text-selection@0.56.1:
resolution: {integrity: sha512-1r6Bz+/nLiyMw6N8opeQii6h/N7J6rNDuvys59QdbTc1rbI02DPNoRwGVfC/rBhSBK5h3vewQZePQokgnf39fQ==}
dependencies:
'@zag-js/dom-query': 0.56.1
dev: false
/@zag-js/toast@0.56.1:
resolution: {integrity: sha512-giFSnmRosL8xWAVmZDBPGGO4gEjV+XrWbSybTco3x93kTdi6+qJMYw47R1kgvsBioYqSHVreGWvdlgiJOcfWNg==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dismissable': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/toggle-group@0.56.1:
resolution: {integrity: sha512-JnAuTBL7apYceD/HpaEZMa8dJCNjaXcPMcQpXC5Pm0fmPMGEG/T065QlVwJCwJE8AJ/8eF5Hzpyc889gzdxiXg==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/tooltip@0.56.1:
resolution: {integrity: sha512-0BAAdZ+Mip+EzLSs/9DYqy9JEIXxDBx8QOLRCum5biY5BpaCkCWzx/ZU9zPFK3CSr5hzoyRlJ9k/g4/UVC+mnw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/popper': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/tree-view@0.56.1:
resolution: {integrity: sha512-k3t80JH4Y9jBrX1EI/48hyyhmVx0Wz8+iHD+VCRZsQKSOCODJMDUasi5TBuGgxGsxBiu/eUquLsb4QVqsmCjmw==}
dependencies:
'@zag-js/anatomy': 0.56.1
'@zag-js/core': 0.56.1
'@zag-js/dom-event': 0.56.1
'@zag-js/dom-query': 0.56.1
'@zag-js/types': 0.56.1
'@zag-js/utils': 0.56.1
dev: false
/@zag-js/types@0.56.1:
resolution: {integrity: sha512-RkAO2PInKWo6oQmrFkwXpWO1VitL0jPs8D30r66/6+lSAEGLnCUifnkZchluJ8vlHvr04Q5emzFkIG4fQHLLmw==}
dependencies:
csstype: 3.1.3
dev: false
/@zag-js/utils@0.56.1:
resolution: {integrity: sha512-zJ1HxV+26I6Uu0M112ybEJUsHXlHQ2bE9XuDX+n7uDzPbilMvLQ87XYhJ40bb2lykgNsKCWhLWV9xkiEVJD38w==}
dev: false
/@zxing/text-encoding@0.9.0: /@zxing/text-encoding@0.9.0:
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
requiresBuild: true requiresBuild: true
@@ -11535,6 +12233,11 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: false dev: false
/cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
dev: false
/co-body@6.1.0: /co-body@6.1.0:
resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==} resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==}
dependencies: dependencies:
@@ -12246,6 +12949,11 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
/denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dev: false
/depd@1.1.2: /depd@1.1.2:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -13754,6 +14462,12 @@ packages:
tslib: 2.6.0 tslib: 2.6.0
dev: false dev: false
/focus-trap@7.5.4:
resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==}
dependencies:
tabbable: 6.2.0
dev: false
/focus-visible@5.2.0: /focus-visible@5.2.0:
resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==} resolution: {integrity: sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ==}
dev: false dev: false
@@ -14766,6 +15480,23 @@ packages:
loose-envify: 1.4.0 loose-envify: 1.4.0
dev: false dev: false
/ioredis@5.4.1:
resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==}
engines: {node: '>=12.22.0'}
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2
debug: 4.3.4
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
dev: false
/ip-regex@4.3.0: /ip-regex@4.3.0:
resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -15930,6 +16661,11 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
/klona@2.0.6:
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
engines: {node: '>= 8'}
dev: false
/known-css-properties@0.24.0: /known-css-properties@0.24.0:
resolution: {integrity: sha512-RTSoaUAfLvpR357vWzAz/50Q/BmHfmE6ETSWfutT0AJiw10e6CmcdYRQJlLRd95B53D0Y2aD1jSxD3V3ySF+PA==} resolution: {integrity: sha512-RTSoaUAfLvpR357vWzAz/50Q/BmHfmE6ETSWfutT0AJiw10e6CmcdYRQJlLRd95B53D0Y2aD1jSxD3V3ySF+PA==}
dev: true dev: true
@@ -16045,6 +16781,10 @@ packages:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false dev: false
/lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
dev: false
/lodash.includes@4.3.0: /lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
dev: false dev: false
@@ -16053,6 +16793,10 @@ packages:
resolution: {integrity: sha512-SC4Usc0XbIKuz3eH7oNwPqibKHfTJSGVZwO/6eGhdoPzqexOY7z43pKo8xz0M5zzXSRteADV6fW7cRf6Ru0+VA==} resolution: {integrity: sha512-SC4Usc0XbIKuz3eH7oNwPqibKHfTJSGVZwO/6eGhdoPzqexOY7z43pKo8xz0M5zzXSRteADV6fW7cRf6Ru0+VA==}
dev: true dev: true
/lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
dev: false
/lodash.isfunction@3.0.9: /lodash.isfunction@3.0.9:
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
dev: true dev: true
@@ -18302,6 +19046,10 @@ packages:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
dev: true dev: true
/perfect-freehand@1.2.2:
resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==}
dev: false
/periscopic@3.1.0: /periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
dependencies: dependencies:
@@ -18974,9 +19722,19 @@ packages:
/proxy-compare@2.6.0: /proxy-compare@2.6.0:
resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==} resolution: {integrity: sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==}
/proxy-compare@3.0.0:
resolution: {integrity: sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w==}
dev: false
/proxy-from-env@1.1.0: /proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
/proxy-memoize@3.0.0:
resolution: {integrity: sha512-2fs4eIg4w6SfOjKHGVdg5tJ9WgHifEXKo2gfS/+tHGajO2YtAu03lLs+ltNKnteGKvq3SvHromkZeKus4J39/g==}
dependencies:
proxy-compare: 3.0.0
dev: false
/pseudomap@1.0.2: /pseudomap@1.0.2:
resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==}
dev: false dev: false
@@ -19520,6 +20278,18 @@ packages:
strip-indent: 3.0.0 strip-indent: 3.0.0
dev: false dev: false
/redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
dev: false
/redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
dependencies:
redis-errors: 1.2.0
dev: false
/reflect.getprototypeof@1.0.5: /reflect.getprototypeof@1.0.5:
resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==} resolution: {integrity: sha512-62wgfC8dJWrmxv44CA36pLDnP6KKl3Vhxb7PL+8+qrrFMMoJij4vgiMP8zV4O8+CBMXY1mHxI5fITGHXFHVmQQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -20486,6 +21256,10 @@ packages:
type-fest: 0.7.1 type-fest: 0.7.1
dev: false dev: false
/standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
dev: false
/statuses@1.5.0: /statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -21710,6 +22484,10 @@ packages:
/upper-case@1.1.3: /upper-case@1.1.3:
resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==}
/uqr@0.1.2:
resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==}
dev: false
/uri-js@4.4.1: /uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies: dependencies: