mirror of
https://github.com/umami-software/umami.git
synced 2026-02-14 01:25:37 +01:00
More work on reports. Added Funnel page.
This commit is contained in:
parent
5159dd470f
commit
3847e32f39
59 changed files with 1815 additions and 2370 deletions
|
|
@ -41,7 +41,7 @@ RUN set -x \
|
||||||
&& apk add --no-cache curl
|
&& apk add --no-cache curl
|
||||||
|
|
||||||
# Script dependencies
|
# 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
|
# Permissions for prisma
|
||||||
RUN chown -R nextjs:nodejs node_modules/.pnpm/
|
RUN chown -R nextjs:nodejs node_modules/.pnpm/
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../src/generated/prisma"
|
previewFeatures = ["driverAdapters"]
|
||||||
moduleFormat = "esm"
|
output = "../src/generated/prisma"
|
||||||
generatedFileExtension = "ts"
|
moduleFormat = "esm"
|
||||||
importFileExtension = "ts"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|
@ -238,12 +237,12 @@ model Report {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Segment {
|
model Segment {
|
||||||
id String @id() @unique() @map("segment_id") @db.Uuid
|
id String @id() @unique() @map("segment_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
filters Json
|
filters Json
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
website Website @relation(fields: [websiteId], references: [id])
|
website Website @relation(fields: [websiteId], references: [id])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,13 @@
|
||||||
"@dicebear/core": "^9.2.1",
|
"@dicebear/core": "^9.2.1",
|
||||||
"@fontsource/inter": "^4.5.15",
|
"@fontsource/inter": "^4.5.15",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
|
"@prisma/adapter-pg": "^6.8.2",
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^9.7.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.28.6",
|
"@tanstack/react-query": "^5.28.6",
|
||||||
"@umami/react-zen": "^0.127.0",
|
"@umami/react-zen": "^0.133.0",
|
||||||
"@umami/redis-client": "^0.27.0",
|
"@umami/redis-client": "^0.27.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
|
|
@ -111,6 +112,7 @@
|
||||||
"next": "15.3.1",
|
"next": "15.3.1",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
"pg": "^8.16.0",
|
||||||
"prisma": "6.8.2",
|
"prisma": "6.8.2",
|
||||||
"pure-rand": "^6.1.0",
|
"pure-rand": "^6.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|
|
||||||
2052
pnpm-lock.yaml
generated
2052
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
.item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
display: flex;
|
|
||||||
align-self: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { FunnelReport } from './FunnelReport';
|
|
||||||
|
|
||||||
export function FunnelReportPage() {
|
|
||||||
return <FunnelReport />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.dropdown {
|
|
||||||
width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { FunnelReportPage } from './FunnelReportPage';
|
|
||||||
import { Metadata } from 'next';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
return <FunnelReportPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Funnel Report',
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.dropdown {
|
|
||||||
width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { GoalsReport } from './GoalsReport';
|
|
||||||
|
|
||||||
export function GoalsReportPage() {
|
|
||||||
return <GoalsReport />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { GoalsReportPage } from './GoalsReportPage';
|
|
||||||
import { Metadata } from 'next';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
return <GoalsReportPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Goals Report',
|
|
||||||
};
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TextArea } from '@umami/react-zen';
|
import { TextField } from '@umami/react-zen';
|
||||||
import { useMessages, useConfig } from '@/components/hooks';
|
import { useMessages, useConfig } from '@/components/hooks';
|
||||||
|
|
||||||
const SCRIPT_NAME = 'script.js';
|
const SCRIPT_NAME = 'script.js';
|
||||||
|
|
@ -21,7 +21,7 @@ export function TrackingCode({ websiteId, hostUrl }: { websiteId: string; hostUr
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p>{formatMessage(messages.trackingCode)}</p>
|
<p>{formatMessage(messages.trackingCode)}</p>
|
||||||
<TextArea rows={4} value={code} isReadOnly allowCopy />
|
<TextField value={code} isReadOnly allowCopy asTextArea />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'funnel',
|
id: 'funnel',
|
||||||
label: formatMessage(labels.funnel),
|
label: formatMessage(labels.funnels),
|
||||||
icon: <Funnel />,
|
icon: <Funnel />,
|
||||||
path: '/funnels',
|
path: '/funnels',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
117
src/app/(main)/websites/[websiteId]/funnels/Funnel.tsx
Normal file
117
src/app/(main)/websites/[websiteId]/funnels/Funnel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/(main)/websites/[websiteId]/funnels/FunnelEditForm.tsx
Normal file
158
src/app/(main)/websites/[websiteId]/funnels/FunnelEditForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/app/(main)/websites/[websiteId]/funnels/FunnelsPage.tsx
Normal file
38
src/app/(main)/websites/[websiteId]/funnels/FunnelsPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/websites/[websiteId]/funnels/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/funnels/page.tsx
Normal 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',
|
||||||
|
};
|
||||||
|
|
@ -1,26 +1,10 @@
|
||||||
import { useState } from 'react';
|
import { Grid, Row, Column, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
|
||||||
import {
|
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||||
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 { 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 { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { formatLongNumber } from '@/lib/format';
|
import { formatLongNumber } from '@/lib/format';
|
||||||
import { GoalAddForm } from '@/app/(main)/websites/[websiteId]/goals/GoalAddForm';
|
import { GoalEditForm } from './GoalEditForm';
|
||||||
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
|
|
||||||
|
|
||||||
export interface GoalProps {
|
export interface GoalProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -41,12 +25,12 @@ export type GoalData = { num: number; total: number };
|
||||||
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
||||||
...parameters,
|
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
},
|
},
|
||||||
|
parameters,
|
||||||
});
|
});
|
||||||
const isPage = parameters?.type === 'page';
|
const isPage = parameters?.type === 'page';
|
||||||
|
|
||||||
|
|
@ -62,7 +46,19 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
<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>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
<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>
|
)} / ${formatLongNumber(data?.total)}`}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap="6">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
value={data?.num || 0}
|
value={data?.num || 0}
|
||||||
minValue={0}
|
minValue={0}
|
||||||
|
|
@ -100,88 +96,3 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||||
</LoadingPanel>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, DialogTrigger, 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 { useMessages } from '@/components/hooks';
|
||||||
import { GoalAddForm } from './GoalAddForm';
|
import { GoalEditForm } from './GoalEditForm';
|
||||||
import { Plus } from '@/components/icons';
|
import { Plus } from '@/components/icons';
|
||||||
|
|
||||||
export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
||||||
|
|
@ -12,15 +12,15 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
||||||
<Icon>
|
<Icon>
|
||||||
<Plus />
|
<Plus />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.addGoal)}</Text>
|
<Text>{formatMessage(labels.goal)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog
|
<Dialog
|
||||||
variant="modal"
|
variant="modal"
|
||||||
title={formatMessage(labels.addGoal)}
|
title={formatMessage(labels.goal)}
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
style={{ minHeight: 375, minWidth: 400 }}
|
||||||
>
|
>
|
||||||
{({ close }) => <GoalAddForm websiteId={websiteId} onClose={close} />}
|
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,12 @@ import {
|
||||||
Radio,
|
Radio,
|
||||||
Text,
|
Text,
|
||||||
Icon,
|
Icon,
|
||||||
|
Loading,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
||||||
import { File, Lightning } from '@/components/icons';
|
import { File, Lightning } from '@/components/icons';
|
||||||
|
|
||||||
const defaultValues = {
|
export function GoalEditForm({
|
||||||
name: '',
|
|
||||||
type: 'page',
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function GoalAddForm({
|
|
||||||
id,
|
id,
|
||||||
websiteId,
|
websiteId,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -36,36 +31,46 @@ export function GoalAddForm({
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const { data } = useReportQuery(id);
|
const { data } = useReportQuery(id);
|
||||||
const { mutate, error, isPending } = useMutation({
|
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(
|
mutate(
|
||||||
{ id, ...data },
|
{ ...data, id, name, type: 'goal', websiteId, parameters },
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
if (id) touch(`report:${id}`);
|
||||||
|
touch('reports:goal');
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
touch('goals');
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (id && !data) {
|
if (id && !data) {
|
||||||
return null;
|
return <Loading position="page" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
name: data?.name || '',
|
||||||
|
type: data?.parameters?.type || 'page',
|
||||||
|
value: data?.parameters?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
||||||
onSubmit={handleSubmit}
|
|
||||||
error={error?.message}
|
|
||||||
defaultValues={data?.parameters || defaultValues}
|
|
||||||
>
|
|
||||||
{({ watch }) => {
|
{({ watch }) => {
|
||||||
const watchType = watch('type');
|
const watchType = watch('type');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
label={formatMessage(labels.name)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
<FormField
|
<FormField
|
||||||
name="type"
|
name="type"
|
||||||
label={formatMessage(labels.type)}
|
label={formatMessage(labels.type)}
|
||||||
|
|
@ -88,13 +93,6 @@ export function GoalAddForm({
|
||||||
</Grid>
|
</Grid>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
|
||||||
name="name"
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="value"
|
name="value"
|
||||||
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
||||||
|
|
@ -106,7 +104,7 @@ export function GoalAddForm({
|
||||||
<Button onPress={onClose} isDisabled={isPending}>
|
<Button onPress={onClose} isDisabled={isPending}>
|
||||||
{formatMessage(labels.cancel)}
|
{formatMessage(labels.cancel)}
|
||||||
</Button>
|
</Button>
|
||||||
<FormSubmitButton>{formatMessage(id ? labels.save : labels.add)}</FormSubmitButton>
|
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -4,12 +4,12 @@ import { SectionHeader } from '@/components/common/SectionHeader';
|
||||||
import { Goal } from './Goal';
|
import { Goal } from './Goal';
|
||||||
import { GoalAddButton } from './GoalAddButton';
|
import { GoalAddButton } from './GoalAddButton';
|
||||||
import { WebsiteControls } from '../WebsiteControls';
|
import { WebsiteControls } from '../WebsiteControls';
|
||||||
import { useDateRange, useGoalsQuery } from '@/components/hooks';
|
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
||||||
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||||
const { result } = useGoalsQuery({ websiteId });
|
const { result } = useReportsQuery({ websiteId, type: 'goal' });
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
} = useDateRange(websiteId);
|
} = useDateRange(websiteId);
|
||||||
|
|
@ -26,9 +26,9 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||||
<GoalAddButton websiteId={websiteId} />
|
<GoalAddButton websiteId={websiteId} />
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<Grid columns="1fr 1fr" gap>
|
<Grid columns="1fr 1fr" gap>
|
||||||
{result?.data?.map((goal: any) => (
|
{result?.data?.map((report: any) => (
|
||||||
<Panel key={goal.id}>
|
<Panel key={report.id}>
|
||||||
<Goal {...goal} reportId={goal.id} startDate={startDate} endDate={endDate} />
|
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
||||||
</Panel>
|
</Panel>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { deleteReport, getReport, updateReport } from '@/queries';
|
import { deleteReport, getReport, updateReport } from '@/queries';
|
||||||
import { canDeleteReport, canUpdateReport, canViewReport } from '@/lib/auth';
|
import { canDeleteReport, canUpdateReport, canViewReport } from '@/lib/auth';
|
||||||
import { unauthorized, json, notFound, ok } from '@/lib/response';
|
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 }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) {
|
||||||
const { auth, error } = await parseRequest(request);
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
@ -20,8 +19,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
report.parameters = JSON.parse(report.parameters);
|
|
||||||
|
|
||||||
return json(report);
|
return json(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,15 +26,7 @@ export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ reportId: string }> },
|
{ params }: { params: Promise<{ reportId: string }> },
|
||||||
) {
|
) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportSchema);
|
||||||
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);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -62,8 +51,8 @@ export async function POST(
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: JSON.stringify(parameters),
|
parameters,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getFunnel } from '@/queries';
|
import { getFunnel } from '@/queries';
|
||||||
import { reportParms } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...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);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -27,9 +13,8 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
steps,
|
|
||||||
window,
|
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
|
parameters: { steps, window },
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
|
||||||
34
src/app/api/reports/goal/route.ts
Normal file
34
src/app/api/reports/goal/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { uuid } from '@/lib/crypto';
|
import { uuid } from '@/lib/crypto';
|
||||||
import { pagingParams, reportTypeParam } from '@/lib/schema';
|
import { pagingParams, reportSchema } from '@/lib/schema';
|
||||||
import { parseRequest } from '@/lib/request';
|
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 { unauthorized, json } from '@/lib/response';
|
||||||
import { getReports, createReport } from '@/queries';
|
import { getReports, createReport } from '@/queries';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
websiteId: z.string().uuid().optional(),
|
websiteId: z.string().uuid().optional(),
|
||||||
teamId: z.string().uuid().optional(),
|
type: z.string().optional(),
|
||||||
...pagingParams,
|
...pagingParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -19,53 +19,24 @@ export async function GET(request: Request) {
|
||||||
return error();
|
return error();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, search, pageSize, websiteId, teamId } = query;
|
const { page, search, pageSize, websiteId, type } = query;
|
||||||
const userId = auth.user.id;
|
|
||||||
const filters = {
|
const filters = {
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
search,
|
search,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (websiteId && !(await canViewWebsite(auth, websiteId))) {
|
||||||
(websiteId && !(await canViewWebsite(auth, websiteId))) ||
|
|
||||||
(teamId && !(await canViewTeam(auth, teamId)))
|
|
||||||
) {
|
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getReports(
|
const data = await getReports(
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
websiteId,
|
||||||
...(websiteId ? [{ websiteId }] : []),
|
type,
|
||||||
...(teamId
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
website: {
|
|
||||||
deletedAt: null,
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(userId && !websiteId && !teamId
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
website: {
|
|
||||||
deletedAt: null,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
website: {
|
website: {
|
||||||
select: {
|
deletedAt: null,
|
||||||
domain: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -76,15 +47,7 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportSchema);
|
||||||
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);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -102,9 +65,9 @@ export async function POST(request: Request) {
|
||||||
websiteId,
|
websiteId,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
description,
|
description: description || '',
|
||||||
parameters: JSON.stringify(parameters),
|
parameters,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { getWebsiteReports } from '@/queries';
|
import { getReports } from '@/queries';
|
||||||
import { pagingParams } from '@/lib/schema';
|
import { filterParams, pagingParams } from '@/lib/schema';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
filters: { type: string },
|
||||||
) {
|
) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
...filterParams,
|
||||||
...pagingParams,
|
...pagingParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -26,11 +28,19 @@ export async function GET(
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getWebsiteReports(websiteId, {
|
const data = await getReports(
|
||||||
page: +page,
|
{
|
||||||
pageSize: +pageSize,
|
where: {
|
||||||
search,
|
websiteId,
|
||||||
});
|
type: filters.type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ export function useGoalQuery(
|
||||||
|
|
||||||
return usePagedQuery({
|
return usePagedQuery({
|
||||||
queryKey: ['goal', { websiteId, reportId, ...params }],
|
queryKey: ['goal', { websiteId, reportId, ...params }],
|
||||||
queryFn: (data: any) => {
|
queryFn: () => {
|
||||||
return post(`/reports/goals`, {
|
return post(`/reports/goals`, {
|
||||||
...data,
|
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,8 @@ export function useGoalsQuery(
|
||||||
|
|
||||||
return usePagedQuery({
|
return usePagedQuery({
|
||||||
queryKey: ['goals', { websiteId, modified, ...params }],
|
queryKey: ['goals', { websiteId, modified, ...params }],
|
||||||
queryFn: (data: any) => {
|
queryFn: () => {
|
||||||
return get(`/websites/${websiteId}/goals`, {
|
return get(`/websites/${websiteId}/goals`, {
|
||||||
...data,
|
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ export function useReportQuery(reportId: string) {
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['report', { reportId, modified }],
|
queryKey: ['report', { reportId, modified }],
|
||||||
queryFn: (data: any) => {
|
queryFn: () => {
|
||||||
return get(`/reports/${reportId}`, {
|
return get(`/reports/${reportId}`);
|
||||||
...data,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
enabled: !!reportId,
|
enabled: !!reportId,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,13 @@ import { useApi } from '../useApi';
|
||||||
import { usePagedQuery } from '../usePagedQuery';
|
import { usePagedQuery } from '../usePagedQuery';
|
||||||
import { useModified } from '../useModified';
|
import { useModified } from '../useModified';
|
||||||
|
|
||||||
export function useReportsQuery({ websiteId, teamId }: { websiteId?: string; teamId?: string }) {
|
export function useReportsQuery({ websiteId, type }: { websiteId: string; type?: string }) {
|
||||||
const { modified } = useModified(`reports`);
|
const { modified } = useModified(`reports:${type}`);
|
||||||
const { get } = useApi();
|
const { get } = useApi();
|
||||||
|
|
||||||
return usePagedQuery({
|
return usePagedQuery({
|
||||||
queryKey: ['reports', { websiteId, teamId, modified }],
|
queryKey: ['reports', { websiteId, type, modified }],
|
||||||
queryFn: (params: any) => {
|
queryFn: async () => get('/reports', { websiteId, type }),
|
||||||
return get('/reports', { websiteId, teamId, ...params });
|
enabled: !!websiteId && !!type,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export function useResultQuery<T>(
|
||||||
|
|
||||||
return useQuery<T>({
|
return useQuery<T>({
|
||||||
queryKey: ['reports', type, params],
|
queryKey: ['reports', type, params],
|
||||||
queryFn: () => post(`/reports/${type}`, params),
|
queryFn: () => post(`/reports/${type}`, { type, ...params }),
|
||||||
enabled: !!type,
|
enabled: !!type,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@ export function useWebsiteSessionsQuery(
|
||||||
|
|
||||||
return usePagedQuery({
|
return usePagedQuery({
|
||||||
queryKey: ['sessions', { websiteId, modified, ...params, ...filters }],
|
queryKey: ['sessions', { websiteId, modified, ...params, ...filters }],
|
||||||
queryFn: (data: any) => {
|
queryFn: () => {
|
||||||
return get(`/websites/${websiteId}/sessions`, {
|
return get(`/websites/${websiteId}/sessions`, {
|
||||||
...data,
|
|
||||||
...params,
|
...params,
|
||||||
...filters,
|
...filters,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,8 @@ export function useWebsites(
|
||||||
|
|
||||||
return usePagedQuery({
|
return usePagedQuery({
|
||||||
queryKey: ['websites', { userId, teamId, modified, ...params }],
|
queryKey: ['websites', { userId, teamId, modified, ...params }],
|
||||||
queryFn: (data: any) => {
|
queryFn: () => {
|
||||||
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {
|
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {
|
||||||
...data,
|
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
100
src/components/input/ReportEditButton.tsx
Normal file
100
src/components/input/ReportEditButton.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -277,7 +277,6 @@ export const labels = defineMessages({
|
||||||
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
|
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
|
||||||
goal: { id: 'label.goal', defaultMessage: 'Goal' },
|
goal: { id: 'label.goal', defaultMessage: 'Goal' },
|
||||||
goals: { id: 'label.goals', defaultMessage: 'Goals' },
|
goals: { id: 'label.goals', defaultMessage: 'Goals' },
|
||||||
addGoal: { id: 'label.add-goal', defaultMessage: 'Add Goal' },
|
|
||||||
goalsDescription: {
|
goalsDescription: {
|
||||||
id: 'label.goals-description',
|
id: 'label.goals-description',
|
||||||
defaultMessage: 'Track your goals for pageviews and events.',
|
defaultMessage: 'Track your goals for pageviews and events.',
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,12 @@
|
||||||
|
|
||||||
.positive {
|
.positive {
|
||||||
color: var(--success-color);
|
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 {
|
.negative {
|
||||||
color: var(--danger-color);
|
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 {
|
.neutral {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Icon, Text } from '@umami/react-zen';
|
import { Icon, Text } from '@umami/react-zen';
|
||||||
import { ReactNode } from 'react';
|
import { HTMLAttributes, ReactNode } from 'react';
|
||||||
import { Arrow } from '@/components/icons';
|
import { Arrow } from '@/components/icons';
|
||||||
import styles from './ChangeLabel.module.css';
|
import styles from './ChangeLabel.module.css';
|
||||||
|
|
||||||
export function ChangeLabel({
|
export function ChangeLabel({
|
||||||
value,
|
value,
|
||||||
size,
|
size,
|
||||||
title,
|
|
||||||
reverseColors,
|
reverseColors,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
|
|
@ -19,7 +19,7 @@ export function ChangeLabel({
|
||||||
showPercentage?: boolean;
|
showPercentage?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
} & HTMLAttributes<HTMLDivElement>) {
|
||||||
const positive = value >= 0;
|
const positive = value >= 0;
|
||||||
const negative = value < 0;
|
const negative = value < 0;
|
||||||
const neutral = value === 0 || isNaN(value);
|
const neutral = value === 0 || isNaN(value);
|
||||||
|
|
@ -27,12 +27,12 @@ export function ChangeLabel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
{...props}
|
||||||
className={classNames(styles.label, className, {
|
className={classNames(styles.label, className, {
|
||||||
[styles.positive]: good,
|
[styles.positive]: good,
|
||||||
[styles.negative]: !good,
|
[styles.negative]: !good,
|
||||||
[styles.neutral]: neutral,
|
[styles.neutral]: neutral,
|
||||||
})}
|
})}
|
||||||
title={title}
|
|
||||||
>
|
>
|
||||||
{!neutral && (
|
{!neutral && (
|
||||||
<Icon rotate={positive ? -90 : 90} size={size}>
|
<Icon rotate={positive ? -90 : 90} size={size}>
|
||||||
|
|
|
||||||
1
src/declaration.d.ts
vendored
1
src/declaration.d.ts
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
declare module 'bcryptjs';
|
declare module 'bcryptjs';
|
||||||
declare module 'chartjs-adapter-date-fns';
|
declare module 'chartjs-adapter-date-fns';
|
||||||
declare module 'cors';
|
declare module 'cors';
|
||||||
|
declare module 'date-fns-tz';
|
||||||
declare module 'debug';
|
declare module 'debug';
|
||||||
declare module 'fs-extra';
|
declare module 'fs-extra';
|
||||||
declare module 'jsonwebtoken';
|
declare module 'jsonwebtoken';
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,8 @@ export const DATA_TYPES = {
|
||||||
|
|
||||||
export const REPORT_TYPES = {
|
export const REPORT_TYPES = {
|
||||||
funnel: 'funnel',
|
funnel: 'funnel',
|
||||||
goals: 'goals',
|
goals: 'goal',
|
||||||
insights: 'insights',
|
insights: 'insight',
|
||||||
retention: 'retention',
|
retention: 'retention',
|
||||||
utm: 'utm',
|
utm: 'utm',
|
||||||
journey: 'journey',
|
journey: 'journey',
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,10 @@ export const urlOrPathParam = z.string().refine(
|
||||||
|
|
||||||
export const reportTypeParam = z.enum([
|
export const reportTypeParam = z.enum([
|
||||||
'funnel',
|
'funnel',
|
||||||
'insights',
|
'insight',
|
||||||
'retention',
|
'retention',
|
||||||
'utm',
|
'utm',
|
||||||
'goals',
|
'goal',
|
||||||
'journey',
|
'journey',
|
||||||
'revenue',
|
'revenue',
|
||||||
'attribution',
|
'attribution',
|
||||||
|
|
@ -76,3 +76,58 @@ export const reportParms = {
|
||||||
value: z.string().optional(),
|
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,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,7 @@ export async function createReport(data: Prisma.ReportUncheckedCreateInput): Pro
|
||||||
return prisma.client.report.create({ data });
|
return prisma.client.report.create({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateReport(
|
export async function updateReport(reportId: string, data: any): Promise<Report> {
|
||||||
reportId: string,
|
|
||||||
data: Prisma.ReportUpdateInput,
|
|
||||||
): Promise<Report> {
|
|
||||||
return prisma.client.report.update({ where: { id: reportId }, data });
|
return prisma.client.report.update({ where: { id: reportId }, data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,14 @@ import clickhouse from '@/lib/clickhouse';
|
||||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
|
export interface FunnelCriteria {
|
||||||
return steps.map((step: { type: string; value: string }, i: number) => {
|
windowMinutes: number;
|
||||||
const visitors = Number(results[i]?.count) || 0;
|
startDate: Date;
|
||||||
const previous = Number(results[i - 1]?.count) || 0;
|
endDate: Date;
|
||||||
const dropped = previous > 0 ? previous - visitors : 0;
|
steps: { type: string; value: string }[];
|
||||||
const dropoff = 1 - visitors / previous;
|
}
|
||||||
const remaining = visitors / Number(results[0].count);
|
|
||||||
|
|
||||||
return {
|
export async function getFunnel(...args: [websiteId: string, criteria: FunnelCriteria]) {
|
||||||
...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 }[];
|
|
||||||
},
|
|
||||||
]
|
|
||||||
) {
|
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
|
@ -40,12 +18,7 @@ export async function getFunnel(
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: FunnelCriteria,
|
||||||
windowMinutes: number;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
steps: { type: string; value: string }[];
|
|
||||||
},
|
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -70,7 +43,7 @@ async function relationalQuery(
|
||||||
(pv, cv, i) => {
|
(pv, cv, i) => {
|
||||||
const levelNumber = i + 1;
|
const levelNumber = i + 1;
|
||||||
const startSum = i > 0 ? 'union ' : '';
|
const startSum = i > 0 ? 'union ' : '';
|
||||||
const isURL = cv.type === 'url';
|
const isURL = cv.type === 'page';
|
||||||
const column = isURL ? 'url_path' : 'event_name';
|
const column = isURL ? 'url_path' : 'event_name';
|
||||||
|
|
||||||
let operator = '=';
|
let operator = '=';
|
||||||
|
|
@ -139,12 +112,7 @@ async function relationalQuery(
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: FunnelCriteria,
|
||||||
windowMinutes: number;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
steps: { type: string; value: string }[];
|
|
||||||
},
|
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -174,7 +142,7 @@ async function clickhouseQuery(
|
||||||
const levelNumber = i + 1;
|
const levelNumber = i + 1;
|
||||||
const startSum = i > 0 ? 'union all ' : '';
|
const startSum = i > 0 ? 'union all ' : '';
|
||||||
const startFilter = i > 0 ? 'or' : '';
|
const startFilter = i > 0 ? 'or' : '';
|
||||||
const isURL = cv.type === 'url';
|
const isURL = cv.type === 'page';
|
||||||
const column = isURL ? 'url_path' : 'event_name';
|
const column = isURL ? 'url_path' : 'event_name';
|
||||||
|
|
||||||
let operator = '=';
|
let operator = '=';
|
||||||
|
|
@ -248,3 +216,22 @@ async function clickhouseQuery(
|
||||||
},
|
},
|
||||||
).then(formatResults(steps));
|
).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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:where(svg) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 15px;
|
width: 15px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue