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