mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Breakdown report.
This commit is contained in:
parent
79ea9974b7
commit
e3cc19638c
21 changed files with 495 additions and 456 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
previewFeatures = ["driverAdapters"]
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
moduleFormat = "esm"
|
moduleFormat = "esm"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,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.136.0",
|
"@umami/react-zen": "^0.137.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",
|
||||||
|
|
|
||||||
490
pnpm-lock.yaml
generated
490
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Logo,
|
Logo,
|
||||||
Grid2X2,
|
Grid2X2,
|
||||||
Settings,
|
Settings,
|
||||||
|
LockKeyhole,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
||||||
|
|
||||||
|
|
@ -41,6 +42,11 @@ export function SideNav(props: any) {
|
||||||
href: renderTeamUrl('/settings'),
|
href: renderTeamUrl('/settings'),
|
||||||
icon: <Settings />,
|
icon: <Settings />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(labels.admin),
|
||||||
|
href: renderTeamUrl('/admin'),
|
||||||
|
icon: <LockKeyhole />,
|
||||||
|
},
|
||||||
].filter(n => n);
|
].filter(n => n);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ReportsNav } from './ReportsNav';
|
||||||
|
|
||||||
export function ReportsLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
export function ReportsLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Grid columns="200px 1fr" gap="6">
|
<Grid columns="180px 1fr" gap="6">
|
||||||
<Column>
|
<Column>
|
||||||
<ReportsNav websiteId={websiteId} />
|
<ReportsNav websiteId={websiteId} />
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Row, NavMenu, NavMenuItem, Icon, Text } from '@umami/react-zen';
|
import { Row, NavMenu, NavMenuItem, Icon, Text } from '@umami/react-zen';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { Funnel, Lightbulb, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
|
import { Funnel, Sheet, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function ReportsNav({ websiteId }: { websiteId: string }) {
|
export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||||
|
|
@ -32,6 +32,12 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||||
icon: <Magnet />,
|
icon: <Magnet />,
|
||||||
path: '/retention',
|
path: '/retention',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'breakdown',
|
||||||
|
label: formatMessage(labels.breakdown),
|
||||||
|
icon: <Sheet />,
|
||||||
|
path: '/breakdown',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'utm',
|
id: 'utm',
|
||||||
label: formatMessage(labels.utm),
|
label: formatMessage(labels.utm),
|
||||||
|
|
@ -50,12 +56,6 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
path: '/attribution',
|
path: '/attribution',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'insights',
|
|
||||||
label: formatMessage(labels.insights),
|
|
||||||
icon: <Lightbulb />,
|
|
||||||
path: '/insights',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'goals';
|
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'goals';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Grid, Column, Heading } from '@umami/react-zen';
|
import { Grid, Column } from '@umami/react-zen';
|
||||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
|
@ -125,8 +125,7 @@ export function Attribution({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel key={value}>
|
<Panel key={value} title={label}>
|
||||||
<Heading>{label}</Heading>
|
|
||||||
<Grid columns="1fr 1fr" gap>
|
<Grid columns="1fr 1fr" gap>
|
||||||
<ListTable
|
<ListTable
|
||||||
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||||
|
|
@ -142,25 +141,15 @@ export function Attribution({
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Grid gap>
|
<Panel title="UTM">
|
||||||
<Grid columns="1fr 1fr" gap>
|
<Grid columns="1fr 1fr" gap>
|
||||||
<Panel>
|
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
||||||
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
||||||
</Panel>
|
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
|
||||||
<Panel>
|
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
|
||||||
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
|
|
||||||
</Panel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||||
const [model, setModel] = useState('first-click');
|
const [model, setModel] = useState('first-click');
|
||||||
const [type, setType] = useState('page');
|
const [type, setType] = useState('page');
|
||||||
const [step, setStep] = useState('');
|
const [step, setStep] = useState('/');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
|
|
@ -44,6 +44,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||||
<SearchField
|
<SearchField
|
||||||
label={formatMessage(labels.conversionStep)}
|
label={formatMessage(labels.conversionStep)}
|
||||||
value={step}
|
value={step}
|
||||||
|
defaultValue={step}
|
||||||
onSearch={setStep}
|
onSearch={setStep}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Text, DataTable, DataColumn } from '@umami/react-zen';
|
||||||
|
import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
import { formatShortTime } from '@/lib/format';
|
||||||
|
|
||||||
|
export interface BreakdownProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
parameters: {
|
||||||
|
fields: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breakdown({ websiteId, parameters, startDate, endDate }: BreakdownProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { formatValue } = useFormat();
|
||||||
|
const { fields } = useFields();
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>(
|
||||||
|
'breakdown',
|
||||||
|
{
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters,
|
||||||
|
},
|
||||||
|
{ enabled: !!parameters.fields.length },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
||||||
|
<DataTable data={data}>
|
||||||
|
{parameters?.fields.map(field => {
|
||||||
|
return (
|
||||||
|
<DataColumn key={field} id={field} label={fields.find(f => f.name === field)?.label}>
|
||||||
|
{row => {
|
||||||
|
const value = formatValue(row[field], field);
|
||||||
|
return (
|
||||||
|
<Text truncate title={value}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
|
||||||
|
{row => row?.['views']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||||
|
{row => row?.['visits']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||||
|
{row => row?.['visitors']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
|
||||||
|
{row => {
|
||||||
|
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||||
|
return Math.round(+n) + '%';
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
|
||||||
|
{row => {
|
||||||
|
const n = (row?.['totaltime'] / row?.['visits']) * 100;
|
||||||
|
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
</DataTable>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Popover,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useDateRange, useMessages, useFields } from '@/components/hooks';
|
||||||
|
import { SquarePlus, Chevron } from '@/components/icons';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { Breakdown } from './Breakdown';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
|
||||||
|
export function BreakdownPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
const [fields, setFields] = useState([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
<Box>
|
||||||
|
<FieldsButton value={fields} onChange={setFields} />
|
||||||
|
</Box>
|
||||||
|
<Panel height="900px" overflow="auto" allowFullscreen>
|
||||||
|
<Breakdown
|
||||||
|
websiteId={websiteId}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
parameters={{ fields }}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldsButton = ({ value, onChange }) => {
|
||||||
|
const [selected, setSelected] = useState(value);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { fields } = useFields();
|
||||||
|
|
||||||
|
const handleChange = value => {
|
||||||
|
setSelected(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange?.(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSelected(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="quiet" onPress={() => setIsOpen(!isOpen)}>
|
||||||
|
<Icon>
|
||||||
|
<SquarePlus />
|
||||||
|
</Icon>
|
||||||
|
<Text>Fields</Text>
|
||||||
|
<Icon rotate={90}>
|
||||||
|
<Chevron />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Popover placement="bottom start" isOpen={isOpen}>
|
||||||
|
<Column width="300px" padding="2" border borderRadius shadow="3" backgroundColor gap>
|
||||||
|
<List value={selected} onChange={handleChange} selectionMode="multiple">
|
||||||
|
{fields.map(({ name, label }) => {
|
||||||
|
return (
|
||||||
|
<ListItem key={name} id={name}>
|
||||||
|
{label}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
<Grid columns="1fr 1fr" gap>
|
||||||
|
<Button onPress={handleClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
<Button onPress={handleApply} variant="primary">
|
||||||
|
{formatMessage(labels.apply)}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Column>
|
||||||
|
</Popover>
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { InsightsPage } from './InsightsPage';
|
import { BreakdownPage } from './BreakdownPage';
|
||||||
|
|
||||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
return <InsightsPage websiteId={websiteId} />;
|
return <BreakdownPage websiteId={websiteId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
@ -24,7 +24,7 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||||
<Grid columns="1fr 1fr" gap>
|
<Grid columns="1fr 1fr" gap>
|
||||||
{result?.data?.map((report: any) => (
|
{result?.data?.map((report: any) => (
|
||||||
<Panel key={report.id}>
|
<Panel key={report.id}>
|
||||||
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
<Goal {...report} startDate={startDate} endDate={endDate} />
|
||||||
</Panel>
|
</Panel>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
|
|
||||||
import { Empty } from '@/components/common/Empty';
|
|
||||||
import { Users } from '@/components/icons';
|
|
||||||
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
|
|
||||||
import { formatDate } from '@/lib/date';
|
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
|
|
||||||
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
|
|
||||||
|
|
||||||
export interface AttributionProps {
|
|
||||||
websiteId: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
days?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Insights({ websiteId, days = DAYS, startDate, endDate }: AttributionProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const { data, error, isLoading } = useResultQuery<any>('insights', {
|
|
||||||
websiteId,
|
|
||||||
dateRange: {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
days,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <Empty />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = data.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
|
|
||||||
const { date, visitors, day } = row;
|
|
||||||
if (day === 0) {
|
|
||||||
return arr.concat({
|
|
||||||
date,
|
|
||||||
visitors,
|
|
||||||
records: days
|
|
||||||
.reduce((arr, day) => {
|
|
||||||
arr[day] = data.find(x => x.date === date && x.day === day);
|
|
||||||
return arr;
|
|
||||||
}, [])
|
|
||||||
.filter(n => n),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const totalDays = rows.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
|
||||||
<Panel allowFullscreen height="900px">
|
|
||||||
<Column gap="1" width="100%" overflow="auto">
|
|
||||||
<Grid
|
|
||||||
columns="120px repeat(10, 100px)"
|
|
||||||
alignItems="center"
|
|
||||||
gap="1"
|
|
||||||
height="50px"
|
|
||||||
autoFlow="column"
|
|
||||||
>
|
|
||||||
<Column>
|
|
||||||
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
|
|
||||||
</Column>
|
|
||||||
{days.map(n => (
|
|
||||||
<Column key={n}>
|
|
||||||
<Text weight="bold" align="center" wrap="nowrap">
|
|
||||||
{formatMessage(labels.day)} {n}
|
|
||||||
</Text>
|
|
||||||
</Column>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
|
||||||
return (
|
|
||||||
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
|
||||||
<Column justifyContent="center" gap="1">
|
|
||||||
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>
|
|
||||||
<Users />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatLongNumber(visitors)}</Text>
|
|
||||||
</Row>
|
|
||||||
</Column>
|
|
||||||
{days.map(day => {
|
|
||||||
if (totalDays - rowIndex < day) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
|
||||||
return (
|
|
||||||
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Column>
|
|
||||||
</Panel>
|
|
||||||
</LoadingPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Cell = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
width="100px"
|
|
||||||
height="100px"
|
|
||||||
backgroundColor="2"
|
|
||||||
borderRadius
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Column } from '@umami/react-zen';
|
|
||||||
import { Insights } from './Insights';
|
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
|
||||||
import { useDateRange } from '@/components/hooks';
|
|
||||||
|
|
||||||
export function InsightsPage({ websiteId }: { websiteId: string }) {
|
|
||||||
const {
|
|
||||||
dateRange: { startDate, endDate },
|
|
||||||
} = useDateRange(websiteId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column gap>
|
|
||||||
<WebsiteControls websiteId={websiteId} />
|
|
||||||
<Insights websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getInsights } from '@/queries';
|
import { getBreakdown } from '@/queries';
|
||||||
import { reportResultSchema } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|
@ -14,27 +14,17 @@ export async function POST(request: Request) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
fields,
|
parameters: { fields },
|
||||||
filters,
|
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getInsights(websiteId, fields, {
|
const data = await getBreakdown(websiteId, fields, {
|
||||||
...convertFilters(filters),
|
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertFilters(filters: any[]) {
|
|
||||||
return filters.reduce((obj, filter) => {
|
|
||||||
obj[filter.name] = filter;
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
Button,
|
Button,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Heading,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { Maximize, Close } from '@/components/icons';
|
import { Maximize, Close } from '@/components/icons';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export interface PanelProps extends ColumnProps {
|
export interface PanelProps extends ColumnProps {
|
||||||
|
title?: string;
|
||||||
allowFullscreen?: boolean;
|
allowFullscreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +27,7 @@ const fullscreenStyles = {
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) {
|
export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -44,6 +46,7 @@ export function Panel({ allowFullscreen, style, children, ...props }: PanelProps
|
||||||
{...props}
|
{...props}
|
||||||
style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
|
style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
|
||||||
>
|
>
|
||||||
|
{title && <Heading>{title}</Heading>}
|
||||||
{allowFullscreen && (
|
{allowFullscreen && (
|
||||||
<Row justifyContent="flex-end" alignItems="center">
|
<Row justifyContent="flex-end" alignItems="center">
|
||||||
<TooltipTrigger delay={0} isDisabled={isFullscreen}>
|
<TooltipTrigger delay={0} isDisabled={isFullscreen}>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ export {
|
||||||
ChartPie,
|
ChartPie,
|
||||||
ChevronRight as Chevron,
|
ChevronRight as Chevron,
|
||||||
Clock,
|
Clock,
|
||||||
X as Close,
|
|
||||||
Copy,
|
Copy,
|
||||||
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -29,11 +29,15 @@ export {
|
||||||
RefreshCw as Refresh,
|
RefreshCw as Refresh,
|
||||||
Settings,
|
Settings,
|
||||||
Share,
|
Share,
|
||||||
|
Sheet,
|
||||||
Slash,
|
Slash,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
SquarePlus,
|
||||||
Sun,
|
Sun,
|
||||||
Trash,
|
Trash,
|
||||||
|
Upload,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
X as Close,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
export * from '@/components/svg';
|
export * from '@/components/svg';
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const labels = defineMessages({
|
||||||
user: { id: 'label.user', defaultMessage: 'User' },
|
user: { id: 'label.user', defaultMessage: 'User' },
|
||||||
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
|
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
|
||||||
manage: { id: 'label.manage', defaultMessage: 'Manage' },
|
manage: { id: 'label.manage', defaultMessage: 'Manage' },
|
||||||
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
|
admin: { id: 'label.admin', defaultMessage: 'Admin' },
|
||||||
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
|
||||||
details: { id: 'label.details', defaultMessage: 'Details' },
|
details: { id: 'label.details', defaultMessage: 'Details' },
|
||||||
website: { id: 'label.website', defaultMessage: 'Website' },
|
website: { id: 'label.website', defaultMessage: 'Website' },
|
||||||
|
|
@ -215,6 +215,7 @@ export const labels = defineMessages({
|
||||||
value: { id: 'label.value', defaultMessage: 'Value' },
|
value: { id: 'label.value', defaultMessage: 'Value' },
|
||||||
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
||||||
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
||||||
|
insight: { id: 'label.insight', defaultMessage: 'Insight' },
|
||||||
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
||||||
insightsDescription: {
|
insightsDescription: {
|
||||||
id: 'label.insights-description',
|
id: 'label.insights-description',
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,31 @@ export const urlOrPathParam = z.string().refine(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fieldsParam = z.enum([
|
||||||
|
'url',
|
||||||
|
'referrer',
|
||||||
|
'title',
|
||||||
|
'query',
|
||||||
|
'os',
|
||||||
|
'browser',
|
||||||
|
'device',
|
||||||
|
'country',
|
||||||
|
'region',
|
||||||
|
'city',
|
||||||
|
'tag',
|
||||||
|
'host',
|
||||||
|
'language',
|
||||||
|
]);
|
||||||
|
|
||||||
export const reportTypeParam = z.enum([
|
export const reportTypeParam = z.enum([
|
||||||
|
'attribution',
|
||||||
|
'breakdown',
|
||||||
'funnel',
|
'funnel',
|
||||||
'insight',
|
|
||||||
'retention',
|
|
||||||
'utm',
|
|
||||||
'goal',
|
'goal',
|
||||||
'journey',
|
'journey',
|
||||||
|
'retention',
|
||||||
'revenue',
|
'revenue',
|
||||||
'attribution',
|
'utm',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportParms = {
|
export const reportParms = {
|
||||||
|
|
@ -141,8 +157,11 @@ export const attributionReportSchema = z.object({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insightsReportSchema = z.object({
|
export const breakdownReportSchema = z.object({
|
||||||
type: z.literal('insights'),
|
type: z.literal('breakdown'),
|
||||||
|
parameters: z.object({
|
||||||
|
fields: z.array(fieldsParam),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reportBaseSchema = z.object({
|
export const reportBaseSchema = z.object({
|
||||||
|
|
@ -160,7 +179,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
utmReportSchema,
|
utmReportSchema,
|
||||||
revenueReportSchema,
|
revenueReportSchema,
|
||||||
attributionReportSchema,
|
attributionReportSchema,
|
||||||
insightsReportSchema,
|
breakdownReportSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export * from '@/queries/sql/events/saveEvent';
|
||||||
export * from '@/queries/sql/reports/getFunnel';
|
export * from '@/queries/sql/reports/getFunnel';
|
||||||
export * from '@/queries/sql/reports/getJourney';
|
export * from '@/queries/sql/reports/getJourney';
|
||||||
export * from '@/queries/sql/reports/getRetention';
|
export * from '@/queries/sql/reports/getRetention';
|
||||||
export * from '@/queries/sql/reports/getInsights';
|
export * from '@/queries/sql/reports/getBreakdown';
|
||||||
export * from '@/queries/sql/reports/getUTM';
|
export * from '@/queries/sql/reports/getUTM';
|
||||||
export * from '@/queries/sql/pageviews/getPageviewMetrics';
|
export * from '@/queries/sql/pageviews/getPageviewMetrics';
|
||||||
export * from '@/queries/sql/pageviews/getPageviewStats';
|
export * from '@/queries/sql/pageviews/getPageviewStats';
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import clickhouse from '@/lib/clickhouse';
|
||||||
import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
|
||||||
import { QueryFilters } from '@/lib/types';
|
import { QueryFilters } from '@/lib/types';
|
||||||
|
|
||||||
export async function getInsights(
|
export async function getBreakdown(
|
||||||
...args: [websiteId: string, fields: { name: string; type?: string }[], filters: QueryFilters]
|
...args: [websiteId: string, fields: string[], filters: QueryFilters]
|
||||||
) {
|
) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
|
@ -15,7 +15,7 @@ export async function getInsights(
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
fields: { name: string; type?: string }[],
|
fields: string[],
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +31,7 @@ async function relationalQuery(
|
||||||
eventType: EVENT_TYPE.pageView,
|
eventType: EVENT_TYPE.pageView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
joinSession: !!fields.find(({ name }) => SESSION_COLUMNS.includes(name)),
|
joinSession: !!fields.find(name => SESSION_COLUMNS.includes(name)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -71,7 +71,7 @@ async function relationalQuery(
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
fields: { name: string; type?: string }[],
|
fields: string[],
|
||||||
filters: QueryFilters,
|
filters: QueryFilters,
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
|
|
@ -118,10 +118,10 @@ async function clickhouseQuery(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFields(fields: { name: any }[]) {
|
function parseFields(fields: string[]) {
|
||||||
return fields.map(({ name }) => `${FILTER_COLUMNS[name]} as "${name}"`).join(',');
|
return fields.map(name => `${FILTER_COLUMNS[name]} as "${name}"`).join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFieldsByName(fields: { name: any }[]) {
|
function parseFieldsByName(fields: string[]) {
|
||||||
return `${fields.map(({ name }) => name).join(',')}`;
|
return `${fields.map(name => name).join(',')}`;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue