New website nav.

This commit is contained in:
Mike Cao 2025-07-15 03:35:18 -07:00
parent 5e6799a715
commit a534c51b5e
38 changed files with 190 additions and 159 deletions

View file

@ -0,0 +1,142 @@
import { Grid, Column } from '@umami/react-zen';
import { useMessages, useResultQuery } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { SectionHeader } from '@/components/common/SectionHeader';
import { formatLongNumber } from '@/lib/format';
import { percentFilter } from '@/lib/filters';
export interface AttributionProps {
websiteId: string;
startDate: Date;
endDate: Date;
model: string;
type: string;
step: string;
currency?: string;
}
export function Attribution({
websiteId,
startDate,
endDate,
model,
type,
step,
currency,
}: AttributionProps) {
const { data, error, isLoading } = useResultQuery<any>('attribution', {
websiteId,
startDate,
endDate,
model,
type,
step,
});
const { formatMessage, labels } = useMessages();
const { pageviews, visitors, visits } = data?.total || {};
const metrics = data
? [
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: pageviews,
label: formatMessage(labels.views),
formatValue: formatLongNumber,
},
]
: [];
function UTMTable({ data = [], title }: { data: any; title: string }) {
return (
<ListTable
title={title}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={percentFilter(
data.map(({ name, value }) => ({
x: name,
y: Number(value),
})),
)}
/>
);
}
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Column gap>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
return (
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
);
})}
</MetricsBar>
<SectionHeader title={formatMessage(labels.sources)} />
<Grid columns="1fr 1fr" gap>
<Panel>
<ListTable
title={formatMessage(labels.referrer)}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={percentFilter(
data?.['referrer']?.map(({ name, value }) => ({
x: name,
y: Number(value),
})),
)}
/>
</Panel>
<Panel>
<ListTable
title={formatMessage(labels.paidAds)}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={percentFilter(
data?.['paidAds']?.map(({ name, value }) => ({
x: name,
y: Number(value),
})),
)}
/>
</Panel>
</Grid>
<SectionHeader title="UTM" />
<Grid columns="1fr 1fr" gap>
<Panel>
<UTMTable data={data?.['utm_source']} title={formatMessage(labels.sources)} />
</Panel>
<Panel>
<UTMTable data={data?.['utm_medium']} title={formatMessage(labels.medium)} />
</Panel>
<Panel>
<UTMTable data={data?.['utm_cmapaign']} title={formatMessage(labels.campaigns)} />
</Panel>
<Panel>
<UTMTable data={data?.['utm_content']} title={formatMessage(labels.content)} />
</Panel>
<Panel>
<UTMTable data={data?.['utm_term']} title={formatMessage(labels.terms)} />
</Panel>
</Grid>
</Column>
)}
</LoadingPanel>
);
}

View file

@ -0,0 +1,63 @@
'use client';
import { useState } from 'react';
import { Column, Grid, Select, ListItem, SearchField } from '@umami/react-zen';
import { Attribution } from './Attribution';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange, useMessages } from '@/components/hooks';
export function AttributionPage({ websiteId }: { websiteId: string }) {
const [model, setModel] = useState('first-click');
const [type, setType] = useState('page');
const [step, setStep] = useState('/');
const { formatMessage, labels } = useMessages();
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap="6">
<WebsiteControls websiteId={websiteId} />
<Grid columns="1fr 1fr 1fr" gap>
<Column>
<Select
label={formatMessage(labels.model)}
value={model}
defaultValue={model}
onChange={setModel}
>
<ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem>
<ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem>
</Select>
</Column>
<Column>
<Select
label={formatMessage(labels.type)}
value={type}
defaultValue={type}
onChange={setType}
>
<ListItem id="page">{formatMessage(labels.page)}</ListItem>
<ListItem id="event">{formatMessage(labels.event)}</ListItem>
</Select>
</Column>
<Column>
<SearchField
label={formatMessage(labels.conversionStep)}
value={step}
defaultValue={step}
onSearch={setStep}
delay={1000}
/>
</Column>
</Grid>
<Attribution
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
model={model}
type={type}
step={step}
/>
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { AttributionPage } from './AttributionPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <AttributionPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Attribution',
};

View file

@ -0,0 +1,69 @@
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;
selectedFields: string[];
}
export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { fields } = useFields();
const { data, error, isLoading } = useResultQuery<any>(
'breakdown',
{
websiteId,
startDate,
endDate,
fields: selectedFields,
},
{ enabled: !!selectedFields.length },
);
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<DataTable data={data}>
{selectedFields.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="visitors" label={formatMessage(labels.visitors)} align="end">
{row => row?.['visitors']?.toLocaleString()}
</DataColumn>
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
{row => row?.['visits']?.toLocaleString()}
</DataColumn>
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
{row => row?.['views']?.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>
);
}

View file

@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import { Button, Column, Box, Text, Icon, DialogTrigger, Modal, Dialog } from '@umami/react-zen';
import { useDateRange, useMessages } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { Panel } from '@/components/common/Panel';
import { Breakdown } from './Breakdown';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
export function BreakdownPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const [fields, setFields] = useState(['path']);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<FieldsButton value={fields} onChange={setFields} />
<Panel height="900px" overflow="auto" allowFullscreen>
<Breakdown
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
selectedFields={fields}
/>
</Panel>
</Column>
);
}
const FieldsButton = ({ value, onChange }) => {
const { formatMessage, labels } = useMessages();
return (
<Box>
<DialogTrigger>
<Button>
<Icon>
<ListCheck />
</Icon>
<Text>Fields</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.fields)} style={{ width: 400 }}>
{({ close }) => (
<FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />
)}
</Dialog>
</Modal>
</DialogTrigger>
</Box>
);
};

View file

@ -0,0 +1,46 @@
import { Column, List, ListItem, Grid, Button } from '@umami/react-zen';
import { useFields, useMessages } from '@/components/hooks';
import { useState } from 'react';
export function FieldSelectForm({
selectedFields = [],
onChange,
onClose,
}: {
selectedFields?: string[];
onChange: (values: string[]) => void;
onClose?: () => void;
}) {
const [selected, setSelected] = useState(selectedFields);
const { formatMessage, labels } = useMessages();
const { fields } = useFields();
const handleChange = (value: string[]) => {
setSelected(value);
};
const handleApply = () => {
onChange?.(selected);
onClose();
};
return (
<Column width="300px" gap="6">
<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={onClose}>{formatMessage(labels.cancel)}</Button>
<Button onPress={handleApply} variant="primary">
{formatMessage(labels.apply)}
</Button>
</Grid>
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { BreakdownPage } from './BreakdownPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <BreakdownPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Insights',
};

View file

@ -0,0 +1,133 @@
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';
import { formatLongNumber } from '@/lib/format';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { FunnelEditForm } from './FunnelEditForm';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
type FunnelResult = {
type: string;
value: string;
visitors: number;
previous: number;
dropped: number;
dropoff: number;
remaining: number;
};
export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery(type, {
websiteId,
...parameters,
});
return (
<LoadingPanel data={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.funnel)}
variant="modal"
style={{ minHeight: 300, minWidth: 400 }}
>
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
</Grid>
{data?.map(
(
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
index: number,
) => {
const isPage = type === 'page';
return (
<Grid key={index} columns="auto 1fr" gap="6">
<Column alignItems="center" position="relative">
<Row
borderRadius="full"
backgroundColor="3"
width="40px"
height="40px"
justifyContent="center"
alignItems="center"
style={{ zIndex: 1 }}
>
<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>
<Text color="muted">
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
</Text>
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
</Row>
<Row alignItems="center" justifyContent="space-between" gap>
<Row alignItems="center" gap>
<Icon>{type === 'page' ? <File /> : <Lightning />}</Icon>
<Text>{value}</Text>
</Row>
<Row alignItems="center" gap>
{index > 0 && (
<ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
{formatLongNumber(dropped)}
</ChangeLabel>
)}
<Icon>
<User />
</Icon>
<Text title={visitors.toString()} transform="lowercase">
{`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
</Text>
</Row>
</Row>
<Row alignItems="center" gap="6">
<ProgressBar
value={visitors || 0}
minValue={0}
maxValue={previous || 1}
style={{ width: '100%' }}
/>
<Row minWidth="90px" justifyContent="end">
<Text weight="bold" size="7">
{Math.round(remaining * 100)}%
</Text>
</Row>
</Row>
</Column>
</Grid>
);
},
)}
</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 { FunnelEditForm } from './FunnelEditForm';
import { Plus } from '@/components/icons';
export function FunnelAddButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.funnel)}</Text>
</Button>
<Modal>
<Dialog
variant="modal"
title={formatMessage(labels.funnel)}
style={{ minHeight: 375, minWidth: 600 }}
>
{({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,158 @@
import {
Form,
FormField,
FormFieldArray,
TextField,
Grid,
FormController,
FormButtons,
FormSubmitButton,
Button,
RadioGroup,
Radio,
Text,
Icon,
Row,
Loading,
} from '@umami/react-zen';
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
import { File, Lightning, Close, Plus } from '@/components/icons';
const FUNNEL_STEPS_MAX = 8;
export function FunnelEditForm({
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: 'funnel', websiteId, parameters },
{
onSuccess: async () => {
touch('reports:funnel');
onSave?.();
onClose?.();
},
},
);
};
if (id && !data) {
return <Loading position="page" />;
}
const defaultValues = {
name: data?.name || '',
window: data?.parameters?.window || 60,
steps: data?.parameters?.steps || [{ type: 'page', value: '/' }],
};
return (
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoFocus />
</FormField>
<FormField
name="window"
label={formatMessage(labels.window)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormFieldArray name="steps" label={formatMessage(labels.steps)}>
{({ fields, append, remove, control }) => {
return (
<Grid gap>
{fields.map((field: { id: string; type: string; value: string }, index: number) => {
return (
<Row key={field.id} alignItems="center" justifyContent="space-between" gap>
<FormController control={control} name={`steps.${index}.type`}>
{({ field }) => {
return (
<RadioGroup
orientation="horizontal"
variant="box"
value={field.value}
onChange={field.onChange}
>
<Grid columns="1fr 1fr" flexGrow={1} gap>
<Radio id="page" value="page">
<Icon>
<File />
</Icon>
<Text>{formatMessage(labels.page)}</Text>
</Radio>
<Radio id="event" value="event">
<Icon>
<Lightning />
</Icon>
<Text>{formatMessage(labels.event)}</Text>
</Radio>
</Grid>
</RadioGroup>
);
}}
</FormController>
<FormController control={control} name={`steps.${index}.value`}>
{({ field }) => {
return (
<TextField
value={field.value}
onChange={field.onChange}
defaultValue={field.value}
style={{ flexGrow: 1 }}
/>
);
}}
</FormController>
<Button variant="quiet" onPress={() => remove(index)}>
<Icon size="sm">
<Close />
</Icon>
</Button>
</Row>
);
})}
<Row>
<Button
onPress={() => append({ type: 'page', value: '/' })}
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.add)}</Text>
</Button>
</Row>
</Grid>
);
}}
</FormFieldArray>
<FormButtons>
<Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -0,0 +1,36 @@
'use client';
import { Grid, Column } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Grid gap>
{data['data']?.map((report: any) => (
<Panel key={report.id}>
<Funnel {...report} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>
)}
</LoadingPanel>
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { FunnelsPage } from './FunnelsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <FunnelsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Funnels',
};

View file

@ -0,0 +1,98 @@
import { Grid, Row, Column, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { useMessages, useResultQuery } from '@/components/hooks';
import { File, Lightning, User } from '@/components/icons';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { formatLongNumber } from '@/lib/format';
import { GoalEditForm } from './GoalEditForm';
export interface GoalProps {
id: string;
name: string;
type: string;
parameters: {
name: string;
type: string;
value: string;
};
websiteId: string;
startDate: Date;
endDate: Date;
}
export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages();
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId,
startDate,
endDate,
...parameters,
});
const isPage = parameters?.type === 'page';
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
{data && (
<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: 300, minWidth: 400 }}
>
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
</Grid>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
</Text>
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
</Row>
<Row alignItems="center" justifyContent="space-between" gap>
<Row alignItems="center" gap>
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
<Text>{parameters.value}</Text>
</Row>
<Row alignItems="center" gap>
<Icon>
<User />
</Icon>
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
data?.num,
)} / ${formatLongNumber(data?.total)}`}</Text>
</Row>
</Row>
<Row alignItems="center" gap="6">
<ProgressBar
value={data?.num || 0}
minValue={0}
maxValue={data?.total || 1}
style={{ width: '100%' }}
/>
<Text weight="bold" size="7">
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
</Text>
</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 { GoalEditForm } from './GoalEditForm';
import { Plus } from '@/components/icons';
export function GoalAddButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.goal)}</Text>
</Button>
<Modal>
<Dialog
variant="modal"
title={formatMessage(labels.goal)}
style={{ minHeight: 375, minWidth: 400 }}
>
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,114 @@
import {
Form,
FormField,
TextField,
Grid,
FormButtons,
FormSubmitButton,
Button,
RadioGroup,
Radio,
Text,
Icon,
Loading,
} from '@umami/react-zen';
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons';
export function GoalEditForm({
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: 'goal', websiteId, parameters },
{
onSuccess: async () => {
if (id) touch(`report:${id}`);
touch('reports:goal');
onSave?.();
onClose?.();
},
},
);
};
if (id && !data) {
return <Loading position="page" />;
}
const defaultValues = {
name: data?.name || '',
type: data?.parameters?.type || 'page',
value: data?.parameters?.value || '',
};
return (
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
{({ watch }) => {
const watchType = watch('type');
return (
<>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoFocus />
</FormField>
<FormField
name="type"
label={formatMessage(labels.type)}
rules={{ required: formatMessage(labels.required) }}
>
<RadioGroup orientation="horizontal" variant="box">
<Grid columns="1fr 1fr" flexGrow={1} gap>
<Radio value="page">
<Icon>
<File />
</Icon>
<Text>{formatMessage(labels.page)}</Text>
</Radio>
<Radio value="event">
<Icon>
<Lightning />
</Icon>
<Text>{formatMessage(labels.event)}</Text>
</Radio>
</Grid>
</RadioGroup>
</FormField>
<FormField
name="value"
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormButtons>
<Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</>
);
}}
</Form>
);
}

View file

@ -0,0 +1,36 @@
'use client';
import { Grid, Column } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
export function GoalsPage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Grid columns="1fr 1fr" gap>
{data['data'].map((report: any) => (
<Panel key={report.id}>
<Goal {...report} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>
)}
</LoadingPanel>
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { GoalsPage } from './GoalsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <GoalsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Goals',
};

View file

@ -0,0 +1,267 @@
.container {
width: 100%;
height: 100%;
position: relative;
--journey-line-color: var(--base-color-6);
--journey-active-color: var(--primary-color);
--journey-faded-color: var(--base-color-3);
}
.view {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
overflow: auto;
gap: 100px;
padding-right: 20px;
}
.header {
margin-bottom: 20px;
}
.stats {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
}
.visitors {
font-weight: 600;
font-size: 16px;
text-transform: lowercase;
}
.dropoff {
font-weight: 600;
color: var(--font-color-muted);
background: var(--base-color-2);
padding: 4px 8px;
border-radius: 5px;
}
.num {
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
width: 50px;
height: 50px;
font-size: 16px;
font-weight: 700;
color: var(--base-color-1);
background: var(--base-color-12);
z-index: 1;
margin: 0 auto 20px;
}
.column {
display: flex;
flex-direction: column;
}
.nodes {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.wrapper {
padding-bottom: 10px;
}
.node {
position: relative;
cursor: pointer;
padding: 10px 20px;
background: var(--base-color-3);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
width: 300px;
max-width: 300px;
height: 60px;
max-height: 60px;
}
.node:hover:not(.selected) {
background: var(--base-color-4);
}
.node.selected {
color: var(--base-color-1);
background: var(--base-color-12);
}
.node.active {
color: var(--primary-font-color);
background: var(--primary-color);
}
.node.selected .count {
color: var(--base-color-1);
background: var(--base-color-12);
}
.node.selected.active .count {
color: var(--primary-font-color);
background: var(--primary-color);
}
.name {
max-width: 200px;
}
.line {
position: absolute;
bottom: 0;
left: -100px;
width: 100px;
pointer-events: none;
}
.line.up {
bottom: 0;
}
.line.down {
top: 0;
}
.segment {
position: absolute;
}
.start {
left: 0;
width: 50px;
height: 30px;
border: 0;
}
.mid {
top: 60px;
width: 50px;
border-right: 3px solid var(--journey-line-color);
}
.end {
width: 50px;
height: 30px;
border: 0;
}
.up .start {
top: 30px;
border-top-right-radius: 100%;
border-top: 3px solid var(--journey-line-color);
border-right: 3px solid var(--journey-line-color);
}
.up .end {
width: 52px;
bottom: 27px;
right: 0;
border-bottom-left-radius: 100%;
border-bottom: 3px solid var(--journey-line-color);
border-left: 3px solid var(--journey-line-color);
}
.down .start {
bottom: 27px;
border-bottom-right-radius: 100%;
border-bottom: 3px solid var(--journey-line-color);
border-right: 3px solid var(--journey-line-color);
}
.down .end {
width: 52px;
top: 30px;
right: 0;
border-top-left-radius: 100%;
border-top: 3px solid var(--journey-line-color);
border-left: 3px solid var(--journey-line-color);
}
.flat .start {
left: 0;
top: 30px;
border-top: 3px solid var(--journey-line-color);
}
.flat .end {
right: 0;
top: 30px;
border-top: 3px solid var(--journey-line-color);
}
.start:before,
.end:before {
content: '';
position: absolute;
border-radius: 100%;
border: 3px solid var(--journey-line-color);
background: var(--base-color-1);
width: 14px;
height: 14px;
}
.line:not(.active) .start:before,
.line:not(.active) .end:before {
display: none;
}
.up .start:before {
left: -8px;
top: -8px;
}
.up .end:before {
right: -8px;
bottom: -8px;
}
.down .start:before {
left: -8px;
bottom: -8px;
}
.down .end:before {
right: -8px;
top: -8px;
}
.flat .start:before {
left: -8px;
top: -8px;
}
.flat .end:before {
right: -8px;
top: -8px;
}
.line.active .segment,
.line.active .segment:before {
border-color: var(--journey-active-color);
z-index: 1;
}
.column.active .line:not(.active) .segment {
border-color: var(--journey-faded-color);
}
.column.active .line:not(.active) .segment:before {
display: none;
}

View file

@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import { TooltipTrigger, Tooltip, Focusable, Icon, Text, Row, Column } from '@umami/react-zen';
import { firstBy } from 'thenby';
import classNames from 'classnames';
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons';
import { objectToArray } from '@/lib/data';
import { formatLongNumber } from '@/lib/format';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import styles from './Journey.module.css';
const NODE_HEIGHT = 60;
const NODE_GAP = 10;
const LINE_WIDTH = 3;
export interface JourneyProps {
websiteId: string;
startDate: Date;
endDate: Date;
steps: number;
startStep?: string;
endStep?: string;
}
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('journey', {
websiteId,
steps,
startStep,
endStep,
});
useEscapeKey(() => setSelectedNode(null));
const columns = useMemo(() => {
if (!data) {
return [];
}
const selectedPaths = selectedNode?.paths ?? [];
const activePaths = activeNode?.paths ?? [];
const columns = [];
for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
const nodes = {};
data.forEach(({ items, count }: any, nodeIndex: any) => {
const name = items[columnIndex];
if (name) {
const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
if (!nodes[name]) {
const paths = data.filter(({ items }) => items[columnIndex] === name);
nodes[name] = {
name,
count,
totalCount: count,
nodeIndex,
columnIndex,
selected,
active,
paths,
pathMap: paths.map(({ items, count }) => ({
[`${columnIndex}:${items.join(':')}`]: count,
})),
};
} else {
nodes[name].totalCount += count;
}
}
});
columns.push({
nodes: objectToArray(nodes).sort(firstBy('total', -1)),
});
}
columns.forEach((column, columnIndex) => {
const nodes = column.nodes.map(
(
currentNode: { totalCount: number; name: string; selected: boolean },
currentNodeIndex: any,
) => {
const previousNodes = columns[columnIndex - 1]?.nodes;
let selectedCount = previousNodes ? 0 : currentNode.totalCount;
let activeCount = selectedCount;
const lines =
previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
const fromCount = selectedNode?.paths.reduce((sum, path) => {
if (
previousNode.name === path.items[columnIndex - 1] &&
currentNode.name === path.items[columnIndex]
) {
sum += path.count;
}
return sum;
}, 0);
if (currentNode.selected && previousNode.selected && fromCount) {
arr.push([previousNodeIndex, currentNodeIndex]);
selectedCount += fromCount;
if (previousNode.active) {
activeCount += fromCount;
}
}
return arr;
}, []) || [];
return { ...currentNode, selectedCount, activeCount, lines };
},
);
const visitorCount = nodes.reduce(
(sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
if (!selectedNode) {
sum += totalCount;
} else if (!activeNode && selectedNode && selected) {
sum += selectedCount;
} else if (activeNode && active) {
sum += activeCount;
}
return sum;
},
0,
);
const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
const dropOff =
previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
Object.assign(column, { nodes, visitorCount, dropOff });
});
return columns;
}, [data, selectedNode, activeNode]);
const handleClick = (name: string, columnIndex: number, paths: any[]) => {
if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
setSelectedNode({ name, columnIndex, paths });
} else {
setSelectedNode(null);
}
setActiveNode(null);
};
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
<div className={styles.container}>
<div className={styles.view}>
{columns.map(({ visitorCount, nodes }, columnIndex) => {
return (
<div
key={columnIndex}
className={classNames(styles.column, {
[styles.selected]: selectedNode,
[styles.active]: activeNode,
})}
>
<div className={styles.header}>
<div className={styles.num}>{columnIndex + 1}</div>
<div className={styles.stats}>
<div className={styles.visitors} title={visitorCount}>
{formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
</div>
</div>
</div>
<div className={styles.nodes}>
{nodes.map(
({
name,
totalCount,
selected,
active,
paths,
activeCount,
selectedCount,
lines,
}) => {
const nodeCount = selected
? active
? activeCount
: selectedCount
: totalCount;
const remaining =
columnIndex > 0
? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
: 0;
const dropped = 100 - remaining;
return (
<div
key={name}
className={styles.wrapper}
onMouseEnter={() =>
selected && setActiveNode({ name, columnIndex, paths })
}
onMouseLeave={() => selected && setActiveNode(null)}
>
<div
className={classNames(styles.node, {
[styles.selected]: selected,
[styles.active]: active,
})}
onClick={() => handleClick(name, columnIndex, paths)}
>
<Row alignItems="center" className={styles.name} title={name} gap>
<Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
<Text truncate>{name}</Text>
</Row>
<div className={styles.count} title={nodeCount}>
<TooltipTrigger
delay={0}
isDisabled={columnIndex === 0 || (selectedNode && !selected)}
>
<Focusable>
<div>{formatLongNumber(nodeCount)}</div>
</Focusable>
<Tooltip placement="top" offset={20} showArrow>
<Text transform="lowercase" color="ruby">
{`${dropped}% ${formatMessage(labels.dropoff)}`}
</Text>
<Column>
<Text transform="lowercase">
{`${remaining}% ${formatMessage(labels.conversion)}`}
</Text>
</Column>
</Tooltip>
</TooltipTrigger>
</div>
{columnIndex < columns.length &&
lines.map(([fromIndex, nodeIndex], i) => {
const height =
(Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
NODE_GAP;
const midHeight =
(Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
NODE_GAP +
LINE_WIDTH;
const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
return (
<div
key={`${fromIndex}${nodeIndex}${i}`}
className={classNames(styles.line, {
[styles.active]:
active &&
activeNode?.paths.find(
(path: { items: any[] }) =>
path.items[columnIndex] === name &&
path.items[columnIndex - 1] === nodeName,
),
[styles.up]: fromIndex < nodeIndex,
[styles.down]: fromIndex > nodeIndex,
[styles.flat]: fromIndex === nodeIndex,
})}
style={{ height }}
>
<div className={classNames(styles.segment, styles.start)} />
<div
className={classNames(styles.segment, styles.mid)}
style={{
height: midHeight,
}}
/>
<div className={classNames(styles.segment, styles.end)} />
</div>
);
})}
</div>
</div>
);
},
)}
</div>
</div>
);
})}
</div>
</div>
</LoadingPanel>
);
}

View file

@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { ListItem, Select, Column, Grid, SearchField } from '@umami/react-zen';
import { useDateRange, useMessages } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { Journey } from './Journey';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
const DEFAULT_STEP = 3;
export function JourneysPage({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState('');
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Grid columns="repeat(3, 1fr)" gap>
<Select
items={JOURNEY_STEPS}
label={formatMessage(labels.steps)}
value={steps}
defaultValue={steps}
onChange={setSteps}
>
{JOURNEY_STEPS.map(step => (
<ListItem key={step} id={step}>
{step}
</ListItem>
))}
</Select>
<Column>
<SearchField
label={formatMessage(labels.startStep)}
value={startStep}
onSearch={setStartStep}
delay={1000}
/>
</Column>
<Column>
<SearchField
label={formatMessage(labels.endStep)}
value={endStep}
onSearch={setEndStep}
delay={1000}
/>
</Column>
</Grid>
<Panel height="900px" allowFullscreen>
<Journey
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
steps={steps}
startStep={startStep}
endStep={endStep}
/>
</Panel>
</Column>
);
}

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

@ -0,0 +1,3 @@
import GoalsPage from './goals/page';
export default GoalsPage;

View file

@ -0,0 +1,119 @@
import { ReactNode } from 'react';
import { Grid, Row, Column, Text, Icon } from '@umami/react-zen';
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 RetentionProps {
websiteId: string;
startDate: Date;
endDate: Date;
days?: number[];
}
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { data, error, isLoading } = useResultQuery('retention', {
websiteId,
startDate,
endDate,
});
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: { date: any; day: number }) => x.date === date && x.day === day,
);
return arr;
}, [])
.filter(n => n),
});
}
return arr;
}, []) || [];
const totalDays = rows.length;
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<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" align="center">
{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>
);
};

View file

@ -0,0 +1,22 @@
'use client';
import { Column } from '@umami/react-zen';
import { Retention } from './Retention';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange } from '@/components/hooks';
import { endOfMonth, startOfMonth } from 'date-fns';
export function RetentionPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate },
} = useDateRange(websiteId, { ignoreOffset: true });
const monthStartDate = startOfMonth(startDate);
const monthEndDate = endOfMonth(startDate);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
<Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { RetentionPage } from './RetentionPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <RetentionPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Retention',
};

View file

@ -0,0 +1,152 @@
import { useState } from 'react';
import { Grid, Row, Text } from '@umami/react-zen';
import classNames from 'classnames';
import { colord } from 'colord';
import { BarChart } from '@/components/charts/BarChart';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { useCallback, useMemo } from 'react';
import { Panel } from '@/components/common/Panel';
import { Column } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { getMinimumUnit } from '@/lib/date';
import { CurrencySelect } from '@/components/input/CurrencySelect';
export interface RevenueProps {
websiteId: string;
startDate: Date;
endDate: Date;
}
export function Revenue({ websiteId, startDate, endDate }: RevenueProps) {
const [currency, setCurrency] = useState('USD');
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { countryNames } = useCountryNames(locale);
const unit = getMinimumUnit(startDate, endDate);
const { data, error, isLoading } = useResultQuery<any>('revenue', {
websiteId,
startDate,
endDate,
currency,
});
const renderCountryName = useCallback(
({ x: code }) => (
<Row className={classNames(locale)} gap>
<TypeIcon type="country" value={code} />
<Text>{countryNames[code] || formatMessage(labels.unknown)}</Text>
</Row>
),
[countryNames, locale],
);
const chartData: any = useMemo(() => {
if (!data) return [];
const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
obj[x] = [];
}
obj[x].push({ x: t, y });
return obj;
}, {});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, startDate, endDate, unit]);
const metrics = useMemo(() => {
if (!data) return [];
const { sum, count, unique_count } = data.total;
return [
{
value: sum,
label: formatMessage(labels.total),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count ? sum / count : 0,
label: formatMessage(labels.average),
formatValue: n => formatLongCurrency(n, currency),
},
{
value: count,
label: formatMessage(labels.transactions),
formatValue: formatLongNumber,
},
{
value: unique_count,
label: formatMessage(labels.uniqueCustomers),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return (
<Column gap>
<Grid columns="280px" gap>
<CurrencySelect value={currency} onChange={setCurrency} />
</Grid>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Column gap>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
return (
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
);
})}
</MetricsBar>
<Panel>
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
stacked={true}
currency={currency}
renderXLabel={renderDateLabels(unit, locale)}
height="400px"
/>
</Panel>
<Panel>
<ListTable
title={formatMessage(labels.country)}
metric={formatMessage(labels.revenue)}
data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
x: name,
y: value,
z: (value / data?.total.sum) * 100,
}))}
currency={currency}
renderLabel={renderCountryName}
/>
</Panel>
</Column>
)}
</LoadingPanel>
</Column>
);
}

View file

@ -0,0 +1,18 @@
'use client';
import { Column } from '@umami/react-zen';
import { Revenue } from './Revenue';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange } from '@/components/hooks';
export function RevenuePage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} allowFilter={false} />
<Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} />
</Column>
);
}

View file

@ -0,0 +1,21 @@
import { DataColumn, DataTable } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { formatLongCurrency } from '@/lib/format';
export function RevenueTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
return (
<DataTable data={data}>
<DataColumn id="currency" label={formatMessage(labels.currency)} align="end" />
<DataColumn id="total" label={formatMessage(labels.total)} align="end">
{(row: any) => formatLongCurrency(row.sum, row.currency)}
</DataColumn>
<DataColumn id="average" label={formatMessage(labels.average)} align="end">
{(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
</DataColumn>
<DataColumn id="count" label={formatMessage(labels.transactions)} align="end" />
<DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" />
</DataTable>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { RevenuePage } from './RevenuePage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <RevenuePage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Revenue',
};

View file

@ -0,0 +1,80 @@
import { Grid, Column, Heading, Text } from '@umami/react-zen';
import { firstBy } from 'thenby';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
import { useResultQuery } from '@/components/hooks';
import { PieChart } from '@/components/charts/PieChart';
import { ListTable } from '@/components/metrics/ListTable';
import { useMessages } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
export interface UTMProps {
websiteId: string;
startDate: Date;
endDate: Date;
}
export function UTM({ websiteId, startDate, endDate }: UTMProps) {
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('utm', {
websiteId,
startDate,
endDate,
});
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Column gap>
{UTM_PARAMS.map(param => {
const items = toArray(data?.[param]);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<Panel key={param}>
<Grid columns="1fr 1fr">
<Column>
<Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({
x: name,
y: value,
z: (value / total) * 100,
}))}
/>
</Column>
<Column>
<PieChart type="doughnut" chartData={chartData} />
</Column>
</Grid>
</Panel>
);
})}
</Column>
)}
</LoadingPanel>
);
}
function toArray(data: Record<string, number> = {}) {
return Object.keys(data)
.map(key => {
return { name: key, value: data[key] };
})
.sort(firstBy('value', -1));
}

View file

@ -0,0 +1,18 @@
'use client';
import { Column } from '@umami/react-zen';
import { useDateRange } from '@/components/hooks';
import { UTM } from './UTM';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function UTMPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={false} />
<UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { UTMPage } from './UTMPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <UTMPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'UTM Parameters',
};