New standalone Journey page.

This commit is contained in:
Mike Cao 2025-06-06 23:31:30 -07:00
parent cee05d762c
commit a167c590c5
16 changed files with 389 additions and 628 deletions

View file

@ -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
View file

@ -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

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -1,5 +0,0 @@
import { JourneyReport } from './JourneyReport';
export function JourneyReportPage() {
return <JourneyReport />;
}

View file

@ -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>
);
}

View file

@ -1,10 +0,0 @@
import { Metadata } from 'next';
import { JourneyReportPage } from './JourneyReportPage';
export default function () {
return <JourneyReportPage />;
}
export const metadata: Metadata = {
title: 'Journey Report',
};

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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))) {

View file

@ -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>
);
}

View file

@ -18,7 +18,9 @@ export {
ListFilter,
LockKeyhole,
LogOut,
Maximize,
Menu,
Minimize,
Moon,
MoreHorizontal as More,
PanelLeft,

View file

@ -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(),
}),