New goals page. Upgraded prisma.

This commit is contained in:
Mike Cao 2025-05-31 02:11:18 -07:00
parent 99330a1a4d
commit 49bcbfd7f9
65 changed files with 769 additions and 1195 deletions

View file

@ -9,8 +9,7 @@ import {
Grid2X2,
Settings,
} from '@/components/icons';
import { useMessages, useNavigation } from '@/components/hooks';
import useGlobalState from '@/components/hooks/useGlobalState';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
export function SideNav(props: any) {
const { formatMessage, labels } = useMessages();

View file

@ -1,7 +1,8 @@
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
export function ReportDeleteButton({
reportId,
@ -13,11 +14,7 @@ export function ReportDeleteButton({
onDelete?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isPending, error } = useMutation({
mutationFn: reportId => del(`/reports/${reportId}`),
});
const { touch } = useModified();
const { mutate, isPending, error, touch } = useDeleteQuery(`/reports/${reportId}`);
const handleConfirm = (close: () => void) => {
mutate(reportId as any, {

View file

@ -18,7 +18,7 @@ export function InsightsParameters() {
};
return (
<Form values={parameters} onSubmit={handleSubmit} gap="6">
<Form values={parameters} onSubmit={handleSubmit}>
<BaseParameters allowWebsiteSelect={!id} />
{parametersSelected && <FieldParameters />}
{parametersSelected && <FilterParameters />}

View file

@ -67,7 +67,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
id: 'journeys',
label: formatMessage(labels.journey),
icon: <Path />,
path: '/goals',
path: '/journeys',
},
{
id: 'retention',
@ -106,7 +106,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
<Link key={id} href={renderTeamUrl(`/websites/${websiteId}${path}`)}>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon style={{ fill: 'currentcolor' }}>{icon}</Icon>
<Icon>{icon}</Icon>
<Text>{label}</Text>
</Row>
</NavMenuItem>

View file

@ -0,0 +1,171 @@
import { useState } from 'react';
import {
Grid,
Row,
Column,
Text,
Icon,
Button,
MenuTrigger,
Menu,
MenuItem,
Popover,
ProgressBar,
Dialog,
Modal,
AlertDialog,
} from '@umami/react-zen';
import { useMessages, useResultQuery } from '@/components/hooks';
import { Edit, More, Trash, File, Lightning, User, Eye } 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';
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 } = useResultQuery<GoalData>(type, {
...parameters,
websiteId,
dateRange: {
startDate,
endDate,
},
});
const isPage = parameters?.type === 'page';
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>
<ActionsButton id={id} websiteId={websiteId} />
</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>{isPage ? <Eye /> : <User />}</Icon>
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
data?.num,
)} / ${formatLongNumber(data?.total)}`}</Text>
</Row>
</Row>
<Row alignItems="center" gap>
<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>
);
}
const ActionsButton = ({ id, websiteId }: { id: string; websiteId: string }) => {
const { formatMessage, labels } = 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 variant="modal" style={{ minHeight: 375, minWidth: 300 }}>
<GoalAddForm id={id} websiteId={websiteId} onClose={handleClose} />
</Dialog>
)}
{showDelete && (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleDelete}
onCancel={handleClose}
/>
)}
</Modal>
</>
);
};

View file

@ -1,4 +1,4 @@
import { Button, MenuTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { GoalAddForm } from './GoalAddForm';
import { Plus } from '@/components/icons';
@ -7,7 +7,7 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
return (
<MenuTrigger>
<DialogTrigger>
<Button variant="primary">
<Icon>
<Plus />
@ -15,10 +15,10 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.addGoal)}</Text>
</Button>
<Modal>
<Dialog variant="modal" title={formatMessage(labels.addGoal)}>
<Dialog variant="modal" title={formatMessage(labels.addGoal)} style={{ width: '400px' }}>
{({ close }) => <GoalAddForm websiteId={websiteId} onClose={close} />}
</Dialog>
</Modal>
</MenuTrigger>
</DialogTrigger>
);
}

View file

@ -2,72 +2,116 @@ import {
Form,
FormField,
TextField,
Select,
Grid,
FormButtons,
FormSubmitButton,
Button,
RadioGroup,
Radio,
Text,
Icon,
Loading,
} from '@umami/react-zen';
import { useApi, useMessages } from '@/components/hooks';
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons';
const defaultValues = {
name: '',
type: 'page',
value: '',
};
export function GoalAddForm({
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: (data: any) => post('/websites', { ...data }),
mutationFn: (params: any) => post(`/websites/${websiteId}/goals`, params),
});
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
mutate(
{ id, ...data },
{
onSuccess: async () => {
onSave?.();
onClose?.();
touch('goals');
},
},
});
);
};
const items = [
{ id: 'page', label: formatMessage(labels.page) },
{ id: 'event', label: formatMessage(labels.event) },
];
if (id && !data) {
return <Loading position="page" icon="dots" />;
}
return (
<Form onSubmit={handleSubmit} error={error?.message}>
{websiteId}
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormField
name="type"
label={formatMessage(labels.type)}
rules={{ required: formatMessage(labels.required) }}
>
<Select items={items} defaultValue="page" />
</FormField>
<FormField
name="value"
label={formatMessage(labels.value)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormButtons>
<Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton isDisabled={false}>{formatMessage(labels.add)}</FormSubmitButton>
</FormButtons>
<Form
onSubmit={handleSubmit}
error={error?.message}
defaultValues={data?.parameters || defaultValues}
>
{({ watch }) => {
const watchType = watch('type');
return (
<>
<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="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</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(id ? labels.save : labels.add)}</FormSubmitButton>
</FormButtons>
</>
);
}}
</Form>
);
}

View file

@ -1,16 +1,38 @@
'use client';
import { Column } from '@umami/react-zen';
import { Grid, Loading } 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 { WebsiteControls } from '../WebsiteControls';
import { useDateRange, useGoalsQuery } 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 {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
if (!result) {
return <Loading position="page" />;
}
return (
<Column>
<>
<WebsiteControls websiteId={websiteId} />
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
</Column>
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
<SectionHeader>
<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} />
</Panel>
))}
</Grid>
</LoadingPanel>
</>
);
}

View file

@ -69,10 +69,12 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
{hour > 0 && (
<TooltipTrigger delay={0}>
<Focusable>
<div
className={styles.block}
style={{ opacity: pct, transform: `scale(${pct})` }}
/>
<div>
<div
className={styles.block}
style={{ opacity: pct, transform: `scale(${pct})` }}
/>
</div>
</Focusable>
<Tooltip placement="right">{`${formatMessage(
labels.visitors,

View file

@ -2,34 +2,28 @@ import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getGoals } from '@/queries/sql/reports/getGoals';
import { reportParms } from '@/lib/schema';
import { getGoal } from '@/queries/sql/reports/getGoal';
import { filterParams, reportParms } from '@/lib/schema';
export async function POST(request: Request) {
const schema = z.object({
...reportParms,
goals: z
.array(
z
.object({
type: z.string().regex(/url|event|event-data/),
value: z.string(),
goal: z.coerce.number(),
operator: z
.string()
.regex(/count|sum|average/)
.optional(),
property: z.string().optional(),
})
.refine(data => {
if (data['type'] === 'event-data') {
return data['operator'] && data['property'];
}
return true;
}),
)
.min(1),
});
const schema = z
.object({
...reportParms,
...filterParams,
type: z.enum(['page', 'event']),
value: z.string(),
operator: z
.string()
.regex(/count|sum|average/)
.optional(),
property: z.string().optional(),
})
.refine(data => {
if (data['type'] === 'event' && data['property']) {
return data['operator'] && data['property'];
}
return true;
});
const { auth, body, error } = await parseRequest(request, schema);
@ -39,18 +33,24 @@ export async function POST(request: Request) {
const {
websiteId,
type,
value,
property,
operator,
dateRange: { startDate, endDate },
goals,
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getGoals(websiteId, {
const data = await getGoal(websiteId, {
type,
value,
property,
operator,
startDate: new Date(startDate),
endDate: new Date(endDate),
goals,
});
return json(data);

View file

@ -0,0 +1,94 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json, ok } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getReports, createReport, updateReport } from '@/queries';
import { uuid } from '@/lib/crypto';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const { auth, query, error } = await parseRequest(request);
if (error) {
return error();
}
const { websiteId } = await params;
const { page, search, pageSize } = query;
const filters = {
page,
pageSize,
search,
};
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getReports(
{
where: {
websiteId,
type: 'goals',
},
},
filters,
).then(result => {
result.data = result.data.map(report => {
report.parameters = JSON.parse(report.parameters);
return report;
});
return result;
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
id: z.string().uuid().optional(),
name: z.string(),
type: z.enum(['page', 'event']),
value: z.string(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const { id, name, type, value } = body;
if (id) {
await updateReport(id, {
name,
parameters: JSON.stringify({ name, type, value }),
});
} else {
await createReport({
id: uuid(),
userId: auth.user.id,
websiteId,
type: 'goals',
name,
description: '',
parameters: JSON.stringify({ name, type, value }),
});
}
return ok();
}