mirror of
https://github.com/umami-software/umami.git
synced 2026-02-14 17:45:38 +01:00
New standalone Journey page.
This commit is contained in:
parent
cee05d762c
commit
a167c590c5
16 changed files with 389 additions and 628 deletions
|
|
@ -81,7 +81,7 @@
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^9.7.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.28.6",
|
"@tanstack/react-query": "^5.28.6",
|
||||||
"@umami/react-zen": "^0.134.0",
|
"@umami/react-zen": "^0.136.0",
|
||||||
"@umami/redis-client": "^0.27.0",
|
"@umami/redis-client": "^0.27.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
|
|
|
||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
|
@ -45,8 +45,8 @@ importers:
|
||||||
specifier: ^5.28.6
|
specifier: ^5.28.6
|
||||||
version: 5.77.2(react@19.1.0)
|
version: 5.77.2(react@19.1.0)
|
||||||
'@umami/react-zen':
|
'@umami/react-zen':
|
||||||
specifier: ^0.134.0
|
specifier: ^0.136.0
|
||||||
version: 0.134.0(@babel/core@7.27.1)(@types/react@19.1.5)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
|
version: 0.136.0(@babel/core@7.27.1)(@types/react@19.1.5)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
|
||||||
'@umami/redis-client':
|
'@umami/redis-client':
|
||||||
specifier: ^0.27.0
|
specifier: ^0.27.0
|
||||||
version: 0.27.0
|
version: 0.27.0
|
||||||
|
|
@ -2585,8 +2585,8 @@ packages:
|
||||||
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
|
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@umami/react-zen@0.134.0':
|
'@umami/react-zen@0.136.0':
|
||||||
resolution: {integrity: sha512-RBSD50mTw2YKY0Z73OSxVtjrMvIq3nGtWYtcZHPXl/4oYj3Ph0cKTKto14Jx2qs2kHm2DxcS3ND1FR1OrPEknw==}
|
resolution: {integrity: sha512-4dStzemPNxGB1nVdfnSxfkmYUnIXTRwqBqJpn4N9RvhmnQQeUfYQvJc4eqMSM0hrQToQcgGdvB/HucDpk30W7Q==}
|
||||||
|
|
||||||
'@umami/redis-client@0.27.0':
|
'@umami/redis-client@0.27.0':
|
||||||
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
||||||
|
|
@ -9805,7 +9805,7 @@ snapshots:
|
||||||
'@typescript-eslint/types': 8.32.1
|
'@typescript-eslint/types': 8.32.1
|
||||||
eslint-visitor-keys: 4.2.0
|
eslint-visitor-keys: 4.2.0
|
||||||
|
|
||||||
'@umami/react-zen@0.134.0(@babel/core@7.27.1)(@types/react@19.1.5)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
|
'@umami/react-zen@0.136.0(@babel/core@7.27.1)(@types/react@19.1.5)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fontsource/jetbrains-mono': 5.2.5
|
'@fontsource/jetbrains-mono': 5.2.5
|
||||||
'@internationalized/date': 3.8.2
|
'@internationalized/date': 3.8.2
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { useMessages, useReport } from '@/components/hooks';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
Form,
|
|
||||||
FormButtons,
|
|
||||||
FormField,
|
|
||||||
ListItem,
|
|
||||||
FormSubmitButton,
|
|
||||||
TextField,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { BaseParameters } from '../[reportId]/BaseParameters';
|
|
||||||
|
|
||||||
export function JourneyParameters() {
|
|
||||||
const { report, runReport, isRunning } = useReport();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const { id, parameters } = report || {};
|
|
||||||
const { websiteId, dateRange, steps } = parameters || {};
|
|
||||||
const queryDisabled = !websiteId || !dateRange || !steps;
|
|
||||||
|
|
||||||
const handleSubmit = (data: any, e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!queryDisabled) {
|
|
||||||
runReport(data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
|
||||||
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
|
|
||||||
<FormField
|
|
||||||
label={formatMessage(labels.steps)}
|
|
||||||
name="steps"
|
|
||||||
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/, min: 3, max: 7 }}
|
|
||||||
>
|
|
||||||
<Select items={[3, 4, 5, 6, 7]}>
|
|
||||||
{(item: any) => (
|
|
||||||
<ListItem key={item.toString()} id={item.toString()}>
|
|
||||||
{item}
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label={formatMessage(labels.startStep)} name="startStep">
|
|
||||||
<TextField autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={formatMessage(labels.endStep)} name="endStep">
|
|
||||||
<TextField autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<FormButtons>
|
|
||||||
<FormSubmitButton variant="primary" isDisabled={queryDisabled} isLoading={isRunning}>
|
|
||||||
{formatMessage(labels.runQuery)}
|
|
||||||
</FormSubmitButton>
|
|
||||||
</FormButtons>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Report } from '../[reportId]/Report';
|
|
||||||
import { ReportHeader } from '../[reportId]/ReportHeader';
|
|
||||||
import { ReportMenu } from '../[reportId]/ReportMenu';
|
|
||||||
import { ReportBody } from '../[reportId]/ReportBody';
|
|
||||||
import { JourneyParameters } from './JourneyParameters';
|
|
||||||
import { JourneyView } from './JourneyView';
|
|
||||||
import { Path } from '@/components/icons';
|
|
||||||
import { REPORT_TYPES } from '@/lib/constants';
|
|
||||||
|
|
||||||
const defaultParameters = {
|
|
||||||
type: REPORT_TYPES.journey,
|
|
||||||
parameters: { steps: 5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function JourneyReport({ reportId }: { reportId?: string }) {
|
|
||||||
return (
|
|
||||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
|
||||||
<ReportHeader icon={<Path />} />
|
|
||||||
<ReportMenu>
|
|
||||||
<JourneyParameters />
|
|
||||||
</ReportMenu>
|
|
||||||
<ReportBody>
|
|
||||||
<JourneyView />
|
|
||||||
</ReportBody>
|
|
||||||
</Report>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { JourneyReport } from './JourneyReport';
|
|
||||||
|
|
||||||
export function JourneyReportPage() {
|
|
||||||
return <JourneyReport />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { TooltipTrigger, Tooltip, Focusable } from '@umami/react-zen';
|
|
||||||
import { firstBy } from 'thenby';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useEscapeKey, useMessages, useReport } from '@/components/hooks';
|
|
||||||
import { objectToArray } from '@/lib/data';
|
|
||||||
import styles from './JourneyView.module.css';
|
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
|
|
||||||
const NODE_HEIGHT = 60;
|
|
||||||
const NODE_GAP = 10;
|
|
||||||
const LINE_WIDTH = 3;
|
|
||||||
|
|
||||||
export function JourneyView() {
|
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
|
||||||
const [activeNode, setActiveNode] = useState(null);
|
|
||||||
const { report } = useReport();
|
|
||||||
const { data, parameters } = report || {};
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
useEscapeKey(() => setSelectedNode(null));
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedPaths = selectedNode?.paths ?? [];
|
|
||||||
const activePaths = activeNode?.paths ?? [];
|
|
||||||
const columns = [];
|
|
||||||
|
|
||||||
for (let columnIndex = 0; columnIndex < +parameters.steps; columnIndex++) {
|
|
||||||
const nodes = {};
|
|
||||||
|
|
||||||
data.forEach(({ items, count }: any, nodeIndex: any) => {
|
|
||||||
const name = items[columnIndex];
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
|
|
||||||
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
|
|
||||||
|
|
||||||
if (!nodes[name]) {
|
|
||||||
const paths = data.filter(({ items }) => items[columnIndex] === name);
|
|
||||||
|
|
||||||
nodes[name] = {
|
|
||||||
name,
|
|
||||||
count,
|
|
||||||
totalCount: count,
|
|
||||||
nodeIndex,
|
|
||||||
columnIndex,
|
|
||||||
selected,
|
|
||||||
active,
|
|
||||||
paths,
|
|
||||||
pathMap: paths.map(({ items, count }) => ({
|
|
||||||
[`${columnIndex}:${items.join(':')}`]: count,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
nodes[name].totalCount += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.forEach((column, columnIndex) => {
|
|
||||||
const nodes = column.nodes.map((currentNode, currentNodeIndex) => {
|
|
||||||
const previousNodes = columns[columnIndex - 1]?.nodes;
|
|
||||||
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
|
|
||||||
let activeCount = selectedCount;
|
|
||||||
|
|
||||||
const lines =
|
|
||||||
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
|
|
||||||
const fromCount = selectedNode?.paths.reduce((sum, path) => {
|
|
||||||
if (
|
|
||||||
previousNode.name === path.items[columnIndex - 1] &&
|
|
||||||
currentNode.name === path.items[columnIndex]
|
|
||||||
) {
|
|
||||||
sum += path.count;
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
if (currentNode.selected && previousNode.selected && fromCount) {
|
|
||||||
arr.push([previousNodeIndex, currentNodeIndex]);
|
|
||||||
selectedCount += fromCount;
|
|
||||||
|
|
||||||
if (previousNode.active) {
|
|
||||||
activeCount += fromCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return arr;
|
|
||||||
}, []) || [];
|
|
||||||
|
|
||||||
return { ...currentNode, selectedCount, activeCount, lines };
|
|
||||||
});
|
|
||||||
|
|
||||||
const visitorCount = nodes.reduce(
|
|
||||||
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
|
|
||||||
if (!selectedNode) {
|
|
||||||
sum += totalCount;
|
|
||||||
} else if (!activeNode && selectedNode && selected) {
|
|
||||||
sum += selectedCount;
|
|
||||||
} else if (activeNode && active) {
|
|
||||||
sum += activeCount;
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
|
|
||||||
const dropOff =
|
|
||||||
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
|
|
||||||
|
|
||||||
Object.assign(column, { nodes, visitorCount, dropOff });
|
|
||||||
});
|
|
||||||
|
|
||||||
return columns;
|
|
||||||
}, [data, selectedNode, activeNode]);
|
|
||||||
|
|
||||||
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
|
|
||||||
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
|
|
||||||
setSelectedNode({ name, columnIndex, paths });
|
|
||||||
} else {
|
|
||||||
setSelectedNode(null);
|
|
||||||
}
|
|
||||||
setActiveNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.view}>
|
|
||||||
{columns.map((column, columnIndex) => {
|
|
||||||
const dropOffPercent = `${~~column.dropOff}%`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={columnIndex}
|
|
||||||
className={classNames(styles.column, {
|
|
||||||
[styles.selected]: selectedNode,
|
|
||||||
[styles.active]: activeNode,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.num}>{columnIndex + 1}</div>
|
|
||||||
<div className={styles.stats}>
|
|
||||||
<div className={styles.visitors} title={column.visitorCount}>
|
|
||||||
{formatLongNumber(column.visitorCount)} {formatMessage(labels.visitors)}
|
|
||||||
</div>
|
|
||||||
{columnIndex > 0 && <div className={styles.dropoff}>{dropOffPercent}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.nodes}>
|
|
||||||
{column.nodes.map(
|
|
||||||
({
|
|
||||||
name,
|
|
||||||
totalCount,
|
|
||||||
selected,
|
|
||||||
active,
|
|
||||||
paths,
|
|
||||||
activeCount,
|
|
||||||
selectedCount,
|
|
||||||
lines,
|
|
||||||
}) => {
|
|
||||||
const nodeCount = selected
|
|
||||||
? active
|
|
||||||
? activeCount
|
|
||||||
: selectedCount
|
|
||||||
: totalCount;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className={styles.wrapper}
|
|
||||||
onMouseEnter={() => selected && setActiveNode({ name, columnIndex, paths })}
|
|
||||||
onMouseLeave={() => selected && setActiveNode(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(styles.node, {
|
|
||||||
[styles.selected]: selected,
|
|
||||||
[styles.active]: active,
|
|
||||||
})}
|
|
||||||
onClick={() => handleClick(name, columnIndex, paths)}
|
|
||||||
>
|
|
||||||
<div className={styles.name} title={name}>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
<TooltipTrigger isDisabled={!selected}>
|
|
||||||
<Focusable>
|
|
||||||
<div>{dropOffPercent}</div>
|
|
||||||
</Focusable>
|
|
||||||
<Tooltip>
|
|
||||||
<div className={styles.count} title={nodeCount}>
|
|
||||||
{formatLongNumber(nodeCount)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{columnIndex < columns.length &&
|
|
||||||
lines.map(([fromIndex, nodeIndex], i) => {
|
|
||||||
const height =
|
|
||||||
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
|
|
||||||
NODE_GAP;
|
|
||||||
const midHeight =
|
|
||||||
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
|
|
||||||
NODE_GAP +
|
|
||||||
LINE_WIDTH;
|
|
||||||
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${fromIndex}${nodeIndex}${i}`}
|
|
||||||
className={classNames(styles.line, {
|
|
||||||
[styles.active]:
|
|
||||||
active &&
|
|
||||||
activeNode?.paths.find(
|
|
||||||
path =>
|
|
||||||
path.items[columnIndex] === name &&
|
|
||||||
path.items[columnIndex - 1] === nodeName,
|
|
||||||
),
|
|
||||||
[styles.up]: fromIndex < nodeIndex,
|
|
||||||
[styles.down]: fromIndex > nodeIndex,
|
|
||||||
[styles.flat]: fromIndex === nodeIndex,
|
|
||||||
})}
|
|
||||||
style={{ height }}
|
|
||||||
>
|
|
||||||
<div className={classNames(styles.segment, styles.start)} />
|
|
||||||
<div
|
|
||||||
className={classNames(styles.segment, styles.mid)}
|
|
||||||
style={{
|
|
||||||
height: midHeight,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={classNames(styles.segment, styles.end)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Metadata } from 'next';
|
|
||||||
import { JourneyReportPage } from './JourneyReportPage';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
return <JourneyReportPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Journey Report',
|
|
||||||
};
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
--journey-line-color: var(--base600);
|
--journey-line-color: var(--base-color-6);
|
||||||
--journey-active-color: var(--primary-color);
|
--journey-active-color: var(--primary-color);
|
||||||
--journey-faded-color: var(--base300);
|
--journey-faded-color: var(--base-color-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view {
|
.view {
|
||||||
|
|
@ -43,8 +43,8 @@
|
||||||
|
|
||||||
.dropoff {
|
.dropoff {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--blue800);
|
color: var(--font-color-muted);
|
||||||
background: var(--blue100);
|
background: var(--base-color-3);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
@ -58,8 +58,8 @@
|
||||||
height: 50px;
|
height: 50px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--base100);
|
color: var(--base-color-1);
|
||||||
background: var(--base800);
|
background: var(--base-color-12);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--base75);
|
background: var(--base-color-3);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -96,40 +96,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.node:hover:not(.selected) {
|
.node:hover:not(.selected) {
|
||||||
color: var(--base900);
|
background: var(--base-color-4);
|
||||||
background: var(--base100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.selected {
|
.node.selected {
|
||||||
color: var(--base75);
|
color: var(--base-color-1);
|
||||||
background: var(--base900);
|
background: var(--base-color-12);
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.active {
|
.node.active {
|
||||||
color: var(--light50);
|
color: var(--primary-font-color);
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.selected .count {
|
.node.selected .count {
|
||||||
color: var(--base50);
|
color: var(--base-color-1);
|
||||||
background: var(--base800);
|
background: var(--base-color-12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.selected.active .count {
|
.node.selected.active .count {
|
||||||
background: var(--primary600);
|
color: var(--primary-font-color);
|
||||||
|
background: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.count {
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: var(--base200);
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|
@ -219,7 +212,7 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
border: 3px solid var(--journey-line-color);
|
border: 3px solid var(--journey-line-color);
|
||||||
background: var(--light50);
|
background: var(--base-color-1);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -1,99 +1,289 @@
|
||||||
import { Grid, Row, Column, Text, Icon, Button, Dialog } from '@umami/react-zen';
|
import { useMemo, useState } from 'react';
|
||||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
import { TooltipTrigger, Tooltip, Focusable } from '@umami/react-zen';
|
||||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
import { firstBy } from 'thenby';
|
||||||
import { Arrow, Eye } from '@/components/icons';
|
import classNames from 'classnames';
|
||||||
|
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
|
||||||
|
import { objectToArray } from '@/lib/data';
|
||||||
|
import styles from './Journey.module.css';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { JourneyEditForm } from './JourneyEditForm';
|
|
||||||
|
const NODE_HEIGHT = 60;
|
||||||
|
const NODE_GAP = 10;
|
||||||
|
const LINE_WIDTH = 3;
|
||||||
|
|
||||||
export interface JourneyProps {
|
export interface JourneyProps {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
parameters: {
|
|
||||||
steps: string;
|
|
||||||
startStep: string;
|
|
||||||
endStep: string;
|
|
||||||
};
|
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
steps: number;
|
||||||
|
startStep?: string;
|
||||||
|
endStep?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GoalData = { num: number; total: number };
|
|
||||||
|
|
||||||
export function Journey({
|
export function Journey({
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
parameters,
|
|
||||||
websiteId,
|
websiteId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
steps,
|
||||||
|
startStep,
|
||||||
|
endStep,
|
||||||
}: JourneyProps) {
|
}: JourneyProps) {
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
|
const [activeNode, setActiveNode] = useState(null);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
const { data, error, isLoading } = useResultQuery<any>('journey', {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
},
|
},
|
||||||
parameters,
|
parameters: {
|
||||||
|
steps,
|
||||||
|
startStep,
|
||||||
|
endStep,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEscapeKey(() => setSelectedNode(null));
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPaths = selectedNode?.paths ?? [];
|
||||||
|
const activePaths = activeNode?.paths ?? [];
|
||||||
|
const columns = [];
|
||||||
|
|
||||||
|
for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
|
||||||
|
const nodes = {};
|
||||||
|
|
||||||
|
data.forEach(({ items, count }: any, nodeIndex: any) => {
|
||||||
|
const name = items[columnIndex];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
|
||||||
|
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
|
||||||
|
|
||||||
|
if (!nodes[name]) {
|
||||||
|
const paths = data.filter(({ items }) => items[columnIndex] === name);
|
||||||
|
|
||||||
|
nodes[name] = {
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
totalCount: count,
|
||||||
|
nodeIndex,
|
||||||
|
columnIndex,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
paths,
|
||||||
|
pathMap: paths.map(({ items, count }) => ({
|
||||||
|
[`${columnIndex}:${items.join(':')}`]: count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nodes[name].totalCount += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
columns.push({
|
||||||
|
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.forEach((column, columnIndex) => {
|
||||||
|
const nodes = column.nodes.map(
|
||||||
|
(
|
||||||
|
currentNode: { totalCount: number; name: string; selected: boolean },
|
||||||
|
currentNodeIndex: any,
|
||||||
|
) => {
|
||||||
|
const previousNodes = columns[columnIndex - 1]?.nodes;
|
||||||
|
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
|
||||||
|
let activeCount = selectedCount;
|
||||||
|
|
||||||
|
const lines =
|
||||||
|
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
|
||||||
|
const fromCount = selectedNode?.paths.reduce((sum, path) => {
|
||||||
|
if (
|
||||||
|
previousNode.name === path.items[columnIndex - 1] &&
|
||||||
|
currentNode.name === path.items[columnIndex]
|
||||||
|
) {
|
||||||
|
sum += path.count;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (currentNode.selected && previousNode.selected && fromCount) {
|
||||||
|
arr.push([previousNodeIndex, currentNodeIndex]);
|
||||||
|
selectedCount += fromCount;
|
||||||
|
|
||||||
|
if (previousNode.active) {
|
||||||
|
activeCount += fromCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr;
|
||||||
|
}, []) || [];
|
||||||
|
|
||||||
|
return { ...currentNode, selectedCount, activeCount, lines };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const visitorCount = nodes.reduce(
|
||||||
|
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
|
||||||
|
if (!selectedNode) {
|
||||||
|
sum += totalCount;
|
||||||
|
} else if (!activeNode && selectedNode && selected) {
|
||||||
|
sum += selectedCount;
|
||||||
|
} else if (activeNode && active) {
|
||||||
|
sum += activeCount;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
|
||||||
|
const dropOff =
|
||||||
|
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
|
||||||
|
|
||||||
|
Object.assign(column, { nodes, visitorCount, dropOff });
|
||||||
|
});
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}, [data, selectedNode, activeNode]);
|
||||||
|
|
||||||
|
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
|
||||||
|
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
|
||||||
|
setSelectedNode({ name, columnIndex, paths });
|
||||||
|
} else {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}
|
||||||
|
setActiveNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
||||||
<Grid gap>
|
<div className={styles.container}>
|
||||||
<Grid columns="1fr auto" gap>
|
<div className={styles.view}>
|
||||||
<Column gap>
|
{columns.map((column, columnIndex) => {
|
||||||
<Row>
|
const dropOffPercent = `${~~column.dropOff}%`;
|
||||||
<Text size="4" weight="bold">
|
return (
|
||||||
{name}
|
<div
|
||||||
</Text>
|
key={columnIndex}
|
||||||
</Row>
|
className={classNames(styles.column, {
|
||||||
</Column>
|
[styles.selected]: selectedNode,
|
||||||
<Column>
|
[styles.active]: activeNode,
|
||||||
<ReportEditButton id={id} name={name} type={type}>
|
})}
|
||||||
{({ close }) => {
|
>
|
||||||
return (
|
<div className={styles.header}>
|
||||||
<Dialog
|
<div className={styles.num}>{columnIndex + 1}</div>
|
||||||
title={formatMessage(labels.goal)}
|
<div className={styles.stats}>
|
||||||
variant="modal"
|
<div className={styles.visitors} title={column.visitorCount}>
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
{formatLongNumber(column.visitorCount)} {formatMessage(labels.visitors)}
|
||||||
>
|
</div>
|
||||||
<JourneyEditForm id={id} websiteId={websiteId} onClose={close} />
|
{columnIndex > 0 && <div className={styles.dropoff}>{dropOffPercent}</div>}
|
||||||
</Dialog>
|
</div>
|
||||||
);
|
</div>
|
||||||
}}
|
<div className={styles.nodes}>
|
||||||
</ReportEditButton>
|
{column.nodes.map(
|
||||||
</Column>
|
({
|
||||||
</Grid>
|
name,
|
||||||
<Row alignItems="center" gap>
|
totalCount,
|
||||||
<Text>
|
selected,
|
||||||
{formatMessage(labels.steps)}: {parameters?.steps}
|
active,
|
||||||
</Text>
|
paths,
|
||||||
</Row>
|
activeCount,
|
||||||
<Row alignItems="center" justifyContent="space-between">
|
selectedCount,
|
||||||
<Row alignItems="center" gap="6">
|
lines,
|
||||||
<Text>
|
}) => {
|
||||||
{formatMessage(labels.startStep)}: {parameters?.startStep}
|
const nodeCount = selected
|
||||||
</Text>
|
? active
|
||||||
<Icon>
|
? activeCount
|
||||||
<Arrow />
|
: selectedCount
|
||||||
</Icon>
|
: totalCount;
|
||||||
<Text>
|
|
||||||
{formatMessage(labels.endStep)}: {parameters?.endStep || formatMessage(labels.none)}
|
return (
|
||||||
</Text>
|
<div
|
||||||
</Row>
|
key={name}
|
||||||
<Button>
|
className={styles.wrapper}
|
||||||
<Row alignItems="center" gap>
|
onMouseEnter={() =>
|
||||||
<Icon>
|
selected && setActiveNode({ name, columnIndex, paths })
|
||||||
<Eye />
|
}
|
||||||
</Icon>
|
onMouseLeave={() => selected && setActiveNode(null)}
|
||||||
<Text>View</Text>
|
>
|
||||||
</Row>
|
<div
|
||||||
</Button>
|
className={classNames(styles.node, {
|
||||||
</Row>
|
[styles.selected]: selected,
|
||||||
</Grid>
|
[styles.active]: active,
|
||||||
|
})}
|
||||||
|
onClick={() => handleClick(name, columnIndex, paths)}
|
||||||
|
>
|
||||||
|
<div className={styles.name} title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.count} title={nodeCount}>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Focusable>
|
||||||
|
<div>{formatLongNumber(nodeCount)}</div>
|
||||||
|
</Focusable>
|
||||||
|
<Tooltip placement="right" offset={10}>
|
||||||
|
{dropOffPercent}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</div>
|
||||||
|
{columnIndex < columns.length &&
|
||||||
|
lines.map(([fromIndex, nodeIndex], i) => {
|
||||||
|
const height =
|
||||||
|
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
|
||||||
|
NODE_GAP;
|
||||||
|
const midHeight =
|
||||||
|
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
|
||||||
|
NODE_GAP +
|
||||||
|
LINE_WIDTH;
|
||||||
|
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${fromIndex}${nodeIndex}${i}`}
|
||||||
|
className={classNames(styles.line, {
|
||||||
|
[styles.active]:
|
||||||
|
active &&
|
||||||
|
activeNode?.paths.find(
|
||||||
|
(path: { items: any[] }) =>
|
||||||
|
path.items[columnIndex] === name &&
|
||||||
|
path.items[columnIndex - 1] === nodeName,
|
||||||
|
),
|
||||||
|
[styles.up]: fromIndex < nodeIndex,
|
||||||
|
[styles.down]: fromIndex > nodeIndex,
|
||||||
|
[styles.flat]: fromIndex === nodeIndex,
|
||||||
|
})}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div className={classNames(styles.segment, styles.start)} />
|
||||||
|
<div
|
||||||
|
className={classNames(styles.segment, styles.mid)}
|
||||||
|
style={{
|
||||||
|
height: midHeight,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={classNames(styles.segment, styles.end)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { JourneyEditForm } from './JourneyEditForm';
|
|
||||||
import { Plus } from '@/components/icons';
|
|
||||||
|
|
||||||
export function JourneyAddButton({ websiteId }: { websiteId: string }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.journey)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal>
|
|
||||||
<Dialog
|
|
||||||
variant="modal"
|
|
||||||
title={formatMessage(labels.journey)}
|
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
|
||||||
>
|
|
||||||
{({ close }) => <JourneyEditForm websiteId={websiteId} onClose={close} />}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormField,
|
|
||||||
TextField,
|
|
||||||
FormButtons,
|
|
||||||
FormSubmitButton,
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
ListItem,
|
|
||||||
Loading,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
|
||||||
|
|
||||||
const JOURNEY_STEPS = ['3', '4', '5', '6', '7'];
|
|
||||||
|
|
||||||
export function JourneyEditForm({
|
|
||||||
id,
|
|
||||||
websiteId,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
id?: string;
|
|
||||||
websiteId: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const { data } = useReportQuery(id);
|
|
||||||
const { mutate, error, isPending } = useMutation({
|
|
||||||
mutationFn: (params: any) => post(`/reports${id ? `/${id}` : ''}`, params),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async ({ name, ...parameters }) => {
|
|
||||||
mutate(
|
|
||||||
{ ...data, id, name, type: 'journey', websiteId, parameters },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
if (id) touch(`report:${id}`);
|
|
||||||
touch('reports:journey');
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id && !data) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValues = {
|
|
||||||
name: data?.name || '',
|
|
||||||
steps: data?.steps || '5',
|
|
||||||
startStep: data?.parameters?.startStep || '',
|
|
||||||
endStep: data?.parameters?.endStep || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
|
||||||
<FormField
|
|
||||||
name="name"
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
name="steps"
|
|
||||||
label={formatMessage(labels.steps)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<Select>
|
|
||||||
{JOURNEY_STEPS.map(step => (
|
|
||||||
<ListItem key={step} id={step}>
|
|
||||||
{step}
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
|
||||||
<FormField name="startStep" label={formatMessage(labels.startStep)}>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormField name="endStep" label={formatMessage(labels.endStep)}>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormButtons>
|
|
||||||
<Button onPress={onClose} isDisabled={isPending}>
|
|
||||||
{formatMessage(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
|
||||||
</FormButtons>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +1,67 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Grid, Loading } from '@umami/react-zen';
|
import { useState } from 'react';
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
import { ListItem, Select, Column, Grid, SearchField } from '@umami/react-zen';
|
||||||
import { Journey } from './Journey';
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
import { JourneyAddButton } from './JourneyAddButton';
|
|
||||||
import { WebsiteControls } from '../WebsiteControls';
|
|
||||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { Journey } from './Journey';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
|
||||||
|
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
|
||||||
|
const DEFAULT_STEP = 3;
|
||||||
|
|
||||||
export function JourneysPage({ websiteId }: { websiteId: string }) {
|
export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
const { result } = useReportsQuery({ websiteId, type: 'journey' });
|
const { formatMessage, labels } = useMessages();
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
} = useDateRange(websiteId);
|
} = useDateRange(websiteId);
|
||||||
|
const [steps, setSteps] = useState(DEFAULT_STEP);
|
||||||
if (!result) {
|
const [startStep, setStartStep] = useState('');
|
||||||
return <Loading position="page" />;
|
const [endStep, setEndStep] = useState('');
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Column gap="6">
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
<Grid columns="repeat(3, 1fr)" gap>
|
||||||
<SectionHeader>
|
<Select
|
||||||
<JourneyAddButton websiteId={websiteId} />
|
items={JOURNEY_STEPS}
|
||||||
</SectionHeader>
|
label={formatMessage(labels.steps)}
|
||||||
<Grid columns="1fr 1fr" gap>
|
value={steps}
|
||||||
{result?.data?.map((report: any) => (
|
defaultValue={steps}
|
||||||
<Panel key={report.id}>
|
onChange={setSteps}
|
||||||
<Journey {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
>
|
||||||
</Panel>
|
{JOURNEY_STEPS.map(step => (
|
||||||
|
<ListItem key={step} id={step}>
|
||||||
|
{step}
|
||||||
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Select>
|
||||||
</LoadingPanel>
|
<Column>
|
||||||
</>
|
<SearchField
|
||||||
|
label={formatMessage(labels.startStep)}
|
||||||
|
value={startStep}
|
||||||
|
onSearch={setStartStep}
|
||||||
|
delay={1000}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<SearchField
|
||||||
|
label={formatMessage(labels.endStep)}
|
||||||
|
value={endStep}
|
||||||
|
onSearch={setEndStep}
|
||||||
|
delay={1000}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
<Panel height="900px" allowFullscreen>
|
||||||
|
<Journey
|
||||||
|
websiteId={websiteId}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
steps={steps}
|
||||||
|
startStep={startStep}
|
||||||
|
endStep={endStep}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getJourney } from '@/queries';
|
import { getJourney } from '@/queries';
|
||||||
import { reportParms } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...reportParms,
|
|
||||||
steps: z.coerce.number().min(3).max(7),
|
|
||||||
startStep: z.string().optional(),
|
|
||||||
endStep: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -22,9 +14,7 @@ export async function POST(request: Request) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
steps,
|
parameters: { steps, startStep, endStep },
|
||||||
startStep,
|
|
||||||
endStep,
|
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,46 @@
|
||||||
import { Box } from '@umami/react-zen';
|
import { useState } from 'react';
|
||||||
import type { BoxProps } from '@umami/react-zen/Box';
|
import { Column, type ColumnProps, Row, Icon, Button } from '@umami/react-zen';
|
||||||
|
import { Maximize, Close } from '@/components/icons';
|
||||||
|
|
||||||
export function Panel(props: BoxProps) {
|
export interface PanelProps extends ColumnProps {
|
||||||
return <Box padding="6" border borderRadius="3" backgroundColor position="relative" {...props} />;
|
allowFullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenStyles = {
|
||||||
|
position: 'fixed',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
setIsFullscreen(!isFullscreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
padding="6"
|
||||||
|
border
|
||||||
|
borderRadius="3"
|
||||||
|
backgroundColor
|
||||||
|
position="relative"
|
||||||
|
gap
|
||||||
|
{...props}
|
||||||
|
style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
|
||||||
|
>
|
||||||
|
{allowFullscreen && (
|
||||||
|
<Row justifyContent="flex-end" alignItems="center">
|
||||||
|
<Button variant="quiet" onPress={handleFullscreen}>
|
||||||
|
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ export {
|
||||||
ListFilter,
|
ListFilter,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Maximize,
|
||||||
Menu,
|
Menu,
|
||||||
|
Minimize,
|
||||||
Moon,
|
Moon,
|
||||||
MoreHorizontal as More,
|
MoreHorizontal as More,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export const funnelReportSchema = z.object({
|
||||||
export const journeyReportSchema = z.object({
|
export const journeyReportSchema = z.object({
|
||||||
type: z.literal('journey'),
|
type: z.literal('journey'),
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
steps: z.coerce.number().positive(),
|
steps: z.coerce.number().min(2).max(7),
|
||||||
startStep: z.string().optional(),
|
startStep: z.string().optional(),
|
||||||
endStep: z.string().optional(),
|
endStep: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue