mirror of
https://github.com/umami-software/umami.git
synced 2026-02-24 14:35:35 +01:00
New website nav.
This commit is contained in:
parent
5e6799a715
commit
a534c51b5e
38 changed files with 190 additions and 159 deletions
133
src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
Normal file
133
src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue