heatmaps UI polish, finish feature gate + messaging, overlay bug fixes
This commit is contained in:
@@ -160,10 +160,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heatOverlay {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.canvasLoading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Column, Grid, Heading, Loading, Row, Switch, Text } from '@umami/react-zen';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
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';
|
||||
@@ -115,6 +115,14 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
onUrlPathChange(filteredPages[0].urlPath);
|
||||
}, [filteredPages, isLoading, onUrlPathChange, urlPath]);
|
||||
|
||||
if (!isLoading && pages.length === 0) {
|
||||
return (
|
||||
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
|
||||
<EmptyState message="No data available." />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
|
||||
<Grid columns="320px 12px 1fr" minHeight="900px" className={styles.layoutGrid}>
|
||||
@@ -167,15 +175,11 @@ function PageList({
|
||||
mode: HeatmapMode;
|
||||
hasSearch: boolean;
|
||||
}) {
|
||||
const { t, messages } = useMessages();
|
||||
|
||||
return (
|
||||
<Column className={styles.pageList} gap="1">
|
||||
<Heading size="lg">Pages</Heading>
|
||||
<Column className={styles.pageListItems} gap="2">
|
||||
{pages.length === 0 && (
|
||||
<Text color="muted">{hasSearch ? 'No matching pages' : t(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
{pages.length === 0 && hasSearch && <Text color="muted">No matching pages</Text>}
|
||||
{pages.map(p => (
|
||||
<button
|
||||
key={p.urlPath}
|
||||
@@ -295,29 +299,29 @@ function HeatmapView({
|
||||
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>
|
||||
{showOverlay && (
|
||||
<div className={`${styles.overlay} ${styles.heatOverlay}`}>
|
||||
{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>
|
||||
{snapshot && (
|
||||
@@ -560,12 +564,8 @@ function ReplaySnapshot({
|
||||
void finalize();
|
||||
});
|
||||
|
||||
replayer.on('resize', (dimension: { width?: number; height?: number }) => {
|
||||
resizeReplayFrame(
|
||||
replayer,
|
||||
dimension.width && Number.isFinite(dimension.width) ? dimension.width : width,
|
||||
dimension.height && Number.isFinite(dimension.height) ? dimension.height : height,
|
||||
);
|
||||
replayer.on('resize', () => {
|
||||
resizeReplayFrame(replayer, width, height);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -661,6 +661,12 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
|
||||
if (wrapper) {
|
||||
wrapper.style.width = `${width}px`;
|
||||
wrapper.style.height = `${height}px`;
|
||||
wrapper.style.minWidth = `${width}px`;
|
||||
wrapper.style.minHeight = `${height}px`;
|
||||
wrapper.style.maxWidth = `${width}px`;
|
||||
wrapper.style.maxHeight = `${height}px`;
|
||||
wrapper.style.margin = '0';
|
||||
wrapper.style.padding = '0';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
@@ -669,6 +675,11 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
|
||||
iframe.setAttribute('height', String(height));
|
||||
iframe.style.width = `${width}px`;
|
||||
iframe.style.height = `${height}px`;
|
||||
iframe.style.minWidth = `${width}px`;
|
||||
iframe.style.minHeight = `${height}px`;
|
||||
iframe.style.maxWidth = `${width}px`;
|
||||
iframe.style.maxHeight = `${height}px`;
|
||||
iframe.style.margin = '0';
|
||||
iframe.style.display = 'block';
|
||||
}
|
||||
|
||||
@@ -678,7 +689,7 @@ function resizeReplayFrame(replayer: ReplayInstance, width: number, height: numb
|
||||
function EmptyState({ message }: { message?: string } = {}) {
|
||||
return (
|
||||
<Column alignItems="center" justifyContent="center" minHeight="360px" gap>
|
||||
<Heading size="lg">{message ? 'No data' : 'Select a page'}</Heading>
|
||||
{!message && <Heading size="lg">Select a page</Heading>}
|
||||
<Text color="muted">{message ?? 'Choose a page from the list to view its heatmap.'}</Text>
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
import { Column, Row, SearchField } from '@umami/react-zen';
|
||||
import { Button, Column, Row, SearchField } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMobile } from '@/components/hooks';
|
||||
import { useMessages, useMobile, useSubscription, useWebsite } from '@/components/hooks';
|
||||
import { Flame } from '@/components/icons';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import type { HeatmapMode } from '@/queries/sql';
|
||||
import { Heatmap } from './Heatmap';
|
||||
@@ -17,12 +19,36 @@ export function HeatmapsPage({ websiteId }: { websiteId: string }) {
|
||||
const [mode, setMode] = useState<HeatmapMode>('click');
|
||||
const [search, setSearch] = useState('');
|
||||
const { isPhone } = useMobile();
|
||||
const website = useWebsite();
|
||||
const { t, labels, messages } = useMessages();
|
||||
const { hasFeature, cloudMode } = useSubscription(website?.teamId);
|
||||
|
||||
const buttons = [
|
||||
{ id: 'click', label: 'Clicks' },
|
||||
{ id: 'scroll', label: 'Scroll' },
|
||||
];
|
||||
|
||||
if (cloudMode && !hasFeature('replays')) {
|
||||
return (
|
||||
<Column gap="3">
|
||||
<Panel>
|
||||
<EmptyPlaceholder
|
||||
icon={<Flame />}
|
||||
title={t(messages.upgradeRequired, { plan: 'Business' })}
|
||||
description="View click and scroll heatmaps for your pages."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => window.open(`${process.env.cloudUrl}/settings/billing`, '_blank')}
|
||||
>
|
||||
{t(labels.upgrade)}
|
||||
</Button>
|
||||
</EmptyPlaceholder>
|
||||
</Panel>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
|
||||
@@ -80,6 +80,11 @@ interface HeatmapFilterContext {
|
||||
queryParams: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SnapshotPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
parameters: HeatmapParameters,
|
||||
@@ -186,6 +191,7 @@ async function relationalQuery(
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -226,6 +232,7 @@ async function relationalQuery(
|
||||
);
|
||||
|
||||
const viewport = pickSnapshotViewport(rawPoints);
|
||||
const point = pickRepresentativePoint(rawPoints, viewport);
|
||||
const snapshot = await getRelationalSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
@@ -234,6 +241,7 @@ async function relationalQuery(
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -250,6 +258,7 @@ async function getRelationalSnapshot(
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
@@ -259,6 +268,7 @@ async function getRelationalSnapshot(
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
@@ -269,6 +279,12 @@ async function getRelationalSnapshot(
|
||||
and h.viewport_h = {{viewportH}}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {{pointX}}
|
||||
and h.y = {{pointY}}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows: SnapshotRow[] = await rawQuery(
|
||||
`
|
||||
@@ -284,6 +300,7 @@ async function getRelationalSnapshot(
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
@@ -306,6 +323,7 @@ async function getRelationalSnapshot(
|
||||
and h.url_path = {{urlPath}}
|
||||
and h.created_at between {{startDate}} and {{endDate}}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
case when h.replay_chunk_index is null then 1 else 0 end asc,
|
||||
h.replay_chunk_index asc nulls last,
|
||||
@@ -313,7 +331,18 @@ async function getRelationalSnapshot(
|
||||
h.created_at asc
|
||||
limit 1
|
||||
`,
|
||||
{ ...filterContext.queryParams, websiteId, eventType, urlPath, startDate, endDate, viewportW, viewportH },
|
||||
{
|
||||
...filterContext.queryParams,
|
||||
websiteId,
|
||||
eventType,
|
||||
urlPath,
|
||||
startDate,
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
|
||||
@@ -438,6 +467,7 @@ async function clickhouseQuery(
|
||||
endDate,
|
||||
viewportW: scroll.viewportW,
|
||||
viewportH: scroll.viewportH,
|
||||
point: null,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -496,6 +526,7 @@ async function clickhouseQuery(
|
||||
}));
|
||||
|
||||
const viewport = pickSnapshotViewport(points);
|
||||
const point = pickRepresentativePoint(points, viewport);
|
||||
const snapshot = await getClickhouseSnapshot(rawQuery, {
|
||||
websiteId,
|
||||
eventType,
|
||||
@@ -504,6 +535,7 @@ async function clickhouseQuery(
|
||||
endDate,
|
||||
viewportW: viewport?.width ?? null,
|
||||
viewportH: viewport?.height ?? null,
|
||||
point,
|
||||
filterContext,
|
||||
});
|
||||
|
||||
@@ -520,6 +552,7 @@ async function getClickhouseSnapshot(
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
point,
|
||||
filterContext,
|
||||
}: {
|
||||
websiteId: string;
|
||||
@@ -529,6 +562,7 @@ async function getClickhouseSnapshot(
|
||||
endDate: Date;
|
||||
viewportW: number | null;
|
||||
viewportH: number | null;
|
||||
point: SnapshotPoint | null;
|
||||
filterContext: HeatmapFilterContext;
|
||||
},
|
||||
): Promise<HeatmapSnapshot | null> {
|
||||
@@ -539,6 +573,12 @@ async function getClickhouseSnapshot(
|
||||
and h.viewport_h = {viewportH:UInt32}
|
||||
`
|
||||
: '';
|
||||
const pointFilter = point
|
||||
? `
|
||||
and h.x = {pointX:UInt32}
|
||||
and h.y = {pointY:UInt32}
|
||||
`
|
||||
: '';
|
||||
|
||||
const rows = await rawQuery<SnapshotRow[]>(
|
||||
`
|
||||
@@ -554,6 +594,7 @@ async function getClickhouseSnapshot(
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
group by h.visit_id
|
||||
order by event_count desc, first_seen asc
|
||||
limit 1
|
||||
@@ -576,6 +617,7 @@ async function getClickhouseSnapshot(
|
||||
and h.url_path = {urlPath:String}
|
||||
and h.created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
${viewportFilter}
|
||||
${pointFilter}
|
||||
order by
|
||||
isNull(h.replay_chunk_index) asc,
|
||||
h.replay_chunk_index asc,
|
||||
@@ -592,6 +634,8 @@ async function getClickhouseSnapshot(
|
||||
endDate,
|
||||
viewportW,
|
||||
viewportH,
|
||||
pointX: point?.x,
|
||||
pointY: point?.y,
|
||||
},
|
||||
FUNCTION_NAME,
|
||||
);
|
||||
@@ -632,6 +676,21 @@ function pickSnapshotViewport(points: HeatmapPoint[]): { width: number; height:
|
||||
return best ? { width: best.width, height: best.height } : null;
|
||||
}
|
||||
|
||||
function pickRepresentativePoint(
|
||||
points: HeatmapPoint[],
|
||||
viewport: { width: number; height: number } | null,
|
||||
): SnapshotPoint | null {
|
||||
if (!viewport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = points.find(
|
||||
point => point.viewportW === viewport.width && point.viewportH === viewport.height,
|
||||
);
|
||||
|
||||
return match ? { x: match.x, y: match.y } : null;
|
||||
}
|
||||
|
||||
function mapSnapshot(row?: SnapshotRow | null): HeatmapSnapshot | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user