mirror of
https://github.com/umami-software/umami.git
synced 2026-02-13 00:55:37 +01:00
Updated Funnel report component.
This commit is contained in:
parent
2832ff9622
commit
f81f0839c6
7 changed files with 169 additions and 116 deletions
|
|
@ -1,3 +1,84 @@
|
||||||
.loading {
|
.chart {
|
||||||
height: 300px;
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 100%;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--base800);
|
||||||
|
background: var(--base100);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
column-gap: 30px;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 25px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background-color: var(--base100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
background: var(--base900);
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--base700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--base50);
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
background-color: var(--base100);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--base300);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,57 @@
|
||||||
import { JSX, useCallback, useContext, useMemo } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Loading, StatusLight } from 'react-basics';
|
import classNames from 'classnames';
|
||||||
import { useMessages, useTheme } from 'components/hooks';
|
import { useMessages } from 'components/hooks';
|
||||||
import BarChart from 'components/metrics/BarChart';
|
|
||||||
import { formatLongNumber } from 'lib/format';
|
|
||||||
import { ReportContext } from '../[reportId]/Report';
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
import styles from './FunnelChart.module.css';
|
import styles from './FunnelChart.module.css';
|
||||||
|
import { formatLongNumber } from 'lib/format';
|
||||||
|
|
||||||
export interface FunnelChartProps {
|
export interface FunnelChartProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FunnelChart({ className, isLoading }: FunnelChartProps) {
|
export function FunnelChart({ className }: FunnelChartProps) {
|
||||||
const { report } = useContext(ReportContext);
|
const { report } = useContext(ReportContext);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { colors } = useTheme();
|
|
||||||
|
|
||||||
const { parameters, data } = report || {};
|
const { data } = report || {};
|
||||||
|
|
||||||
const renderXLabel = useCallback(
|
|
||||||
(label: string, index: number) => {
|
|
||||||
return parameters.urls[index];
|
|
||||||
},
|
|
||||||
[parameters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTooltipPopup = useCallback(
|
|
||||||
(
|
|
||||||
setTooltipPopup: (arg0: JSX.Element) => void,
|
|
||||||
model: { tooltip: { opacity: any; labelColors: any; dataPoints: any } },
|
|
||||||
) => {
|
|
||||||
const { opacity, labelColors, dataPoints } = model.tooltip;
|
|
||||||
|
|
||||||
if (!dataPoints?.length || !opacity) {
|
|
||||||
setTooltipPopup(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTooltipPopup(
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
{formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
|
||||||
{formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)}
|
|
||||||
</StatusLight>
|
|
||||||
</div>
|
|
||||||
</>,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: formatMessage(labels.uniqueVisitors),
|
|
||||||
data: data,
|
|
||||||
borderWidth: 1,
|
|
||||||
...colors.chart.visitors,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [data, colors, formatMessage, labels]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading icon="dots" className={styles.loading} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BarChart
|
<div className={classNames(styles.chart, className)}>
|
||||||
className={className}
|
{data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => {
|
||||||
datasets={datasets}
|
return (
|
||||||
unit="day"
|
<div key={url} className={styles.step}>
|
||||||
isLoading={isLoading}
|
<div className={styles.num}>{index + 1}</div>
|
||||||
renderXLabel={renderXLabel}
|
<div className={styles.card}>
|
||||||
renderTooltipPopup={renderTooltipPopup}
|
<div className={styles.header}>
|
||||||
XAxisType="category"
|
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span>
|
||||||
/>
|
<span className={styles.item}>{url}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.track}>
|
||||||
|
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div>
|
||||||
|
<b>{formatLongNumber(visitors)}</b>
|
||||||
|
<span> {formatMessage(labels.visitors)}</span>
|
||||||
|
<span> ({(remaining * 100).toFixed(2)}%)</span>
|
||||||
|
</div>
|
||||||
|
{dropoff > 0 && (
|
||||||
|
<div>
|
||||||
|
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
|
||||||
|
{(dropoff * 100).toFixed(2)}%)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import FunnelChart from './FunnelChart';
|
import FunnelChart from './FunnelChart';
|
||||||
import FunnelTable from './FunnelTable';
|
|
||||||
import FunnelParameters from './FunnelParameters';
|
import FunnelParameters from './FunnelParameters';
|
||||||
import Report from '../[reportId]/Report';
|
import Report from '../[reportId]/Report';
|
||||||
import ReportHeader from '../[reportId]/ReportHeader';
|
import ReportHeader from '../[reportId]/ReportHeader';
|
||||||
|
|
@ -22,7 +21,6 @@ export default function FunnelReport({ reportId }: { reportId?: string }) {
|
||||||
</ReportMenu>
|
</ReportMenu>
|
||||||
<ReportBody>
|
<ReportBody>
|
||||||
<FunnelChart />
|
<FunnelChart />
|
||||||
<FunnelTable />
|
|
||||||
</ReportBody>
|
</ReportBody>
|
||||||
</Report>
|
</Report>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import ListTable from 'components/metrics/ListTable';
|
|
||||||
import { useMessages } from 'components/hooks';
|
|
||||||
import { ReportContext } from '../[reportId]/Report';
|
|
||||||
|
|
||||||
export function FunnelTable() {
|
|
||||||
const { report } = useContext(ReportContext);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
return (
|
|
||||||
<ListTable
|
|
||||||
data={report?.data}
|
|
||||||
title={formatMessage(labels.url)}
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
showPercentage={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FunnelTable;
|
|
||||||
|
|
@ -209,6 +209,18 @@ export const labels = defineMessages({
|
||||||
select: { id: 'label.select', defaultMessage: 'Select' },
|
select: { id: 'label.select', defaultMessage: 'Select' },
|
||||||
myAccount: { id: 'label.my-account', defaultMessage: 'My account' },
|
myAccount: { id: 'label.my-account', defaultMessage: 'My account' },
|
||||||
transfer: { id: 'label.transfer', defaultMessage: 'Transfer' },
|
transfer: { id: 'label.transfer', defaultMessage: 'Transfer' },
|
||||||
|
viewedPage: {
|
||||||
|
id: 'message.viewed-page',
|
||||||
|
defaultMessage: 'Viewed page',
|
||||||
|
},
|
||||||
|
triggeredEvent: {
|
||||||
|
id: 'message.triggered-event',
|
||||||
|
defaultMessage: 'Triggered event',
|
||||||
|
},
|
||||||
|
visitorsDroppedOff: {
|
||||||
|
id: 'message.visitors-dropped-off',
|
||||||
|
defaultMessage: 'Visitors droppped off',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,25 @@ import clickhouse from 'lib/clickhouse';
|
||||||
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
||||||
import prisma from 'lib/prisma';
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
|
const formatResults = (urls: string[]) => (results: unknown) => {
|
||||||
|
return urls.map((url: string, i: number) => {
|
||||||
|
const visitors = Number(results[i]?.count) || 0;
|
||||||
|
const previous = Number(results[i - 1]?.count) || 0;
|
||||||
|
const dropped = previous > 0 ? previous - visitors : 0;
|
||||||
|
const dropoff = 1 - visitors / previous;
|
||||||
|
const remaining = visitors / Number(results[0].count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
visitors,
|
||||||
|
previous,
|
||||||
|
dropped,
|
||||||
|
dropoff,
|
||||||
|
remaining,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export async function getFunnel(
|
export async function getFunnel(
|
||||||
...args: [
|
...args: [
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
|
|
@ -29,9 +48,9 @@ async function relationalQuery(
|
||||||
},
|
},
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
x: string;
|
url: string;
|
||||||
y: number;
|
visitors: number;
|
||||||
z: number;
|
dropoff: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const { windowMinutes, startDate, endDate, urls } = criteria;
|
const { windowMinutes, startDate, endDate, urls } = criteria;
|
||||||
|
|
@ -98,13 +117,7 @@ async function relationalQuery(
|
||||||
endDate,
|
endDate,
|
||||||
...urls,
|
...urls,
|
||||||
},
|
},
|
||||||
).then(results => {
|
).then(formatResults(urls));
|
||||||
return urls.map((a, i) => ({
|
|
||||||
x: a,
|
|
||||||
y: results[i]?.count || 0,
|
|
||||||
z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
|
|
@ -117,9 +130,9 @@ async function clickhouseQuery(
|
||||||
},
|
},
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
x: string;
|
url: string;
|
||||||
y: number;
|
visitors: number;
|
||||||
z: number;
|
dropoff: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const { windowMinutes, startDate, endDate, urls } = criteria;
|
const { windowMinutes, startDate, endDate, urls } = criteria;
|
||||||
|
|
@ -198,11 +211,5 @@ async function clickhouseQuery(
|
||||||
endDate,
|
endDate,
|
||||||
...urlParams,
|
...urlParams,
|
||||||
},
|
},
|
||||||
).then(results => {
|
).then(formatResults(urls));
|
||||||
return urls.map((a, i) => ({
|
|
||||||
x: a,
|
|
||||||
y: Number(results[i]?.count) || 0,
|
|
||||||
z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,27 +20,27 @@ const initialState = {
|
||||||
|
|
||||||
const store = create(() => ({ ...initialState }));
|
const store = create(() => ({ ...initialState }));
|
||||||
|
|
||||||
export function setTheme(theme) {
|
export function setTheme(theme: string) {
|
||||||
store.setState({ theme });
|
store.setState({ theme });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLocale(locale) {
|
export function setLocale(locale: string) {
|
||||||
store.setState({ locale });
|
store.setState({ locale });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setShareToken(shareToken) {
|
export function setShareToken(shareToken: string) {
|
||||||
store.setState({ shareToken });
|
store.setState({ shareToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUser(user) {
|
export function setUser(user: object) {
|
||||||
store.setState({ user });
|
store.setState({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setConfig(config) {
|
export function setConfig(config: object) {
|
||||||
store.setState({ config });
|
store.setState({ config });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDateRange(dateRange) {
|
export function setDateRange(dateRange: object) {
|
||||||
store.setState({ dateRange });
|
store.setState({ dateRange });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue