diff --git a/db/clickhouse/migrations/12_add_heatmap.sql b/db/clickhouse/migrations/12_add_heatmap.sql new file mode 100644 index 000000000..1c983ab0d --- /dev/null +++ b/db/clickhouse/migrations/12_add_heatmap.sql @@ -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; diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index c5c8dce67..ccf71c243 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -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; diff --git a/next.config.ts b/next.config.ts index ab7fbf484..1e96de41c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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}; `; diff --git a/prisma/migrations/20_add_heatmap/migration.sql b/prisma/migrations/20_add_heatmap/migration.sql new file mode 100644 index 000000000..10266df15 --- /dev/null +++ b/prisma/migrations/20_add_heatmap/migration.sql @@ -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"); diff --git a/prisma/migrations/21_add_heatmap_page_h/migration.sql b/prisma/migrations/21_add_heatmap_page_h/migration.sql new file mode 100644 index 000000000..e985e33bc --- /dev/null +++ b/prisma/migrations/21_add_heatmap_page_h/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "heatmap_event" ADD COLUMN "page_h" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63c81ec0d..ced346e6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } \ No newline at end of file diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 6614c6500..e6cf904c6 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -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", diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css new file mode 100644 index 000000000..269a52bba --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.module.css @@ -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; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx new file mode 100644 index 000000000..3ebadf362 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -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() { + const ref = useRef(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('heatmap', { + websiteId, + urlPath: urlPath || undefined, + mode, + }); + + const pages = data?.pages ?? []; + const points = data?.points ?? []; + const scroll = data?.scroll; + + return ( + + + + + + {urlPath ? ( + mode === 'scroll' ? ( + + ) : ( + + ) + ) : ( + + )} + + + + ); +} + +function ModeToggle({ + mode, + onChange, +}: { + mode: HeatmapMode; + onChange: (mode: HeatmapMode) => void; +}) { + return ( + + + + + ); +} + +function PageList({ + pages, + selected, + onSelect, + mode, +}: { + pages: HeatmapResult['pages']; + selected: string; + onSelect: (urlPath: string) => void; + mode: HeatmapMode; +}) { + return ( + + Pages + {pages.length === 0 && No data yet} + {pages.map(p => ( + + ))} + + ); +} + +function pickViewport(points: HeatmapPoint[]): ViewportBucket | null { + if (!points.length) return null; + const buckets = new Map(); + 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(); + const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + + useEffect(() => { + setSnapshotReady(!(showPage && snapshot)); + }, [containerWidth, showPage, snapshot]); + + if (!viewport || visible.length === 0) { + return ; + } + + 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 ( + + + + {visible.length} positions · {formatLongNumber(visible.reduce((s, p) => s + p.count, 0))}{' '} + clicks · viewport {viewport.width}×{viewport.height} + + {snapshot && ( + + )} + +
+
+ {showSnapshot && !snapshotReady && } + {showSnapshot && ( + + )} + {showOverlay && ( +
+ {visible.map((p, i) => { + const intensity = Math.min(1, p.count / maxCount); + const size = 24 + intensity * 36; + return ( +
+ ); + })} +
+ )} +
+
+ + ); +} + +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(); + const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []); + + useEffect(() => { + setSnapshotReady(!(showPage && snapshot)); + }, [containerWidth, showPage, snapshot]); + + if (!scroll || scroll.totalSessions === 0 || !scroll.pageH || !scroll.viewportW) { + return ; + } + + 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 ( + + + + {formatLongNumber(totalSessions)} sessions · page {viewportW}×{pageH} + {viewportH ? ` · viewport ${viewportH}` : ''} + + {snapshot && ( + + )} + +
+
+ {showSnapshot && !snapshotReady && } + {showSnapshot && ( + + )} + {showOverlay && ( +
+ {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 ( +
+ + {b.fromPct}% · {Math.round(intensity * 100)}% + +
+ ); + })} +
+ )} +
+
+
+ ); +} + +function ReplaySnapshot({ + websiteId, + snapshot, + width, + height, + scale, + onReady, +}: { + websiteId: string; + snapshot: HeatmapSnapshot; + width: number; + height: number; + scale: number; + onReady: () => void; +}) { + const containerRef = useRef(null); + const replayerRef = useRef(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 ( +
+ ); +} + +function CanvasLoading() { + return ( +
+ +
+ ); +} + +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 ( + + {message ? 'No data' : 'Select a page'} + {message ?? 'Choose a page from the list to view its heatmap.'} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/HeatmapsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/HeatmapsPage.tsx new file mode 100644 index 000000000..2c0c8e4f0 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/HeatmapsPage.tsx @@ -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(''); + const [mode, setMode] = useState('click'); + + return ( + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/page.tsx new file mode 100644 index 000000000..d980279ce --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/page.tsx @@ -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 ; +} + +export const metadata: Metadata = { + title: 'Heatmaps', +}; diff --git a/src/app/api/record/route.ts b/src/app/api/record/route.ts index 20ca53469..59d51d59f 100644 --- a/src/app/api/record/route.ts +++ b/src/app/api/record/route.ts @@ -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); diff --git a/src/app/api/reports/heatmap/route.ts b/src/app/api/reports/heatmap/route.ts new file mode 100644 index 000000000..79af35c3e --- /dev/null +++ b/src/app/api/reports/heatmap/route.ts @@ -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); +} diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx index 8894b3a97..450c1250e 100644 --- a/src/components/common/TypeIcon.tsx +++ b/src/components/common/TypeIcon.tsx @@ -10,11 +10,13 @@ export function TypeIcon({ value: string; children?: ReactNode; }) { + const iconValue = type === 'browser' && value === 'browser' ? 'unknown' : value; + return ( { e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`; diff --git a/src/components/hooks/useWebsiteNavItems.tsx b/src/components/hooks/useWebsiteNavItems.tsx index 11c5b8caf..09731f101 100644 --- a/src/components/hooks/useWebsiteNavItems.tsx +++ b/src/components/hooks/useWebsiteNavItems.tsx @@ -3,6 +3,7 @@ import { ChartPie, Clock, Eye, + Flame, Sheet, Tag, User, @@ -107,6 +108,12 @@ export function useWebsiteNavItems(websiteId: string) { icon: