Added journey page. Removed dashboard.

This commit is contained in:
Mike Cao 2025-06-06 19:44:09 -07:00
parent 3847e32f39
commit cee05d762c
24 changed files with 328 additions and 422 deletions

View file

@ -130,7 +130,7 @@ const redirects = [
},
{
source: '/teams/:id',
destination: '/teams/:id/dashboard',
destination: '/teams/:id/websites',
permanent: true,
},
{

View file

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

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

View file

@ -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'),

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
.buttonGroup {
display: flex;
place-items: center;
gap: 10px;
}

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
},
{
id: 'journeys',
label: formatMessage(labels.journey),
label: formatMessage(labels.journeys),
icon: <Path />,
path: '/journeys',
},

View file

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

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

View file

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

View file

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

View file

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

View 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',
};

View file

@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
export default function RootPage() {
redirect('/dashboard');
redirect('/websites');
}

View file

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

View file

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

View file

@ -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.',

View file

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

View file

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