feat: Heatmaps toolbar code (#21630)
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
@ -209,6 +209,7 @@ export const FEATURE_FLAGS = {
|
|||||||
SESSION_REPLAY_MOBILE_ONBOARDING: 'session-replay-mobile-onboarding', // owner: #team-replay
|
SESSION_REPLAY_MOBILE_ONBOARDING: 'session-replay-mobile-onboarding', // owner: #team-replay
|
||||||
IP_ALLOWLIST_SETTING: 'ip-allowlist-setting', // owner: @benjackwhite
|
IP_ALLOWLIST_SETTING: 'ip-allowlist-setting', // owner: @benjackwhite
|
||||||
EMAIL_VERIFICATION_TICKET_SUBMISSION: 'email-verification-ticket-submission', // owner: #team-growth
|
EMAIL_VERIFICATION_TICKET_SUBMISSION: 'email-verification-ticket-submission', // owner: #team-growth
|
||||||
|
TOOLBAR_HEATMAPS: 'toolbar-heatmaps', // owner: #team-replay
|
||||||
THEME: 'theme', // owner: @aprilfools
|
THEME: 'theme', // owner: @aprilfools
|
||||||
} as const
|
} as const
|
||||||
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
|
||||||
|
@ -43,6 +43,9 @@ export function loadPostHogJS(): void {
|
|||||||
capture_copied_text: true,
|
capture_copied_text: true,
|
||||||
},
|
},
|
||||||
process_person: 'identified_only',
|
process_person: 'identified_only',
|
||||||
|
|
||||||
|
__preview_heatmaps: true,
|
||||||
|
|
||||||
// Helper to capture events for assertions in Cypress
|
// Helper to capture events for assertions in Cypress
|
||||||
_onCapture: (window as any)._cypress_posthog_captures
|
_onCapture: (window as any)._cypress_posthog_captures
|
||||||
? (_, event) => (window as any)._cypress_posthog_captures.push(event)
|
? (_, event) => (window as any)._cypress_posthog_captures.push(event)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IconPencil, IconPlus, IconSearch, IconTrash, IconX } from '@posthog/icons'
|
import { IconPencil, IconPlus, IconSearch, IconTrash } from '@posthog/icons'
|
||||||
import { LemonDivider, LemonTag } from '@posthog/lemon-ui'
|
import { LemonDivider, LemonTag } from '@posthog/lemon-ui'
|
||||||
import { useActions, useValues } from 'kea'
|
import { useActions, useValues } from 'kea'
|
||||||
import { Field, Form, Group } from 'kea-forms'
|
import { Field, Form, Group } from 'kea-forms'
|
||||||
@ -9,7 +9,7 @@ import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic'
|
|||||||
import { SelectorEditingModal } from '~/toolbar/actions/SelectorEditingModal'
|
import { SelectorEditingModal } from '~/toolbar/actions/SelectorEditingModal'
|
||||||
import { StepField } from '~/toolbar/actions/StepField'
|
import { StepField } from '~/toolbar/actions/StepField'
|
||||||
import { ToolbarMenu } from '~/toolbar/bar/ToolbarMenu'
|
import { ToolbarMenu } from '~/toolbar/bar/ToolbarMenu'
|
||||||
import { posthog } from '~/toolbar/posthog'
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
|
|
||||||
export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
@ -38,7 +38,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
|||||||
startingSelector={editingSelectorValue}
|
startingSelector={editingSelectorValue}
|
||||||
onChange={(selector) => {
|
onChange={(selector) => {
|
||||||
if (selector && editingSelector !== null) {
|
if (selector && editingSelector !== null) {
|
||||||
posthog.capture('toolbar_manual_selector_applied', {
|
toolbarPosthogJS.capture('toolbar_manual_selector_applied', {
|
||||||
chosenSelector: selector,
|
chosenSelector: selector,
|
||||||
})
|
})
|
||||||
setElementSelector(selector, editingSelector)
|
setElementSelector(selector, editingSelector)
|
||||||
@ -52,7 +52,7 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
|||||||
enableFormOnSubmit
|
enableFormOnSubmit
|
||||||
className="flex flex-col overflow-hidden flex-1"
|
className="flex flex-col overflow-hidden flex-1"
|
||||||
>
|
>
|
||||||
<ToolbarMenu.Header>
|
<ToolbarMenu.Header className="border-b">
|
||||||
<h1 className="p-1 font-bold text-sm mb-0">
|
<h1 className="p-1 font-bold text-sm mb-0">
|
||||||
{selectedActionId === 'new' ? 'New ' : 'Edit '}
|
{selectedActionId === 'new' ? 'New ' : 'Edit '}
|
||||||
action
|
action
|
||||||
@ -124,9 +124,12 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
|||||||
icon={<IconPencil />}
|
icon={<IconPencil />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
posthog.capture('toolbar_manual_selector_modal_opened', {
|
toolbarPosthogJS.capture(
|
||||||
selector: step?.selector,
|
'toolbar_manual_selector_modal_opened',
|
||||||
})
|
{
|
||||||
|
selector: step?.selector,
|
||||||
|
}
|
||||||
|
)
|
||||||
editSelectorWithIndex(index)
|
editSelectorWithIndex(index)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -198,22 +201,25 @@ export const ActionsEditingToolbarMenu = (): JSX.Element => {
|
|||||||
</ToolbarMenu.Body>
|
</ToolbarMenu.Body>
|
||||||
<ToolbarMenu.Footer>
|
<ToolbarMenu.Footer>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<LemonButton
|
{selectedActionId !== 'new' ? (
|
||||||
type="secondary"
|
<LemonButton
|
||||||
size="small"
|
type="secondary"
|
||||||
onClick={() => selectAction(null)}
|
status="danger"
|
||||||
sideIcon={<IconX />}
|
onClick={deleteAction}
|
||||||
>
|
icon={<IconTrash />}
|
||||||
Cancel
|
size="small"
|
||||||
</LemonButton>
|
>
|
||||||
|
Delete
|
||||||
|
</LemonButton>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<LemonButton type="primary" htmlType="submit">
|
<LemonButton type="secondary" size="small" onClick={() => selectAction(null)}>
|
||||||
|
Cancel
|
||||||
|
</LemonButton>
|
||||||
|
<LemonButton type="primary" htmlType="submit" size="small">
|
||||||
{selectedActionId === 'new' ? 'Create ' : 'Save '}
|
{selectedActionId === 'new' ? 'Create ' : 'Save '}
|
||||||
action
|
action
|
||||||
</LemonButton>
|
</LemonButton>
|
||||||
{selectedActionId !== 'new' ? (
|
|
||||||
<LemonButton type="secondary" status="danger" onClick={deleteAction} icon={<IconTrash />} />
|
|
||||||
) : null}
|
|
||||||
</ToolbarMenu.Footer>
|
</ToolbarMenu.Footer>
|
||||||
</Form>
|
</Form>
|
||||||
</ToolbarMenu>
|
</ToolbarMenu>
|
||||||
|
@ -23,7 +23,7 @@ export function ActionsListView({ actions }: ActionsListViewProps): JSX.Element
|
|||||||
subtle
|
subtle
|
||||||
key={action.id}
|
key={action.id}
|
||||||
onClick={() => selectAction(action.id || null)}
|
onClick={() => selectAction(action.id || null)}
|
||||||
className="font-medium my-1"
|
className="font-medium my-1 w-full"
|
||||||
>
|
>
|
||||||
<span className="min-w-[2rem] inline-block text-left">{index + 1}.</span>
|
<span className="min-w-[2rem] inline-block text-left">{index + 1}.</span>
|
||||||
<span className="flex-grow">
|
<span className="flex-grow">
|
||||||
|
@ -7,8 +7,8 @@ import { urls } from 'scenes/urls'
|
|||||||
|
|
||||||
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
||||||
import { toolbarLogic } from '~/toolbar/bar/toolbarLogic'
|
import { toolbarLogic } from '~/toolbar/bar/toolbarLogic'
|
||||||
import { posthog } from '~/toolbar/posthog'
|
|
||||||
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
||||||
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
import { ActionDraftType, ActionForm } from '~/toolbar/types'
|
import { ActionDraftType, ActionForm } from '~/toolbar/types'
|
||||||
import { actionStepToActionStepFormItem, elementToActionStep, stepToDatabaseFormat } from '~/toolbar/utils'
|
import { actionStepToActionStepFormItem, elementToActionStep, stepToDatabaseFormat } from '~/toolbar/utils'
|
||||||
import { ActionType, ElementType } from '~/types'
|
import { ActionType, ElementType } from '~/types'
|
||||||
@ -292,11 +292,11 @@ export const actionsTabLogic = kea<actionsTabLogicType>([
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showButtonActions: () => {
|
showButtonActions: () => {
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: true })
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: true })
|
||||||
},
|
},
|
||||||
hideButtonActions: () => {
|
hideButtonActions: () => {
|
||||||
actions.setShowActionsTooltip(false)
|
actions.setShowActionsTooltip(false)
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'actions', enabled: false })
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'actions', enabled: false })
|
||||||
},
|
},
|
||||||
[actionsLogic.actionTypes.getActionsSuccess]: () => {
|
[actionsLogic.actionTypes.getActionsSuccess]: () => {
|
||||||
const { userIntent, actionId } = values
|
const { userIntent, actionId } = values
|
||||||
|
@ -1,15 +1,24 @@
|
|||||||
export function ToolbarMenu({ children }: { children: React.ReactNode }): JSX.Element {
|
import clsx from 'clsx'
|
||||||
return <div className="w-full h-full flex flex-col overflow-hidden">{children}</div>
|
|
||||||
|
export type ToolbarMenuProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarMenu.Header = function ToolbarMenuHeader({ children }: { children: React.ReactNode }): JSX.Element {
|
export function ToolbarMenu({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||||
return <div className="pt-1 px-1">{children}</div>
|
return <div className={clsx('w-full h-full flex flex-col overflow-hidden', className)}>{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarMenu.Body = function ToolbarMenuBody({ children }: { children: React.ReactNode }): JSX.Element {
|
ToolbarMenu.Header = function ToolbarMenuHeader({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||||
return <div className="flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20">{children}</div>
|
return <div className={clsx('pt-1 px-1', className)}>{children}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarMenu.Footer = function ToolbarMenufooter({ children }: { children: React.ReactNode }): JSX.Element {
|
ToolbarMenu.Body = function ToolbarMenuBody({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||||
return <div className="flex flex-row items-center p-2 border-t">{children}</div>
|
return (
|
||||||
|
<div className={clsx('flex flex-col flex-1 h-full overflow-y-auto px-1 min-h-20', className)}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolbarMenu.Footer = function ToolbarMenufooter({ children, className }: ToolbarMenuProps): JSX.Element {
|
||||||
|
return <div className={clsx('flex flex-row items-center p-2 border-t gap-2', className)}>{children}</div>
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ElementRect } from '~/toolbar/types'
|
import { ElementRect } from '~/toolbar/types'
|
||||||
|
|
||||||
interface HeatmapElementProps {
|
interface AutocaptureElementProps {
|
||||||
rect?: ElementRect
|
rect?: ElementRect
|
||||||
style: Record<string, any>
|
style: Record<string, any>
|
||||||
onClick: (event: React.MouseEvent) => void
|
onClick: (event: React.MouseEvent) => void
|
||||||
@ -8,13 +8,13 @@ interface HeatmapElementProps {
|
|||||||
onMouseOut: (event: React.MouseEvent) => void
|
onMouseOut: (event: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeatmapElement({
|
export function AutocaptureElement({
|
||||||
rect,
|
rect,
|
||||||
style = {},
|
style = {},
|
||||||
onClick,
|
onClick,
|
||||||
onMouseOver,
|
onMouseOver,
|
||||||
onMouseOut,
|
onMouseOut,
|
||||||
}: HeatmapElementProps): JSX.Element | null {
|
}: AutocaptureElementProps): JSX.Element | null {
|
||||||
if (!rect) {
|
if (!rect) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
@ -12,18 +12,18 @@ const heatmapLabelStyle = {
|
|||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeatmapLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
|
interface AutocaptureElementLabelProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
|
||||||
rect?: ElementRect
|
rect?: ElementRect
|
||||||
align?: 'left' | 'right'
|
align?: 'left' | 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeatmapLabel({
|
export function AutocaptureElementLabel({
|
||||||
rect,
|
rect,
|
||||||
style = {},
|
style = {},
|
||||||
align = 'right',
|
align = 'right',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: HeatmapLabelProps): JSX.Element | null {
|
}: AutocaptureElementLabelProps): JSX.Element | null {
|
||||||
if (!rect) {
|
if (!rect) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
@ -81,7 +81,6 @@ export function ElementInfoWindow(): JSX.Element | null {
|
|||||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||||
backgroundBlendMode: 'multiply',
|
backgroundBlendMode: 'multiply',
|
||||||
background: 'white',
|
background: 'white',
|
||||||
boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
@ -111,8 +110,16 @@ export function ElementInfoWindow(): JSX.Element | null {
|
|||||||
<IconX />
|
<IconX />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* eslint-disable-next-line react/forbid-dom-props */}
|
<div
|
||||||
<div style={{ minHeight, maxHeight, overflow: 'auto' }}>
|
// eslint-disable-next-line react/forbid-dom-props
|
||||||
|
style={{
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
overflow: 'auto',
|
||||||
|
boxShadow: `hsla(4, 30%, 27%, 0.6) 0px 3px 10px 2px`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ElementInfo />
|
<ElementInfo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,14 +4,17 @@ import { useActions, useValues } from 'kea'
|
|||||||
import { compactNumber } from 'lib/utils'
|
import { compactNumber } from 'lib/utils'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
import { AutocaptureElement } from '~/toolbar/elements/AutocaptureElement'
|
||||||
|
import { AutocaptureElementLabel } from '~/toolbar/elements/AutocaptureElementLabel'
|
||||||
import { ElementInfoWindow } from '~/toolbar/elements/ElementInfoWindow'
|
import { ElementInfoWindow } from '~/toolbar/elements/ElementInfoWindow'
|
||||||
import { elementsLogic } from '~/toolbar/elements/elementsLogic'
|
import { elementsLogic } from '~/toolbar/elements/elementsLogic'
|
||||||
import { FocusRect } from '~/toolbar/elements/FocusRect'
|
import { FocusRect } from '~/toolbar/elements/FocusRect'
|
||||||
import { HeatmapElement } from '~/toolbar/elements/HeatmapElement'
|
|
||||||
import { HeatmapLabel } from '~/toolbar/elements/HeatmapLabel'
|
|
||||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||||
import { getBoxColors, getHeatMapHue } from '~/toolbar/utils'
|
import { getBoxColors, getHeatMapHue } from '~/toolbar/utils'
|
||||||
|
|
||||||
|
import { Heatmap } from './Heatmap'
|
||||||
|
import { ScrollDepth } from './ScrollDepth'
|
||||||
|
|
||||||
export function Elements(): JSX.Element {
|
export function Elements(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
heatmapElements,
|
heatmapElements,
|
||||||
@ -48,16 +51,18 @@ export function Elements(): JSX.Element {
|
|||||||
zIndex: 2147483010,
|
zIndex: 2147483010,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ScrollDepth />
|
||||||
|
<Heatmap />
|
||||||
{highlightElementMeta?.rect ? <FocusRect rect={highlightElementMeta.rect} /> : null}
|
{highlightElementMeta?.rect ? <FocusRect rect={highlightElementMeta.rect} /> : null}
|
||||||
|
|
||||||
{elementsToDisplay.map(({ rect, element }, index) => (
|
{elementsToDisplay.map(({ rect, element }, index) => (
|
||||||
<HeatmapElement
|
<AutocaptureElement
|
||||||
key={`inspect-${index}`}
|
key={`inspect-${index}`}
|
||||||
rect={rect}
|
rect={rect}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: heatmapPointerEvents,
|
pointerEvents: heatmapPointerEvents,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
zIndex: 0,
|
zIndex: hoverElement === element ? 2 : 1,
|
||||||
opacity:
|
opacity:
|
||||||
(!hoverElement && !selectedElement) ||
|
(!hoverElement && !selectedElement) ||
|
||||||
selectedElement === element ||
|
selectedElement === element ||
|
||||||
@ -65,6 +70,7 @@ export function Elements(): JSX.Element {
|
|||||||
? 1
|
? 1
|
||||||
: 0.4,
|
: 0.4,
|
||||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||||
|
borderRadius: 5,
|
||||||
...getBoxColors('blue', hoverElement === element || selectedElement === element),
|
...getBoxColors('blue', hoverElement === element || selectedElement === element),
|
||||||
}}
|
}}
|
||||||
onClick={() => selectElement(element)}
|
onClick={() => selectElement(element)}
|
||||||
@ -76,14 +82,15 @@ export function Elements(): JSX.Element {
|
|||||||
{heatmapElements.map(({ rect, count, clickCount, rageclickCount, element }, index) => {
|
{heatmapElements.map(({ rect, count, clickCount, rageclickCount, element }, index) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={`heatmap-${index}`}>
|
<Fragment key={`heatmap-${index}`}>
|
||||||
<HeatmapElement
|
<AutocaptureElement
|
||||||
rect={rect}
|
rect={rect}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: inspectEnabled ? 'none' : heatmapPointerEvents,
|
pointerEvents: inspectEnabled ? 'none' : heatmapPointerEvents,
|
||||||
zIndex: 1,
|
zIndex: hoverElement === element ? 4 : 3,
|
||||||
opacity: !hoverElement || hoverElement === element ? 1 : 0.4,
|
opacity: !hoverElement || hoverElement === element ? 1 : 0.4,
|
||||||
transition: 'opacity 0.2s, box-shadow 0.2s',
|
transition: 'opacity 0.2s, box-shadow 0.2s',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
borderRadius: 5,
|
||||||
...getBoxColors(
|
...getBoxColors(
|
||||||
'red',
|
'red',
|
||||||
hoverElement === element,
|
hoverElement === element,
|
||||||
@ -95,7 +102,7 @@ export function Elements(): JSX.Element {
|
|||||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||||
/>
|
/>
|
||||||
{!!clickCount && (
|
{!!clickCount && (
|
||||||
<HeatmapLabel
|
<AutocaptureElementLabel
|
||||||
rect={rect}
|
rect={rect}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: heatmapPointerEvents,
|
pointerEvents: heatmapPointerEvents,
|
||||||
@ -122,10 +129,10 @@ export function Elements(): JSX.Element {
|
|||||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||||
>
|
>
|
||||||
{compactNumber(clickCount || 0)}
|
{compactNumber(clickCount || 0)}
|
||||||
</HeatmapLabel>
|
</AutocaptureElementLabel>
|
||||||
)}
|
)}
|
||||||
{!!rageclickCount && (
|
{!!rageclickCount && (
|
||||||
<HeatmapLabel
|
<AutocaptureElementLabel
|
||||||
rect={rect}
|
rect={rect}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: heatmapPointerEvents,
|
pointerEvents: heatmapPointerEvents,
|
||||||
@ -153,7 +160,7 @@ export function Elements(): JSX.Element {
|
|||||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||||
>
|
>
|
||||||
{compactNumber(rageclickCount)}😡
|
{compactNumber(rageclickCount)}😡
|
||||||
</HeatmapLabel>
|
</AutocaptureElementLabel>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
@ -162,7 +169,7 @@ export function Elements(): JSX.Element {
|
|||||||
{labelsToDisplay.map(({ element, rect, index }, loopIndex) => {
|
{labelsToDisplay.map(({ element, rect, index }, loopIndex) => {
|
||||||
if (rect) {
|
if (rect) {
|
||||||
return (
|
return (
|
||||||
<HeatmapLabel
|
<AutocaptureElementLabel
|
||||||
key={`label-${loopIndex}`}
|
key={`label-${loopIndex}`}
|
||||||
rect={rect}
|
rect={rect}
|
||||||
align="left"
|
align="left"
|
||||||
@ -182,7 +189,7 @@ export function Elements(): JSX.Element {
|
|||||||
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
onMouseOut={() => selectedElement === null && setHoverElement(null)}
|
||||||
>
|
>
|
||||||
{(index || loopIndex) + 1}
|
{(index || loopIndex) + 1}
|
||||||
</HeatmapLabel>
|
</AutocaptureElementLabel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
46
frontend/src/toolbar/elements/Heatmap.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import heatmapsJs, { Heatmap as HeatmapJS } from 'heatmap.js'
|
||||||
|
import { useValues } from 'kea'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||||
|
|
||||||
|
export function Heatmap(): JSX.Element | null {
|
||||||
|
const { heatmapJsData, heatmapEnabled, heatmapFilters } = useValues(heatmapLogic)
|
||||||
|
const heatmapsJsRef = useRef<HeatmapJS<'value', 'x', 'y'>>()
|
||||||
|
const heatmapsJsContainerRef = useRef<HTMLDivElement | null>()
|
||||||
|
|
||||||
|
const updateHeatmapData = useCallback((): void => {
|
||||||
|
try {
|
||||||
|
heatmapsJsRef.current?.setData(heatmapJsData)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('error setting data', e)
|
||||||
|
}
|
||||||
|
}, [heatmapJsData])
|
||||||
|
|
||||||
|
const setHeatmapContainer = useCallback((container: HTMLDivElement | null): void => {
|
||||||
|
heatmapsJsContainerRef.current = container
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
heatmapsJsRef.current = heatmapsJs.create({
|
||||||
|
container,
|
||||||
|
})
|
||||||
|
|
||||||
|
updateHeatmapData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateHeatmapData()
|
||||||
|
}, [heatmapJsData])
|
||||||
|
|
||||||
|
if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type === 'scrolldepth') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0" ref={setHeatmapContainer} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
121
frontend/src/toolbar/elements/ScrollDepth.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useValues } from 'kea'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||||
|
|
||||||
|
import { toolbarConfigLogic } from '../toolbarConfigLogic'
|
||||||
|
|
||||||
|
function ScrollDepthMouseInfo(): JSX.Element | null {
|
||||||
|
const { posthog } = useValues(toolbarConfigLogic)
|
||||||
|
const { heatmapElements, rawHeatmapLoading } = useValues(heatmapLogic)
|
||||||
|
|
||||||
|
// Track the mouse position and render an indicator about how many people have scrolled to this point
|
||||||
|
const [mouseY, setMouseY] = useState<null | number>(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent): void => {
|
||||||
|
setMouseY(e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!mouseY) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollOffset = (posthog as any).scrollManager.scrollY()
|
||||||
|
const scrolledMouseY = mouseY + scrollOffset
|
||||||
|
|
||||||
|
const elementInMouseY = heatmapElements.find((x, i) => {
|
||||||
|
const lastY = heatmapElements[i - 1]?.y ?? 0
|
||||||
|
return scrolledMouseY >= lastY && scrolledMouseY < x.y
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxCount = heatmapElements[0]?.count ?? 0
|
||||||
|
const percentage = ((elementInMouseY?.count ?? 0) / maxCount) * 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 flex items-center z-10"
|
||||||
|
// eslint-disable-next-line react/forbid-dom-props
|
||||||
|
style={{
|
||||||
|
top: mouseY,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="border-b w-full" />
|
||||||
|
<div className="bg-border whitespace-nowrap text-default rounded p-2 font-semibold">
|
||||||
|
{rawHeatmapLoading ? (
|
||||||
|
<>Loading...</>
|
||||||
|
) : heatmapElements.length ? (
|
||||||
|
<>{percentage.toPrecision(4)}% scrolled this far</>
|
||||||
|
) : (
|
||||||
|
<>No scroll data for the current dimension range</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b w-10" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollDepth(): JSX.Element | null {
|
||||||
|
const { posthog } = useValues(toolbarConfigLogic)
|
||||||
|
|
||||||
|
const { heatmapEnabled, heatmapFilters, heatmapElements, scrollDepthPosthogJsError } = useValues(heatmapLogic)
|
||||||
|
|
||||||
|
if (!heatmapEnabled || !heatmapFilters.enabled || heatmapFilters.type !== 'scrolldepth') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollDepthPosthogJsError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollOffset = (posthog as any).scrollManager.scrollY()
|
||||||
|
|
||||||
|
// We want to have a fading color from red to orange to green to blue to grey, fading from the highest count to the lowest
|
||||||
|
const maxCount = heatmapElements[0]?.count ?? 0
|
||||||
|
|
||||||
|
function color(count: number): string {
|
||||||
|
const value = 1 - count / maxCount
|
||||||
|
const safeValue = Math.max(0, Math.min(1, value))
|
||||||
|
const hue = Math.round(260 * safeValue)
|
||||||
|
|
||||||
|
// Return hsl color. You can adjust saturation and lightness to your liking
|
||||||
|
return `hsl(${hue}, 100%, 50%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0"
|
||||||
|
// eslint-disable-next-line react/forbid-dom-props
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${-scrollOffset}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{heatmapElements.map(({ y, count }, i) => (
|
||||||
|
<div
|
||||||
|
key={y}
|
||||||
|
// eslint-disable-next-line react/forbid-dom-props
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: heatmapElements[i - 1]?.y ?? 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: y - (heatmapElements[i - 1]?.y ?? 0),
|
||||||
|
backgroundColor: color(count),
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ScrollDepthMouseInfo />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -3,9 +3,9 @@ import { collectAllElementsDeep } from 'query-selector-shadow-dom'
|
|||||||
|
|
||||||
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
import { actionsLogic } from '~/toolbar/actions/actionsLogic'
|
||||||
import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic'
|
import { actionsTabLogic } from '~/toolbar/actions/actionsTabLogic'
|
||||||
import { posthog } from '~/toolbar/posthog'
|
|
||||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||||
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
|
||||||
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
import { ActionElementWithMetadata, ElementWithMetadata } from '~/toolbar/types'
|
import { ActionElementWithMetadata, ElementWithMetadata } from '~/toolbar/types'
|
||||||
|
|
||||||
import { elementToActionStep, getAllClickTargets, getElementForStep, getRectForElement } from '../utils'
|
import { elementToActionStep, getAllClickTargets, getElementForStep, getRectForElement } from '../utils'
|
||||||
@ -371,11 +371,11 @@ export const elementsLogic = kea<elementsLogicType>([
|
|||||||
}),
|
}),
|
||||||
listeners(({ actions }) => ({
|
listeners(({ actions }) => ({
|
||||||
enableInspect: () => {
|
enableInspect: () => {
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: true })
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: true })
|
||||||
actionsLogic.actions.getActions()
|
actionsLogic.actions.getActions()
|
||||||
},
|
},
|
||||||
disableInspect: () => {
|
disableInspect: () => {
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'inspect', enabled: false })
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'inspect', enabled: false })
|
||||||
},
|
},
|
||||||
selectElement: ({ element }) => {
|
selectElement: ({ element }) => {
|
||||||
const inspectForAction =
|
const inspectForAction =
|
||||||
@ -401,7 +401,7 @@ export const elementsLogic = kea<elementsLogicType>([
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posthog.capture('toolbar selected HTML element', {
|
toolbarPosthogJS.capture('toolbar selected HTML element', {
|
||||||
element_tag: element?.tagName.toLowerCase(),
|
element_tag: element?.tagName.toLowerCase(),
|
||||||
element_type: (element as HTMLInputElement)?.type,
|
element_type: (element as HTMLInputElement)?.type,
|
||||||
has_href: !!(element as HTMLAnchorElement)?.href,
|
has_href: !!(element as HTMLAnchorElement)?.href,
|
||||||
|
@ -1,30 +1,66 @@
|
|||||||
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
||||||
import { loaders } from 'kea-loaders'
|
import { loaders } from 'kea-loaders'
|
||||||
import { encodeParams } from 'kea-router'
|
import { encodeParams } from 'kea-router'
|
||||||
|
import { subscriptions } from 'kea-subscriptions'
|
||||||
|
import { windowValues } from 'kea-window-values'
|
||||||
import { elementToSelector, escapeRegex } from 'lib/actionUtils'
|
import { elementToSelector, escapeRegex } from 'lib/actionUtils'
|
||||||
import { PaginatedResponse } from 'lib/api'
|
import { PaginatedResponse } from 'lib/api'
|
||||||
import { dateFilterToText } from 'lib/utils'
|
import { dateFilterToText } from 'lib/utils'
|
||||||
|
import { PostHog } from 'posthog-js'
|
||||||
import { collectAllElementsDeep, querySelectorAllDeep } from 'query-selector-shadow-dom'
|
import { collectAllElementsDeep, querySelectorAllDeep } from 'query-selector-shadow-dom'
|
||||||
|
|
||||||
import { posthog } from '~/toolbar/posthog'
|
|
||||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||||
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
||||||
import { CountedHTMLElement, ElementsEventType } from '~/toolbar/types'
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
|
import {
|
||||||
|
CountedHTMLElement,
|
||||||
|
ElementsEventType,
|
||||||
|
HeatmapElement,
|
||||||
|
HeatmapRequestType,
|
||||||
|
HeatmapResponseType,
|
||||||
|
} from '~/toolbar/types'
|
||||||
import { elementToActionStep, trimElement } from '~/toolbar/utils'
|
import { elementToActionStep, trimElement } from '~/toolbar/utils'
|
||||||
import { FilterType, PropertyFilterType, PropertyOperator } from '~/types'
|
import { FilterType, PropertyFilterType, PropertyOperator } from '~/types'
|
||||||
|
|
||||||
import type { heatmapLogicType } from './heatmapLogicType'
|
import type { heatmapLogicType } from './heatmapLogicType'
|
||||||
|
|
||||||
|
export const SCROLL_DEPTH_JS_VERSION = [1, 99]
|
||||||
|
|
||||||
const emptyElementsStatsPages: PaginatedResponse<ElementsEventType> = {
|
const emptyElementsStatsPages: PaginatedResponse<ElementsEventType> = {
|
||||||
next: undefined,
|
next: undefined,
|
||||||
previous: undefined,
|
previous: undefined,
|
||||||
results: [],
|
results: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommonFilters = {
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeatmapFilters = {
|
||||||
|
enabled: boolean
|
||||||
|
type?: string
|
||||||
|
viewportAccuracy?: number
|
||||||
|
aggregation?: HeatmapRequestType['aggregation']
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeatmapJsDataPoint = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeatmapJsData = {
|
||||||
|
data: HeatmapJsDataPoint[]
|
||||||
|
max: number
|
||||||
|
min: number
|
||||||
|
}
|
||||||
|
export type HeatmapFixedPositionMode = 'fixed' | 'relative' | 'hidden'
|
||||||
|
|
||||||
export const heatmapLogic = kea<heatmapLogicType>([
|
export const heatmapLogic = kea<heatmapLogicType>([
|
||||||
path(['toolbar', 'elements', 'heatmapLogic']),
|
path(['toolbar', 'elements', 'heatmapLogic']),
|
||||||
connect({
|
connect({
|
||||||
values: [currentPageLogic, ['href', 'wildcardHref']],
|
values: [currentPageLogic, ['href', 'wildcardHref'], toolbarConfigLogic, ['posthog']],
|
||||||
actions: [currentPageLogic, ['setHref', 'setWildcardHref']],
|
actions: [currentPageLogic, ['setHref', 'setWildcardHref']],
|
||||||
}),
|
}),
|
||||||
actions({
|
actions({
|
||||||
@ -33,12 +69,27 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
}),
|
}),
|
||||||
enableHeatmap: true,
|
enableHeatmap: true,
|
||||||
disableHeatmap: true,
|
disableHeatmap: true,
|
||||||
setShowHeatmapTooltip: (showHeatmapTooltip: boolean) => ({ showHeatmapTooltip }),
|
|
||||||
setShiftPressed: (shiftPressed: boolean) => ({ shiftPressed }),
|
setShiftPressed: (shiftPressed: boolean) => ({ shiftPressed }),
|
||||||
setHeatmapFilter: (filter: Partial<FilterType>) => ({ filter }),
|
setCommonFilters: (filters: CommonFilters) => ({ filters }),
|
||||||
|
setHeatmapFilters: (filters: HeatmapFilters) => ({ filters }),
|
||||||
|
patchHeatmapFilters: (filters: Partial<HeatmapFilters>) => ({ filters }),
|
||||||
|
toggleClickmapsEnabled: (enabled?: boolean) => ({ enabled }),
|
||||||
|
|
||||||
loadMoreElementStats: true,
|
loadMoreElementStats: true,
|
||||||
setMatchLinksByHref: (matchLinksByHref: boolean) => ({ matchLinksByHref }),
|
setMatchLinksByHref: (matchLinksByHref: boolean) => ({ matchLinksByHref }),
|
||||||
|
loadHeatmap: (type: string) => ({
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
loadAllEnabled: (delayMs: number = 0) => ({ delayMs }),
|
||||||
|
maybeLoadClickmap: (delayMs: number = 0) => ({ delayMs }),
|
||||||
|
maybeLoadHeatmap: (delayMs: number = 0) => ({ delayMs }),
|
||||||
|
fetchHeatmapApi: (params: HeatmapRequestType) => ({ params }),
|
||||||
|
setHeatmapScrollY: (scrollY: number) => ({ scrollY }),
|
||||||
|
setHeatmapFixedPositionMode: (mode: HeatmapFixedPositionMode) => ({ mode }),
|
||||||
}),
|
}),
|
||||||
|
windowValues(() => ({
|
||||||
|
windowWidth: (window: Window) => window.innerWidth,
|
||||||
|
})),
|
||||||
reducers({
|
reducers({
|
||||||
matchLinksByHref: [false, { setMatchLinksByHref: (_, { matchLinksByHref }) => matchLinksByHref }],
|
matchLinksByHref: [false, { setMatchLinksByHref: (_, { matchLinksByHref }) => matchLinksByHref }],
|
||||||
canLoadMoreElementStats: [
|
canLoadMoreElementStats: [
|
||||||
@ -56,31 +107,49 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
getElementStatsFailure: () => false,
|
getElementStatsFailure: () => false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
heatmapLoading: [
|
|
||||||
false,
|
|
||||||
{
|
|
||||||
getElementStats: () => true,
|
|
||||||
getElementStatsSuccess: () => false,
|
|
||||||
getElementStatsFailure: () => false,
|
|
||||||
resetElementStats: () => false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
showHeatmapTooltip: [
|
|
||||||
false,
|
|
||||||
{
|
|
||||||
setShowHeatmapTooltip: (_, { showHeatmapTooltip }) => showHeatmapTooltip,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
shiftPressed: [
|
shiftPressed: [
|
||||||
false,
|
false,
|
||||||
{
|
{
|
||||||
setShiftPressed: (_, { shiftPressed }) => shiftPressed,
|
setShiftPressed: (_, { shiftPressed }) => shiftPressed,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
heatmapFilter: [
|
commonFilters: [
|
||||||
{} as Partial<FilterType>,
|
{} as CommonFilters,
|
||||||
{
|
{
|
||||||
setHeatmapFilter: (_, { filter }) => filter,
|
setCommonFilters: (_, { filters }) => filters,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
heatmapFilters: [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
type: 'click',
|
||||||
|
viewportAccuracy: 0.9,
|
||||||
|
aggregation: 'total_count',
|
||||||
|
} as HeatmapFilters,
|
||||||
|
{ persist: true },
|
||||||
|
{
|
||||||
|
setHeatmapFilters: (_, { filters }) => filters,
|
||||||
|
patchHeatmapFilters: (state, { filters }) => ({ ...state, ...filters }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clickmapsEnabled: [
|
||||||
|
true,
|
||||||
|
{ persist: true },
|
||||||
|
{
|
||||||
|
toggleClickmapsEnabled: (state, { enabled }) => (enabled === undefined ? !state : enabled),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
heatmapScrollY: [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
setHeatmapScrollY: (_, { scrollY }) => scrollY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
heatmapFixedPositionMode: [
|
||||||
|
'fixed' as HeatmapFixedPositionMode,
|
||||||
|
{
|
||||||
|
setHeatmapFixedPositionMode: (_, { mode }) => mode,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@ -110,7 +179,8 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
type: PropertyFilterType.Event,
|
type: PropertyFilterType.Event,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
...values.heatmapFilter,
|
date_from: values.commonFilters.date_from,
|
||||||
|
date_to: values.commonFilters.date_to,
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultUrl = `/api/element/stats/${encodeParams({ ...params, paginate_response: true }, '?')}`
|
defaultUrl = `/api/element/stats/${encodeParams({ ...params, paginate_response: true }, '?')}`
|
||||||
@ -144,22 +214,58 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
rawHeatmap: [
|
||||||
|
null as HeatmapResponseType | null,
|
||||||
|
{
|
||||||
|
loadHeatmap: async () => {
|
||||||
|
const { href, wildcardHref } = values
|
||||||
|
const { date_from, date_to } = values.commonFilters
|
||||||
|
const { type, aggregation } = values.heatmapFilters
|
||||||
|
const urlExact = wildcardHref === href ? href : undefined
|
||||||
|
const urlRegex = wildcardHref !== href ? wildcardHref : undefined
|
||||||
|
|
||||||
|
// toolbar fetch collapses queryparams but this URL has multiple with the same name
|
||||||
|
const response = await toolbarFetch(
|
||||||
|
`/api/heatmap/${encodeParams(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
url_exact: urlExact,
|
||||||
|
url_pattern: urlRegex,
|
||||||
|
viewport_width_min: values.viewportRange.min,
|
||||||
|
viewport_width_max: values.viewportRange.max,
|
||||||
|
aggregation,
|
||||||
|
},
|
||||||
|
'?'
|
||||||
|
)}`,
|
||||||
|
'GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
toolbarConfigLogic.actions.authenticate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error('API error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
})),
|
})),
|
||||||
|
|
||||||
selectors(({ cache }) => ({
|
selectors(({ cache }) => ({
|
||||||
dateRange: [
|
dateRange: [
|
||||||
(s) => [s.heatmapFilter],
|
(s) => [s.commonFilters],
|
||||||
(heatmapFilter: Partial<FilterType>) => {
|
(commonFilters: Partial<FilterType>) => {
|
||||||
return dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')
|
return dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
elements: [
|
elements: [
|
||||||
(selectors) => [
|
(s) => [s.elementStats, toolbarConfigLogic.selectors.dataAttributes, s.href, s.matchLinksByHref],
|
||||||
selectors.elementStats,
|
|
||||||
toolbarConfigLogic.selectors.dataAttributes,
|
|
||||||
selectors.href,
|
|
||||||
selectors.matchLinksByHref,
|
|
||||||
],
|
|
||||||
(elementStats, dataAttributes, href, matchLinksByHref) => {
|
(elementStats, dataAttributes, href, matchLinksByHref) => {
|
||||||
cache.pageElements = cache.lastHref == href ? cache.pageElements : collectAllElementsDeep('*', document)
|
cache.pageElements = cache.lastHref == href ? cache.pageElements : collectAllElementsDeep('*', document)
|
||||||
cache.selectorToElements = cache.lastHref == href ? cache.selectorToElements : {}
|
cache.selectorToElements = cache.lastHref == href ? cache.selectorToElements : {}
|
||||||
@ -240,8 +346,11 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
countedElements: [
|
countedElements: [
|
||||||
(selectors) => [selectors.elements, toolbarConfigLogic.selectors.dataAttributes],
|
(s) => [s.elements, toolbarConfigLogic.selectors.dataAttributes, s.clickmapsEnabled],
|
||||||
(elements, dataAttributes) => {
|
(elements, dataAttributes, clickmapsEnabled) => {
|
||||||
|
if (!clickmapsEnabled) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
const normalisedElements = new Map<HTMLElement, CountedHTMLElement>()
|
const normalisedElements = new Map<HTMLElement, CountedHTMLElement>()
|
||||||
;(elements || []).forEach((countedElement) => {
|
;(elements || []).forEach((countedElement) => {
|
||||||
const trimmedElement = trimElement(countedElement.element)
|
const trimmedElement = trimElement(countedElement.element)
|
||||||
@ -273,22 +382,225 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
return countedElements.map((e, i) => ({ ...e, position: i + 1 }))
|
return countedElements.map((e, i) => ({ ...e, position: i + 1 }))
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
elementCount: [(selectors) => [selectors.countedElements], (countedElements) => countedElements.length],
|
elementCount: [(s) => [s.countedElements], (countedElements) => countedElements.length],
|
||||||
clickCount: [
|
clickCount: [
|
||||||
(selectors) => [selectors.countedElements],
|
(s) => [s.countedElements],
|
||||||
(countedElements) => (countedElements ? countedElements.map((e) => e.count).reduce((a, b) => a + b, 0) : 0),
|
(countedElements) => (countedElements ? countedElements.map((e) => e.count).reduce((a, b) => a + b, 0) : 0),
|
||||||
],
|
],
|
||||||
highestClickCount: [
|
highestClickCount: [
|
||||||
(selectors) => [selectors.countedElements],
|
(s) => [s.countedElements],
|
||||||
(countedElements) =>
|
(countedElements) =>
|
||||||
countedElements ? countedElements.map((e) => e.count).reduce((a, b) => (b > a ? b : a), 0) : 0,
|
countedElements ? countedElements.map((e) => e.count).reduce((a, b) => (b > a ? b : a), 0) : 0,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
heatmapElements: [
|
||||||
|
(s) => [s.rawHeatmap],
|
||||||
|
(rawHeatmap): HeatmapElement[] => {
|
||||||
|
if (!rawHeatmap) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements: HeatmapElement[] = []
|
||||||
|
|
||||||
|
rawHeatmap?.results.forEach((element) => {
|
||||||
|
if ('scroll_depth_bucket' in element) {
|
||||||
|
elements.push({
|
||||||
|
count: element.cumulative_count,
|
||||||
|
xPercentage: 0,
|
||||||
|
targetFixed: false,
|
||||||
|
y: element.scroll_depth_bucket,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
elements.push({
|
||||||
|
count: element.count,
|
||||||
|
xPercentage: element.pointer_relative_x,
|
||||||
|
targetFixed: element.pointer_target_fixed,
|
||||||
|
y: element.pointer_y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return elements
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
viewportRange: [
|
||||||
|
(s) => [s.heatmapFilters, s.windowWidth],
|
||||||
|
(heatmapFilters, windowWidth): { max: number; min: number } => {
|
||||||
|
const viewportAccuracy = heatmapFilters.viewportAccuracy ?? 0.2
|
||||||
|
const extraPixels = windowWidth - windowWidth * viewportAccuracy
|
||||||
|
|
||||||
|
const minWidth = Math.max(0, windowWidth - extraPixels)
|
||||||
|
const maxWidth = windowWidth + extraPixels
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: Math.round(minWidth),
|
||||||
|
max: Math.round(maxWidth),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
scrollDepthPosthogJsError: [
|
||||||
|
(s) => [s.posthog],
|
||||||
|
(posthog: PostHog): 'version' | 'disabled' | null => {
|
||||||
|
const posthogVersion = posthog?._calculate_event_properties('test', {})?.['$lib_version'] ?? '0.0.0'
|
||||||
|
const majorMinorVersion = posthogVersion.split('.')
|
||||||
|
const majorVersion = parseInt(majorMinorVersion[0], 10)
|
||||||
|
const minorVersion = parseInt(majorMinorVersion[1], 10)
|
||||||
|
|
||||||
|
if (!(posthog as any)?.scrollManager?.scrollY) {
|
||||||
|
return 'version'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSupported =
|
||||||
|
majorVersion > SCROLL_DEPTH_JS_VERSION[0] ||
|
||||||
|
(majorVersion === SCROLL_DEPTH_JS_VERSION[0] && minorVersion >= SCROLL_DEPTH_JS_VERSION[1])
|
||||||
|
const isDisabled = posthog?.config.disable_scroll_properties
|
||||||
|
|
||||||
|
return !isSupported ? 'version' : isDisabled ? 'disabled' : null
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
heatmapJsData: [
|
||||||
|
(s) => [s.heatmapElements, s.heatmapScrollY, s.windowWidth, s.heatmapFixedPositionMode],
|
||||||
|
(heatmapElements, heatmapScrollY, windowWidth, heatmapFixedPositionMode): HeatmapJsData => {
|
||||||
|
// We want to account for all the fixed position elements, the scroll of the context and the browser width
|
||||||
|
const data = heatmapElements.reduce((acc, element) => {
|
||||||
|
if (heatmapFixedPositionMode === 'hidden' && element.targetFixed) {
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
const y = Math.round(
|
||||||
|
element.targetFixed && heatmapFixedPositionMode === 'fixed'
|
||||||
|
? element.y
|
||||||
|
: element.y - heatmapScrollY
|
||||||
|
)
|
||||||
|
const x = Math.round(element.xPercentage * windowWidth)
|
||||||
|
|
||||||
|
return [...acc, { x, y, value: element.count }]
|
||||||
|
}, [] as HeatmapJsDataPoint[])
|
||||||
|
|
||||||
|
// Max is the highest value in the data set we have
|
||||||
|
const max = data.reduce((max, { value }) => Math.max(max, value), 0)
|
||||||
|
|
||||||
|
// TODO: Group based on some sensible resolutions (we can then use this for a hover state to show more detail)
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: 0,
|
||||||
|
max,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
|
||||||
|
subscriptions(({ actions }) => ({
|
||||||
|
viewportRange: () => {
|
||||||
|
actions.maybeLoadHeatmap(500)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
listeners(({ actions, values }) => ({
|
||||||
|
fetchHeatmapApi: async () => {
|
||||||
|
const { href, wildcardHref } = values
|
||||||
|
const { date_from, date_to } = values.commonFilters
|
||||||
|
const { type, aggregation } = values.heatmapFilters
|
||||||
|
const urlExact = wildcardHref === href ? href : undefined
|
||||||
|
const urlRegex = wildcardHref !== href ? wildcardHref : undefined
|
||||||
|
|
||||||
|
// toolbar fetch collapses queryparams but this URL has multiple with the same name
|
||||||
|
const response = await toolbarFetch(
|
||||||
|
`/api/heatmap/${encodeParams(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
url_exact: urlExact,
|
||||||
|
url_pattern: urlRegex,
|
||||||
|
viewport_width_min: values.viewportRange.min,
|
||||||
|
viewport_width_max: values.viewportRange.max,
|
||||||
|
aggregation,
|
||||||
|
},
|
||||||
|
'?'
|
||||||
|
)}`,
|
||||||
|
'GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
toolbarConfigLogic.actions.authenticate()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error('API error')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
},
|
||||||
|
enableHeatmap: () => {
|
||||||
|
actions.loadAllEnabled()
|
||||||
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true })
|
||||||
|
},
|
||||||
|
disableHeatmap: () => {
|
||||||
|
actions.resetElementStats()
|
||||||
|
toolbarPosthogJS.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAllEnabled: async ({ delayMs }, breakpoint) => {
|
||||||
|
await breakpoint(delayMs)
|
||||||
|
|
||||||
|
actions.maybeLoadHeatmap()
|
||||||
|
actions.maybeLoadClickmap()
|
||||||
|
},
|
||||||
|
maybeLoadClickmap: async ({ delayMs }, breakpoint) => {
|
||||||
|
await breakpoint(delayMs)
|
||||||
|
if (values.heatmapEnabled && values.clickmapsEnabled) {
|
||||||
|
actions.getElementStats()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
maybeLoadHeatmap: async ({ delayMs }, breakpoint) => {
|
||||||
|
await breakpoint(delayMs)
|
||||||
|
if (values.heatmapEnabled) {
|
||||||
|
if (values.heatmapFilters.enabled && values.heatmapFilters.type) {
|
||||||
|
actions.loadHeatmap(values.heatmapFilters.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setHref: () => {
|
||||||
|
actions.loadAllEnabled()
|
||||||
|
},
|
||||||
|
setWildcardHref: () => {
|
||||||
|
actions.loadAllEnabled(1000)
|
||||||
|
},
|
||||||
|
setCommonFilters: () => {
|
||||||
|
actions.loadAllEnabled(200)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Only trigger element stats loading if clickmaps are enabled
|
||||||
|
toggleClickmapsEnabled: () => {
|
||||||
|
if (values.clickmapsEnabled) {
|
||||||
|
actions.getElementStats()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMoreElementStats: () => {
|
||||||
|
if (values.elementStats?.next) {
|
||||||
|
actions.getElementStats(values.elementStats.next)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
patchHeatmapFilters: ({ filters }) => {
|
||||||
|
if (filters.type) {
|
||||||
|
// Clear the heatmap if the type changes
|
||||||
|
actions.loadHeatmapSuccess({ results: [] })
|
||||||
|
}
|
||||||
|
actions.maybeLoadHeatmap(200)
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
|
|
||||||
afterMount(({ actions, values, cache }) => {
|
afterMount(({ actions, values, cache }) => {
|
||||||
if (values.heatmapEnabled) {
|
actions.loadAllEnabled()
|
||||||
actions.getElementStats()
|
|
||||||
}
|
|
||||||
cache.keyDownListener = (event: KeyboardEvent) => {
|
cache.keyDownListener = (event: KeyboardEvent) => {
|
||||||
if (event.shiftKey && !values.shiftPressed) {
|
if (event.shiftKey && !values.shiftPressed) {
|
||||||
actions.setShiftPressed(true)
|
actions.setShiftPressed(true)
|
||||||
@ -301,53 +613,18 @@ export const heatmapLogic = kea<heatmapLogicType>([
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', cache.keyDownListener)
|
window.addEventListener('keydown', cache.keyDownListener)
|
||||||
window.addEventListener('keyup', cache.keyUpListener)
|
window.addEventListener('keyup', cache.keyUpListener)
|
||||||
|
|
||||||
|
cache.scrollCheckTimer = setInterval(() => {
|
||||||
|
const scrollY = (values.posthog as any)?.scrollManager?.scrollY() ?? 0
|
||||||
|
if (values.heatmapScrollY !== scrollY) {
|
||||||
|
actions.setHeatmapScrollY(scrollY)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
beforeUnmount(({ cache }) => {
|
beforeUnmount(({ cache }) => {
|
||||||
window.removeEventListener('keydown', cache.keyDownListener)
|
window.removeEventListener('keydown', cache.keyDownListener)
|
||||||
window.removeEventListener('keyup', cache.keyUpListener)
|
window.removeEventListener('keyup', cache.keyUpListener)
|
||||||
|
clearInterval(cache.scrollCheckTimer)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listeners(({ actions, values }) => ({
|
|
||||||
loadMoreElementStats: () => {
|
|
||||||
if (values.elementStats?.next) {
|
|
||||||
actions.getElementStats(values.elementStats.next)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setHref: () => {
|
|
||||||
if (values.heatmapEnabled) {
|
|
||||||
actions.resetElementStats()
|
|
||||||
actions.getElementStats()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setWildcardHref: async (_, breakpoint) => {
|
|
||||||
await breakpoint(100)
|
|
||||||
if (values.heatmapEnabled) {
|
|
||||||
actions.resetElementStats()
|
|
||||||
actions.getElementStats()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enableHeatmap: () => {
|
|
||||||
actions.getElementStats()
|
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: true })
|
|
||||||
},
|
|
||||||
disableHeatmap: () => {
|
|
||||||
actions.resetElementStats()
|
|
||||||
actions.setShowHeatmapTooltip(false)
|
|
||||||
posthog.capture('toolbar mode triggered', { mode: 'heatmap', enabled: false })
|
|
||||||
},
|
|
||||||
getElementStatsSuccess: () => {
|
|
||||||
actions.setShowHeatmapTooltip(true)
|
|
||||||
},
|
|
||||||
setShowHeatmapTooltip: async ({ showHeatmapTooltip }, breakpoint) => {
|
|
||||||
if (showHeatmapTooltip) {
|
|
||||||
await breakpoint(1000)
|
|
||||||
actions.setShowHeatmapTooltip(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setHeatmapFilter: () => {
|
|
||||||
actions.resetElementStats()
|
|
||||||
actions.getElementStats()
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
])
|
])
|
||||||
|
@ -5,8 +5,8 @@ import { encodeParams } from 'kea-router'
|
|||||||
import { permanentlyMount } from 'lib/utils/kea-logic-builders'
|
import { permanentlyMount } from 'lib/utils/kea-logic-builders'
|
||||||
import type { PostHog } from 'posthog-js'
|
import type { PostHog } from 'posthog-js'
|
||||||
|
|
||||||
import { posthog as posthogJS } from '~/toolbar/posthog'
|
|
||||||
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
import { toolbarConfigLogic, toolbarFetch } from '~/toolbar/toolbarConfigLogic'
|
||||||
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
import { CombinedFeatureFlagAndValueType } from '~/types'
|
import { CombinedFeatureFlagAndValueType } from '~/types'
|
||||||
|
|
||||||
import type { flagsToolbarLogicType } from './flagsToolbarLogicType'
|
import type { flagsToolbarLogicType } from './flagsToolbarLogicType'
|
||||||
@ -119,7 +119,7 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
|
|||||||
const clientPostHog = values.posthog
|
const clientPostHog = values.posthog
|
||||||
if (clientPostHog) {
|
if (clientPostHog) {
|
||||||
clientPostHog.featureFlags.override({ ...values.localOverrides, [flagKey]: overrideValue })
|
clientPostHog.featureFlags.override({ ...values.localOverrides, [flagKey]: overrideValue })
|
||||||
posthogJS.capture('toolbar feature flag overridden')
|
toolbarPosthogJS.capture('toolbar feature flag overridden')
|
||||||
actions.checkLocalOverrides()
|
actions.checkLocalOverrides()
|
||||||
clientPostHog.featureFlags.reloadFeatureFlags()
|
clientPostHog.featureFlags.reloadFeatureFlags()
|
||||||
}
|
}
|
||||||
@ -134,7 +134,7 @@ export const flagsToolbarLogic = kea<flagsToolbarLogicType>([
|
|||||||
} else {
|
} else {
|
||||||
clientPostHog.featureFlags.override(false)
|
clientPostHog.featureFlags.override(false)
|
||||||
}
|
}
|
||||||
posthogJS.capture('toolbar feature flag override removed')
|
toolbarPosthogJS.capture('toolbar feature flag override removed')
|
||||||
actions.checkLocalOverrides()
|
actions.checkLocalOverrides()
|
||||||
clientPostHog.featureFlags.reloadFeatureFlags()
|
clientPostHog.featureFlags.reloadFeatureFlags()
|
||||||
}
|
}
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import PostHog from 'posthog-js-lite'
|
|
||||||
|
|
||||||
const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg'
|
|
||||||
|
|
||||||
const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT
|
|
||||||
const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY
|
|
||||||
const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com'
|
|
||||||
|
|
||||||
export const posthog = new PostHog(apiKey || DEFAULT_API_KEY, {
|
|
||||||
host: apiHost,
|
|
||||||
enable: false, // must call .optIn() before any events are sent
|
|
||||||
persistence: 'memory', // We don't want to persist anything, all events are in-memory
|
|
||||||
persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence
|
|
||||||
})
|
|
||||||
|
|
||||||
if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) {
|
|
||||||
posthog.debug()
|
|
||||||
}
|
|
@ -1,9 +1,12 @@
|
|||||||
|
import { IconMagicWand } from '@posthog/icons'
|
||||||
|
import { LemonLabel, LemonSegmentedButton } from '@posthog/lemon-ui'
|
||||||
import { useActions, useValues } from 'kea'
|
import { useActions, useValues } from 'kea'
|
||||||
import { CUSTOM_OPTION_KEY } from 'lib/components/DateFilter/types'
|
import { CUSTOM_OPTION_KEY } from 'lib/components/DateFilter/types'
|
||||||
import { IconSync } from 'lib/lemon-ui/icons'
|
import { IconSync } from 'lib/lemon-ui/icons'
|
||||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||||
import { LemonInput } from 'lib/lemon-ui/LemonInput'
|
import { LemonInput } from 'lib/lemon-ui/LemonInput'
|
||||||
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'
|
import { LemonMenu } from 'lib/lemon-ui/LemonMenu'
|
||||||
|
import { LemonSlider } from 'lib/lemon-ui/LemonSlider'
|
||||||
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
|
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
|
||||||
import { Spinner } from 'lib/lemon-ui/Spinner'
|
import { Spinner } from 'lib/lemon-ui/Spinner'
|
||||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||||
@ -14,98 +17,326 @@ import { elementsLogic } from '~/toolbar/elements/elementsLogic'
|
|||||||
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
import { heatmapLogic } from '~/toolbar/elements/heatmapLogic'
|
||||||
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
import { currentPageLogic } from '~/toolbar/stats/currentPageLogic'
|
||||||
|
|
||||||
|
import { useToolbarFeatureFlag } from '../toolbarPosthogJS'
|
||||||
|
|
||||||
|
const ScrollDepthJSWarning = (): JSX.Element | null => {
|
||||||
|
const { scrollDepthPosthogJsError } = useValues(heatmapLogic)
|
||||||
|
|
||||||
|
if (!scrollDepthPosthogJsError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="my-2 bg-danger-highlight border border-danger rounded p-2">
|
||||||
|
{scrollDepthPosthogJsError === 'version' ? (
|
||||||
|
<>This feature requires a newer version of posthog-js</>
|
||||||
|
) : scrollDepthPosthogJsError === 'disabled' ? (
|
||||||
|
<>
|
||||||
|
Your posthog-js config has <i>disable_scroll_properties</i> set - these properties are required for
|
||||||
|
scroll depth calculations to work.
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const HeatmapToolbarMenu = (): JSX.Element => {
|
export const HeatmapToolbarMenu = (): JSX.Element => {
|
||||||
const { wildcardHref } = useValues(currentPageLogic)
|
const { wildcardHref } = useValues(currentPageLogic)
|
||||||
const { setWildcardHref } = useActions(currentPageLogic)
|
const { setWildcardHref, autoWildcardHref } = useActions(currentPageLogic)
|
||||||
|
|
||||||
const { matchLinksByHref, countedElements, clickCount, heatmapLoading, heatmapFilter, canLoadMoreElementStats } =
|
const {
|
||||||
useValues(heatmapLogic)
|
matchLinksByHref,
|
||||||
const { setHeatmapFilter, loadMoreElementStats, setMatchLinksByHref } = useActions(heatmapLogic)
|
countedElements,
|
||||||
|
clickCount,
|
||||||
|
commonFilters,
|
||||||
|
heatmapFilters,
|
||||||
|
canLoadMoreElementStats,
|
||||||
|
viewportRange,
|
||||||
|
rawHeatmapLoading,
|
||||||
|
elementStatsLoading,
|
||||||
|
clickmapsEnabled,
|
||||||
|
heatmapFixedPositionMode,
|
||||||
|
} = useValues(heatmapLogic)
|
||||||
|
const {
|
||||||
|
setCommonFilters,
|
||||||
|
patchHeatmapFilters,
|
||||||
|
loadMoreElementStats,
|
||||||
|
setMatchLinksByHref,
|
||||||
|
toggleClickmapsEnabled,
|
||||||
|
setHeatmapFixedPositionMode,
|
||||||
|
} = useActions(heatmapLogic)
|
||||||
const { setHighlightElement, setSelectedElement } = useActions(elementsLogic)
|
const { setHighlightElement, setSelectedElement } = useActions(elementsLogic)
|
||||||
|
|
||||||
const dateItems = dateMapping
|
const dateItems = dateMapping
|
||||||
.filter((dm) => dm.key !== CUSTOM_OPTION_KEY)
|
.filter((dm) => dm.key !== CUSTOM_OPTION_KEY)
|
||||||
.map((dateOption) => ({
|
.map((dateOption) => ({
|
||||||
label: dateOption.key,
|
label: dateOption.key,
|
||||||
onClick: () => setHeatmapFilter({ date_from: dateOption.values[0], date_to: dateOption.values[1] }),
|
onClick: () => setCommonFilters({ date_from: dateOption.values[0], date_to: dateOption.values[1] }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const showNewHeatmaps = useToolbarFeatureFlag('toolbar-heatmaps')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarMenu>
|
<ToolbarMenu>
|
||||||
<ToolbarMenu.Header>
|
<ToolbarMenu.Header>
|
||||||
<LemonInput value={wildcardHref} onChange={setWildcardHref} />
|
<div className="flex gap-1">
|
||||||
<div className="space-y-1 border-b px-1 pb-2">
|
<LemonInput className="flex-1" value={wildcardHref} onChange={setWildcardHref} />
|
||||||
<div className="text-muted p-1">Use * as a wildcard</div>
|
<LemonButton
|
||||||
<div className="flex flex-row items-center space-x-2">
|
type="secondary"
|
||||||
<LemonMenu items={dateItems}>
|
icon={<IconMagicWand />}
|
||||||
<LemonButton size="small" type="secondary">
|
size="small"
|
||||||
{dateFilterToText(heatmapFilter.date_from, heatmapFilter.date_to, 'Last 7 days')}
|
onClick={() => autoWildcardHref()}
|
||||||
</LemonButton>
|
tooltip={
|
||||||
</LemonMenu>
|
<>
|
||||||
|
You can use the wildcard character <code>*</code> to match any character in the URL. For
|
||||||
|
example, <code>https://example.com/*</code> will match{' '}
|
||||||
|
<code>https://example.com/page</code> and <code>https://example.com/page/1</code>.
|
||||||
|
<br />
|
||||||
|
Click this button to automatically wildcards where we believe it would make sense
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<LemonButton
|
<div className="flex flex-row items-center gap-2 py-2 border-b">
|
||||||
icon={<IconSync />}
|
<LemonMenu items={dateItems}>
|
||||||
type="secondary"
|
<LemonButton size="small" type="secondary">
|
||||||
size="small"
|
{dateFilterToText(commonFilters.date_from, commonFilters.date_to, 'Last 7 days')}
|
||||||
onClick={loadMoreElementStats}
|
|
||||||
disabledReason={
|
|
||||||
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</LemonButton>
|
</LemonButton>
|
||||||
|
</LemonMenu>
|
||||||
{heatmapLoading ? <Spinner /> : null}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Found: {countedElements.length} elements / {clickCount} clicks!
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip title="Matching links by their target URL can exclude clicks from the heatmap if the URL is too unique.">
|
|
||||||
<LemonSwitch
|
|
||||||
checked={matchLinksByHref}
|
|
||||||
label="Match links by their target URL"
|
|
||||||
onChange={(checked) => setMatchLinksByHref(checked)}
|
|
||||||
fullWidth={true}
|
|
||||||
bordered={true}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</ToolbarMenu.Header>
|
</ToolbarMenu.Header>
|
||||||
<ToolbarMenu.Body>
|
<ToolbarMenu.Body>
|
||||||
<div className="flex flex-col space-y-2">
|
{showNewHeatmaps ? (
|
||||||
<div className="flex flex-col w-full h-full">
|
<div className="border-b p-2">
|
||||||
{heatmapLoading ? (
|
<LemonSwitch
|
||||||
<span className="flex-1 flex justify-center items-center p-4">
|
className="w-full"
|
||||||
<Spinner className="text-2xl" />
|
checked={!!heatmapFilters.enabled}
|
||||||
</span>
|
label={<>Heatmaps {rawHeatmapLoading ? <Spinner /> : null}</>}
|
||||||
) : countedElements.length ? (
|
onChange={(e) =>
|
||||||
countedElements.map(({ element, count, actionStep }, index) => {
|
patchHeatmapFilters({
|
||||||
return (
|
enabled: e,
|
||||||
<div
|
})
|
||||||
className="p-2 flex flex-row justify-between cursor-pointer hover:bg-primary-highlight"
|
}
|
||||||
key={index}
|
/>
|
||||||
onClick={() => setSelectedElement(element)}
|
|
||||||
onMouseEnter={() => setHighlightElement(element)}
|
{heatmapFilters.enabled && (
|
||||||
onMouseLeave={() => setHighlightElement(null)}
|
<>
|
||||||
>
|
<p>
|
||||||
<div>
|
Heatmaps are calculated using additional data sent along with standard events. They
|
||||||
{index + 1}.
|
are based off of general pointer interactions and might not be 100% accurate to the
|
||||||
{actionStep?.text ||
|
page you are viewing.
|
||||||
(actionStep?.tag_name ? (
|
</p>
|
||||||
<code><{actionStep.tag_name}></code>
|
<div className="space-y-2">
|
||||||
) : (
|
<LemonLabel>Heatmap type</LemonLabel>
|
||||||
<em>Element</em>
|
<div className="flex gap-2 justify-between items-center">
|
||||||
))}
|
<LemonSegmentedButton
|
||||||
</div>
|
onChange={(e) => patchHeatmapFilters({ type: e })}
|
||||||
<div>{count} clicks</div>
|
value={heatmapFilters.type ?? undefined}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'click',
|
||||||
|
label: 'Clicks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rageclick',
|
||||||
|
label: 'Rageclicks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'mousemove',
|
||||||
|
label: 'Mouse moves',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'scrolldepth',
|
||||||
|
label: 'Scroll depth',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})
|
{heatmapFilters.type === 'scrolldepth' && (
|
||||||
) : (
|
<>
|
||||||
<div className="p-2">No elements found.</div>
|
<p>
|
||||||
|
Scroll depth uses additional information from Pageview and Pageleave
|
||||||
|
events to indicate how far down the page users have scrolled.
|
||||||
|
</p>
|
||||||
|
<ScrollDepthJSWarning />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LemonLabel>Aggregation</LemonLabel>
|
||||||
|
<div className="flex gap-2 justify-between items-center">
|
||||||
|
<LemonSegmentedButton
|
||||||
|
onChange={(e) => patchHeatmapFilters({ aggregation: e })}
|
||||||
|
value={heatmapFilters.aggregation ?? 'total_count'}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'total_count',
|
||||||
|
label: 'Total count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'unique_visitors',
|
||||||
|
label: 'Unique visitors',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LemonLabel>Viewport accuracy</LemonLabel>
|
||||||
|
<div className="flex gap-2 justify-between items-center">
|
||||||
|
<LemonSlider
|
||||||
|
className="flex-1"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={heatmapFilters.viewportAccuracy ?? 0}
|
||||||
|
onChange={(value) => patchHeatmapFilters({ viewportAccuracy: value })}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
title={`
|
||||||
|
The range of values
|
||||||
|
Heatmap will be loaded for all viewports where the width is above
|
||||||
|
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<code className="w-[12rem] text-right text-xs whitsepace-nowrap">
|
||||||
|
{`${Math.round((heatmapFilters.viewportAccuracy ?? 1) * 100)}% (${
|
||||||
|
viewportRange.min
|
||||||
|
}px - ${viewportRange.max}px)`}
|
||||||
|
</code>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{heatmapFilters.type !== 'scrolldepth' ? (
|
||||||
|
<>
|
||||||
|
<LemonLabel>Fixed positioning calculation</LemonLabel>
|
||||||
|
<p>
|
||||||
|
PostHog JS will attempt to detect fixed elements such as headers or
|
||||||
|
modals and will therefore show those heatmap areas, ignoring the scroll
|
||||||
|
value.
|
||||||
|
<br />
|
||||||
|
You can choose to show these areas as fixed, include them with scrolled
|
||||||
|
data or hide them altogether.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LemonSegmentedButton
|
||||||
|
onChange={setHeatmapFixedPositionMode}
|
||||||
|
value={heatmapFixedPositionMode}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'fixed',
|
||||||
|
label: 'Show fixed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'relative',
|
||||||
|
label: 'Show scrolled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'hidden',
|
||||||
|
label: 'Hide',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="p-2">
|
||||||
|
{showNewHeatmaps ? (
|
||||||
|
<LemonSwitch
|
||||||
|
className="w-full"
|
||||||
|
checked={!!clickmapsEnabled}
|
||||||
|
label={<>Clickmaps (autocapture) {elementStatsLoading ? <Spinner /> : null}</>}
|
||||||
|
onChange={(e) => toggleClickmapsEnabled(e)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(clickmapsEnabled || !showNewHeatmaps) && (
|
||||||
|
<>
|
||||||
|
{showNewHeatmaps ? (
|
||||||
|
<p>
|
||||||
|
Clickmaps are built using Autocapture events. They are more accurate than heatmaps
|
||||||
|
if the event can be mapped to a specific element found on the page you are viewing
|
||||||
|
but less data is usually captured.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LemonButton
|
||||||
|
icon={<IconSync />}
|
||||||
|
type="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={loadMoreElementStats}
|
||||||
|
disabledReason={
|
||||||
|
canLoadMoreElementStats ? undefined : 'Loaded all elements in this data range.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</LemonButton>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
Matching links by their target URL can exclude clicks from the heatmap if
|
||||||
|
the URL is too unique.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LemonSwitch
|
||||||
|
className="flex-1"
|
||||||
|
checked={matchLinksByHref}
|
||||||
|
label="Match links by their target URL"
|
||||||
|
onChange={(checked) => setMatchLinksByHref(checked)}
|
||||||
|
fullWidth={true}
|
||||||
|
bordered={true}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2">
|
||||||
|
Found: {countedElements.length} elements / {clickCount} clicks!
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full h-full">
|
||||||
|
{countedElements.length ? (
|
||||||
|
countedElements.map(({ element, count, actionStep }, index) => {
|
||||||
|
return (
|
||||||
|
<LemonButton
|
||||||
|
key={index}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
onClick={() => setSelectedElement(element)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-1 justify-between"
|
||||||
|
key={index}
|
||||||
|
onMouseEnter={() => setHighlightElement(element)}
|
||||||
|
onMouseLeave={() => setHighlightElement(null)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{index + 1}.
|
||||||
|
{actionStep?.text ||
|
||||||
|
(actionStep?.tag_name ? (
|
||||||
|
<code><{actionStep.tag_name}></code>
|
||||||
|
) : (
|
||||||
|
<em>Element</em>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>{count} clicks</div>
|
||||||
|
</div>
|
||||||
|
</LemonButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="p-2">No elements found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ToolbarMenu.Body>
|
</ToolbarMenu.Body>
|
||||||
</ToolbarMenu>
|
</ToolbarMenu>
|
||||||
|
@ -1,12 +1,32 @@
|
|||||||
import { actions, afterMount, beforeUnmount, kea, path, reducers } from 'kea'
|
import { actions, afterMount, beforeUnmount, kea, listeners, path, reducers } from 'kea'
|
||||||
|
|
||||||
import type { currentPageLogicType } from './currentPageLogicType'
|
import type { currentPageLogicType } from './currentPageLogicType'
|
||||||
|
|
||||||
|
const replaceWithWildcard = (part: string): string => {
|
||||||
|
// replace uuids
|
||||||
|
if (part.match(/^([a-f]|[0-9]){8}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){4}-([a-f]|[0-9]){12}$/)) {
|
||||||
|
return '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace digits
|
||||||
|
if (part.match(/^[0-9]+$/)) {
|
||||||
|
return '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace long values
|
||||||
|
if (part.length > 24) {
|
||||||
|
return '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
return part
|
||||||
|
}
|
||||||
|
|
||||||
export const currentPageLogic = kea<currentPageLogicType>([
|
export const currentPageLogic = kea<currentPageLogicType>([
|
||||||
path(['toolbar', 'stats', 'currentPageLogic']),
|
path(['toolbar', 'stats', 'currentPageLogic']),
|
||||||
actions(() => ({
|
actions(() => ({
|
||||||
setHref: (href: string) => ({ href }),
|
setHref: (href: string) => ({ href }),
|
||||||
setWildcardHref: (href: string) => ({ href }),
|
setWildcardHref: (href: string) => ({ href }),
|
||||||
|
autoWildcardHref: true,
|
||||||
})),
|
})),
|
||||||
reducers(() => ({
|
reducers(() => ({
|
||||||
href: [window.location.href, { setHref: (_, { href }) => href }],
|
href: [window.location.href, { setHref: (_, { href }) => href }],
|
||||||
@ -16,6 +36,31 @@ export const currentPageLogic = kea<currentPageLogicType>([
|
|||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
listeners(({ actions, values }) => ({
|
||||||
|
autoWildcardHref: () => {
|
||||||
|
let url = values.wildcardHref
|
||||||
|
|
||||||
|
const urlParts = url.split('?')
|
||||||
|
|
||||||
|
url = urlParts[0]
|
||||||
|
.split('/')
|
||||||
|
.map((part) => replaceWithWildcard(part))
|
||||||
|
.join('/')
|
||||||
|
|
||||||
|
// Iterate over query params and do the same for their values
|
||||||
|
if (urlParts.length > 1) {
|
||||||
|
const queryParams = urlParts[1].split('&')
|
||||||
|
for (let i = 0; i < queryParams.length; i++) {
|
||||||
|
const [key, value] = queryParams[i].split('=')
|
||||||
|
queryParams[i] = `${key}=${replaceWithWildcard(value)}`
|
||||||
|
}
|
||||||
|
url = `${url}?${queryParams.join('&')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.setWildcardHref(url)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
afterMount(({ actions, values, cache }) => {
|
afterMount(({ actions, values, cache }) => {
|
||||||
cache.interval = window.setInterval(() => {
|
cache.interval = window.setInterval(() => {
|
||||||
if (window.location.href !== values.href) {
|
if (window.location.href !== values.href) {
|
||||||
|
@ -2,7 +2,7 @@ import { actions, afterMount, kea, listeners, path, props, reducers, selectors }
|
|||||||
import { combineUrl, encodeParams } from 'kea-router'
|
import { combineUrl, encodeParams } from 'kea-router'
|
||||||
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
|
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
|
||||||
|
|
||||||
import { posthog } from '~/toolbar/posthog'
|
import { toolbarPosthogJS } from '~/toolbar/toolbarPosthogJS'
|
||||||
import { ToolbarProps } from '~/types'
|
import { ToolbarProps } from '~/types'
|
||||||
|
|
||||||
import type { toolbarConfigLogicType } from './toolbarConfigLogicType'
|
import type { toolbarConfigLogicType } from './toolbarConfigLogicType'
|
||||||
@ -51,17 +51,17 @@ export const toolbarConfigLogic = kea<toolbarConfigLogicType>([
|
|||||||
|
|
||||||
listeners(({ values, actions }) => ({
|
listeners(({ values, actions }) => ({
|
||||||
authenticate: () => {
|
authenticate: () => {
|
||||||
posthog.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated })
|
toolbarPosthogJS.capture('toolbar authenticate', { is_authenticated: values.isAuthenticated })
|
||||||
const encodedUrl = encodeURIComponent(window.location.href)
|
const encodedUrl = encodeURIComponent(window.location.href)
|
||||||
actions.persistConfig()
|
actions.persistConfig()
|
||||||
window.location.href = `${values.apiURL}/authorize_and_redirect/?redirect=${encodedUrl}`
|
window.location.href = `${values.apiURL}/authorize_and_redirect/?redirect=${encodedUrl}`
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
posthog.capture('toolbar logout')
|
toolbarPosthogJS.capture('toolbar logout')
|
||||||
localStorage.removeItem(LOCALSTORAGE_KEY)
|
localStorage.removeItem(LOCALSTORAGE_KEY)
|
||||||
},
|
},
|
||||||
tokenExpired: () => {
|
tokenExpired: () => {
|
||||||
posthog.capture('toolbar token expired')
|
toolbarPosthogJS.capture('toolbar token expired')
|
||||||
console.warn('PostHog Toolbar API token expired. Clearing session.')
|
console.warn('PostHog Toolbar API token expired. Clearing session.')
|
||||||
if (values.props.source !== 'localstorage') {
|
if (values.props.source !== 'localstorage') {
|
||||||
lemonToast.error('PostHog Toolbar API token expired.')
|
lemonToast.error('PostHog Toolbar API token expired.')
|
||||||
@ -87,12 +87,14 @@ export const toolbarConfigLogic = kea<toolbarConfigLogicType>([
|
|||||||
afterMount(({ props, values }) => {
|
afterMount(({ props, values }) => {
|
||||||
if (props.instrument) {
|
if (props.instrument) {
|
||||||
const distinctId = props.distinctId
|
const distinctId = props.distinctId
|
||||||
|
|
||||||
|
void toolbarPosthogJS.optIn()
|
||||||
|
|
||||||
if (distinctId) {
|
if (distinctId) {
|
||||||
posthog.identify(distinctId, props.userEmail ? { email: props.userEmail } : {})
|
toolbarPosthogJS.identify(distinctId, props.userEmail ? { email: props.userEmail } : {})
|
||||||
}
|
}
|
||||||
posthog.optIn()
|
|
||||||
}
|
}
|
||||||
posthog.capture('toolbar loaded', { is_authenticated: values.isAuthenticated })
|
toolbarPosthogJS.capture('toolbar loaded', { is_authenticated: values.isAuthenticated })
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
35
frontend/src/toolbar/toolbarPosthogJS.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { FeatureFlagKey } from 'lib/constants'
|
||||||
|
import PostHog from 'posthog-js-lite'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const DEFAULT_API_KEY = 'sTMFPsFhdP1Ssg'
|
||||||
|
|
||||||
|
const runningOnPosthog = !!window.POSTHOG_APP_CONTEXT
|
||||||
|
const apiKey = runningOnPosthog ? window.JS_POSTHOG_API_KEY : DEFAULT_API_KEY
|
||||||
|
const apiHost = runningOnPosthog ? window.JS_POSTHOG_HOST : 'https://internal-e.posthog.com'
|
||||||
|
|
||||||
|
export const toolbarPosthogJS = new PostHog(apiKey || DEFAULT_API_KEY, {
|
||||||
|
host: apiHost,
|
||||||
|
defaultOptIn: false, // must call .optIn() before any events are sent
|
||||||
|
persistence: 'memory', // We don't want to persist anything, all events are in-memory
|
||||||
|
persistence_name: apiKey + '_toolbar', // We don't need this but it ensures we don't accidentally mess with the standard persistence
|
||||||
|
preloadFeatureFlags: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (runningOnPosthog && window.JS_POSTHOG_SELF_CAPTURE) {
|
||||||
|
toolbarPosthogJS.debug()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToolbarFeatureFlag = (flag: FeatureFlagKey, match?: string): boolean => {
|
||||||
|
const [flagValue, setFlagValue] = useState<boolean | string | undefined>(toolbarPosthogJS.getFeatureFlag(flag))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return toolbarPosthogJS.onFeatureFlag(flag, (value) => setFlagValue(value))
|
||||||
|
}, [flag, match])
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return flagValue === match
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!flagValue
|
||||||
|
}
|
@ -7,6 +7,42 @@ export type ElementsEventType = {
|
|||||||
type: '$autocapture' | '$rageclick'
|
type: '$autocapture' | '$rageclick'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HeatmapKind = 'click' | 'rageclick' | 'mousemove' | 'scrolldepth'
|
||||||
|
|
||||||
|
export type HeatmapRequestType = {
|
||||||
|
type: HeatmapKind
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
url_exact?: string
|
||||||
|
url_pattern?: string
|
||||||
|
viewport_width_min?: number
|
||||||
|
viewport_width_max?: number
|
||||||
|
aggregation: 'total_count' | 'unique_visitors'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeatmapResponseType = {
|
||||||
|
results: (
|
||||||
|
| {
|
||||||
|
count: number
|
||||||
|
pointer_relative_x: number
|
||||||
|
pointer_target_fixed: boolean
|
||||||
|
pointer_y: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
scroll_depth_bucket: number
|
||||||
|
bucket_count: number
|
||||||
|
cumulative_count: number
|
||||||
|
}
|
||||||
|
)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeatmapElement = {
|
||||||
|
count: number
|
||||||
|
xPercentage: number
|
||||||
|
targetFixed: boolean
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CountedHTMLElement {
|
export interface CountedHTMLElement {
|
||||||
count: number // total of types of clicks
|
count: number // total of types of clicks
|
||||||
clickCount: number // autocapture clicks
|
clickCount: number // autocapture clicks
|
||||||
@ -44,12 +80,6 @@ export interface ActionElementWithMetadata extends ElementWithMetadata {
|
|||||||
step?: ActionStepType
|
step?: ActionStepType
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BoxColor = {
|
|
||||||
backgroundBlendMode: string
|
|
||||||
background: string
|
|
||||||
boxShadow: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActionDraftType = Omit<ActionType, 'id' | 'created_at' | 'created_by'>
|
export type ActionDraftType = Omit<ActionType, 'id' | 'created_at' | 'created_by'>
|
||||||
|
|
||||||
export interface ActionStepForm extends ActionStepType {
|
export interface ActionStepForm extends ActionStepType {
|
||||||
|
@ -2,8 +2,9 @@ import { finder } from '@medv/finder'
|
|||||||
import { CLICK_TARGET_SELECTOR, CLICK_TARGETS, escapeRegex, TAGS_TO_IGNORE } from 'lib/actionUtils'
|
import { CLICK_TARGET_SELECTOR, CLICK_TARGETS, escapeRegex, TAGS_TO_IGNORE } from 'lib/actionUtils'
|
||||||
import { cssEscape } from 'lib/utils/cssEscape'
|
import { cssEscape } from 'lib/utils/cssEscape'
|
||||||
import { querySelectorAllDeep } from 'query-selector-shadow-dom'
|
import { querySelectorAllDeep } from 'query-selector-shadow-dom'
|
||||||
|
import { CSSProperties } from 'react'
|
||||||
|
|
||||||
import { ActionStepForm, BoxColor, ElementRect } from '~/toolbar/types'
|
import { ActionStepForm, ElementRect } from '~/toolbar/types'
|
||||||
import { ActionStepType, StringMatching } from '~/types'
|
import { ActionStepType, StringMatching } from '~/types'
|
||||||
|
|
||||||
export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__'
|
export const TOOLBAR_ID = '__POSTHOG_TOOLBAR__'
|
||||||
@ -246,26 +247,21 @@ export function getElementForStep(step: ActionStepForm, allElements?: HTMLElemen
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): BoxColor | undefined {
|
export function getBoxColors(color: 'blue' | 'red' | 'green', hover = false, opacity = 0.2): CSSProperties | undefined {
|
||||||
if (color === 'blue') {
|
if (color === 'blue') {
|
||||||
return {
|
return {
|
||||||
backgroundBlendMode: 'multiply',
|
backgroundBlendMode: 'multiply',
|
||||||
background: `hsla(240, 90%, 58%, ${opacity})`,
|
background: `hsla(240, 90%, 58%, ${opacity})`,
|
||||||
boxShadow: `hsla(240, 90%, 27%, 0.5) 0px 3px 10px ${hover ? 4 : 2}px`,
|
boxShadow: `hsla(240, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 4 : 0}px`,
|
||||||
|
outline: `hsla(240, 90%, 58%, 0.5) solid 1px`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (color === 'red') {
|
if (color === 'red') {
|
||||||
return {
|
return {
|
||||||
backgroundBlendMode: 'multiply',
|
backgroundBlendMode: 'multiply',
|
||||||
background: `hsla(4, 90%, 58%, ${opacity})`,
|
background: `hsla(4, 90%, 58%, ${opacity})`,
|
||||||
boxShadow: `hsla(4, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`,
|
boxShadow: `hsla(4, 90%, 27%, 0.2) 0px 3px 10px ${hover ? 5 : 0}px`,
|
||||||
}
|
outline: `hsla(4, 90%, 58%, 0.5) solid 1px`,
|
||||||
}
|
|
||||||
if (color === 'green') {
|
|
||||||
return {
|
|
||||||
backgroundBlendMode: 'multiply',
|
|
||||||
background: `hsla(97, 90%, 58%, ${opacity})`,
|
|
||||||
boxShadow: `hsla(97, 90%, 27%, 0.8) 0px 3px 10px ${hover ? 4 : 2}px`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,6 +126,7 @@
|
|||||||
"fflate": "^0.7.4",
|
"fflate": "^0.7.4",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
|
"heatmap.js": "^2.0.5",
|
||||||
"husky": "^7.0.4",
|
"husky": "^7.0.4",
|
||||||
"image-blob-reduce": "^4.1.0",
|
"image-blob-reduce": "^4.1.0",
|
||||||
"kea": "^3.1.5",
|
"kea": "^3.1.5",
|
||||||
@ -146,7 +147,7 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-preset-env": "^9.3.0",
|
"postcss-preset-env": "^9.3.0",
|
||||||
"posthog-js": "1.128.3",
|
"posthog-js": "1.128.3",
|
||||||
"posthog-js-lite": "2.5.0",
|
"posthog-js-lite": "3.0.0",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"protomaps-themes-base": "2.0.0-alpha.1",
|
"protomaps-themes-base": "2.0.0-alpha.1",
|
||||||
@ -217,6 +218,7 @@
|
|||||||
"@types/d3": "^7.4.0",
|
"@types/d3": "^7.4.0",
|
||||||
"@types/d3-sankey": "^0.12.1",
|
"@types/d3-sankey": "^0.12.1",
|
||||||
"@types/dompurify": "^3.0.3",
|
"@types/dompurify": "^3.0.3",
|
||||||
|
"@types/heatmap.js": "^2.0.41",
|
||||||
"@types/image-blob-reduce": "^4.1.1",
|
"@types/image-blob-reduce": "^4.1.1",
|
||||||
"@types/jest": "^29.2.3",
|
"@types/jest": "^29.2.3",
|
||||||
"@types/jest-image-snapshot": "^6.1.0",
|
"@types/jest-image-snapshot": "^6.1.0",
|
||||||
@ -307,7 +309,8 @@
|
|||||||
"playwright": "1.41.2"
|
"playwright": "1.41.2"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch"
|
"rrweb@2.0.0-alpha.12": "patches/rrweb@2.0.0-alpha.12.patch",
|
||||||
|
"heatmap.js@2.0.5": "patches/heatmap.js@2.0.5.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
13
patches/heatmap.js@2.0.5.patch
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/build/heatmap.js b/build/heatmap.js
|
||||||
|
index 3eee39ea8c127b065fb4df763ab76af152e7d368..a37c950b937d04805b62832c661890931d0f3ff1 100644
|
||||||
|
--- a/build/heatmap.js
|
||||||
|
+++ b/build/heatmap.js
|
||||||
|
@@ -524,7 +524,7 @@ var Canvas2dRenderer = (function Canvas2dRendererClosure() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
- img.data = imgData;
|
||||||
|
+ //img.data = imgData;
|
||||||
|
this.ctx.putImageData(img, x, y);
|
||||||
|
|
||||||
|
this._renderBoundaries = [1000, 1000, 0, 0];
|
@ -1,4 +1,4 @@
|
|||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.1'
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
@ -8,6 +8,9 @@ overrides:
|
|||||||
playwright: 1.41.2
|
playwright: 1.41.2
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
heatmap.js@2.0.5:
|
||||||
|
hash: gydrxrztd4ruyhouu6tu7zh43e
|
||||||
|
path: patches/heatmap.js@2.0.5.patch
|
||||||
rrweb@2.0.0-alpha.12:
|
rrweb@2.0.0-alpha.12:
|
||||||
hash: t3xxecww6aodjl4qopwv6jdxmq
|
hash: t3xxecww6aodjl4qopwv6jdxmq
|
||||||
path: patches/rrweb@2.0.0-alpha.12.patch
|
path: patches/rrweb@2.0.0-alpha.12.patch
|
||||||
@ -196,6 +199,9 @@ dependencies:
|
|||||||
fuse.js:
|
fuse.js:
|
||||||
specifier: ^6.6.2
|
specifier: ^6.6.2
|
||||||
version: 6.6.2
|
version: 6.6.2
|
||||||
|
heatmap.js:
|
||||||
|
specifier: ^2.0.5
|
||||||
|
version: 2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e)
|
||||||
husky:
|
husky:
|
||||||
specifier: ^7.0.4
|
specifier: ^7.0.4
|
||||||
version: 7.0.4
|
version: 7.0.4
|
||||||
@ -257,8 +263,8 @@ dependencies:
|
|||||||
specifier: 1.128.3
|
specifier: 1.128.3
|
||||||
version: 1.128.3
|
version: 1.128.3
|
||||||
posthog-js-lite:
|
posthog-js-lite:
|
||||||
specifier: 2.5.0
|
specifier: 3.0.0
|
||||||
version: 2.5.0
|
version: 3.0.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^2.8.8
|
specifier: ^2.8.8
|
||||||
version: 2.8.8
|
version: 2.8.8
|
||||||
@ -350,7 +356,7 @@ dependencies:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents:
|
fsevents:
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.3
|
version: 2.3.2
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@babel/core':
|
'@babel/core':
|
||||||
@ -467,6 +473,9 @@ devDependencies:
|
|||||||
'@types/dompurify':
|
'@types/dompurify':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
|
'@types/heatmap.js':
|
||||||
|
specifier: ^2.0.41
|
||||||
|
version: 2.0.41
|
||||||
'@types/image-blob-reduce':
|
'@types/image-blob-reduce':
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
@ -7734,7 +7743,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==}
|
resolution: {integrity: sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.0.3
|
'@types/d3-array': 3.0.3
|
||||||
'@types/geojson': 7946.0.10
|
'@types/geojson': 7946.0.12
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/d3-delaunay@6.0.1:
|
/@types/d3-delaunay@6.0.1:
|
||||||
@ -7776,7 +7785,7 @@ packages:
|
|||||||
/@types/d3-geo@3.0.2:
|
/@types/d3-geo@3.0.2:
|
||||||
resolution: {integrity: sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==}
|
resolution: {integrity: sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.10
|
'@types/geojson': 7946.0.12
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/d3-hierarchy@3.1.0:
|
/@types/d3-hierarchy@3.1.0:
|
||||||
@ -7985,13 +7994,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
|
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/geojson@7946.0.10:
|
|
||||||
resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/geojson@7946.0.12:
|
/@types/geojson@7946.0.12:
|
||||||
resolution: {integrity: sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==}
|
resolution: {integrity: sha512-uK2z1ZHJyC0nQRbuovXFt4mzXDwf27vQeUWNhfKGwRcWW429GOhP8HxUHlM6TLH4bzmlv/HlEjpvJh3JfmGsAA==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/@types/google.maps@3.55.4:
|
/@types/google.maps@3.55.4:
|
||||||
resolution: {integrity: sha512-Ip3IfRs3RZjeC88V8FGnWQTQXeS5gkJedPSosN6DMi9Xs8buGTpsPq6UhREoZsGH+62VoQ6jiRBUR8R77If69w==}
|
resolution: {integrity: sha512-Ip3IfRs3RZjeC88V8FGnWQTQXeS5gkJedPSosN6DMi9Xs8buGTpsPq6UhREoZsGH+62VoQ6jiRBUR8R77If69w==}
|
||||||
@ -8009,6 +8013,12 @@ packages:
|
|||||||
'@types/unist': 2.0.6
|
'@types/unist': 2.0.6
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/heatmap.js@2.0.41:
|
||||||
|
resolution: {integrity: sha512-3oHffxC+N+1EKXjeC65klk1kHnLJ5i6tEKFNb/04J+qSfQuCliacsNBWDpt59JfG2vBXRRn+ICbzRZj48j6HfQ==}
|
||||||
|
dependencies:
|
||||||
|
'@types/leaflet': 0.7.40
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/hogan.js@3.0.5:
|
/@types/hogan.js@3.0.5:
|
||||||
resolution: {integrity: sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==}
|
resolution: {integrity: sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -8078,6 +8088,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/leaflet@0.7.40:
|
||||||
|
resolution: {integrity: sha512-R2UwXOKwnKZi9zNm37WbPTAVuqHmysE6NVihkc5DUrovTirUxFSbZzvXrlwv0n5sibe0w8VF1bWu0ta4kZlAaA==}
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.12
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/less@3.0.6:
|
/@types/less@3.0.6:
|
||||||
resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==}
|
resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -12825,7 +12841,6 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
dev: true
|
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/fsevents@2.3.3:
|
/fsevents@2.3.3:
|
||||||
@ -13273,6 +13288,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==}
|
resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/heatmap.js@2.0.5(patch_hash=gydrxrztd4ruyhouu6tu7zh43e):
|
||||||
|
resolution: {integrity: sha512-CG2gYFP5Cv9IQCXEg3ZRxnJDyAilhWnQlAuHYGuWVzv6mFtQelS1bR9iN80IyDmFECbFPbg6I0LR5uAFHgCthw==}
|
||||||
|
dev: false
|
||||||
|
patched: true
|
||||||
|
|
||||||
/helpertypes@0.0.19:
|
/helpertypes@0.0.19:
|
||||||
resolution: {integrity: sha512-J00e55zffgi3yVnUp0UdbMztNkr2PnizEkOe9URNohnrNhW5X0QpegkuLpOmFQInpi93Nb8MCjQRHAiCDF42NQ==}
|
resolution: {integrity: sha512-J00e55zffgi3yVnUp0UdbMztNkr2PnizEkOe9URNohnrNhW5X0QpegkuLpOmFQInpi93Nb8MCjQRHAiCDF42NQ==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@ -17453,8 +17473,8 @@ packages:
|
|||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
source-map-js: 1.0.2
|
source-map-js: 1.0.2
|
||||||
|
|
||||||
/posthog-js-lite@2.5.0:
|
/posthog-js-lite@3.0.0:
|
||||||
resolution: {integrity: sha512-Urvlp0Vu9h3td0BVFWt0QXFJDoOZcaAD83XM9d91NKMKTVPZtfU0ysoxstIf5mw/ce9ZfuMgpWPaagrZI4rmSg==}
|
resolution: {integrity: sha512-dyajjnfzZD1tht4N7p7iwf7nBnR1MjVaVu+MKr+7gBgA39bn28wizCIJZztZPtHy4PY0YwtSGgwfBCuG/hnHgA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/posthog-js@1.128.3:
|
/posthog-js@1.128.3:
|
||||||
|