2
0

Add Meta Pixel block

Closes #582
This commit is contained in:
Baptiste Arnaud
2023-06-28 09:52:03 +02:00
parent 92f7f3cbe2
commit 033f8f99dd
39 changed files with 826 additions and 38 deletions

View File

@ -9,7 +9,7 @@ import {
} from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from '@/components/icons'
import { createId } from '@paralleldrive/cuid2'
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
type ItemWithId<T> = T & { id: string }
@ -40,6 +40,10 @@ export const TableList = <T,>({
const [items, setItems] = useState(initialItems)
const [showDeleteIndex, setShowDeleteIndex] = useState<number | null>(null)
useEffect(() => {
if (items.length && initialItems.length === 0) setItems(initialItems)
}, [initialItems, items.length])
const createItem = () => {
const id = createId()
const newItem = { id, ...newItemDefaultProps } as ItemWithId<T>

View File

@ -225,8 +225,10 @@ test('should display invoices', async ({ page }) => {
await page.click('text=Settings & Members')
await page.click('text=Billing & Usage')
await expect(page.locator('text="Invoices"')).toBeVisible()
await expect(page.locator('tr')).toHaveCount(2)
await expect(page.locator('tr')).toHaveCount(3)
await expect(page.locator('text="$39.00"')).toBeVisible()
await expect(page.locator('text="$34.00"')).toBeVisible()
await expect(page.locator('text="$174.00"')).toBeVisible()
})
test('custom plans should work', async ({ page }) => {

View File

@ -0,0 +1,44 @@
import { IconProps, Icon } from '@chakra-ui/react'
export const PixelLogo = (props: IconProps) => (
<Icon viewBox="0 0 288 191" fill="none" {...props}>
<path
d="M31.06 125.96C31.06 136.94 33.47 145.37 36.62 150.47C40.75 157.15 46.91 159.98 53.19 159.98C61.29 159.98 68.7 157.97 82.98 138.22C94.42 122.39 107.9 100.17 116.97 86.24L132.33 62.64C143 46.25 155.35 28.03 169.51 15.68C181.07 5.6 193.54 0 206.09 0C227.16 0 247.23 12.21 262.59 35.11C279.4 60.19 287.56 91.78 287.56 124.38C287.56 143.76 283.74 158 277.24 169.25C270.96 180.13 258.72 191 238.13 191V159.98C255.76 159.98 260.16 143.78 260.16 125.24C260.16 98.82 254 69.5 240.43 48.55C230.8 33.69 218.32 24.61 204.59 24.61C189.74 24.61 177.79 35.81 164.36 55.78C157.22 66.39 149.89 79.32 141.66 93.91L132.6 109.96C114.4 142.23 109.79 149.58 100.69 161.71C84.74 182.95 71.12 191 53.19 191C31.92 191 18.47 181.79 10.14 167.91C3.34 156.6 0 141.76 0 124.85L31.06 125.96Z"
fill="#0081FB"
/>
<path
d="M24.4902 37.3C38.7302 15.35 59.2802 0 82.8502 0C96.5002 0 110.07 4.04 124.24 15.61C139.74 28.26 156.26 49.09 176.87 83.42L184.26 95.74C202.1 125.46 212.25 140.75 218.19 147.96C225.83 157.22 231.18 159.98 238.13 159.98C255.76 159.98 260.16 143.78 260.16 125.24L287.56 124.38C287.56 143.76 283.74 158 277.24 169.25C270.96 180.13 258.72 191 238.13 191C225.33 191 213.99 188.22 201.45 176.39C191.81 167.31 180.54 151.18 171.87 136.68L146.08 93.6C133.14 71.98 121.27 55.86 114.4 48.56C107.01 40.71 97.5102 31.23 82.3502 31.23C70.0802 31.23 59.6602 39.84 50.9402 53.01L24.4902 37.3Z"
fill="url(#paint0_linear_1302_7)"
/>
<path
d="M82.35 31.23C70.08 31.23 59.66 39.84 50.94 53.01C38.61 71.62 31.06 99.34 31.06 125.96C31.06 136.94 33.47 145.37 36.62 150.47L10.14 167.91C3.34 156.6 0 141.76 0 124.85C0 94.1 8.44 62.05 24.49 37.3C38.73 15.35 59.28 0 82.85 0L82.35 31.23Z"
fill="url(#paint1_linear_1302_7)"
/>
<defs>
<linearGradient
id="paint0_linear_1302_7"
x1="61.0002"
y1="117"
x2="259"
y2="127"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0064E1" />
<stop offset="0.4" stop-color="#0064E1" />
<stop offset="0.83" stop-color="#0073EE" />
<stop offset="1" stop-color="#0082FB" />
</linearGradient>
<linearGradient
id="paint1_linear_1302_7"
x1="45"
y1="139"
x2="45"
y2="66"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0082FB" />
<stop offset="1" stop-color="#0064E0" />
</linearGradient>
</defs>
</Icon>
)

View File

@ -0,0 +1,20 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
import { PixelBlock } from '@typebot.io/schemas'
type Props = {
options: PixelBlock['options']
}
export const PixelNodeBody = ({ options }: Props) => (
<Text
color={options.eventType || options.pixelId ? 'currentcolor' : 'gray.500'}
noOfLines={1}
>
{options.eventType
? `Track "${options.eventType}"`
: options.pixelId
? 'Init Pixel'
: 'Configure...'}
</Text>
)

View File

@ -0,0 +1,185 @@
import { DropdownList } from '@/components/DropdownList'
import { SwitchWithRelatedSettings } from '@/components/SwitchWithRelatedSettings'
import { TableList, TableListItemProps } from '@/components/TableList'
import { TextLink } from '@/components/TextLink'
import { TextInput } from '@/components/inputs'
import { CodeEditor } from '@/components/inputs/CodeEditor'
import { Select } from '@/components/inputs/Select'
import { Stack, Text } from '@chakra-ui/react'
import { isDefined, isEmpty } from '@typebot.io/lib'
import {
PixelBlock,
pixelEventTypes,
pixelObjectProperties,
} from '@typebot.io/schemas'
import React, { useMemo } from 'react'
const pixelReferenceUrl =
'https://developers.facebook.com/docs/meta-pixel/reference#standard-events'
type Props = {
options?: PixelBlock['options']
onOptionsChange: (options: PixelBlock['options']) => void
}
type Item = NonNullable<PixelBlock['options']['params']>[number]
export const PixelSettings = ({ options, onOptionsChange }: Props) => {
const updatePixelId = (pixelId: string) =>
onOptionsChange({
...options,
pixelId: isEmpty(pixelId) ? undefined : pixelId,
})
const updateIsTrackingEventEnabled = (isChecked: boolean) =>
onOptionsChange({
...options,
params: isChecked && !options?.params ? [] : undefined,
})
const updateEventType = (
_: string | undefined,
eventType?: (typeof pixelEventTypes)[number] | 'Custom'
) =>
onOptionsChange({
...options,
params: [],
eventType,
})
const updateParams = (params: PixelBlock['options']['params']) =>
onOptionsChange({
...options,
params,
})
const updateEventName = (name: string) => {
if (options?.eventType !== 'Custom') return
onOptionsChange({
...options,
name: isEmpty(name) ? undefined : name,
})
}
const Item = useMemo(
() =>
function Component(props: TableListItemProps<Item>) {
return <ParamItem {...props} eventType={options?.eventType} />
},
[options?.eventType]
)
return (
<Stack spacing={4}>
<TextInput
defaultValue={options?.pixelId ?? ''}
onChange={updatePixelId}
withVariableButton={false}
placeholder='Pixel ID (e.g. "123456789")'
/>
<SwitchWithRelatedSettings
label={'Track event'}
initialValue={isDefined(options?.params)}
onCheckChange={updateIsTrackingEventEnabled}
>
<Text fontSize="sm" color="gray.500">
Read the{' '}
<TextLink href={pixelReferenceUrl} isExternal>
reference
</TextLink>{' '}
to better understand the available options.
</Text>
<Select
items={['Custom', ...pixelEventTypes] as const}
selectedItem={options?.eventType}
placeholder="Select event type"
onSelect={updateEventType}
/>
{options?.eventType === 'Custom' && (
<TextInput
defaultValue={options.name ?? ''}
onChange={updateEventName}
placeholder="Event name"
/>
)}
{options?.eventType &&
(options.eventType === 'Custom' ||
pixelObjectProperties.filter((prop) =>
prop.associatedEvents.includes(options.eventType)
).length > 0) && (
<TableList
initialItems={options?.params ?? []}
Item={Item}
onItemsChange={updateParams}
addLabel="Add parameter"
/>
)}
</SwitchWithRelatedSettings>
</Stack>
)
}
type ParamItemProps = {
item: Item
eventType: 'Custom' | (typeof pixelEventTypes)[number] | undefined
onItemChange: (item: Item) => void
}
const ParamItem = ({ item, eventType, onItemChange }: ParamItemProps) => {
const possibleObjectProps =
eventType && eventType !== 'Custom'
? pixelObjectProperties.filter((prop) =>
prop.associatedEvents.includes(eventType)
)
: []
const currentObject = possibleObjectProps.find(
(prop) => prop.key === item.key
)
const updateKey = (key: string) =>
onItemChange({
...item,
key,
})
const updateValue = (value: string) =>
onItemChange({
...item,
value,
})
if (!eventType) return null
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
{eventType === 'Custom' ? (
<TextInput
defaultValue={item.key}
onChange={updateKey}
placeholder="Key"
/>
) : (
<DropdownList
currentItem={item.key}
items={possibleObjectProps.map((prop) => prop.key)}
onItemSelect={updateKey}
placeholder="Select key"
/>
)}
{currentObject?.type === 'code' ? (
<CodeEditor
lang={'javascript'}
defaultValue={item.value}
onChange={updateValue}
/>
) : (
<TextInput
defaultValue={item.value}
onChange={updateValue}
placeholder="Value"
/>
)}
</Stack>
)
}

View File

@ -0,0 +1,37 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from '@typebot.io/lib/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
import { IntegrationBlockType } from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2'
test.describe('Pixel block', () => {
test('its configuration should work', async ({ page }) => {
const typebotId = createId()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: IntegrationBlockType.PIXEL,
options: {},
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.getByPlaceholder('Pixel ID (e.g. "123456789")').fill('pixelid')
await expect(page.getByText('Init Pixel')).toBeVisible()
await page.getByText('Track event').click()
await page.getByPlaceholder('Select event type').click()
await page.getByRole('menuitem', { name: 'Lead' }).click()
await expect(page.getByText('Track "Lead"')).toBeVisible()
await page.getByRole('button', { name: 'Add parameter' }).click()
await page.getByRole('button', { name: 'Select key' }).click()
await page.getByRole('menuitem', { name: 'currency' }).click()
await page.getByPlaceholder('Value').fill('USD')
await page.getByRole('button', { name: 'Preview' }).click()
await expect(
page.getByText('Pixel is not enabled in Preview mode').nth(1)
).toBeVisible()
})
})

View File

@ -39,6 +39,7 @@ import { SetVariableIcon } from '@/features/blocks/logic/setVariable/components/
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink/components/TypebotLinkIcon'
import { AbTestIcon } from '@/features/blocks/logic/abTest/components/AbTestIcon'
import { PictureChoiceIcon } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceIcon'
import { PixelLogo } from '@/features/blocks/integrations/pixel/components/PixelLogo'
type BlockIconProps = { type: BlockType } & IconProps
@ -115,6 +116,8 @@ export const BlockIcon = ({ type, ...props }: BlockIconProps): JSX.Element => {
return <ChatwootLogo {...props} />
case IntegrationBlockType.OPEN_AI:
return <OpenAILogo fill={openAIColor} {...props} />
case IntegrationBlockType.PIXEL:
return <PixelLogo {...props} />
case 'start':
return <FlagIcon {...props} />
}

View File

@ -79,5 +79,7 @@ export const BlockLabel = ({ type }: Props): JSX.Element => {
return <Text fontSize="sm">Chatwoot</Text>
case IntegrationBlockType.OPEN_AI:
return <Text fontSize="sm">OpenAI</Text>
case IntegrationBlockType.PIXEL:
return <Text fontSize="sm">Pixel</Text>
}
}

View File

@ -41,6 +41,7 @@ import { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAn
import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody'
import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
import { PictureChoiceNode } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceNode'
import { PixelNodeBody } from '@/features/blocks/integrations/pixel/components/PixelNodeBody'
type Props = {
block: Block | StartBlock
@ -194,6 +195,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
/>
)
}
case IntegrationBlockType.PIXEL: {
return <PixelNodeBody options={block.options} />
}
case 'start': {
return <Text>Start</Text>
}

View File

@ -46,6 +46,7 @@ import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/compon
import { AbTestSettings } from '@/features/blocks/logic/abTest/components/AbTestSettings'
import { PictureChoiceSettings } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceSettings'
import { SettingsHoverBar } from './SettingsHoverBar'
import { PixelSettings } from '@/features/blocks/integrations/pixel/components/PixelSettings'
type Props = {
block: BlockWithOptions
@ -311,5 +312,13 @@ export const BlockSettings = ({
/>
)
}
case IntegrationBlockType.PIXEL: {
return (
<PixelSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
}
}

View File

@ -61,5 +61,7 @@ export const getHelpDocUrl = (blockType: BlockWithOptions['type']): string => {
return 'https://docs.typebot.io/editor/blocks/logic/abTest'
case LogicBlockType.JUMP:
return 'https://docs.typebot.io/editor/blocks/logic/jump'
case IntegrationBlockType.PIXEL:
return 'https://docs.typebot.io/editor/blocks/integrations/pixel'
}
}

View File

@ -10,8 +10,8 @@ export const FlutterFlowLogo = (props: IconProps) => (
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M60.6347 0C62.6766 0 64.5668 1.10496 65.5244 2.90118C66.4419 4.6217 66.403 6.64918 65.4227 8.33205L65.3893 8.38864L56.7972 22.7617C55.8256 24.387 54.0715 25.4014 52.1968 25.423L52.1356 25.4234L42.3241 25.4233L46.4021 34.7593L46.4183 34.7867L46.4434 34.8308C47.4038 36.5666 47.3804 38.6304 46.3844 40.3403L46.3509 40.3972L37.7588 54.7702C36.7872 56.3955 35.0332 57.4099 33.1585 57.4315L33.0973 57.4319L21.0549 57.4318L12.3978 66.5627L12.3814 66.5797C11.5668 67.4078 10.4661 67.8698 9.31846 67.8698C9.03158 67.8698 8.74452 67.8408 8.46053 67.7829C7.06066 67.4971 5.89759 66.5365 5.33774 65.2117L5.32195 65.1739L0.766133 54.7156L0.742168 54.6755L0.717276 54.6324C0.713141 54.625 0.708936 54.6175 0.704544 54.6095C-0.255836 52.8739 -0.232574 50.8101 0.763263 49.1001L0.796818 49.0432L9.13021 35.1029L2.98975 21.0452L3.10227 20.9879L3.10057 20.9794C2.84075 19.6863 3.0499 18.3378 3.71716 17.1567L3.75401 17.0925L3.78807 17.0347L12.3802 2.66159C13.3519 1.03641 15.106 0.0219807 16.9806 0H17.0418H60.6347ZM15.5195 57.3226L6.39039 57.3224L9.0717 63.525L9.07829 63.5415C9.12274 63.6526 9.19463 63.7134 9.30164 63.7354C9.39709 63.7551 9.47432 63.7348 9.54736 63.6682L9.56013 63.6558L15.5195 57.3226ZM41.6168 35.9985H14.0216C13.9912 35.9985 13.9607 35.9995 13.9303 36.0016L13.9219 36.0022L21.4883 53.3099H33.0959C33.5731 53.3099 34.0327 53.0544 34.2941 52.6441L34.3148 52.6105L42.9193 38.2287C43.176 37.7997 43.2044 37.3019 43.001 36.854C42.7665 36.3375 42.2221 35.9985 41.6168 35.9985ZM11.1531 39.5525L4.32399 51.0629C4.05225 51.5211 4.0365 52.0576 4.27984 52.5298L4.30565 52.5778L4.3194 52.6013L4.34697 52.6464L4.40158 52.7276L4.46255 52.8069L4.52088 52.8735L4.531 52.8843L4.57045 52.9243L4.60472 52.9569C4.81954 53.1519 5.08509 53.2709 5.37798 53.3015L5.44378 53.3071L5.48188 53.3091L5.53015 53.3099H17.1171L11.1531 39.5525ZM9.35736 25.4512L12.3566 32.3299L12.4151 32.3099C12.933 32.1366 13.4746 32.0427 14.022 32.0341L14.1042 32.0335L40.8529 32.0334L37.9829 25.4513L9.35736 25.4512ZM60.5585 4.01257H32.8649L40.4335 21.3239H52.037C52.515 21.3239 52.975 21.0683 53.2365 20.6578L53.2573 20.6242L61.8623 6.24261C62.1189 5.81371 62.1473 5.31633 61.9441 4.8688C61.7144 4.36289 61.1876 4.02709 60.5971 4.01303L60.5585 4.01257ZM28.6254 4.01257H17.0861C16.6116 4.01257 16.1539 4.26756 15.8936 4.67744L15.8729 4.71101L7.29049 19.0929C7.03426 19.5222 7.00595 20.0207 7.20912 20.4692C7.43797 20.9746 7.96188 21.3095 8.54887 21.3235L8.58723 21.3239H36.1742L28.6254 4.01257Z"
fill="#4B39EF"
/>

View File

@ -27,6 +27,6 @@ test.describe.parallel('Templates page', () => {
await page.click('text=Customer Support')
await expect(page.locator('text=How can I help you?')).toBeVisible()
await page.click('text=Use this template')
await expect(page).toHaveURL(new RegExp(`/edit`))
await expect(page).toHaveURL(new RegExp(`/edit`), { timeout: 10000 })
})
})

View File

@ -141,6 +141,8 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
return defaultChatwootOptions
case IntegrationBlockType.OPEN_AI:
return {}
case IntegrationBlockType.PIXEL:
return {}
}
}

View File

@ -8,6 +8,15 @@ The Google Analytics integration block allows you to track a Google Analytics ev
alt="Google Analytics block"
/>
:::note
This block is not executed in Preview mode. To test it, you need to launch the published bot.
:::
When your flow contains a Google Analytics block, under the hood it:
- Initialize GA and track a "Page view" event on page load.
- Track the event if any when the block is executed.
## Track conversions with Google Ads
To track conversions for your Google Ad, you can add a Google Analytics block whenever you'd like to trigger the conversion event with the following properties:
@ -15,3 +24,7 @@ To track conversions for your Google Ad, you can add a Google Analytics block wh
- Event action: conversion
- Send to: <YOUR_AW_ID>
- Value (optional): a number to quantify the conversion
## Troubleshooting
To help you debug how your Google Analytics behaves, I suggest you add the [Google Analytics Debugger](https://chrome.google.com/webstore/detail/google-analytics-debugger/jnkmfdileelhofjcijamephohjechhna) extension to your browser.

View File

@ -0,0 +1,22 @@
# Meta pixel
The Pixel integration block allows you to add a Meta pixel to your bot and track specific events.
<img
src="/img/blocks/integrations/pixel.png"
width="600"
alt="Pixel block"
/>
:::note
This block is not executed in Preview mode. To test it, you need to launch the published bot.
:::
When your flow contains a pixel block, under the hood it:
- Initialize the pixel and track "PageView" event on page load.
- Track the event if any when the block is executed.
## Troubleshooting
To help you debug how your pixel behaves, I suggest you add the [Facebook Pixel Helper](https://chrome.google.com/webstore/detail/facebook-pixel-helper/fdgfkebogiimcoedlicjlajpkdmockpc) extension to your browser.

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@ -1,23 +1,21 @@
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { isNotEmpty } from '@typebot.io/lib'
import { GoogleAnalyticsBlock, SessionState } from '@typebot.io/schemas'
export const executeGoogleAnalyticsBlock = (
{ typebot: { variables } }: SessionState,
{ typebot: { variables }, result }: SessionState,
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => {
const googleAnalytics = deepParseVariables(variables)(block.options)
if (!result) return { outgoingEdgeId: block.outgoingEdgeId }
const googleAnalytics = deepParseVariables(variables, {
guessCorrectTypes: true,
removeEmptyStrings: true,
})(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
googleAnalytics: {
...googleAnalytics,
value: isNotEmpty(googleAnalytics.value as string)
? Number(googleAnalytics.value)
: undefined,
},
googleAnalytics,
},
],
}

View File

@ -0,0 +1,25 @@
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { PixelBlock, SessionState } from '@typebot.io/schemas'
export const executePixelBlock = (
{ typebot: { variables }, result }: SessionState,
block: PixelBlock
): ExecuteIntegrationResponse => {
if (!result) return { outgoingEdgeId: block.outgoingEdgeId }
const pixel = deepParseVariables(variables, {
guessCorrectTypes: true,
removeEmptyStrings: true,
})(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
pixel: {
...pixel,
pixelId: block.options.pixelId,
},
},
],
}
}

View File

@ -6,6 +6,9 @@ import {
ChatReply,
chatReplySchema,
ChatSession,
GoogleAnalyticsBlock,
IntegrationBlockType,
PixelBlock,
ResultInSession,
sendMessageInputSchema,
SessionState,
@ -180,10 +183,40 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
'setVariable' in action || 'streamOpenAiChatCompletion' in action
)
const startClientSideAction = clientSideActions ?? []
const parsedStartPropsActions = parseStartClientSideAction(typebot)
const startLogs = logs ?? []
if (isDefined(parsedStartPropsActions)) {
if (!result) {
if ('startPropsToInject' in parsedStartPropsActions) {
const { customHeadCode, googleAnalyticsId, pixelId, gtmId } =
parsedStartPropsActions.startPropsToInject
let toolsList = ''
if (customHeadCode) toolsList += 'Custom head code, '
if (googleAnalyticsId) toolsList += 'Google Analytics, '
if (pixelId) toolsList += 'Pixel, '
if (gtmId) toolsList += 'Google Tag Manager, '
toolsList = toolsList.slice(0, -2)
startLogs.push({
description: `${toolsList} ${
toolsList.includes(',') ? 'are not' : 'is not'
} enabled in Preview mode`,
status: 'info',
})
}
} else {
startClientSideAction.push(parsedStartPropsActions)
}
}
if (!input && !clientSideActionsNeedSessionId)
return {
messages,
clientSideActions,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
typebot: {
id: typebot.id,
settings: deepParseVariables(newSessionState.typebot.variables)(
@ -194,7 +227,7 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
),
},
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
logs: startLogs.length > 0 ? startLogs : undefined,
}
const session = (await prisma.chatSession.create({
@ -217,9 +250,10 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
},
messages,
input,
clientSideActions,
clientSideActions:
startClientSideAction.length > 0 ? startClientSideAction : undefined,
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
logs: startLogs.length > 0 ? startLogs : undefined,
} satisfies ChatReply
}
@ -403,3 +437,38 @@ const parseDynamicThemeReply = (
),
}
}
const parseStartClientSideAction = (
typebot: StartTypebot
): NonNullable<ChatReply['clientSideActions']>[number] | undefined => {
const blocks = typebot.groups.flatMap((group) => group.blocks)
const startPropsToInject = {
customHeadCode: typebot.settings.metadata.customHeadCode,
gtmId: typebot.settings.metadata.googleTagManagerId,
googleAnalyticsId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.GOOGLE_ANALYTICS &&
block.options.trackingId
) as GoogleAnalyticsBlock | undefined
)?.options.trackingId,
pixelId: (
blocks.find(
(block) =>
block.type === IntegrationBlockType.PIXEL && block.options.pixelId
) as PixelBlock | undefined
)?.options.pixelId,
}
if (
!startPropsToInject.customHeadCode &&
!startPropsToInject.gtmId &&
!startPropsToInject.googleAnalyticsId &&
!startPropsToInject.pixelId
)
return
return {
startPropsToInject,
}
}

View File

@ -165,7 +165,11 @@ const parseBubbleBlock =
(block: BubbleBlock): ChatReply['messages'][0] => {
switch (block.type) {
case BubbleBlockType.TEXT:
return deepParseVariables(variables, { takeLatestIfList: true })(block)
return deepParseVariables(
variables,
{},
{ takeLatestIfList: true }
)(block)
case BubbleBlockType.EMBED: {
const message = deepParseVariables(variables)(block)
return {

View File

@ -4,6 +4,7 @@ import { executeWebhookBlock } from '@/features/blocks/integrations/webhook/exec
import { executeChatwootBlock } from '@/features/blocks/integrations/chatwoot/executeChatwootBlock'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock'
import { executeGoogleSheetBlock } from '@/features/blocks/integrations/googleSheets/executeGoogleSheetBlock'
import { executePixelBlock } from '@/features/blocks/integrations/pixel/executePixelBlock'
import {
IntegrationBlock,
IntegrationBlockType,
@ -30,5 +31,7 @@ export const executeIntegration =
return executeWebhookBlock(state, block)
case IntegrationBlockType.OPEN_AI:
return executeOpenAIBlock(state, block)
case IntegrationBlockType.PIXEL:
return executePixelBlock(state, block)
}
}

View File

@ -4,21 +4,38 @@ import {
parseVariables,
ParseVariablesOptions,
} from './parseVariables'
import { parseGuessedTypeFromString } from './parseGuessedTypeFromString'
type DeepParseOptions = {
guessCorrectTypes?: boolean
removeEmptyStrings?: boolean
}
export const deepParseVariables =
(
variables: Variable[],
options: ParseVariablesOptions = defaultParseVariablesOptions
deepParseOptions: DeepParseOptions = {
guessCorrectTypes: false,
removeEmptyStrings: false,
},
parseVariablesOptions: ParseVariablesOptions = defaultParseVariablesOptions
) =>
<T extends Record<string, unknown>>(object: T): T =>
Object.keys(object).reduce<T>((newObj, key) => {
const currentValue = object[key]
if (typeof currentValue === 'string') {
const parsedVariable = parseVariables(variables, options)(currentValue)
const parsedVariable = parseVariables(
variables,
parseVariablesOptions
)(currentValue)
if (deepParseOptions.removeEmptyStrings && parsedVariable === '')
return newObj
return {
...newObj,
[key]: parsedVariable,
[key]: deepParseOptions.guessCorrectTypes
? parseGuessedTypeFromString(parsedVariable)
: parsedVariable,
}
}
@ -27,14 +44,21 @@ export const deepParseVariables =
...newObj,
[key]: deepParseVariables(
variables,
options
deepParseOptions,
parseVariablesOptions
)(currentValue as Record<string, unknown>),
}
if (currentValue instanceof Array)
return {
...newObj,
[key]: currentValue.map(deepParseVariables(variables, options)),
[key]: currentValue.map(
deepParseVariables(
variables,
deepParseOptions,
parseVariablesOptions
)
),
}
return { ...newObj, [key]: currentValue }

View File

@ -0,0 +1,12 @@
export const parseGuessedTypeFromString = (value: string): unknown => {
if (value === 'undefined') return undefined
return safeJsonParse(value)
}
const safeJsonParse = (value: string): unknown => {
try {
return JSON.parse(value)
} catch {
return value
}
}

View File

@ -28,7 +28,7 @@ export const parseVariables =
return text.replace(
pattern,
(_full, nameInCurlyBraces, _dollarSign, nameInTemplateLitteral) => {
const dollarSign = _dollarSign ?? ''
const dollarSign = (_dollarSign ?? '') as string
const matchedVarName = nameInCurlyBraces ?? nameInTemplateLitteral
const variable = variables.find((variable) => {
return (

View File

@ -1,6 +1,6 @@
import { LiteBadge } from './LiteBadge'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { injectCustomHeadCode, isNotEmpty } from '@typebot.io/lib'
import { isNotEmpty } from '@typebot.io/lib'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import { setIsMobile } from '@/utils/isMobileSignal'
@ -89,8 +89,6 @@ export const Bot = (props: BotProps & { class?: string }) => {
groupId: data.input.groupId,
})
if (data.logs) props.onNewLogs?.(data.logs)
const customHeadCode = data.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
}
createEffect(() => {

View File

@ -5,7 +5,5 @@ export const executeGoogleAnalyticsBlock = async (
options: GoogleAnalyticsOptions
) => {
if (!options?.trackingId) return
const { default: initGoogleAnalytics } = await import('@/lib/gtag')
await initGoogleAnalytics(options.trackingId)
sendGaEvent(options)
}

View File

@ -0,0 +1,8 @@
import { trackPixelEvent } from '@/lib/pixel'
import { isEmpty } from '@typebot.io/lib/utils'
import type { PixelBlock } from '@typebot.io/schemas'
export const executePixel = async (options: PixelBlock['options']) => {
if (isEmpty(options?.pixelId)) return
trackPixelEvent(options)
}

View File

@ -1,3 +1,4 @@
import { isEmpty } from '@typebot.io/lib/utils'
import type { GoogleAnalyticsOptions } from '@typebot.io/schemas'
declare const gtag: (
@ -11,7 +12,7 @@ declare const gtag: (
}
) => void
const initGoogleAnalytics = (id: string): Promise<void> =>
export const initGoogleAnalytics = (id: string): Promise<void> =>
new Promise((resolve) => {
const existingScript = document.getElementById('gtag')
if (!existingScript) {
@ -37,11 +38,9 @@ const initGoogleAnalytics = (id: string): Promise<void> =>
export const sendGaEvent = (options: GoogleAnalyticsOptions) => {
if (!options) return
gtag('event', options.action, {
event_category: options.label?.length ? options.category : undefined,
event_label: options.label?.length ? options.label : undefined,
event_category: isEmpty(options.category) ? undefined : options.category,
event_label: isEmpty(options.label) ? undefined : options.label,
value: options.value as number,
send_to: options.sendTo?.length ? options.sendTo : undefined,
send_to: isEmpty(options.sendTo) ? undefined : options.sendTo,
})
}
export default initGoogleAnalytics

View File

@ -0,0 +1,23 @@
export const gtmHeadSnippet = (
googleTagManagerId: string
) => `<!-- Google Tag Manager -->
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${googleTagManagerId}');
<!-- End Google Tag Manager -->`
export const gtmBodyElement = (googleTagManagerId: string) => {
if (document.getElementById('gtm-noscript')) return ''
const noScriptElement = document.createElement('noscript')
noScriptElement.id = 'gtm-noscript'
const iframeElement = document.createElement('iframe')
iframeElement.src = `https://www.googletagmanager.com/ns.html?id=${googleTagManagerId}`
iframeElement.height = '0'
iframeElement.width = '0'
iframeElement.style.display = 'none'
iframeElement.style.visibility = 'hidden'
noScriptElement.appendChild(iframeElement)
return noScriptElement
}

View File

@ -0,0 +1,41 @@
import { PixelBlock } from '@typebot.io/schemas'
declare const fbq: (
arg0: string,
arg1: string,
arg2: Record<string, string> | undefined
) => void
export const initPixel = (pixelId: string) => {
const script = document.createElement('script')
script.innerHTML = `!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixelId}');
fbq('track', 'PageView');`
document.head.appendChild(script)
const noscript = document.createElement('noscript')
noscript.innerHTML = `<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1"/>`
document.head.appendChild(noscript)
}
export const trackPixelEvent = (options: PixelBlock['options']) => {
if (!options.eventType) return
const params = options.params?.length
? options.params.reduce<Record<string, string>>((obj, param) => {
if (!param.key || !param.value) return obj
return { ...obj, [param.key]: param.value }
}, {})
: undefined
if (options.eventType === 'Custom') {
if (!options.name) return
fbq('trackCustom', options.name, params)
}
fbq('track', options.eventType, params)
}

View File

@ -6,8 +6,10 @@ import { executeScript } from '@/features/blocks/logic/script/executeScript'
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
import { executeWait } from '@/features/blocks/logic/wait/utils/executeWait'
import { executeWebhook } from '@/features/blocks/integrations/webhook/executeWebhook'
import { executePixel } from '@/features/blocks/integrations/pixel/executePixel'
import { ClientSideActionContext } from '@/types'
import type { ChatReply, ReplyLog } from '@typebot.io/schemas'
import { injectStartProps } from './injectStartProps'
export const executeClientSideAction = async (
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0],
@ -58,4 +60,10 @@ export const executeClientSideAction = async (
const response = await executeWebhook(clientSideAction.webhookToExecute)
return { replyToSend: response }
}
if ('startPropsToInject' in clientSideAction) {
return injectStartProps(clientSideAction.startPropsToInject)
}
if ('pixel' in clientSideAction) {
return executePixel(clientSideAction.pixel)
}
}

View File

@ -0,0 +1,20 @@
/* eslint-disable solid/reactivity */
import { initGoogleAnalytics } from '@/lib/gtag'
import { gtmBodyElement } from '@/lib/gtm'
import { initPixel } from '@/lib/pixel'
import { injectCustomHeadCode, isNotEmpty } from '@typebot.io/lib/utils'
import { StartPropsToInject } from '@typebot.io/schemas'
export const injectStartProps = async (
startPropsToInject: StartPropsToInject
) => {
const customHeadCode = startPropsToInject.customHeadCode
if (isNotEmpty(customHeadCode)) injectCustomHeadCode(customHeadCode)
const gtmId = startPropsToInject.gtmId
if (isNotEmpty(gtmId)) document.body.prepend(gtmBodyElement(gtmId))
const googleAnalyticsId = startPropsToInject.googleAnalyticsId
if (isNotEmpty(googleAnalyticsId))
await initGoogleAnalytics(googleAnalyticsId)
const pixelId = startPropsToInject.pixelId
if (isNotEmpty(pixelId)) initPixel(pixelId)
}

View File

@ -8,4 +8,5 @@ export enum IntegrationBlockType {
MAKE_COM = 'Make.com',
PABBLY_CONNECT = 'Pabbly',
CHATWOOT = 'Chatwoot',
PIXEL = 'Pixel',
}

View File

@ -7,3 +7,5 @@ export * from './pabblyConnect'
export * from './sendEmail'
export * from './webhook'
export * from './zapier'
export * from './pixel/schemas'
export * from './pixel/constants'

View File

@ -0,0 +1,134 @@
// Reference: https://developers.facebook.com/docs/meta-pixel/reference#standard-events
export const pixelEventTypes = [
'Lead',
'Contact',
'CompleteRegistration',
'Schedule',
'SubmitApplication',
'ViewContent',
'AddPaymentInfo',
'AddToCart',
'AddToWishlist',
'CustomizeProduct',
'Donate',
'FindLocation',
'InitiateCheckout',
'Purchase',
'Search',
'StartTrial',
'Subscribe',
] as const
export const allEventTypes = ['Custom', ...pixelEventTypes] as const
export const pixelObjectProperties: {
key: string
type: 'text' | 'code'
associatedEvents: (typeof pixelEventTypes)[number][]
}[] = [
{
key: 'content_category',
type: 'text',
associatedEvents: [
'AddPaymentInfo',
'AddToWishlist',
'InitiateCheckout',
'Lead',
'Search',
'ViewContent',
],
},
{
key: 'content_ids',
type: 'code',
associatedEvents: [
'AddPaymentInfo',
'AddToCart',
'AddToWishlist',
'InitiateCheckout',
'Purchase',
'Search',
'ViewContent',
],
},
{
key: 'content_name',
type: 'text',
associatedEvents: [
'AddToCart',
'AddToWishlist',
'CompleteRegistration',
'Lead',
'Purchase',
'ViewContent',
],
},
{
key: 'contents',
type: 'code',
associatedEvents: [
'AddPaymentInfo',
'AddToCart',
'AddToWishlist',
'InitiateCheckout',
'Purchase',
'Search',
'ViewContent',
],
},
{
key: 'currency',
type: 'text',
associatedEvents: [
'AddPaymentInfo',
'AddToCart',
'AddToWishlist',
'CompleteRegistration',
'InitiateCheckout',
'Lead',
'Purchase',
'Search',
'StartTrial',
'Subscribe',
'ViewContent',
],
},
{
key: 'num_items',
type: 'text',
associatedEvents: ['InitiateCheckout', 'Purchase'],
},
{
key: 'predicted_ltv',
type: 'text',
associatedEvents: ['StartTrial', 'Subscribe'],
},
{
key: 'search_string',
type: 'text',
associatedEvents: ['Search'],
},
{
key: 'status',
type: 'text',
associatedEvents: ['CompleteRegistration'],
},
{
key: 'value',
type: 'text',
associatedEvents: [
'AddPaymentInfo',
'AddToCart',
'AddToWishlist',
'CompleteRegistration',
'InitiateCheckout',
'Lead',
'Purchase',
'Search',
'StartTrial',
'Subscribe',
'ViewContent',
],
},
]

View File

@ -0,0 +1,51 @@
import { z } from 'zod'
import { pixelEventTypes } from './constants'
import { blockBaseSchema } from '../../baseSchemas'
import { IntegrationBlockType } from '../enums'
const basePixelOptionSchema = z.object({
pixelId: z.string().optional(),
params: z
.array(
z.object({
id: z.string(),
key: z.string().optional(),
value: z.any().optional(),
})
)
.optional(),
})
const initialPixelOptionSchema = basePixelOptionSchema.merge(
z.object({
eventType: z.undefined(),
})
)
const standardPixelEventOptionSchema = basePixelOptionSchema.merge(
z.object({
eventType: z.enum(pixelEventTypes),
})
)
const customPixelOptionSchema = basePixelOptionSchema.merge(
z.object({
eventType: z.enum(['Custom']),
name: z.string().optional(),
})
)
export const pixelOptionsSchema = z.discriminatedUnion('eventType', [
initialPixelOptionSchema,
standardPixelEventOptionSchema,
customPixelOptionSchema,
])
export const pixelBlockSchema = blockBaseSchema.merge(
z.object({
type: z.enum([IntegrationBlockType.PIXEL]),
options: pixelOptionsSchema,
})
)
export type PixelBlock = z.infer<typeof pixelBlockSchema>

View File

@ -1,9 +1,9 @@
import { ZodDiscriminatedUnionOption, z } from 'zod'
import { z } from 'zod'
import { BubbleBlockType } from './bubbles/enums'
import { choiceInputSchema } from './inputs/choice'
import { InputBlockType } from './inputs/enums'
import { IntegrationBlockType } from './integrations/enums'
import { ConditionBlock, conditionBlockSchema } from './logic/condition'
import { conditionBlockSchema } from './logic/condition'
import { LogicBlockType } from './logic/enums'
import { blockBaseSchema } from './baseSchemas'
import { startBlockSchema } from './start/schemas'
@ -31,6 +31,7 @@ import {
googleSheetsBlockSchema,
makeComBlockSchema,
pabblyConnectBlockSchema,
pixelBlockSchema,
sendEmailBlockSchema,
webhookBlockSchema,
zapierBlockSchema,
@ -125,6 +126,7 @@ export const blockSchema = z.discriminatedUnion('type', [
sendEmailBlockSchema,
webhookBlockSchema,
zapierBlockSchema,
pixelBlockSchema,
])
export type Block = z.infer<typeof blockSchema>

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import {
googleAnalyticsOptionsSchema,
paymentInputRuntimeOptionsSchema,
pixelOptionsSchema,
redirectOptionsSchema,
} from './blocks'
import { publicTypebotSchema } from './publicTypebot'
@ -197,6 +198,13 @@ export const sendMessageInputSchema = z.object({
const runtimeOptionsSchema = paymentInputRuntimeOptionsSchema.optional()
const startPropsToInjectSchema = z.object({
googleAnalyticsId: z.string().optional(),
pixelId: z.string().optional(),
gtmId: z.string().optional(),
customHeadCode: z.string().optional(),
})
const clientSideActionSchema = z
.object({
lastBubbleBlockId: z.string().optional(),
@ -247,6 +255,16 @@ const clientSideActionSchema = z
webhookToExecute: executableWebhookSchema,
})
)
.or(
z.object({
startPropsToInject: startPropsToInjectSchema,
})
)
.or(
z.object({
pixel: pixelOptionsSchema,
})
)
)
export const chatReplySchema = z.object({
@ -282,3 +300,4 @@ export type StartParams = z.infer<typeof startParamsSchema>
export type RuntimeOptions = z.infer<typeof runtimeOptionsSchema>
export type StartTypebot = z.infer<typeof startTypebotSchema>
export type ReplyLog = z.infer<typeof replyLogSchema>
export type StartPropsToInject = z.infer<typeof startPropsToInjectSchema>