diff --git a/apps/builder/src/components/icons.tsx b/apps/builder/src/components/icons.tsx
index 1e96c5043c..4422fc2be4 100644
--- a/apps/builder/src/components/icons.tsx
+++ b/apps/builder/src/components/icons.tsx
@@ -594,3 +594,13 @@ export const TableIcon = (props: IconProps) => (
)
+
+export const ShuffleIcon = (props: IconProps) => (
+
+
+
+
+
+
+
+)
diff --git a/apps/builder/src/components/inputs/NumberInput.tsx b/apps/builder/src/components/inputs/NumberInput.tsx
index 2b26d91a64..03c12d7119 100644
--- a/apps/builder/src/components/inputs/NumberInput.tsx
+++ b/apps/builder/src/components/inputs/NumberInput.tsx
@@ -9,6 +9,7 @@ import {
HStack,
FormControl,
FormLabel,
+ Stack,
} from '@chakra-ui/react'
import { Variable, VariableString } from '@typebot.io/schemas'
import { useEffect, useState } from 'react'
@@ -27,6 +28,7 @@ type Props = {
label?: string
moreInfoTooltip?: string
isRequired?: boolean
+ direction?: 'row' | 'column'
onValueChange: (value?: Value) => void
} & Omit
@@ -38,6 +40,7 @@ export const NumberInput = ({
label,
moreInfoTooltip,
isRequired,
+ direction,
...props
}: Props) => {
const [value, setValue] = useState(defaultValue?.toString() ?? '')
@@ -90,7 +93,7 @@ export const NumberInput = ({
return (
{
const handleInputSubmit = () => {
if (itemValue === '') deleteItem(indices)
else
- updateItem(indices, { content: itemValue === '' ? undefined : itemValue })
+ updateItem(indices, {
+ content: itemValue === '' ? undefined : itemValue,
+ } as Item)
}
const handleKeyPress = (e: React.KeyboardEvent) => {
diff --git a/apps/builder/src/features/blocks/logic/abTest/abTest.spec.ts b/apps/builder/src/features/blocks/logic/abTest/abTest.spec.ts
new file mode 100644
index 0000000000..feb4e83513
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/abTest/abTest.spec.ts
@@ -0,0 +1,24 @@
+import test, { expect } from '@playwright/test'
+import { importTypebotInDatabase } from '@typebot.io/lib/playwright/databaseActions'
+import { createId } from '@paralleldrive/cuid2'
+import { getTestAsset } from '@/test/utils/playwright'
+
+const typebotId = createId()
+
+test.describe('AB Test block', () => {
+ test('its configuration should work', async ({ page }) => {
+ await importTypebotInDatabase(getTestAsset('typebots/logic/abTest.json'), {
+ id: typebotId,
+ })
+
+ await page.goto(`/typebots/${typebotId}/edit`)
+ await page.getByText('A 50%').click()
+ await page.getByLabel('Percent of users to follow A:').fill('100')
+ await expect(page.getByText('A 100%')).toBeVisible()
+ await expect(page.getByText('B 0%')).toBeVisible()
+ await page.getByRole('button', { name: 'Preview' }).click()
+ await expect(
+ page.locator('typebot-standard').getByText('How are you?')
+ ).toBeVisible()
+ })
+})
diff --git a/apps/builder/src/features/blocks/logic/abTest/components/AbTestIcon.tsx b/apps/builder/src/features/blocks/logic/abTest/components/AbTestIcon.tsx
new file mode 100644
index 0000000000..b192065621
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/abTest/components/AbTestIcon.tsx
@@ -0,0 +1,7 @@
+import { ShuffleIcon } from '@/components/icons'
+import { IconProps } from '@chakra-ui/react'
+import React from 'react'
+
+export const AbTestIcon = (props: IconProps) => (
+
+)
diff --git a/apps/builder/src/features/blocks/logic/abTest/components/AbTestNodeBody.tsx b/apps/builder/src/features/blocks/logic/abTest/components/AbTestNodeBody.tsx
new file mode 100644
index 0000000000..a680a5d5c3
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/abTest/components/AbTestNodeBody.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import { Flex, Stack, useColorModeValue, Text, Tag } from '@chakra-ui/react'
+import { AbTestBlock } from '@typebot.io/schemas'
+import { SourceEndpoint } from '@/features/graph/components/endpoints/SourceEndpoint'
+
+type Props = {
+ block: AbTestBlock
+}
+
+export const AbTestNodeBody = ({ block }: Props) => {
+ const borderColor = useColorModeValue('gray.200', 'gray.700')
+ const bg = useColorModeValue('white', undefined)
+
+ return (
+
+
+
+ A {block.options.aPercent}%
+
+
+
+
+
+ B {100 - block.options.aPercent}%
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/blocks/logic/abTest/components/AbTestSettings.tsx b/apps/builder/src/features/blocks/logic/abTest/components/AbTestSettings.tsx
new file mode 100644
index 0000000000..2b55486a55
--- /dev/null
+++ b/apps/builder/src/features/blocks/logic/abTest/components/AbTestSettings.tsx
@@ -0,0 +1,29 @@
+import { Stack } from '@chakra-ui/react'
+import React from 'react'
+import { isDefined } from '@typebot.io/lib'
+import { AbTestBlock } from '@typebot.io/schemas'
+import { NumberInput } from '@/components/inputs'
+
+type Props = {
+ options: AbTestBlock['options']
+ onOptionsChange: (options: AbTestBlock['options']) => void
+}
+
+export const AbTestSettings = ({ options, onOptionsChange }: Props) => {
+ const updateAPercent = (aPercent?: number) =>
+ isDefined(aPercent) ? onOptionsChange({ ...options, aPercent }) : null
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/editor/components/BlockIcon.tsx b/apps/builder/src/features/editor/components/BlockIcon.tsx
index 39cfd8a5aa..c66c2effe2 100644
--- a/apps/builder/src/features/editor/components/BlockIcon.tsx
+++ b/apps/builder/src/features/editor/components/BlockIcon.tsx
@@ -37,6 +37,7 @@ import { ConditionIcon } from '@/features/blocks/logic/condition/components/Cond
import { RedirectIcon } from '@/features/blocks/logic/redirect/components/RedirectIcon'
import { SetVariableIcon } from '@/features/blocks/logic/setVariable/components/SetVariableIcon'
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink/components/TypebotLinkIcon'
+import { AbTestIcon } from '@/features/blocks/logic/abTest/components/AbTestIcon'
type BlockIconProps = { type: BlockType } & IconProps
@@ -91,6 +92,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return
case LogicBlockType.TYPEBOT_LINK:
return
+ case LogicBlockType.AB_TEST:
+ return
case IntegrationBlockType.GOOGLE_SHEETS:
return
case IntegrationBlockType.GOOGLE_ANALYTICS:
diff --git a/apps/builder/src/features/editor/components/BlockLabel.tsx b/apps/builder/src/features/editor/components/BlockLabel.tsx
index 45be8c065c..d8893219f7 100644
--- a/apps/builder/src/features/editor/components/BlockLabel.tsx
+++ b/apps/builder/src/features/editor/components/BlockLabel.tsx
@@ -57,6 +57,8 @@ export const BlockLabel = ({ type }: Props): JSX.Element => {
return Wait
case LogicBlockType.JUMP:
return Jump
+ case LogicBlockType.AB_TEST:
+ return AB Test
case IntegrationBlockType.GOOGLE_SHEETS:
return Sheets
case IntegrationBlockType.GOOGLE_ANALYTICS:
diff --git a/apps/builder/src/features/editor/providers/typebotActions/items.ts b/apps/builder/src/features/editor/providers/typebotActions/items.ts
index 03029ea312..a991f152d8 100644
--- a/apps/builder/src/features/editor/providers/typebotActions/items.ts
+++ b/apps/builder/src/features/editor/providers/typebotActions/items.ts
@@ -7,6 +7,8 @@ import {
Block,
LogicBlockType,
InputBlockType,
+ ConditionItem,
+ ButtonItem,
} from '@typebot.io/schemas'
import { SetTypebot } from '../TypebotProvider'
import produce from 'immer'
@@ -15,7 +17,11 @@ import { byId, blockHasItems } from '@typebot.io/lib'
import { createId } from '@paralleldrive/cuid2'
import { WritableDraft } from 'immer/dist/types/types-external'
-type NewItem = Pick- & Partial
-
+type NewItem = Pick<
+ ConditionItem | ButtonItem,
+ 'blockId' | 'outgoingEdgeId' | 'type'
+> &
+ Partial
export type ItemsActions = {
createItem: (item: NewItem, indices: ItemIndices) => void
diff --git a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
index 7b4fbc6a1b..5530446940 100644
--- a/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
+++ b/apps/builder/src/features/graph/components/nodes/block/BlockNodeContent.tsx
@@ -39,6 +39,7 @@ import { TypebotLinkNode } from '@/features/blocks/logic/typebotLink/components/
import { ItemNodesList } from '../item/ItemNodesList'
import { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeBody'
import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody'
+import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
type Props = {
block: Block | StartBlock
@@ -142,6 +143,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case LogicBlockType.JUMP: {
return
}
+ case LogicBlockType.AB_TEST: {
+ return
+ }
case LogicBlockType.TYPEBOT_LINK:
return
case LogicBlockType.CONDITION:
diff --git a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
index d80d1c22be..cf8e366ff6 100644
--- a/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
+++ b/apps/builder/src/features/graph/components/nodes/block/SettingsPopoverContent.tsx
@@ -45,6 +45,7 @@ import { DateInputSettings } from '@/features/blocks/inputs/date/components/Date
import { PhoneInputSettings } from '@/features/blocks/inputs/phone/components/PhoneInputSettings'
import { GoogleSheetsSettings } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsSettings'
import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/components/ChatwootSettings'
+import { AbTestSettings } from '@/features/blocks/logic/abTest/components/AbTestSettings'
type Props = {
block: BlockWithOptions
@@ -229,6 +230,14 @@ export const BlockSettings = ({
/>
)
}
+ case LogicBlockType.AB_TEST: {
+ return (
+
+ )
+ }
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
void
connectionDisabled?: boolean
}
@@ -33,7 +39,7 @@ export const ItemNode = ({
}: Props) => {
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('gray.200', 'gray.700')
- const bg = useColorModeValue('white', undefined)
+ const bg = useColorModeValue('white', 'gray.850')
const { typebot } = useTypebot()
const { previewingEdge } = useGraph()
const [isMouseOver, setIsMouseOver] = useState(false)
@@ -48,7 +54,7 @@ export const ItemNode = ({
| undefined
)?.options?.isMultipleChoice
const onDrag = (position: NodePosition) => {
- if (!onMouseDown) return
+ if (!onMouseDown || item.type === ItemType.AB_TEST) return
onMouseDown(position, item)
}
useDragDistance({
diff --git a/apps/builder/src/features/graph/components/nodes/item/ItemNodeContent.tsx b/apps/builder/src/features/graph/components/nodes/item/ItemNodeContent.tsx
index 6001b9a872..0bdb81c35f 100644
--- a/apps/builder/src/features/graph/components/nodes/item/ItemNodeContent.tsx
+++ b/apps/builder/src/features/graph/components/nodes/item/ItemNodeContent.tsx
@@ -28,5 +28,7 @@ export const ItemNodeContent = ({ item, indices, isMouseOver }: Props) => {
indices={indices}
/>
)
+ case ItemType.AB_TEST:
+ return <>>
}
}
diff --git a/apps/builder/src/features/graph/components/nodes/item/ItemNodesList.tsx b/apps/builder/src/features/graph/components/nodes/item/ItemNodesList.tsx
index 83dd16f08b..70427670f6 100644
--- a/apps/builder/src/features/graph/components/nodes/item/ItemNodesList.tsx
+++ b/apps/builder/src/features/graph/components/nodes/item/ItemNodesList.tsx
@@ -11,7 +11,6 @@ import {
BlockIndices,
BlockWithItems,
LogicBlockType,
- Item,
} from '@typebot.io/schemas'
import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode'
@@ -20,6 +19,7 @@ import { isDefined } from '@typebot.io/lib'
import {
useBlockDnd,
computeNearestPlaceholderIndex,
+ DraggabbleItem,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities'
@@ -107,7 +107,7 @@ export const ItemNodesList = ({
(itemIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
- item: Item
+ item: DraggabbleItem
) => {
if (!typebot || block.items.length <= 1) return
placeholderRefs.current.splice(itemIndex + 1, 1)
diff --git a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
index f8ea9e8558..45480cc94f 100644
--- a/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
+++ b/apps/builder/src/features/graph/providers/GraphDndProvider.tsx
@@ -1,5 +1,10 @@
import { useEventListener } from '@chakra-ui/react'
-import { DraggableBlock, DraggableBlockType, Item } from '@typebot.io/schemas'
+import {
+ AbTestBlock,
+ DraggableBlock,
+ DraggableBlockType,
+ Item,
+} from '@typebot.io/schemas'
import {
createContext,
Dispatch,
@@ -18,13 +23,15 @@ type NodeElement = {
element: HTMLDivElement
}
+export type DraggabbleItem = Exclude
-
+
const graphDndContext = createContext<{
draggedBlockType?: DraggableBlockType
setDraggedBlockType: Dispatch>
draggedBlock?: DraggableBlock
setDraggedBlock: Dispatch>
- draggedItem?: Item
- setDraggedItem: Dispatch>
+ draggedItem?: DraggabbleItem
+ setDraggedItem: Dispatch>
mouseOverGroup?: NodeElement
setMouseOverGroup: (node: NodeElement | undefined) => void
mouseOverBlock?: NodeElement
@@ -40,7 +47,7 @@ export const GraphDndProvider = ({ children }: { children: ReactNode }) => {
const [draggedBlockType, setDraggedBlockType] = useState<
DraggableBlockType | undefined
>()
- const [draggedItem, setDraggedItem] = useState
- ()
+ const [draggedItem, setDraggedItem] = useState()
const [mouseOverGroup, _setMouseOverGroup] = useState()
const [mouseOverBlock, _setMouseOverBlock] = useState()
diff --git a/apps/builder/src/features/typebot/helpers/hasDefaultConnector.ts b/apps/builder/src/features/typebot/helpers/hasDefaultConnector.ts
index 2439eb1387..e029c506e5 100644
--- a/apps/builder/src/features/typebot/helpers/hasDefaultConnector.ts
+++ b/apps/builder/src/features/typebot/helpers/hasDefaultConnector.ts
@@ -1,7 +1,9 @@
import { isChoiceInput, isConditionBlock, isDefined } from '@typebot.io/lib'
-import { Block, InputBlockType } from '@typebot.io/schemas'
+import { Block, InputBlockType, LogicBlockType } from '@typebot.io/schemas'
export const hasDefaultConnector = (block: Block) =>
- (!isChoiceInput(block) && !isConditionBlock(block)) ||
+ (!isChoiceInput(block) &&
+ !isConditionBlock(block) &&
+ block.type !== LogicBlockType.AB_TEST) ||
(block.type === InputBlockType.CHOICE &&
isDefined(block.options.dynamicVariableId))
diff --git a/apps/builder/src/features/typebot/helpers/parseNewBlock.ts b/apps/builder/src/features/typebot/helpers/parseNewBlock.ts
index d5af13b1fe..a1921bc1f0 100644
--- a/apps/builder/src/features/typebot/helpers/parseNewBlock.ts
+++ b/apps/builder/src/features/typebot/helpers/parseNewBlock.ts
@@ -42,10 +42,14 @@ import {
Item,
ItemType,
LogicBlockType,
+ defaultAbTestOptions,
} from '@typebot.io/schemas'
const parseDefaultItems = (
- type: LogicBlockType.CONDITION | InputBlockType.CHOICE,
+ type:
+ | LogicBlockType.CONDITION
+ | InputBlockType.CHOICE
+ | LogicBlockType.AB_TEST,
blockId: string
): Item[] => {
switch (type) {
@@ -60,6 +64,11 @@ const parseDefaultItems = (
content: defaultConditionContent,
},
]
+ case LogicBlockType.AB_TEST:
+ return [
+ { id: createId(), blockId, type: ItemType.AB_TEST, path: 'a' },
+ { id: createId(), blockId, type: ItemType.AB_TEST, path: 'b' },
+ ]
}
}
@@ -112,6 +121,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
return {}
case LogicBlockType.TYPEBOT_LINK:
return {}
+ case LogicBlockType.AB_TEST:
+ return defaultAbTestOptions
case IntegrationBlockType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions
case IntegrationBlockType.GOOGLE_ANALYTICS:
diff --git a/apps/builder/src/test/assets/typebots/logic/abTest.json b/apps/builder/src/test/assets/typebots/logic/abTest.json
new file mode 100644
index 0000000000..fbc44710ff
--- /dev/null
+++ b/apps/builder/src/test/assets/typebots/logic/abTest.json
@@ -0,0 +1,164 @@
+{
+ "id": "clgkx6u4n00021ait8o4c1nvs",
+ "version": "3",
+ "createdAt": "2023-04-17T14:17:13.223Z",
+ "updatedAt": "2023-04-17T14:17:13.223Z",
+ "icon": null,
+ "name": "My typebot",
+ "folderId": null,
+ "groups": [
+ {
+ "id": "bb5ad8yx3j52fecxdnztlap5",
+ "title": "Start",
+ "blocks": [
+ {
+ "id": "q20ww00q5nnv4icmi3rhbtda",
+ "type": "start",
+ "label": "Start",
+ "groupId": "bb5ad8yx3j52fecxdnztlap5",
+ "outgoingEdgeId": "qr2ebzm62bcz21jh2wmv1odd"
+ }
+ ],
+ "graphCoordinates": { "x": 0, "y": 0 }
+ },
+ {
+ "id": "wv63xqdlwj6ub5vb3ulbmzd3",
+ "graphCoordinates": { "x": 310, "y": 206 },
+ "title": "Group #1",
+ "blocks": [
+ {
+ "id": "ice9jqqw4glrx5kblisez4lp",
+ "groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
+ "type": "text",
+ "content": {
+ "richText": [{ "type": "p", "children": [{ "text": "Hey" }] }]
+ }
+ },
+ {
+ "id": "rorajfbwc2evvumo837ulwmy",
+ "groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
+ "type": "AB test",
+ "options": { "aPercent": 50 },
+ "items": [
+ {
+ "id": "rcw6vr8xfcb5dmfx2ts04rb0",
+ "blockId": "rorajfbwc2evvumo837ulwmy",
+ "type": 2,
+ "path": "a",
+ "outgoingEdgeId": "lmof3n7qyjo5bkw9kaksgrzh"
+ },
+ {
+ "id": "i9shyze7fyd5wfxfq0svg2lg",
+ "blockId": "rorajfbwc2evvumo837ulwmy",
+ "type": 2,
+ "path": "b",
+ "outgoingEdgeId": "kvaqhcb1df8cmvekwtpd1irr"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "ybebe0sxg6zu307a4ruwhmb6",
+ "graphCoordinates": { "x": 705.96875, "y": 166.57421875 },
+ "title": "Group #2",
+ "blocks": [
+ {
+ "id": "jqqjruceaqwqvm74isgoxbez",
+ "groupId": "ybebe0sxg6zu307a4ruwhmb6",
+ "type": "text",
+ "content": {
+ "richText": [
+ { "type": "p", "children": [{ "text": "How are you?" }] }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "id": "d66ppqavqy5mac7fy9jo5lib",
+ "graphCoordinates": { "x": 702.44921875, "y": 350.9921875 },
+ "title": "Group #2 copy",
+ "blocks": [
+ {
+ "id": "x74mxf3hby8hw7zo3k8b5vc1",
+ "groupId": "d66ppqavqy5mac7fy9jo5lib",
+ "type": "text",
+ "content": {
+ "richText": [
+ { "type": "p", "children": [{ "text": "What's up?" }] }
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "variables": [],
+ "edges": [
+ {
+ "from": {
+ "groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
+ "blockId": "rorajfbwc2evvumo837ulwmy",
+ "itemId": "rcw6vr8xfcb5dmfx2ts04rb0"
+ },
+ "to": { "groupId": "ybebe0sxg6zu307a4ruwhmb6" },
+ "id": "lmof3n7qyjo5bkw9kaksgrzh"
+ },
+ {
+ "from": {
+ "groupId": "wv63xqdlwj6ub5vb3ulbmzd3",
+ "blockId": "rorajfbwc2evvumo837ulwmy",
+ "itemId": "i9shyze7fyd5wfxfq0svg2lg"
+ },
+ "to": { "groupId": "d66ppqavqy5mac7fy9jo5lib" },
+ "id": "kvaqhcb1df8cmvekwtpd1irr"
+ },
+ {
+ "from": {
+ "groupId": "bb5ad8yx3j52fecxdnztlap5",
+ "blockId": "q20ww00q5nnv4icmi3rhbtda"
+ },
+ "to": { "groupId": "wv63xqdlwj6ub5vb3ulbmzd3" },
+ "id": "qr2ebzm62bcz21jh2wmv1odd"
+ }
+ ],
+ "theme": {
+ "chat": {
+ "inputs": {
+ "color": "#303235",
+ "backgroundColor": "#FFFFFF",
+ "placeholderColor": "#9095A0"
+ },
+ "buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
+ "hostAvatar": {
+ "url": "https://avatars.githubusercontent.com/u/16015833?v=4",
+ "isEnabled": true
+ },
+ "hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
+ "guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
+ },
+ "general": {
+ "font": "Open Sans",
+ "background": { "type": "Color", "content": "#ffffff" }
+ }
+ },
+ "selectedThemeTemplateId": null,
+ "settings": {
+ "general": {
+ "isBrandingEnabled": false,
+ "isInputPrefillEnabled": true,
+ "isHideQueryParamsEnabled": true,
+ "isNewResultOnRefreshEnabled": true
+ },
+ "metadata": {
+ "description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
+ },
+ "typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
+ },
+ "publicId": null,
+ "customDomain": null,
+ "workspaceId": "proWorkspace",
+ "resultsTablePreferences": null,
+ "isArchived": false,
+ "isClosed": false
+}
diff --git a/apps/viewer/src/features/blocks/logic/abTest/executeAbTest.ts b/apps/viewer/src/features/blocks/logic/abTest/executeAbTest.ts
new file mode 100644
index 0000000000..1b321eb107
--- /dev/null
+++ b/apps/viewer/src/features/blocks/logic/abTest/executeAbTest.ts
@@ -0,0 +1,16 @@
+import { AbTestBlock, SessionState } from '@typebot.io/schemas'
+import { ExecuteLogicResponse } from '@/features/chat/types'
+
+export const executeAbTest = (
+ _: SessionState,
+ block: AbTestBlock
+): ExecuteLogicResponse => {
+ const aEdgeId = block.items[0].outgoingEdgeId
+ const random = Math.random() * 100
+ if (random < block.options.aPercent && aEdgeId) {
+ return { outgoingEdgeId: aEdgeId }
+ }
+ const bEdgeId = block.items[1].outgoingEdgeId
+ if (bEdgeId) return { outgoingEdgeId: bEdgeId }
+ return { outgoingEdgeId: block.outgoingEdgeId }
+}
diff --git a/apps/viewer/src/features/chat/helpers/executeLogic.ts b/apps/viewer/src/features/chat/helpers/executeLogic.ts
index f7eac45266..db002f0712 100644
--- a/apps/viewer/src/features/chat/helpers/executeLogic.ts
+++ b/apps/viewer/src/features/chat/helpers/executeLogic.ts
@@ -7,6 +7,7 @@ import { executeRedirect } from '@/features/blocks/logic/redirect/executeRedirec
import { executeCondition } from '@/features/blocks/logic/condition/executeCondition'
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/executeTypebotLink'
+import { executeAbTest } from '@/features/blocks/logic/abTest/executeAbTest'
export const executeLogic =
(state: SessionState, lastBubbleBlockId?: string) =>
@@ -26,5 +27,7 @@ export const executeLogic =
return executeWait(state, block, lastBubbleBlockId)
case LogicBlockType.JUMP:
return executeJumpBlock(state, block.options)
+ case LogicBlockType.AB_TEST:
+ return executeAbTest(state, block)
}
}
diff --git a/packages/lib/utils.ts b/packages/lib/utils.ts
index f167b6f204..c3e3478ebb 100644
--- a/packages/lib/utils.ts
+++ b/packages/lib/utils.ts
@@ -132,8 +132,13 @@ export const blockTypeHasWebhook = (
export const blockTypeHasItems = (
type: BlockType
-): type is LogicBlockType.CONDITION | InputBlockType.CHOICE =>
- type === LogicBlockType.CONDITION || type === InputBlockType.CHOICE
+): type is
+ | LogicBlockType.CONDITION
+ | InputBlockType.CHOICE
+ | LogicBlockType.AB_TEST =>
+ type === LogicBlockType.CONDITION ||
+ type === InputBlockType.CHOICE ||
+ type === LogicBlockType.AB_TEST
export const blockHasItems = (
block: Block
diff --git a/packages/schemas/features/blocks/logic/abTest.ts b/packages/schemas/features/blocks/logic/abTest.ts
new file mode 100644
index 0000000000..df3452db30
--- /dev/null
+++ b/packages/schemas/features/blocks/logic/abTest.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod'
+import { blockBaseSchema } from '../baseSchemas'
+import { LogicBlockType } from './enums'
+import { itemBaseSchema } from '../../items/baseSchemas'
+import { ItemType } from '../../items/enums'
+
+export const aItemSchema = itemBaseSchema.extend({
+ type: z.literal(ItemType.AB_TEST),
+ path: z.literal('a'),
+})
+
+export const bItemSchema = itemBaseSchema.extend({
+ type: z.literal(ItemType.AB_TEST),
+ path: z.literal('b'),
+})
+
+export const abTestBlockSchema = blockBaseSchema.merge(
+ z.object({
+ type: z.enum([LogicBlockType.AB_TEST]),
+ items: z.tuple([aItemSchema, bItemSchema]),
+ options: z.object({
+ aPercent: z.number().min(0).max(100),
+ }),
+ })
+)
+
+export const defaultAbTestOptions = {
+ aPercent: 50,
+}
+
+export type AbTestBlock = z.infer
diff --git a/packages/schemas/features/blocks/logic/enums.ts b/packages/schemas/features/blocks/logic/enums.ts
index 87f35ff54d..12fd493af7 100644
--- a/packages/schemas/features/blocks/logic/enums.ts
+++ b/packages/schemas/features/blocks/logic/enums.ts
@@ -6,4 +6,5 @@ export enum LogicBlockType {
TYPEBOT_LINK = 'Typebot link',
WAIT = 'Wait',
JUMP = 'Jump',
+ AB_TEST = 'AB test',
}
diff --git a/packages/schemas/features/blocks/logic/index.ts b/packages/schemas/features/blocks/logic/index.ts
index 7dc3029bed..4ca2648f09 100644
--- a/packages/schemas/features/blocks/logic/index.ts
+++ b/packages/schemas/features/blocks/logic/index.ts
@@ -5,3 +5,4 @@ export * from './redirect'
export * from './setVariable'
export * from './typebotLink'
export * from './wait'
+export * from './abTest'
diff --git a/packages/schemas/features/blocks/schemas.ts b/packages/schemas/features/blocks/schemas.ts
index a20e1d6c58..fed048369d 100644
--- a/packages/schemas/features/blocks/schemas.ts
+++ b/packages/schemas/features/blocks/schemas.ts
@@ -42,6 +42,8 @@ import {
setVariableBlockSchema,
typebotLinkBlockSchema,
waitBlockSchema,
+ abTestBlockSchema,
+ AbTestBlock,
} from './logic'
import { jumpBlockSchema } from './logic/jump'
@@ -79,7 +81,7 @@ export type BlockOptions =
| LogicBlockOptions
| IntegrationBlockOptions
-export type BlockWithItems = ConditionBlock | ChoiceInputBlock
+export type BlockWithItems = ConditionBlock | ChoiceInputBlock | AbTestBlock
export type BlockBase = z.infer
@@ -123,6 +125,7 @@ export const logicBlockSchema = z.discriminatedUnion('type', [
typebotLinkBlockSchema,
waitBlockSchema,
jumpBlockSchema,
+ abTestBlockSchema,
])
export type LogicBlock = z.infer
diff --git a/packages/schemas/features/items/enums.ts b/packages/schemas/features/items/enums.ts
index b277f09fe4..e05da4d893 100644
--- a/packages/schemas/features/items/enums.ts
+++ b/packages/schemas/features/items/enums.ts
@@ -1,4 +1,5 @@
export enum ItemType {
BUTTON,
CONDITION,
+ AB_TEST,
}
diff --git a/packages/schemas/features/items/schemas.ts b/packages/schemas/features/items/schemas.ts
index 5482c520d9..efc38deb7c 100644
--- a/packages/schemas/features/items/schemas.ts
+++ b/packages/schemas/features/items/schemas.ts
@@ -1,7 +1,11 @@
import { z } from 'zod'
import { buttonItemSchema } from '../blocks/inputs/choice'
import { conditionItemSchema } from '../blocks/logic/condition'
+import { aItemSchema, bItemSchema } from '../blocks'
-const itemSchema = buttonItemSchema.or(conditionItemSchema)
+const itemSchema = buttonItemSchema
+ .or(conditionItemSchema)
+ .or(aItemSchema)
+ .or(bItemSchema)
export type Item = z.infer