2
0

(analytics) Improve analytics graph accuracy

This commit is contained in:
Baptiste Arnaud
2023-06-30 12:13:17 +02:00
parent 55ff944ebb
commit b0f25f301b
28 changed files with 512 additions and 157 deletions

View File

@@ -21,7 +21,7 @@ test('analytics are not available for non-pro workspaces', async ({ page }) => {
const firstDropoffBox = page.locator('text="%" >> nth=0')
await firstDropoffBox.hover()
await expect(
page.locator('text="Unlock Drop-off rate by upgrading to Pro plan"')
page.locator('text="Upgrade your plan to PRO to reveal drop-off rate."')
).toBeVisible()
await firstDropoffBox.click()
await expect(

View File

@@ -0,0 +1,60 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { PublicTypebot } from '@typebot.io/schemas'
import { z } from 'zod'
import { canReadTypebots } from '@/helpers/databaseRules'
import { totalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
export const getTotalAnswersInBlocks = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/analytics/totalAnswersInBlocks',
protect: true,
summary: 'List total answers in blocks',
tags: ['Analytics'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(z.object({ totalAnswersInBlocks: z.array(totalAnswersInBlock) }))
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const typebot = await prisma.typebot.findFirst({
where: canReadTypebots(typebotId, user),
select: { publishedTypebot: true },
})
if (!typebot?.publishedTypebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Published typebot not found',
})
const publishedTypebot = typebot.publishedTypebot as PublicTypebot
const totalAnswersPerBlock = await prisma.answer.groupBy({
by: ['itemId', 'blockId'],
where: {
result: {
typebotId: typebot.publishedTypebot.typebotId,
},
blockId: {
in: publishedTypebot.groups.flatMap((group) =>
group.blocks.map((block) => block.id)
),
},
},
_count: { _all: true },
})
return {
totalAnswersInBlocks: totalAnswersPerBlock.map((answer) => ({
blockId: answer.blockId,
itemId: answer.itemId ?? undefined,
total: answer._count._all,
})),
}
})

View File

@@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { getTotalAnswersInBlocks } from './getTotalAnswersInBlocks'
export const analyticsRouter = router({
getTotalAnswersInBlocks,
})

View File

@@ -4,32 +4,37 @@ import {
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Stats } from '@typebot.io/schemas'
import React from 'react'
import { useAnswersCount } from '../hooks/useAnswersCount'
import { StatsCards } from './StatsCards'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { Graph } from '@/features/graph/components/Graph'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { useI18n } from '@/locales'
import { trpc } from '@/lib/trpc'
import { isDefined } from '@typebot.io/lib'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const t = useI18n()
const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot()
const { showToast } = useToast()
const { answersCounts } = useAnswersCount({
typebotId: publishedTypebot && typebot?.id,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const { data } = trpc.analytics.getTotalAnswersInBlocks.useQuery(
{
typebotId: typebot?.id as string,
},
{ enabled: isDefined(publishedTypebot) }
)
const startBlockId = publishedTypebot?.groups
.find((group) => group.blocks.at(0)?.type === 'start')
?.blocks.at(0)?.id
return (
<Flex
w="full"
pos="relative"
bgColor={useColorModeValue('white', 'gray.850')}
bgColor={useColorModeValue('#f4f5f8', 'gray.850')}
backgroundImage={useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
@@ -39,18 +44,24 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
h="full"
justifyContent="center"
>
{publishedTypebot && answersCounts && stats ? (
{publishedTypebot &&
data?.totalAnswersInBlocks &&
stats &&
startBlockId ? (
<GraphProvider isReadOnly>
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
answersCounts={
answersCounts[0]
totalAnswersInBlocks={
startBlockId
? [
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts.slice(1),
{
blockId: startBlockId,
total: stats.totalViews,
},
...data.totalAnswersInBlocks,
]
: []
}
@@ -72,7 +83,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
isOpen={isOpen}
type={t('billing.limitMessage.analytics')}
/>
<StatsCards stats={stats} pos="absolute" top={10} />
<StatsCards stats={stats} pos="absolute" />
</Flex>
)
}

View File

@@ -0,0 +1,85 @@
import { isInputBlock } from '@typebot.io/lib'
import { PublicTypebot } from '@typebot.io/schemas'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
export const computePreviousTotalAnswers = (
publishedTypebot: PublicTypebot,
currentBlockId: string,
totalAnswersInBlocks: TotalAnswersInBlock[]
): number => {
let totalAnswers = 0
const allBlocks = publishedTypebot.groups.flatMap((group) => group.blocks)
const currentGroup = publishedTypebot.groups.find((group) =>
group.blocks.find((block) => block.id === currentBlockId)
)
if (!currentGroup) return 0
const currentBlockIndex = currentGroup.blocks.findIndex(
(block) => block.id === currentBlockId
)
const previousBlocks = currentGroup.blocks.slice(0, currentBlockIndex)
for (const block of previousBlocks.reverse()) {
if (isInputBlock(block) || block.type === 'start')
return (
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === block.id &&
totalAnswersInBlock.itemId === undefined
)?.total ?? 0
)
const connectedEdges = publishedTypebot.edges.filter(
(edge) => edge.to.blockId === block.id
)
if (connectedEdges.length) {
for (const connectedEdge of connectedEdges) {
const connectedBlock = allBlocks.find(
(block) => block.id === connectedEdge.from.blockId
)
if (connectedBlock) {
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
totalAnswers +=
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
totalAnswersInBlock.itemId === connectedEdge.from.itemId
)?.total ?? 0
} else {
totalAnswers += computePreviousTotalAnswers(
publishedTypebot,
connectedBlock.id,
totalAnswersInBlocks
)
}
}
}
}
}
const edgesConnectedToGroup = publishedTypebot.edges.filter(
(edge) => edge.to.groupId === currentGroup.id
)
if (edgesConnectedToGroup.length) {
for (const connectedEdge of edgesConnectedToGroup) {
const connectedBlock = allBlocks.find(
(block) => block.id === connectedEdge.from.blockId
)
if (connectedBlock) {
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
totalAnswers +=
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
totalAnswersInBlock.itemId === connectedEdge.from.itemId
)?.total ?? 0
} else {
totalAnswers += computePreviousTotalAnswers(
publishedTypebot,
connectedBlock.id,
totalAnswersInBlocks
)
}
}
}
}
return totalAnswers
}

View File

@@ -1,25 +0,0 @@
import { fetcher } from '@/helpers/fetcher'
import useSWR from 'swr'
import { AnswersCount } from '../types'
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/analytics/answersCount` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -1 +0,0 @@
export type AnswersCount = { groupId: string; totalAnswers: number }

View File

@@ -225,7 +225,7 @@ 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(3)
await expect(page.locator('tr')).toHaveCount(4)
await expect(page.locator('text="$39.00"')).toBeVisible()
await expect(page.locator('text="$34.00"')).toBeVisible()
await expect(page.locator('text="$174.00"')).toBeVisible()

View File

@@ -23,10 +23,10 @@ export const PixelLogo = (props: IconProps) => (
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" />
<stop stopColor="#0064E1" />
<stop offset="0.4" stopColor="#0064E1" />
<stop offset="0.83" stopColor="#0073EE" />
<stop offset="1" stopColor="#0082FB" />
</linearGradient>
<linearGradient
id="paint1_linear_1302_7"
@@ -36,8 +36,8 @@ export const PixelLogo = (props: IconProps) => (
y2="66"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0082FB" />
<stop offset="1" stop-color="#0064E0" />
<stop stopColor="#0082FB" />
<stop offset="1" stopColor="#0064E0" />
</linearGradient>
</defs>
</Icon>

View File

@@ -9,13 +9,13 @@ import { useUser } from '@/features/account/hooks/useUser'
import { ZoomButtons } from './ZoomButtons'
import { useGesture } from '@use-gesture/react'
import { GraphNavigation } from '@typebot.io/prisma'
import { AnswersCount } from '@/features/analytics/types'
import { headerHeight } from '@/features/editor/constants'
import { graphPositionDefaultValue, blockWidth } from '../constants'
import { useBlockDnd } from '../providers/GraphDndProvider'
import { useGraph } from '../providers/GraphProvider'
import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
import { Coordinates } from '../types'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
const maxScale = 2
const minScale = 0.3
@@ -23,12 +23,12 @@ const zoomButtonsScaleBlock = 0.2
export const Graph = ({
typebot,
answersCounts,
totalAnswersInBlocks,
onUnlockProPlanClick,
...props
}: {
typebot: Typebot | PublicTypebot
answersCounts?: AnswersCount[]
totalAnswersInBlocks?: TotalAnswersInBlock[]
onUnlockProPlanClick?: () => void
} & FlexProps) => {
const {
@@ -244,7 +244,7 @@ export const Graph = ({
<GraphElements
edges={typebot.edges}
groups={typebot.groups}
answersCounts={answersCounts}
totalAnswersInBlocks={totalAnswersInBlocks}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
</Flex>

View File

@@ -1,5 +1,5 @@
import { AnswersCount } from '@/features/analytics/types'
import { Edge, Group } from '@typebot.io/schemas'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
import React, { memo } from 'react'
import { EndpointsProvider } from '../providers/EndpointsProvider'
import { Edges } from './edges/Edges'
@@ -8,20 +8,20 @@ import { GroupNode } from './nodes/group'
type Props = {
edges: Edge[]
groups: Group[]
answersCounts?: AnswersCount[]
totalAnswersInBlocks?: TotalAnswersInBlock[]
onUnlockProPlanClick?: () => void
}
const GroupNodes = ({
edges,
groups,
answersCounts,
totalAnswersInBlocks,
onUnlockProPlanClick,
}: Props) => {
return (
<EndpointsProvider>
<Edges
edges={edges}
answersCounts={answersCounts}
totalAnswersInBlocks={totalAnswersInBlocks}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
{groups.map((group, idx) => (

View File

@@ -9,23 +9,35 @@ import {
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import React, { useMemo } from 'react'
import { byId, isDefined } from '@typebot.io/lib'
import { useEndpoints } from '../../providers/EndpointsProvider'
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
import { AnswersCount } from '@/features/analytics/types'
import { isProPlan } from '@/features/billing/helpers/isProPlan'
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
import { computePreviousTotalAnswers } from '@/features/analytics/helpers/computePreviousTotalAnswers'
import { blockHasItems } from '@typebot.io/lib'
export const dropOffBoxDimensions = {
width: 100,
height: 55,
}
export const dropOffSegmentLength = 80
const dropOffSegmentMinWidth = 2
const dropOffSegmentMaxWidth = 20
export const dropOffStubLength = 30
type Props = {
groupId: string
answersCounts: AnswersCount[]
totalAnswersInBlocks: TotalAnswersInBlock[]
blockId: string
onUnlockProPlanClick?: () => void
}
export const DropOffEdge = ({
answersCounts,
groupId,
totalAnswersInBlocks,
blockId,
onUnlockProPlanClick,
}: Props) => {
const dropOffColor = useColorModeValue(
@@ -36,25 +48,33 @@ export const DropOffEdge = ({
const { groupsCoordinates } = useGroupsCoordinates()
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
const { publishedTypebot } = useTypebot()
const currentBlock = useMemo(
() =>
totalAnswersInBlocks.reduce<TotalAnswersInBlock | undefined>(
(block, totalAnswersInBlock) => {
if (totalAnswersInBlock.blockId === blockId) {
return block
? { ...block, total: block.total + totalAnswersInBlock.total }
: totalAnswersInBlock
}
return block
},
undefined
),
[blockId, totalAnswersInBlocks]
)
const isWorkspaceProPlan = isProPlan(workspace)
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.groupId === groupId)?.totalAnswers,
[answersCounts, groupId]
)
const { totalDroppedUser, dropOffRate } = useMemo(() => {
if (!publishedTypebot || totalAnswers === undefined)
if (!publishedTypebot || currentBlock?.total === undefined)
return { previousTotal: undefined, dropOffRate: undefined }
const previousGroupIds = publishedTypebot.edges
.map((edge) =>
edge.to.groupId === groupId ? edge.from.groupId : undefined
const totalAnswers = currentBlock.total
const previousTotal = computePreviousTotalAnswers(
publishedTypebot,
currentBlock.blockId,
totalAnswersInBlocks
)
.filter(isDefined)
const previousTotal = answersCounts
.filter((a) => previousGroupIds.includes(a.groupId))
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
if (previousTotal === 0)
return { previousTotal: undefined, dropOffRate: undefined }
const totalDroppedUser = previousTotal - totalAnswers
@@ -63,42 +83,92 @@ export const DropOffEdge = ({
totalDroppedUser,
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
}
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
const group = publishedTypebot?.groups.find(byId(groupId))
}, [
currentBlock?.blockId,
currentBlock?.total,
publishedTypebot,
totalAnswersInBlocks,
])
const sourceTop = useMemo(() => {
const endpointId = group?.blocks[group.blocks.length - 1].id
return endpointId ? sourceEndpoints.get(endpointId)?.y : undefined
}, [group?.blocks, sourceEndpoints])
const blockTop = currentBlock?.blockId
? sourceEndpoints.get(currentBlock.blockId)?.y
: undefined
if (blockTop) return blockTop
const block = publishedTypebot?.groups
.flatMap((group) => group.blocks)
.find((block) => block.id === currentBlock?.blockId)
if (!block || !blockHasItems(block)) return 0
const itemId = block.items.at(-1)?.id
if (!itemId) return 0
return sourceEndpoints.get(itemId)?.y
}, [currentBlock?.blockId, publishedTypebot?.groups, sourceEndpoints])
const labelCoordinates = useMemo(() => {
if (!groupsCoordinates[groupId]) return
return computeSourceCoordinates(groupsCoordinates[groupId], sourceTop ?? 0)
}, [groupsCoordinates, groupId, sourceTop])
const endpointCoordinates = useMemo(() => {
const groupId = publishedTypebot?.groups.find((group) =>
group.blocks.some((block) => block.id === currentBlock?.blockId)
)?.id
if (!groupId) return undefined
const coordinates = groupsCoordinates[groupId]
if (!coordinates) return undefined
return computeSourceCoordinates(coordinates, sourceTop ?? 0)
}, [
publishedTypebot?.groups,
groupsCoordinates,
sourceTop,
currentBlock?.blockId,
])
const isLastBlock = useMemo(() => {
if (!publishedTypebot) return false
const lastBlock = publishedTypebot.groups
.find((group) =>
group.blocks.some((block) => block.id === currentBlock?.blockId)
)
?.blocks.at(-1)
return lastBlock?.id === currentBlock?.blockId
}, [publishedTypebot, currentBlock?.blockId])
if (!endpointCoordinates) return null
if (!labelCoordinates) return <></>
return (
<>
<path
d={computeDropOffPath(
{ x: labelCoordinates.x - 300, y: labelCoordinates.y },
sourceTop ?? 0
{
x: endpointCoordinates.x,
y: endpointCoordinates.y,
},
isLastBlock
)}
stroke={dropOffColor}
strokeWidth="2px"
markerEnd="url(#red-arrow)"
strokeWidth={
dropOffSegmentMinWidth * (1 - (dropOffRate ?? 0) / 100) +
dropOffSegmentMaxWidth * ((dropOffRate ?? 0) / 100)
}
fill="none"
/>
<foreignObject
width="100"
height="80"
x={labelCoordinates.x - 30}
y={labelCoordinates.y + 80}
width={dropOffBoxDimensions.width}
height={dropOffBoxDimensions.height}
x={endpointCoordinates.x + dropOffStubLength}
y={
endpointCoordinates.y +
(isLastBlock
? dropOffSegmentLength
: -(dropOffBoxDimensions.height / 2))
}
>
<Tooltip
label="Unlock Drop-off rate by upgrading to Pro plan"
label={
isWorkspaceProPlan
? `At this input, ${totalDroppedUser} user${
(totalDroppedUser ?? 2) > 1 ? 's' : ''
} left. This represents ${dropOffRate}% of the users who saw this input.`
: 'Upgrade your plan to PRO to reveal drop-off rate.'
}
isDisabled={isWorkspaceProPlan}
placement="top"
>
<VStack
bgColor={dropOffColor}
@@ -110,8 +180,9 @@ export const DropOffEdge = ({
h="full"
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
cursor={isWorkspaceProPlan ? 'auto' : 'pointer'}
spacing={0.5}
>
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'}>
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'} fontSize="sm">
{isWorkspaceProPlan ? (
dropOffRate
) : (
@@ -121,7 +192,7 @@ export const DropOffEdge = ({
)}
%
</Text>
<Tag colorScheme="red">
<Tag colorScheme="red" size="sm">
{isWorkspaceProPlan ? (
totalDroppedUser
) : (
@@ -129,7 +200,7 @@ export const DropOffEdge = ({
NN
</Text>
)}{' '}
users
user{(totalDroppedUser ?? 2) > 1 ? 's' : ''}
</Tag>
</VStack>
</Tooltip>

View File

@@ -1,24 +1,34 @@
import { chakra, useColorMode } from '@chakra-ui/react'
import { colors } from '@/lib/theme'
import { Edge as EdgeProps } from '@typebot.io/schemas'
import React from 'react'
import { AnswersCount } from '@/features/analytics/types'
import React, { useMemo } from 'react'
import { DrawingEdge } from './DrawingEdge'
import { DropOffEdge } from './DropOffEdge'
import { Edge } from './Edge'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
type Props = {
edges: EdgeProps[]
answersCounts?: AnswersCount[]
totalAnswersInBlocks?: TotalAnswersInBlock[]
onUnlockProPlanClick?: () => void
}
export const Edges = ({
edges,
answersCounts,
totalAnswersInBlocks,
onUnlockProPlanClick,
}: Props) => {
const isDark = useColorMode().colorMode === 'dark'
const uniqueBlockIds = useMemo(
() => [
...new Set(
totalAnswersInBlocks?.map(
(totalAnswersInBlock) => totalAnswersInBlock.blockId
)
),
],
[totalAnswersInBlocks]
)
return (
<chakra.svg
width="full"
@@ -33,11 +43,14 @@ export const Edges = ({
{edges.map((edge) => (
<Edge key={edge.id} edge={edge} />
))}
{answersCounts?.map((answerCount) => (
{totalAnswersInBlocks &&
uniqueBlockIds
?.slice(1)
.map((blockId) => (
<DropOffEdge
key={answerCount.groupId}
answersCounts={answersCounts}
groupId={answerCount.groupId}
key={blockId}
blockId={blockId}
totalAnswersInBlocks={totalAnswersInBlocks}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
))}
@@ -71,21 +84,6 @@ export const Edges = ({
fill={colors.blue[400]}
/>
</marker>
<marker
id={'red-arrow'}
refX="8"
refY="4"
orient="auto"
viewBox="0 0 20 20"
markerUnits="userSpaceOnUse"
markerWidth="20"
markerHeight="20"
>
<path
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
fill="#e53e3e"
/>
</marker>
</chakra.svg>
)
}

View File

@@ -20,9 +20,11 @@ const endpointHeight = 32
export const SourceEndpoint = ({
source,
isHidden,
...props
}: BoxProps & {
source: Source
isHidden?: boolean
}) => {
const id = source.itemId ?? source.blockId
const color = useColorModeValue('blue.200', 'blue.100')
@@ -123,6 +125,7 @@ export const SourceEndpoint = ({
justify="center"
align="center"
pointerEvents="all"
visibility={isHidden ? 'hidden' : 'visible'}
{...props}
>
<Flex

View File

@@ -16,7 +16,12 @@ import {
TextBubbleBlock,
LogicBlockType,
} from '@typebot.io/schemas'
import { isBubbleBlock, isDefined, isTextBubbleBlock } from '@typebot.io/lib'
import {
isBubbleBlock,
isDefined,
isInputBlock,
isTextBubbleBlock,
} from '@typebot.io/lib'
import { BlockNodeContent } from './BlockNodeContent'
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
@@ -54,6 +59,7 @@ export const BlockNode = ({
const bg = useColorModeValue('gray.50', 'gray.850')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('gray.200', 'gray.800')
const { pathname } = useRouter()
const { query } = useRouter()
const {
setConnectingIds,
@@ -241,7 +247,9 @@ export const BlockNode = ({
groupId={block.groupId}
/>
)}
{isConnectable && hasDefaultConnector(block) && (
{(isConnectable ||
(pathname.endsWith('analytics') && isInputBlock(block))) &&
hasDefaultConnector(block) && (
<SourceEndpoint
source={{
groupId: block.groupId,
@@ -250,6 +258,7 @@ export const BlockNode = ({
pos="absolute"
right="-34px"
bottom="10px"
isHidden={!isConnectable}
/>
)}
</HStack>

View File

@@ -1,20 +1,27 @@
import { roundCorners } from 'svg-round-corners'
import { pathRadius } from '../constants'
import { Coordinates } from '../types'
import { computeSourceCoordinates } from './computeSourceCoordinates'
import { computeTwoSegments } from './segments'
import {
dropOffBoxDimensions,
dropOffSegmentLength,
dropOffStubLength,
} from '../components/edges/DropOffEdge'
export const computeDropOffPath = (
sourcePosition: Coordinates,
sourceTop: number
isLastBlock = false
) => {
const sourceCoord = computeSourceCoordinates(sourcePosition, sourceTop)
const segments = computeTwoSegments(sourceCoord, {
x: sourceCoord.x + 20,
y: sourceCoord.y + 80,
const segments = computeTwoSegments(sourcePosition, {
x:
sourcePosition.x +
(isLastBlock
? dropOffStubLength + dropOffBoxDimensions.width / 2
: dropOffStubLength),
y: sourcePosition.y + (isLastBlock ? dropOffSegmentLength : 0),
})
return roundCorners(
`M${sourceCoord.x},${sourceCoord.y} ${segments}`,
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
pathRadius
).path
}

View File

@@ -57,12 +57,18 @@ export const ResultsPage = () => {
/>
<TypebotHeader />
{workspace && <UsageAlertBanners workspace={workspace} />}
<Flex h="full" w="full">
<Flex
h="full"
w="full"
bgColor={useColorModeValue(
router.pathname.endsWith('analytics') ? '#f4f5f8' : 'white',
router.pathname.endsWith('analytics') ? 'gray.850' : 'gray.900'
)}
>
<Flex
pos="absolute"
zIndex={2}
w="full"
bg={useColorModeValue('white', 'gray.900')}
justifyContent="center"
h="60px"
display={['none', 'flex']}

View File

@@ -9,11 +9,13 @@ import { themeRouter } from '@/features/theme/api/router'
import { typebotRouter } from '@/features/typebot/api/router'
import { workspaceRouter } from '@/features/workspace/api/router'
import { router } from '../../trpc'
import { analyticsRouter } from '@/features/analytics/api/router'
export const trpcRouter = router({
getAppVersionProcedure,
processTelemetryEvent,
getLinkedTypebots,
analytics: analyticsRouter,
workspace: workspaceRouter,
typebot: typebotRouter,
webhook: webhookRouter,

View File

@@ -27,7 +27,7 @@ const App = ({ Component, pageProps }: AppProps) => {
const { query, pathname } = useRouter()
useEffect(() => {
if (pathname.endsWith('/edit')) {
if (pathname.endsWith('/edit') || pathname.endsWith('/analytics')) {
document.body.style.overflow = 'hidden'
document.body.classList.add('disable-scroll-x-behavior')
} else {

View File

@@ -5,6 +5,7 @@ import { methodNotAllowed, notAuthenticated } from '@typebot.io/lib/api'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { canReadTypebots } from '@/helpers/databaseRules'
// TODO: Delete (deprecated)
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)

View File

@@ -3642,6 +3642,73 @@
}
}
},
"/typebots/{typebotId}/analytics/totalAnswersInBlocks": {
"get": {
"operationId": "analytics-getTotalAnswersInBlocks",
"summary": "List total answers in blocks",
"tags": [
"Analytics"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"name": "typebotId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"totalAnswersInBlocks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"blockId": {
"type": "string"
},
"itemId": {
"type": "string"
},
"total": {
"type": "number"
}
},
"required": [
"blockId",
"total"
],
"additionalProperties": false
}
}
},
"required": [
"totalAnswersInBlocks"
],
"additionalProperties": false
}
}
}
},
"default": {
"$ref": "#/components/responses/error"
}
}
}
},
"/workspaces": {
"get": {
"operationId": "workspace-listWorkspaces",
@@ -4818,6 +4885,10 @@
"blockId": {
"type": "string"
},
"itemId": {
"type": "string",
"nullable": true
},
"groupId": {
"type": "string"
},
@@ -4837,6 +4908,7 @@
"createdAt",
"resultId",
"blockId",
"itemId",
"groupId",
"variableId",
"content",

View File

@@ -96,7 +96,18 @@ export const continueBotFlow =
return parseRetryMessage(block)
}
newSessionState = await processAndSaveAnswer(state, block)(formattedReply)
const nextEdgeId = getOutgoingEdgeId(newSessionState)(
block,
formattedReply
)
const itemId = nextEdgeId
? state.typebot.edges.find(byId(nextEdgeId))?.from.itemId
: undefined
newSessionState = await processAndSaveAnswer(
state,
block,
itemId
)(formattedReply)
}
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
@@ -121,10 +132,10 @@ export const continueBotFlow =
}
const processAndSaveAnswer =
(state: SessionState, block: InputBlock) =>
(state: SessionState, block: InputBlock, itemId?: string) =>
async (reply: string | null): Promise<SessionState> => {
if (!reply) return state
let newState = await saveAnswer(state, block)(reply)
let newState = await saveAnswer(state, block, itemId)(reply)
newState = await saveVariableValueIfAny(newState, block)(reply)
return newState
}
@@ -179,13 +190,13 @@ const parseRetryMessage = (
}
const saveAnswer =
(state: SessionState, block: InputBlock) =>
(state: SessionState, block: InputBlock, itemId?: string) =>
async (reply: string): Promise<SessionState> => {
const resultId = state.result?.id
const answer = {
resultId,
blockId: block.id,
groupId: block.groupId,
itemId,
content: reply,
variableId: block.options.variableId,
storageUsed: 0,
@@ -207,8 +218,8 @@ const saveAnswer =
where: {
resultId_blockId_groupId: {
resultId,
groupId: block.groupId,
blockId: block.id,
groupId: block.groupId,
},
},
create: answer as Prisma.AnswerUncheckedCreateInput,

View File

@@ -274,6 +274,7 @@ model Answer {
createdAt DateTime @default(now()) @updatedAt
resultId String
blockId String
itemId String?
groupId String
variableId String?
content String @db.Text
@@ -281,7 +282,7 @@ model Answer {
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
@@unique([resultId, blockId, groupId])
@@index([groupId])
@@index([blockId, itemId])
@@index([storageUsed])
}

View File

@@ -0,0 +1,26 @@
-- DropIndex
DROP INDEX IF EXISTS "Answer_groupId_idx";
-- DropIndex
DROP INDEX IF EXISTS "Result_typebotId_idx";
-- AlterTable
ALTER TABLE
"Answer"
ADD
COLUMN "itemId" TEXT;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Answer_blockId_itemId_idx" ON "Answer"("blockId", "itemId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Answer_storageUsed_idx" ON "Answer"("storageUsed");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Result_typebotId_hasStarted_createdAt_idx" ON "Result"("typebotId", "hasStarted", "createdAt" DESC);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Result_typebotId_isCompleted_idx" ON "Result"("typebotId", "isCompleted");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Typebot_isArchived_createdAt_idx" ON "Typebot"("isArchived", "createdAt" DESC);

View File

@@ -255,6 +255,7 @@ model Answer {
createdAt DateTime @default(now()) @updatedAt
resultId String
blockId String
itemId String?
groupId String
variableId String?
content String
@@ -262,7 +263,7 @@ model Answer {
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
@@unique([resultId, blockId, groupId])
@@index([groupId])
@@index([blockId, itemId])
@@index([storageUsed])
}

View File

@@ -1,3 +1,3 @@
import { executePrismaCommand } from './executeCommand'
executePrismaCommand('prisma db push --skip-generate')
executePrismaCommand('prisma db push --skip-generate --accept-data-loss')

View File

@@ -0,0 +1,9 @@
import { z } from 'zod'
export const totalAnswersInBlock = z.object({
blockId: z.string(),
itemId: z.string().optional(),
total: z.number(),
})
export type TotalAnswersInBlock = z.infer<typeof totalAnswersInBlock>

View File

@@ -5,6 +5,7 @@ export const answerSchema = z.object({
createdAt: z.date(),
resultId: z.string(),
blockId: z.string(),
itemId: z.string().nullable(),
groupId: z.string(),
variableId: z.string().nullable(),
content: z.string(),
@@ -22,6 +23,7 @@ export const answerInputSchema = answerSchema
z.object({
variableId: z.string().nullish(),
storageUsed: z.number().nullish(),
itemId: z.string().nullish(),
})
) satisfies z.ZodType<Prisma.AnswerUncheckedUpdateInput>