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