mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +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',
|
||||
destination: '/teams/:id/dashboard',
|
||||
destination: '/teams/:id/websites',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@
|
|||
"start-server": "node server.js",
|
||||
"build-app": "next build",
|
||||
"build-icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
|
||||
"build-components": "rollup -c rollup.components.config.mjs",
|
||||
"build-tracker": "rollup -c rollup.tracker.config.mjs",
|
||||
"build-db": "npm-run-all copy-db-files build-db-client",
|
||||
"build-components": "rollup -c rollup.components.config.js",
|
||||
"build-tracker": "rollup -c rollup.tracker.config.js",
|
||||
"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-geo": "node scripts/build-geo.js",
|
||||
"build-db-schema": "prisma db pull",
|
||||
|
|
@ -80,7 +81,7 @@
|
|||
"@react-spring/web": "^9.7.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
"@umami/react-zen": "^0.133.0",
|
||||
"@umami/react-zen": "^0.134.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.133.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))
|
||||
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))
|
||||
'@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.133.0':
|
||||
resolution: {integrity: sha512-AAhtYdmLwVZ4i5lzcr5mylc5IQIirlxEL0bMRktlFHbX73wiBrTnk2RrYjmRhCe2KVRkCw2EF8ORXQ7GFrfAOg==}
|
||||
'@umami/react-zen@0.134.0':
|
||||
resolution: {integrity: sha512-RBSD50mTw2YKY0Z73OSxVtjrMvIq3nGtWYtcZHPXl/4oYj3Ph0cKTKto14Jx2qs2kHm2DxcS3ND1FR1OrPEknw==}
|
||||
|
||||
'@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.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:
|
||||
'@fontsource/jetbrains-mono': 5.2.5
|
||||
'@internationalized/date': 3.8.2
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import Link from 'next/link';
|
||||
import { Sidebar, SidebarHeader, SidebarSection, SidebarItem } from '@umami/react-zen';
|
||||
import {
|
||||
Copy,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Link as LinkIcon,
|
||||
|
|
@ -17,11 +16,6 @@ export function SideNav(props: any) {
|
|||
const [isCollapsed] = useGlobalState('sidenav-collapsed');
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: formatMessage(labels.dashboard),
|
||||
href: renderTeamUrl('/dashboard'),
|
||||
icon: <Copy />,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.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',
|
||||
label: formatMessage(labels.journey),
|
||||
label: formatMessage(labels.journeys),
|
||||
icon: <Path />,
|
||||
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 { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { File, Lightning, User } from '@/components/icons';
|
||||
|
|
@ -13,7 +13,7 @@ type FunnelResult = {
|
|||
visitors: number;
|
||||
previous: number;
|
||||
dropped: number;
|
||||
droppoff: number;
|
||||
dropoff: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
|
|
@ -53,25 +53,35 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
|||
</Grid>
|
||||
{data?.map(
|
||||
(
|
||||
{ type, value, visitors, previous, dropped, remaining }: FunnelResult,
|
||||
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
|
||||
index: number,
|
||||
) => {
|
||||
const isPage = type === 'page';
|
||||
return (
|
||||
<Grid key={index} columns="auto 1fr" gap="6">
|
||||
<Column>
|
||||
<Column alignItems="center" position="relative">
|
||||
<Row
|
||||
borderRadius="full"
|
||||
backgroundColor="2"
|
||||
backgroundColor="3"
|
||||
width="40px"
|
||||
height="40px"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<Text weight="bold" size="4">
|
||||
<Text weight="bold" size="3">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</Row>
|
||||
{index > 0 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
backgroundColor="3"
|
||||
width="2px"
|
||||
height="120px"
|
||||
top="-100%"
|
||||
/>
|
||||
)}
|
||||
</Column>
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
|
|
@ -87,12 +97,16 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
|||
</Row>
|
||||
<Row alignItems="center" gap>
|
||||
{index > 0 && (
|
||||
<ChangeLabel value={-dropped}>{formatLongNumber(dropped)}</ChangeLabel>
|
||||
<ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
|
||||
{formatLongNumber(dropped)}
|
||||
</ChangeLabel>
|
||||
)}
|
||||
<Icon>
|
||||
<User />
|
||||
</Icon>
|
||||
<Text title={visitors.toString()}>{formatLongNumber(visitors)}</Text>
|
||||
<Text title={visitors.toString()} transform="lowercase">
|
||||
{`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
|
||||
</Text>
|
||||
</Row>
|
||||
</Row>
|
||||
<Row alignItems="center" gap="6">
|
||||
|
|
@ -102,9 +116,11 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
|||
maxValue={previous || 1}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Text weight="bold" size="7">
|
||||
{Math.round(remaining * 100)}%
|
||||
</Text>
|
||||
<Row minWidth="90px" justifyContent="end">
|
||||
<Text weight="bold" size="7">
|
||||
{Math.round(remaining * 100)}%
|
||||
</Text>
|
||||
</Row>
|
||||
</Row>
|
||||
</Column>
|
||||
</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';
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard');
|
||||
redirect('/websites');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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 { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
||||
|
||||
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>
|
||||
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function TeamsButton({
|
|||
const selectedKeys = new Set([teamId || user.id]);
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ export const labels = defineMessages({
|
|||
defaultMessage: 'Track your goals for pageviews and events.',
|
||||
},
|
||||
journey: { id: 'label.journey', defaultMessage: 'Journey' },
|
||||
journeys: { id: 'label.journeys', defaultMessage: 'Journeys' },
|
||||
journeyDescription: {
|
||||
id: 'label.journey-description',
|
||||
defaultMessage: 'Understand how users navigate through your website.',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size);
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 5px;
|
||||
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({
|
||||
websiteId: z.string().uuid(),
|
||||
type: reportTypeParam,
|
||||
|
|
@ -120,6 +129,7 @@ export const reportBaseSchema = z.object({
|
|||
export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||
goalReportSchema,
|
||||
funnelReportSchema,
|
||||
journeyReportSchema,
|
||||
]);
|
||||
|
||||
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue