Converted UTM report to a view.

This commit is contained in:
Mike Cao 2025-05-20 21:25:06 -07:00
parent 06f76dda13
commit d0d11225f4
24 changed files with 1815 additions and 1568 deletions

View file

@ -0,0 +1,40 @@
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import {
Button,
Icon,
Icons,
Modal,
Dialog,
DialogTrigger,
Text,
useToast,
} from '@umami/react-zen';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-website-add" variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addBoard)}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,64 @@
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
import { useApi } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';
import { useMessages } from '@/components/hooks';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/websites', { ...data, teamId }),
});
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
data-test="input-name"
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.domain)}
data-test="input-domain"
name="domain"
rules={{
required: formatMessage(labels.required),
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
}}
>
<TextField autoComplete="off" />
</FormField>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -1,14 +1,21 @@
'use client';
import { Column } from '@umami/react-zen';
import Link from 'next/link';
import { PageHeader } from '@/components/common/PageHeader';
import { PageBody } from '@/components/common/PageBody';
import { BoardAddButton } from './BoardAddButton';
export function BoardsPage() {
return (
<Column>
<PageHeader title="My Boards" />
<Link href="/teams/3a97e34a-7f9d-4de2-8754-ed81714b528d/boards/86d4095c-a2a8-4fc8-9521-103e858e2b41">
Board 1
</Link>
</Column>
<PageBody>
<Column>
<PageHeader title="My Boards">
<BoardAddButton />
</PageHeader>
<Link href="/teams/3a97e34a-7f9d-4de2-8754-ed81714b528d/boards/86d4095c-a2a8-4fc8-9521-103e858e2b41">
Board 1
</Link>
</Column>
</PageBody>
);
}

View file

@ -11,8 +11,8 @@ export function WebsiteControls({
showFilter?: boolean;
}) {
return (
<Column marginBottom="6" gap="3">
<Row alignItems="center" justifyContent="space-between" gap="3" paddingY="3">
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
<Row alignItems="center" gap="3">
<WebsiteDateFilter websiteId={websiteId} />

View file

@ -7,6 +7,7 @@ import { WebsiteExpandedView } from './WebsiteExpandedView';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { WebsiteTableView } from './WebsiteTableView';
import { WebsiteCompareTables } from './WebsiteCompareTables';
import { WebsiteControls } from './WebsiteControls';
export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
const {
@ -15,6 +16,7 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Panel>
<WebsiteMetricsBar websiteId={websiteId} showFilter={true} showChange={true} />
</Panel>

View file

@ -3,6 +3,7 @@ import { PageHeader } from '@/components/common/PageHeader';
import { useWebsite } from '@/components/hooks/useWebsite';
import { Lucide } from '@/components/icons';
import { Favicon } from '@/components/common/Favicon';
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader() {
const website = useWebsite();
@ -10,6 +11,7 @@ export function WebsiteHeader() {
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />}>
<Row alignItems="center" gap>
<ActiveUsers websiteId={website.id} />
<Button>
<Icon>
<Lucide.Share />

View file

@ -2,7 +2,6 @@
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { WebsiteProvider } from './WebsiteProvider';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { PageBody } from '@/components/common/PageBody';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
@ -11,16 +10,15 @@ export function WebsiteLayout({ websiteId, children }: { websiteId: string; chil
return (
<WebsiteProvider websiteId={websiteId}>
<PageBody>
<WebsiteHeader />
<Grid columns="auto 1fr" justifyContent="center" gap width="100%">
<Column position="sticky" top="0px" alignSelf="flex-start" width="200px" paddingTop="3">
<WebsiteNav websiteId={websiteId} />
</Column>
<Column>
<WebsiteControls websiteId={websiteId} />
{children}
</Column>
</Grid>
<Column gap="6">
<WebsiteHeader />
<Grid columns="auto 1fr" justifyContent="center" gap="6" width="100%">
<Column position="sticky" top="20px" alignSelf="flex-start" width="200px">
<WebsiteNav websiteId={websiteId} />
</Column>
<Column>{children}</Column>
</Grid>
</Column>
</PageBody>
</WebsiteProvider>
);

View file

@ -9,6 +9,7 @@ import { EventsChart } from '@/components/metrics/EventsChart';
import { GridRow } from '@/components/common/GridRow';
import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
@ -21,6 +22,7 @@ export function EventsPage({ websiteId }) {
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<Panel>
<EventsMetricsBar websiteId={websiteId} />
</Panel>

View file

@ -1,6 +1,14 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, Button, Heading } from '@umami/react-zen';
export function GoalsPage({ websiteId }: { websiteId: string }) {
return <Column>Goals {websiteId}</Column>;
return (
<Column>
{websiteId}
<Button>Add goal</Button>
<Heading>Goal 1</Heading>
<Heading>Goal 2</Heading>
<Heading>Goal 3</Heading>
</Column>
);
}

View file

@ -1,6 +1,13 @@
'use client';
import { Column } from '@umami/react-zen';
import { UTMView } from './UTMView';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function UTMPage({ websiteId }: { websiteId: string }) {
return <Column>Goals {websiteId}</Column>;
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<UTMView websiteId={websiteId} />
</Column>
);
}

View file

@ -0,0 +1,68 @@
import { Column, Heading } from '@umami/react-zen';
import { firstBy } from 'thenby';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
import { useUTMQuery } 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 { GridRow } from '@/components/common/GridRow';
function toArray(data: { [key: string]: number } = {}) {
return Object.keys(data)
.map(key => {
return { name: key, value: data[key] };
})
.sort(firstBy('value', -1));
}
export function UTMView({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { data } = useUTMQuery(websiteId);
if (!data) {
return null;
}
return (
<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}>
<GridRow layout="two">
<Column>
<Heading>{param.replace(/^utm_/, '')}</Heading>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({
x: name,
y: value,
z: (value / total) * 100,
}))}
/>
</Column>
<Column>
<PieChart type="doughnut" data={chartData} />
</Column>
</GridRow>
</Panel>
);
})}
</Column>
);
}

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { getRequestDateRange, parseRequest } from '@/lib/request';
import { getUTM } from '@/queries';
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
unit: unitParam,
timezone: timezoneParam,
...filterParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { timezone } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const { startDate, endDate } = await getRequestDateRange(query);
const data = await getUTM(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
timezone,
});
return json(data);
}

View file

@ -38,7 +38,7 @@ export function FilterRecord({
<Row gap alignItems="center">
<Select
items={operators.filter(({ type }) => type === 'string')}
selectedKey={operator}
value={operator}
onSelectionChange={value => onSelect?.(name, value)}
>
{({ name, label }: any) => {

View file

@ -21,6 +21,7 @@ export * from './queries/useTeamWebsitesQuery';
export * from './queries/useTeamMembersQuery';
export * from './queries/useUserQuery';
export * from './queries/useUsersQuery';
export * from './queries/useUTMQuery';
export * from './queries/useWebsiteQuery';
export * from './queries/useWebsites';
export * from './queries/useWebsiteEventsQuery';

View file

@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
import { UseQueryOptions } from '@tanstack/react-query';
export function useUTMQuery(
websiteId: string,
queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const filterParams = useFilterParams(websiteId);
return useQuery({
queryKey: ['utm', websiteId, { ...filterParams, ...queryParams }],
queryFn: () =>
get(`/websites/${websiteId}/utm`, { websiteId, ...filterParams, ...queryParams }),
enabled: !!websiteId,
...options,
});
}

View file

@ -23,22 +23,8 @@ export function FilterBar() {
}
return (
<Row
theme="dark"
backgroundColor="1"
gap
alignItems="center"
justifyContent="space-between"
paddingY="2"
paddingLeft="3"
paddingRight="2"
border
borderRadius="2"
>
<Row alignItems="center" gap="3" wrap="wrap" paddingX="2">
<Text color="11" weight="bold">
{formatMessage(labels.filters)}
</Text>
<Row gap alignItems="center" justifyContent="space-between" paddingY="3">
<Row alignItems="center" gap="3" wrap="wrap">
{Object.keys(filters).map(key => {
const filter = filters[key];
const { name, label, operator, value } = filter;
@ -51,9 +37,9 @@ export function FilterBar() {
padding
backgroundColor
borderRadius
shadow="1"
alignItems="center"
justifyContent="space-between"
theme="dark"
>
<Row alignItems="center" gap="4">
<Row alignItems="center" gap="2">

View file

@ -95,7 +95,7 @@ export function WebsiteDateFilter({
{!isAllTime && showCompare && (
<TooltipTrigger delay={0}>
<Button variant="quiet" onPress={handleCompare}>
<Icon fillColor="currentColor">{compare ? <Icons.Close /> : <Icons.Compare />}</Icon>
<Icon fillColor>{compare ? <Icons.Close /> : <Icons.Compare />}</Icon>
</Button>
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
</TooltipTrigger>

View file

@ -318,6 +318,7 @@ export const labels = defineMessages({
apply: { id: 'label.apply', defaultMessage: 'Apply' },
links: { id: 'label.links', defaultMessage: 'Links' },
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
});
export const messages = defineMessages({

View file

@ -28,7 +28,9 @@ export function ActiveUsers({
return (
<StatusLight variant="success">
<Text size="2">{formatMessage(messages.activeUsers, { x: count })}</Text>
<Text size="2" weight="bold">
{formatMessage(messages.activeUsers, { x: count })}
</Text>
</StatusLight>
);
}

View file

@ -1,4 +1,4 @@
import { z, ZodSchema } from 'zod';
import { z } from 'zod/v4';
import { FILTER_COLUMNS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
@ -7,7 +7,7 @@ import { getWebsiteDateRange } from '@/queries';
export async function parseRequest(
request: Request,
schema?: ZodSchema,
schema?: any,
options?: { skipAuth: boolean },
): Promise<any> {
const url = new URL(request.url);
@ -21,7 +21,7 @@ export async function parseRequest(
const result = schema.safeParse(isGet ? query : body);
if (!result.success) {
error = () => badRequest(getErrorMessages(result.error));
error = () => badRequest(z.treeifyError(result.error));
} else if (isGet) {
query = result.data;
} else {
@ -87,13 +87,3 @@ export function getRequestFilters(query: Record<string, any>) {
return obj;
}, {});
}
export function getErrorMessages(error: z.ZodError) {
return Object.entries(error.format())
.flatMap(([key, value]) => {
if (key === '_errors') {
return value;
}
})
.filter(Boolean);
}