mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +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",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
"@umami/react-zen": "^0.134.0",
|
||||
"@umami/react-zen": "^0.136.0",
|
||||
"@umami/redis-client": "^0.27.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chalk": "^4.1.1",
|
||||
|
|
|
|||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
|
|
@ -45,8 +45,8 @@ importers:
|
|||
specifier: ^5.28.6
|
||||
version: 5.77.2(react@19.1.0)
|
||||
'@umami/react-zen':
|
||||
specifier: ^0.134.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))
|
||||
specifier: ^0.136.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':
|
||||
specifier: ^0.27.0
|
||||
version: 0.27.0
|
||||
|
|
@ -2585,8 +2585,8 @@ packages:
|
|||
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@umami/react-zen@0.134.0':
|
||||
resolution: {integrity: sha512-RBSD50mTw2YKY0Z73OSxVtjrMvIq3nGtWYtcZHPXl/4oYj3Ph0cKTKto14Jx2qs2kHm2DxcS3ND1FR1OrPEknw==}
|
||||
'@umami/react-zen@0.136.0':
|
||||
resolution: {integrity: sha512-4dStzemPNxGB1nVdfnSxfkmYUnIXTRwqBqJpn4N9RvhmnQQeUfYQvJc4eqMSM0hrQToQcgGdvB/HucDpk30W7Q==}
|
||||
|
||||
'@umami/redis-client@0.27.0':
|
||||
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
||||
|
|
@ -9805,7 +9805,7 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.32.1
|
||||
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:
|
||||
'@fontsource/jetbrains-mono': 5.2.5
|
||||
'@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%;
|
||||
position: relative;
|
||||
|
||||
--journey-line-color: var(--base600);
|
||||
--journey-line-color: var(--base-color-6);
|
||||
--journey-active-color: var(--primary-color);
|
||||
--journey-faded-color: var(--base300);
|
||||
--journey-faded-color: var(--base-color-3);
|
||||
}
|
||||
|
||||
.view {
|
||||
|
|
@ -43,8 +43,8 @@
|
|||
|
||||
.dropoff {
|
||||
font-weight: 600;
|
||||
color: var(--blue800);
|
||||
background: var(--blue100);
|
||||
color: var(--font-color-muted);
|
||||
background: var(--base-color-3);
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
|
@ -58,8 +58,8 @@
|
|||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--base100);
|
||||
background: var(--base800);
|
||||
color: var(--base-color-1);
|
||||
background: var(--base-color-12);
|
||||
z-index: 1;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
background: var(--base75);
|
||||
background: var(--base-color-3);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -96,40 +96,33 @@
|
|||
}
|
||||
|
||||
.node:hover:not(.selected) {
|
||||
color: var(--base900);
|
||||
background: var(--base100);
|
||||
background: var(--base-color-4);
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
color: var(--base75);
|
||||
background: var(--base900);
|
||||
font-weight: 400;
|
||||
color: var(--base-color-1);
|
||||
background: var(--base-color-12);
|
||||
}
|
||||
|
||||
.node.active {
|
||||
color: var(--light50);
|
||||
color: var(--primary-font-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.node.selected .count {
|
||||
color: var(--base50);
|
||||
background: var(--base800);
|
||||
color: var(--base-color-1);
|
||||
background: var(--base-color-12);
|
||||
}
|
||||
|
||||
.node.selected.active .count {
|
||||
background: var(--primary600);
|
||||
color: var(--primary-font-color);
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.name {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.count {
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
background: var(--base200);
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
|
@ -219,7 +212,7 @@
|
|||
position: absolute;
|
||||
border-radius: 100%;
|
||||
border: 3px solid var(--journey-line-color);
|
||||
background: var(--light50);
|
||||
background: var(--base-color-1);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
|
@ -1,99 +1,289 @@
|
|||
import { Grid, Row, Column, Text, Icon, Button, Dialog } from '@umami/react-zen';
|
||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { Arrow, Eye } from '@/components/icons';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TooltipTrigger, Tooltip, Focusable } from '@umami/react-zen';
|
||||
import { firstBy } from 'thenby';
|
||||
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 { JourneyEditForm } from './JourneyEditForm';
|
||||
|
||||
const NODE_HEIGHT = 60;
|
||||
const NODE_GAP = 10;
|
||||
const LINE_WIDTH = 3;
|
||||
|
||||
export interface JourneyProps {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parameters: {
|
||||
steps: string;
|
||||
startStep: string;
|
||||
endStep: string;
|
||||
};
|
||||
websiteId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
steps: number;
|
||||
startStep?: string;
|
||||
endStep?: string;
|
||||
}
|
||||
|
||||
export type GoalData = { num: number; total: number };
|
||||
|
||||
export function Journey({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
parameters,
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
steps,
|
||||
startStep,
|
||||
endStep,
|
||||
}: JourneyProps) {
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [activeNode, setActiveNode] = useState(null);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
||||
const { data, error, isLoading } = useResultQuery<any>('journey', {
|
||||
websiteId,
|
||||
dateRange: {
|
||||
startDate,
|
||||
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 (
|
||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
||||
<Grid gap>
|
||||
<Grid columns="1fr auto" gap>
|
||||
<Column gap>
|
||||
<Row>
|
||||
<Text size="4" weight="bold">
|
||||
{name}
|
||||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={formatMessage(labels.goal)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 375, minWidth: 400 }}
|
||||
>
|
||||
<JourneyEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Row alignItems="center" gap>
|
||||
<Text>
|
||||
{formatMessage(labels.steps)}: {parameters?.steps}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Row alignItems="center" gap="6">
|
||||
<Text>
|
||||
{formatMessage(labels.startStep)}: {parameters?.startStep}
|
||||
</Text>
|
||||
<Icon>
|
||||
<Arrow />
|
||||
</Icon>
|
||||
<Text>
|
||||
{formatMessage(labels.endStep)}: {parameters?.endStep || formatMessage(labels.none)}
|
||||
</Text>
|
||||
</Row>
|
||||
<Button>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Eye />
|
||||
</Icon>
|
||||
<Text>View</Text>
|
||||
</Row>
|
||||
</Button>
|
||||
</Row>
|
||||
</Grid>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
import { Grid, Loading } from '@umami/react-zen';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { Journey } from './Journey';
|
||||
import { JourneyAddButton } from './JourneyAddButton';
|
||||
import { WebsiteControls } from '../WebsiteControls';
|
||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useState } from 'react';
|
||||
import { ListItem, Select, Column, Grid, SearchField } from '@umami/react-zen';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
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 }) {
|
||||
const { result } = useReportsQuery({ websiteId, type: 'journey' });
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange(websiteId);
|
||||
|
||||
if (!result) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
const [steps, setSteps] = useState(DEFAULT_STEP);
|
||||
const [startStep, setStartStep] = useState('');
|
||||
const [endStep, setEndStep] = useState('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column gap="6">
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
||||
<SectionHeader>
|
||||
<JourneyAddButton websiteId={websiteId} />
|
||||
</SectionHeader>
|
||||
<Grid columns="1fr 1fr" gap>
|
||||
{result?.data?.map((report: any) => (
|
||||
<Panel key={report.id}>
|
||||
<Journey {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
||||
</Panel>
|
||||
<Grid columns="repeat(3, 1fr)" gap>
|
||||
<Select
|
||||
items={JOURNEY_STEPS}
|
||||
label={formatMessage(labels.steps)}
|
||||
value={steps}
|
||||
defaultValue={steps}
|
||||
onChange={setSteps}
|
||||
>
|
||||
{JOURNEY_STEPS.map(step => (
|
||||
<ListItem key={step} id={step}>
|
||||
{step}
|
||||
</ListItem>
|
||||
))}
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
</>
|
||||
</Select>
|
||||
<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 { unauthorized, json } from '@/lib/response';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { getJourney } from '@/queries';
|
||||
import { reportParms } from '@/lib/schema';
|
||||
import { reportResultSchema } from '@/lib/schema';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
...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);
|
||||
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
|
|
@ -22,9 +14,7 @@ export async function POST(request: Request) {
|
|||
const {
|
||||
websiteId,
|
||||
dateRange: { startDate, endDate },
|
||||
steps,
|
||||
startStep,
|
||||
endStep,
|
||||
parameters: { steps, startStep, endStep },
|
||||
} = body;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,46 @@
|
|||
import { Box } from '@umami/react-zen';
|
||||
import type { BoxProps } from '@umami/react-zen/Box';
|
||||
import { useState } from 'react';
|
||||
import { Column, type ColumnProps, Row, Icon, Button } from '@umami/react-zen';
|
||||
import { Maximize, Close } from '@/components/icons';
|
||||
|
||||
export function Panel(props: BoxProps) {
|
||||
return <Box padding="6" border borderRadius="3" backgroundColor position="relative" {...props} />;
|
||||
export interface PanelProps extends ColumnProps {
|
||||
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,
|
||||
LockKeyhole,
|
||||
LogOut,
|
||||
Maximize,
|
||||
Menu,
|
||||
Minimize,
|
||||
Moon,
|
||||
MoreHorizontal as More,
|
||||
PanelLeft,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export const funnelReportSchema = z.object({
|
|||
export const journeyReportSchema = z.object({
|
||||
type: z.literal('journey'),
|
||||
parameters: z.object({
|
||||
steps: z.coerce.number().positive(),
|
||||
steps: z.coerce.number().min(2).max(7),
|
||||
startStep: z.string().optional(),
|
||||
endStep: z.string().optional(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue