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