mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Added journey page. Removed dashboard.
This commit is contained in:
parent
3847e32f39
commit
cee05d762c
24 changed files with 328 additions and 422 deletions
|
|
@ -130,7 +130,7 @@ const redirects = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/teams/:id',
|
source: '/teams/:id',
|
||||||
destination: '/teams/:id/dashboard',
|
destination: '/teams/:id/websites',
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@
|
||||||
"start-server": "node server.js",
|
"start-server": "node server.js",
|
||||||
"build-app": "next build",
|
"build-app": "next build",
|
||||||
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
||||||
"build-components": "rollup -c rollup.components.config.mjs",
|
"build-components": "rollup -c rollup.components.config.js",
|
||||||
"build-tracker": "rollup -c rollup.tracker.config.mjs",
|
"build-tracker": "rollup -c rollup.tracker.config.js",
|
||||||
"build-db": "npm-run-all copy-db-files build-db-client",
|
"build-prisma-client": "node scripts/build-prisma-client.js",
|
||||||
|
"build-db": "npm-run-all copy-db-files build-db-client build-prisma-client",
|
||||||
"build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
|
"build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang",
|
||||||
"build-geo": "node scripts/build-geo.js",
|
"build-geo": "node scripts/build-geo.js",
|
||||||
"build-db-schema": "prisma db pull",
|
"build-db-schema": "prisma db pull",
|
||||||
|
|
@ -80,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.133.0",
|
"@umami/react-zen": "^0.134.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.133.0
|
specifier: ^0.134.0
|
||||||
version: 0.133.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.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/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.133.0':
|
'@umami/react-zen@0.134.0':
|
||||||
resolution: {integrity: sha512-AAhtYdmLwVZ4i5lzcr5mylc5IQIirlxEL0bMRktlFHbX73wiBrTnk2RrYjmRhCe2KVRkCw2EF8ORXQ7GFrfAOg==}
|
resolution: {integrity: sha512-RBSD50mTw2YKY0Z73OSxVtjrMvIq3nGtWYtcZHPXl/4oYj3Ph0cKTKto14Jx2qs2kHm2DxcS3ND1FR1OrPEknw==}
|
||||||
|
|
||||||
'@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.133.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.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))':
|
||||||
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,7 +1,6 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Sidebar, SidebarHeader, SidebarSection, SidebarItem } from '@umami/react-zen';
|
import { Sidebar, SidebarHeader, SidebarSection, SidebarItem } from '@umami/react-zen';
|
||||||
import {
|
import {
|
||||||
Copy,
|
|
||||||
Globe,
|
Globe,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
|
@ -17,11 +16,6 @@ export function SideNav(props: any) {
|
||||||
const [isCollapsed] = useGlobalState('sidenav-collapsed');
|
const [isCollapsed] = useGlobalState('sidenav-collapsed');
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
|
||||||
label: formatMessage(labels.dashboard),
|
|
||||||
href: renderTeamUrl('/dashboard'),
|
|
||||||
icon: <Copy />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: formatMessage(labels.websites),
|
label: formatMessage(labels.websites),
|
||||||
href: renderTeamUrl('/websites'),
|
href: renderTeamUrl('/websites'),
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--base400);
|
|
||||||
background: var(--base50);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.domain {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--base700);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragActive {
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragActive:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
max-width: 360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active {
|
|
||||||
border-color: var(--base600);
|
|
||||||
box-shadow: 4px 4px 4px var(--base100);
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Button, Loading, Toggle, SearchField } from '@umami/react-zen';
|
|
||||||
import { firstBy } from 'thenby';
|
|
||||||
import { useDashboard, saveDashboard } from '@/store/dashboard';
|
|
||||||
import { useMessages, useWebsites } from '@/components/hooks';
|
|
||||||
import styles from './DashboardEdit.module.css';
|
|
||||||
|
|
||||||
const DRAG_ID = 'dashboard-website-ordering';
|
|
||||||
|
|
||||||
export function DashboardEdit({ teamId }: { teamId: string }) {
|
|
||||||
const settings = useDashboard();
|
|
||||||
const { websiteOrder, websiteActive, isEdited } = settings;
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const [order, setOrder] = useState(websiteOrder || []);
|
|
||||||
const [active, setActive] = useState(websiteActive || []);
|
|
||||||
const [edited, setEdited] = useState(isEdited);
|
|
||||||
const [websites, setWebsites] = useState([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const {
|
|
||||||
result,
|
|
||||||
query: { isLoading },
|
|
||||||
setParams,
|
|
||||||
} = useWebsites({ teamId });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (result?.data) {
|
|
||||||
setWebsites(prevWebsites => {
|
|
||||||
const newWebsites = [...prevWebsites, ...result.data];
|
|
||||||
if (newWebsites.length < result.count) {
|
|
||||||
setParams(prevParams => ({ ...prevParams, page: prevParams.page + 1 }));
|
|
||||||
}
|
|
||||||
return newWebsites;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
const ordered = useMemo(() => {
|
|
||||||
if (websites) {
|
|
||||||
return websites
|
|
||||||
.map((website: { id: any; name: string; domain: string }) => ({
|
|
||||||
...website,
|
|
||||||
order: order.indexOf(website.id),
|
|
||||||
}))
|
|
||||||
.sort(firstBy('order'));
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [websites, order]);
|
|
||||||
|
|
||||||
function handleWebsiteDrag({ destination, source }) {
|
|
||||||
if (!destination || destination.index === source.index) return;
|
|
||||||
|
|
||||||
const orderedWebsites = [...ordered];
|
|
||||||
const [removed] = orderedWebsites.splice(source.index, 1);
|
|
||||||
orderedWebsites.splice(destination.index, 0, removed);
|
|
||||||
|
|
||||||
setOrder(orderedWebsites.map(website => website?.id || 0));
|
|
||||||
setEdited(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleActiveWebsites(id: string) {
|
|
||||||
setActive(prevActive =>
|
|
||||||
prevActive.includes(id) ? prevActive.filter(a => a !== id) : [...prevActive, id],
|
|
||||||
);
|
|
||||||
setEdited(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSave() {
|
|
||||||
saveDashboard({
|
|
||||||
editing: false,
|
|
||||||
isEdited: edited,
|
|
||||||
websiteOrder: order,
|
|
||||||
websiteActive: active,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
saveDashboard({ editing: false, websiteOrder, websiteActive, isEdited });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReset() {
|
|
||||||
setOrder([]);
|
|
||||||
setActive([]);
|
|
||||||
setEdited(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.header}>
|
|
||||||
<SearchField className={styles.search} value={search} onSearch={setSearch} />
|
|
||||||
<div className={styles.buttons}>
|
|
||||||
<Button onClick={handleSave} variant="primary" size="sm">
|
|
||||||
{formatMessage(labels.save)}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCancel} size="sm">
|
|
||||||
{formatMessage(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleReset} size="sm">
|
|
||||||
{formatMessage(labels.reset)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.dragActive}>
|
|
||||||
<DragDropContext onDragEnd={handleWebsiteDrag}>
|
|
||||||
<Droppable droppableId={DRAG_ID}>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<div
|
|
||||||
{...provided.droppableProps}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
|
|
||||||
>
|
|
||||||
{ordered.map(({ id, name, domain }, index) => {
|
|
||||||
if (
|
|
||||||
search &&
|
|
||||||
!`${name.toLowerCase()}${domain.toLowerCase()}`.includes(search.toLowerCase())
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<div
|
|
||||||
ref={provided.innerRef}
|
|
||||||
className={classNames(styles.item, {
|
|
||||||
[styles.active]: snapshot.isDragging,
|
|
||||||
})}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
>
|
|
||||||
<div className={styles.text}>
|
|
||||||
<div className={styles.name}>{name}</div>
|
|
||||||
<div className={styles.domain}>{domain}</div>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={active.includes(id)}
|
|
||||||
onChange={() => handleActiveWebsites(id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Icon, Loading, Text } from '@umami/react-zen';
|
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
|
||||||
import { Pager } from '@/components/common/Pager';
|
|
||||||
import { WebsiteChartList } from '../websites/[websiteId]/WebsiteChartList';
|
|
||||||
import { DashboardSettingsButton } from '@/app/(main)/dashboard/DashboardSettingsButton';
|
|
||||||
import { DashboardEdit } from '@/app/(main)/dashboard/DashboardEdit';
|
|
||||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
|
||||||
import { useMessages, useNavigation, useWebsites } from '@/components/hooks';
|
|
||||||
import { Arrow } from '@/components/icons';
|
|
||||||
import { useDashboard } from '@/store/dashboard';
|
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { teamId, renderTeamUrl } = useNavigation();
|
|
||||||
const { showCharts, editing, isEdited } = useDashboard();
|
|
||||||
const pageSize = isEdited ? 200 : 10;
|
|
||||||
|
|
||||||
const { result, query, params, setParams } = useWebsites({ teamId }, { pageSize });
|
|
||||||
const { page } = params;
|
|
||||||
const hasData = !!result?.data?.length;
|
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setParams({ ...params, page });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (query.isLoading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section style={{ marginBottom: 60 }}>
|
|
||||||
<SectionHeader title={formatMessage(labels.dashboard)}>
|
|
||||||
{!editing && hasData && <DashboardSettingsButton />}
|
|
||||||
</SectionHeader>
|
|
||||||
{!hasData && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
|
||||||
<LinkButton href={renderTeamUrl('/settings')}>
|
|
||||||
<Icon>
|
|
||||||
<Arrow />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(messages.goToSettings)}</Text>
|
|
||||||
</LinkButton>
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
)}
|
|
||||||
{hasData && (
|
|
||||||
<>
|
|
||||||
{editing && <DashboardEdit teamId={teamId} />}
|
|
||||||
{!editing && (
|
|
||||||
<>
|
|
||||||
<WebsiteChartList
|
|
||||||
websites={result?.data as any}
|
|
||||||
showCharts={showCharts}
|
|
||||||
limit={pageSize}
|
|
||||||
/>
|
|
||||||
<Pager
|
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
count={result?.count}
|
|
||||||
onPageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
.buttonGroup {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Row, TooltipTrigger, Tooltip, Icon, Text, Button } from '@umami/react-zen';
|
|
||||||
import { BarChart, Edit } from '@/components/icons';
|
|
||||||
import { saveDashboard } from '@/store/dashboard';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
|
|
||||||
export function DashboardSettingsButton() {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const handleToggleCharts = () => {
|
|
||||||
saveDashboard(state => ({ showCharts: !state.showCharts }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
saveDashboard({ editing: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row gap="3">
|
|
||||||
<TooltipTrigger>
|
|
||||||
<Button onPress={handleToggleCharts}>
|
|
||||||
<Icon>
|
|
||||||
<BarChart />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip placement="bottom">{formatMessage(labels.toggleCharts)}</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<Button onPress={handleEdit}>
|
|
||||||
<Icon>
|
|
||||||
<Edit />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { DashboardPage } from './DashboardPage';
|
|
||||||
import { Metadata } from 'next';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
return <DashboardPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Dashboard',
|
|
||||||
};
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { Text, Icon } from '@umami/react-zen';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { firstBy } from 'thenby';
|
|
||||||
import { WebsiteChart } from './WebsiteChart';
|
|
||||||
import { useDashboard } from '@/store/dashboard';
|
|
||||||
import { WebsiteControls } from './WebsiteControls';
|
|
||||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
|
||||||
import { Arrow } from '@/components/icons';
|
|
||||||
|
|
||||||
export function WebsiteChartList({
|
|
||||||
websites,
|
|
||||||
showCharts,
|
|
||||||
limit,
|
|
||||||
}: {
|
|
||||||
websites: any[];
|
|
||||||
showCharts?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
}) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { websiteOrder, websiteActive } = useDashboard();
|
|
||||||
const { renderTeamUrl } = useNavigation();
|
|
||||||
|
|
||||||
const ordered = useMemo(() => {
|
|
||||||
return websites
|
|
||||||
.filter(website => (websiteActive.length ? websiteActive.includes(website.id) : true))
|
|
||||||
.map(website => ({ ...website, order: websiteOrder.indexOf(website.id) || 0 }))
|
|
||||||
.sort(firstBy('order'));
|
|
||||||
}, [websites, websiteOrder, websiteActive]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{ordered.map(({ id }: { id: string }, index) => {
|
|
||||||
return index < limit ? (
|
|
||||||
<div key={id}>
|
|
||||||
<WebsiteControls websiteId={id} showLinks={false}>
|
|
||||||
<LinkButton href={renderTeamUrl(`/websites/${id}`)} variant="primary">
|
|
||||||
<Text>{formatMessage(labels.viewDetails)}</Text>
|
|
||||||
<Icon>
|
|
||||||
<Icon>
|
|
||||||
<Arrow />
|
|
||||||
</Icon>
|
|
||||||
</Icon>
|
|
||||||
</LinkButton>
|
|
||||||
</WebsiteControls>
|
|
||||||
<WebsiteMetricsBar websiteId={id} showChange={true} />
|
|
||||||
{showCharts && <WebsiteChart websiteId={id} />}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'journeys',
|
id: 'journeys',
|
||||||
label: formatMessage(labels.journey),
|
label: formatMessage(labels.journeys),
|
||||||
icon: <Path />,
|
icon: <Path />,
|
||||||
path: '/journeys',
|
path: '/journeys',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Grid, Column, Row, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
|
import { Grid, Column, Row, Text, Icon, ProgressBar, Dialog, Box } from '@umami/react-zen';
|
||||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { File, Lightning, User } from '@/components/icons';
|
import { File, Lightning, User } from '@/components/icons';
|
||||||
|
|
@ -13,7 +13,7 @@ type FunnelResult = {
|
||||||
visitors: number;
|
visitors: number;
|
||||||
previous: number;
|
previous: number;
|
||||||
dropped: number;
|
dropped: number;
|
||||||
droppoff: number;
|
dropoff: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -53,25 +53,35 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
||||||
</Grid>
|
</Grid>
|
||||||
{data?.map(
|
{data?.map(
|
||||||
(
|
(
|
||||||
{ type, value, visitors, previous, dropped, remaining }: FunnelResult,
|
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
|
||||||
index: number,
|
index: number,
|
||||||
) => {
|
) => {
|
||||||
const isPage = type === 'page';
|
const isPage = type === 'page';
|
||||||
return (
|
return (
|
||||||
<Grid key={index} columns="auto 1fr" gap="6">
|
<Grid key={index} columns="auto 1fr" gap="6">
|
||||||
<Column>
|
<Column alignItems="center" position="relative">
|
||||||
<Row
|
<Row
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
backgroundColor="2"
|
backgroundColor="3"
|
||||||
width="40px"
|
width="40px"
|
||||||
height="40px"
|
height="40px"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
>
|
>
|
||||||
<Text weight="bold" size="4">
|
<Text weight="bold" size="3">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Text>
|
</Text>
|
||||||
</Row>
|
</Row>
|
||||||
|
{index > 0 && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
backgroundColor="3"
|
||||||
|
width="2px"
|
||||||
|
height="120px"
|
||||||
|
top="-100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Column>
|
</Column>
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
|
|
@ -87,12 +97,16 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<ChangeLabel value={-dropped}>{formatLongNumber(dropped)}</ChangeLabel>
|
<ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
|
||||||
|
{formatLongNumber(dropped)}
|
||||||
|
</ChangeLabel>
|
||||||
)}
|
)}
|
||||||
<Icon>
|
<Icon>
|
||||||
<User />
|
<User />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text title={visitors.toString()}>{formatLongNumber(visitors)}</Text>
|
<Text title={visitors.toString()} transform="lowercase">
|
||||||
|
{`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
|
||||||
|
</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap="6">
|
<Row alignItems="center" gap="6">
|
||||||
|
|
@ -102,9 +116,11 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
||||||
maxValue={previous || 1}
|
maxValue={previous || 1}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<Text weight="bold" size="7">
|
<Row minWidth="90px" justifyContent="end">
|
||||||
{Math.round(remaining * 100)}%
|
<Text weight="bold" size="7">
|
||||||
</Text>
|
{Math.round(remaining * 100)}%
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
99
src/app/(main)/websites/[websiteId]/journeys/Journey.tsx
Normal file
99
src/app/(main)/websites/[websiteId]/journeys/Journey.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
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 { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
import { JourneyEditForm } from './JourneyEditForm';
|
||||||
|
|
||||||
|
export interface JourneyProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
parameters: {
|
||||||
|
steps: string;
|
||||||
|
startStep: string;
|
||||||
|
endStep: string;
|
||||||
|
};
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GoalData = { num: number; total: number };
|
||||||
|
|
||||||
|
export function Journey({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
parameters,
|
||||||
|
websiteId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
}: JourneyProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
'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 { Panel } from '@/components/common/Panel';
|
||||||
|
|
||||||
|
export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const { result } = useReportsQuery({ websiteId, type: 'journey' });
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</LoadingPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/websites/[websiteId]/journeys/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/journeys/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { JourneysPage } from './JourneysPage';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <JourneysPage websiteId={websiteId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Journeys',
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
redirect('/dashboard');
|
redirect('/websites');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState, Key } from 'react';
|
import { useState, Key } from 'react';
|
||||||
import { Grid, Row, Column, Label, List, ListItem, Button, Heading } from '@umami/react-zen';
|
import { Grid, Row, Column, Label, List, ListItem, Button, Heading, Text } from '@umami/react-zen';
|
||||||
import { useFilters, useMessages } from '@/components/hooks';
|
import { useFilters, useMessages } from '@/components/hooks';
|
||||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
|
||||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
import { FilterRecord } from '@/components/common/FilterRecord';
|
||||||
|
|
||||||
export interface FilterEditFormProps {
|
export interface FilterEditFormProps {
|
||||||
|
|
@ -73,7 +72,7 @@ export function FilterEditForm({ data = [], onChange, onClose }: FilterEditFormP
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!filters.length && <EmptyPlaceholder message="No filters selected." />}
|
{!filters.length && <Text align="center">{formatMessage(labels.none)}</Text>}
|
||||||
</Column>
|
</Column>
|
||||||
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function TeamsButton({
|
||||||
const selectedKeys = new Set([teamId || user.id]);
|
const selectedKeys = new Set([teamId || user.id]);
|
||||||
|
|
||||||
const handleSelect = (id: Key) => {
|
const handleSelect = (id: Key) => {
|
||||||
router.push(id === user.id ? '/dashboard' : `/teams/${id}/dashboard`);
|
router.push(id === user.id ? '/websites' : `/teams/${id}/websites`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!result?.count) {
|
if (!result?.count) {
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@ export const labels = defineMessages({
|
||||||
defaultMessage: 'Track your goals for pageviews and events.',
|
defaultMessage: 'Track your goals for pageviews and events.',
|
||||||
},
|
},
|
||||||
journey: { id: 'label.journey', defaultMessage: 'Journey' },
|
journey: { id: 'label.journey', defaultMessage: 'Journey' },
|
||||||
|
journeys: { id: 'label.journeys', defaultMessage: 'Journeys' },
|
||||||
journeyDescription: {
|
journeyDescription: {
|
||||||
id: 'label.journey-description',
|
id: 'label.journey-description',
|
||||||
defaultMessage: 'Understand how users navigate through your website.',
|
defaultMessage: 'Understand how users navigate through your website.',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 12px;
|
font-size: var(--font-size);
|
||||||
padding: 0.1em 0.5em;
|
padding: 0.1em 0.5em;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: var(--base500);
|
color: var(--base500);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,15 @@ export const funnelReportSchema = z.object({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const journeyReportSchema = z.object({
|
||||||
|
type: z.literal('journey'),
|
||||||
|
parameters: z.object({
|
||||||
|
steps: z.coerce.number().positive(),
|
||||||
|
startStep: z.string().optional(),
|
||||||
|
endStep: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const reportBaseSchema = z.object({
|
export const reportBaseSchema = z.object({
|
||||||
websiteId: z.string().uuid(),
|
websiteId: z.string().uuid(),
|
||||||
type: reportTypeParam,
|
type: reportTypeParam,
|
||||||
|
|
@ -120,6 +129,7 @@ export const reportBaseSchema = z.object({
|
||||||
export const reportTypeSchema = z.discriminatedUnion('type', [
|
export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
goalReportSchema,
|
goalReportSchema,
|
||||||
funnelReportSchema,
|
funnelReportSchema,
|
||||||
|
journeyReportSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue