heatmap mobile layout. session replay mobile replays fix. share page tests. Closes #4417.

This commit is contained in:
Francis Cao committed 2026-08-04 10:56:18 -07:00
1 parent fc19aa60d8
commit 9bb1f5519a
8 files changed
+508 -44

No files matched your search

@@ -58,6 +58,35 @@
font-weight: 600;
}
.mobilePageHeader {
min-width: 0;
}
.mobilePageSection {
padding: 4px 0 2px;
}
.mobileSectionLabel {
font-size: 12px;
font-weight: 600;
}
.mobileSelectedPageButton {
width: 100%;
text-align: left;
border: 1px solid var(--border-base);
background: var(--surface-raised);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
font: inherit;
color: inherit;
}
.mobileSelectedPageButton:hover {
background: var(--surface-sunken);
}
.summaryHeader {
min-width: 0;
}
@@ -98,6 +127,10 @@
min-width: 0;
}
.mobileSummaryControls {
width: 100%;
}
.screenWidthCount {
margin-left: auto;
font-variant-numeric: tabular-nums;
@@ -228,3 +261,38 @@
pointer-events: auto;
mix-blend-mode: multiply;
}
@media (max-width: 640px) {
.summaryStats,
.summaryHeader {
width: 100%;
align-items: stretch;
}
.summaryStats {
gap: 8px;
}
.summaryStat {
width: 100%;
justify-content: flex-start;
}
.screenWidthControl {
width: 100%;
margin-left: 0;
justify-content: space-between;
}
.screenWidthLabel {
font-size: 12px;
}
.canvasWrapper {
max-height: 70vh;
}
.scrollBandLabel {
display: none;
}
}
@@ -1,19 +1,25 @@
'use client';
import {
Button,
Column,
Dialog,
Grid,
Heading,
Icon,
ListItem,
Loading,
Modal,
Row,
Select,
Text,
} from '@umami/react-zen';
import { Laptop, Monitor, Smartphone, Tablet } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ControlledDialog } from '@/components/common/ControlledDialog';
import { IconLabel } from '@/components/common/IconLabel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useResultQuery } from '@/components/hooks';
import { useMobile, useResultQuery } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { formatLongNumber } from '@/lib/format';
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
import styles from './Heatmap.module.css';
@@ -53,6 +59,8 @@ interface HeatmapProps {
}
export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: HeatmapProps) {
const { isPhone } = useMobile();
const [isPagePickerOpen, setIsPagePickerOpen] = useState(false);
const {
data: pagesData,
error,
@@ -88,6 +96,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
return pages.filter(page => page.urlPath.toLowerCase().includes(value));
}, [pages, search]);
const selectedPage = filteredPages.find(page => page.urlPath === urlPath) ?? null;
const points = detailData?.points ?? [];
const scroll = detailData?.scroll;
const snapshot = detailData?.snapshot ?? null;
@@ -122,16 +131,33 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
return (
<LoadingPanel data={pagesData} isLoading={isLoading} error={error} minHeight="900px">
<Grid columns="320px 12px 1fr" minHeight="900px" className={styles.layoutGrid}>
<PageList
pages={filteredPages}
selected={urlPath}
onSelect={onUrlPathChange}
mode={mode}
hasSearch={Boolean(search)}
/>
<div className={styles.railDivider} aria-hidden="true" />
<Column className={styles.contentColumn} gap>
{isPhone ? (
<Column gap="4" minHeight="900px">
<Column gap="2" className={styles.mobilePageSection}>
<Row alignItems="center" justifyContent="space-between" gap className={styles.mobilePageHeader}>
<Text color="muted" className={styles.mobileSectionLabel}>
Selected page
</Text>
<Button variant="outline" onPress={() => setIsPagePickerOpen(true)}>
<IconLabel icon={<ListCheck />} label="Pages" />
</Button>
</Row>
{selectedPage && (
<button
type="button"
className={styles.mobileSelectedPageButton}
onClick={() => setIsPagePickerOpen(true)}
>
<Row alignItems="center" justifyContent="space-between" gap="3">
<Text truncate>{selectedPage.urlPath}</Text>
<Text color="muted" className={styles.pageMetric}>
{formatLongNumber(selectedPage.sessions)}
</Text>
</Row>
</button>
)}
</Column>
{urlPath ? (
mode === 'scroll' ? (
<ScrollHeatmapView
@@ -151,8 +177,74 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
) : (
<EmptyState />
)}
<ControlledDialog>
<Modal
isOpen={isPagePickerOpen}
onOpenChange={isOpen => setIsPagePickerOpen(isOpen)}
placement="fullscreen"
>
<Dialog
title="Pages"
style={{
width: '100%',
height: '100%',
maxHeight: '100%',
overflowY: 'auto',
padding: '24px',
}}
>
{({ close }) => (
<PageList
pages={filteredPages}
selected={urlPath}
onSelect={nextUrlPath => {
onUrlPathChange(nextUrlPath);
setIsPagePickerOpen(false);
close();
}}
mode={mode}
hasSearch={Boolean(search)}
showHeading={false}
/>
)}
</Dialog>
</Modal>
</ControlledDialog>
</Column>
</Grid>
) : (
<Grid columns="320px 12px 1fr" minHeight="900px" className={styles.layoutGrid}>
<PageList
pages={filteredPages}
selected={urlPath}
onSelect={onUrlPathChange}
mode={mode}
hasSearch={Boolean(search)}
/>
<div className={styles.railDivider} aria-hidden="true" />
<Column className={styles.contentColumn} gap>
{urlPath ? (
mode === 'scroll' ? (
<ScrollHeatmapView
urlPath={urlPath}
scroll={scroll}
snapshot={snapshot}
isLoading={detailLoading}
/>
) : (
<ClickHeatmapView
urlPath={urlPath}
points={points}
snapshot={snapshot}
isLoading={detailLoading}
/>
)
) : (
<EmptyState />
)}
</Column>
</Grid>
)}
</LoadingPanel>
);
}
@@ -163,12 +255,14 @@ function PageList({
onSelect,
mode,
hasSearch,
showHeading = true,
}: {
pages: HeatmapResult['pages'];
selected: string;
onSelect: (urlPath: string) => void;
mode: HeatmapMode;
hasSearch: boolean;
showHeading?: boolean;
}) {
const getPageMetricTitle = (page: HeatmapResult['pages'][number]) => {
const metricLabel = mode === 'scroll' ? 'scroll events' : 'clicks';
@@ -178,7 +272,7 @@ function PageList({
return (
<Column className={styles.pageList} gap="1">
<Heading size="lg">Pages</Heading>
{showHeading && <Heading size="lg">Pages</Heading>}
<Column className={styles.pageListItems} gap="2">
{pages.length === 0 && hasSearch && <Text color="muted">No matching pages</Text>}
{pages.map(page => (
@@ -546,6 +640,7 @@ function ClickHeatmapView({
snapshot: HeatmapSnapshot | null;
isLoading: boolean;
}) {
const { isPhone } = useMobile();
const [snapshotReady, setSnapshotReady] = useState(false);
const screenWidthBuckets = useMemo(() => getScreenWidthBuckets(points), [points]);
const { viewport, setSelectedScreenWidth } = useSelectedScreenWidthBucket(screenWidthBuckets);
@@ -596,17 +691,27 @@ function ClickHeatmapView({
return (
<Column gap>
<Column gap="2" className={styles.summaryHeader}>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted" title={urlPath} className={styles.summaryPath}>
{urlPath}
</Text>
</Row>
{!isPhone && (
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted" title={urlPath} className={styles.summaryPath}>
{urlPath}
</Text>
</Row>
)}
{showLoading ? (
<Row alignItems="center" gap className={styles.summaryStats}>
<Text color="muted" className={styles.summaryStat}>
Loading Heatmap...
</Text>
</Row>
) : isPhone ? (
<Column gap="2" className={styles.mobileSummaryControls}>
<ScreenWidthSelect
buckets={screenWidthBuckets}
value={viewport?.width ?? null}
onChange={setSelectedScreenWidth}
/>
</Column>
) : (
<Row
alignItems="center"
@@ -705,6 +810,7 @@ function ScrollHeatmapView({
snapshot: HeatmapSnapshot | null;
isLoading: boolean;
}) {
const { isPhone } = useMobile();
const [snapshotReady, setSnapshotReady] = useState(false);
const handleSnapshotReady = useCallback(() => setSnapshotReady(true), []);
const hasSnapshot = Boolean(snapshot);
@@ -771,15 +877,25 @@ function ScrollHeatmapView({
return (
<Column gap>
<Text color="muted" title={urlPath} className={styles.summaryPath}>
{urlPath}
</Text>
{!isPhone && (
<Text color="muted" title={urlPath} className={styles.summaryPath}>
{urlPath}
</Text>
)}
{showLoading ? (
<Row alignItems="center" gap className={styles.summaryStats}>
<Text color="muted" className={styles.summaryStat}>
Loading Heatmap...
</Text>
</Row>
) : isPhone ? (
<Column gap="2" className={styles.mobileSummaryControls}>
<ScreenWidthSelect
buckets={screenWidthBuckets}
value={viewport?.width ?? null}
onChange={setSelectedScreenWidth}
/>
</Column>
) : (
<Row
alignItems="center"
@@ -8,3 +8,7 @@
height: calc(100dvh - 80px);
overflow: hidden;
}
.portrait {
width: min(calc(100dvw - 48px), 900px);
}
@@ -1,5 +1,6 @@
'use client';
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
import { ControlledDialog } from '@/components/common/ControlledDialog';
import { useNavigation } from '@/components/hooks';
@@ -19,7 +20,21 @@ export function ReplayModal({ websiteId, replayId, className, ...props }: Replay
updateParams,
} = useNavigation();
const activeReplayId = replayId || replay;
const modalClassName = [styles.modal, className].filter(Boolean).join(' ');
const [replayOrientation, setReplayOrientation] = useState<'portrait' | 'landscape' | null>(null);
useEffect(() => {
if (activeReplayId) {
setReplayOrientation(null);
}
}, [activeReplayId]);
const modalClassName = [
styles.modal,
replayOrientation === 'portrait' ? styles.portrait : null,
className,
]
.filter(Boolean)
.join(' ');
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
@@ -47,7 +62,12 @@ export function ReplayModal({ websiteId, replayId, className, ...props }: Replay
{({ close }) => (
<Column padding="6">
{activeReplayId && (
<ReplayPlayback websiteId={websiteId} replayId={activeReplayId} onClose={close} />
<ReplayPlayback
websiteId={websiteId}
replayId={activeReplayId}
onClose={close}
onReplayStateChange={setReplayOrientation}
/>
)}
</Column>
)}
@@ -1,7 +1,7 @@
'use client';
import { Button, Column, Dialog, DialogTrigger, Icon, Popover, Row, Text } from '@umami/react-zen';
import { Bookmark, X } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { SessionInfo } from '@/app/(main)/websites/[websiteId]/sessions/SessionInfo';
import { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel';
@@ -13,19 +13,24 @@ import {
useWebsiteSessionQuery,
} from '@/components/hooks';
import { touch } from '@/components/hooks/useModified';
import { getReplayViewport } from '@/lib/replay';
import { ReplayPlayer } from './ReplayPlayer';
import { ReplaySaveForm } from './ReplaySaveForm';
type ReplayOrientation = 'portrait' | 'landscape';
export function ReplayPlayback({
websiteId,
replayId,
showSessionInfo = true,
onClose,
onReplayStateChange,
}: {
websiteId: string;
replayId: string;
showSessionInfo?: boolean;
onClose?: () => void;
onReplayStateChange?: (orientation: ReplayOrientation | null) => void;
}) {
const { data: replay, isLoading, error } = useReplayQuery(websiteId, replayId);
const { data: replaySaved } = useReplaySavedQuery(websiteId, replayId);
@@ -33,9 +38,21 @@ export function ReplayPlayback({
const { t, labels } = useMessages();
const [isSaved, setIsSaved] = useState<boolean | null>(null);
const { mutate } = useUpdateQuery(`/websites/${websiteId}/replays/saved/${replayId}`);
const replayViewport = useMemo(() => getReplayViewport(replay?.events), [replay?.events]);
const replayOrientation: ReplayOrientation | null = replayViewport
? replayViewport.height > replayViewport.width
? 'portrait'
: 'landscape'
: replay
? 'landscape'
: null;
const saved = isSaved ?? replaySaved?.isSaved ?? false;
useEffect(() => {
onReplayStateChange?.(replayOrientation);
}, [replayOrientation, onReplayStateChange]);
const handleUnsave = () => {
setIsSaved(false);
mutate({ isSaved: false }, { onSuccess: () => touch('replays') });
@@ -24,24 +24,71 @@ function destroyReplayPlayer(player: { $destroy?: () => void } | null) {
}
export function ReplayPlayer({ events }: { events: any[] }) {
const playerWrapperRef = useRef<HTMLDivElement>(null);
const playerRootRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const [playerError, setPlayerError] = useState(false);
const [availableWidth, setAvailableWidth] = useState(0);
const [viewportHeight, setViewportHeight] = useState(
() => (typeof window !== 'undefined' ? window.innerHeight : 0),
);
const { isMobile, isPhone } = useMobile();
const replayEvents = useMemo(() => getReplayPlayerEvents(events), [events]);
const replayViewport = useMemo(() => getReplayViewport(replayEvents), [replayEvents]);
const canReplay = replayEvents.length >= 2;
const showUnavailable = !events?.length || !canReplay || playerError;
const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
const preferredPlayerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
const replayAspectRatio = replayViewport
? replayViewport.height / replayViewport.width
: DEFAULT_REPLAY_ASPECT_RATIO;
const playerHeight = Math.round(
playerWidth * Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 1.5),
const isPortraitReplay = replayViewport
? replayViewport.height > replayViewport.width
: false;
const fittedAspectRatio = isPortraitReplay
? Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 2.25)
: Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 1.5);
const maxPortraitPlayerHeight = Math.max(
240,
Math.round((viewportHeight || 900) * (isMobile ? (isPhone ? 0.55 : 0.6) : 0.88)),
);
let playerWidth = Math.min(preferredPlayerWidth, availableWidth || preferredPlayerWidth);
let playerHeight = Math.round(playerWidth * fittedAspectRatio);
// Portrait/mobile-recorded replays need to shrink to the visible viewport,
// otherwise the player keeps a large desktop frame with a tiny phone replay inside it.
if (isPortraitReplay && playerHeight > maxPortraitPlayerHeight) {
playerHeight = maxPortraitPlayerHeight;
playerWidth = Math.max(1, Math.round(playerHeight / fittedAspectRatio));
}
const playerOuterHeight = playerHeight + 80;
useEffect(() => {
const updateAvailableSize = () => {
const width = playerWrapperRef.current?.clientWidth ?? 0;
setAvailableWidth(current => (current === width ? current : width));
setViewportHeight(current => (current === window.innerHeight ? current : window.innerHeight));
};
updateAvailableSize();
const resizeObserver =
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateAvailableSize) : null;
if (playerWrapperRef.current && resizeObserver) {
resizeObserver.observe(playerWrapperRef.current);
}
window.addEventListener('resize', updateAvailableSize);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateAvailableSize);
};
}, []);
useEffect(() => {
let cancelled = false;
const playerRoot = playerRootRef.current;
@@ -114,27 +161,28 @@ export function ReplayPlayer({ events }: { events: any[] }) {
return (
<Column alignItems="center" width="100%">
<div
style={{
width: '100%',
maxWidth: playerWidth,
height: showUnavailable ? playerHeight : playerOuterHeight,
overflowX: showUnavailable ? 'hidden' : 'auto',
overflowY: 'hidden',
borderRadius: '8px',
border: '1px solid var(--base300)',
background: 'var(--base75)',
}}
>
<div ref={playerWrapperRef} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<div
ref={playerRootRef}
style={{
display: showUnavailable ? 'none' : 'block',
width: playerWidth,
height: playerOuterHeight,
maxWidth: '100%',
height: showUnavailable ? playerHeight : playerOuterHeight,
overflow: 'hidden',
borderRadius: '8px',
border: '1px solid var(--base300)',
background: 'var(--base75)',
}}
/>
{showUnavailable && <Empty message="Replay unavailable." />}
>
<div
ref={playerRootRef}
style={{
display: showUnavailable ? 'none' : 'block',
width: playerWidth,
height: playerOuterHeight,
}}
/>
{showUnavailable && <Empty message="Replay unavailable." />}
</div>
</div>
</Column>
);
+30
View File
@@ -80,3 +80,33 @@ select:is(:-webkit-autofill, :autofill):focus {
[data-theme="dark"] .rr-controller__btns .label {
color: black;
}
@media (max-width: 640px) {
.rr-controller {
height: auto !important;
padding: 8px 0 10px;
gap: 8px;
}
.rr-timeline {
width: calc(100% - 24px) !important;
gap: 4px;
}
.rr-timeline__time {
width: auto !important;
min-width: 52px;
font-size: 12px;
}
.rr-progress {
margin: 0 2px;
}
.rr-controller__btns {
width: calc(100% - 24px);
flex-wrap: wrap;
justify-content: center;
row-gap: 8px;
}
}
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { ReactNode } from 'react';
import { render, screen } from '@/test/render';
import { resetTestNavigation } from '@/test/navigation';
import { setShareData } from '@/store/app';
import { ShareProvider } from '@/app/share/ShareProvider';
import { SharePage } from './SharePage';
const mockShare = vi.hoisted(() => ({
slug: 'slug',
shareId: 'share-1',
shareType: 1,
websiteId: 'website-1',
token: 'share-token',
parameters: {
events: true,
},
}));
const initTheme = vi.fn();
vi.mock('@umami/react-zen', async importOriginal => {
const actual = await importOriginal<typeof import('@umami/react-zen')>();
return {
...actual,
useTheme: () => ({
initTheme,
}),
};
});
vi.mock('@/components/hooks', async importOriginal => {
const actual = await importOriginal<typeof import('@/components/hooks')>();
return {
...actual,
useShareTokenQuery: () => ({
share: mockShare,
data: mockShare,
isLoading: false,
isFetching: false,
}),
};
});
vi.mock('@/app/(main)/boards/[boardId]/BoardViewPage', () => ({
BoardViewPage: () => <div>board view page</div>,
}));
vi.mock('@/app/(main)/links/[linkId]/LinkPage', () => ({
LinkPage: () => <div>link page</div>,
}));
vi.mock('@/app/(main)/pixels/[pixelId]/PixelPage', () => ({
PixelPage: () => <div>pixel page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage', () => ({
AttributionPage: () => <div>attribution page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage', () => ({
BreakdownPage: () => <div>breakdown page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage', () => ({
FunnelsPage: () => <div>funnels page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage', () => ({
GoalsPage: () => <div>goals page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage', () => ({
JourneysPage: () => <div>journeys page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/performance/PerformancePage', () => ({
PerformancePage: () => <div>performance page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage', () => ({
RetentionPage: () => <div>retention page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage', () => ({
RevenuePage: () => <div>revenue page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage', () => ({
UTMPage: () => <div>utm page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/compare/ComparePage', () => ({
ComparePage: () => <div>compare page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/events/EventsPage', () => ({
EventsPage: () => <div>events page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/realtime/RealtimePage', () => ({
RealtimePage: () => <div>realtime page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/sessions/SessionsPage', () => ({
SessionsPage: () => <div>sessions page</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/WebsiteHeader', () => ({
WebsiteHeader: () => <div>website header</div>,
}));
vi.mock('@/app/(main)/websites/[websiteId]/WebsitePage', () => ({
WebsitePage: () => <div>website page</div>,
}));
vi.mock('@/app/(main)/websites/WebsiteProvider', () => ({
WebsiteProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/common/PageBody', () => ({
PageBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/input/MobileMenuButton', () => ({
MobileMenuButton: ({ children }: { children: ReactNode | ((props: { close: () => void }) => ReactNode) }) => (
<div>{typeof children === 'function' ? children({ close: () => {} }) : children}</div>
),
}));
vi.mock('./ShareFooter', () => ({
ShareFooter: () => <div>share footer</div>,
}));
vi.mock('./ShareNav', () => ({
ShareNav: () => <div>share nav</div>,
}));
describe('SharePage', () => {
beforeEach(() => {
resetTestNavigation();
initTheme.mockReset();
setShareData(mockShare, { token: mockShare.token });
});
test('renders an allowed website share route', () => {
render(
<ShareProvider slug="slug">
<SharePage />
</ShareProvider>,
{ route: '/share/slug/events' },
);
expect(screen.getAllByText('share nav')).toHaveLength(2);
expect(screen.getByText('website header')).toBeInTheDocument();
expect(screen.getByText('events page')).toBeInTheDocument();
expect(initTheme).toHaveBeenCalled();
});
});