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

@ -41,7 +41,7 @@ RUN set -x \
&& apk add --no-cache curl
# Script dependencies
RUN pnpm add npm-run-all dotenv prisma@6.7.0
RUN pnpm add npm-run-all dotenv prisma@6.8.2
# Permissions for prisma
RUN chown -R nextjs:nodejs node_modules/.pnpm/

View file

@ -1,9 +1,8 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
generatedFileExtension = "ts"
importFileExtension = "ts"
provider = "prisma-client"
previewFeatures = ["driverAdapters"]
output = "../src/generated/prisma"
moduleFormat = "esm"
}
datasource db {
@ -238,12 +237,12 @@ model Report {
}
model Segment {
id String @id() @unique() @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
name String @db.VarChar(200)
filters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
id String @id() @unique() @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid
name String @db.VarChar(200)
filters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
website Website @relation(fields: [websiteId], references: [id])

View file

@ -74,12 +74,13 @@
"@dicebear/core": "^9.2.1",
"@fontsource/inter": "^4.5.15",
"@hello-pangea/dnd": "^17.0.0",
"@prisma/adapter-pg": "^6.8.2",
"@prisma/client": "^6.8.2",
"@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6",
"@umami/react-zen": "^0.127.0",
"@umami/react-zen": "^0.133.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",
@ -111,6 +112,7 @@
"next": "15.3.1",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"pg": "^8.16.0",
"prisma": "6.8.2",
"pure-rand": "^6.1.0",
"react": "^19.0.0",

2052
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,105 +0,0 @@
.chart {
display: grid;
}
.num {
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
width: 50px;
height: 50px;
font-size: 16px;
font-weight: 700;
color: var(--base800);
background: var(--base100);
z-index: 1;
}
.step {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 30px;
position: relative;
padding-bottom: 60px;
}
.step::before {
content: '';
position: absolute;
top: 0;
left: 25px;
bottom: 0;
width: 2px;
background-color: var(--base100);
}
.step:last-child::before {
display: none;
}
.card {
display: grid;
gap: 20px;
margin-top: 14px;
}
.header {
display: flex;
flex-direction: column;
gap: 20px;
}
.bar {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 30px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.label {
color: var(--base600);
font-weight: 700;
text-transform: uppercase;
}
.track {
background-color: var(--base100);
border-radius: 5px;
}
.info {
text-transform: lowercase;
}
.item {
font-size: 20px;
color: var(--base900);
font-weight: 700;
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
text-transform: lowercase;
}
.visitors {
color: var(--base900);
font-size: 24px;
font-weight: 900;
margin-right: 10px;
}
.percent {
font-size: 20px;
font-weight: 700;
align-self: flex-end;
}

View file

@ -1,52 +0,0 @@
import classNames from 'classnames';
import { useMessages, useReport } from '@/components/hooks';
import { formatLongNumber } from '@/lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps {
className?: string;
isLoading?: boolean;
}
export function FunnelChart({ className }: FunnelChartProps) {
const { report } = useReport();
const { formatMessage, labels } = useMessages();
const { data } = report || {};
return (
<div className={classNames(styles.chart, className)}>
{data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => {
return (
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>
{formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(visitors)}</span>
{formatMessage(labels.visitors)}
</div>
<div className={styles.percent}>{(remaining * 100).toFixed(2)}%</div>
</div>
<div className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
</div>
{dropoff > 0 && (
<div className={styles.info}>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,12 +0,0 @@
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.value {
display: flex;
align-self: center;
gap: 20px;
}

View file

@ -1,117 +0,0 @@
import { useMessages, useReport } from '@/components/hooks';
import {
Icon,
Form,
FormButtons,
FormField,
DialogTrigger,
Popover,
FormSubmitButton,
TextField,
Button,
} from '@umami/react-zen';
import { Eye, Bolt, Plus } from '@/components/icons';
import { FunnelStepAddForm } from './FunnelStepAddForm';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { ParameterList } from '../[reportId]/ParameterList';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useReport();
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
};
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddStepButton = () => {
return (
<DialogTrigger>
<Button>
<Icon>
<Plus />
</Icon>
</Button>
<Popover placement="start">
<FunnelStepAddForm onChange={handleAddStep} />
</Popover>
</DialogTrigger>
);
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters allowWebsiteSelect={!id} />
<FormField
label={formatMessage(labels.window)}
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField autoComplete="off" />
</FormField>
<FormField name="steps" label={formatMessage(labels.steps)}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<DialogTrigger key={index}>
<ParameterList.Item
icon={step.type === 'url' ? <Eye /> : <Bolt />}
onRemove={() => handleRemoveStep(index)}
>
<div className={styles.value}>
<div>{step.value}</div>
</div>
</ParameterList.Item>
<Popover placement="start">
{({ close }: any) => (
<FunnelStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
/>
)}
</Popover>
</DialogTrigger>
);
})}
</ParameterList>
<AddStepButton />
</FormField>
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,10 +0,0 @@
.filters {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
line-height: 32px;
padding: 10px;
overflow: hidden;
}

View file

@ -1,27 +0,0 @@
import { FunnelChart } from './FunnelChart';
import { FunnelParameters } from './FunnelParameters';
import { Report } from '../[reportId]/Report';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { ReportBody } from '../[reportId]/ReportBody';
import { Funnel } from '@/components/icons';
import { REPORT_TYPES } from '@/lib/constants';
const defaultParameters = {
type: REPORT_TYPES.funnel,
parameters: { window: 60, steps: [] },
};
export function FunnelReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Funnel />} />
<ReportMenu>
<FunnelParameters />
</ReportMenu>
<ReportBody>
<FunnelChart />
</ReportBody>
</Report>
);
}

View file

@ -1,6 +0,0 @@
'use client';
import { FunnelReport } from './FunnelReport';
export function FunnelReportPage() {
return <FunnelReport />;
}

View file

@ -1,7 +0,0 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View file

@ -1,81 +0,0 @@
import { useState } from 'react';
import { useMessages } from '@/components/hooks';
import {
Button,
Column,
Row,
TextField,
Label,
Select,
ListItem,
FormButtons,
} from '@umami/react-zen';
import styles from './FunnelStepAddForm.module.css';
export interface FunnelStepAddFormProps {
type?: string;
value?: string;
onChange?: (step: { type: string; value: string }) => void;
}
export function FunnelStepAddForm({
type: defaultType = 'url',
value: defaultValue = '',
onChange,
}: FunnelStepAddFormProps) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ id: 'url', label: formatMessage(labels.url), value: 'url' },
{ id: 'event', label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange({ type, value });
setValue('');
};
const handleChange = e => {
setValue(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Column gap="3">
<Label>{formatMessage(defaultValue ? labels.update : labels.add)}</Label>
<Row gap="3">
<Select
className={styles.dropdown}
items={items}
value={type}
onChange={(value: any) => setType(value)}
>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
className={styles.input}
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
<FormButtons>
<Button variant="primary" onPress={handleSave} isDisabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormButtons>
</Column>
);
}

View file

@ -1,10 +0,0 @@
import { FunnelReportPage } from './FunnelReportPage';
import { Metadata } from 'next';
export default function () {
return <FunnelReportPage />;
}
export const metadata: Metadata = {
title: 'Funnel Report',
};

View file

@ -1,7 +0,0 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View file

@ -1,141 +0,0 @@
import { useMessages } from '@/components/hooks';
import { useState } from 'react';
import {
Button,
Row,
Column,
Select,
Label,
ListItem,
TextField,
FormButtons,
} from '@umami/react-zen';
import styles from './GoalsAddForm.module.css';
export function GoalsAddForm({
type: defaultType = 'url',
value: defaultValue = '',
property: defaultProperty = '',
operator: defaultAggregae = null,
goal: defaultGoal = 10,
onChange,
}: {
type?: string;
value?: string;
operator?: string;
property?: string;
goal?: number;
onChange?: (step: {
type: string;
value: string;
goal: number;
operator?: string;
property?: string;
}) => void;
}) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const [operator, setOperator] = useState(defaultAggregae);
const [property, setProperty] = useState(defaultProperty);
const [goal, setGoal] = useState(defaultGoal);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
{ label: formatMessage(labels.eventData), value: 'event-data' },
];
const operators = [
{ label: formatMessage(labels.count), value: 'count' },
{ label: formatMessage(labels.average), value: 'average' },
{ label: formatMessage(labels.sum), value: 'sum' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange(
type === 'event-data' ? { type, value, goal, operator, property } : { type, value, goal },
);
setValue('');
setProperty('');
setGoal(10);
};
const handleChange = (e, set) => {
set(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Column gap="3">
<Label>{formatMessage(defaultValue ? labels.update : labels.add)}</Label>
<Row gap="3">
<Select
className={styles.dropdown}
items={items}
value={type}
onChange={(value: any) => setType(value)}
>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
className={styles.input}
value={value}
onChange={e => handleChange(e, setValue)}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
{type === 'event-data' && (
<Column>
<Label>label={formatMessage(labels.property)}</Label>
<Row gap="3">
<Select
className={styles.dropdown}
items={operators}
value={operator}
onChange={(value: any) => setOperator(value)}
>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
className={styles.input}
value={property}
onChange={e => handleChange(e, setProperty)}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
</Column>
)}
<Column>
<Label>{formatMessage(labels.goal)}</Label>
<Row gap="3">
<TextField
className={styles.input}
value={goal?.toString()}
onChange={e => handleChange(e, setGoal)}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
</Column>
<FormButtons>
<Button variant="primary" onPress={handleSave} isDisabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormButtons>
</Column>
);
}

View file

@ -1,95 +0,0 @@
.chart {
display: grid;
gap: 30px;
}
.goal {
padding-bottom: 40px;
border-bottom: 1px solid var(--base400);
}
.goal:last-child {
border: 0;
}
.card {
display: grid;
gap: 20px;
margin-top: 14px;
}
.header {
display: flex;
flex-direction: column;
gap: 20px;
}
.label {
color: var(--base600);
font-weight: 700;
text-transform: uppercase;
}
.item {
font-size: 20px;
color: var(--base900);
font-weight: 700;
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
text-transform: lowercase;
}
.value {
color: var(--base900);
font-size: 24px;
font-weight: 900;
margin-right: 10px;
}
.percent {
font-size: 20px;
font-weight: 700;
align-self: flex-end;
}
.total {
color: var(--base700);
}
.bar {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 10px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.bar.level1 {
background: var(--red800);
}
.bar.level2 {
background: var(--orange200);
}
.bar.level3 {
background: var(--orange400);
}
.bar.level4 {
background: var(--orange600);
}
.bar.level5 {
background: var(--green600);
}
.track {
background-color: var(--base100);
border-radius: 5px;
}

View file

@ -1,70 +0,0 @@
import classNames from 'classnames';
import { useMessages, useReport } from '@/components/hooks';
import { formatLongNumber } from '@/lib/format';
import styles from './GoalsChart.module.css';
export function GoalsChart({ className }: { className?: string; isLoading?: boolean }) {
const { report } = useReport();
const { formatMessage, labels } = useMessages();
const { data } = report || {};
const getLabel = type => {
let label = '';
switch (type) {
case 'url':
label = labels.viewedPage;
break;
case 'event':
label = labels.triggeredEvent;
break;
default:
label = labels.collectedData;
break;
}
return label;
};
return (
<div className={classNames(styles.chart, className)}>
{data?.map(({ type, value, goal, result, property, operator }, index: number) => {
const percent = result > goal ? 100 : (result / goal) * 100;
return (
<div key={index} className={styles.goal}>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>{formatMessage(getLabel(type))}</span>
<span className={styles.item}>{`${value}${
type === 'event-data' ? `:(${operator}):${property}` : ''
}`}</span>
</div>
<div className={styles.track}>
<div
className={classNames(
classNames(styles.bar, {
[styles.level1]: percent <= 20,
[styles.level2]: percent > 20 && percent <= 40,
[styles.level3]: percent > 40 && percent <= 60,
[styles.level4]: percent > 60 && percent <= 80,
[styles.level5]: percent > 80,
}),
)}
style={{ width: `${percent}%` }}
></div>
</div>
<div className={styles.metric}>
<div className={styles.value}>
{formatLongNumber(result)}
<span className={styles.total}> / {formatLongNumber(goal)}</span>
</div>
<div className={styles.percent}>{((result / goal) * 100).toFixed(2)}%</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -1,25 +0,0 @@
.value {
width: 100%;
margin-bottom: 8px;
font-weight: 600;
}
.eventData {
color: var(--orange900);
background-color: var(--orange100);
font-size: 12px;
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
width: fit-content;
}
.goal {
color: var(--blue900);
background-color: var(--blue100);
font-size: 12px;
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
width: fit-content;
}

View file

@ -1,131 +0,0 @@
import { useMessages, useReport } from '@/components/hooks';
import { Plus, Eye, Bolt } from '@/components/icons';
import { formatNumber } from '@/lib/format';
import {
Button,
Form,
FormButtons,
FormField,
Icon,
Popover,
MenuTrigger,
FormSubmitButton,
Column,
} from '@umami/react-zen';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { ParameterList } from '../[reportId]/ParameterList';
import { GoalsAddForm } from './GoalsAddForm';
import styles from './GoalsParameters.module.css';
export function GoalsParameters() {
const { report, runReport, updateReport, isRunning } = useReport();
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, goals } = parameters || {};
const queryDisabled = !websiteId || !dateRange || goals?.length < 1;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddGoals = (goal: { type: string; value: string }) => {
updateReport({ parameters: { goals: parameters.goals.concat(goal) } });
};
const handleUpdateGoals = (
close: () => void,
index: number,
goal: { type: string; value: string },
) => {
const goals = [...parameters.goals];
goals[index] = goal;
updateReport({ parameters: { goals } });
close();
};
const handleRemoveGoals = (index: number) => {
const goals = [...parameters.goals];
delete goals[index];
updateReport({ parameters: { goals: goals.filter(n => n) } });
};
const AddGoalsButton = () => {
return (
<MenuTrigger>
<Button>
<Icon>
<Plus />
</Icon>
</Button>
<Popover placement="start">
<GoalsAddForm onChange={handleAddGoals} />
</Popover>
</MenuTrigger>
);
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters allowWebsiteSelect={!id} />
<AddGoalsButton />
<FormField name="goal" label={formatMessage(labels.goals)}>
<ParameterList>
{goals.map(
(
goal: {
type: string;
value: string;
goal: number;
operator?: string;
property?: string;
},
index: number,
) => {
return (
<MenuTrigger key={index}>
<ParameterList.Item
icon={goal.type === 'url' ? <Eye /> : <Bolt />}
onRemove={() => handleRemoveGoals(index)}
>
<Column>
<div className={styles.value}>{goal.value}</div>
{goal.type === 'event-data' && (
<div className={styles.eventData}>
{formatMessage(labels[goal.operator])}: {goal.property}
</div>
)}
<div className={styles.goal}>
{formatMessage(labels.goal)}: {formatNumber(goal.goal)}
</div>
</Column>
</ParameterList.Item>
<Popover placement="start">
<GoalsAddForm
type={goal.type}
value={goal.value}
goal={goal.goal}
operator={goal.operator}
property={goal.property}
onChange={handleUpdateGoals.bind(null, () => {}, index)}
/>
</Popover>
</MenuTrigger>
);
},
)}
</ParameterList>
</FormField>
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,10 +0,0 @@
.filters {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
line-height: 32px;
padding: 10px;
overflow: hidden;
}

View file

@ -1,27 +0,0 @@
import { GoalsChart } from './GoalsChart';
import { GoalsParameters } from './GoalsParameters';
import { Report } from '../[reportId]/Report';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { ReportBody } from '../[reportId]/ReportBody';
import { Target } from '@/components/icons';
import { REPORT_TYPES } from '@/lib/constants';
const defaultParameters = {
type: REPORT_TYPES.goals,
parameters: { goals: [] },
};
export function GoalsReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Target />} />
<ReportMenu>
<GoalsParameters />
</ReportMenu>
<ReportBody>
<GoalsChart />
</ReportBody>
</Report>
);
}

View file

@ -1,6 +0,0 @@
'use client';
import { GoalsReport } from './GoalsReport';
export function GoalsReportPage() {
return <GoalsReport />;
}

View file

@ -1,10 +0,0 @@
import { GoalsReportPage } from './GoalsReportPage';
import { Metadata } from 'next';
export default function () {
return <GoalsReportPage />;
}
export const metadata: Metadata = {
title: 'Goals Report',
};

View file

@ -1,4 +1,4 @@
import { TextArea } from '@umami/react-zen';
import { TextField } from '@umami/react-zen';
import { useMessages, useConfig } from '@/components/hooks';
const SCRIPT_NAME = 'script.js';
@ -21,7 +21,7 @@ export function TrackingCode({ websiteId, hostUrl }: { websiteId: string; hostUr
return (
<>
<p>{formatMessage(messages.trackingCode)}</p>
<TextArea rows={4} value={code} isReadOnly allowCopy />
<TextField value={code} isReadOnly allowCopy asTextArea />
</>
);
}

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>

View file

@ -1,9 +1,8 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { deleteReport, getReport, updateReport } from '@/queries';
import { canDeleteReport, canUpdateReport, canViewReport } from '@/lib/auth';
import { unauthorized, json, notFound, ok } from '@/lib/response';
import { reportTypeParam } from '@/lib/schema';
import { reportSchema } from '@/lib/schema';
export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) {
const { auth, error } = await parseRequest(request);
@ -20,8 +19,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
return unauthorized();
}
report.parameters = JSON.parse(report.parameters);
return json(report);
}
@ -29,15 +26,7 @@ export async function POST(
request: Request,
{ params }: { params: Promise<{ reportId: string }> },
) {
const schema = z.object({
websiteId: z.string().uuid(),
type: reportTypeParam,
name: z.string().max(200),
description: z.string().max(500),
parameters: z.object({}).passthrough(),
});
const { auth, body, error } = await parseRequest(request, schema);
const { auth, body, error } = await parseRequest(request, reportSchema);
if (error) {
return error();
@ -62,8 +51,8 @@ export async function POST(
type,
name,
description,
parameters: JSON.stringify(parameters),
} as any);
parameters,
});
return json(result);
}

View file

@ -1,25 +1,11 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getFunnel } from '@/queries';
import { reportParms } from '@/lib/schema';
import { reportResultSchema } from '@/lib/schema';
export async function POST(request: Request) {
const schema = z.object({
...reportParms,
window: z.coerce.number().positive(),
steps: z
.array(
z.object({
type: z.string(),
value: z.string(),
}),
)
.min(2),
});
const { auth, body, error } = await parseRequest(request, schema);
const { auth, body, error } = await parseRequest(request, reportResultSchema);
if (error) {
return error();
@ -27,9 +13,8 @@ export async function POST(request: Request) {
const {
websiteId,
steps,
window,
dateRange: { startDate, endDate },
parameters: { steps, window },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {

View file

@ -0,0 +1,34 @@
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getGoal } from '@/queries/sql/reports/getGoal';
import { reportResultSchema } from '@/lib/schema';
export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema);
if (error) {
return error();
}
const {
websiteId,
dateRange: { startDate, endDate },
parameters: { type, value, property, operator },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getGoal(websiteId, {
type,
value,
property,
operator,
startDate: new Date(startDate),
endDate: new Date(endDate),
});
return json(data);
}

View file

@ -1,57 +0,0 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getGoal } from '@/queries/sql/reports/getGoal';
import { filterParams, reportParms } from '@/lib/schema';
export async function POST(request: Request) {
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);
if (error) {
return error();
}
const {
websiteId,
type,
value,
property,
operator,
dateRange: { startDate, endDate },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getGoal(websiteId, {
type,
value,
property,
operator,
startDate: new Date(startDate),
endDate: new Date(endDate),
});
return json(data);
}

View file

@ -1,15 +1,15 @@
import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { pagingParams, reportTypeParam } from '@/lib/schema';
import { pagingParams, reportSchema } from '@/lib/schema';
import { parseRequest } from '@/lib/request';
import { canViewTeam, canViewWebsite, canUpdateWebsite } from '@/lib/auth';
import { canViewWebsite, canUpdateWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { getReports, createReport } from '@/queries';
export async function GET(request: Request) {
const schema = z.object({
websiteId: z.string().uuid().optional(),
teamId: z.string().uuid().optional(),
type: z.string().optional(),
...pagingParams,
});
@ -19,53 +19,24 @@ export async function GET(request: Request) {
return error();
}
const { page, search, pageSize, websiteId, teamId } = query;
const userId = auth.user.id;
const { page, search, pageSize, websiteId, type } = query;
const filters = {
page,
pageSize,
search,
};
if (
(websiteId && !(await canViewWebsite(auth, websiteId))) ||
(teamId && !(await canViewTeam(auth, teamId)))
) {
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getReports(
{
where: {
OR: [
...(websiteId ? [{ websiteId }] : []),
...(teamId
? [
{
website: {
deletedAt: null,
teamId,
},
},
]
: []),
...(userId && !websiteId && !teamId
? [
{
website: {
deletedAt: null,
userId,
},
},
]
: []),
],
},
include: {
websiteId,
type,
website: {
select: {
domain: true,
},
deletedAt: null,
},
},
},
@ -76,15 +47,7 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const schema = z.object({
websiteId: z.string().uuid(),
name: z.string().max(200),
type: reportTypeParam,
description: z.string().max(500),
parameters: z.object({}).passthrough(),
});
const { auth, body, error } = await parseRequest(request, schema);
const { auth, body, error } = await parseRequest(request, reportSchema);
if (error) {
return error();
@ -102,9 +65,9 @@ export async function POST(request: Request) {
websiteId,
type,
name,
description,
parameters: JSON.stringify(parameters),
} as any);
description: description || '',
parameters,
});
return json(result);
}

View file

@ -1,94 +0,0 @@
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();
}

View file

@ -1,15 +1,17 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { getWebsiteReports } from '@/queries';
import { pagingParams } from '@/lib/schema';
import { getReports } from '@/queries';
import { filterParams, pagingParams } from '@/lib/schema';
import { parseRequest } from '@/lib/request';
import { unauthorized, json } from '@/lib/response';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
filters: { type: string },
) {
const schema = z.object({
...filterParams,
...pagingParams,
});
@ -26,11 +28,19 @@ export async function GET(
return unauthorized();
}
const data = await getWebsiteReports(websiteId, {
page: +page,
pageSize: +pageSize,
search,
});
const data = await getReports(
{
where: {
websiteId,
type: filters.type,
},
},
{
page,
pageSize,
search,
},
);
return json(data);
}

View file

@ -9,9 +9,8 @@ export function useGoalQuery(
return usePagedQuery({
queryKey: ['goal', { websiteId, reportId, ...params }],
queryFn: (data: any) => {
queryFn: () => {
return post(`/reports/goals`, {
...data,
...params,
});
},

View file

@ -11,9 +11,8 @@ export function useGoalsQuery(
return usePagedQuery({
queryKey: ['goals', { websiteId, modified, ...params }],
queryFn: (data: any) => {
queryFn: () => {
return get(`/websites/${websiteId}/goals`, {
...data,
...params,
});
},

View file

@ -7,10 +7,8 @@ export function useReportQuery(reportId: string) {
return useQuery({
queryKey: ['report', { reportId, modified }],
queryFn: (data: any) => {
return get(`/reports/${reportId}`, {
...data,
});
queryFn: () => {
return get(`/reports/${reportId}`);
},
enabled: !!reportId,
});

View file

@ -2,14 +2,13 @@ import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import { useModified } from '../useModified';
export function useReportsQuery({ websiteId, teamId }: { websiteId?: string; teamId?: string }) {
const { modified } = useModified(`reports`);
export function useReportsQuery({ websiteId, type }: { websiteId: string; type?: string }) {
const { modified } = useModified(`reports:${type}`);
const { get } = useApi();
return usePagedQuery({
queryKey: ['reports', { websiteId, teamId, modified }],
queryFn: (params: any) => {
return get('/reports', { websiteId, teamId, ...params });
},
queryKey: ['reports', { websiteId, type, modified }],
queryFn: async () => get('/reports', { websiteId, type }),
enabled: !!websiteId && !!type,
});
}

View file

@ -10,7 +10,7 @@ export function useResultQuery<T>(
return useQuery<T>({
queryKey: ['reports', type, params],
queryFn: () => post(`/reports/${type}`, params),
queryFn: () => post(`/reports/${type}`, { type, ...params }),
enabled: !!type,
...options,
});

View file

@ -13,9 +13,8 @@ export function useWebsiteSessionsQuery(
return usePagedQuery({
queryKey: ['sessions', { websiteId, modified, ...params, ...filters }],
queryFn: (data: any) => {
queryFn: () => {
return get(`/websites/${websiteId}/sessions`, {
...data,
...params,
...filters,
pageSize: 20,

View file

@ -13,9 +13,8 @@ export function useWebsites(
return usePagedQuery({
queryKey: ['websites', { userId, teamId, modified, ...params }],
queryFn: (data: any) => {
queryFn: () => {
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {
...data,
...params,
});
},

View file

@ -0,0 +1,100 @@
import { ReactNode, useState } from 'react';
import { useMessages } from '@/components/hooks';
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
import {
AlertDialog,
Button,
Icon,
Menu,
MenuItem,
MenuTrigger,
Modal,
Popover,
Text,
Row,
} from '@umami/react-zen';
import { Edit, More, Trash } from '@/components/icons';
export function ReportEditButton({
id,
name,
type,
children,
onDelete,
}: {
id: string;
name: string;
type: string;
onDelete?: () => void;
children: ({ close }: { close: () => void }) => ReactNode;
}) {
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(`reports:${type}`);
setShowDelete(false);
onDelete?.();
},
});
};
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 && children({ close: handleClose })}
{showDelete && (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleDelete}
onCancel={handleClose}
>
<Row gap="1">
{formatMessage(messages.confirmDelete, { target: <b key={name}>{name}</b> })}
</Row>
</AlertDialog>
)}
</Modal>
</>
);
}

View file

@ -277,7 +277,6 @@ export const labels = defineMessages({
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
goal: { id: 'label.goal', defaultMessage: 'Goal' },
goals: { id: 'label.goals', defaultMessage: 'Goals' },
addGoal: { id: 'label.add-goal', defaultMessage: 'Add Goal' },
goalsDescription: {
id: 'label.goals-description',
defaultMessage: 'Track your goals for pageviews and events.',

View file

@ -11,12 +11,12 @@
.positive {
color: var(--success-color);
background: color-mix(in srgb, var(--success-color), var(--background-color) 85%);
background: color-mix(in srgb, var(--success-color), var(--background-color) 95%);
}
.negative {
color: var(--danger-color);
background: color-mix(in srgb, var(--danger-color), var(--background-color) 85%);
background: color-mix(in srgb, var(--danger-color), var(--background-color) 95%);
}
.neutral {

View file

@ -1,16 +1,16 @@
import classNames from 'classnames';
import { Icon, Text } from '@umami/react-zen';
import { ReactNode } from 'react';
import { HTMLAttributes, ReactNode } from 'react';
import { Arrow } from '@/components/icons';
import styles from './ChangeLabel.module.css';
export function ChangeLabel({
value,
size,
title,
reverseColors,
className,
children,
...props
}: {
value: number;
size?: 'xs' | 'sm' | 'md' | 'lg';
@ -19,7 +19,7 @@ export function ChangeLabel({
showPercentage?: boolean;
className?: string;
children?: ReactNode;
}) {
} & HTMLAttributes<HTMLDivElement>) {
const positive = value >= 0;
const negative = value < 0;
const neutral = value === 0 || isNaN(value);
@ -27,12 +27,12 @@ export function ChangeLabel({
return (
<div
{...props}
className={classNames(styles.label, className, {
[styles.positive]: good,
[styles.negative]: !good,
[styles.neutral]: neutral,
})}
title={title}
>
{!neutral && (
<Icon rotate={positive ? -90 : 90} size={size}>

View file

@ -1,6 +1,7 @@
declare module 'bcryptjs';
declare module 'chartjs-adapter-date-fns';
declare module 'cors';
declare module 'date-fns-tz';
declare module 'debug';
declare module 'fs-extra';
declare module 'jsonwebtoken';

View file

@ -111,8 +111,8 @@ export const DATA_TYPES = {
export const REPORT_TYPES = {
funnel: 'funnel',
goals: 'goals',
insights: 'insights',
goals: 'goal',
insights: 'insight',
retention: 'retention',
utm: 'utm',
journey: 'journey',

View file

@ -56,10 +56,10 @@ export const urlOrPathParam = z.string().refine(
export const reportTypeParam = z.enum([
'funnel',
'insights',
'insight',
'retention',
'utm',
'goals',
'goal',
'journey',
'revenue',
'attribution',
@ -76,3 +76,58 @@ export const reportParms = {
value: z.string().optional(),
}),
};
export const goalReportSchema = z.object({
type: z.literal('goal'),
parameters: z
.object({
type: z.string(),
value: z.string(),
operator: z.enum(['count', 'sum', 'average']).optional(),
property: z.string().optional(),
})
.refine(data => {
if (data['type'] === 'event' && data['property']) {
return data['operator'] && data['property'];
}
return true;
}),
});
export const funnelReportSchema = z.object({
type: z.literal('funnel'),
parameters: z.object({
window: z.coerce.number().positive(),
steps: z
.array(
z.object({
type: z.enum(['page', 'event']),
value: z.string(),
}),
)
.min(2)
.max(8),
}),
});
export const reportBaseSchema = z.object({
websiteId: z.string().uuid(),
type: reportTypeParam,
name: z.string().max(200),
description: z.string().max(500).optional(),
});
export const reportTypeSchema = z.discriminatedUnion('type', [
goalReportSchema,
funnelReportSchema,
]);
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
export const reportResultSchema = z.intersection(
z.object({
...reportParms,
...filterParams,
}),
reportTypeSchema,
);

View file

@ -88,10 +88,7 @@ export async function createReport(data: Prisma.ReportUncheckedCreateInput): Pro
return prisma.client.report.create({ data });
}
export async function updateReport(
reportId: string,
data: Prisma.ReportUpdateInput,
): Promise<Report> {
export async function updateReport(reportId: string, data: any): Promise<Report> {
return prisma.client.report.update({ where: { id: reportId }, data });
}

View file

@ -2,36 +2,14 @@ import clickhouse from '@/lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 0;
const dropped = previous > 0 ? previous - visitors : 0;
const dropoff = 1 - visitors / previous;
const remaining = visitors / Number(results[0].count);
export interface FunnelCriteria {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
}
return {
...step,
visitors,
previous,
dropped,
dropoff,
remaining,
};
});
};
export async function getFunnel(
...args: [
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
]
) {
export async function getFunnel(...args: [websiteId: string, criteria: FunnelCriteria]) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
@ -40,12 +18,7 @@ export async function getFunnel(
async function relationalQuery(
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
criteria: FunnelCriteria,
): Promise<
{
value: string;
@ -70,7 +43,7 @@ async function relationalQuery(
(pv, cv, i) => {
const levelNumber = i + 1;
const startSum = i > 0 ? 'union ' : '';
const isURL = cv.type === 'url';
const isURL = cv.type === 'page';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
@ -139,12 +112,7 @@ async function relationalQuery(
async function clickhouseQuery(
websiteId: string,
criteria: {
windowMinutes: number;
startDate: Date;
endDate: Date;
steps: { type: string; value: string }[];
},
criteria: FunnelCriteria,
): Promise<
{
value: string;
@ -174,7 +142,7 @@ async function clickhouseQuery(
const levelNumber = i + 1;
const startSum = i > 0 ? 'union all ' : '';
const startFilter = i > 0 ? 'or' : '';
const isURL = cv.type === 'url';
const isURL = cv.type === 'page';
const column = isURL ? 'url_path' : 'event_name';
let operator = '=';
@ -248,3 +216,22 @@ async function clickhouseQuery(
},
).then(formatResults(steps));
}
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
return steps.map((step: { type: string; value: string }, i: number) => {
const visitors = Number(results[i]?.count) || 0;
const previous = Number(results[i - 1]?.count) || 0;
const dropped = previous > 0 ? previous - visitors : 0;
const dropoff = 1 - visitors / previous;
const remaining = visitors / Number(results[0].count);
return {
...step,
visitors,
previous,
dropped,
dropoff,
remaining,
};
});
};

View file

@ -15,6 +15,11 @@ a:hover {
text-decoration: none;
}
:where(svg) {
width: 1rem;
height: 1rem;
}
::-webkit-scrollbar {
width: 15px;
background: transparent;