More work on reports. Added Funnel page.

This commit is contained in:
Mike Cao 2025-06-05 22:19:35 -07:00
parent 5159dd470f
commit 3847e32f39
59 changed files with 1815 additions and 2370 deletions

View file

@ -59,7 +59,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
},
{
id: 'funnel',
label: formatMessage(labels.funnel),
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: '/funnels',
},

View file

@ -0,0 +1,117 @@
import { Grid, Column, Row, Text, Icon, ProgressBar, Dialog } 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;
droppoff: number;
remaining: number;
};
export function Funnel({ id, name, type, parameters, websiteId, startDate, endDate }) {
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>(type, {
websiteId,
dateRange: {
startDate,
endDate,
},
parameters,
});
return (
<LoadingPanel isEmpty={!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">
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
</Grid>
{data?.map(
(
{ type, value, visitors, previous, dropped, remaining }: FunnelResult,
index: number,
) => {
const isPage = type === 'page';
return (
<Grid key={index} columns="auto 1fr" gap="6">
<Column>
<Row
borderRadius="full"
backgroundColor="2"
width="40px"
height="40px"
justifyContent="center"
alignItems="center"
>
<Text weight="bold" size="4">
{index + 1}
</Text>
</Row>
</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}>{formatLongNumber(dropped)}</ChangeLabel>
)}
<Icon>
<User />
</Icon>
<Text title={visitors.toString()}>{formatLongNumber(visitors)}</Text>
</Row>
</Row>
<Row alignItems="center" gap="6">
<ProgressBar
value={visitors || 0}
minValue={0}
maxValue={previous || 1}
style={{ width: '100%' }}
/>
<Text weight="bold" size="7">
{Math.round(remaining * 100)}%
</Text>
</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,38 @@
'use client';
import { Grid, Loading } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton';
import { WebsiteControls } from '../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 { result } = useReportsQuery({ websiteId, type: 'funnel' });
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
if (!result) {
return <Loading position="page" />;
}
return (
<>
<WebsiteControls websiteId={websiteId} />
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
<Grid gap>
{result?.data?.map((report: any) => (
<Panel key={report.id}>
<Funnel {...report} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>
</LoadingPanel>
</>
);
}

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

@ -1,26 +1,10 @@
import { useState } from 'react';
import {
Grid,
Row,
Column,
Text,
Icon,
Button,
MenuTrigger,
Menu,
MenuItem,
Popover,
ProgressBar,
Dialog,
Modal,
AlertDialog,
} from '@umami/react-zen';
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 { Edit, More, Trash, File, Lightning, User } from '@/components/icons';
import { File, Lightning, User } from '@/components/icons';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { formatLongNumber } from '@/lib/format';
import { GoalAddForm } from '@/app/(main)/websites/[websiteId]/goals/GoalAddForm';
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
import { GoalEditForm } from './GoalEditForm';
export interface GoalProps {
id: string;
@ -41,12 +25,12 @@ 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 } = useResultQuery<GoalData>(type, {
...parameters,
websiteId,
dateRange: {
startDate,
endDate,
},
parameters,
});
const isPage = parameters?.type === 'page';
@ -62,7 +46,19 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Row>
</Column>
<Column>
<ActionsButton id={id} name={name} websiteId={websiteId} />
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog
title={formatMessage(labels.goal)}
variant="modal"
style={{ minHeight: 375, minWidth: 400 }}
>
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
</Grid>
<Row alignItems="center" justifyContent="space-between" gap>
@ -85,7 +81,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
)} / ${formatLongNumber(data?.total)}`}</Text>
</Row>
</Row>
<Row alignItems="center" gap>
<Row alignItems="center" gap="6">
<ProgressBar
value={data?.num || 0}
minValue={0}
@ -100,88 +96,3 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</LoadingPanel>
);
}
const ActionsButton = ({
id,
name,
websiteId,
}: {
id: string;
name: string;
websiteId: string;
}) => {
const { formatMessage, labels, messages } = useMessages();
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const { mutate, touch } = useDeleteQuery(`/reports/${id}`);
const handleAction = (id: any) => {
if (id === 'edit') {
setShowEdit(true);
} else if (id === 'delete') {
setShowDelete(true);
}
};
const handleClose = () => {
setShowEdit(false);
setShowDelete(false);
};
const handleDelete = async () => {
mutate(null, {
onSuccess: async () => {
touch(`goals`);
setShowDelete(false);
},
});
};
return (
<>
<MenuTrigger>
<Button variant="quiet">
<Icon>
<More />
</Icon>
</Button>
<Popover placement="bottom">
<Menu onAction={handleAction}>
<MenuItem id="edit">
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</MenuItem>
<MenuItem id="delete">
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
<Modal isOpen={showEdit || showDelete} isDismissable={true}>
{showEdit && (
<Dialog
title={formatMessage(labels.goal)}
variant="modal"
style={{ minHeight: 375, minWidth: 400 }}
>
<GoalAddForm id={id} websiteId={websiteId} onClose={handleClose} />
</Dialog>
)}
{showDelete && (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleDelete}
onCancel={handleClose}
>
{formatMessage(messages.confirmDelete, { target: name })}
</AlertDialog>
)}
</Modal>
</>
);
};

View file

@ -1,6 +1,6 @@
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { GoalAddForm } from './GoalAddForm';
import { GoalEditForm } from './GoalEditForm';
import { Plus } from '@/components/icons';
export function GoalAddButton({ websiteId }: { websiteId: string }) {
@ -12,15 +12,15 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addGoal)}</Text>
<Text>{formatMessage(labels.goal)}</Text>
</Button>
<Modal>
<Dialog
variant="modal"
title={formatMessage(labels.addGoal)}
title={formatMessage(labels.goal)}
style={{ minHeight: 375, minWidth: 400 }}
>
{({ close }) => <GoalAddForm websiteId={websiteId} onClose={close} />}
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>

View file

@ -10,17 +10,12 @@ import {
Radio,
Text,
Icon,
Loading,
} from '@umami/react-zen';
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons';
const defaultValues = {
name: '',
type: 'page',
value: '',
};
export function GoalAddForm({
export function GoalEditForm({
id,
websiteId,
onSave,
@ -36,36 +31,46 @@ export function GoalAddForm({
const { post, useMutation } = useApi();
const { data } = useReportQuery(id);
const { mutate, error, isPending } = useMutation({
mutationFn: (params: any) => post(`/websites/${websiteId}/goals`, params),
mutationFn: (params: any) => post(`/reports${id ? `/${id}` : ''}`, params),
});
const handleSubmit = async (data: any) => {
const handleSubmit = async ({ name, ...parameters }) => {
mutate(
{ id, ...data },
{ ...data, id, name, type: 'goal', websiteId, parameters },
{
onSuccess: async () => {
if (id) touch(`report:${id}`);
touch('reports:goal');
onSave?.();
onClose?.();
touch('goals');
},
},
);
};
if (id && !data) {
return null;
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={data?.parameters || defaultValues}
>
<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 />
</FormField>
<FormField
name="type"
label={formatMessage(labels.type)}
@ -88,13 +93,6 @@ export function GoalAddForm({
</Grid>
</RadioGroup>
</FormField>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormField
name="value"
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
@ -106,7 +104,7 @@ export function GoalAddForm({
<Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton>{formatMessage(id ? labels.save : labels.add)}</FormSubmitButton>
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</>
);

View file

@ -4,12 +4,12 @@ import { SectionHeader } from '@/components/common/SectionHeader';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';
import { WebsiteControls } from '../WebsiteControls';
import { useDateRange, useGoalsQuery } from '@/components/hooks';
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 { result } = useGoalsQuery({ websiteId });
const { result } = useReportsQuery({ websiteId, type: 'goal' });
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
@ -26,9 +26,9 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
<Grid columns="1fr 1fr" gap>
{result?.data?.map((goal: any) => (
<Panel key={goal.id}>
<Goal {...goal} reportId={goal.id} startDate={startDate} endDate={endDate} />
{result?.data?.map((report: any) => (
<Panel key={report.id}>
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>