⚡ (analytics) Improve analytics graph accuracy
This commit is contained in:
@@ -21,7 +21,7 @@ test('analytics are not available for non-pro workspaces', async ({ page }) => {
|
|||||||
const firstDropoffBox = page.locator('text="%" >> nth=0')
|
const firstDropoffBox = page.locator('text="%" >> nth=0')
|
||||||
await firstDropoffBox.hover()
|
await firstDropoffBox.hover()
|
||||||
await expect(
|
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()
|
).toBeVisible()
|
||||||
await firstDropoffBox.click()
|
await firstDropoffBox.click()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
6
apps/builder/src/features/analytics/api/router.ts
Normal file
6
apps/builder/src/features/analytics/api/router.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { router } from '@/helpers/server/trpc'
|
||||||
|
import { getTotalAnswersInBlocks } from './getTotalAnswersInBlocks'
|
||||||
|
|
||||||
|
export const analyticsRouter = router({
|
||||||
|
getTotalAnswersInBlocks,
|
||||||
|
})
|
||||||
@@ -4,32 +4,37 @@ import {
|
|||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useToast } from '@/hooks/useToast'
|
|
||||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
import { Stats } from '@typebot.io/schemas'
|
import { Stats } from '@typebot.io/schemas'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useAnswersCount } from '../hooks/useAnswersCount'
|
|
||||||
import { StatsCards } from './StatsCards'
|
import { StatsCards } from './StatsCards'
|
||||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||||
import { Graph } from '@/features/graph/components/Graph'
|
import { Graph } from '@/features/graph/components/Graph'
|
||||||
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
|
||||||
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
|
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
|
||||||
import { useI18n } from '@/locales'
|
import { useI18n } from '@/locales'
|
||||||
|
import { trpc } from '@/lib/trpc'
|
||||||
|
import { isDefined } from '@typebot.io/lib'
|
||||||
|
|
||||||
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||||
const { typebot, publishedTypebot } = useTypebot()
|
const { typebot, publishedTypebot } = useTypebot()
|
||||||
const { showToast } = useToast()
|
const { data } = trpc.analytics.getTotalAnswersInBlocks.useQuery(
|
||||||
const { answersCounts } = useAnswersCount({
|
{
|
||||||
typebotId: publishedTypebot && typebot?.id,
|
typebotId: typebot?.id as string,
|
||||||
onError: (err) => showToast({ title: err.name, description: err.message }),
|
},
|
||||||
})
|
{ enabled: isDefined(publishedTypebot) }
|
||||||
|
)
|
||||||
|
const startBlockId = publishedTypebot?.groups
|
||||||
|
.find((group) => group.blocks.at(0)?.type === 'start')
|
||||||
|
?.blocks.at(0)?.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
w="full"
|
w="full"
|
||||||
pos="relative"
|
pos="relative"
|
||||||
bgColor={useColorModeValue('white', 'gray.850')}
|
bgColor={useColorModeValue('#f4f5f8', 'gray.850')}
|
||||||
backgroundImage={useColorModeValue(
|
backgroundImage={useColorModeValue(
|
||||||
'radial-gradient(#c6d0e1 1px, transparent 0)',
|
'radial-gradient(#c6d0e1 1px, transparent 0)',
|
||||||
'radial-gradient(#2f2f39 1px, transparent 0)'
|
'radial-gradient(#2f2f39 1px, transparent 0)'
|
||||||
@@ -39,18 +44,24 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
|||||||
h="full"
|
h="full"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
{publishedTypebot && answersCounts && stats ? (
|
{publishedTypebot &&
|
||||||
|
data?.totalAnswersInBlocks &&
|
||||||
|
stats &&
|
||||||
|
startBlockId ? (
|
||||||
<GraphProvider isReadOnly>
|
<GraphProvider isReadOnly>
|
||||||
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
|
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
|
||||||
<Graph
|
<Graph
|
||||||
flex="1"
|
flex="1"
|
||||||
typebot={publishedTypebot}
|
typebot={publishedTypebot}
|
||||||
onUnlockProPlanClick={onOpen}
|
onUnlockProPlanClick={onOpen}
|
||||||
answersCounts={
|
totalAnswersInBlocks={
|
||||||
answersCounts[0]
|
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}
|
isOpen={isOpen}
|
||||||
type={t('billing.limitMessage.analytics')}
|
type={t('billing.limitMessage.analytics')}
|
||||||
/>
|
/>
|
||||||
<StatsCards stats={stats} pos="absolute" top={10} />
|
<StatsCards stats={stats} pos="absolute" />
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export type AnswersCount = { groupId: string; totalAnswers: number }
|
|
||||||
@@ -225,7 +225,7 @@ test('should display invoices', async ({ page }) => {
|
|||||||
await page.click('text=Settings & Members')
|
await page.click('text=Settings & Members')
|
||||||
await page.click('text=Billing & Usage')
|
await page.click('text=Billing & Usage')
|
||||||
await expect(page.locator('text="Invoices"')).toBeVisible()
|
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="$39.00"')).toBeVisible()
|
||||||
await expect(page.locator('text="$34.00"')).toBeVisible()
|
await expect(page.locator('text="$34.00"')).toBeVisible()
|
||||||
await expect(page.locator('text="$174.00"')).toBeVisible()
|
await expect(page.locator('text="$174.00"')).toBeVisible()
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const PixelLogo = (props: IconProps) => (
|
|||||||
y2="127"
|
y2="127"
|
||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<stop stop-color="#0064E1" />
|
<stop stopColor="#0064E1" />
|
||||||
<stop offset="0.4" stop-color="#0064E1" />
|
<stop offset="0.4" stopColor="#0064E1" />
|
||||||
<stop offset="0.83" stop-color="#0073EE" />
|
<stop offset="0.83" stopColor="#0073EE" />
|
||||||
<stop offset="1" stop-color="#0082FB" />
|
<stop offset="1" stopColor="#0082FB" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id="paint1_linear_1302_7"
|
id="paint1_linear_1302_7"
|
||||||
@@ -36,8 +36,8 @@ export const PixelLogo = (props: IconProps) => (
|
|||||||
y2="66"
|
y2="66"
|
||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
>
|
>
|
||||||
<stop stop-color="#0082FB" />
|
<stop stopColor="#0082FB" />
|
||||||
<stop offset="1" stop-color="#0064E0" />
|
<stop offset="1" stopColor="#0064E0" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</Icon>
|
</Icon>
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { useUser } from '@/features/account/hooks/useUser'
|
|||||||
import { ZoomButtons } from './ZoomButtons'
|
import { ZoomButtons } from './ZoomButtons'
|
||||||
import { useGesture } from '@use-gesture/react'
|
import { useGesture } from '@use-gesture/react'
|
||||||
import { GraphNavigation } from '@typebot.io/prisma'
|
import { GraphNavigation } from '@typebot.io/prisma'
|
||||||
import { AnswersCount } from '@/features/analytics/types'
|
|
||||||
import { headerHeight } from '@/features/editor/constants'
|
import { headerHeight } from '@/features/editor/constants'
|
||||||
import { graphPositionDefaultValue, blockWidth } from '../constants'
|
import { graphPositionDefaultValue, blockWidth } from '../constants'
|
||||||
import { useBlockDnd } from '../providers/GraphDndProvider'
|
import { useBlockDnd } from '../providers/GraphDndProvider'
|
||||||
import { useGraph } from '../providers/GraphProvider'
|
import { useGraph } from '../providers/GraphProvider'
|
||||||
import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
|
import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
|
||||||
import { Coordinates } from '../types'
|
import { Coordinates } from '../types'
|
||||||
|
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||||
|
|
||||||
const maxScale = 2
|
const maxScale = 2
|
||||||
const minScale = 0.3
|
const minScale = 0.3
|
||||||
@@ -23,12 +23,12 @@ const zoomButtonsScaleBlock = 0.2
|
|||||||
|
|
||||||
export const Graph = ({
|
export const Graph = ({
|
||||||
typebot,
|
typebot,
|
||||||
answersCounts,
|
totalAnswersInBlocks,
|
||||||
onUnlockProPlanClick,
|
onUnlockProPlanClick,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
typebot: Typebot | PublicTypebot
|
typebot: Typebot | PublicTypebot
|
||||||
answersCounts?: AnswersCount[]
|
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||||
onUnlockProPlanClick?: () => void
|
onUnlockProPlanClick?: () => void
|
||||||
} & FlexProps) => {
|
} & FlexProps) => {
|
||||||
const {
|
const {
|
||||||
@@ -244,7 +244,7 @@ export const Graph = ({
|
|||||||
<GraphElements
|
<GraphElements
|
||||||
edges={typebot.edges}
|
edges={typebot.edges}
|
||||||
groups={typebot.groups}
|
groups={typebot.groups}
|
||||||
answersCounts={answersCounts}
|
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AnswersCount } from '@/features/analytics/types'
|
|
||||||
import { Edge, Group } from '@typebot.io/schemas'
|
import { Edge, Group } from '@typebot.io/schemas'
|
||||||
|
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||||
import React, { memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
import { EndpointsProvider } from '../providers/EndpointsProvider'
|
import { EndpointsProvider } from '../providers/EndpointsProvider'
|
||||||
import { Edges } from './edges/Edges'
|
import { Edges } from './edges/Edges'
|
||||||
@@ -8,20 +8,20 @@ import { GroupNode } from './nodes/group'
|
|||||||
type Props = {
|
type Props = {
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
answersCounts?: AnswersCount[]
|
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||||
onUnlockProPlanClick?: () => void
|
onUnlockProPlanClick?: () => void
|
||||||
}
|
}
|
||||||
const GroupNodes = ({
|
const GroupNodes = ({
|
||||||
edges,
|
edges,
|
||||||
groups,
|
groups,
|
||||||
answersCounts,
|
totalAnswersInBlocks,
|
||||||
onUnlockProPlanClick,
|
onUnlockProPlanClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<EndpointsProvider>
|
<EndpointsProvider>
|
||||||
<Edges
|
<Edges
|
||||||
edges={edges}
|
edges={edges}
|
||||||
answersCounts={answersCounts}
|
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||||
/>
|
/>
|
||||||
{groups.map((group, idx) => (
|
{groups.map((group, idx) => (
|
||||||
|
|||||||
@@ -9,23 +9,35 @@ import {
|
|||||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { byId, isDefined } from '@typebot.io/lib'
|
|
||||||
import { useEndpoints } from '../../providers/EndpointsProvider'
|
import { useEndpoints } from '../../providers/EndpointsProvider'
|
||||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
||||||
import { AnswersCount } from '@/features/analytics/types'
|
|
||||||
import { isProPlan } from '@/features/billing/helpers/isProPlan'
|
import { isProPlan } from '@/features/billing/helpers/isProPlan'
|
||||||
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
|
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
|
||||||
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
|
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 = {
|
type Props = {
|
||||||
groupId: string
|
totalAnswersInBlocks: TotalAnswersInBlock[]
|
||||||
answersCounts: AnswersCount[]
|
blockId: string
|
||||||
onUnlockProPlanClick?: () => void
|
onUnlockProPlanClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropOffEdge = ({
|
export const DropOffEdge = ({
|
||||||
answersCounts,
|
totalAnswersInBlocks,
|
||||||
groupId,
|
blockId,
|
||||||
onUnlockProPlanClick,
|
onUnlockProPlanClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const dropOffColor = useColorModeValue(
|
const dropOffColor = useColorModeValue(
|
||||||
@@ -36,25 +48,33 @@ export const DropOffEdge = ({
|
|||||||
const { groupsCoordinates } = useGroupsCoordinates()
|
const { groupsCoordinates } = useGroupsCoordinates()
|
||||||
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
|
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
|
||||||
const { publishedTypebot } = useTypebot()
|
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 isWorkspaceProPlan = isProPlan(workspace)
|
||||||
|
|
||||||
const totalAnswers = useMemo(
|
|
||||||
() => answersCounts.find((a) => a.groupId === groupId)?.totalAnswers,
|
|
||||||
[answersCounts, groupId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { totalDroppedUser, dropOffRate } = useMemo(() => {
|
const { totalDroppedUser, dropOffRate } = useMemo(() => {
|
||||||
if (!publishedTypebot || totalAnswers === undefined)
|
if (!publishedTypebot || currentBlock?.total === undefined)
|
||||||
return { previousTotal: undefined, dropOffRate: undefined }
|
return { previousTotal: undefined, dropOffRate: undefined }
|
||||||
const previousGroupIds = publishedTypebot.edges
|
const totalAnswers = currentBlock.total
|
||||||
.map((edge) =>
|
const previousTotal = computePreviousTotalAnswers(
|
||||||
edge.to.groupId === groupId ? edge.from.groupId : undefined
|
publishedTypebot,
|
||||||
)
|
currentBlock.blockId,
|
||||||
.filter(isDefined)
|
totalAnswersInBlocks
|
||||||
const previousTotal = answersCounts
|
)
|
||||||
.filter((a) => previousGroupIds.includes(a.groupId))
|
|
||||||
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
|
|
||||||
if (previousTotal === 0)
|
if (previousTotal === 0)
|
||||||
return { previousTotal: undefined, dropOffRate: undefined }
|
return { previousTotal: undefined, dropOffRate: undefined }
|
||||||
const totalDroppedUser = previousTotal - totalAnswers
|
const totalDroppedUser = previousTotal - totalAnswers
|
||||||
@@ -63,42 +83,92 @@ export const DropOffEdge = ({
|
|||||||
totalDroppedUser,
|
totalDroppedUser,
|
||||||
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
|
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
|
||||||
}
|
}
|
||||||
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
|
}, [
|
||||||
|
currentBlock?.blockId,
|
||||||
const group = publishedTypebot?.groups.find(byId(groupId))
|
currentBlock?.total,
|
||||||
|
publishedTypebot,
|
||||||
|
totalAnswersInBlocks,
|
||||||
|
])
|
||||||
|
|
||||||
const sourceTop = useMemo(() => {
|
const sourceTop = useMemo(() => {
|
||||||
const endpointId = group?.blocks[group.blocks.length - 1].id
|
const blockTop = currentBlock?.blockId
|
||||||
return endpointId ? sourceEndpoints.get(endpointId)?.y : undefined
|
? sourceEndpoints.get(currentBlock.blockId)?.y
|
||||||
}, [group?.blocks, sourceEndpoints])
|
: 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(() => {
|
const endpointCoordinates = useMemo(() => {
|
||||||
if (!groupsCoordinates[groupId]) return
|
const groupId = publishedTypebot?.groups.find((group) =>
|
||||||
return computeSourceCoordinates(groupsCoordinates[groupId], sourceTop ?? 0)
|
group.blocks.some((block) => block.id === currentBlock?.blockId)
|
||||||
}, [groupsCoordinates, groupId, sourceTop])
|
)?.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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
d={computeDropOffPath(
|
d={computeDropOffPath(
|
||||||
{ x: labelCoordinates.x - 300, y: labelCoordinates.y },
|
{
|
||||||
sourceTop ?? 0
|
x: endpointCoordinates.x,
|
||||||
|
y: endpointCoordinates.y,
|
||||||
|
},
|
||||||
|
isLastBlock
|
||||||
)}
|
)}
|
||||||
stroke={dropOffColor}
|
stroke={dropOffColor}
|
||||||
strokeWidth="2px"
|
strokeWidth={
|
||||||
markerEnd="url(#red-arrow)"
|
dropOffSegmentMinWidth * (1 - (dropOffRate ?? 0) / 100) +
|
||||||
|
dropOffSegmentMaxWidth * ((dropOffRate ?? 0) / 100)
|
||||||
|
}
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
<foreignObject
|
<foreignObject
|
||||||
width="100"
|
width={dropOffBoxDimensions.width}
|
||||||
height="80"
|
height={dropOffBoxDimensions.height}
|
||||||
x={labelCoordinates.x - 30}
|
x={endpointCoordinates.x + dropOffStubLength}
|
||||||
y={labelCoordinates.y + 80}
|
y={
|
||||||
|
endpointCoordinates.y +
|
||||||
|
(isLastBlock
|
||||||
|
? dropOffSegmentLength
|
||||||
|
: -(dropOffBoxDimensions.height / 2))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tooltip
|
<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}
|
isDisabled={isWorkspaceProPlan}
|
||||||
|
placement="top"
|
||||||
>
|
>
|
||||||
<VStack
|
<VStack
|
||||||
bgColor={dropOffColor}
|
bgColor={dropOffColor}
|
||||||
@@ -110,8 +180,9 @@ export const DropOffEdge = ({
|
|||||||
h="full"
|
h="full"
|
||||||
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
|
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
|
||||||
cursor={isWorkspaceProPlan ? 'auto' : 'pointer'}
|
cursor={isWorkspaceProPlan ? 'auto' : 'pointer'}
|
||||||
|
spacing={0.5}
|
||||||
>
|
>
|
||||||
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'}>
|
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'} fontSize="sm">
|
||||||
{isWorkspaceProPlan ? (
|
{isWorkspaceProPlan ? (
|
||||||
dropOffRate
|
dropOffRate
|
||||||
) : (
|
) : (
|
||||||
@@ -121,7 +192,7 @@ export const DropOffEdge = ({
|
|||||||
)}
|
)}
|
||||||
%
|
%
|
||||||
</Text>
|
</Text>
|
||||||
<Tag colorScheme="red">
|
<Tag colorScheme="red" size="sm">
|
||||||
{isWorkspaceProPlan ? (
|
{isWorkspaceProPlan ? (
|
||||||
totalDroppedUser
|
totalDroppedUser
|
||||||
) : (
|
) : (
|
||||||
@@ -129,7 +200,7 @@ export const DropOffEdge = ({
|
|||||||
NN
|
NN
|
||||||
</Text>
|
</Text>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
users
|
user{(totalDroppedUser ?? 2) > 1 ? 's' : ''}
|
||||||
</Tag>
|
</Tag>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { chakra, useColorMode } from '@chakra-ui/react'
|
import { chakra, useColorMode } from '@chakra-ui/react'
|
||||||
import { colors } from '@/lib/theme'
|
import { colors } from '@/lib/theme'
|
||||||
import { Edge as EdgeProps } from '@typebot.io/schemas'
|
import { Edge as EdgeProps } from '@typebot.io/schemas'
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { AnswersCount } from '@/features/analytics/types'
|
|
||||||
import { DrawingEdge } from './DrawingEdge'
|
import { DrawingEdge } from './DrawingEdge'
|
||||||
import { DropOffEdge } from './DropOffEdge'
|
import { DropOffEdge } from './DropOffEdge'
|
||||||
import { Edge } from './Edge'
|
import { Edge } from './Edge'
|
||||||
|
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
edges: EdgeProps[]
|
edges: EdgeProps[]
|
||||||
answersCounts?: AnswersCount[]
|
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||||
onUnlockProPlanClick?: () => void
|
onUnlockProPlanClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Edges = ({
|
export const Edges = ({
|
||||||
edges,
|
edges,
|
||||||
answersCounts,
|
totalAnswersInBlocks,
|
||||||
onUnlockProPlanClick,
|
onUnlockProPlanClick,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isDark = useColorMode().colorMode === 'dark'
|
const isDark = useColorMode().colorMode === 'dark'
|
||||||
|
const uniqueBlockIds = useMemo(
|
||||||
|
() => [
|
||||||
|
...new Set(
|
||||||
|
totalAnswersInBlocks?.map(
|
||||||
|
(totalAnswersInBlock) => totalAnswersInBlock.blockId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[totalAnswersInBlocks]
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<chakra.svg
|
<chakra.svg
|
||||||
width="full"
|
width="full"
|
||||||
@@ -33,14 +43,17 @@ export const Edges = ({
|
|||||||
{edges.map((edge) => (
|
{edges.map((edge) => (
|
||||||
<Edge key={edge.id} edge={edge} />
|
<Edge key={edge.id} edge={edge} />
|
||||||
))}
|
))}
|
||||||
{answersCounts?.map((answerCount) => (
|
{totalAnswersInBlocks &&
|
||||||
<DropOffEdge
|
uniqueBlockIds
|
||||||
key={answerCount.groupId}
|
?.slice(1)
|
||||||
answersCounts={answersCounts}
|
.map((blockId) => (
|
||||||
groupId={answerCount.groupId}
|
<DropOffEdge
|
||||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
key={blockId}
|
||||||
/>
|
blockId={blockId}
|
||||||
))}
|
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||||
|
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<marker
|
<marker
|
||||||
id={'arrow'}
|
id={'arrow'}
|
||||||
refX="8"
|
refX="8"
|
||||||
@@ -71,21 +84,6 @@ export const Edges = ({
|
|||||||
fill={colors.blue[400]}
|
fill={colors.blue[400]}
|
||||||
/>
|
/>
|
||||||
</marker>
|
</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>
|
</chakra.svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ const endpointHeight = 32
|
|||||||
|
|
||||||
export const SourceEndpoint = ({
|
export const SourceEndpoint = ({
|
||||||
source,
|
source,
|
||||||
|
isHidden,
|
||||||
...props
|
...props
|
||||||
}: BoxProps & {
|
}: BoxProps & {
|
||||||
source: Source
|
source: Source
|
||||||
|
isHidden?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const id = source.itemId ?? source.blockId
|
const id = source.itemId ?? source.blockId
|
||||||
const color = useColorModeValue('blue.200', 'blue.100')
|
const color = useColorModeValue('blue.200', 'blue.100')
|
||||||
@@ -123,6 +125,7 @@ export const SourceEndpoint = ({
|
|||||||
justify="center"
|
justify="center"
|
||||||
align="center"
|
align="center"
|
||||||
pointerEvents="all"
|
pointerEvents="all"
|
||||||
|
visibility={isHidden ? 'hidden' : 'visible'}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
TextBubbleBlock,
|
TextBubbleBlock,
|
||||||
LogicBlockType,
|
LogicBlockType,
|
||||||
} from '@typebot.io/schemas'
|
} 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 { BlockNodeContent } from './BlockNodeContent'
|
||||||
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
|
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
|
||||||
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
||||||
@@ -54,6 +59,7 @@ export const BlockNode = ({
|
|||||||
const bg = useColorModeValue('gray.50', 'gray.850')
|
const bg = useColorModeValue('gray.50', 'gray.850')
|
||||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
||||||
|
const { pathname } = useRouter()
|
||||||
const { query } = useRouter()
|
const { query } = useRouter()
|
||||||
const {
|
const {
|
||||||
setConnectingIds,
|
setConnectingIds,
|
||||||
@@ -241,17 +247,20 @@ export const BlockNode = ({
|
|||||||
groupId={block.groupId}
|
groupId={block.groupId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isConnectable && hasDefaultConnector(block) && (
|
{(isConnectable ||
|
||||||
<SourceEndpoint
|
(pathname.endsWith('analytics') && isInputBlock(block))) &&
|
||||||
source={{
|
hasDefaultConnector(block) && (
|
||||||
groupId: block.groupId,
|
<SourceEndpoint
|
||||||
blockId: block.id,
|
source={{
|
||||||
}}
|
groupId: block.groupId,
|
||||||
pos="absolute"
|
blockId: block.id,
|
||||||
right="-34px"
|
}}
|
||||||
bottom="10px"
|
pos="absolute"
|
||||||
/>
|
right="-34px"
|
||||||
)}
|
bottom="10px"
|
||||||
|
isHidden={!isConnectable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { roundCorners } from 'svg-round-corners'
|
import { roundCorners } from 'svg-round-corners'
|
||||||
import { pathRadius } from '../constants'
|
import { pathRadius } from '../constants'
|
||||||
import { Coordinates } from '../types'
|
import { Coordinates } from '../types'
|
||||||
import { computeSourceCoordinates } from './computeSourceCoordinates'
|
|
||||||
import { computeTwoSegments } from './segments'
|
import { computeTwoSegments } from './segments'
|
||||||
|
import {
|
||||||
|
dropOffBoxDimensions,
|
||||||
|
dropOffSegmentLength,
|
||||||
|
dropOffStubLength,
|
||||||
|
} from '../components/edges/DropOffEdge'
|
||||||
|
|
||||||
export const computeDropOffPath = (
|
export const computeDropOffPath = (
|
||||||
sourcePosition: Coordinates,
|
sourcePosition: Coordinates,
|
||||||
sourceTop: number
|
isLastBlock = false
|
||||||
) => {
|
) => {
|
||||||
const sourceCoord = computeSourceCoordinates(sourcePosition, sourceTop)
|
const segments = computeTwoSegments(sourcePosition, {
|
||||||
const segments = computeTwoSegments(sourceCoord, {
|
x:
|
||||||
x: sourceCoord.x + 20,
|
sourcePosition.x +
|
||||||
y: sourceCoord.y + 80,
|
(isLastBlock
|
||||||
|
? dropOffStubLength + dropOffBoxDimensions.width / 2
|
||||||
|
: dropOffStubLength),
|
||||||
|
y: sourcePosition.y + (isLastBlock ? dropOffSegmentLength : 0),
|
||||||
})
|
})
|
||||||
return roundCorners(
|
return roundCorners(
|
||||||
`M${sourceCoord.x},${sourceCoord.y} ${segments}`,
|
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
|
||||||
pathRadius
|
pathRadius
|
||||||
).path
|
).path
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,18 @@ export const ResultsPage = () => {
|
|||||||
/>
|
/>
|
||||||
<TypebotHeader />
|
<TypebotHeader />
|
||||||
{workspace && <UsageAlertBanners workspace={workspace} />}
|
{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
|
<Flex
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
zIndex={2}
|
zIndex={2}
|
||||||
w="full"
|
w="full"
|
||||||
bg={useColorModeValue('white', 'gray.900')}
|
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
h="60px"
|
h="60px"
|
||||||
display={['none', 'flex']}
|
display={['none', 'flex']}
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import { themeRouter } from '@/features/theme/api/router'
|
|||||||
import { typebotRouter } from '@/features/typebot/api/router'
|
import { typebotRouter } from '@/features/typebot/api/router'
|
||||||
import { workspaceRouter } from '@/features/workspace/api/router'
|
import { workspaceRouter } from '@/features/workspace/api/router'
|
||||||
import { router } from '../../trpc'
|
import { router } from '../../trpc'
|
||||||
|
import { analyticsRouter } from '@/features/analytics/api/router'
|
||||||
|
|
||||||
export const trpcRouter = router({
|
export const trpcRouter = router({
|
||||||
getAppVersionProcedure,
|
getAppVersionProcedure,
|
||||||
processTelemetryEvent,
|
processTelemetryEvent,
|
||||||
getLinkedTypebots,
|
getLinkedTypebots,
|
||||||
|
analytics: analyticsRouter,
|
||||||
workspace: workspaceRouter,
|
workspace: workspaceRouter,
|
||||||
typebot: typebotRouter,
|
typebot: typebotRouter,
|
||||||
webhook: webhookRouter,
|
webhook: webhookRouter,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const App = ({ Component, pageProps }: AppProps) => {
|
|||||||
const { query, pathname } = useRouter()
|
const { query, pathname } = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname.endsWith('/edit')) {
|
if (pathname.endsWith('/edit') || pathname.endsWith('/analytics')) {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
document.body.classList.add('disable-scroll-x-behavior')
|
document.body.classList.add('disable-scroll-x-behavior')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { methodNotAllowed, notAuthenticated } from '@typebot.io/lib/api'
|
|||||||
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
|
||||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||||
|
|
||||||
|
// TODO: Delete (deprecated)
|
||||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
const user = await getAuthenticatedUser(req, res)
|
const user = await getAuthenticatedUser(req, res)
|
||||||
if (!user) return notAuthenticated(res)
|
if (!user) return notAuthenticated(res)
|
||||||
|
|||||||
@@ -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": {
|
"/workspaces": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "workspace-listWorkspaces",
|
"operationId": "workspace-listWorkspaces",
|
||||||
@@ -4818,6 +4885,10 @@
|
|||||||
"blockId": {
|
"blockId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"itemId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"groupId": {
|
"groupId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4837,6 +4908,7 @@
|
|||||||
"createdAt",
|
"createdAt",
|
||||||
"resultId",
|
"resultId",
|
||||||
"blockId",
|
"blockId",
|
||||||
|
"itemId",
|
||||||
"groupId",
|
"groupId",
|
||||||
"variableId",
|
"variableId",
|
||||||
"content",
|
"content",
|
||||||
|
|||||||
@@ -96,7 +96,18 @@ export const continueBotFlow =
|
|||||||
return parseRetryMessage(block)
|
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
|
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
|
||||||
@@ -121,10 +132,10 @@ export const continueBotFlow =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const processAndSaveAnswer =
|
const processAndSaveAnswer =
|
||||||
(state: SessionState, block: InputBlock) =>
|
(state: SessionState, block: InputBlock, itemId?: string) =>
|
||||||
async (reply: string | null): Promise<SessionState> => {
|
async (reply: string | null): Promise<SessionState> => {
|
||||||
if (!reply) return state
|
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)
|
newState = await saveVariableValueIfAny(newState, block)(reply)
|
||||||
return newState
|
return newState
|
||||||
}
|
}
|
||||||
@@ -179,13 +190,13 @@ const parseRetryMessage = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveAnswer =
|
const saveAnswer =
|
||||||
(state: SessionState, block: InputBlock) =>
|
(state: SessionState, block: InputBlock, itemId?: string) =>
|
||||||
async (reply: string): Promise<SessionState> => {
|
async (reply: string): Promise<SessionState> => {
|
||||||
const resultId = state.result?.id
|
const resultId = state.result?.id
|
||||||
const answer = {
|
const answer = {
|
||||||
resultId,
|
resultId,
|
||||||
blockId: block.id,
|
blockId: block.id,
|
||||||
groupId: block.groupId,
|
itemId,
|
||||||
content: reply,
|
content: reply,
|
||||||
variableId: block.options.variableId,
|
variableId: block.options.variableId,
|
||||||
storageUsed: 0,
|
storageUsed: 0,
|
||||||
@@ -207,8 +218,8 @@ const saveAnswer =
|
|||||||
where: {
|
where: {
|
||||||
resultId_blockId_groupId: {
|
resultId_blockId_groupId: {
|
||||||
resultId,
|
resultId,
|
||||||
groupId: block.groupId,
|
|
||||||
blockId: block.id,
|
blockId: block.id,
|
||||||
|
groupId: block.groupId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: answer as Prisma.AnswerUncheckedCreateInput,
|
create: answer as Prisma.AnswerUncheckedCreateInput,
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ model Answer {
|
|||||||
createdAt DateTime @default(now()) @updatedAt
|
createdAt DateTime @default(now()) @updatedAt
|
||||||
resultId String
|
resultId String
|
||||||
blockId String
|
blockId String
|
||||||
|
itemId String?
|
||||||
groupId String
|
groupId String
|
||||||
variableId String?
|
variableId String?
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
@@ -281,7 +282,7 @@ model Answer {
|
|||||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([resultId, blockId, groupId])
|
@@unique([resultId, blockId, groupId])
|
||||||
@@index([groupId])
|
@@index([blockId, itemId])
|
||||||
@@index([storageUsed])
|
@@index([storageUsed])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -255,6 +255,7 @@ model Answer {
|
|||||||
createdAt DateTime @default(now()) @updatedAt
|
createdAt DateTime @default(now()) @updatedAt
|
||||||
resultId String
|
resultId String
|
||||||
blockId String
|
blockId String
|
||||||
|
itemId String?
|
||||||
groupId String
|
groupId String
|
||||||
variableId String?
|
variableId String?
|
||||||
content String
|
content String
|
||||||
@@ -262,7 +263,7 @@ model Answer {
|
|||||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([resultId, blockId, groupId])
|
@@unique([resultId, blockId, groupId])
|
||||||
@@index([groupId])
|
@@index([blockId, itemId])
|
||||||
@@index([storageUsed])
|
@@index([storageUsed])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import { executePrismaCommand } from './executeCommand'
|
import { executePrismaCommand } from './executeCommand'
|
||||||
|
|
||||||
executePrismaCommand('prisma db push --skip-generate')
|
executePrismaCommand('prisma db push --skip-generate --accept-data-loss')
|
||||||
|
|||||||
9
packages/schemas/features/analytics.ts
Normal file
9
packages/schemas/features/analytics.ts
Normal 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>
|
||||||
@@ -5,6 +5,7 @@ export const answerSchema = z.object({
|
|||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
resultId: z.string(),
|
resultId: z.string(),
|
||||||
blockId: z.string(),
|
blockId: z.string(),
|
||||||
|
itemId: z.string().nullable(),
|
||||||
groupId: z.string(),
|
groupId: z.string(),
|
||||||
variableId: z.string().nullable(),
|
variableId: z.string().nullable(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
@@ -22,6 +23,7 @@ export const answerInputSchema = answerSchema
|
|||||||
z.object({
|
z.object({
|
||||||
variableId: z.string().nullish(),
|
variableId: z.string().nullish(),
|
||||||
storageUsed: z.number().nullish(),
|
storageUsed: z.number().nullish(),
|
||||||
|
itemId: z.string().nullish(),
|
||||||
})
|
})
|
||||||
) satisfies z.ZodType<Prisma.AnswerUncheckedUpdateInput>
|
) satisfies z.ZodType<Prisma.AnswerUncheckedUpdateInput>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user