From 680f6a841378406ee8efe2792e3454c980258821 Mon Sep 17 00:00:00 2001 From: Bill Yang Date: Wed, 21 Jan 2026 19:05:25 -0800 Subject: [PATCH] heatmaps --- client/src/api/analytics/endpoints/heatmap.ts | 83 +++++++++ client/src/api/analytics/endpoints/index.ts | 13 ++ .../hooks/heatmap/useGetClickHeatmap.ts | 35 ++++ .../hooks/heatmap/useGetHeatmapPages.ts | 26 +++ .../app/[site]/[privateKey]/heatmaps/page.tsx | 2 + .../app/[site]/components/Sidebar/Sidebar.tsx | 11 ++ .../heatmaps/components/HeatmapControls.tsx | 42 +++++ .../heatmaps/components/HeatmapPageList.tsx | 63 +++++++ .../heatmaps/components/HeatmapViewer.tsx | 139 ++++++++++++++ client/src/app/[site]/heatmaps/page.tsx | 92 ++++++++++ .../src/components/heatmap/HeatmapCanvas.tsx | 132 ++++++++++++++ client/src/components/heatmap/PagePreview.tsx | 99 ++++++++++ server/src/api/heatmap/getClickHeatmap.ts | 39 ++++ server/src/api/heatmap/getHeatmapPages.ts | 31 ++++ server/src/api/heatmap/index.ts | 2 + server/src/db/clickhouse/clickhouse.ts | 24 +++ server/src/index.ts | 9 +- .../services/heatmap/clickHeatmapService.ts | 172 ++++++++++++++++++ .../replay/sessionReplayIngestService.ts | 85 +++++++++ 19 files changed, 1098 insertions(+), 1 deletion(-) create mode 100644 client/src/api/analytics/endpoints/heatmap.ts create mode 100644 client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts create mode 100644 client/src/api/analytics/hooks/heatmap/useGetHeatmapPages.ts create mode 100644 client/src/app/[site]/[privateKey]/heatmaps/page.tsx create mode 100644 client/src/app/[site]/heatmaps/components/HeatmapControls.tsx create mode 100644 client/src/app/[site]/heatmaps/components/HeatmapPageList.tsx create mode 100644 client/src/app/[site]/heatmaps/components/HeatmapViewer.tsx create mode 100644 client/src/app/[site]/heatmaps/page.tsx create mode 100644 client/src/components/heatmap/HeatmapCanvas.tsx create mode 100644 client/src/components/heatmap/PagePreview.tsx create mode 100644 server/src/api/heatmap/getClickHeatmap.ts create mode 100644 server/src/api/heatmap/getHeatmapPages.ts create mode 100644 server/src/api/heatmap/index.ts create mode 100644 server/src/services/heatmap/clickHeatmapService.ts diff --git a/client/src/api/analytics/endpoints/heatmap.ts b/client/src/api/analytics/endpoints/heatmap.ts new file mode 100644 index 000000000..1bd95e543 --- /dev/null +++ b/client/src/api/analytics/endpoints/heatmap.ts @@ -0,0 +1,83 @@ +import { authedFetch } from "../../utils"; +import { CommonApiParams, toQueryParams } from "./types"; + +// Heatmap data point type +export interface HeatmapDataPoint { + x: number; // 0-100 percentage + y: number; // 0-100 percentage + value: number; // click count +} + +// Click heatmap result type +export interface ClickHeatmapResult { + points: HeatmapDataPoint[]; + totalClicks: number; + uniqueSessions: number; +} + +// Click heatmap response type +export interface ClickHeatmapResponse { + data: ClickHeatmapResult; + pathname: string; +} + +// Heatmap page type +export interface HeatmapPage { + pathname: string; + clickCount: number; + sessionCount: number; +} + +// Heatmap pages response type +export interface HeatmapPagesResponse { + data: HeatmapPage[]; +} + +// Viewport breakpoint type +export type ViewportBreakpoint = "mobile" | "tablet" | "desktop" | "all"; + +// Click heatmap params +export interface ClickHeatmapParams extends CommonApiParams { + pathname: string; + viewportBreakpoint?: ViewportBreakpoint; + gridResolution?: number; +} + +// Heatmap pages params +export interface HeatmapPagesParams extends CommonApiParams { + limit?: number; +} + +/** + * Fetch click heatmap data for a specific page + * GET /api/sites/:siteId/heatmap/clicks + */ +export async function fetchClickHeatmap( + site: string | number, + params: ClickHeatmapParams +): Promise { + const queryParams = { + ...toQueryParams(params), + pathname: params.pathname, + viewportBreakpoint: params.viewportBreakpoint, + gridResolution: params.gridResolution, + }; + + return authedFetch(`/sites/${site}/heatmap/clicks`, queryParams); +} + +/** + * Fetch list of pages with click data for heatmaps + * GET /api/sites/:siteId/heatmap/pages + */ +export async function fetchHeatmapPages( + site: string | number, + params: HeatmapPagesParams +): Promise { + const queryParams = { + ...toQueryParams(params), + limit: params.limit, + }; + + return authedFetch(`/sites/${site}/heatmap/pages`, queryParams); +} diff --git a/client/src/api/analytics/endpoints/index.ts b/client/src/api/analytics/endpoints/index.ts index f171400d2..e31e5000b 100644 --- a/client/src/api/analytics/endpoints/index.ts +++ b/client/src/api/analytics/endpoints/index.ts @@ -135,3 +135,16 @@ export type { // Export endpoints export { exportPdfReport } from "./export"; export type { ExportPdfParams } from "./export"; + +// Heatmap endpoints +export { fetchClickHeatmap, fetchHeatmapPages } from "./heatmap"; +export type { + HeatmapDataPoint, + ClickHeatmapResult, + ClickHeatmapResponse, + HeatmapPage, + HeatmapPagesResponse, + ViewportBreakpoint, + ClickHeatmapParams, + HeatmapPagesParams, +} from "./heatmap"; diff --git a/client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts b/client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts new file mode 100644 index 000000000..b45aa8919 --- /dev/null +++ b/client/src/api/analytics/hooks/heatmap/useGetClickHeatmap.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { useStore } from "../../../../lib/store"; +import { buildApiParams } from "../../../utils"; +import { fetchClickHeatmap, ViewportBreakpoint } from "../../endpoints/heatmap"; + +interface UseGetClickHeatmapOptions { + pathname: string; + viewportBreakpoint?: ViewportBreakpoint; + gridResolution?: number; + enabled?: boolean; +} + +export function useGetClickHeatmap({ + pathname, + viewportBreakpoint = "all", + gridResolution = 100, + enabled = true, +}: UseGetClickHeatmapOptions) { + const { time, site, filters, timezone } = useStore(); + const params = buildApiParams(time, { filters }); + + return useQuery({ + queryKey: ["click-heatmap", site, pathname, viewportBreakpoint, gridResolution, time, filters, timezone], + queryFn: () => + fetchClickHeatmap(site, { + ...params, + pathname, + viewportBreakpoint, + gridResolution, + }), + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + enabled: !!site && !!pathname && enabled, + }); +} diff --git a/client/src/api/analytics/hooks/heatmap/useGetHeatmapPages.ts b/client/src/api/analytics/hooks/heatmap/useGetHeatmapPages.ts new file mode 100644 index 000000000..7d8aa80b7 --- /dev/null +++ b/client/src/api/analytics/hooks/heatmap/useGetHeatmapPages.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useStore } from "../../../../lib/store"; +import { buildApiParams } from "../../../utils"; +import { fetchHeatmapPages } from "../../endpoints/heatmap"; + +interface UseGetHeatmapPagesOptions { + limit?: number; + enabled?: boolean; +} + +export function useGetHeatmapPages({ limit = 100, enabled = true }: UseGetHeatmapPagesOptions = {}) { + const { time, site, filters, timezone } = useStore(); + const params = buildApiParams(time, { filters }); + + return useQuery({ + queryKey: ["heatmap-pages", site, limit, time, filters, timezone], + queryFn: () => + fetchHeatmapPages(site, { + ...params, + limit, + }), + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + enabled: !!site && enabled, + }); +} diff --git a/client/src/app/[site]/[privateKey]/heatmaps/page.tsx b/client/src/app/[site]/[privateKey]/heatmaps/page.tsx new file mode 100644 index 000000000..7c16bedce --- /dev/null +++ b/client/src/app/[site]/[privateKey]/heatmaps/page.tsx @@ -0,0 +1,2 @@ +// Re-export the heatmaps page for private link routes +export { default } from "../../heatmaps/page"; diff --git a/client/src/app/[site]/components/Sidebar/Sidebar.tsx b/client/src/app/[site]/components/Sidebar/Sidebar.tsx index e9ad72330..0d8e41afe 100644 --- a/client/src/app/[site]/components/Sidebar/Sidebar.tsx +++ b/client/src/app/[site]/components/Sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import { ChartColumnDecreasing, Code, File, + Flame, Funnel, Gauge, Globe2, @@ -124,6 +125,16 @@ function SidebarContent() { /> )} +
+ {!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( + } + /> + )} +
void; +} + +const VIEWPORT_OPTIONS: { value: ViewportBreakpoint; label: string; icon: React.ReactNode }[] = [ + { value: "all", label: "All", icon: null }, + { value: "desktop", label: "Desktop", icon: }, + { value: "tablet", label: "Tablet", icon: }, + { value: "mobile", label: "Mobile", icon: }, +]; + +export function HeatmapControls({ viewportBreakpoint, onViewportChange }: HeatmapControlsProps) { + return ( +
+ Viewport: +
+ {VIEWPORT_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/client/src/app/[site]/heatmaps/components/HeatmapPageList.tsx b/client/src/app/[site]/heatmaps/components/HeatmapPageList.tsx new file mode 100644 index 000000000..21876e78a --- /dev/null +++ b/client/src/app/[site]/heatmaps/components/HeatmapPageList.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { FileText, Loader2 } from "lucide-react"; +import { HeatmapPage } from "../../../../api/analytics/endpoints/heatmap"; +import { cn } from "../../../../lib/utils"; + +interface HeatmapPageListProps { + pages: HeatmapPage[]; + selectedPathname: string | null; + onSelectPage: (pathname: string) => void; + isLoading: boolean; +} + +export function HeatmapPageList({ pages, selectedPathname, onSelectPage, isLoading }: HeatmapPageListProps) { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (pages.length === 0) { + return ( +
+ +

No pages with click data found

+

+ Click data will appear once session replay captures user interactions +

+
+ ); + } + + return ( +
+
+ Pages ({pages.length}) +
+ {pages.map((page) => ( + + ))} +
+ ); +} diff --git a/client/src/app/[site]/heatmaps/components/HeatmapViewer.tsx b/client/src/app/[site]/heatmaps/components/HeatmapViewer.tsx new file mode 100644 index 000000000..94ae50da4 --- /dev/null +++ b/client/src/app/[site]/heatmaps/components/HeatmapViewer.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Loader2, MousePointerClick } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useGetClickHeatmap } from "../../../../api/analytics/hooks/heatmap/useGetClickHeatmap"; +import { HeatmapCanvas } from "../../../../components/heatmap/HeatmapCanvas"; +import { ViewportBreakpoint } from "../../../../api/analytics/endpoints/heatmap"; + +interface HeatmapViewerProps { + pathname: string; + baseUrl: string; + viewportBreakpoint: ViewportBreakpoint; + width: number; + height: number; +} + +export function HeatmapViewer({ pathname, baseUrl, viewportBreakpoint, width, height }: HeatmapViewerProps) { + const { data, isLoading, error } = useGetClickHeatmap({ + pathname, + viewportBreakpoint, + gridResolution: 100, + }); + + const [iframeLoaded, setIframeLoaded] = useState(false); + const [iframeError, setIframeError] = useState(false); + + // Construct full page URL + const pageUrl = useMemo(() => { + try { + const url = new URL(pathname, baseUrl); + return url.toString(); + } catch { + return `${baseUrl}${pathname}`; + } + }, [pathname, baseUrl]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Failed to load heatmap data

+

{error.message}

+
+ ); + } + + const points = data?.data.points ?? []; + const totalClicks = data?.data.totalClicks ?? 0; + const uniqueSessions = data?.data.uniqueSessions ?? 0; + + return ( +
+ {/* Stats bar */} +
+
+ + + {totalClicks.toLocaleString()} + + clicks +
+
+ from {uniqueSessions.toLocaleString()} sessions +
+
+ + {/* Heatmap visualization */} +
+ {/* Iframe with page preview */} +