2
0

🖐️ Analytics drop off rates

This commit is contained in:
Baptiste Arnaud
2022-01-03 17:39:59 +01:00
parent 1093453c07
commit 6322402c96
38 changed files with 876 additions and 147 deletions

View File

@ -0,0 +1,44 @@
import {
GridProps,
SimpleGrid,
Skeleton,
Stat,
StatLabel,
StatNumber,
} from '@chakra-ui/react'
import { Stats } from 'bot-engine'
import React from 'react'
export const StatsCards = ({
stats,
...props
}: { stats?: Stats } & GridProps) => {
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing="6" {...props}>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Views</StatLabel>
{stats ? (
<StatNumber>{stats.totalViews}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Starts</StatLabel>
{stats ? (
<StatNumber>{stats.totalStarts}</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
<Stat bgColor="white" p="4" rounded="md" boxShadow="md">
<StatLabel>Completion rate</StatLabel>
{stats ? (
<StatNumber>{stats.completionRate}%</StatNumber>
) : (
<Skeleton w="50%" h="10px" mt="2" />
)}
</Stat>
</SimpleGrid>
)
}

View File

@ -0,0 +1,61 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo, useRef } from 'react'
import { AnswersCount } from 'services/analytics'
import { BlockNode } from './blocks/BlockNode'
import { StartBlockNode } from './blocks/StartBlockNode'
import { Edges } from './Edges'
const AnalyticsGraph = ({
answersCounts,
...props
}: { answersCounts?: AnswersCount[] } & FlexProps) => {
const { typebot, graphPosition, setGraphPosition } = useAnalyticsGraph()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const transform = useMemo(
() =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
[graphPosition]
)
const handleMouseWheel = (e: WheelEvent) => {
e.preventDefault()
const isPinchingTrackpad = e.ctrlKey
if (isPinchingTrackpad) {
const scale = graphPosition.scale - e.deltaY * 0.01
if (scale <= 0.2 || scale >= 1) return
setGraphPosition({
...graphPosition,
scale,
})
} else
setGraphPosition({
...graphPosition,
x: graphPosition.x - e.deltaX,
y: graphPosition.y - e.deltaY,
})
}
useEventListener('wheel', handleMouseWheel, graphContainerRef.current)
if (!typebot) return <></>
return (
<Flex ref={graphContainerRef} {...props}>
<Flex
flex="1"
boxSize={'200px'}
maxW={'200px'}
style={{
transform,
}}
>
<Edges answersCounts={answersCounts} />
{typebot.startBlock && <StartBlockNode block={typebot.startBlock} />}
{(typebot.blocks ?? []).map((block) => (
<BlockNode block={block} key={block.id} />
))}
</Flex>
</Flex>
)
}
export default AnalyticsGraph

View File

@ -0,0 +1,18 @@
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo } from 'react'
import { computeDropOffPath } from 'services/graph'
type Props = {
blockId: string
}
export const DropOffEdge = ({ blockId }: Props) => {
const { typebot } = useAnalyticsGraph()
const path = useMemo(() => {
if (!typebot) return
const block = (typebot?.blocks ?? []).find((b) => b.id === blockId)
if (!block) return ''
return computeDropOffPath(block.graphCoordinates, block.steps.length - 1)
}, [blockId, typebot])
return <path d={path} stroke={'#E53E3E'} strokeWidth="2px" fill="none" />
}

View File

@ -0,0 +1,56 @@
import { Block } from 'bot-engine'
import { StepWithTarget } from 'components/board/graph/Edges/Edge'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo } from 'react'
import {
getAnchorsPosition,
computeFlowChartConnectorPath,
} from 'services/graph'
export const Edge = ({ step }: { step: StepWithTarget }) => {
const { typebot } = useAnalyticsGraph()
const { blocks, startBlock } = typebot ?? {}
const { sourceBlock, targetBlock, targetStepIndex } = useMemo(() => {
const targetBlock = blocks?.find(
(b) => b?.id === step.target.blockId
) as Block
const targetStepIndex = step.target.stepId
? targetBlock.steps.findIndex((s) => s.id === step.target.stepId)
: undefined
return {
sourceBlock: [startBlock, ...(blocks ?? [])].find(
(b) => b?.id === step.blockId
),
targetBlock,
targetStepIndex,
}
}, [
blocks,
startBlock,
step.blockId,
step.target.blockId,
step.target.stepId,
])
const path = useMemo(() => {
if (!sourceBlock || !targetBlock) return ``
const anchorsPosition = getAnchorsPosition(
sourceBlock,
targetBlock,
sourceBlock.steps.findIndex((s) => s.id === step.id),
targetStepIndex
)
return computeFlowChartConnectorPath(anchorsPosition)
}, [sourceBlock, step.id, targetBlock, targetStepIndex])
return (
<path
d={path}
stroke={'#718096'}
strokeWidth="2px"
markerEnd="url(#arrow)"
fill="none"
/>
)
}

View File

@ -0,0 +1,69 @@
import { chakra } from '@chakra-ui/system'
import { StepWithTarget } from 'components/board/graph/Edges/Edge'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics'
import { DropOffBlock } from '../blocks/DropOffBlock'
import { DropOffEdge } from './DropOffEdge'
import { Edge } from './Edge'
type Props = { answersCounts?: AnswersCount[] }
export const Edges = ({ answersCounts }: Props) => {
const { typebot } = useAnalyticsGraph()
const { blocks, startBlock } = typebot ?? {}
const stepsWithTarget: StepWithTarget[] = useMemo(() => {
if (!startBlock) return []
return [
...(startBlock.steps.filter((s) => s.target) as StepWithTarget[]),
...((blocks ?? [])
.flatMap((b) => b.steps)
.filter((s) => s.target) as StepWithTarget[]),
]
}, [blocks, startBlock])
return (
<>
<chakra.svg
width="full"
height="full"
overflow="visible"
pos="absolute"
left="0"
top="0"
>
{stepsWithTarget.map((step) => (
<Edge key={step.id} step={step} />
))}
<marker
id={'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="#718096"
/>
</marker>
{answersCounts?.map((answersCount) => (
<DropOffEdge
key={answersCount.blockId}
blockId={answersCount.blockId}
/>
))}
</chakra.svg>
{answersCounts?.map((answersCount) => (
<DropOffBlock
key={answersCount.blockId}
answersCounts={answersCounts}
blockId={answersCount.blockId}
/>
))}
</>
)
}

View File

@ -0,0 +1 @@
export { Edges } from './Edges'

View File

@ -0,0 +1,62 @@
import {
Editable,
EditableInput,
EditablePreview,
Stack,
useEventListener,
} from '@chakra-ui/react'
import React, { useState } from 'react'
import { Block } from 'bot-engine'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import { StepsList } from './StepsList'
type Props = {
block: Block
}
export const BlockNode = ({ block }: Props) => {
const { updateBlockPosition } = useAnalyticsGraph()
const [isMouseDown, setIsMouseDown] = useState(false)
const handleMouseDown = () => {
setIsMouseDown(true)
}
const handleMouseUp = () => {
setIsMouseDown(false)
}
const handleMouseMove = (event: MouseEvent) => {
if (!isMouseDown) return
const { movementX, movementY } = event
updateBlockPosition(block.id, {
x: block.graphCoordinates.x + movementX,
y: block.graphCoordinates.y + movementY,
})
}
useEventListener('mousemove', handleMouseMove)
return (
<Stack
p="4"
rounded="lg"
bgColor="blue.50"
borderWidth="2px"
minW="300px"
transition="border 300ms"
pos="absolute"
style={{
transform: `translate(${block.graphCoordinates.x}px, ${block.graphCoordinates.y}px)`,
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<Editable defaultValue={block.title}>
<EditablePreview px="1" userSelect={'none'} />
<EditableInput minW="0" px="1" />
</Editable>
<StepsList blockId={block.id} steps={block.steps} />
</Stack>
)
}

View File

@ -0,0 +1,71 @@
import { Tag, Text, VStack } from '@chakra-ui/react'
import { Block } from 'bot-engine'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import React, { useMemo } from 'react'
import { AnswersCount } from 'services/analytics'
import { computeSourceCoordinates } from 'services/graph'
type Props = {
answersCounts: AnswersCount[]
blockId: string
}
export const DropOffBlock = ({ answersCounts, blockId }: Props) => {
const { typebot } = useAnalyticsGraph()
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,
[answersCounts, blockId]
)
const { totalDroppedUser, dropOffRate } = useMemo(() => {
if (!typebot || totalAnswers === undefined)
return { previousTotal: undefined, dropOffRate: undefined }
const previousTotal = answersCounts
.filter(
(a) =>
[typebot.startBlock, ...typebot.blocks].find((b) =>
(b as Block).steps.find((s) => s.target?.blockId === blockId)
)?.id === a.blockId
)
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
if (previousTotal === 0)
return { previousTotal: undefined, dropOffRate: undefined }
const totalDroppedUser = previousTotal - totalAnswers
return {
totalDroppedUser,
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
}
}, [answersCounts, blockId, totalAnswers, typebot])
const labelCoordinates = useMemo(() => {
if (!typebot) return { x: 0, y: 0 }
const sourceBlock = typebot?.blocks.find((b) => b.id === blockId)
if (!sourceBlock) return
return computeSourceCoordinates(
sourceBlock?.graphCoordinates,
sourceBlock?.steps.length - 1
)
}, [blockId, typebot])
if (!labelCoordinates) return <></>
return (
<VStack
bgColor={'red.500'}
color="white"
rounded="md"
p="2"
justifyContent="center"
style={{
transform: `translate(${labelCoordinates.x - 20}px, ${
labelCoordinates.y + 80
}px)`,
}}
pos="absolute"
>
<Text>{dropOffRate}%</Text>
<Tag colorScheme="red">{totalDroppedUser} users</Tag>
</VStack>
)
}

View File

@ -0,0 +1,62 @@
import {
Editable,
EditableInput,
EditablePreview,
Stack,
useEventListener,
} from '@chakra-ui/react'
import React, { useState } from 'react'
import { StartBlock } from 'bot-engine'
import { useAnalyticsGraph } from 'contexts/AnalyticsGraphProvider'
import { StepsList } from './StepsList'
type Props = {
block: StartBlock
}
export const StartBlockNode = ({ block }: Props) => {
const { updateBlockPosition } = useAnalyticsGraph()
const [isMouseDown, setIsMouseDown] = useState(false)
const handleMouseDown = () => {
setIsMouseDown(true)
}
const handleMouseUp = () => {
setIsMouseDown(false)
}
const handleMouseMove = (event: MouseEvent) => {
if (!isMouseDown) return
const { movementX, movementY } = event
updateBlockPosition(block.id, {
x: block.graphCoordinates.x + movementX,
y: block.graphCoordinates.y + movementY,
})
}
useEventListener('mousemove', handleMouseMove)
return (
<Stack
p="4"
rounded="lg"
bgColor="blue.50"
borderWidth="2px"
minW="300px"
transition="border 300ms"
pos="absolute"
style={{
transform: `translate(${block.graphCoordinates.x}px, ${block.graphCoordinates.y}px)`,
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<Editable value={block.title} isDisabled>
<EditablePreview px="1" userSelect={'none'} />
<EditableInput minW="0" px="1" />
</Editable>
<StepsList blockId={block.id} steps={block.steps} />
</Stack>
)
}

View File

@ -0,0 +1,27 @@
import { Flex, Stack } from '@chakra-ui/react'
import { StartStep, Step } from 'bot-engine'
import { StepNodeOverlay } from 'components/board/graph/BlockNode/StepNode'
export const StepsList = ({
steps,
}: {
blockId: string
steps: Step[] | [StartStep]
}) => {
return (
<Stack spacing={1} transition="none">
<Flex h={'2px'} bgColor={'gray.400'} visibility={'hidden'} rounded="lg" />
{steps.map((step) => (
<Stack key={step.id} spacing={1}>
<StepNodeOverlay key={step.id} step={step} />
<Flex
h={'2px'}
bgColor={'gray.400'}
visibility={'hidden'}
rounded="lg"
/>
</Stack>
))}
</Stack>
)
}

View File

@ -5,6 +5,7 @@ import { DndContext } from 'contexts/DndContext'
import { StepTypesList } from './StepTypesList' import { StepTypesList } from './StepTypesList'
import { PreviewDrawer } from './preview/PreviewDrawer' import { PreviewDrawer } from './preview/PreviewDrawer'
import { RightPanel, useEditor } from 'contexts/EditorContext' import { RightPanel, useEditor } from 'contexts/EditorContext'
import { GraphProvider } from 'contexts/GraphContext'
export const Board = () => { export const Board = () => {
const { rightPanel } = useEditor() const { rightPanel } = useEditor()
@ -12,7 +13,9 @@ export const Board = () => {
<Flex flex="1" pos="relative" bgColor="gray.50" h="full"> <Flex flex="1" pos="relative" bgColor="gray.50" h="full">
<DndContext> <DndContext>
<StepTypesList /> <StepTypesList />
<Graph /> <GraphProvider>
<Graph flex="1" />
</GraphProvider>
{rightPanel === RightPanel.PREVIEW && <PreviewDrawer />} {rightPanel === RightPanel.PREVIEW && <PreviewDrawer />}
</DndContext> </DndContext>
</Flex> </Flex>

View File

@ -6,7 +6,7 @@ import {
useEventListener, useEventListener,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Block, StartBlock } from 'bot-engine' import { Block } from 'bot-engine'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { useDnd } from 'contexts/DndContext' import { useDnd } from 'contexts/DndContext'
import { StepsList } from './StepsList' import { StepsList } from './StepsList'
@ -15,7 +15,11 @@ import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu' import { ContextMenu } from 'components/shared/ContextMenu'
import { BlockNodeContextMenu } from './BlockNodeContextMenu' import { BlockNodeContextMenu } from './BlockNodeContextMenu'
export const BlockNode = ({ block }: { block: Block | StartBlock }) => { type Props = {
block: Block
}
export const BlockNode = ({ block }: Props) => {
const { connectingIds, setConnectingIds, previewingIds } = useGraph() const { connectingIds, setConnectingIds, previewingIds } = useGraph()
const { updateBlockPosition, addStepToBlock } = useTypebot() const { updateBlockPosition, addStepToBlock } = useTypebot()
const { draggedStep, draggedStepType, setDraggedStepType, setDraggedStep } = const { draggedStep, draggedStepType, setDraggedStepType, setDraggedStep } =

View File

@ -11,7 +11,10 @@ import { StepNode } from './StepNode'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
export const StartBlockNode = ({ block }: { block: StartBlock }) => { type Props = {
block: StartBlock
}
export const StartBlockNode = ({ block }: Props) => {
const { previewingIds } = useGraph() const { previewingIds } = useGraph()
const [isMouseDown, setIsMouseDown] = useState(false) const [isMouseDown, setIsMouseDown] = useState(false)
const [titleValue, setTitleValue] = useState(block.title) const [titleValue, setTitleValue] = useState(block.title)

View File

@ -1,12 +1,12 @@
import { StackProps, HStack } from '@chakra-ui/react' import { StackProps, HStack } from '@chakra-ui/react'
import { Step } from 'bot-engine' import { StartStep, Step } from 'bot-engine'
import { StepIcon } from 'components/board/StepTypesList/StepIcon' import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { StepContent } from './StepContent' import { StepContent } from './StepContent'
export const StepNodeOverlay = ({ export const StepNodeOverlay = ({
step, step,
...props ...props
}: { step: Step } & StackProps) => { }: { step: Step | StartStep } & StackProps) => {
return ( return (
<HStack <HStack
p="3" p="3"
@ -14,9 +14,6 @@ export const StepNodeOverlay = ({
rounded="lg" rounded="lg"
bgColor="white" bgColor="white"
cursor={'grab'} cursor={'grab'}
pos="fixed"
top="0"
left="0"
w="264px" w="264px"
pointerEvents="none" pointerEvents="none"
{...props} {...props}

View File

@ -118,6 +118,9 @@ export const StepsList = ({
<StepNodeOverlay <StepNodeOverlay
step={draggedStep} step={draggedStep}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"
style={{ style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`, transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
}} }}

View File

@ -1,4 +1,4 @@
import { Flex, useEventListener } from '@chakra-ui/react' import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo } from 'react' import React, { useRef, useMemo } from 'react'
import { blockWidth, useGraph } from 'contexts/GraphContext' import { blockWidth, useGraph } from 'contexts/GraphContext'
import { BlockNode } from './BlockNode/BlockNode' import { BlockNode } from './BlockNode/BlockNode'
@ -8,11 +8,11 @@ import { useTypebot } from 'contexts/TypebotContext'
import { StartBlockNode } from './BlockNode/StartBlockNode' import { StartBlockNode } from './BlockNode/StartBlockNode'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader' import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
const Graph = () => { const Graph = ({ ...props }: FlexProps) => {
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } = const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
useDnd() useDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null) const graphContainerRef = useRef<HTMLDivElement | null>(null)
const { typebot, addNewBlock } = useTypebot() const { addNewBlock, typebot } = useTypebot()
const { graphPosition, setGraphPosition } = useGraph() const { graphPosition, setGraphPosition } = useGraph()
const transform = useMemo( const transform = useMemo(
() => () =>
@ -60,7 +60,7 @@ const Graph = () => {
if (!typebot) return <></> if (!typebot) return <></>
return ( return (
<Flex ref={graphContainerRef} flex="1"> <Flex ref={graphContainerRef} {...props}>
<Flex <Flex
flex="1" flex="1"
boxSize={'200px'} boxSize={'200px'}
@ -70,6 +70,7 @@ const Graph = () => {
}} }}
> >
<Edges /> <Edges />
{props.children}
{typebot.startBlock && <StartBlockNode block={typebot.startBlock} />} {typebot.startBlock && <StartBlockNode block={typebot.startBlock} />}
{(typebot.blocks ?? []).map((block) => ( {(typebot.blocks ?? []).map((block) => (
<BlockNode block={block} key={block.id} /> <BlockNode block={block} key={block.id} />

View File

@ -37,8 +37,7 @@ export const FolderContent = ({ folder }: Props) => {
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { useSensor(MouseSensor, {
activationConstraint: { activationConstraint: {
delay: 100, distance: 20,
tolerance: 300,
}, },
}) })
) )

View File

@ -92,7 +92,13 @@ export const TypebotButton = ({
> >
<MoreButton pos="absolute" top="10px" right="10px"> <MoreButton pos="absolute" top="10px" right="10px">
<MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem> <MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem>
<MenuItem color="red" onClick={onDeleteOpen}> <MenuItem
color="red"
onClick={(e) => {
e.stopPropagation()
onDeleteOpen()
}}
>
Delete Delete
</MenuItem> </MenuItem>
</MoreButton> </MoreButton>

View File

@ -0,0 +1,79 @@
import { Block, PublicTypebot } from 'bot-engine'
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react'
import { Coordinates } from './GraphContext'
type Position = Coordinates & { scale: number }
const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 }
const graphContext = createContext<{
typebot?: PublicTypebot
updateBlockPosition: (blockId: string, newPositon: Coordinates) => void
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
graphPosition: graphPositionDefaultValue,
})
export const AnalyticsGraphProvider = ({
children,
initialTypebot,
}: {
children: ReactNode
initialTypebot: PublicTypebot
}) => {
const [typebot, setTypebot] = useState(initialTypebot)
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const updateBlocks = (blocks: Block[]) => {
if (!typebot) return
setTypebot({
...typebot,
blocks: [...blocks],
})
}
const updateBlockPosition = (blockId: string, newPosition: Coordinates) => {
if (!typebot) return
blockId === 'start-block'
? setTypebot({
...typebot,
startBlock: {
...typebot.startBlock,
graphCoordinates: newPosition,
},
})
: updateBlocks(
typebot.blocks.map((block) =>
block.id === blockId
? { ...block, graphCoordinates: newPosition }
: block
)
)
}
return (
<graphContext.Provider
value={{
typebot,
graphPosition,
setGraphPosition,
updateBlockPosition,
}}
>
{children}
</graphContext.Provider>
)
}
export const useAnalyticsGraph = () => useContext(graphContext)

View File

@ -323,9 +323,15 @@ export const TypebotContext = ({
const publishTypebot = async () => { const publishTypebot = async () => {
if (!localTypebot) return if (!localTypebot) return
if (!localPublishedTypebot) if (!localPublishedTypebot) {
updatePublicId(parseDefaultPublicId(localTypebot.name, localTypebot.id)) const newPublicId = parseDefaultPublicId(
if (hasUnsavedChanges) await saveTypebot() localTypebot.name,
localTypebot.id
)
updatePublicId(newPublicId)
localTypebot.publicId = newPublicId
}
if (hasUnsavedChanges || !localPublishedTypebot) await saveTypebot()
setIsPublishing(true) setIsPublishing(true)
if (localPublishedTypebot) { if (localPublishedTypebot) {
const { error } = await updatePublishedTypebot( const { error } = await updatePublishedTypebot(

View File

@ -4,7 +4,7 @@ describe('Dashboard page', () => {
cy.signOut() cy.signOut()
}) })
it('should navigate correctly', () => { test('folders navigation should work', () => {
cy.signIn('test1@gmail.com') cy.signIn('test1@gmail.com')
cy.visit('/typebots') cy.visit('/typebots')
createFolder('My folder #1') createFolder('My folder #1')
@ -26,7 +26,7 @@ describe('Dashboard page', () => {
cy.findByDisplayValue('My folder #2').should('not.exist') cy.findByDisplayValue('My folder #2').should('not.exist')
}) })
it('should be droppable', () => { test('folders should be draggable and droppable', () => {
cy.signIn('test2@gmail.com') cy.signIn('test2@gmail.com')
cy.visit('/typebots') cy.visit('/typebots')
cy.findByTestId('typebot-button-typebot1').mouseMoveBy(-100, 0, { cy.findByTestId('typebot-button-typebot1').mouseMoveBy(-100, 0, {

View File

@ -1,5 +1,43 @@
import { Flex, useToast } from '@chakra-ui/react'
import { Stats } from 'bot-engine'
import AnalyticsGraph from 'components/analytics/graph/AnalyticsGraph'
import { StatsCards } from 'components/analytics/StatsCards'
import { AnalyticsGraphProvider } from 'contexts/AnalyticsGraphProvider'
import { useTypebot } from 'contexts/TypebotContext'
import React from 'react' import React from 'react'
import { useAnswersCount } from 'services/analytics'
export const AnalyticsContent = () => { export const AnalyticsContent = ({ stats }: { stats?: Stats }) => {
return <>Hi</> const { typebot, publishedTypebot } = useTypebot()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { answersCounts } = useAnswersCount({
typebotId: typebot?.id,
onError: (err) => toast({ title: err.name, description: err.message }),
})
return (
<Flex
w="full"
pos="relative"
bgColor="gray.50"
h="full"
justifyContent="center"
>
{publishedTypebot && (
<AnalyticsGraphProvider initialTypebot={publishedTypebot}>
<AnalyticsGraph
flex="1"
answersCounts={[
{ blockId: 'start-block', totalAnswers: stats?.totalViews ?? 0 },
...(answersCounts ?? []),
]}
/>
</AnalyticsGraphProvider>
)}
<StatsCards stats={stats} pos="absolute" top={10} />
</Flex>
)
} }

View File

@ -1,17 +1,9 @@
import { import { Button, Flex, HStack, Tag, useToast, Text } from '@chakra-ui/react'
Button,
Flex,
HStack,
Stack,
Tag,
useToast,
Text,
} from '@chakra-ui/react'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink' import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { useResultsCount } from 'services/results' import { useStats } from 'services/analytics'
import { AnalyticsContent } from './AnalyticsContent' import { AnalyticsContent } from './AnalyticsContent'
import { SubmissionsContent } from './SubmissionContent' import { SubmissionsContent } from './SubmissionContent'
@ -27,14 +19,21 @@ export const ResultsContent = () => {
status: 'error', status: 'error',
}) })
const { totalResults } = useResultsCount({ const { stats } = useStats({
typebotId: typebot?.id, typebotId: typebot?.id,
onError: (err) => toast({ title: err.name, description: err.message }), onError: (err) => toast({ title: err.name, description: err.message }),
}) })
return ( return (
<Flex h="full" w="full" justifyContent="center" align="flex-start"> <Flex h="full" w="full">
<Stack maxW="1200px" w="full" pt="4" spacing={6}> <Flex
<HStack> pos="absolute"
zIndex={2}
bgColor="white"
w="full"
justifyContent="center"
h="60px"
>
<HStack maxW="1200px" w="full">
<Button <Button
as={NextChakraLink} as={NextChakraLink}
colorScheme={!isAnalytics ? 'blue' : 'gray'} colorScheme={!isAnalytics ? 'blue' : 'gray'}
@ -43,9 +42,9 @@ export const ResultsContent = () => {
href={`/typebots/${typebot?.id}/results`} href={`/typebots/${typebot?.id}/results`}
> >
<Text>Submissions</Text> <Text>Submissions</Text>
{totalResults && ( {(stats?.totalStarts ?? 0) > 0 && (
<Tag size="sm" colorScheme="blue" ml="1"> <Tag size="sm" colorScheme="blue" ml="1">
{totalResults} {stats?.totalStarts}
</Tag> </Tag>
)} )}
</Button> </Button>
@ -59,16 +58,18 @@ export const ResultsContent = () => {
Analytics Analytics
</Button> </Button>
</HStack> </HStack>
</Flex>
<Flex pt="60px" w="full" justify="center">
{typebot && {typebot &&
(isAnalytics ? ( (isAnalytics ? (
<AnalyticsContent /> <AnalyticsContent stats={stats} />
) : ( ) : (
<SubmissionsContent <SubmissionsContent
typebotId={typebot.id} typebotId={typebot.id}
totalResults={totalResults ?? 0} totalResults={stats?.totalStarts ?? 0}
/> />
))} ))}
</Stack> </Flex>
</Flex> </Flex>
) )
} }

View File

@ -43,7 +43,7 @@ export const SubmissionsContent = ({ typebotId, totalResults }: Props) => {
) )
return ( return (
<Stack> <Stack maxW="1200px" w="full">
<Flex w="full" justifyContent="flex-end"> <Flex w="full" justifyContent="flex-end">
<HStack> <HStack>
<HStack as={Button} colorScheme="blue"> <HStack as={Button} colorScheme="blue">

View File

@ -25,6 +25,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
where: { where: {
typebotId, typebotId,
typebot: { ownerId: user.id }, typebot: { ownerId: user.id },
answers: { some: {} },
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@ -0,0 +1,41 @@
import { PublicTypebot } from 'bot-engine'
import { User } from 'db'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getSession } from 'next-auth/react'
import { methodNotAllowed } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (!session?.user)
return res.status(401).send({ message: 'Not authenticated' })
const user = session.user as User
if (req.method === 'GET') {
const typebotId = req.query.typebotId.toString()
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
include: { publishedTypebot: true },
})
if (!typebot) return res.status(404).send({ answersCounts: [] })
if (typebot?.ownerId !== user.id)
return res.status(403).send({ message: 'Forbidden' })
const answersCounts: { blockId: string; totalAnswers: number }[] =
await Promise.all(
(typebot.publishedTypebot as PublicTypebot).blocks.map(
async (block) => {
const totalAnswers = await prisma.answer.count({
where: { blockId: block.id },
})
return { blockId: block.id, totalAnswers }
}
)
)
return res.status(200).send({ answersCounts })
}
return methodNotAllowed(res)
}
export default handler

View File

@ -1,3 +1,4 @@
import { Stats } from 'bot-engine'
import { User } from 'db' import { User } from 'db'
import prisma from 'libs/prisma' import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next' import { NextApiRequest, NextApiResponse } from 'next'
@ -13,13 +14,33 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = session.user as User const user = session.user as User
if (req.method === 'GET') { if (req.method === 'GET') {
const typebotId = req.query.typebotId.toString() const typebotId = req.query.typebotId.toString()
const totalResults = await prisma.result.count({
const totalViews = await prisma.result.count({
where: { where: {
typebotId, typebotId,
typebot: { ownerId: user.id }, typebot: { ownerId: user.id },
}, },
}) })
return res.status(200).send({ totalResults }) const totalStarts = await prisma.result.count({
where: {
typebotId,
typebot: { ownerId: user.id },
answers: { some: {} },
},
})
const totalCompleted = await prisma.result.count({
where: {
typebotId,
typebot: { ownerId: user.id },
isCompleted: true,
},
})
const stats: Stats = {
totalViews,
totalStarts,
completionRate: Math.round((totalCompleted / totalStarts) * 100),
}
return res.status(200).send({ stats })
} }
return methodNotAllowed(res) return methodNotAllowed(res)
} }

View File

@ -3,7 +3,6 @@ import { Board } from 'components/board/Board'
import { Seo } from 'components/Seo' import { Seo } from 'components/Seo'
import { TypebotHeader } from 'components/shared/TypebotHeader' import { TypebotHeader } from 'components/shared/TypebotHeader'
import { EditorContext } from 'contexts/EditorContext' import { EditorContext } from 'contexts/EditorContext'
import { GraphProvider } from 'contexts/GraphContext'
import { TypebotContext } from 'contexts/TypebotContext' import { TypebotContext } from 'contexts/TypebotContext'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { KBarProvider } from 'kbar' import { KBarProvider } from 'kbar'
@ -23,9 +22,7 @@ const TypebotEditPage = () => {
<KBar /> <KBar />
<Flex overflow="hidden" h="100vh" flexDir="column"> <Flex overflow="hidden" h="100vh" flexDir="column">
<TypebotHeader /> <TypebotHeader />
<GraphProvider> <Board />
<Board />
</GraphProvider>
</Flex> </Flex>
</KBarProvider> </KBarProvider>
</EditorContext> </EditorContext>

View File

@ -0,0 +1,45 @@
import { Stats } from 'bot-engine'
import useSWR from 'swr'
import { fetcher } from './utils'
export const useStats = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ stats: Stats }, Error>(
typebotId ? `/api/typebots/${typebotId}/results/stats` : null,
fetcher
)
if (error) onError(error)
return {
stats: data?.stats,
isLoading: !error && !data,
mutate,
}
}
export type AnswersCount = { blockId: string; totalAnswers: number }
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/results/answers/count` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@ -11,6 +11,29 @@ import {
import { roundCorners } from 'svg-round-corners' import { roundCorners } from 'svg-round-corners'
import { isDefined } from 'utils' import { isDefined } from 'utils'
export const computeDropOffPath = (
sourcePosition: Coordinates,
sourceStepIndex: number
) => {
const sourceCoord = computeSourceCoordinates(sourcePosition, sourceStepIndex)
const segments = computeTwoSegments(sourceCoord, {
x: sourceCoord.x + 20,
y: sourceCoord.y + 80,
})
return roundCorners(`M${sourceCoord.x},${sourceCoord.y} ${segments}`, 10).path
}
export const computeSourceCoordinates = (
sourcePosition: Coordinates,
sourceStepIndex: number
) => ({
x: sourcePosition.x + blockWidth,
y:
(sourcePosition.y ?? 0) +
firstStepOffsetY +
spaceBetweenSteps * sourceStepIndex,
})
export const computeFlowChartConnectorPath = ({ export const computeFlowChartConnectorPath = ({
sourcePosition, sourcePosition,
targetPosition, targetPosition,
@ -125,20 +148,16 @@ export const getAnchorsPosition = (
sourceStepIndex: number, sourceStepIndex: number,
targetStepIndex?: number targetStepIndex?: number
): AnchorsPositionProps => { ): AnchorsPositionProps => {
const sourceOffsetY =
(sourceBlock.graphCoordinates.y ?? 0) +
firstStepOffsetY +
spaceBetweenSteps * sourceStepIndex
const targetOffsetY = isDefined(targetStepIndex) const targetOffsetY = isDefined(targetStepIndex)
? (targetBlock.graphCoordinates.y ?? 0) + ? (targetBlock.graphCoordinates.y ?? 0) +
firstStepOffsetY + firstStepOffsetY +
spaceBetweenSteps * targetStepIndex spaceBetweenSteps * targetStepIndex
: undefined : undefined
const sourcePosition = { const sourcePosition = computeSourceCoordinates(
x: (sourceBlock.graphCoordinates.x ?? 0) + blockWidth, sourceBlock.graphCoordinates,
y: sourceOffsetY, sourceStepIndex
} )
let sourceType: 'right' | 'left' = 'right' let sourceType: 'right' | 'left' = 'right'
if (sourceBlock.graphCoordinates.x > targetBlock.graphCoordinates.x) { if (sourceBlock.graphCoordinates.x > targetBlock.graphCoordinates.x) {
sourcePosition.x = sourceBlock.graphCoordinates.x sourcePosition.x = sourceBlock.graphCoordinates.x
@ -148,7 +167,7 @@ export const getAnchorsPosition = (
const { targetPosition, totalSegments } = computeBlockTargetPosition( const { targetPosition, totalSegments } = computeBlockTargetPosition(
sourceBlock.graphCoordinates, sourceBlock.graphCoordinates,
targetBlock.graphCoordinates, targetBlock.graphCoordinates,
sourceOffsetY, sourcePosition.y,
targetOffsetY targetOffsetY
) )
return { sourcePosition, targetPosition, sourceType, totalSegments } return { sourcePosition, targetPosition, sourceType, totalSegments }

View File

@ -28,25 +28,6 @@ export const useResults = ({
} }
} }
export const useResultsCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ totalResults: number }, Error>(
typebotId ? `/api/typebots/${typebotId}/results/count` : null,
fetcher
)
if (error) onError(error)
return {
totalResults: data?.totalResults,
isLoading: !error && !data,
mutate,
}
}
export const parseDateToReadable = (dateStr: string): string => { export const parseDateToReadable = (dateStr: string): string => {
const date = new Date(dateStr) const date = new Date(dateStr)
return ( return (

View File

@ -8,7 +8,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const id = req.query.id.toString() const id = req.query.id.toString()
const result = await prisma.result.update({ const result = await prisma.result.update({
where: { id }, where: { id },
data: { ...data, updatedAt: new Date() }, data,
}) })
return res.send(result) return res.send(result)
} }

View File

@ -1,7 +1,7 @@
import { Answer } from 'db' import { Answer } from 'bot-engine'
import { sendRequest } from 'utils' import { sendRequest } from 'utils'
export const upsertAnswer = async (answer: Answer) => { export const upsertAnswer = async (answer: Answer & { resultId: string }) => {
return sendRequest<Answer>({ return sendRequest<Answer>({
url: `/api/answers`, url: `/api/answers`,
method: 'PUT', method: 'PUT',

View File

@ -16,7 +16,7 @@
}, },
"devDependencies": { "devDependencies": {
"dotenv-cli": "^4.1.1", "dotenv-cli": "^4.1.1",
"turbo": "^1.0.24-canary.2" "turbo": "^1.0.24"
}, },
"turbo": { "turbo": {
"baseBranch": "origin/main", "baseBranch": "origin/main",

View File

@ -1,3 +1,9 @@
import { Answer as AnswerFromPrisma } from 'db' import { Answer as AnswerFromPrisma } from 'db'
export type Answer = Omit<AnswerFromPrisma, 'resultId'> export type Answer = Omit<AnswerFromPrisma, 'resultId' | 'createdAt'>
export type Stats = {
totalViews: number
totalStarts: number
completionRate: number
}

View File

@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `updatedAt` on the `Result` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Result" DROP COLUMN "updatedAt";

View File

@ -112,7 +112,6 @@ model PublicTypebot {
model Result { model Result {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now())
typebotId String typebotId String
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
answers Answer[] answers Answer[]

128
yarn.lock
View File

@ -7325,83 +7325,83 @@ tunnel-agent@^0.6.0:
dependencies: dependencies:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
turbo-darwin-64@1.0.24-canary.2: turbo-darwin-64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.0.24-canary.2.tgz#5d64c95d14fb06f45b2c0c44d1657b1884e009d7" resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.0.24.tgz#f135baff0e44f9160c9b027e8c4dd2d5c8bb10a7"
integrity sha512-MTY6Eea5QQc+ZqC2EqZWtIRaIygenWY7i0f+PVyCyfaXp5TqZiIUljd4s1vfp4tygVg66EVkI2Pu34FsXNVB3g== integrity sha512-A65Wxp+jBMfI3QX2uObX6DKvk+TxNXTf7ufQTHvRSLeAreB8QiVzJdYE0nC6YdrRwfPgFY3L72dhYd2v8ouXDg==
turbo-darwin-arm64@1.0.24-canary.2: turbo-darwin-arm64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-1.0.24-canary.2.tgz#2169cebc1be5d8fbcf8334a50d411432c7480c10" resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-1.0.24.tgz#c360d7cc6a7403855733e3aebb841b1227fbbb2e"
integrity sha512-FaaAJ25smSeKjnkyUYxJhth0L59VmGjM9MrHXg+KhjU9jPFzyXTr9Bnb0nV6TAFRcL+IzZ3TXHykRCqfY4FjYA== integrity sha512-31zfexqUhvk/CIfAUk2mwjlpEjIURXu4QG8hoWlGxpcpAhlnkIX6CXle+LoQSnU3+4EbNe2SE92fYXsT/SnHAg==
turbo-freebsd-64@1.0.24-canary.2: turbo-freebsd-64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-freebsd-64/-/turbo-freebsd-64-1.0.24-canary.2.tgz#91528c2a75f621d7e7861e94b73c9a70443206ef" resolved "https://registry.yarnpkg.com/turbo-freebsd-64/-/turbo-freebsd-64-1.0.24.tgz#9ef8914e7d1aaa995a8001a0ad81f7cc4520d332"
integrity sha512-alNrWyWvpIdKoQARB85PJchwr6VqKbWdayq2zlzGnSnTmH0EQb0bSDL9zNsdiKu4ISGE2E7YUN616lfvqx3wHA== integrity sha512-vZYbDkOHH5eeQrxsAYldrh2nDY884irtmgJdGbpjryJgnJx+xzriZfoFalm/d1ZfG3ArENRJqGU+k6BriefZzw==
turbo-freebsd-arm64@1.0.24-canary.2: turbo-freebsd-arm64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-freebsd-arm64/-/turbo-freebsd-arm64-1.0.24-canary.2.tgz#8cea1bf6335e284beea3136eb7cc811da8501c72" resolved "https://registry.yarnpkg.com/turbo-freebsd-arm64/-/turbo-freebsd-arm64-1.0.24.tgz#12644e8f1b077f9d7afb367f2b8c2a2e0592ca72"
integrity sha512-vfS+L5NqRLN2w4R0QgmBFl9Z9pXnIYwoS3IWcfx2SRqTUdcqfrM3Mq3jHUdfQE2DufMYcGt1u28aakYb5LEpdA== integrity sha512-TDIu1PlyusY8AB69KGM4wGrCjtfbzmVF4Hlgf9mVeSWVKzqkRASorOEq1k8KvfZ+sBTS2GBMpqwpa1KVkYpVhw==
turbo-linux-32@1.0.24-canary.2: turbo-linux-32@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-32/-/turbo-linux-32-1.0.24-canary.2.tgz#41d68a780b805111a05a2e4b692b3f10c944bb51" resolved "https://registry.yarnpkg.com/turbo-linux-32/-/turbo-linux-32-1.0.24.tgz#6129f7560f5c48214c1724ae7e8196dedc56de21"
integrity sha512-kjhHH3IQv83MrxpW5ScWlC08XzoWgoI2IbrZsZeZYXVdzDgIk5Uf8PfiUe0fJXpdUL8bIFq3nQ+yX8U/FuChAw== integrity sha512-lhhK7914sUtuWYcDO8LV7NQkvTIwpAZlYH0XEOC/OTiYRQJvtKbEySLvefvtwuGjx7cGNI6OYraUsY3WWoK3FA==
turbo-linux-64@1.0.24-canary.2: turbo-linux-64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.0.24-canary.2.tgz#18331dfc7b1d0f89a5f82cc17383571186779e85" resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.0.24.tgz#221e3e14037e8fc3108e12a62de209d8a47f0348"
integrity sha512-v8+u+gV01UsCl7psvptL+mr863lrVr3L1j0CFKXhjyZQJlEgXoRbkxU4VUWaXwf9ABaadsPsmv9B2oQnudypeA== integrity sha512-EbfdrkwVsHDG7AIVQ1enWHoD6riAApx4VRAuFcQHTvJU9e+BuOQBMjb7e9jO4mUrpumtN3n20tP+86odRwsk5g==
turbo-linux-arm64@1.0.24-canary.2: turbo-linux-arm64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.0.24-canary.2.tgz#8873aa5b812f668934d16b69d59f5030b3d9fc8e" resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.0.24.tgz#95891e7d4375ccbf2478677568557948be33717a"
integrity sha512-v3/XDIQ9Cyz1dfPXnuJyH2V4xIDGPw9SaQS3FPiGH6TvUULkFYphg+cLttk7DPo3573GDxUQug3c5TvlIZFO8Q== integrity sha512-H4rqlgP2L7G3iAB/un/7DclExzLUkQ1NoZ0p/1Oa7Wb8H1YUlc8GkwUmpIFd5AOFSPL75DjYvlS8T5Tm23i+1A==
turbo-linux-arm@1.0.24-canary.2: turbo-linux-arm@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-arm/-/turbo-linux-arm-1.0.24-canary.2.tgz#22126953b881f952a1b5f480a3053c514cf314aa" resolved "https://registry.yarnpkg.com/turbo-linux-arm/-/turbo-linux-arm-1.0.24.tgz#f5acb74170a8b5a787915e799e7b52840c7c6982"
integrity sha512-pkfSuuOjN8DDJDOAdx0jqwTL4jeEsZbjmgNnhgrkb1HwVjQCjmqHbh15Wa2E51G12/Ctr6h2aKit2kbLXPfGWw== integrity sha512-lCNDVEkwxcn0acyPFVJgV5N5vKAP4LfXb+8uW/JpGHVoPHSONKtzYQG05J1KbHXpIjUT+DNgFtshtsdZYOewZQ==
turbo-linux-mips64le@1.0.24-canary.2: turbo-linux-mips64le@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-mips64le/-/turbo-linux-mips64le-1.0.24-canary.2.tgz#11254292c40ac8acef8452a379bb74bad39bf858" resolved "https://registry.yarnpkg.com/turbo-linux-mips64le/-/turbo-linux-mips64le-1.0.24.tgz#f2cc99570222ac42fdcc0d0638f13bc0176859f9"
integrity sha512-35WJMoIxfIG8ruoMJVdOCxuQlJjNeLbxbqigDizJh2zkjlJITzEK+WwmF9KrqzMaN7Ks4LPq+xd/YvBEULx4Zw== integrity sha512-AmrgQUDIe9AdNyh5YrI6pfMTUHD/gYfbylNmedLuN5Al3xINdZObcISzd/7VWd+V8wNW/1b9lUnt70Rv/KExfA==
turbo-linux-ppc64le@1.0.24-canary.2: turbo-linux-ppc64le@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-linux-ppc64le/-/turbo-linux-ppc64le-1.0.24-canary.2.tgz#abc93ede90deca66f20c23fedc0f2b032e43a48a" resolved "https://registry.yarnpkg.com/turbo-linux-ppc64le/-/turbo-linux-ppc64le-1.0.24.tgz#4d9508290d24cfdbaca24e57d8bcd0127281e2ed"
integrity sha512-ZCipAVam+ZKyiQ5KYrL8pPgQ06JJ09H6mqTxEAP0OP2kN6Se+TuycFjAsedE0Gn/G+6MBmStjR99ha+I2fRnDA== integrity sha512-+6ESjsfrvRUr1AsurNcRTrqYr+XHG8g763+hXLog1MP9mn1cufZqWlAyE4G8/MLXDHsEKgK+tXqPLIyLBRjLEw==
turbo-windows-32@1.0.24-canary.2: turbo-windows-32@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-windows-32/-/turbo-windows-32-1.0.24-canary.2.tgz#c24ae090c84c4ad83833deaeb0dba68186fe8e7d" resolved "https://registry.yarnpkg.com/turbo-windows-32/-/turbo-windows-32-1.0.24.tgz#2bf906c0cc9d675afc4693221fc339ade29e6c13"
integrity sha512-b9FBMoxhunGQNN/DwT9KK7g4FwDG3/cc4DEV5TdD809I66LmyVk1ThvpnUdZIRZRymcWq1c6kjT+0LfJFEBxGw== integrity sha512-pqRys+FfHxuLVmW/AariITL5qpItp4WPAsYnWLx4u7VpCOO/qmTAI/SL7/jnTm4gxjBv3uf//lisu0AvEZd+TA==
turbo-windows-64@1.0.24-canary.2: turbo-windows-64@1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.0.24-canary.2.tgz#f783fcb3661634cc3c1f578fdc79441ed3de5f8e" resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.0.24.tgz#5dd30b10110f2bb69caa479ddd72b4c471fb0dea"
integrity sha512-HqXW3k+5h4nP8eCg5i/0NhUlR4+3vxTSyI3UvoACVz/IkiUIwVKF9ZhlAfFpkRCYU+1KKOMs7iDWlYiTyW4Inw== integrity sha512-YHAWha5XkW0Ate1HtwhzFD32kZFXtC8KB4ReEvHc9GM2inQob1ZinvktS0xi5MC5Sxl9+bObOWmsxeZPOgNCFA==
turbo@^1.0.24-canary.2: turbo@^1.0.24:
version "1.0.24-canary.2" version "1.0.24"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-1.0.24-canary.2.tgz#7b7c134d4e6dce0cf6e9e7b09393dde9767c397e" resolved "https://registry.yarnpkg.com/turbo/-/turbo-1.0.24.tgz#5efdeb44aab2f5e97b24a3e0ed4a159bfcd0a877"
integrity sha512-Ve/KYeSxPBGKB6qmHc7BBZhZ7AaFH6WeUjd97IB38zfEtrcLlQ0xziY8EX7qiACarbrV9TKkYG5QctI5OIbD9A== integrity sha512-bfOr7iW48+chDl+yKiZ5FIWzXOF6xOIyrAGPaWI+I5CdD27IZCEGvqvTV/weaHvjLbV7otybHQ56XCybBlVjoA==
optionalDependencies: optionalDependencies:
turbo-darwin-64 "1.0.24-canary.2" turbo-darwin-64 "1.0.24"
turbo-darwin-arm64 "1.0.24-canary.2" turbo-darwin-arm64 "1.0.24"
turbo-freebsd-64 "1.0.24-canary.2" turbo-freebsd-64 "1.0.24"
turbo-freebsd-arm64 "1.0.24-canary.2" turbo-freebsd-arm64 "1.0.24"
turbo-linux-32 "1.0.24-canary.2" turbo-linux-32 "1.0.24"
turbo-linux-64 "1.0.24-canary.2" turbo-linux-64 "1.0.24"
turbo-linux-arm "1.0.24-canary.2" turbo-linux-arm "1.0.24"
turbo-linux-arm64 "1.0.24-canary.2" turbo-linux-arm64 "1.0.24"
turbo-linux-mips64le "1.0.24-canary.2" turbo-linux-mips64le "1.0.24"
turbo-linux-ppc64le "1.0.24-canary.2" turbo-linux-ppc64le "1.0.24"
turbo-windows-32 "1.0.24-canary.2" turbo-windows-32 "1.0.24"
turbo-windows-64 "1.0.24-canary.2" turbo-windows-64 "1.0.24"
tweetnacl@^0.14.3, tweetnacl@~0.14.0: tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"