2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,3 +30,5 @@ dump.sql
|
|||||||
dump.tar
|
dump.tar
|
||||||
|
|
||||||
__env.js
|
__env.js
|
||||||
|
|
||||||
|
invalidTypebots.json
|
||||||
@@ -14,7 +14,9 @@ Sentry.init({
|
|||||||
hint?.event.target.innerText
|
hint?.event.target.innerText
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
return breadcrumb
|
return breadcrumb
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const SearchableDropdown = ({
|
|||||||
const { onOpen, onClose, isOpen } = useDisclosure()
|
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||||
const [inputValue, setInputValue] = useState(selectedItem ?? '')
|
const [inputValue, setInputValue] = useState(selectedItem ?? '')
|
||||||
const debounced = useDebouncedCallback(
|
const debounced = useDebouncedCallback(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
onValueChange ? onValueChange : () => {},
|
onValueChange ? onValueChange : () => {},
|
||||||
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
Flex,
|
Flex,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverTrigger,
|
|
||||||
Input,
|
Input,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
Button,
|
Button,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const userContext = createContext<{
|
|||||||
currentWorkspaceId?: string
|
currentWorkspaceId?: string
|
||||||
updateUser: (newUser: Partial<User>) => void
|
updateUser: (newUser: Partial<User>) => void
|
||||||
saveUser: (newUser?: Partial<User>) => Promise<void>
|
saveUser: (newUser?: Partial<User>) => Promise<void>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { UploadIcon } from '@/components/icons'
|
import { UploadIcon } from '@/components/icons'
|
||||||
import React, { ChangeEvent, useState } from 'react'
|
import React, { ChangeEvent } from 'react'
|
||||||
import { isDefined } from 'utils'
|
import { isDefined } from 'utils'
|
||||||
import { ApiTokensList } from './ApiTokensList'
|
import { ApiTokensList } from './ApiTokensList'
|
||||||
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
|
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'
|
||||||
|
|||||||
@@ -46,10 +46,14 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
|||||||
flex="1"
|
flex="1"
|
||||||
typebot={publishedTypebot}
|
typebot={publishedTypebot}
|
||||||
onUnlockProPlanClick={onOpen}
|
onUnlockProPlanClick={onOpen}
|
||||||
answersCounts={[
|
answersCounts={
|
||||||
|
answersCounts
|
||||||
|
? [
|
||||||
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
|
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
|
||||||
...answersCounts?.slice(1),
|
...answersCounts.slice(1),
|
||||||
]}
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</GroupsCoordinatesProvider>
|
</GroupsCoordinatesProvider>
|
||||||
</GraphProvider>
|
</GraphProvider>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
|||||||
onCredentialsSelect={handleCredentialsSelect}
|
onCredentialsSelect={handleCredentialsSelect}
|
||||||
onCreateNewClick={onOpen}
|
onCreateNewClick={onOpen}
|
||||||
defaultCredentialLabel={env('SMTP_FROM')
|
defaultCredentialLabel={env('SMTP_FROM')
|
||||||
?.match(/\<(.*)\>/)
|
?.match(/<(.*)>/)
|
||||||
?.pop()}
|
?.pop()}
|
||||||
refreshDropdownKey={refreshCredentialsKey}
|
refreshDropdownKey={refreshCredentialsKey}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const getDeepKeys = (obj: any): string[] => {
|
export const getDeepKeys = (obj: any): string[] => {
|
||||||
let keys: string[] = []
|
let keys: string[] = []
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
SkeletonCircle,
|
SkeletonCircle,
|
||||||
SkeletonText,
|
|
||||||
Text,
|
Text,
|
||||||
Tag,
|
Tag,
|
||||||
Flex,
|
Flex,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const useTypebots = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, refetch } = trpc.typebot.listTypebots.useQuery(
|
const { data, isLoading, refetch } = trpc.typebot.listTypebots.useQuery(
|
||||||
{
|
{
|
||||||
workspaceId: workspaceId!,
|
workspaceId: workspaceId as string,
|
||||||
folderId,
|
folderId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Flex, HStack, Tooltip, useColorModeValue } from '@chakra-ui/react'
|
||||||
Flex,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
useColorModeValue,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { DraggableBlockType } from 'models'
|
import { DraggableBlockType } from 'models'
|
||||||
import { useBlockDnd } from '@/features/graph'
|
import { useBlockDnd } from '@/features/graph'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const editorContext = createContext<{
|
|||||||
setRightPanel: Dispatch<SetStateAction<RightPanel | undefined>>
|
setRightPanel: Dispatch<SetStateAction<RightPanel | undefined>>
|
||||||
startPreviewAtGroup: string | undefined
|
startPreviewAtGroup: string | undefined
|
||||||
setStartPreviewAtGroup: Dispatch<SetStateAction<string | undefined>>
|
setStartPreviewAtGroup: Dispatch<SetStateAction<string | undefined>>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const typebotContext = createContext<
|
|||||||
ItemsActions &
|
ItemsActions &
|
||||||
VariablesActions &
|
VariablesActions &
|
||||||
EdgesActions
|
EdgesActions
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
>({})
|
>({})
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const typebotDndContext = createContext<{
|
|||||||
setDraggedTypebot: Dispatch<SetStateAction<TypebotInDashboard | undefined>>
|
setDraggedTypebot: Dispatch<SetStateAction<TypebotInDashboard | undefined>>
|
||||||
mouseOverFolderId?: string | null
|
mouseOverFolderId?: string | null
|
||||||
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
|
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const FolderContent = ({ folder }: Props) => {
|
|||||||
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
|
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTypebotDeleted = (deletedId: string) => {
|
const handleTypebotDeleted = () => {
|
||||||
if (!typebots) return
|
if (!typebots) return
|
||||||
refetchTypebots()
|
refetchTypebots()
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ export const FolderContent = ({ folder }: Props) => {
|
|||||||
<TypebotButton
|
<TypebotButton
|
||||||
key={typebot.id.toString()}
|
key={typebot.id.toString()}
|
||||||
typebot={typebot}
|
typebot={typebot}
|
||||||
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
|
onTypebotDeleted={handleTypebotDeleted}
|
||||||
onMouseDown={handleMouseDown(typebot)}
|
onMouseDown={handleMouseDown(typebot)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
VStack,
|
VStack,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { GlobeIcon, ToolIcon } from '@/components/icons'
|
|
||||||
import { TypebotInDashboard } from '@/features/dashboard'
|
import { TypebotInDashboard } from '@/features/dashboard'
|
||||||
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Coordinates, useGraph, useGroupsCoordinates } from '../../providers'
|
import { Coordinates, useGraph, useGroupsCoordinates } from '../../providers'
|
||||||
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||||
import { Edge as EdgeProps } from 'models'
|
import { Edge as EdgeProps } from 'models'
|
||||||
import { color, Portal, useColorMode, useDisclosure } from '@chakra-ui/react'
|
import { Portal, useColorMode, useDisclosure } from '@chakra-ui/react'
|
||||||
import { useTypebot } from '@/features/editor'
|
import { useTypebot } from '@/features/editor'
|
||||||
import { EdgeMenu } from './EdgeMenu'
|
import { EdgeMenu } from './EdgeMenu'
|
||||||
import { colors } from '@/lib/theme'
|
import { colors } from '@/lib/theme'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
|
import { useEventListener, Stack, Portal } from '@chakra-ui/react'
|
||||||
import { DraggableBlock, DraggableBlockType, Block } from 'models'
|
import { DraggableBlock, DraggableBlockType, Block } from 'models'
|
||||||
import {
|
import {
|
||||||
computeNearestPlaceholderIndex,
|
computeNearestPlaceholderIndex,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const graphDndContext = createContext<{
|
|||||||
setMouseOverGroup: Dispatch<SetStateAction<NodeInfo | undefined>>
|
setMouseOverGroup: Dispatch<SetStateAction<NodeInfo | undefined>>
|
||||||
mouseOverBlock?: NodeInfo
|
mouseOverBlock?: NodeInfo
|
||||||
setMouseOverBlock: Dispatch<SetStateAction<NodeInfo | undefined>>
|
setMouseOverBlock: Dispatch<SetStateAction<NodeInfo | undefined>>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const graphContext = createContext<{
|
|||||||
isReadOnly: boolean
|
isReadOnly: boolean
|
||||||
focusedGroupId?: string
|
focusedGroupId?: string
|
||||||
setFocusedGroupId: Dispatch<SetStateAction<string | undefined>>
|
setFocusedGroupId: Dispatch<SetStateAction<string | undefined>>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({
|
}>({
|
||||||
graphPosition: graphPositionDefaultValue({ x: 0, y: 0 }),
|
graphPosition: graphPositionDefaultValue({ x: 0, y: 0 }),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GroupsCoordinates, Coordinates } from './GraphProvider'
|
|||||||
const groupsCoordinatesContext = createContext<{
|
const groupsCoordinatesContext = createContext<{
|
||||||
groupsCoordinates: GroupsCoordinates
|
groupsCoordinates: GroupsCoordinates
|
||||||
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
|
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const resultsContext = createContext<{
|
|||||||
onDeleteResults: (totalResultsDeleted: number) => void
|
onDeleteResults: (totalResultsDeleted: number) => void
|
||||||
fetchNextPage: () => void
|
fetchNextPage: () => void
|
||||||
refetchResults: () => void
|
refetchResults: () => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Checkbox, Flex } from '@chakra-ui/react'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const TableCheckBox = (
|
const TableCheckBox = (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
{ indeterminate, checked, ...rest }: any,
|
{ indeterminate, checked, ...rest }: any,
|
||||||
ref: React.LegacyRef<HTMLInputElement>
|
ref: React.LegacyRef<HTMLInputElement>
|
||||||
) => {
|
) => {
|
||||||
const defaultRef = React.useRef()
|
const defaultRef = React.useRef()
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const resolvedRef: any = ref || defaultRef
|
const resolvedRef: any = ref || defaultRef
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Flex,
|
Flex,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
useColorMode,
|
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test.describe.parallel('Templates page', () => {
|
|||||||
|
|
||||||
test('From file should import correctly', async ({ page }) => {
|
test('From file should import correctly', async ({ page }) => {
|
||||||
await page.goto('/typebots/create')
|
await page.goto('/typebots/create')
|
||||||
await page.waitForTimeout(1000)
|
await page.waitForTimeout(2000)
|
||||||
await page.setInputFiles(
|
await page.setInputFiles(
|
||||||
'input[type="file"]',
|
'input[type="file"]',
|
||||||
getTestAsset('typebots/singleChoiceTarget.json')
|
getTestAsset('typebots/singleChoiceTarget.json')
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ test.describe.parallel('Theme page', () => {
|
|||||||
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
|
||||||
id: typebotId,
|
id: typebotId,
|
||||||
})
|
})
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
|
||||||
await page.goto(`/typebots/${typebotId}/theme`)
|
await page.goto(`/typebots/${typebotId}/theme`)
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -26,6 +25,7 @@ const workspaceContext = createContext<{
|
|||||||
updateWorkspace: (updates: { icon?: string; name?: string }) => void
|
updateWorkspace: (updates: { icon?: string; name?: string }) => void
|
||||||
deleteCurrentWorkspace: () => Promise<void>
|
deleteCurrentWorkspace: () => Promise<void>
|
||||||
refreshWorkspace: () => void
|
refreshWorkspace: () => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
@@ -59,12 +59,12 @@ export const WorkspaceProvider = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { data: workspaceData } = trpc.workspace.getWorkspace.useQuery(
|
const { data: workspaceData } = trpc.workspace.getWorkspace.useQuery(
|
||||||
{ workspaceId: workspaceId! },
|
{ workspaceId: workspaceId as string },
|
||||||
{ enabled: !!workspaceId }
|
{ enabled: !!workspaceId }
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: membersData } = trpc.workspace.listMembersInWorkspace.useQuery(
|
const { data: membersData } = trpc.workspace.listMembersInWorkspace.useQuery(
|
||||||
{ workspaceId: workspaceId! },
|
{ workspaceId: workspaceId as string },
|
||||||
{ enabled: !!workspaceId }
|
{ enabled: !!workspaceId }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from '@/components/icons'
|
} from '@/components/icons'
|
||||||
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
||||||
import { GraphNavigation, User, Workspace, WorkspaceRole } from 'db'
|
import { User, Workspace, WorkspaceRole } from 'db'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { MembersList } from './MembersList'
|
import { MembersList } from './MembersList'
|
||||||
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
|
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
|
||||||
@@ -149,13 +149,7 @@ export const WorkspaceSettingsModal = ({
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<Flex flex="1" p="10">
|
<Flex flex="1" p="10">
|
||||||
<SettingsContent
|
<SettingsContent tab={selectedTab} onClose={onClose} />
|
||||||
tab={selectedTab}
|
|
||||||
onClose={onClose}
|
|
||||||
defaultGraphNavigation={
|
|
||||||
user.graphNavigation ?? GraphNavigation.TRACKPAD
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
@@ -165,11 +159,9 @@ export const WorkspaceSettingsModal = ({
|
|||||||
|
|
||||||
const SettingsContent = ({
|
const SettingsContent = ({
|
||||||
tab,
|
tab,
|
||||||
defaultGraphNavigation,
|
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
tab: SettingsTab
|
tab: SettingsTab
|
||||||
defaultGraphNavigation: GraphNavigation
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => {
|
}) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Sentry from '@sentry/nextjs'
|
import * as Sentry from '@sentry/nextjs'
|
||||||
|
import { NextPageContext } from 'next'
|
||||||
import NextErrorComponent from 'next/error'
|
import NextErrorComponent from 'next/error'
|
||||||
|
|
||||||
const CustomErrorComponent = (props: {
|
const CustomErrorComponent = (props: {
|
||||||
@@ -31,7 +32,7 @@ const CustomErrorComponent = (props: {
|
|||||||
return <NextErrorComponent statusCode={props.statusCode} />
|
return <NextErrorComponent statusCode={props.statusCode} />
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomErrorComponent.getInitialProps = async (contextData: any) => {
|
CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => {
|
||||||
// In case this is running in a serverless function, await this in order to give Sentry
|
// In case this is running in a serverless function, await this in order to give Sentry
|
||||||
// time to send the error before the lambda exits
|
// time to send the error before the lambda exits
|
||||||
await Sentry.captureUnderscoreErrorException(contextData)
|
await Sentry.captureUnderscoreErrorException(contextData)
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const domain = req.query.domain as string
|
const domain = req.query.domain as string
|
||||||
try {
|
try {
|
||||||
await deleteDomainOnVercel(domain)
|
await deleteDomainOnVercel(domain)
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
const customDomains = await prisma.customDomain.delete({
|
const customDomains = await prisma.customDomain.delete({
|
||||||
where: { name: domain },
|
where: { name: domain },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { captureException, withSentry } from '@sentry/nextjs'
|
import { captureException } from '@sentry/nextjs'
|
||||||
import { SmtpCredentialsData } from 'models'
|
import { SmtpCredentialsData } from 'models'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { createTransport } from 'nodemailer'
|
import { createTransport } from 'nodemailer'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
import { drive } from '@googleapis/drive'
|
import { drive } from '@googleapis/drive'
|
||||||
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
|
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
|
||||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
|
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
|
||||||
import { setUser, withSentry } from '@sentry/nextjs'
|
import { setUser } from '@sentry/nextjs'
|
||||||
import { getAuthenticatedUser } from '@/features/auth/api'
|
import { getAuthenticatedUser } from '@/features/auth/api'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GoogleSpreadsheet } from 'google-spreadsheet'
|
|||||||
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
|
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
|
||||||
import { isDefined } from 'utils'
|
import { isDefined } from 'utils'
|
||||||
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
|
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
|
||||||
import { withSentry, setUser } from '@sentry/nextjs'
|
import { setUser } from '@sentry/nextjs'
|
||||||
import { getAuthenticatedUser } from '@/features/auth/api'
|
import { getAuthenticatedUser } from '@/features/auth/api'
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Cors from 'micro-cors'
|
|||||||
import { buffer } from 'micro'
|
import { buffer } from 'micro'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { Plan } from 'db'
|
import { Plan } from 'db'
|
||||||
|
import { RequestHandler } from 'next/dist/server/next'
|
||||||
|
|
||||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
|
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
|
||||||
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
|
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
|
||||||
@@ -127,4 +128,4 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
return methodNotAllowed(res)
|
return methodNotAllowed(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default cors(webhookHandler as any)
|
export default cors(webhookHandler as RequestHandler)
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/typebots/{typebotId}/sendMessage": {
|
"/sendMessage": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "query.chat.sendMessage",
|
"operationId": "query.chat.sendMessage",
|
||||||
"summary": "Send a message",
|
"summary": "Send a message",
|
||||||
"description": "To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
|
"description": "To initiate a chat, do not provide a `sessionId` nor a `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
@@ -24,85 +24,104 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The answer to the previous question"
|
"description": "The answer to the previous chat input. Do not provide it if you are starting a new chat."
|
||||||
},
|
},
|
||||||
"sessionId": {
|
"sessionId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Session ID that you get from the initial chat request to a bot"
|
"description": "Session ID that you get from the initial chat request to a bot. If not provided, it will create a new session."
|
||||||
|
},
|
||||||
|
"startParams": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"typebotId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)"
|
||||||
},
|
},
|
||||||
"isPreview": {
|
"isPreview": {
|
||||||
"type": "boolean"
|
"type": "boolean",
|
||||||
|
"description": "If set to `true`, it will start a Preview session with the unpublished bot and it won't be saved in the Results tab."
|
||||||
|
},
|
||||||
|
"resultId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Provide it if you'd like to overwrite an existing result."
|
||||||
|
},
|
||||||
|
"prefilledVariables": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"message"
|
"typebotId"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"parameters": [
|
"parameters": [],
|
||||||
{
|
|
||||||
"name": "typebotId",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": "[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Successful response",
|
"description": "Successful response",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"messages": {
|
"messages": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"text",
|
"text"
|
||||||
"image",
|
|
||||||
"video",
|
|
||||||
"embed",
|
|
||||||
"audio"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"plainText": {
|
"html": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"html": {
|
"plainText": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"plainText",
|
"html",
|
||||||
"html"
|
"plainText"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"content"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"image"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {
|
"url": {
|
||||||
@@ -111,9 +130,25 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"content"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {
|
"url": {
|
||||||
@@ -133,9 +168,52 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"content"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"audio"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"content"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"embed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"url": {
|
"url": {
|
||||||
@@ -150,19 +228,6 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
@@ -170,8 +235,12 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -457,9 +526,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"retryMessageContent"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1191,12 +1257,55 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prefilledValue": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"runtimeOptions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"paymentIntentSecret": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amountLabel": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicKey": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"paymentIntentSecret",
|
||||||
|
"amountLabel",
|
||||||
|
"publicKey"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"logic": {
|
"logic": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"redirectUrl": {
|
"redirect": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"isNewTab": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"isNewTab"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"codeToExecute": {
|
"codeToExecute": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1341,33 +1450,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"messages"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"sessionId": {
|
"sessionId": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"not": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
|
||||||
],
|
|
||||||
"nullable": true
|
|
||||||
},
|
},
|
||||||
"typebot": {
|
"typebot": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"not": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -1605,6 +1692,9 @@
|
|||||||
},
|
},
|
||||||
"customHeadCode": {
|
"customHeadCode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"googleTagManagerId": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1623,15 +1713,16 @@
|
|||||||
"settings"
|
"settings"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
},
|
||||||
],
|
"resultId": {
|
||||||
"nullable": true
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ The Builder API is about what you can edit on https://app.typebot.io (i.e. creat
|
|||||||
## Chat
|
## Chat
|
||||||
|
|
||||||
:::caution
|
:::caution
|
||||||
You should not use it in production. This API is experimental at the moment and will be heavily modified with time.
|
You should not use it in production. This API is experimental at the moment and will be changed without notice.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
The Chat API allows you to execute (chat) with a typebot.
|
The Chat API allows you to execute (chat) with a typebot.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/nextjs": "7.27.0",
|
"@sentry/nextjs": "7.27.0",
|
||||||
"@trpc/server": "10.5.0",
|
"@trpc/server": "10.5.0",
|
||||||
|
"@typebot.io/react": "workspace:*",
|
||||||
"aws-sdk": "2.1277.0",
|
"aws-sdk": "2.1277.0",
|
||||||
"bot-engine": "workspace:*",
|
"bot-engine": "workspace:*",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"next": "13.0.7",
|
"next": "13.0.7",
|
||||||
"nextjs-cors": "^2.1.2",
|
"nextjs-cors": "^2.1.2",
|
||||||
"nodemailer": "6.8.0",
|
"nodemailer": "6.8.0",
|
||||||
|
"phone": "^3.1.31",
|
||||||
"qs": "6.11.0",
|
"qs": "6.11.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@@ -42,8 +44,8 @@
|
|||||||
"@types/qs": "6.9.7",
|
"@types/qs": "6.9.7",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/sanitize-html": "2.8.0",
|
"@types/sanitize-html": "2.8.0",
|
||||||
"dotenv": "16.0.3",
|
|
||||||
"cuid": "2.1.8",
|
"cuid": "2.1.8",
|
||||||
|
"dotenv": "16.0.3",
|
||||||
"emails": "workspace:*",
|
"emails": "workspace:*",
|
||||||
"eslint": "8.30.0",
|
"eslint": "8.30.0",
|
||||||
"eslint-config-custom": "workspace:*",
|
"eslint-config-custom": "workspace:*",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { ErrorPage } from './ErrorPage'
|
|||||||
import { createResultQuery, updateResultQuery } from '@/features/results'
|
import { createResultQuery, updateResultQuery } from '@/features/results'
|
||||||
import { upsertAnswerQuery } from '@/features/answers'
|
import { upsertAnswerQuery } from '@/features/answers'
|
||||||
import { gtmBodyElement } from '@/lib/google-tag-manager'
|
import { gtmBodyElement } from '@/lib/google-tag-manager'
|
||||||
|
import {
|
||||||
|
getExistingResultFromSession,
|
||||||
|
setResultInSession,
|
||||||
|
} from '@/utils/sessionStorage'
|
||||||
|
|
||||||
export type TypebotPageProps = {
|
export type TypebotPageProps = {
|
||||||
publishedTypebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> & {
|
publishedTypebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> & {
|
||||||
@@ -18,8 +22,6 @@ export type TypebotPageProps = {
|
|||||||
customHeadCode: string | null
|
customHeadCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionStorageKey = 'resultId'
|
|
||||||
|
|
||||||
export const TypebotPage = ({
|
export const TypebotPage = ({
|
||||||
publishedTypebot,
|
publishedTypebot,
|
||||||
isIE,
|
isIE,
|
||||||
@@ -153,15 +155,3 @@ export const TypebotPage = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getExistingResultFromSession = () => {
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem(sessionStorageKey)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setResultInSession = (resultId: string) => {
|
|
||||||
try {
|
|
||||||
return sessionStorage.setItem(sessionStorageKey, resultId)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|||||||
110
apps/viewer/src/components/TypebotPageV2.tsx
Normal file
110
apps/viewer/src/components/TypebotPageV2.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
|
||||||
|
import {
|
||||||
|
getExistingResultFromSession,
|
||||||
|
setResultInSession,
|
||||||
|
} from '@/utils/sessionStorage'
|
||||||
|
import Bot from '@typebot.io/react'
|
||||||
|
import { BackgroundType, InitialChatReply, Typebot } from 'models'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ErrorPage } from './ErrorPage'
|
||||||
|
import { SEO } from './Seo'
|
||||||
|
|
||||||
|
export type TypebotPageV2Props = {
|
||||||
|
url: string
|
||||||
|
typebot: Pick<
|
||||||
|
Typebot,
|
||||||
|
'settings' | 'theme' | 'id' | 'name' | 'isClosed' | 'isArchived'
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasInitializedChat = false
|
||||||
|
|
||||||
|
export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
|
||||||
|
const { asPath, push } = useRouter()
|
||||||
|
const [initialChatReply, setInitialChatReply] = useState<InitialChatReply>()
|
||||||
|
const [error, setError] = useState<Error | undefined>(undefined)
|
||||||
|
|
||||||
|
const background = typebot.theme.general.background
|
||||||
|
|
||||||
|
const clearQueryParamsIfNecessary = useCallback(() => {
|
||||||
|
const hasQueryParams = asPath.includes('?')
|
||||||
|
if (
|
||||||
|
!hasQueryParams ||
|
||||||
|
!(typebot.settings.general.isHideQueryParamsEnabled ?? true)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
push(asPath.split('?')[0], undefined, { shallow: true })
|
||||||
|
}, [asPath, push, typebot.settings.general.isHideQueryParamsEnabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearQueryParamsIfNecessary()
|
||||||
|
}, [clearQueryParamsIfNecessary])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInitializedChat) return
|
||||||
|
hasInitializedChat = true
|
||||||
|
const prefilledVariables = extractPrefilledVariables()
|
||||||
|
const existingResultId = getExistingResultFromSession() ?? undefined
|
||||||
|
|
||||||
|
getInitialChatReplyQuery({
|
||||||
|
typebotId: typebot.id,
|
||||||
|
resultId:
|
||||||
|
typebot.settings.general.isNewResultOnRefreshEnabled ?? false
|
||||||
|
? undefined
|
||||||
|
: existingResultId,
|
||||||
|
prefilledVariables,
|
||||||
|
}).then(({ data, error }) => {
|
||||||
|
if (error && 'code' in error && error.code === 'FORBIDDEN') {
|
||||||
|
setError(new Error('This bot is now closed.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!data) return setError(new Error("Couldn't initiate the chat"))
|
||||||
|
setInitialChatReply(data)
|
||||||
|
setResultInSession(data.resultId)
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
initialChatReply,
|
||||||
|
typebot.id,
|
||||||
|
typebot.settings.general.isNewResultOnRefreshEnabled,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorPage error={error} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
// Set background color to avoid SSR flash
|
||||||
|
backgroundColor:
|
||||||
|
background.type === BackgroundType.COLOR
|
||||||
|
? background.content
|
||||||
|
: 'white',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SEO
|
||||||
|
url={url}
|
||||||
|
typebotName={typebot.name}
|
||||||
|
metadata={typebot.settings.metadata}
|
||||||
|
/>
|
||||||
|
{initialChatReply && (
|
||||||
|
<Bot.Standard
|
||||||
|
typebotId={typebot.id}
|
||||||
|
initialChatReply={initialChatReply}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractPrefilledVariables = () => {
|
||||||
|
const urlParams = new URLSearchParams(location.search)
|
||||||
|
|
||||||
|
const prefilledVariables: { [key: string]: string } = {}
|
||||||
|
urlParams.forEach((value, key) => {
|
||||||
|
prefilledVariables[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
return prefilledVariables
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const emailRegex =
|
const emailRegex =
|
||||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
|
|
||||||
export const validateEmail = (email: string) => emailRegex.test(email)
|
export const validateEmail = (email: string) => emailRegex.test(email)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { parse } from 'papaparse'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { isDefined } from 'utils'
|
||||||
|
import {
|
||||||
|
createWorkspaces,
|
||||||
|
importTypebotInDatabase,
|
||||||
|
injectFakeResults,
|
||||||
|
} from 'utils/playwright/databaseActions'
|
||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
import { Plan } from 'db'
|
||||||
|
|
||||||
|
const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
test('should work as expected', async ({ page, browser }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
})
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page
|
||||||
|
.locator(`input[type="file"]`)
|
||||||
|
.setInputFiles([
|
||||||
|
getTestAsset('typebots/api.json'),
|
||||||
|
getTestAsset('typebots/fileUpload.json'),
|
||||||
|
getTestAsset('typebots/hugeGroup.json'),
|
||||||
|
])
|
||||||
|
await expect(page.locator(`text="3"`)).toBeVisible()
|
||||||
|
await page.locator('text="Upload 3 files"').click()
|
||||||
|
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.getByRole('link', { name: 'api.json' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
/.+\/api\.json/
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'fileUpload.json' })
|
||||||
|
).toHaveAttribute('href', /.+\/fileUpload\.json/)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: 'hugeGroup.json' })
|
||||||
|
).toHaveAttribute('href', /.+\/hugeGroup\.json/)
|
||||||
|
|
||||||
|
await page.click('[data-testid="checkbox"] >> nth=0')
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.locator('text="Export"').click(),
|
||||||
|
])
|
||||||
|
const downloadPath = await download.path()
|
||||||
|
expect(downloadPath).toBeDefined()
|
||||||
|
const file = readFileSync(downloadPath as string).toString()
|
||||||
|
const { data } = parse(file)
|
||||||
|
expect(data).toHaveLength(2)
|
||||||
|
expect((data[1] as unknown[])[1]).toContain(process.env.S3_ENDPOINT)
|
||||||
|
|
||||||
|
const urls = (
|
||||||
|
await Promise.all(
|
||||||
|
[
|
||||||
|
page.getByRole('link', { name: 'api.json' }),
|
||||||
|
page.getByRole('link', { name: 'fileUpload.json' }),
|
||||||
|
page.getByRole('link', { name: 'hugeGroup.json' }),
|
||||||
|
].map((elem) => elem.getAttribute('href'))
|
||||||
|
)
|
||||||
|
).filter(isDefined)
|
||||||
|
|
||||||
|
const page2 = await browser.newPage()
|
||||||
|
await page2.goto(urls[0])
|
||||||
|
await expect(page2.locator('pre')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('button >> text="Delete"').click()
|
||||||
|
await page.locator('button >> text="Delete" >> nth=1').click()
|
||||||
|
await expect(page.locator('text="api.json"')).toBeHidden()
|
||||||
|
await page2.goto(urls[0])
|
||||||
|
await expect(page2.locator('pre')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Storage limit is reached', () => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
const workspaceId = cuid()
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await createWorkspaces([{ id: workspaceId, plan: Plan.STARTER }])
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
await injectFakeResults({
|
||||||
|
typebotId,
|
||||||
|
count: 20,
|
||||||
|
fakeStorage: THREE_GIGABYTES,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shouldn't upload anything if limit has been reached", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page
|
||||||
|
.locator(`input[type="file"]`)
|
||||||
|
.setInputFiles([
|
||||||
|
getTestAsset('typebots/api.json'),
|
||||||
|
getTestAsset('typebots/fileUpload.json'),
|
||||||
|
getTestAsset('typebots/hugeGroup.json'),
|
||||||
|
])
|
||||||
|
await expect(page.locator(`text="3"`)).toBeVisible()
|
||||||
|
await page.locator('text="Upload 3 files"').click()
|
||||||
|
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.localStorage.setItem('workspaceId', 'starterWorkspace')
|
||||||
|
)
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text="150%"')).toBeVisible()
|
||||||
|
await expect(page.locator('text="api.json"')).toBeHidden()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './utils'
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { parseVariables } from '@/features/variables'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import {
|
||||||
|
PaymentInputOptions,
|
||||||
|
PaymentInputRuntimeOptions,
|
||||||
|
SessionState,
|
||||||
|
StripeCredentialsData,
|
||||||
|
} from 'models'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { decrypt } from 'utils/api/encryption'
|
||||||
|
|
||||||
|
export const computePaymentInputRuntimeOptions =
|
||||||
|
(state: SessionState) => (options: PaymentInputOptions) =>
|
||||||
|
createStripePaymentIntent(state)(options)
|
||||||
|
|
||||||
|
const createStripePaymentIntent =
|
||||||
|
(state: SessionState) =>
|
||||||
|
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
|
||||||
|
const {
|
||||||
|
isPreview,
|
||||||
|
typebot: { variables },
|
||||||
|
} = state
|
||||||
|
if (!options.credentialsId)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Missing credentialsId',
|
||||||
|
})
|
||||||
|
const stripeKeys = await getStripeInfo(options.credentialsId)
|
||||||
|
if (!stripeKeys)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: 'Credentials not found',
|
||||||
|
})
|
||||||
|
const stripe = new Stripe(
|
||||||
|
isPreview && stripeKeys?.test?.secretKey
|
||||||
|
? stripeKeys.test.secretKey
|
||||||
|
: stripeKeys.live.secretKey,
|
||||||
|
{ apiVersion: '2022-11-15' }
|
||||||
|
)
|
||||||
|
const amount =
|
||||||
|
Number(parseVariables(variables)(options.amount)) *
|
||||||
|
(isZeroDecimalCurrency(options.currency) ? 1 : 100)
|
||||||
|
if (isNaN(amount))
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'Could not parse amount, make sure your block is configured correctly',
|
||||||
|
})
|
||||||
|
// Create a PaymentIntent with the order amount and currency
|
||||||
|
const receiptEmail = parseVariables(variables)(
|
||||||
|
options.additionalInformation?.email
|
||||||
|
)
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency: options.currency,
|
||||||
|
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
|
||||||
|
automatic_payment_methods: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!paymentIntent.client_secret)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Could not create payment intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentIntentSecret: paymentIntent.client_secret,
|
||||||
|
publicKey:
|
||||||
|
isPreview && stripeKeys.test?.publicKey
|
||||||
|
? stripeKeys.test.publicKey
|
||||||
|
: stripeKeys.live.publicKey,
|
||||||
|
amountLabel: `${
|
||||||
|
amount / (isZeroDecimalCurrency(options.currency) ? 1 : 100)
|
||||||
|
}${currencySymbols[options.currency] ?? ` ${options.currency}`}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStripeInfo = async (
|
||||||
|
credentialsId: string
|
||||||
|
): Promise<StripeCredentialsData | undefined> => {
|
||||||
|
const credentials = await prisma.credentials.findUnique({
|
||||||
|
where: { id: credentialsId },
|
||||||
|
})
|
||||||
|
if (!credentials) return
|
||||||
|
return decrypt(credentials.data, credentials.iv) as StripeCredentialsData
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stripe.com/docs/currencies#zero-decimal
|
||||||
|
const isZeroDecimalCurrency = (currency: string) =>
|
||||||
|
[
|
||||||
|
'BIF',
|
||||||
|
'CLP',
|
||||||
|
'DJF',
|
||||||
|
'GNF',
|
||||||
|
'JPY',
|
||||||
|
'KMF',
|
||||||
|
'KRW',
|
||||||
|
'MGA',
|
||||||
|
'PYG',
|
||||||
|
'RWF',
|
||||||
|
'UGX',
|
||||||
|
'VND',
|
||||||
|
'VUV',
|
||||||
|
'XAF',
|
||||||
|
'XOF',
|
||||||
|
'XPF',
|
||||||
|
].includes(currency)
|
||||||
|
|
||||||
|
const currencySymbols: { [key: string]: string } = {
|
||||||
|
USD: '$',
|
||||||
|
EUR: '€',
|
||||||
|
CRC: '₡',
|
||||||
|
GBP: '£',
|
||||||
|
ILS: '₪',
|
||||||
|
INR: '₹',
|
||||||
|
JPY: '¥',
|
||||||
|
KRW: '₩',
|
||||||
|
NGN: '₦',
|
||||||
|
PHP: '₱',
|
||||||
|
PLN: 'zł',
|
||||||
|
PYG: '₲',
|
||||||
|
THB: '฿',
|
||||||
|
UAH: '₴',
|
||||||
|
VND: '₫',
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './computePaymentInputRuntimeOptions'
|
||||||
@@ -1 +1 @@
|
|||||||
export { validatePhoneNumber } from './utils/validatePhoneNumber'
|
export * from './utils'
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import phone from 'phone'
|
||||||
|
|
||||||
|
export const formatPhoneNumber = (phoneNumber: string) =>
|
||||||
|
phone(phoneNumber).phoneNumber
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './formatPhoneNumber'
|
||||||
|
export * from './validatePhoneNumber'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const phoneRegex = /^\+?[0-9]{6,}$/
|
import { phone } from 'phone'
|
||||||
|
|
||||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
export const validatePhoneNumber = (phoneNumber: string) =>
|
||||||
phoneRegex.test(phoneNumber)
|
phone(phoneNumber).isValid
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { createTypebots } from 'utils/playwright/databaseActions'
|
||||||
|
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||||
|
import { defaultChatwootOptions, IntegrationBlockType } from 'models'
|
||||||
|
|
||||||
|
const typebotId = cuid()
|
||||||
|
|
||||||
|
const chatwootTestWebsiteToken = 'tueXiiqEmrWUCZ4NUyoR7nhE'
|
||||||
|
|
||||||
|
test('should work as expected', async ({ page }) => {
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultGroupWithBlock(
|
||||||
|
{
|
||||||
|
type: IntegrationBlockType.CHATWOOT,
|
||||||
|
options: {
|
||||||
|
...defaultChatwootOptions,
|
||||||
|
websiteToken: chatwootTestWebsiteToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ withGoButton: true }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page.getByRole('button', { name: 'Go' }).click()
|
||||||
|
await expect(page.locator('#chatwoot_live_chat_widget')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { SessionState, GoogleSheetsGetOptions, VariableWithValue } from 'models'
|
import {
|
||||||
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
|
SessionState,
|
||||||
|
GoogleSheetsGetOptions,
|
||||||
|
VariableWithValue,
|
||||||
|
ComparisonOperators,
|
||||||
|
LogicalOperator,
|
||||||
|
} from 'models'
|
||||||
|
import { saveErrorLog } from '@/features/logs/api'
|
||||||
import { getAuthenticatedGoogleDoc } from './helpers'
|
import { getAuthenticatedGoogleDoc } from './helpers'
|
||||||
import { parseVariables, updateVariables } from '@/features/variables'
|
import { updateVariables } from '@/features/variables'
|
||||||
import { isNotEmpty, byId } from 'utils'
|
import { isNotEmpty, byId, isDefined } from 'utils'
|
||||||
import { ExecuteIntegrationResponse } from '@/features/chat'
|
import { ExecuteIntegrationResponse } from '@/features/chat'
|
||||||
|
import type { GoogleSpreadsheetRow } from 'google-spreadsheet'
|
||||||
|
|
||||||
export const getRow = async (
|
export const getRow = async (
|
||||||
state: SessionState,
|
state: SessionState,
|
||||||
@@ -12,56 +19,51 @@ export const getRow = async (
|
|||||||
options,
|
options,
|
||||||
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
|
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
|
||||||
): Promise<ExecuteIntegrationResponse> => {
|
): Promise<ExecuteIntegrationResponse> => {
|
||||||
const { sheetId, cellsToExtract, referenceCell } = options
|
const { sheetId, cellsToExtract, referenceCell, filter } = options
|
||||||
if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId }
|
if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId }
|
||||||
|
|
||||||
const variables = state.typebot.variables
|
const variables = state.typebot.variables
|
||||||
const resultId = state.result.id
|
const resultId = state.result?.id
|
||||||
|
|
||||||
const doc = await getAuthenticatedGoogleDoc({
|
const doc = await getAuthenticatedGoogleDoc({
|
||||||
credentialsId: options.credentialsId,
|
credentialsId: options.credentialsId,
|
||||||
spreadsheetId: options.spreadsheetId,
|
spreadsheetId: options.spreadsheetId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsedReferenceCell = {
|
|
||||||
column: referenceCell.column,
|
|
||||||
value: parseVariables(variables)(referenceCell.value),
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractingColumns = cellsToExtract
|
|
||||||
.map((cell) => cell.column)
|
|
||||||
.filter(isNotEmpty)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await doc.loadInfo()
|
await doc.loadInfo()
|
||||||
const sheet = doc.sheetsById[sheetId]
|
const sheet = doc.sheetsById[sheetId]
|
||||||
const rows = await sheet.getRows()
|
const rows = await sheet.getRows()
|
||||||
const row = rows.find(
|
const filteredRows = rows.filter((row) =>
|
||||||
(row) =>
|
referenceCell
|
||||||
row[parsedReferenceCell.column as string] === parsedReferenceCell.value
|
? row[referenceCell.column as string] === referenceCell.value
|
||||||
|
: matchFilter(row, filter)
|
||||||
)
|
)
|
||||||
if (!row) {
|
if (filteredRows.length === 0) {
|
||||||
await saveErrorLog({
|
await saveErrorLog({
|
||||||
resultId,
|
resultId,
|
||||||
message: "Couldn't find reference cell",
|
message: "Couldn't find reference cell",
|
||||||
})
|
})
|
||||||
return { outgoingEdgeId }
|
return { outgoingEdgeId }
|
||||||
}
|
}
|
||||||
const data: { [key: string]: string } = {
|
const randomIndex = Math.floor(Math.random() * filteredRows.length)
|
||||||
...extractingColumns.reduce(
|
const extractingColumns = cellsToExtract
|
||||||
|
.map((cell) => cell.column)
|
||||||
|
.filter(isNotEmpty)
|
||||||
|
const selectedRow = filteredRows
|
||||||
|
.map((row) =>
|
||||||
|
extractingColumns.reduce<{ [key: string]: string }>(
|
||||||
(obj, column) => ({ ...obj, [column]: row[column] }),
|
(obj, column) => ({ ...obj, [column]: row[column] }),
|
||||||
{}
|
{}
|
||||||
),
|
)
|
||||||
}
|
)
|
||||||
await saveSuccessLog({
|
.at(randomIndex)
|
||||||
resultId,
|
if (!selectedRow) return { outgoingEdgeId }
|
||||||
message: 'Succesfully fetched spreadsheet data',
|
|
||||||
})
|
|
||||||
|
|
||||||
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
|
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
|
||||||
(newVariables, cell) => {
|
(newVariables, cell) => {
|
||||||
const existingVariable = variables.find(byId(cell.variableId))
|
const existingVariable = variables.find(byId(cell.variableId))
|
||||||
const value = data[cell.column ?? ''] ?? null
|
const value = selectedRow[cell.column ?? ''] ?? null
|
||||||
if (!existingVariable) return newVariables
|
if (!existingVariable) return newVariables
|
||||||
return [
|
return [
|
||||||
...newVariables,
|
...newVariables,
|
||||||
@@ -87,3 +89,56 @@ export const getRow = async (
|
|||||||
}
|
}
|
||||||
return { outgoingEdgeId }
|
return { outgoingEdgeId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchFilter = (
|
||||||
|
row: GoogleSpreadsheetRow,
|
||||||
|
filter: GoogleSheetsGetOptions['filter']
|
||||||
|
) => {
|
||||||
|
return filter.logicalOperator === LogicalOperator.AND
|
||||||
|
? filter.comparisons.every(
|
||||||
|
(comparison) =>
|
||||||
|
comparison.column &&
|
||||||
|
matchComparison(
|
||||||
|
row[comparison.column],
|
||||||
|
comparison.comparisonOperator,
|
||||||
|
comparison.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: filter.comparisons.some(
|
||||||
|
(comparison) =>
|
||||||
|
comparison.column &&
|
||||||
|
matchComparison(
|
||||||
|
row[comparison.column],
|
||||||
|
comparison.comparisonOperator,
|
||||||
|
comparison.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchComparison = (
|
||||||
|
inputValue?: string,
|
||||||
|
comparisonOperator?: ComparisonOperators,
|
||||||
|
value?: string
|
||||||
|
) => {
|
||||||
|
if (!inputValue || !comparisonOperator || !value) return false
|
||||||
|
switch (comparisonOperator) {
|
||||||
|
case ComparisonOperators.CONTAINS: {
|
||||||
|
return inputValue.toLowerCase().includes(value.toLowerCase())
|
||||||
|
}
|
||||||
|
case ComparisonOperators.EQUAL: {
|
||||||
|
return inputValue === value
|
||||||
|
}
|
||||||
|
case ComparisonOperators.NOT_EQUAL: {
|
||||||
|
return inputValue !== value
|
||||||
|
}
|
||||||
|
case ComparisonOperators.GREATER: {
|
||||||
|
return parseFloat(inputValue) > parseFloat(value)
|
||||||
|
}
|
||||||
|
case ComparisonOperators.LESS: {
|
||||||
|
return parseFloat(inputValue) < parseFloat(value)
|
||||||
|
}
|
||||||
|
case ComparisonOperators.IS_SET: {
|
||||||
|
return isDefined(inputValue) && inputValue.length > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ export const insertRow = async (
|
|||||||
await doc.loadInfo()
|
await doc.loadInfo()
|
||||||
const sheet = doc.sheetsById[options.sheetId]
|
const sheet = doc.sheetsById[options.sheetId]
|
||||||
await sheet.addRow(parsedValues)
|
await sheet.addRow(parsedValues)
|
||||||
await saveSuccessLog({
|
result &&
|
||||||
|
(await saveSuccessLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: 'Succesfully inserted row',
|
message: 'Succesfully inserted row',
|
||||||
})
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await saveErrorLog({
|
result &&
|
||||||
|
(await saveErrorLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: "Couldn't fetch spreadsheet data",
|
message: "Couldn't fetch spreadsheet data",
|
||||||
details: err,
|
details: err,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
return { outgoingEdgeId }
|
return { outgoingEdgeId }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,18 @@ export const updateRow = async (
|
|||||||
rows[updatingRowIndex][key] = parsedValues[key]
|
rows[updatingRowIndex][key] = parsedValues[key]
|
||||||
}
|
}
|
||||||
await rows[updatingRowIndex].save()
|
await rows[updatingRowIndex].save()
|
||||||
await saveSuccessLog({
|
result &&
|
||||||
|
(await saveSuccessLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: 'Succesfully updated row',
|
message: 'Succesfully updated row',
|
||||||
})
|
}))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await saveErrorLog({
|
result &&
|
||||||
|
(await saveErrorLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: "Couldn't fetch spreadsheet data",
|
message: "Couldn't fetch spreadsheet data",
|
||||||
details: err,
|
details: err,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
return { outgoingEdgeId }
|
return { outgoingEdgeId }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const executeSendEmailBlock = async (
|
|||||||
const { variables } = typebot
|
const { variables } = typebot
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
typebotId: typebot.id,
|
typebotId: typebot.id,
|
||||||
resultId: result.id,
|
resultId: result?.id,
|
||||||
credentialsId: options.credentialsId,
|
credentialsId: options.credentialsId,
|
||||||
recipients: options.recipients.map(parseVariables(variables)),
|
recipients: options.recipients.map(parseVariables(variables)),
|
||||||
subject: parseVariables(variables)(options.subject ?? ''),
|
subject: parseVariables(variables)(options.subject ?? ''),
|
||||||
@@ -59,7 +59,7 @@ const sendEmail = async ({
|
|||||||
fileUrls,
|
fileUrls,
|
||||||
}: SendEmailOptions & {
|
}: SendEmailOptions & {
|
||||||
typebotId: string
|
typebotId: string
|
||||||
resultId: string
|
resultId?: string
|
||||||
fileUrls?: string
|
fileUrls?: string
|
||||||
}) => {
|
}) => {
|
||||||
const { name: replyToName } = parseEmailRecipient(replyTo)
|
const { name: replyToName } = parseEmailRecipient(replyTo)
|
||||||
@@ -114,7 +114,7 @@ const sendEmail = async ({
|
|||||||
...emailBody,
|
...emailBody,
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const info = await transporter.sendMail(email)
|
await transporter.sendMail(email)
|
||||||
await saveSuccessLog({
|
await saveSuccessLog({
|
||||||
resultId,
|
resultId,
|
||||||
message: 'Email successfully sent',
|
message: 'Email successfully sent',
|
||||||
@@ -169,7 +169,7 @@ const getEmailBody = async ({
|
|||||||
resultId,
|
resultId,
|
||||||
}: {
|
}: {
|
||||||
typebotId: string
|
typebotId: string
|
||||||
resultId: string
|
resultId?: string
|
||||||
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
|
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
|
||||||
{ html?: string; text?: string } | undefined
|
{ html?: string; text?: string } | undefined
|
||||||
> => {
|
> => {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { SmtpCredentialsData } from 'models'
|
||||||
|
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
|
||||||
|
const mockSmtpCredentials: SmtpCredentialsData = {
|
||||||
|
from: {
|
||||||
|
email: 'marley.cummings@ethereal.email',
|
||||||
|
name: 'Marley Cummings',
|
||||||
|
},
|
||||||
|
host: 'smtp.ethereal.email',
|
||||||
|
port: 587,
|
||||||
|
username: 'marley.cummings@ethereal.email',
|
||||||
|
password: 'E5W1jHbAmv5cXXcut2',
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
const credentialsId = 'send-email-credentials'
|
||||||
|
await createSmtpCredentials(credentialsId, mockSmtpCredentials)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should send an email', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
})
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page.locator('text=Send email').click()
|
||||||
|
await expect(page.getByText('Email sent!')).toBeVisible()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await page.click('text="See logs"')
|
||||||
|
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -35,37 +35,46 @@ export const executeWebhookBlock = async (
|
|||||||
where: { id: block.webhookId },
|
where: { id: block.webhookId },
|
||||||
})) as Webhook | null
|
})) as Webhook | null
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
await saveErrorLog({
|
result &&
|
||||||
|
(await saveErrorLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: `Couldn't find webhook`,
|
message: `Couldn't find webhook`,
|
||||||
})
|
}))
|
||||||
return { outgoingEdgeId: block.outgoingEdgeId }
|
return { outgoingEdgeId: block.outgoingEdgeId }
|
||||||
}
|
}
|
||||||
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
|
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
|
||||||
const resultValues = await getResultValues(result.id)
|
const resultValues = result && (await getResultValues(result.id))
|
||||||
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId }
|
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId }
|
||||||
const webhookResponse = await executeWebhook({ typebot })(
|
const webhookResponse = await executeWebhook({ typebot })(
|
||||||
preparedWebhook,
|
preparedWebhook,
|
||||||
typebot.variables,
|
typebot.variables,
|
||||||
block.groupId,
|
block.groupId,
|
||||||
resultValues,
|
resultValues,
|
||||||
result.id
|
result?.id
|
||||||
)
|
)
|
||||||
const status = webhookResponse.statusCode.toString()
|
const status = webhookResponse.statusCode.toString()
|
||||||
const isError = status.startsWith('4') || status.startsWith('5')
|
const isError = status.startsWith('4') || status.startsWith('5')
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
await saveErrorLog({
|
result &&
|
||||||
|
(await saveErrorLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: `Webhook returned error: ${webhookResponse.data}`,
|
message: `Webhook returned error: ${webhookResponse.data}`,
|
||||||
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
|
details: JSON.stringify(webhookResponse.data, null, 2).substring(
|
||||||
})
|
0,
|
||||||
|
1000
|
||||||
|
),
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
await saveSuccessLog({
|
result &&
|
||||||
|
(await saveSuccessLog({
|
||||||
resultId: result.id,
|
resultId: result.id,
|
||||||
message: `Webhook returned success: ${webhookResponse.data}`,
|
message: `Webhook returned success: ${webhookResponse.data}`,
|
||||||
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
|
details: JSON.stringify(webhookResponse.data, null, 2).substring(
|
||||||
})
|
0,
|
||||||
|
1000
|
||||||
|
),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const newVariables = block.options.responseVariableMapping.reduce<
|
const newVariables = block.options.responseVariableMapping.reduce<
|
||||||
@@ -265,6 +274,7 @@ const convertKeyValueTableToObject = (
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const safeJsonParse = (json: string): { data: any; isJson: boolean } => {
|
const safeJsonParse = (json: string): { data: any; isJson: boolean } => {
|
||||||
try {
|
try {
|
||||||
return { data: JSON.parse(json), isJson: true }
|
return { data: JSON.parse(json), isJson: true }
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test.describe('Bot', () => {
|
|||||||
publicId: `${typebotId}-public`,
|
publicId: `${typebotId}-public`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
await createWebhook(typebotId, {
|
await createWebhook(typebotId, {
|
||||||
id: 'failing-webhook',
|
id: 'failing-webhook',
|
||||||
url: 'http://localhost:3001/api/mock/fail',
|
url: 'http://localhost:3001/api/mock/fail',
|
||||||
@@ -43,6 +44,9 @@ test.describe('Bot', () => {
|
|||||||
method: HttpMethod.POST,
|
method: HttpMethod.POST,
|
||||||
body: `{{Full body}}`,
|
body: `{{Full body}}`,
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { HttpMethod } from 'models'
|
||||||
|
import {
|
||||||
|
createWebhook,
|
||||||
|
deleteTypebots,
|
||||||
|
deleteWebhooks,
|
||||||
|
importTypebotInDatabase,
|
||||||
|
} from 'utils/playwright/databaseActions'
|
||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
|
||||||
|
const typebotId = cuid()
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createWebhook(typebotId, {
|
||||||
|
id: 'failing-webhook',
|
||||||
|
url: 'http://localhost:3001/api/mock/fail',
|
||||||
|
method: HttpMethod.POST,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createWebhook(typebotId, {
|
||||||
|
id: 'partial-body-webhook',
|
||||||
|
url: 'http://localhost:3000/api/mock/webhook-easy-config',
|
||||||
|
method: HttpMethod.POST,
|
||||||
|
body: `{
|
||||||
|
"name": "{{Name}}",
|
||||||
|
"age": {{Age}},
|
||||||
|
"gender": "{{Gender}}"
|
||||||
|
}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await createWebhook(typebotId, {
|
||||||
|
id: 'full-body-webhook',
|
||||||
|
url: 'http://localhost:3000/api/mock/webhook-easy-config',
|
||||||
|
method: HttpMethod.POST,
|
||||||
|
body: `{{Full body}}`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await deleteTypebots([typebotId])
|
||||||
|
await deleteWebhooks([
|
||||||
|
'failing-webhook',
|
||||||
|
'partial-body-webhook',
|
||||||
|
'full-body-webhook',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should execute webhooks properly', async ({ page }) => {
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page.locator('text=Send failing webhook').click()
|
||||||
|
await page.locator('[placeholder="Type a name..."]').fill('John')
|
||||||
|
await page.locator('text="Send"').click()
|
||||||
|
await page.locator('[placeholder="Type an age..."]').fill('30')
|
||||||
|
await page.locator('text="Send"').click()
|
||||||
|
await page.locator('text="Male"').click()
|
||||||
|
await expect(
|
||||||
|
page.getByText('{"name":"John","age":25,"gender":"male"}')
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText('{"name":"John","age":30,"gender":"Male"}')
|
||||||
|
).toBeVisible()
|
||||||
|
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
|
||||||
|
await page.click('text="See logs"')
|
||||||
|
await expect(
|
||||||
|
page.locator('text="Webhook successfuly executed." >> nth=1')
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -10,7 +10,9 @@ export const executeRedirect = (
|
|||||||
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
|
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
|
||||||
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
|
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
|
||||||
return {
|
return {
|
||||||
logic: { redirectUrl: formattedUrl },
|
logic: {
|
||||||
|
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
|
||||||
|
},
|
||||||
outgoingEdgeId: block.outgoingEdgeId,
|
outgoingEdgeId: block.outgoingEdgeId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { ExecuteLogicResponse } from '@/features/chat'
|
import { ExecuteLogicResponse } from '@/features/chat'
|
||||||
import { saveErrorLog } from '@/features/logs/api'
|
import { saveErrorLog } from '@/features/logs/api'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { TypebotLinkBlock, Edge, SessionState, TypebotInSession } from 'models'
|
import {
|
||||||
|
TypebotLinkBlock,
|
||||||
|
Edge,
|
||||||
|
SessionState,
|
||||||
|
TypebotInSession,
|
||||||
|
Variable,
|
||||||
|
} from 'models'
|
||||||
import { byId } from 'utils'
|
import { byId } from 'utils'
|
||||||
|
|
||||||
export const executeTypebotLink = async (
|
export const executeTypebotLink = async (
|
||||||
@@ -9,6 +15,7 @@ export const executeTypebotLink = async (
|
|||||||
block: TypebotLinkBlock
|
block: TypebotLinkBlock
|
||||||
): Promise<ExecuteLogicResponse> => {
|
): Promise<ExecuteLogicResponse> => {
|
||||||
if (!block.options.typebotId) {
|
if (!block.options.typebotId) {
|
||||||
|
state.result &&
|
||||||
saveErrorLog({
|
saveErrorLog({
|
||||||
resultId: state.result.id,
|
resultId: state.result.id,
|
||||||
message: 'Failed to link typebot',
|
message: 'Failed to link typebot',
|
||||||
@@ -18,6 +25,7 @@ export const executeTypebotLink = async (
|
|||||||
}
|
}
|
||||||
const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId)
|
const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId)
|
||||||
if (!linkedTypebot) {
|
if (!linkedTypebot) {
|
||||||
|
state.result &&
|
||||||
saveErrorLog({
|
saveErrorLog({
|
||||||
resultId: state.result.id,
|
resultId: state.result.id,
|
||||||
message: 'Failed to link typebot',
|
message: 'Failed to link typebot',
|
||||||
@@ -32,6 +40,7 @@ export const executeTypebotLink = async (
|
|||||||
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
|
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
|
||||||
?.id
|
?.id
|
||||||
if (!nextGroupId) {
|
if (!nextGroupId) {
|
||||||
|
state.result &&
|
||||||
saveErrorLog({
|
saveErrorLog({
|
||||||
resultId: state.result.id,
|
resultId: state.result.id,
|
||||||
message: 'Failed to link typebot',
|
message: 'Failed to link typebot',
|
||||||
@@ -65,12 +74,17 @@ const addLinkedTypebotToState = (
|
|||||||
state: SessionState,
|
state: SessionState,
|
||||||
block: TypebotLinkBlock,
|
block: TypebotLinkBlock,
|
||||||
linkedTypebot: TypebotInSession
|
linkedTypebot: TypebotInSession
|
||||||
): SessionState => ({
|
): SessionState => {
|
||||||
|
const incomingVariables = fillVariablesWithExistingValues(
|
||||||
|
linkedTypebot.variables,
|
||||||
|
state.typebot.variables
|
||||||
|
)
|
||||||
|
return {
|
||||||
...state,
|
...state,
|
||||||
typebot: {
|
typebot: {
|
||||||
...state.typebot,
|
...state.typebot,
|
||||||
groups: [...state.typebot.groups, ...linkedTypebot.groups],
|
groups: [...state.typebot.groups, ...linkedTypebot.groups],
|
||||||
variables: [...state.typebot.variables, ...linkedTypebot.variables],
|
variables: [...state.typebot.variables, ...incomingVariables],
|
||||||
edges: [...state.typebot.edges, ...linkedTypebot.edges],
|
edges: [...state.typebot.edges, ...linkedTypebot.edges],
|
||||||
},
|
},
|
||||||
linkedTypebots: {
|
linkedTypebots: {
|
||||||
@@ -87,6 +101,22 @@ const addLinkedTypebotToState = (
|
|||||||
: state.linkedTypebots.queue,
|
: state.linkedTypebots.queue,
|
||||||
},
|
},
|
||||||
currentTypebotId: linkedTypebot.id,
|
currentTypebotId: linkedTypebot.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillVariablesWithExistingValues = (
|
||||||
|
variables: Variable[],
|
||||||
|
variablesWithValues: Variable[]
|
||||||
|
): Variable[] =>
|
||||||
|
variables.map((variable) => {
|
||||||
|
const matchedVariable = variablesWithValues.find(
|
||||||
|
(variableWithValue) => variableWithValue.name === variable.name
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...variable,
|
||||||
|
value: matchedVariable?.value ?? variable.value,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const getLinkedTypebot = async (
|
const getLinkedTypebot = async (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import test, { expect } from '@playwright/test'
|
|||||||
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||||
import { typebotViewer } from 'utils/playwright/testHelpers'
|
import { typebotViewer } from 'utils/playwright/testHelpers'
|
||||||
|
|
||||||
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
|
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm1'
|
||||||
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
|
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||||
|
|
||||||
|
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
|
||||||
|
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
await importTypebotInDatabase(
|
||||||
|
getTestAsset('typebots/linkTypebots/1.json'),
|
||||||
|
{ id: typebotId, publicId: `${typebotId}-public` }
|
||||||
|
)
|
||||||
|
await importTypebotInDatabase(
|
||||||
|
getTestAsset('typebots/linkTypebots/2.json'),
|
||||||
|
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should work as expected', async ({ page }) => {
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page.locator('input').fill('Hello there!')
|
||||||
|
await page.locator('input').press('Enter')
|
||||||
|
await expect(page.getByText('Cheers!')).toBeVisible()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text=Hello there!')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -1,58 +1,40 @@
|
|||||||
|
import { checkChatsUsage } from '@/features/usage'
|
||||||
|
import { parsePrefilledVariables } from '@/features/variables'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { publicProcedure } from '@/utils/server/trpc'
|
import { publicProcedure } from '@/utils/server/trpc'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
|
import { Prisma } from 'db'
|
||||||
import {
|
import {
|
||||||
chatReplySchema,
|
chatReplySchema,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
PublicTypebotWithName,
|
PublicTypebot,
|
||||||
Result,
|
Result,
|
||||||
|
sendMessageInputSchema,
|
||||||
SessionState,
|
SessionState,
|
||||||
typebotSchema,
|
StartParams,
|
||||||
|
Typebot,
|
||||||
|
Variable,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { z } from 'zod'
|
|
||||||
import { continueBotFlow, getSession, startBotFlow } from '../utils'
|
import { continueBotFlow, getSession, startBotFlow } from '../utils'
|
||||||
|
|
||||||
export const sendMessageProcedure = publicProcedure
|
export const sendMessageProcedure = publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/typebots/{typebotId}/sendMessage',
|
path: '/sendMessage',
|
||||||
summary: 'Send a message',
|
summary: 'Send a message',
|
||||||
description:
|
description:
|
||||||
"To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
|
'To initiate a chat, do not provide a `sessionId` nor a `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(
|
.input(sendMessageInputSchema)
|
||||||
z.object({
|
.output(chatReplySchema)
|
||||||
typebotId: z.string({
|
.query(async ({ input: { sessionId, message, startParams } }) => {
|
||||||
description:
|
|
||||||
'[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)',
|
|
||||||
}),
|
|
||||||
message: z.string().describe('The answer to the previous question'),
|
|
||||||
sessionId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Session ID that you get from the initial chat request to a bot'
|
|
||||||
),
|
|
||||||
isPreview: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.output(
|
|
||||||
chatReplySchema.and(
|
|
||||||
z.object({
|
|
||||||
sessionId: z.string().nullish(),
|
|
||||||
typebot: typebotSchema.pick({ theme: true, settings: true }).nullish(),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.query(async ({ input: { typebotId, sessionId, message } }) => {
|
|
||||||
const session = sessionId ? await getSession(sessionId) : null
|
const session = sessionId ? await getSession(sessionId) : null
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const { sessionId, typebot, messages, input } = await startSession(
|
const { sessionId, typebot, messages, input, resultId } =
|
||||||
typebotId
|
await startSession(startParams)
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
typebot: typebot
|
typebot: typebot
|
||||||
@@ -60,14 +42,14 @@ export const sendMessageProcedure = publicProcedure
|
|||||||
theme: typebot.theme,
|
theme: typebot.theme,
|
||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
}
|
}
|
||||||
: null,
|
: undefined,
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
|
resultId,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { messages, input, logic, newSessionState } = await continueBotFlow(
|
const { messages, input, logic, newSessionState, integrations } =
|
||||||
session.state
|
await continueBotFlow(session.state)(message)
|
||||||
)(message)
|
|
||||||
|
|
||||||
await prisma.chatSession.updateMany({
|
await prisma.chatSession.updateMany({
|
||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
@@ -80,15 +62,41 @@ export const sendMessageProcedure = publicProcedure
|
|||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
logic,
|
logic,
|
||||||
|
integrations,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const startSession = async (typebotId: string) => {
|
const startSession = async (startParams?: StartParams) => {
|
||||||
const typebot = await prisma.typebot.findUnique({
|
if (!startParams?.typebotId)
|
||||||
where: { id: typebotId },
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No typebotId provided in startParams',
|
||||||
|
})
|
||||||
|
const typebotQuery = startParams.isPreview
|
||||||
|
? await prisma.typebot.findUnique({
|
||||||
|
where: { id: startParams.typebotId },
|
||||||
select: {
|
select: {
|
||||||
publishedTypebot: true,
|
groups: true,
|
||||||
|
edges: true,
|
||||||
|
settings: true,
|
||||||
|
theme: true,
|
||||||
|
variables: true,
|
||||||
|
isArchived: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await prisma.typebot.findUnique({
|
||||||
|
where: { id: startParams.typebotId },
|
||||||
|
select: {
|
||||||
|
publishedTypebot: {
|
||||||
|
select: {
|
||||||
|
groups: true,
|
||||||
|
edges: true,
|
||||||
|
settings: true,
|
||||||
|
theme: true,
|
||||||
|
variables: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
name: true,
|
name: true,
|
||||||
isClosed: true,
|
isClosed: true,
|
||||||
isArchived: true,
|
isArchived: true,
|
||||||
@@ -96,43 +104,61 @@ const startSession = async (typebotId: string) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!typebot?.publishedTypebot || typebot.isArchived)
|
const typebot =
|
||||||
|
typebotQuery && 'publishedTypebot' in typebotQuery
|
||||||
|
? (typebotQuery.publishedTypebot as Pick<
|
||||||
|
PublicTypebot,
|
||||||
|
'groups' | 'edges' | 'settings' | 'theme' | 'variables'
|
||||||
|
>)
|
||||||
|
: (typebotQuery as Pick<
|
||||||
|
Typebot,
|
||||||
|
'groups' | 'edges' | 'settings' | 'theme' | 'variables' | 'isArchived'
|
||||||
|
>)
|
||||||
|
|
||||||
|
if (!typebot)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Typebot not found',
|
message: 'Typebot not found',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (typebot.isClosed)
|
if ('isClosed' in typebot && typebot.isClosed)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Typebot is closed',
|
message: 'Typebot is closed',
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = (await prisma.result.create({
|
const hasReachedLimit = !startParams.isPreview
|
||||||
data: { isCompleted: false, typebotId },
|
? await checkChatsUsage(startParams.typebotId)
|
||||||
select: {
|
: false
|
||||||
id: true,
|
|
||||||
variables: true,
|
|
||||||
hasStarted: true,
|
|
||||||
},
|
|
||||||
})) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
|
|
||||||
|
|
||||||
const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName
|
if (hasReachedLimit)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Your workspace reached its chat limit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const startVariables = startParams.prefilledVariables
|
||||||
|
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
|
||||||
|
: typebot.variables
|
||||||
|
|
||||||
|
const result = await getResult({ ...startParams, startVariables })
|
||||||
|
|
||||||
const initialState: SessionState = {
|
const initialState: SessionState = {
|
||||||
typebot: {
|
typebot: {
|
||||||
id: publicTypebot.typebotId,
|
id: startParams.typebotId,
|
||||||
groups: publicTypebot.groups,
|
groups: typebot.groups,
|
||||||
edges: publicTypebot.edges,
|
edges: typebot.edges,
|
||||||
variables: publicTypebot.variables,
|
variables: startVariables,
|
||||||
},
|
},
|
||||||
linkedTypebots: {
|
linkedTypebots: {
|
||||||
typebots: [],
|
typebots: [],
|
||||||
queue: [],
|
queue: [],
|
||||||
},
|
},
|
||||||
result: { id: result.id, variables: [], hasStarted: false },
|
result: result
|
||||||
|
? { id: result.id, variables: result.variables, hasStarted: false }
|
||||||
|
: undefined,
|
||||||
isPreview: false,
|
isPreview: false,
|
||||||
currentTypebotId: publicTypebot.typebotId,
|
currentTypebotId: startParams.typebotId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -145,8 +171,6 @@ const startSession = async (typebotId: string) => {
|
|||||||
if (!input)
|
if (!input)
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
typebot: null,
|
|
||||||
sessionId: null,
|
|
||||||
logic,
|
logic,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,13 +189,47 @@ const startSession = async (typebotId: string) => {
|
|||||||
})) as ChatSession
|
})) as ChatSession
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
resultId: result?.id,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
typebot: {
|
typebot: {
|
||||||
theme: publicTypebot.theme,
|
theme: typebot.theme,
|
||||||
settings: publicTypebot.settings,
|
settings: typebot.settings,
|
||||||
},
|
},
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
logic,
|
logic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getResult = async ({
|
||||||
|
typebotId,
|
||||||
|
isPreview,
|
||||||
|
resultId,
|
||||||
|
startVariables,
|
||||||
|
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
|
||||||
|
startVariables: Variable[]
|
||||||
|
}) => {
|
||||||
|
if (isPreview) return undefined
|
||||||
|
const data = {
|
||||||
|
isCompleted: false,
|
||||||
|
typebotId: typebotId,
|
||||||
|
variables: { set: startVariables.filter((variable) => variable.value) },
|
||||||
|
} satisfies Prisma.ResultUncheckedCreateInput
|
||||||
|
const select = {
|
||||||
|
id: true,
|
||||||
|
variables: true,
|
||||||
|
hasStarted: true,
|
||||||
|
} satisfies Prisma.ResultSelect
|
||||||
|
return (
|
||||||
|
resultId
|
||||||
|
? await prisma.result.update({
|
||||||
|
where: { id: resultId },
|
||||||
|
data,
|
||||||
|
select,
|
||||||
|
})
|
||||||
|
: await prisma.result.create({
|
||||||
|
data,
|
||||||
|
select,
|
||||||
|
})
|
||||||
|
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { validateButtonInput } from '@/features/blocks/inputs/buttons/api'
|
import { validateButtonInput } from '@/features/blocks/inputs/buttons/api'
|
||||||
import { validateEmail } from '@/features/blocks/inputs/email/api'
|
import { validateEmail } from '@/features/blocks/inputs/email/api'
|
||||||
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/api'
|
import {
|
||||||
|
formatPhoneNumber,
|
||||||
|
validatePhoneNumber,
|
||||||
|
} from '@/features/blocks/inputs/phone/api'
|
||||||
import { validateUrl } from '@/features/blocks/inputs/url/api'
|
import { validateUrl } from '@/features/blocks/inputs/url/api'
|
||||||
|
import { parseVariables } from '@/features/variables'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import {
|
import {
|
||||||
Block,
|
Block,
|
||||||
|
BlockType,
|
||||||
BubbleBlockType,
|
BubbleBlockType,
|
||||||
ChatReply,
|
ChatReply,
|
||||||
InputBlock,
|
InputBlock,
|
||||||
@@ -20,7 +25,7 @@ import { getNextGroup } from './getNextGroup'
|
|||||||
export const continueBotFlow =
|
export const continueBotFlow =
|
||||||
(state: SessionState) =>
|
(state: SessionState) =>
|
||||||
async (
|
async (
|
||||||
reply: string
|
reply?: string
|
||||||
): Promise<ChatReply & { newSessionState?: SessionState }> => {
|
): Promise<ChatReply & { newSessionState?: SessionState }> => {
|
||||||
const group = state.typebot.groups.find(
|
const group = state.typebot.groups.find(
|
||||||
(group) => group.id === state.currentBlock?.groupId
|
(group) => group.id === state.currentBlock?.groupId
|
||||||
@@ -30,7 +35,7 @@ export const continueBotFlow =
|
|||||||
(block) => block.id === state.currentBlock?.blockId
|
(block) => block.id === state.currentBlock?.blockId
|
||||||
) ?? -1
|
) ?? -1
|
||||||
|
|
||||||
const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null
|
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
|
||||||
|
|
||||||
if (!block || !group)
|
if (!block || !group)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -44,9 +49,15 @@ export const continueBotFlow =
|
|||||||
message: 'Current block is not an input block',
|
message: 'Current block is not an input block',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isInputValid(reply, block)) return parseRetryMessage(block)
|
const formattedReply = formatReply(reply, block.type)
|
||||||
|
|
||||||
const newVariables = await processAndSaveAnswer(state, block)(reply)
|
if (!formattedReply || !isReplyValid(formattedReply, block))
|
||||||
|
return parseRetryMessage(block)
|
||||||
|
|
||||||
|
const newVariables = await processAndSaveAnswer(
|
||||||
|
state,
|
||||||
|
block
|
||||||
|
)(formattedReply)
|
||||||
|
|
||||||
const newSessionState = {
|
const newSessionState = {
|
||||||
...state,
|
...state,
|
||||||
@@ -58,15 +69,15 @@ export const continueBotFlow =
|
|||||||
|
|
||||||
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
|
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
|
||||||
|
|
||||||
if (groupHasMoreBlocks) {
|
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
|
||||||
|
|
||||||
|
if (groupHasMoreBlocks && !nextEdgeId) {
|
||||||
return executeGroup(newSessionState)({
|
return executeGroup(newSessionState)({
|
||||||
...group,
|
...group,
|
||||||
blocks: group.blocks.slice(blockIndex + 1),
|
blocks: group.blocks.slice(blockIndex + 1),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEdgeId = block.outgoingEdgeId
|
|
||||||
|
|
||||||
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
|
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
|
||||||
return { messages: [] }
|
return { messages: [] }
|
||||||
|
|
||||||
@@ -80,7 +91,7 @@ export const continueBotFlow =
|
|||||||
const processAndSaveAnswer =
|
const processAndSaveAnswer =
|
||||||
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
|
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
|
||||||
async (reply: string): Promise<Variable[]> => {
|
async (reply: string): Promise<Variable[]> => {
|
||||||
await saveAnswer(state.result.id, block)(reply)
|
state.result && (await saveAnswer(state.result.id, block)(reply))
|
||||||
const newVariables = saveVariableValueIfAny(state, block)(reply)
|
const newVariables = saveVariableValueIfAny(state, block)(reply)
|
||||||
return newVariables
|
return newVariables
|
||||||
}
|
}
|
||||||
@@ -105,22 +116,26 @@ const saveVariableValueIfAny =
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseRetryMessage = (block: InputBlock) => ({
|
const parseRetryMessage = (
|
||||||
|
block: InputBlock
|
||||||
|
): Pick<ChatReply, 'messages' | 'input'> => {
|
||||||
|
const retryMessage =
|
||||||
|
'retryMessageContent' in block.options && block.options.retryMessageContent
|
||||||
|
? block.options.retryMessageContent
|
||||||
|
: 'Invalid message. Please, try again.'
|
||||||
|
return {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
type: BubbleBlockType.TEXT,
|
type: BubbleBlockType.TEXT,
|
||||||
content: {
|
content: {
|
||||||
plainText:
|
plainText: retryMessage,
|
||||||
'retryMessageContent' in block.options
|
html: `<div>${retryMessage}</div>`,
|
||||||
? block.options.retryMessageContent
|
|
||||||
: 'Invalid message. Please, try again.',
|
|
||||||
richText: [],
|
|
||||||
html: '',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
input: block,
|
input: block,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveAnswer =
|
const saveAnswer =
|
||||||
(resultId: string, block: InputBlock) => async (reply: string) => {
|
(resultId: string, block: InputBlock) => async (reply: string) => {
|
||||||
@@ -135,7 +150,35 @@ const saveAnswer =
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isInputValid = (inputValue: string, block: Block): boolean => {
|
const getOutgoingEdgeId =
|
||||||
|
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
|
||||||
|
(block: InputBlock, reply?: string) => {
|
||||||
|
if (
|
||||||
|
block.type === InputBlockType.CHOICE &&
|
||||||
|
!block.options.isMultipleChoice &&
|
||||||
|
reply
|
||||||
|
) {
|
||||||
|
const matchedItem = block.items.find(
|
||||||
|
(item) => parseVariables(variables)(item.content) === reply
|
||||||
|
)
|
||||||
|
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
|
||||||
|
}
|
||||||
|
return block.outgoingEdgeId
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatReply = (
|
||||||
|
inputValue: string | undefined,
|
||||||
|
blockType: BlockType
|
||||||
|
): string | null => {
|
||||||
|
if (!inputValue) return null
|
||||||
|
switch (blockType) {
|
||||||
|
case InputBlockType.PHONE:
|
||||||
|
return formatPhoneNumber(inputValue)
|
||||||
|
}
|
||||||
|
return inputValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isReplyValid = (inputValue: string, block: Block): boolean => {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case InputBlockType.EMAIL:
|
case InputBlockType.EMAIL:
|
||||||
return validateEmail(inputValue)
|
return validateEmail(inputValue)
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { parseVariables } from '@/features/variables'
|
|||||||
import {
|
import {
|
||||||
BubbleBlock,
|
BubbleBlock,
|
||||||
BubbleBlockType,
|
BubbleBlockType,
|
||||||
ChatMessageContent,
|
ChatMessage,
|
||||||
ChatReply,
|
ChatReply,
|
||||||
Group,
|
Group,
|
||||||
|
InputBlock,
|
||||||
|
InputBlockType,
|
||||||
|
RuntimeOptions,
|
||||||
SessionState,
|
SessionState,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import {
|
import {
|
||||||
isBubbleBlock,
|
isBubbleBlock,
|
||||||
|
isDefined,
|
||||||
isInputBlock,
|
isInputBlock,
|
||||||
isIntegrationBlock,
|
isIntegrationBlock,
|
||||||
isLogicBlock,
|
isLogicBlock,
|
||||||
@@ -16,6 +20,7 @@ import {
|
|||||||
import { executeLogic } from './executeLogic'
|
import { executeLogic } from './executeLogic'
|
||||||
import { getNextGroup } from './getNextGroup'
|
import { getNextGroup } from './getNextGroup'
|
||||||
import { executeIntegration } from './executeIntegration'
|
import { executeIntegration } from './executeIntegration'
|
||||||
|
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api'
|
||||||
|
|
||||||
export const executeGroup =
|
export const executeGroup =
|
||||||
(state: SessionState, currentReply?: ChatReply) =>
|
(state: SessionState, currentReply?: ChatReply) =>
|
||||||
@@ -33,17 +38,20 @@ export const executeGroup =
|
|||||||
nextEdgeId = block.outgoingEdgeId
|
nextEdgeId = block.outgoingEdgeId
|
||||||
|
|
||||||
if (isBubbleBlock(block)) {
|
if (isBubbleBlock(block)) {
|
||||||
messages.push({
|
messages.push(parseBubbleBlockContent(newSessionState)(block))
|
||||||
type: block.type,
|
|
||||||
content: parseBubbleBlockContent(newSessionState)(block),
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInputBlock(block))
|
if (isInputBlock(block))
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
input: block,
|
input: {
|
||||||
|
...block,
|
||||||
|
runtimeOptions: await computeRuntimeOptions(newSessionState)(block),
|
||||||
|
prefilledValue: getPrefilledInputValue(
|
||||||
|
newSessionState.typebot.variables
|
||||||
|
)(block),
|
||||||
|
},
|
||||||
newSessionState: {
|
newSessionState: {
|
||||||
...newSessionState,
|
...newSessionState,
|
||||||
currentBlock: {
|
currentBlock: {
|
||||||
@@ -53,9 +61,9 @@ export const executeGroup =
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
const executionResponse = isLogicBlock(block)
|
const executionResponse = isLogicBlock(block)
|
||||||
? await executeLogic(state)(block)
|
? await executeLogic(newSessionState)(block)
|
||||||
: isIntegrationBlock(block)
|
: isIntegrationBlock(block)
|
||||||
? await executeIntegration(state)(block)
|
? await executeIntegration(newSessionState)(block)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (!executionResponse) continue
|
if (!executionResponse) continue
|
||||||
@@ -84,30 +92,50 @@ export const executeGroup =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const computeRuntimeOptions =
|
||||||
|
(state: SessionState) =>
|
||||||
|
(block: InputBlock): Promise<RuntimeOptions> | undefined => {
|
||||||
|
switch (block.type) {
|
||||||
|
case InputBlockType.PAYMENT: {
|
||||||
|
return computePaymentInputRuntimeOptions(state)(block.options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parseBubbleBlockContent =
|
const parseBubbleBlockContent =
|
||||||
({ typebot: { variables } }: SessionState) =>
|
({ typebot: { variables } }: SessionState) =>
|
||||||
(block: BubbleBlock): ChatMessageContent => {
|
(block: BubbleBlock): ChatMessage => {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case BubbleBlockType.TEXT: {
|
case BubbleBlockType.TEXT: {
|
||||||
const plainText = parseVariables(variables)(block.content.plainText)
|
const plainText = parseVariables(variables)(block.content.plainText)
|
||||||
const html = parseVariables(variables)(block.content.html)
|
const html = parseVariables(variables)(block.content.html)
|
||||||
return { plainText, html }
|
return { type: block.type, content: { plainText, html } }
|
||||||
}
|
}
|
||||||
case BubbleBlockType.IMAGE: {
|
case BubbleBlockType.IMAGE: {
|
||||||
const url = parseVariables(variables)(block.content.url)
|
const url = parseVariables(variables)(block.content.url)
|
||||||
return { url }
|
return { type: block.type, content: { ...block.content, url } }
|
||||||
}
|
}
|
||||||
case BubbleBlockType.VIDEO: {
|
case BubbleBlockType.VIDEO: {
|
||||||
const url = parseVariables(variables)(block.content.url)
|
const url = parseVariables(variables)(block.content.url)
|
||||||
return { url }
|
return { type: block.type, content: { ...block.content, url } }
|
||||||
}
|
}
|
||||||
case BubbleBlockType.AUDIO: {
|
case BubbleBlockType.AUDIO: {
|
||||||
const url = parseVariables(variables)(block.content.url)
|
const url = parseVariables(variables)(block.content.url)
|
||||||
return { url }
|
return { type: block.type, content: { ...block.content, url } }
|
||||||
}
|
}
|
||||||
case BubbleBlockType.EMBED: {
|
case BubbleBlockType.EMBED: {
|
||||||
const url = parseVariables(variables)(block.content.url)
|
const url = parseVariables(variables)(block.content.url)
|
||||||
return { url }
|
return { type: block.type, content: { ...block.content, url } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPrefilledInputValue =
|
||||||
|
(variables: SessionState['typebot']['variables']) => (block: InputBlock) => {
|
||||||
|
return (
|
||||||
|
variables.find(
|
||||||
|
(variable) =>
|
||||||
|
variable.id === block.options.variableId && isDefined(variable.value)
|
||||||
|
)?.value ?? undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { getTestAsset } from '@/test/utils/playwright'
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
import test, { expect } from '@playwright/test'
|
import test, { expect } from '@playwright/test'
|
||||||
import cuid from 'cuid'
|
import cuid from 'cuid'
|
||||||
import { HttpMethod } from 'models'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { HttpMethod, SendMessageInput } from 'models'
|
||||||
import {
|
import {
|
||||||
createWebhook,
|
createWebhook,
|
||||||
deleteTypebots,
|
deleteTypebots,
|
||||||
@@ -9,10 +10,14 @@ import {
|
|||||||
importTypebotInDatabase,
|
importTypebotInDatabase,
|
||||||
} from 'utils/playwright/databaseActions'
|
} from 'utils/playwright/databaseActions'
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await deleteWebhooks(['chat-webhook-id'])
|
||||||
|
await deleteTypebots(['chat-sub-bot'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API chat execution should work on preview bot', async ({ request }) => {
|
||||||
const typebotId = cuid()
|
const typebotId = cuid()
|
||||||
const publicId = `${typebotId}-public`
|
const publicId = `${typebotId}-public`
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
|
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
|
||||||
id: typebotId,
|
id: typebotId,
|
||||||
publicId,
|
publicId,
|
||||||
@@ -26,25 +31,64 @@ test.beforeEach(async () => {
|
|||||||
method: HttpMethod.GET,
|
method: HttpMethod.GET,
|
||||||
url: 'https://api.chucknorris.io/jokes/random',
|
url: 'https://api.chucknorris.io/jokes/random',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test.step('Start the chat', async () => {
|
||||||
|
const { sessionId, messages, input, resultId } = await (
|
||||||
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
|
data: {
|
||||||
|
startParams: {
|
||||||
|
typebotId,
|
||||||
|
isPreview: true,
|
||||||
|
},
|
||||||
|
// TODO: replace with satisfies once compatible with playwright
|
||||||
|
} as SendMessageInput,
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
expect(resultId).toBeUndefined()
|
||||||
|
expect(sessionId).toBeDefined()
|
||||||
|
expect(messages[0].content.plainText).toBe('Hi there! 👋')
|
||||||
|
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
|
||||||
|
expect(input.type).toBe('text input')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test('API chat execution should work on published bot', async ({ request }) => {
|
||||||
await deleteWebhooks(['chat-webhook-id'])
|
const typebotId = cuid()
|
||||||
await deleteTypebots(['chat-sub-bot'])
|
const publicId = `${typebotId}-public`
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId,
|
||||||
|
})
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/chat/linkedBot.json'), {
|
||||||
|
id: 'chat-sub-bot',
|
||||||
|
publicId: 'chat-sub-bot-public',
|
||||||
|
})
|
||||||
|
await createWebhook(typebotId, {
|
||||||
|
id: 'chat-webhook-id',
|
||||||
|
method: HttpMethod.GET,
|
||||||
|
url: 'https://api.chucknorris.io/jokes/random',
|
||||||
})
|
})
|
||||||
|
|
||||||
test('API chat execution should work', async ({ request }) => {
|
|
||||||
let chatSessionId: string
|
let chatSessionId: string
|
||||||
|
|
||||||
await test.step('Start the chat', async () => {
|
await test.step('Start the chat', async () => {
|
||||||
const { sessionId, messages, input } = await (
|
const { sessionId, messages, input, resultId } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: {
|
data: {
|
||||||
message: 'Hi',
|
startParams: {
|
||||||
|
typebotId,
|
||||||
},
|
},
|
||||||
|
// TODO: replace with satisfies once compatible with playwright
|
||||||
|
} as SendMessageInput,
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
chatSessionId = sessionId
|
chatSessionId = sessionId
|
||||||
|
expect(resultId).toBeDefined()
|
||||||
|
const result = await prisma.result.findUnique({
|
||||||
|
where: {
|
||||||
|
id: resultId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result).toBeDefined()
|
||||||
expect(sessionId).toBeDefined()
|
expect(sessionId).toBeDefined()
|
||||||
expect(messages[0].content.plainText).toBe('Hi there! 👋')
|
expect(messages[0].content.plainText).toBe('Hi there! 👋')
|
||||||
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
|
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
|
||||||
@@ -53,7 +97,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Name question', async () => {
|
await test.step('Answer Name question', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'John', sessionId: chatSessionId },
|
data: { message: 'John', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -64,7 +108,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Age question', async () => {
|
await test.step('Answer Age question', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: '24', sessionId: chatSessionId },
|
data: { message: '24', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -78,7 +122,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Rating question', async () => {
|
await test.step('Answer Rating question', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: '8', sessionId: chatSessionId },
|
data: { message: '8', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -90,7 +134,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Email question with wrong input', async () => {
|
await test.step('Answer Email question with wrong input', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'invalid email', sessionId: chatSessionId },
|
data: { message: 'invalid email', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -102,7 +146,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Email question with valid input', async () => {
|
await test.step('Answer Email question with valid input', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'typebot@email.com', sessionId: chatSessionId },
|
data: { message: 'typebot@email.com', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -112,7 +156,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer URL question', async () => {
|
await test.step('Answer URL question', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'https://typebot.io', sessionId: chatSessionId },
|
data: { message: 'https://typebot.io', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -122,7 +166,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Buttons question with invalid choice', async () => {
|
await test.step('Answer Buttons question with invalid choice', async () => {
|
||||||
const { messages, input } = await (
|
const { messages, input } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'Yolo', sessionId: chatSessionId },
|
data: { message: 'Yolo', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
@@ -134,7 +178,7 @@ test('API chat execution should work', async ({ request }) => {
|
|||||||
|
|
||||||
await test.step('Answer Buttons question with invalid choice', async () => {
|
await test.step('Answer Buttons question with invalid choice', async () => {
|
||||||
const { messages } = await (
|
const { messages } = await (
|
||||||
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
|
await request.post(`/api/v1/sendMessage`, {
|
||||||
data: { message: 'Yes', sessionId: chatSessionId },
|
data: { message: 'Yes', sessionId: chatSessionId },
|
||||||
})
|
})
|
||||||
).json()
|
).json()
|
||||||
|
|||||||
27
apps/viewer/src/features/results/resultsV2.spec.ts
Normal file
27
apps/viewer/src/features/results/resultsV2.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||||
|
|
||||||
|
test('Big groups should work as expected', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/hugeGroup.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
})
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await page.locator('input').fill('Baptiste')
|
||||||
|
await page.locator('input').press('Enter')
|
||||||
|
await page.locator('input').fill('26')
|
||||||
|
await page.locator('input').press('Enter')
|
||||||
|
await page.locator('button >> text=Yes').click()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text="Baptiste"')).toBeVisible()
|
||||||
|
await expect(page.locator('text="26"')).toBeVisible()
|
||||||
|
await expect(page.locator('text="Yes"')).toBeVisible()
|
||||||
|
await page.hover('tbody > tr')
|
||||||
|
await page.click('button >> text="Open"')
|
||||||
|
await expect(page.locator('text="Baptiste" >> nth=1')).toBeVisible()
|
||||||
|
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
|
||||||
|
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
|
||||||
|
})
|
||||||
179
apps/viewer/src/features/settings/settingsV2.spec.ts
Normal file
179
apps/viewer/src/features/settings/settingsV2.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import {
|
||||||
|
defaultSettings,
|
||||||
|
defaultTextInputOptions,
|
||||||
|
InputBlockType,
|
||||||
|
Metadata,
|
||||||
|
} from 'models'
|
||||||
|
import { createTypebots, updateTypebot } from 'utils/playwright/databaseActions'
|
||||||
|
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
|
||||||
|
|
||||||
|
test('Result should be overwritten on page refresh', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultGroupWithBlock({
|
||||||
|
type: InputBlockType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [, response] = await Promise.all([
|
||||||
|
page.goto(`/next/${typebotId}-public`),
|
||||||
|
page.waitForResponse(/sendMessage/),
|
||||||
|
])
|
||||||
|
const { resultId } = await response.json()
|
||||||
|
expect(resultId).toBeDefined()
|
||||||
|
|
||||||
|
await expect(page.getByRole('textbox')).toBeVisible()
|
||||||
|
const [, secondResponse] = await Promise.all([
|
||||||
|
page.reload(),
|
||||||
|
page.waitForResponse(/sendMessage/),
|
||||||
|
])
|
||||||
|
const { resultId: secondResultId } = await secondResponse.json()
|
||||||
|
expect(secondResultId).toBe(resultId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Create result on page refresh enabled', () => {
|
||||||
|
test('should work', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
general: {
|
||||||
|
...defaultSettings.general,
|
||||||
|
isNewResultOnRefreshEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...parseDefaultGroupWithBlock({
|
||||||
|
type: InputBlockType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const [, response] = await Promise.all([
|
||||||
|
page.goto(`/next/${typebotId}-public`),
|
||||||
|
page.waitForResponse(/sendMessage/),
|
||||||
|
])
|
||||||
|
const { resultId } = await response.json()
|
||||||
|
expect(resultId).toBeDefined()
|
||||||
|
|
||||||
|
await expect(page.getByRole('textbox')).toBeVisible()
|
||||||
|
const [, secondResponse] = await Promise.all([
|
||||||
|
page.reload(),
|
||||||
|
page.waitForResponse(/sendMessage/),
|
||||||
|
])
|
||||||
|
const { resultId: secondResultId } = await secondResponse.json()
|
||||||
|
expect(secondResultId).not.toBe(resultId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hide query params', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultGroupWithBlock({
|
||||||
|
type: InputBlockType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await page.goto(`/next/${typebotId}-public?Name=John`)
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(page.url()).toEqual(`http://localhost:3001/next/${typebotId}-public`)
|
||||||
|
await updateTypebot({
|
||||||
|
id: typebotId,
|
||||||
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
general: { ...defaultSettings.general, isHideQueryParamsEnabled: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.goto(`/next/${typebotId}-public?Name=John`)
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(page.url()).toEqual(
|
||||||
|
`http://localhost:3001/next/${typebotId}-public?Name=John`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Show close message', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
...parseDefaultGroupWithBlock({
|
||||||
|
type: InputBlockType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
isClosed: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text=This bot is now closed')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should correctly parse metadata', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
const customMetadata: Metadata = {
|
||||||
|
description: 'My custom description',
|
||||||
|
title: 'Custom title',
|
||||||
|
favIconUrl: 'https://www.baptistearno.com/favicon.png',
|
||||||
|
imageUrl: 'https://www.baptistearno.com/images/site-preview.png',
|
||||||
|
customHeadCode: '<meta name="author" content="John Doe">',
|
||||||
|
}
|
||||||
|
await createTypebots([
|
||||||
|
{
|
||||||
|
id: typebotId,
|
||||||
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
metadata: customMetadata,
|
||||||
|
},
|
||||||
|
...parseDefaultGroupWithBlock({
|
||||||
|
type: InputBlockType.TEXT,
|
||||||
|
options: defaultTextInputOptions,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(`document.querySelector('title').textContent`)
|
||||||
|
).toBe(customMetadata.title)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(document.querySelector('meta[name="description"]') as HTMLMetaElement)
|
||||||
|
.content
|
||||||
|
)
|
||||||
|
).toBe(customMetadata.description)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(document.querySelector('meta[property="og:image"]') as HTMLMetaElement)
|
||||||
|
.content
|
||||||
|
)
|
||||||
|
).toBe(customMetadata.imageUrl)
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(
|
||||||
|
document.querySelector('link[rel="icon"]') as HTMLLinkElement
|
||||||
|
).getAttribute('href')
|
||||||
|
)
|
||||||
|
).toBe(customMetadata.favIconUrl)
|
||||||
|
await expect(
|
||||||
|
page.locator(
|
||||||
|
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
|
||||||
|
)
|
||||||
|
).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
(document.querySelector('meta[name="author"]') as HTMLMetaElement)
|
||||||
|
.content
|
||||||
|
)
|
||||||
|
).toBe('John Doe')
|
||||||
|
})
|
||||||
1
apps/viewer/src/features/usage/index.ts
Normal file
1
apps/viewer/src/features/usage/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './utils'
|
||||||
70
apps/viewer/src/features/usage/usageV2.spec.ts
Normal file
70
apps/viewer/src/features/usage/usageV2.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { Plan } from 'db'
|
||||||
|
import { defaultSettings } from 'models'
|
||||||
|
import {
|
||||||
|
createWorkspaces,
|
||||||
|
importTypebotInDatabase,
|
||||||
|
injectFakeResults,
|
||||||
|
} from 'utils/playwright/databaseActions'
|
||||||
|
|
||||||
|
test('should not start if chat limit is reached', async ({ page, context }) => {
|
||||||
|
await test.step('Free plan', async () => {
|
||||||
|
const workspaceId = cuid()
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createWorkspaces([{ id: workspaceId, plan: Plan.FREE }])
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
await injectFakeResults({ typebotId, count: 400 })
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text="133%"')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step('Lifetime plan', async () => {
|
||||||
|
const workspaceId = cuid()
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createWorkspaces([{ id: workspaceId, plan: Plan.LIFETIME }])
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
await injectFakeResults({ typebotId, count: 3000 })
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step('Custom plan', async () => {
|
||||||
|
const workspaceId = cuid()
|
||||||
|
const typebotId = cuid()
|
||||||
|
await createWorkspaces([
|
||||||
|
{ id: workspaceId, plan: Plan.CUSTOM, customChatsLimit: 1000 },
|
||||||
|
])
|
||||||
|
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
|
||||||
|
id: typebotId,
|
||||||
|
publicId: `${typebotId}-public`,
|
||||||
|
workspaceId,
|
||||||
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
general: {
|
||||||
|
...defaultSettings.general,
|
||||||
|
isNewResultOnRefreshEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
|
||||||
|
await injectFakeResults({ typebotId, count: 2000 })
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
|
||||||
|
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
|
||||||
|
await expect(page.locator('text="200%"')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
130
apps/viewer/src/features/usage/utils/checkChatsUsage.ts
Normal file
130
apps/viewer/src/features/usage/utils/checkChatsUsage.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { WorkspaceRole } from 'db'
|
||||||
|
import {
|
||||||
|
sendAlmostReachedChatsLimitEmail,
|
||||||
|
sendReachedChatsLimitEmail,
|
||||||
|
} from 'emails'
|
||||||
|
import { env, getChatsLimit, isDefined } from 'utils'
|
||||||
|
|
||||||
|
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
|
||||||
|
|
||||||
|
export const checkChatsUsage = async (typebotId: string) => {
|
||||||
|
const typebot = await prisma.typebot.findUnique({
|
||||||
|
where: {
|
||||||
|
id: typebotId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plan: true,
|
||||||
|
additionalChatsIndex: true,
|
||||||
|
chatsLimitFirstEmailSentAt: true,
|
||||||
|
chatsLimitSecondEmailSentAt: true,
|
||||||
|
customChatsLimit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspace = typebot?.workspace
|
||||||
|
|
||||||
|
if (!workspace) return false
|
||||||
|
|
||||||
|
const chatsLimit = getChatsLimit(workspace)
|
||||||
|
if (chatsLimit === -1) return
|
||||||
|
const now = new Date()
|
||||||
|
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||||
|
const chatsCount = await prisma.$transaction(async (tx) => {
|
||||||
|
const typebotIds = await tx.typebot.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return tx.result.count({
|
||||||
|
where: {
|
||||||
|
typebotId: { in: typebotIds.map((typebot) => typebot.id) },
|
||||||
|
hasStarted: true,
|
||||||
|
createdAt: { gte: firstDayOfMonth, lte: firstDayOfNextMonth },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const hasSentFirstEmail =
|
||||||
|
workspace.chatsLimitFirstEmailSentAt !== null &&
|
||||||
|
workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
|
||||||
|
workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
|
||||||
|
const hasSentSecondEmail =
|
||||||
|
workspace.chatsLimitSecondEmailSentAt !== null &&
|
||||||
|
workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
|
||||||
|
workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
|
||||||
|
if (
|
||||||
|
chatsCount >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
|
||||||
|
!hasSentFirstEmail &&
|
||||||
|
env('E2E_TEST') !== 'true'
|
||||||
|
)
|
||||||
|
await sendAlmostReachChatsLimitNotification({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
chatsLimit,
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
chatsCount >= chatsLimit &&
|
||||||
|
!hasSentSecondEmail &&
|
||||||
|
env('E2E_TEST') !== 'true'
|
||||||
|
)
|
||||||
|
await sendReachedAlertNotification({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
chatsLimit,
|
||||||
|
})
|
||||||
|
return chatsCount >= chatsLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAlmostReachChatsLimitNotification = async ({
|
||||||
|
workspaceId,
|
||||||
|
chatsLimit,
|
||||||
|
}: {
|
||||||
|
workspaceId: string
|
||||||
|
chatsLimit: number
|
||||||
|
}) => {
|
||||||
|
const members = await prisma.memberInWorkspace.findMany({
|
||||||
|
where: { role: WorkspaceRole.ADMIN, workspaceId },
|
||||||
|
include: { user: { select: { email: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendAlmostReachedChatsLimitEmail({
|
||||||
|
to: members.map((member) => member.user.email).filter(isDefined),
|
||||||
|
chatsLimit,
|
||||||
|
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: { chatsLimitFirstEmailSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendReachedAlertNotification = async ({
|
||||||
|
workspaceId,
|
||||||
|
chatsLimit,
|
||||||
|
}: {
|
||||||
|
workspaceId: string
|
||||||
|
chatsLimit: number
|
||||||
|
}) => {
|
||||||
|
const members = await prisma.memberInWorkspace.findMany({
|
||||||
|
where: { role: WorkspaceRole.ADMIN, workspaceId },
|
||||||
|
include: { user: { select: { email: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await sendReachedChatsLimitEmail({
|
||||||
|
to: members.map((member) => member.user.email).filter(isDefined),
|
||||||
|
chatsLimit,
|
||||||
|
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.workspace.update({
|
||||||
|
where: { id: workspaceId },
|
||||||
|
data: { chatsLimitSecondEmailSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
1
apps/viewer/src/features/usage/utils/index.ts
Normal file
1
apps/viewer/src/features/usage/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './checkChatsUsage'
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import {
|
import {
|
||||||
SessionState,
|
SessionState,
|
||||||
|
StartParams,
|
||||||
|
Typebot,
|
||||||
Variable,
|
Variable,
|
||||||
VariableWithUnknowValue,
|
VariableWithUnknowValue,
|
||||||
VariableWithValue,
|
VariableWithValue,
|
||||||
@@ -99,6 +101,19 @@ export const parseVariablesInObject = (
|
|||||||
}
|
}
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
|
export const parsePrefilledVariables = (
|
||||||
|
variables: Typebot['variables'],
|
||||||
|
prefilledVariables: NonNullable<StartParams['prefilledVariables']>
|
||||||
|
): Variable[] =>
|
||||||
|
variables.map((variable) => {
|
||||||
|
const prefilledVariable = prefilledVariables[variable.name]
|
||||||
|
if (!prefilledVariable) return variable
|
||||||
|
return {
|
||||||
|
...variable,
|
||||||
|
value: safeStringify(prefilledVariable),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const updateVariables =
|
export const updateVariables =
|
||||||
(state: SessionState) =>
|
(state: SessionState) =>
|
||||||
async (newVariables: VariableWithUnknowValue[]): Promise<SessionState> => ({
|
async (newVariables: VariableWithUnknowValue[]): Promise<SessionState> => ({
|
||||||
@@ -107,10 +122,12 @@ export const updateVariables =
|
|||||||
...state.typebot,
|
...state.typebot,
|
||||||
variables: updateTypebotVariables(state)(newVariables),
|
variables: updateTypebotVariables(state)(newVariables),
|
||||||
},
|
},
|
||||||
result: {
|
result: state.result
|
||||||
|
? {
|
||||||
...state.result,
|
...state.result,
|
||||||
variables: await updateResultVariables(state)(newVariables),
|
variables: await updateResultVariables(state)(newVariables),
|
||||||
},
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateResultVariables =
|
const updateResultVariables =
|
||||||
@@ -118,6 +135,7 @@ const updateResultVariables =
|
|||||||
async (
|
async (
|
||||||
newVariables: VariableWithUnknowValue[]
|
newVariables: VariableWithUnknowValue[]
|
||||||
): Promise<VariableWithValue[]> => {
|
): Promise<VariableWithValue[]> => {
|
||||||
|
if (!result) return []
|
||||||
const serializedNewVariables = newVariables.map((variable) => ({
|
const serializedNewVariables = newVariables.map((variable) => ({
|
||||||
...variable,
|
...variable,
|
||||||
value: safeStringify(variable.value),
|
value: safeStringify(variable.value),
|
||||||
|
|||||||
21
apps/viewer/src/features/variables/variablesV2.spec.ts
Normal file
21
apps/viewer/src/features/variables/variablesV2.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getTestAsset } from '@/test/utils/playwright'
|
||||||
|
import test, { expect } from '@playwright/test'
|
||||||
|
import cuid from 'cuid'
|
||||||
|
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
|
||||||
|
|
||||||
|
test('should correctly be injected', async ({ page }) => {
|
||||||
|
const typebotId = cuid()
|
||||||
|
await importTypebotInDatabase(
|
||||||
|
getTestAsset('typebots/predefinedVariables.json'),
|
||||||
|
{ id: typebotId, publicId: `${typebotId}-public` }
|
||||||
|
)
|
||||||
|
await page.goto(`/next/${typebotId}-public`)
|
||||||
|
await expect(page.locator('text="Your name is"')).toBeVisible()
|
||||||
|
await page.goto(
|
||||||
|
`/next/${typebotId}-public?Name=Baptiste&Email=email@test.com`
|
||||||
|
)
|
||||||
|
await expect(page.locator('text="Your name is Baptiste"')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('Type your email...')).toHaveValue(
|
||||||
|
'email@test.com'
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
import { authenticateUser } from '@/features/auth/api'
|
import { authenticateUser } from '@/features/auth/api'
|
||||||
|
import { checkChatsUsage } from '@/features/usage'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
import { WorkspaceRole } from 'db'
|
|
||||||
import {
|
|
||||||
sendAlmostReachedChatsLimitEmail,
|
|
||||||
sendReachedChatsLimitEmail,
|
|
||||||
} from 'emails'
|
|
||||||
import { ResultWithAnswers } from 'models'
|
import { ResultWithAnswers } from 'models'
|
||||||
import { NextApiRequest, NextApiResponse } from 'next'
|
import { NextApiRequest, NextApiResponse } from 'next'
|
||||||
import { env, getChatsLimit, isDefined } from 'utils'
|
|
||||||
import { methodNotAllowed } from 'utils/api'
|
import { methodNotAllowed } from 'utils/api'
|
||||||
|
|
||||||
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
|
|
||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const user = await authenticateUser(req)
|
const user = await authenticateUser(req)
|
||||||
@@ -47,125 +40,4 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
methodNotAllowed(res)
|
methodNotAllowed(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkChatsUsage = async (typebotId: string) => {
|
|
||||||
const typebot = await prisma.typebot.findUnique({
|
|
||||||
where: {
|
|
||||||
id: typebotId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
workspace: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
plan: true,
|
|
||||||
additionalChatsIndex: true,
|
|
||||||
chatsLimitFirstEmailSentAt: true,
|
|
||||||
chatsLimitSecondEmailSentAt: true,
|
|
||||||
customChatsLimit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const workspace = typebot?.workspace
|
|
||||||
|
|
||||||
if (!workspace) return false
|
|
||||||
|
|
||||||
const chatsLimit = getChatsLimit(workspace)
|
|
||||||
if (chatsLimit === -1) return
|
|
||||||
const now = new Date()
|
|
||||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
||||||
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
|
||||||
const chatsCount = await prisma.$transaction(async (tx) => {
|
|
||||||
const typebotIds = await tx.typebot.findMany({
|
|
||||||
where: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
return tx.result.count({
|
|
||||||
where: {
|
|
||||||
typebotId: { in: typebotIds.map((typebot) => typebot.id) },
|
|
||||||
hasStarted: true,
|
|
||||||
createdAt: { gte: firstDayOfMonth, lte: firstDayOfNextMonth },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const hasSentFirstEmail =
|
|
||||||
workspace.chatsLimitFirstEmailSentAt !== null &&
|
|
||||||
workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
|
|
||||||
workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
|
|
||||||
const hasSentSecondEmail =
|
|
||||||
workspace.chatsLimitSecondEmailSentAt !== null &&
|
|
||||||
workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
|
|
||||||
workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
|
|
||||||
if (
|
|
||||||
chatsCount >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
|
|
||||||
!hasSentFirstEmail &&
|
|
||||||
env('E2E_TEST') !== 'true'
|
|
||||||
)
|
|
||||||
await sendAlmostReachChatsLimitNotification({
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
chatsLimit,
|
|
||||||
})
|
|
||||||
if (
|
|
||||||
chatsCount >= chatsLimit &&
|
|
||||||
!hasSentSecondEmail &&
|
|
||||||
env('E2E_TEST') !== 'true'
|
|
||||||
)
|
|
||||||
await sendReachedAlertNotification({
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
chatsLimit,
|
|
||||||
})
|
|
||||||
return chatsCount >= chatsLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendAlmostReachChatsLimitNotification = async ({
|
|
||||||
workspaceId,
|
|
||||||
chatsLimit,
|
|
||||||
}: {
|
|
||||||
workspaceId: string
|
|
||||||
chatsLimit: number
|
|
||||||
}) => {
|
|
||||||
const members = await prisma.memberInWorkspace.findMany({
|
|
||||||
where: { role: WorkspaceRole.ADMIN, workspaceId },
|
|
||||||
include: { user: { select: { email: true } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await sendAlmostReachedChatsLimitEmail({
|
|
||||||
to: members.map((member) => member.user.email).filter(isDefined),
|
|
||||||
chatsLimit,
|
|
||||||
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.workspace.update({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
data: { chatsLimitFirstEmailSentAt: new Date() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendReachedAlertNotification = async ({
|
|
||||||
workspaceId,
|
|
||||||
chatsLimit,
|
|
||||||
}: {
|
|
||||||
workspaceId: string
|
|
||||||
chatsLimit: number
|
|
||||||
}) => {
|
|
||||||
const members = await prisma.memberInWorkspace.findMany({
|
|
||||||
where: { role: WorkspaceRole.ADMIN, workspaceId },
|
|
||||||
include: { user: { select: { email: true } } },
|
|
||||||
})
|
|
||||||
|
|
||||||
await sendReachedChatsLimitEmail({
|
|
||||||
to: members.map((member) => member.user.email).filter(isDefined),
|
|
||||||
chatsLimit,
|
|
||||||
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
await prisma.workspace.update({
|
|
||||||
where: { id: workspaceId },
|
|
||||||
data: { chatsLimitSecondEmailSentAt: new Date() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default handler
|
export default handler
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
|
|||||||
|
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
await cors(req, res, {
|
await cors(req, res, {
|
||||||
origin: 'https://docs.typebot.io',
|
origin: ['https://docs.typebot.io', 'http://localhost:3005'],
|
||||||
})
|
})
|
||||||
|
|
||||||
return createOpenApiNextHandler({
|
return createOpenApiNextHandler({
|
||||||
|
|||||||
85
apps/viewer/src/pages/next/[[...publicId]].tsx
Normal file
85
apps/viewer/src/pages/next/[[...publicId]].tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { IncomingMessage } from 'http'
|
||||||
|
import { NotFoundPage } from '@/components/NotFoundPage'
|
||||||
|
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
|
||||||
|
import { env, getViewerUrl, isNotDefined } from 'utils'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { TypebotPageV2, TypebotPageV2Props } from '@/components/TypebotPageV2'
|
||||||
|
import { ErrorPage } from '@/components/ErrorPage'
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async (
|
||||||
|
context: GetServerSidePropsContext
|
||||||
|
) => {
|
||||||
|
const { host, forwardedHost } = getHost(context.req)
|
||||||
|
const pathname = context.resolvedUrl.split('?')[0]
|
||||||
|
try {
|
||||||
|
if (!host) return { props: {} }
|
||||||
|
const viewerUrls = (getViewerUrl({ returnAll: true }) ?? '').split(',')
|
||||||
|
const isMatchingViewerUrl =
|
||||||
|
env('E2E_TEST') === 'true'
|
||||||
|
? true
|
||||||
|
: viewerUrls.some(
|
||||||
|
(url) =>
|
||||||
|
host.split(':')[0].includes(url.split('//')[1].split(':')[0]) ||
|
||||||
|
(forwardedHost &&
|
||||||
|
forwardedHost
|
||||||
|
.split(':')[0]
|
||||||
|
.includes(url.split('//')[1].split(':')[0]))
|
||||||
|
)
|
||||||
|
const typebot = isMatchingViewerUrl
|
||||||
|
? await getTypebotFromPublicId(context.query.publicId?.toString())
|
||||||
|
: null
|
||||||
|
if (!typebot)
|
||||||
|
console.log(
|
||||||
|
isMatchingViewerUrl
|
||||||
|
? `Couldn't find publicId: ${context.query.publicId?.toString()}`
|
||||||
|
: `Couldn't find customDomain`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
typebot,
|
||||||
|
url: `https://${forwardedHost ?? host}${pathname}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
url: `https://${forwardedHost ?? host}${pathname}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypebotFromPublicId = async (
|
||||||
|
publicId?: string
|
||||||
|
): Promise<TypebotPageV2Props['typebot'] | null> => {
|
||||||
|
if (!publicId) return null
|
||||||
|
const typebot = (await prisma.typebot.findUnique({
|
||||||
|
where: { publicId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
theme: true,
|
||||||
|
name: true,
|
||||||
|
settings: true,
|
||||||
|
isArchived: true,
|
||||||
|
isClosed: true,
|
||||||
|
},
|
||||||
|
})) as TypebotPageV2Props['typebot'] | null
|
||||||
|
if (isNotDefined(typebot)) return null
|
||||||
|
return typebot
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHost = (
|
||||||
|
req?: IncomingMessage
|
||||||
|
): { host?: string; forwardedHost?: string } => ({
|
||||||
|
host: req?.headers ? req.headers.host : window.location.host,
|
||||||
|
forwardedHost: req?.headers['x-forwarded-host'] as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const App = ({ typebot, url }: TypebotPageV2Props) => {
|
||||||
|
if (!typebot || typebot.isArchived) return <NotFoundPage />
|
||||||
|
if (typebot.isClosed)
|
||||||
|
return <ErrorPage error={new Error('This bot is now closed')} />
|
||||||
|
return <TypebotPageV2 typebot={typebot} url={url} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
28
apps/viewer/src/queries/getInitialChatReplyQuery.ts
Normal file
28
apps/viewer/src/queries/getInitialChatReplyQuery.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { InitialChatReply, SendMessageInput } from 'models'
|
||||||
|
import { sendRequest } from 'utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
typebotId: string
|
||||||
|
resultId?: string
|
||||||
|
prefilledVariables?: Record<string, string>
|
||||||
|
}
|
||||||
|
export async function getInitialChatReplyQuery({
|
||||||
|
typebotId,
|
||||||
|
resultId,
|
||||||
|
prefilledVariables,
|
||||||
|
}: Props) {
|
||||||
|
if (!typebotId)
|
||||||
|
throw new Error('Typebot ID is required to get initial messages')
|
||||||
|
|
||||||
|
return sendRequest<InitialChatReply>({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/sendMessage`,
|
||||||
|
body: {
|
||||||
|
startParams: {
|
||||||
|
typebotId,
|
||||||
|
resultId,
|
||||||
|
prefilledVariables,
|
||||||
|
},
|
||||||
|
} satisfies SendMessageInput,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,37 +1,56 @@
|
|||||||
{
|
{
|
||||||
"id": "cl0ibhv8d0130n21aw8doxhj5",
|
"id": "clbovazhy000t3b6ok2deqnsw",
|
||||||
"createdAt": "2022-03-08T15:59:06.589Z",
|
"createdAt": "2022-12-15T09:17:00.598Z",
|
||||||
"updatedAt": "2022-03-08T15:59:10.498Z",
|
"updatedAt": "2022-12-15T09:17:15.366Z",
|
||||||
"name": "Another typebot",
|
"icon": null,
|
||||||
|
"name": "Another typebot copy",
|
||||||
"publishedTypebotId": null,
|
"publishedTypebotId": null,
|
||||||
"folderId": null,
|
"folderId": null,
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "p4ByLVoKiDRyRoPHKmcTfw",
|
"id": "clbovazhy000q3b6o716dlfq8",
|
||||||
|
"title": "Start",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "rw6smEWEJzHKbiVKLUKFvZ",
|
"id": "rw6smEWEJzHKbiVKLUKFvZ",
|
||||||
"type": "start",
|
"type": "start",
|
||||||
"label": "Start",
|
"label": "Start",
|
||||||
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
|
"groupId": "clbovazhy000q3b6o716dlfq8",
|
||||||
"outgoingEdgeId": "1z3pfiatTUHbraD2uSoA3E"
|
"outgoingEdgeId": "clbovazhy000s3b6ocjqtqedw"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Start",
|
|
||||||
"graphCoordinates": { "x": 0, "y": 0 }
|
"graphCoordinates": { "x": 0, "y": 0 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bg4QEJseUsTP496H27j5k2",
|
"id": "clbovazhy000r3b6onjzopbsn",
|
||||||
"graphCoordinates": { "x": 366, "y": 191 },
|
|
||||||
"title": "Group #1",
|
"title": "Group #1",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "s8ZeBL9p5za77eBmdKECLYq",
|
"id": "s8ZeBL9p5za77eBmdKECLYq",
|
||||||
"groupId": "bg4QEJseUsTP496H27j5k2",
|
|
||||||
"type": "text input",
|
"type": "text input",
|
||||||
|
"groupId": "clbovazhy000r3b6onjzopbsn",
|
||||||
"options": {
|
"options": {
|
||||||
"isLong": false,
|
"isLong": false,
|
||||||
"labels": { "button": "Send", "placeholder": "Type your answer..." }
|
"labels": { "button": "Send", "placeholder": "Type your answer..." }
|
||||||
|
},
|
||||||
|
"outgoingEdgeId": "clbovbaw600123b6o3i4ouzoq"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"graphCoordinates": { "x": 366, "y": 191 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clbovb3vu00103b6o1pjjuagi",
|
||||||
|
"graphCoordinates": { "x": 740, "y": 288 },
|
||||||
|
"title": "Group #2",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "clbovb3vv00113b6oaa35zfvm",
|
||||||
|
"groupId": "clbovb3vu00103b6o1pjjuagi",
|
||||||
|
"type": "text",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>Cheers!</div>",
|
||||||
|
"richText": [{ "type": "p", "children": [{ "text": "Cheers!" }] }],
|
||||||
|
"plainText": "Cheers!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -40,12 +59,20 @@
|
|||||||
"variables": [],
|
"variables": [],
|
||||||
"edges": [
|
"edges": [
|
||||||
{
|
{
|
||||||
|
"id": "clbovazhy000s3b6ocjqtqedw",
|
||||||
|
"to": { "groupId": "clbovazhy000r3b6onjzopbsn" },
|
||||||
"from": {
|
"from": {
|
||||||
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
|
"blockId": "rw6smEWEJzHKbiVKLUKFvZ",
|
||||||
"blockId": "rw6smEWEJzHKbiVKLUKFvZ"
|
"groupId": "clbovazhy000q3b6o716dlfq8"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"to": { "groupId": "bg4QEJseUsTP496H27j5k2" },
|
{
|
||||||
"id": "1z3pfiatTUHbraD2uSoA3E"
|
"from": {
|
||||||
|
"groupId": "clbovazhy000r3b6onjzopbsn",
|
||||||
|
"blockId": "s8ZeBL9p5za77eBmdKECLYq"
|
||||||
|
},
|
||||||
|
"to": { "groupId": "clbovb3vu00103b6o1pjjuagi" },
|
||||||
|
"id": "clbovbaw600123b6o3i4ouzoq"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -56,6 +83,10 @@
|
|||||||
"placeholderColor": "#9095A0"
|
"placeholderColor": "#9095A0"
|
||||||
},
|
},
|
||||||
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
|
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
|
||||||
|
"hostAvatar": {
|
||||||
|
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
|
||||||
|
"isEnabled": true
|
||||||
|
},
|
||||||
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
|
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
|
||||||
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
|
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
|
||||||
},
|
},
|
||||||
@@ -72,5 +103,9 @@
|
|||||||
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||||
},
|
},
|
||||||
"publicId": null,
|
"publicId": null,
|
||||||
"customDomain": null
|
"customDomain": null,
|
||||||
|
"workspaceId": "proWorkspace",
|
||||||
|
"resultsTablePreferences": null,
|
||||||
|
"isArchived": false,
|
||||||
|
"isClosed": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,85 @@
|
|||||||
{
|
{
|
||||||
"id": "cl1rxxg6l334509lhv44f8qnx",
|
"id": "clbnrow4e000h3b6o4gu6q0eo",
|
||||||
"createdAt": "2022-04-09T14:16:43.053Z",
|
"createdAt": "2022-12-14T14:48:04.766Z",
|
||||||
"updatedAt": "2022-04-12T14:34:44.287Z",
|
"updatedAt": "2022-12-14T14:48:19.086Z",
|
||||||
"icon": null,
|
"icon": null,
|
||||||
"name": "My typebot",
|
"name": "My typebot copy",
|
||||||
"publishedTypebotId": null,
|
"publishedTypebotId": null,
|
||||||
"folderId": null,
|
"folderId": null,
|
||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"id": "cl1rxxg6k000009lhd0mgfy5i",
|
"id": "clbnrow4e000c3b6oycsv9cu3",
|
||||||
|
"title": "Start",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "cl1rxxg6k000109lh2is0gfua",
|
"id": "cl1rxxg6k000109lh2is0gfua",
|
||||||
"type": "start",
|
"type": "start",
|
||||||
"label": "Start",
|
"label": "Start",
|
||||||
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
|
"groupId": "clbnrow4e000c3b6oycsv9cu3",
|
||||||
"outgoingEdgeId": "cl1w8rhzs000f2e694836a1k3"
|
"outgoingEdgeId": "clbnrow4e000f3b6ofulsqfj9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Start",
|
|
||||||
"graphCoordinates": { "x": 0, "y": 0 }
|
"graphCoordinates": { "x": 0, "y": 0 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cl1w8repd000b2e69fwiqsd00",
|
"id": "clbnrow4e000d3b6o7ma9ikmt",
|
||||||
"graphCoordinates": { "x": 364, "y": -2 },
|
|
||||||
"title": "Group #1",
|
"title": "Group #1",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "cl1w8repg000c2e699jqwrepg",
|
"id": "cl1w8repg000c2e699jqwrepg",
|
||||||
"groupId": "cl1w8repd000b2e69fwiqsd00",
|
|
||||||
"type": "choice input",
|
"type": "choice input",
|
||||||
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
|
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": "cl1w8repg000d2e69d8xnkqeq",
|
"id": "cl1w8repg000d2e69d8xnkqeq",
|
||||||
"blockId": "cl1w8repg000c2e699jqwrepg",
|
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
"blockId": "cl1w8repg000c2e699jqwrepg",
|
||||||
"content": "Send email",
|
"content": "Send email",
|
||||||
"outgoingEdgeId": "cl1w8rkoo000i2e69hs60pk0q"
|
"outgoingEdgeId": "clbnrow4e000g3b6oo62hh39h"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"groupId": "clbnrow4e000d3b6o7ma9ikmt",
|
||||||
|
"options": { "buttonLabel": "Send", "isMultipleChoice": false }
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"graphCoordinates": { "x": 364, "y": -2 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cl1w8rjaf000g2e69cqd2bwvk",
|
"id": "clbnrow4e000e3b6ohe6yxtj6",
|
||||||
"graphCoordinates": { "x": 715, "y": -10 },
|
|
||||||
"title": "Group #2",
|
"title": "Group #2",
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{
|
{
|
||||||
"id": "cl1w8rjai000h2e695uvoimq7",
|
"id": "cl1w8rjai000h2e695uvoimq7",
|
||||||
"groupId": "cl1w8rjaf000g2e69cqd2bwvk",
|
|
||||||
"type": "Email",
|
"type": "Email",
|
||||||
|
"groupId": "clbnrow4e000e3b6ohe6yxtj6",
|
||||||
"options": {
|
"options": {
|
||||||
"credentialsId": "send-email-credentials",
|
|
||||||
"recipients": ["baptiste.arnaud95@gmail.com"],
|
|
||||||
"replyTo": "contact@baptiste-arnaud.fr",
|
|
||||||
"cc": ["test1@gmail.com", "test2@gmail.com"],
|
"cc": ["test1@gmail.com", "test2@gmail.com"],
|
||||||
"bcc": ["test3@gmail.com", "test4@gmail.com"],
|
"bcc": ["test3@gmail.com", "test4@gmail.com"],
|
||||||
|
"body": "Test email",
|
||||||
|
"replyTo": "contact@baptiste-arnaud.fr",
|
||||||
"subject": "Hey!",
|
"subject": "Hey!",
|
||||||
"body": "Test email"
|
"recipients": ["baptiste.arnaud95@gmail.com"],
|
||||||
|
"credentialsId": "send-email-credentials"
|
||||||
|
},
|
||||||
|
"outgoingEdgeId": "clbnrp5wn000q3b6o5k21zfvh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"graphCoordinates": { "x": 715, "y": -10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clbnrp1kt000o3b6o2bh5ny0r",
|
||||||
|
"graphCoordinates": { "x": 1052.88671875, "y": -20.20703125 },
|
||||||
|
"title": "Group #3",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "clbnrp1ku000p3b6ouq1uit3r",
|
||||||
|
"groupId": "clbnrp1kt000o3b6o2bh5ny0r",
|
||||||
|
"type": "text",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>Email sent!</div>",
|
||||||
|
"richText": [
|
||||||
|
{ "type": "p", "children": [{ "text": "Email sent!" }] }
|
||||||
|
],
|
||||||
|
"plainText": "Email sent!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -71,21 +91,29 @@
|
|||||||
],
|
],
|
||||||
"edges": [
|
"edges": [
|
||||||
{
|
{
|
||||||
|
"id": "clbnrow4e000f3b6ofulsqfj9",
|
||||||
|
"to": { "groupId": "clbnrow4e000d3b6o7ma9ikmt" },
|
||||||
"from": {
|
"from": {
|
||||||
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
|
"blockId": "cl1rxxg6k000109lh2is0gfua",
|
||||||
"blockId": "cl1rxxg6k000109lh2is0gfua"
|
"groupId": "clbnrow4e000c3b6oycsv9cu3"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"to": { "groupId": "cl1w8repd000b2e69fwiqsd00" },
|
{
|
||||||
"id": "cl1w8rhzs000f2e694836a1k3"
|
"id": "clbnrow4e000g3b6oo62hh39h",
|
||||||
|
"to": { "groupId": "clbnrow4e000e3b6ohe6yxtj6" },
|
||||||
|
"from": {
|
||||||
|
"itemId": "cl1w8repg000d2e69d8xnkqeq",
|
||||||
|
"blockId": "cl1w8repg000c2e699jqwrepg",
|
||||||
|
"groupId": "clbnrow4e000d3b6o7ma9ikmt"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": {
|
"from": {
|
||||||
"groupId": "cl1w8repd000b2e69fwiqsd00",
|
"groupId": "clbnrow4e000e3b6ohe6yxtj6",
|
||||||
"blockId": "cl1w8repg000c2e699jqwrepg",
|
"blockId": "cl1w8rjai000h2e695uvoimq7"
|
||||||
"itemId": "cl1w8repg000d2e69d8xnkqeq"
|
|
||||||
},
|
},
|
||||||
"to": { "groupId": "cl1w8rjaf000g2e69cqd2bwvk" },
|
"to": { "groupId": "clbnrp1kt000o3b6o2bh5ny0r" },
|
||||||
"id": "cl1w8rkoo000i2e69hs60pk0q"
|
"id": "clbnrp5wn000q3b6o5k21zfvh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"theme": {
|
"theme": {
|
||||||
@@ -117,5 +145,9 @@
|
|||||||
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||||
},
|
},
|
||||||
"publicId": null,
|
"publicId": null,
|
||||||
"customDomain": null
|
"customDomain": null,
|
||||||
|
"workspaceId": "proWorkspace",
|
||||||
|
"resultsTablePreferences": null,
|
||||||
|
"isArchived": false,
|
||||||
|
"isClosed": false
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/viewer/src/utils/sessionStorage.ts
Normal file
17
apps/viewer/src/utils/sessionStorage.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const sessionStorageKey = 'resultId'
|
||||||
|
|
||||||
|
export const getExistingResultFromSession = () => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(sessionStorageKey)
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setResultInSession = (resultId: string) => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.setItem(sessionStorageKey, resultId)
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm tsc --noEmit && tsup",
|
"build": "pnpm tsc --noEmit && tsup",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"lint": "eslint \"src/**/*.ts*\""
|
"lint": "eslint --fix \"src/**/*.ts*\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stripe/react-stripe-js": "1.16.1",
|
"@stripe/react-stripe-js": "1.16.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
||||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||||
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
|
import { LinkedTypebot, useTypebot } from '../../providers/TypebotProvider'
|
||||||
@@ -250,7 +250,7 @@ const ChatChunks = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isSkipped, setIsSkipped] = useState(false)
|
const [isSkipped, setIsSkipped] = useState(false)
|
||||||
|
|
||||||
const avatarSideContainerRef = useRef<any>()
|
const avatarSideContainerRef = useRef<{ refreshTopOffset: () => void }>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshTopOffset()
|
refreshTopOffset()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { CSSProperties, useMemo } from 'react'
|
import { CSSProperties, useMemo } from 'react'
|
||||||
import { TypebotProvider } from '../providers/TypebotProvider'
|
import { TypebotProvider } from '../providers/TypebotProvider'
|
||||||
import Frame from 'react-frame-component'
|
import Frame from 'react-frame-component'
|
||||||
import styles from '../assets/style.css'
|
import styles from '../assets/style.css'
|
||||||
@@ -7,7 +7,6 @@ import phoneSyle from '../assets/phone.css'
|
|||||||
import { ConversationContainer } from './ConversationContainer'
|
import { ConversationContainer } from './ConversationContainer'
|
||||||
import { AnswersProvider } from '../providers/AnswersProvider'
|
import { AnswersProvider } from '../providers/AnswersProvider'
|
||||||
import {
|
import {
|
||||||
Answer,
|
|
||||||
AnswerInput,
|
AnswerInput,
|
||||||
BackgroundType,
|
BackgroundType,
|
||||||
Edge,
|
Edge,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const VideoContent = ({
|
|||||||
)
|
)
|
||||||
if (!content?.type) return <></>
|
if (!content?.type) return <></>
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
case VideoBubbleContentType.URL:
|
case VideoBubbleContentType.URL: {
|
||||||
const isSafariBrowser = window.navigator.vendor.match(/apple/i)
|
const isSafariBrowser = window.navigator.vendor.match(/apple/i)
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@@ -102,6 +102,7 @@ const VideoContent = ({
|
|||||||
Sorry, your browser doesn't support embedded videos.
|
Sorry, your browser doesn't support embedded videos.
|
||||||
</video>
|
</video>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
case VideoBubbleContentType.VIMEO:
|
case VideoBubbleContentType.VIMEO:
|
||||||
case VideoBubbleContentType.YOUTUBE: {
|
case VideoBubbleContentType.YOUTUBE: {
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const emailRegex =
|
const emailRegex =
|
||||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
|
|
||||||
export const validateEmail = (email: string) => emailRegex.test(email)
|
export const validateEmail = (email: string) => emailRegex.test(email)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const PhoneInput = ({
|
|||||||
hasGuestAvatar,
|
hasGuestAvatar,
|
||||||
}: PhoneInputProps) => {
|
}: PhoneInputProps) => {
|
||||||
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
const [inputValue, setInputValue] = useState(defaultValue ?? '')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const inputRef = useRef<any>(null)
|
const inputRef = useRef<any>(null)
|
||||||
|
|
||||||
const handleChange = (inputValue: Value | undefined) =>
|
const handleChange = (inputValue: Value | undefined) =>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LinkedTypebot } from '@/providers/TypebotProvider'
|
import { LinkedTypebot } from '@/providers/TypebotProvider'
|
||||||
import { EdgeId, LogicState } from '@/types'
|
import { EdgeId, LogicState } from '@/types'
|
||||||
import { TypebotLinkBlock, Edge, PublicTypebot } from 'models'
|
import { TypebotLinkBlock, Edge, PublicTypebot } from 'models'
|
||||||
import { byId } from 'utils'
|
|
||||||
import { fetchAndInjectTypebot } from '../queries/fetchAndInjectTypebotQuery'
|
import { fetchAndInjectTypebot } from '../queries/fetchAndInjectTypebotQuery'
|
||||||
|
|
||||||
export const executeTypebotLink = async (
|
export const executeTypebotLink = async (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const answersContext = createContext<{
|
|||||||
answer: AnswerInput & { uploadedFiles: boolean }
|
answer: AnswerInput & { uploadedFiles: boolean }
|
||||||
) => Promise<void> | undefined
|
) => Promise<void> | undefined
|
||||||
updateVariables: (variables: VariableWithUnknowValue[]) => void
|
updateVariables: (variables: VariableWithUnknowValue[]) => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, ReactNode, useContext } from 'react'
|
|||||||
|
|
||||||
const chatContext = createContext<{
|
const chatContext = createContext<{
|
||||||
scroll: () => void
|
scroll: () => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const typebotContext = createContext<{
|
|||||||
edgeId: string
|
edgeId: string
|
||||||
}) => void
|
}) => void
|
||||||
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['next', 'prettier'],
|
extends: [
|
||||||
|
'next',
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'prettier',
|
||||||
|
],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
version: 'detect',
|
version: 'detect',
|
||||||
@@ -19,5 +26,6 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'@typescript-eslint/no-namespace': 'off',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"eslint-config-next": "13.0.7",
|
"eslint-config-next": "13.0.7",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-plugin-react": "7.31.11"
|
"eslint-plugin-react": "7.31.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.46.0",
|
||||||
|
"@typescript-eslint/parser": "^5.46.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/js/.eslintignore
Normal file
1
packages/js/.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/src/react/**
|
||||||
10
packages/js/.eslintrc.js
Normal file
10
packages/js/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ['custom', 'plugin:solid/typescript'],
|
||||||
|
plugins: ['solid'],
|
||||||
|
rules: {
|
||||||
|
'@next/next/no-img-element': 'off',
|
||||||
|
'@next/next/no-html-link-for-pages': 'off',
|
||||||
|
'solid/no-innerhtml': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
2
packages/js/.gitignore
vendored
Normal file
2
packages/js/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
16
packages/js/index.html
Normal file
16
packages/js/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
|
||||||
|
<title>Solid App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="/src/demo/index.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
packages/js/package.json
Normal file
50
packages/js/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@typebot.io/js",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "dist/index.mjs",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start:demo": "vite",
|
||||||
|
"dev:demo": "vite",
|
||||||
|
"dev": "rollup --watch --config rollup.config.mjs",
|
||||||
|
"build": "rollup --config rollup.config.mjs",
|
||||||
|
"lint": "eslint --fix \"src/**/*.ts*\""
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@power-elements/stripe-elements": "^3.3.0",
|
||||||
|
"@stripe/stripe-js": "1.46.0",
|
||||||
|
"models": "workspace:*",
|
||||||
|
"phone": "^3.1.31",
|
||||||
|
"solid-element": "^1.6.3",
|
||||||
|
"solid-js": "^1.6.5",
|
||||||
|
"utils": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-babel": "^6.0.3",
|
||||||
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
|
"@rollup/plugin-replace": "^5.0.1",
|
||||||
|
"@rollup/plugin-typescript": "^10.0.1",
|
||||||
|
"@types/react": "^18.0.26",
|
||||||
|
"autoprefixer": "10.4.13",
|
||||||
|
"babel-preset-solid": "^1.6.3",
|
||||||
|
"eslint": "8.29.0",
|
||||||
|
"eslint-config-custom": "workspace:*",
|
||||||
|
"eslint-plugin-solid": "^0.9.1",
|
||||||
|
"postcss": "8.4.20",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"rollup": "^3.7.4",
|
||||||
|
"rollup-plugin-babel": "^4.4.0",
|
||||||
|
"rollup-plugin-dts": "^5.0.0",
|
||||||
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
|
"rollup-plugin-terser": "^7.0.2",
|
||||||
|
"rollup-plugin-typescript-paths": "^1.4.0",
|
||||||
|
"tailwindcss": "3.2.4",
|
||||||
|
"tsconfig": "workspace:*",
|
||||||
|
"tsup": "6.5.0",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^4.0.1",
|
||||||
|
"vite-plugin-solid": "^2.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/js/rollup.config.mjs
Normal file
56
packages/js/rollup.config.mjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import resolve from '@rollup/plugin-node-resolve'
|
||||||
|
import replace from '@rollup/plugin-replace'
|
||||||
|
import { terser } from 'rollup-plugin-terser'
|
||||||
|
import { babel } from '@rollup/plugin-babel'
|
||||||
|
import postcss from 'rollup-plugin-postcss'
|
||||||
|
import autoprefixer from 'autoprefixer'
|
||||||
|
import tailwindcss from 'tailwindcss'
|
||||||
|
import { typescriptPaths } from 'rollup-plugin-typescript-paths'
|
||||||
|
import dts from 'rollup-plugin-dts'
|
||||||
|
import typescript from '@rollup/plugin-typescript'
|
||||||
|
|
||||||
|
const extensions = ['.ts', '.tsx']
|
||||||
|
|
||||||
|
const webComponentsConfig = {
|
||||||
|
input: './src/index.ts',
|
||||||
|
output: {
|
||||||
|
file: 'dist/index.mjs',
|
||||||
|
format: 'es',
|
||||||
|
},
|
||||||
|
external: ['models', 'utils', 'react'],
|
||||||
|
plugins: [
|
||||||
|
replace({
|
||||||
|
preventAssignment: true,
|
||||||
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
|
}),
|
||||||
|
resolve({ extensions }),
|
||||||
|
babel({
|
||||||
|
babelHelpers: 'bundled',
|
||||||
|
exclude: 'node_modules/**',
|
||||||
|
presets: ['solid', '@babel/preset-typescript'],
|
||||||
|
extensions,
|
||||||
|
}),
|
||||||
|
postcss({
|
||||||
|
plugins: [autoprefixer(), tailwindcss()],
|
||||||
|
extract: false,
|
||||||
|
modules: false,
|
||||||
|
autoModules: false,
|
||||||
|
minimize: true,
|
||||||
|
inject: false,
|
||||||
|
}),
|
||||||
|
typescript(),
|
||||||
|
typescriptPaths({ preserveExtensions: true }),
|
||||||
|
terser({ output: { comments: false } }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = [
|
||||||
|
webComponentsConfig,
|
||||||
|
{
|
||||||
|
input: './dist/dts/index.d.ts',
|
||||||
|
output: [{ file: 'dist/index.d.ts', format: 'es' }],
|
||||||
|
plugins: [dts()],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default config
|
||||||
BIN
packages/js/src/assets/favicon.ico
Normal file
BIN
packages/js/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user