heatmap mobile layout. session replay mobile replays fix. share page tests. Closes #4417.
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user