Merge branch 'heatmaps' into dev
# Conflicts: # db/clickhouse/schema.sql
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- Create heatmap_event
|
||||
CREATE TABLE umami.heatmap_event
|
||||
(
|
||||
heatmap_event_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
url_path String,
|
||||
event_type UInt8,
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
@@ -380,3 +380,26 @@ AS SELECT
|
||||
groupArrayState(data_type) AS property_types
|
||||
FROM umami.session_data
|
||||
GROUP BY website_id, session_id, distinct_id;
|
||||
|
||||
-- Create heatmap_event
|
||||
CREATE TABLE umami.heatmap_event
|
||||
(
|
||||
heatmap_event_id UUID,
|
||||
website_id UUID,
|
||||
session_id UUID,
|
||||
visit_id UUID,
|
||||
url_path String,
|
||||
event_type UInt8,
|
||||
node_id Nullable(Int32),
|
||||
x Nullable(Int32),
|
||||
y Nullable(Int32),
|
||||
viewport_w Nullable(Int32),
|
||||
viewport_h Nullable(Int32),
|
||||
page_h Nullable(Int32),
|
||||
scroll_pct Nullable(UInt8),
|
||||
created_at DateTime('UTC')
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (website_id, url_path, event_type, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
@@ -28,6 +28,7 @@ const contentSecurityPolicy = `
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
connect-src 'self' https:;
|
||||
frame-src 'self' http: https:;
|
||||
frame-ancestors 'self' ${frameAncestors};
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "heatmap_event" (
|
||||
"heatmap_event_id" UUID NOT NULL,
|
||||
"website_id" UUID NOT NULL,
|
||||
"session_id" UUID NOT NULL,
|
||||
"visit_id" UUID NOT NULL,
|
||||
"url_path" VARCHAR(500) NOT NULL,
|
||||
"event_type" INTEGER NOT NULL,
|
||||
"node_id" INTEGER,
|
||||
"x" INTEGER,
|
||||
"y" INTEGER,
|
||||
"viewport_w" INTEGER,
|
||||
"viewport_h" INTEGER,
|
||||
"scroll_pct" INTEGER,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "heatmap_event_pkey" PRIMARY KEY ("heatmap_event_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "heatmap_event_website_id_idx" ON "heatmap_event"("website_id");
|
||||
CREATE INDEX "heatmap_event_visit_id_idx" ON "heatmap_event"("visit_id");
|
||||
CREATE INDEX "heatmap_event_website_id_created_at_idx" ON "heatmap_event"("website_id", "created_at");
|
||||
CREATE INDEX "heatmap_event_website_id_url_path_event_type_created_at_idx" ON "heatmap_event"("website_id", "url_path", "event_type", "created_at");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "heatmap_event" ADD COLUMN "page_h" INTEGER;
|
||||
@@ -88,6 +88,7 @@ model Website {
|
||||
sessionData SessionData[]
|
||||
sessionReplays SessionReplay[]
|
||||
sessionReplaysSaved SessionReplaySaved[]
|
||||
heatmapEvents HeatmapEvent[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@ -398,4 +399,29 @@ model SessionReplaySaved {
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@map("session_replay_saved")
|
||||
}
|
||||
|
||||
model HeatmapEvent {
|
||||
id String @id() @map("heatmap_event_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
visitId String @map("visit_id") @db.Uuid
|
||||
urlPath String @map("url_path") @db.VarChar(500)
|
||||
eventType Int @map("event_type") @db.Integer
|
||||
nodeId Int? @map("node_id") @db.Integer
|
||||
x Int? @db.Integer
|
||||
y Int? @db.Integer
|
||||
viewportW Int? @map("viewport_w") @db.Integer
|
||||
viewportH Int? @map("viewport_h") @db.Integer
|
||||
pageH Int? @map("page_h") @db.Integer
|
||||
scrollPct Int? @map("scroll_pct") @db.Integer
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
website Website @relation(fields: [websiteId], references: [id])
|
||||
|
||||
@@index([websiteId])
|
||||
@@index([visitId])
|
||||
@@index([websiteId, createdAt])
|
||||
@@index([websiteId, urlPath, eventType, createdAt])
|
||||
@@map("heatmap_event")
|
||||
}
|
||||
@@ -140,6 +140,9 @@
|
||||
"greater-than-equals": "Greater than or equals",
|
||||
"grouped": "Grouped",
|
||||
"growth": "Growth",
|
||||
"heatmap": "Heatmap",
|
||||
"heatmap-description": "Visualize where visitors click and how far they scroll on your pages.",
|
||||
"heatmaps": "Heatmaps",
|
||||
"hostname": "Hostname",
|
||||
"hour": "Hour",
|
||||
"includes": "Includes",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.pageList {
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.pageButton {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pageButton:hover {
|
||||
background: var(--interactive-bg-hover);
|
||||
}
|
||||
|
||||
.pageButtonSelected,
|
||||
.pageButtonSelected:hover {
|
||||
background: var(--surface-inverted);
|
||||
color: var(--surface-base);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
background: var(--interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggleButtonSelected,
|
||||
.toggleButtonSelected:hover {
|
||||
background: var(--surface-inverted);
|
||||
color: var(--surface-base);
|
||||
border-color: var(--surface-inverted);
|
||||
}
|
||||
|
||||
.scrollBand {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.scrollBandLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.canvasWrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-wrapper) {
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snapshot :global(iframe) {
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snapshot :global(.replayer-mouse),
|
||||
.snapshot :global(.replayer-mouse-tail) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvasLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 60, 0, 0.9) 0%,
|
||||
rgba(255, 200, 0, 0.6) 45%,
|
||||
rgba(255, 200, 0, 0) 80%
|
||||
);
|
||||
pointer-events: auto;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
'use client';
|
||||
import { Column, Grid, Heading, Loading, Row, Text } from '@umami/react-zen';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useResultQuery } from '@/components/hooks';
|
||||
import { useReplayQuery } from '@/components/hooks/queries/useReplayQuery';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
||||
import styles from './Heatmap.module.css';
|
||||
import 'rrweb/dist/replay/rrweb-replay.css';
|
||||
|
||||
const MAX_RENDER_WIDTH = 1024;
|
||||
|
||||
function useElementWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const w = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return [ref, width] as const;
|
||||
}
|
||||
|
||||
interface ReplayData {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
interface ViewportBucket {
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface HeatmapProps {
|
||||
websiteId: string;
|
||||
urlPath: string;
|
||||
onUrlPathChange: (urlPath: string) => void;
|
||||
mode: HeatmapMode;
|
||||
onModeChange: (mode: HeatmapMode) => void;
|
||||
}
|
||||
|
||||
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, onModeChange }: HeatmapProps) {
|
||||
const { data, error, isLoading } = useResultQuery<HeatmapResult>('heatmap', {
|
||||
websiteId,
|
||||
urlPath: urlPath || undefined,
|
||||
mode,
|
||||
});
|
||||
|
||||
const pages = data?.pages ?? [];
|
||||
const points = data?.points ?? [];
|
||||
const scroll = data?.scroll;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="900px">
|
||||
<Grid columns="320px 1fr" gap minHeight="900px">
|
||||
<PageList pages={pages} selected={urlPath} onSelect={onUrlPathChange} mode={mode} />
|
||||
<Column gap>
|
||||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
{urlPath ? (
|
||||
mode === 'scroll' ? (
|
||||
<ScrollHeatmapView
|
||||
websiteId={websiteId}
|
||||
scroll={scroll}
|
||||
snapshot={data?.snapshot ?? null}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapView
|
||||
websiteId={websiteId}
|
||||
points={points}
|
||||
snapshot={data?.snapshot ?? null}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</Column>
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: HeatmapMode;
|
||||
onChange: (mode: HeatmapMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Row gap="2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('click')}
|
||||
className={`${styles.toggleButton} ${mode === 'click' ? styles.toggleButtonSelected : ''}`}
|
||||
>
|
||||
Clicks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('scroll')}
|
||||
className={`${styles.toggleButton} ${mode === 'scroll' ? styles.toggleButtonSelected : ''}`}
|
||||
>
|
||||
Scroll
|
||||
</button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function PageList({
|
||||
pages,
|
||||
selected,
|
||||
onSelect,
|
||||
mode,
|
||||
}: {
|
||||
pages: HeatmapResult['pages'];
|
||||
selected: string;
|
||||
onSelect: (urlPath: string) => void;
|
||||
mode: HeatmapMode;
|
||||
}) {
|
||||
return (
|
||||
<Column className={styles.pageList} gap="2">
|
||||
<Heading size="lg">Pages</Heading>
|
||||
{pages.length === 0 && <Text color="muted">No data yet</Text>}
|
||||
{pages.map(p => (
|
||||
<button
|
||||
key={p.urlPath}
|
||||
type="button"
|
||||
onClick={() => onSelect(p.urlPath)}
|
||||
className={`${styles.pageButton} ${selected === p.urlPath ? styles.pageButtonSelected : ''}`}
|
||||
>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="2">
|
||||
<Text truncate>{p.urlPath}</Text>
|
||||
<Text color="muted">{formatLongNumber(mode === 'scroll' ? p.sessions : p.count)}</Text>
|
||||
</Row>
|
||||
</button>
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function pickViewport(points: HeatmapPoint[]): ViewportBucket | null {
|
||||
if (!points.length) return null;
|
||||
const buckets = new Map<string, ViewportBucket>();
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
}
|
||||
}
|
||||
let best: ViewportBucket | null = null;
|
||||
for (const b of buckets.values()) {
|
||||
if (!best || b.count > best.count) best = b;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function HeatmapView({
|
||||
websiteId,
|
||||
points,
|
||||
snapshot,
|
||||
}: {
|
||||
websiteId: string;
|
||||
points: HeatmapPoint[];
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
|
||||
const viewport = useMemo(() => pickViewport(points), [points]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!viewport) return [];
|
||||
return points.filter(p => p.viewportW === viewport.width && p.viewportH === viewport.height);
|
||||
}, [points, viewport]);
|
||||
|
||||
const maxCount = useMemo(
|
||||
() => visible.reduce((m, p) => (p.count > m ? p.count : m), 1),
|
||||
[visible],
|
||||
);
|
||||
|
||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (!viewport || visible.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||
const scale = renderWidth ? renderWidth / viewport.width : 0;
|
||||
const renderHeight = Math.round(viewport.height * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
{visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '}
|
||||
clicks · viewport {viewport.width}×{viewport.height}
|
||||
</Text>
|
||||
{snapshot && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setShowPage(v => !v)}
|
||||
>
|
||||
{showPage ? 'Hide page' : 'Show page'}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewport.width}
|
||||
height={viewport.height}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{visible.map((p, i) => {
|
||||
const intensity = Math.min(1, p.count / maxCount);
|
||||
const size = 24 + intensity * 36;
|
||||
return (
|
||||
<div
|
||||
key={`${p.x}-${p.y}-${i}`}
|
||||
className={styles.dot}
|
||||
style={{
|
||||
left: p.x * scale - size / 2,
|
||||
top: p.y * scale - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: 0.25 + intensity * 0.55,
|
||||
}}
|
||||
title={`${p.count} click${p.count === 1 ? '' : 's'}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollHeatmapView({
|
||||
websiteId,
|
||||
scroll,
|
||||
snapshot,
|
||||
}: {
|
||||
websiteId: string;
|
||||
scroll: HeatmapResult['scroll'] | undefined;
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
}) {
|
||||
const [showPage, setShowPage] = useState(true);
|
||||
const [snapshotReady, setSnapshotReady] = useState(false);
|
||||
|
||||
const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>();
|
||||
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshotReady(!(showPage && snapshot));
|
||||
}, [containerWidth, showPage, snapshot]);
|
||||
|
||||
if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) {
|
||||
return <EmptyState message="No scroll data for this page yet." />;
|
||||
}
|
||||
|
||||
const { buckets, totalSessions, pageH, viewportW, viewportH } = scroll;
|
||||
const renderWidth = containerWidth > 0 ? Math.min(containerWidth, MAX_RENDER_WIDTH) : 0;
|
||||
const scale = renderWidth ? renderWidth / viewportW : 0;
|
||||
const renderHeight = Math.round(pageH * scale);
|
||||
const showSnapshot = renderWidth > 0 && showPage && !!snapshot;
|
||||
const showOverlay = !showSnapshot || snapshotReady;
|
||||
|
||||
// Cumulative reach: % of sessions that scrolled at least to depth D.
|
||||
const sortedBuckets = [...buckets].sort((a, b) => a.depth - b.depth);
|
||||
let remaining = totalSessions;
|
||||
const cumulative = sortedBuckets.map(b => {
|
||||
const reached = remaining;
|
||||
remaining -= b.sessions;
|
||||
return { depth: b.depth, reached, ratio: reached / totalSessions };
|
||||
});
|
||||
|
||||
// Build page-spanning bands. Each band covers a vertical slice of the page.
|
||||
// Intensity = fraction of sessions reaching the band's TOP edge (everyone who
|
||||
// got that far saw at least the start of the slice).
|
||||
type Band = { fromPct: number; toPct: number; reached: number; ratio: number };
|
||||
const bands: Band[] = [];
|
||||
const firstDepth = cumulative[0]?.depth ?? 100;
|
||||
if (firstDepth > 0) {
|
||||
bands.push({ fromPct: 0, toPct: firstDepth, reached: totalSessions, ratio: 1 });
|
||||
}
|
||||
for (let i = 0; i < cumulative.length; i++) {
|
||||
const c = cumulative[i];
|
||||
const toPct = cumulative[i + 1]?.depth ?? 100;
|
||||
if (c.depth < toPct) {
|
||||
bands.push({ fromPct: c.depth, toPct, reached: c.reached, ratio: c.ratio });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Text color="muted">
|
||||
{formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH}
|
||||
{viewportH ? ` · viewport ${viewportH}` : ''}
|
||||
</Text>
|
||||
{snapshot && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleButton}
|
||||
onClick={() => setShowPage(v => !v)}
|
||||
>
|
||||
{showPage ? 'Hide page' : 'Show page'}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
<div ref={containerRef} className={styles.canvasWrapper}>
|
||||
<div
|
||||
className={styles.canvas}
|
||||
style={{ width: renderWidth || '100%', height: renderHeight || 0 }}
|
||||
>
|
||||
{showSnapshot && !snapshotReady && <CanvasLoading />}
|
||||
{showSnapshot && (
|
||||
<ReplaySnapshot
|
||||
websiteId={websiteId}
|
||||
snapshot={snapshot}
|
||||
width={viewportW}
|
||||
height={pageH}
|
||||
scale={scale}
|
||||
onReady={handleSnapshotReady}
|
||||
/>
|
||||
)}
|
||||
{showOverlay && (
|
||||
<div className={styles.overlay}>
|
||||
{bands.map(b => {
|
||||
const top = Math.round((b.fromPct / 100) * renderHeight);
|
||||
const bottom = Math.round((b.toPct / 100) * renderHeight);
|
||||
const height = Math.max(0, bottom - top);
|
||||
const intensity = b.ratio;
|
||||
const hue = Math.round(60 - intensity * 60); // 60=yellow → 0=red
|
||||
return (
|
||||
<div
|
||||
key={b.fromPct}
|
||||
className={styles.scrollBand}
|
||||
style={{
|
||||
top,
|
||||
height,
|
||||
background: `hsla(${hue}, 90%, 50%, ${0.15 + intensity * 0.5})`,
|
||||
}}
|
||||
title={`${b.fromPct}–${b.toPct}% — ${formatLongNumber(b.reached)} sessions (${Math.round(intensity * 100)}%)`}
|
||||
>
|
||||
<span className={styles.scrollBandLabel}>
|
||||
{b.fromPct}% · {Math.round(intensity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplaySnapshot({
|
||||
websiteId,
|
||||
snapshot,
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
onReady,
|
||||
}: {
|
||||
websiteId: string;
|
||||
snapshot: HeatmapSnapshot;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
onReady: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const replayerRef = useRef<any>(null);
|
||||
const { data } = useReplayQuery(websiteId, snapshot.replayId) as { data?: ReplayData };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const events = data?.events;
|
||||
if (!container || !events?.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
import('rrweb').then(({ Replayer }) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
const replayer = new Replayer(events, {
|
||||
root: container,
|
||||
showWarning: false,
|
||||
mouseTail: false,
|
||||
triggerFocus: false,
|
||||
pauseAnimation: true,
|
||||
useVirtualDom: false,
|
||||
});
|
||||
|
||||
replayerRef.current = replayer;
|
||||
let ready = false;
|
||||
|
||||
const freeze = () => {
|
||||
const offset = Math.max(0, snapshot.timestamp - events[0].timestamp);
|
||||
replayer.pause(offset);
|
||||
replayer.disableInteract();
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
};
|
||||
|
||||
const handleReady = () => {
|
||||
if (ready || cancelled) return;
|
||||
ready = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
freeze();
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
freeze();
|
||||
onReady();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
replayer.on('fullsnapshot-rebuilded', handleReady);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (replayerRef.current) {
|
||||
replayerRef.current.destroy();
|
||||
replayerRef.current = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
};
|
||||
}, [data?.events, height, onReady, snapshot.timestamp, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replayerRef.current) {
|
||||
resizeReplayFrame(replayerRef.current, width, height);
|
||||
}
|
||||
}, [height, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.snapshot}
|
||||
style={{ width, height, transform: `scale(${scale})` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasLoading() {
|
||||
return (
|
||||
<div className={styles.canvasLoading}>
|
||||
<Loading icon="dots" placement="center" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resizeReplayFrame(replayer: any, width: number, height: number) {
|
||||
const { iframe, wrapper } = replayer;
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
if (iframe) {
|
||||
iframe.setAttribute('width', String(width));
|
||||
iframe.setAttribute('height', String(height));
|
||||
iframe.style.width = `${width}px`;
|
||||
iframe.style.height = `${height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<Column alignItems="center" justifyContent="center" height="100%" gap>
|
||||
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
|
||||
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import type { HeatmapMode } from '@/queries/sql';
|
||||
import { Heatmap } from './Heatmap';
|
||||
|
||||
export function HeatmapsPage({ websiteId }: { websiteId: string }) {
|
||||
const [urlPath, setUrlPath] = useState<string>('');
|
||||
const [mode, setMode] = useState<HeatmapMode>('click');
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<Panel minHeight="900px" allowFullscreen>
|
||||
<Heatmap
|
||||
websiteId={websiteId}
|
||||
urlPath={urlPath}
|
||||
onUrlPathChange={setUrlPath}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { HeatmapsPage } from './HeatmapsPage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <HeatmapsPage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Heatmaps',
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, forbidden, json, serverError } from '@/lib/response';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
import { saveRecording } from '@/queries/sql';
|
||||
import { extractHeatmapEvents } from '@/queries/sql/heatmap/extractHeatmapEvents';
|
||||
import { saveHeatmapEvents } from '@/queries/sql/heatmap/saveHeatmapEvents';
|
||||
|
||||
interface Cache {
|
||||
sessionId: string;
|
||||
@@ -113,6 +115,22 @@ export async function POST(request: Request) {
|
||||
endedAt,
|
||||
});
|
||||
|
||||
try {
|
||||
const heatmapRows = extractHeatmapEvents(events).map(e => ({
|
||||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
...e,
|
||||
}));
|
||||
|
||||
if (heatmapRows.length) {
|
||||
await saveHeatmapEvents(heatmapRows);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('heatmap extraction failed', serializeError(e));
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
const error = serializeError(e);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { parseRequest, setWebsiteDate } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { reportResultSchema } from '@/lib/schema';
|
||||
import { canViewWebsite } from '@/permissions';
|
||||
import { getHeatmap, type HeatmapParameters } from '@/queries/sql';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
const { websiteId } = body;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const parameters = (await setWebsiteDate(websiteId, body.parameters)) as HeatmapParameters;
|
||||
|
||||
const data = await getHeatmap(websiteId, parameters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
@@ -10,11 +10,13 @@ export function TypeIcon({
|
||||
value: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const iconValue = type === 'browser' && value === 'browser' ? 'unknown' : value;
|
||||
|
||||
return (
|
||||
<Row gap="3" alignItems="center">
|
||||
<img
|
||||
src={`${process.env.basePath || ''}/images/${type}/${
|
||||
value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
|
||||
iconValue?.replaceAll(' ', '-').toLowerCase() || 'unknown'
|
||||
}.png`}
|
||||
onError={e => {
|
||||
e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChartPie,
|
||||
Clock,
|
||||
Eye,
|
||||
Flame,
|
||||
Sheet,
|
||||
Tag,
|
||||
User,
|
||||
@@ -107,6 +108,12 @@ export function useWebsiteNavItems(websiteId: string) {
|
||||
icon: <Video />,
|
||||
path: renderPath('/replays'),
|
||||
},
|
||||
{
|
||||
id: 'heatmaps',
|
||||
label: t(labels.heatmaps),
|
||||
icon: <Flame />,
|
||||
path: renderPath('/heatmaps'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -284,6 +284,9 @@ export const labels: Record<string, string> = {
|
||||
journey: 'label.journey',
|
||||
journeys: 'label.journeys',
|
||||
journeyDescription: 'label.journey-description',
|
||||
heatmap: 'label.heatmap',
|
||||
heatmaps: 'label.heatmaps',
|
||||
heatmapDescription: 'label.heatmap-description',
|
||||
compareDates: 'label.compare-dates',
|
||||
compare: 'label.compare',
|
||||
current: 'label.current',
|
||||
|
||||
@@ -119,6 +119,11 @@ export const EVENT_TYPE = {
|
||||
performance: 5,
|
||||
} as const;
|
||||
|
||||
export const HEATMAP_EVENT_TYPE = {
|
||||
click: 1,
|
||||
scroll: 2,
|
||||
} as const;
|
||||
|
||||
export const ENTITY_TYPE = {
|
||||
website: 1,
|
||||
link: 2,
|
||||
@@ -271,6 +276,7 @@ export const BROWSERS = {
|
||||
aol: 'AOL',
|
||||
bb10: 'BlackBerry 10',
|
||||
beaker: 'Beaker',
|
||||
browser: 'Unknown',
|
||||
chrome: 'Chrome',
|
||||
'chromium-webview': 'Chrome (webview)',
|
||||
crios: 'Chrome (iOS)',
|
||||
|
||||
+18
-7
@@ -131,6 +131,7 @@ export const reportTypeParam = z.enum([
|
||||
'breakdown',
|
||||
'funnel',
|
||||
'goal',
|
||||
'heatmap',
|
||||
'journey',
|
||||
'performance',
|
||||
'retention',
|
||||
@@ -159,13 +160,12 @@ export const operatorParam = z.enum([
|
||||
|
||||
export const goalReportSchema = z.object({
|
||||
type: z.literal('goal'),
|
||||
parameters: z
|
||||
.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
type: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
parameters: z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
type: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const funnelReportSchema = z.object({
|
||||
@@ -268,6 +268,16 @@ export const breakdownReportSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const heatmapReportSchema = z.object({
|
||||
type: z.literal('heatmap'),
|
||||
parameters: z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
urlPath: z.string().max(500).optional(),
|
||||
mode: z.enum(['click', 'scroll']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const reportBaseSchema = z.object({
|
||||
websiteId: z.uuid(),
|
||||
type: reportTypeParam,
|
||||
@@ -286,6 +296,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||
revenueReportSchema,
|
||||
attributionReportSchema,
|
||||
breakdownReportSchema,
|
||||
heatmapReportSchema,
|
||||
]);
|
||||
|
||||
export const reportSchema = reportBaseSchema;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||
|
||||
const RRWEB_TYPE_INCREMENTAL = 3;
|
||||
const RRWEB_TYPE_META = 4;
|
||||
const RRWEB_TYPE_CUSTOM = 5;
|
||||
const RRWEB_SOURCE_MOUSE_INTERACTION = 2;
|
||||
const RRWEB_SOURCE_VIEWPORT_RESIZE = 4;
|
||||
const RRWEB_MOUSE_CLICK = 2;
|
||||
|
||||
export interface ExtractedHeatmapEvent {
|
||||
eventType: number;
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageH: number | null;
|
||||
scrollPct: number | null;
|
||||
urlPath: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function safePathname(href: unknown): string | null {
|
||||
if (typeof href !== 'string') return null;
|
||||
try {
|
||||
return new URL(href).pathname || '/';
|
||||
} catch {
|
||||
return href.startsWith('/') ? href.split(/[?#]/)[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractHeatmapEvents(events: any[]): ExtractedHeatmapEvent[] {
|
||||
if (!Array.isArray(events) || events.length === 0) return [];
|
||||
|
||||
let urlPath: string | null = null;
|
||||
let viewportW: number | null = null;
|
||||
let viewportH: number | null = null;
|
||||
const out: ExtractedHeatmapEvent[] = [];
|
||||
|
||||
for (const ev of events) {
|
||||
if (!ev || typeof ev !== 'object') continue;
|
||||
|
||||
if (ev.type === RRWEB_TYPE_META && ev.data) {
|
||||
const path = safePathname(ev.data.href);
|
||||
if (path) urlPath = path;
|
||||
if (typeof ev.data.width === 'number') viewportW = ev.data.width;
|
||||
if (typeof ev.data.height === 'number') viewportH = ev.data.height;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev.type === RRWEB_TYPE_CUSTOM && ev.data) {
|
||||
if (ev.data.tag === 'url-change') {
|
||||
const path = safePathname(ev.data.payload?.url);
|
||||
if (path) urlPath = path;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev.data.tag === 'scroll-progress' && ev.data.payload) {
|
||||
const p = ev.data.payload;
|
||||
const path = safePathname(p.url) ?? urlPath;
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.scroll,
|
||||
nodeId: null,
|
||||
x: null,
|
||||
y: null,
|
||||
viewportW: typeof p.viewportW === 'number' ? p.viewportW : viewportW,
|
||||
viewportH: typeof p.viewportH === 'number' ? p.viewportH : viewportH,
|
||||
pageH: typeof p.pageH === 'number' ? p.pageH : null,
|
||||
scrollPct:
|
||||
typeof p.scrollPct === 'number'
|
||||
? Math.max(0, Math.min(100, Math.round(p.scrollPct)))
|
||||
: null,
|
||||
urlPath: path,
|
||||
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev.type !== RRWEB_TYPE_INCREMENTAL || !ev.data) continue;
|
||||
|
||||
const d = ev.data;
|
||||
|
||||
if (d.source === RRWEB_SOURCE_VIEWPORT_RESIZE) {
|
||||
if (typeof d.width === 'number') viewportW = d.width;
|
||||
if (typeof d.height === 'number') viewportH = d.height;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
d.source === RRWEB_SOURCE_MOUSE_INTERACTION &&
|
||||
d.type === RRWEB_MOUSE_CLICK &&
|
||||
urlPath !== null
|
||||
) {
|
||||
out.push({
|
||||
eventType: HEATMAP_EVENT_TYPE.click,
|
||||
nodeId: typeof d.id === 'number' ? d.id : null,
|
||||
x: typeof d.x === 'number' ? Math.round(d.x) : null,
|
||||
y: typeof d.y === 'number' ? Math.round(d.y) : null,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pageH: null,
|
||||
scrollPct: null,
|
||||
urlPath,
|
||||
createdAt: new Date(typeof ev.timestamp === 'number' ? ev.timestamp : Date.now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
const FUNCTION_NAME = 'getHeatmap';
|
||||
|
||||
const POINT_LIMIT = 5000;
|
||||
const PAGE_LIMIT = 100;
|
||||
const SCROLL_BUCKET_SIZE = 5;
|
||||
|
||||
export type HeatmapMode = 'click' | 'scroll';
|
||||
|
||||
export interface HeatmapParameters {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
urlPath?: string;
|
||||
mode?: HeatmapMode;
|
||||
}
|
||||
|
||||
export interface HeatmapPage {
|
||||
urlPath: string;
|
||||
count: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface HeatmapPoint {
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface HeatmapScrollBucket {
|
||||
depth: number;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface HeatmapSnapshot {
|
||||
replayId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface HeatmapResult {
|
||||
mode: HeatmapMode;
|
||||
pages: HeatmapPage[];
|
||||
points: HeatmapPoint[];
|
||||
snapshot: HeatmapSnapshot | null;
|
||||
scroll: {
|
||||
buckets: HeatmapScrollBucket[];
|
||||
totalSessions: number;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHeatmap(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
): Promise<HeatmapResult> {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(websiteId, parameters),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(websiteId, parameters),
|
||||
});
|
||||
}
|
||||
|
||||
const emptyScroll = (): HeatmapResult['scroll'] => ({
|
||||
buckets: [],
|
||||
totalSessions: 0,
|
||||
pageH: null,
|
||||
viewportW: null,
|
||||
viewportH: null,
|
||||
});
|
||||
|
||||
function pickSnapshotViewport(points: HeatmapPoint[]): { width: number; height: number } | null {
|
||||
const buckets = new Map<string, { width: number; height: number; count: number }>();
|
||||
|
||||
for (const p of points) {
|
||||
const key = `${p.viewportW}x${p.viewportH}`;
|
||||
const existing = buckets.get(key);
|
||||
if (existing) {
|
||||
existing.count += p.count;
|
||||
} else {
|
||||
buckets.set(key, { width: p.viewportW, height: p.viewportH, count: p.count });
|
||||
}
|
||||
}
|
||||
|
||||
let best: { width: number; height: number; count: number } | null = null;
|
||||
for (const bucket of buckets.values()) {
|
||||
if (!best || bucket.count > best.count) {
|
||||
best = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { width: best.width, height: best.height } : null;
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
|
||||
): Promise<HeatmapResult> {
|
||||
const { rawQuery } = prisma;
|
||||
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
|
||||
|
||||
const pages: HeatmapPage[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
url_path as "urlPath",
|
||||
count(*)::int as count,
|
||||
count(distinct visit_id)::int as sessions
|
||||
from heatmap_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and event_type = {{eventType}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
group by url_path
|
||||
order by count desc
|
||||
limit ${PAGE_LIMIT}
|
||||
`,
|
||||
{ websiteId, eventType, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
if (mode === 'scroll') {
|
||||
const bucketRows: { depth: number | string; sessions: number | string }[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
(floor(max_pct / ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE})::int as depth,
|
||||
count(*)::int as sessions
|
||||
from (
|
||||
select visit_id, max(scroll_pct) as max_pct
|
||||
from heatmap_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and event_type = {{eventType}}
|
||||
and url_path = {{urlPath}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
and scroll_pct is not null
|
||||
group by visit_id
|
||||
) per_session
|
||||
group by depth
|
||||
order by depth
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const dimRows: {
|
||||
totalSessions: number | string;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
}[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
count(distinct visit_id)::int as "totalSessions",
|
||||
(mode() within group (order by page_h))::int as "pageH",
|
||||
(mode() within group (order by viewport_w))::int as "viewportW",
|
||||
(mode() within group (order by viewport_h))::int as "viewportH"
|
||||
from heatmap_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and event_type = {{eventType}}
|
||||
and url_path = {{urlPath}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
and scroll_pct is not null
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const dim = dimRows[0];
|
||||
const scroll = {
|
||||
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
|
||||
totalSessions: Number(dim?.totalSessions ?? 0),
|
||||
pageH: dim?.pageH ?? null,
|
||||
viewportW: dim?.viewportW ?? null,
|
||||
viewportH: dim?.viewportH ?? null,
|
||||
};
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
});
|
||||
|
||||
return {
|
||||
mode,
|
||||
pages,
|
||||
points: [],
|
||||
snapshot,
|
||||
scroll,
|
||||
};
|
||||
}
|
||||
|
||||
const rawPoints: HeatmapPoint[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
node_id as "nodeId",
|
||||
x,
|
||||
y,
|
||||
viewport_w as "viewportW",
|
||||
viewport_h as "viewportH",
|
||||
count(*)::int as count
|
||||
from heatmap_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and event_type = {{eventType}}
|
||||
and url_path = {{urlPath}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
and x is not null
|
||||
and y is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
group by node_id, x, y, viewport_w, viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const viewport = pickSnapshotViewport(rawPoints);
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points: rawPoints, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getRelationalSnapshot(
|
||||
rawQuery: typeof prisma.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {{viewportW}}
|
||||
and h.viewport_h = {{viewportH}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows: { replayId: string; timestamp: number | string }[] = await rawQuery(
|
||||
`
|
||||
select
|
||||
h.visit_id as "replayId",
|
||||
(extract(epoch from h.created_at) * 1000)::bigint as "timestamp"
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {{websiteId::uuid}}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
where h.website_id = {{websiteId::uuid}}
|
||||
and h.event_type = {{eventType}}
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${viewportFilter}
|
||||
order by h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
return row ? { replayId: row.replayId, timestamp: Number(row.timestamp) } : null;
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
{ startDate, endDate, urlPath, mode = 'click' }: HeatmapParameters,
|
||||
): Promise<HeatmapResult> {
|
||||
const { rawQuery } = clickhouse;
|
||||
const eventType = mode === 'scroll' ? HEATMAP_EVENT_TYPE.scroll : HEATMAP_EVENT_TYPE.click;
|
||||
|
||||
const pageRows = await rawQuery<
|
||||
{ urlPath: string; count: string | number; sessions: string | number }[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
url_path as urlPath,
|
||||
count() as count,
|
||||
uniq(visit_id) as sessions
|
||||
from heatmap_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and event_type = {eventType:UInt8}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
group by url_path
|
||||
order by count desc
|
||||
limit ${PAGE_LIMIT}
|
||||
`,
|
||||
{ websiteId, eventType, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const pages: HeatmapPage[] = pageRows.map(p => ({
|
||||
urlPath: p.urlPath,
|
||||
count: Number(p.count),
|
||||
sessions: Number(p.sessions),
|
||||
}));
|
||||
|
||||
if (!urlPath) {
|
||||
return { mode, pages, points: [], snapshot: null, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
if (mode === 'scroll') {
|
||||
const bucketRows = await rawQuery<{ depth: number | string; sessions: number | string }[]>(
|
||||
`
|
||||
select
|
||||
intDiv(max_pct, ${SCROLL_BUCKET_SIZE}) * ${SCROLL_BUCKET_SIZE} as depth,
|
||||
count() as sessions
|
||||
from (
|
||||
select visit_id, max(scroll_pct) as max_pct
|
||||
from heatmap_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and event_type = {eventType:UInt8}
|
||||
and url_path = {urlPath:String}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and scroll_pct is not null
|
||||
group by visit_id
|
||||
)
|
||||
group by depth
|
||||
order by depth
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const dimRows = await rawQuery<
|
||||
{
|
||||
totalSessions: number | string;
|
||||
pageH: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
uniq(visit_id) as totalSessions,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(page_h), 1))) as pageH,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(viewport_w), 1))) as viewportW,
|
||||
toInt32OrNull(toString(arrayElement(topK(1)(viewport_h), 1))) as viewportH
|
||||
from heatmap_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and event_type = {eventType:UInt8}
|
||||
and url_path = {urlPath:String}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and scroll_pct is not null
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const dim = dimRows[0];
|
||||
const scroll = {
|
||||
buckets: bucketRows.map(r => ({ depth: Number(r.depth), sessions: Number(r.sessions) })),
|
||||
totalSessions: Number(dim?.totalSessions ?? 0),
|
||||
pageH: dim?.pageH === null || dim?.pageH === undefined ? null : Number(dim.pageH),
|
||||
viewportW:
|
||||
dim?.viewportW === null || dim?.viewportW === undefined ? null : Number(dim.viewportW),
|
||||
viewportH:
|
||||
dim?.viewportH === null || dim?.viewportH === undefined ? null : Number(dim.viewportH),
|
||||
};
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
});
|
||||
|
||||
return {
|
||||
mode,
|
||||
pages,
|
||||
points: [],
|
||||
snapshot,
|
||||
scroll,
|
||||
};
|
||||
}
|
||||
|
||||
const pointRows = await rawQuery<
|
||||
{
|
||||
nodeId: number | null;
|
||||
x: number;
|
||||
y: number;
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
count: string | number;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
select
|
||||
node_id as nodeId,
|
||||
x,
|
||||
y,
|
||||
viewport_w as viewportW,
|
||||
viewport_h as viewportH,
|
||||
count() as count
|
||||
from heatmap_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and event_type = {eventType:UInt8}
|
||||
and url_path = {urlPath:String}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and x is not null
|
||||
and y is not null
|
||||
and viewport_w is not null
|
||||
and viewport_h is not null
|
||||
group by node_id, x, y, viewport_w, viewport_h
|
||||
order by count desc
|
||||
limit ${POINT_LIMIT}
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const points: HeatmapPoint[] = pointRows.map(p => ({
|
||||
nodeId: p.nodeId === null || p.nodeId === undefined ? null : Number(p.nodeId),
|
||||
x: Number(p.x),
|
||||
y: Number(p.y),
|
||||
viewportW: Number(p.viewportW),
|
||||
viewportH: Number(p.viewportH),
|
||||
count: Number(p.count),
|
||||
}));
|
||||
|
||||
const viewport = pickSnapshotViewport(points);
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
});
|
||||
|
||||
return { mode, pages, points, snapshot, scroll: emptyScroll() };
|
||||
}
|
||||
|
||||
async function getClickhouseSnapshot(
|
||||
rawQuery: typeof clickhouse.rawQuery,
|
||||
{
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventType: number;
|
||||
urlPath: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
const viewportFilter =
|
||||
viewportW && viewportH
|
||||
? `
|
||||
and h.viewport_w = {viewportW:UInt32}
|
||||
and h.viewport_h = {viewportH:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows = await rawQuery<{ replayId: string; timestamp: string | number }[]>(
|
||||
`
|
||||
select
|
||||
toString(h.visit_id) as replayId,
|
||||
toUnixTimestamp(h.created_at) * 1000 as timestamp
|
||||
from heatmap_event h
|
||||
inner join (
|
||||
select distinct visit_id
|
||||
from session_replay
|
||||
where website_id = {websiteId:UUID}
|
||||
) sr on sr.visit_id = h.visit_id
|
||||
where h.website_id = {websiteId:UUID}
|
||||
and h.event_type = {eventType:UInt8}
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${viewportFilter}
|
||||
order by h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{ websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH },
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
return row ? { replayId: row.replayId, timestamp: Number(row.timestamp) } : null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||
import kafka from '@/lib/kafka';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export interface HeatmapEventRow {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
visitId: string;
|
||||
urlPath: string;
|
||||
eventType: number;
|
||||
nodeId: number | null;
|
||||
x: number | null;
|
||||
y: number | null;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
pageH: number | null;
|
||||
scrollPct: number | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export async function saveHeatmapEvents(rows: HeatmapEventRow[]) {
|
||||
if (!rows?.length) return;
|
||||
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(rows),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(rows),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(rows: HeatmapEventRow[]) {
|
||||
return prisma.client.heatmapEvent.createMany({
|
||||
data: rows.map(r => ({
|
||||
id: uuid(),
|
||||
websiteId: r.websiteId,
|
||||
sessionId: r.sessionId,
|
||||
visitId: r.visitId,
|
||||
urlPath: r.urlPath,
|
||||
eventType: r.eventType,
|
||||
nodeId: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
viewportW: r.viewportW,
|
||||
viewportH: r.viewportH,
|
||||
pageH: r.pageH,
|
||||
scrollPct: r.scrollPct,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function clickhouseQuery(rows: HeatmapEventRow[]) {
|
||||
const { insert, getUTCString } = clickhouse;
|
||||
const { sendMessage } = kafka;
|
||||
|
||||
const messages = rows.map(r => ({
|
||||
heatmap_event_id: uuid(),
|
||||
website_id: r.websiteId,
|
||||
session_id: r.sessionId,
|
||||
visit_id: r.visitId,
|
||||
url_path: r.urlPath,
|
||||
event_type: r.eventType,
|
||||
node_id: r.nodeId,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
viewport_w: r.viewportW,
|
||||
viewport_h: r.viewportH,
|
||||
page_h: r.pageH,
|
||||
scroll_pct: r.scrollPct,
|
||||
created_at: getUTCString(r.createdAt),
|
||||
}));
|
||||
|
||||
if (kafka.enabled) {
|
||||
return sendMessage('heatmap_event', messages);
|
||||
}
|
||||
|
||||
return insert('heatmap_event', messages);
|
||||
}
|
||||
@@ -26,6 +26,9 @@ export * from './getValues';
|
||||
export * from './getWebsiteDateRange';
|
||||
export * from './getWebsiteStats';
|
||||
export * from './getWeeklyTraffic';
|
||||
export * from './heatmap/extractHeatmapEvents';
|
||||
export * from './heatmap/getHeatmap';
|
||||
export * from './heatmap/saveHeatmapEvents';
|
||||
export * from './pageviews/getPageviewExpandedMetrics';
|
||||
export * from './pageviews/getPageviewMetrics';
|
||||
export * from './pageviews/getPageviewStats';
|
||||
|
||||
+111
-2
@@ -33,6 +33,8 @@ import { record } from 'rrweb';
|
||||
let flushTimer = null;
|
||||
let startTime = null;
|
||||
let stopped = false;
|
||||
let recorderReady = false;
|
||||
let customEventBuffer = [];
|
||||
|
||||
const sendEvents = (events, useKeepalive = false) => {
|
||||
const session = window.umami?.getSession?.();
|
||||
@@ -84,6 +86,42 @@ import { record } from 'rrweb';
|
||||
if (stopFn) stopFn();
|
||||
};
|
||||
|
||||
const flushCustomEvents = () => {
|
||||
if (!recorderReady || !customEventBuffer.length) return;
|
||||
|
||||
const events = customEventBuffer;
|
||||
customEventBuffer = [];
|
||||
|
||||
for (const event of events) {
|
||||
addCustomEvent(event.tag, event.payload);
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomEvent = (tag, payload) => {
|
||||
if (stopped) return;
|
||||
|
||||
if (!recorderReady) {
|
||||
customEventBuffer.push({ tag, payload });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
record.addCustomEvent(tag, payload);
|
||||
} catch (e) {
|
||||
if (e?.message === 'please add custom event after start recording') {
|
||||
recorderReady = false;
|
||||
customEventBuffer.push({ tag, payload });
|
||||
setTimeout(() => {
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const getMaskConfig = level => {
|
||||
switch (level) {
|
||||
case 'strict':
|
||||
@@ -124,6 +162,8 @@ import { record } from 'rrweb';
|
||||
}
|
||||
|
||||
eventBuffer.push(event);
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
|
||||
if (eventBuffer.length >= FLUSH_EVENT_COUNT) {
|
||||
flush();
|
||||
@@ -148,12 +188,81 @@ import { record } from 'rrweb';
|
||||
checkoutEveryNms: 30000,
|
||||
...(blockSelector && { blockSelector }),
|
||||
});
|
||||
recorderReady = true;
|
||||
flushCustomEvents();
|
||||
|
||||
let scrollUrl = location.href;
|
||||
let maxScrollPct = 0;
|
||||
let scrollTimer = null;
|
||||
|
||||
const computeScrollPct = () => {
|
||||
const pageH = document.documentElement.scrollHeight;
|
||||
const visible = window.scrollY + window.innerHeight;
|
||||
return {
|
||||
pct: Math.max(0, Math.min(100, Math.round((visible / Math.max(1, pageH)) * 100))),
|
||||
pageH,
|
||||
};
|
||||
};
|
||||
|
||||
const flushScroll = () => {
|
||||
if (maxScrollPct <= 0) return;
|
||||
addCustomEvent('scroll-progress', {
|
||||
url: scrollUrl,
|
||||
scrollPct: maxScrollPct,
|
||||
viewportW: window.innerWidth,
|
||||
viewportH: window.innerHeight,
|
||||
pageH: document.documentElement.scrollHeight,
|
||||
});
|
||||
maxScrollPct = 0;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (scrollTimer) return;
|
||||
scrollTimer = setTimeout(() => {
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
scrollTimer = null;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onUrlChange = () => {
|
||||
if (location.href === scrollUrl) return;
|
||||
flushScroll();
|
||||
scrollUrl = location.href;
|
||||
addCustomEvent('url-change', { url: scrollUrl });
|
||||
};
|
||||
|
||||
const hookHistory = method => {
|
||||
const orig = history[method];
|
||||
history[method] = function (...args) {
|
||||
const result = orig.apply(this, args);
|
||||
onUrlChange();
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
hookHistory('pushState');
|
||||
hookHistory('replaceState');
|
||||
window.addEventListener('popstate', onUrlChange);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
// Capture initial scroll position
|
||||
{
|
||||
const { pct } = computeScrollPct();
|
||||
if (pct > maxScrollPct) maxScrollPct = pct;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush(true);
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => flush(true));
|
||||
window.addEventListener('beforeunload', () => {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
|
||||
Reference in New Issue
Block a user