More work on reports. Added Funnel page.

This commit is contained in:
Mike Cao 2025-06-05 22:19:35 -07:00
parent 5159dd470f
commit 3847e32f39
59 changed files with 1815 additions and 2370 deletions

View file

@ -41,7 +41,7 @@ RUN set -x \
&& apk add --no-cache curl && 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/

View file

@ -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])

View file

@ -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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { TextArea } from '@umami/react-zen'; import { TextField } from '@umami/react-zen';
import { useMessages, useConfig } from '@/components/hooks'; 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 />
</> </>
); );
} }

View file

@ -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',
}, },

View file

@ -0,0 +1,117 @@
import { Grid, Column, Row, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
import { useMessages, useResultQuery } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { File, Lightning, User } from '@/components/icons';
import { formatLongNumber } from '@/lib/format';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { FunnelEditForm } from './FunnelEditForm';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
type FunnelResult = {
type: string;
value: string;
visitors: number;
previous: number;
dropped: number;
droppoff: number;
remaining: number;
};
export function Funnel({ id, name, type, parameters, websiteId, startDate, endDate }) {
const { formatMessage, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>(type, {
websiteId,
dateRange: {
startDate,
endDate,
},
parameters,
});
return (
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
<Grid gap>
<Grid columns="1fr auto" gap>
<Column gap>
<Row>
<Text size="4" weight="bold">
{name}
</Text>
</Row>
</Column>
<Column>
<ReportEditButton id={id} name={name} type={type}>
{({ close }) => {
return (
<Dialog title={formatMessage(labels.funnel)} variant="modal">
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog>
);
}}
</ReportEditButton>
</Column>
</Grid>
{data?.map(
(
{ type, value, visitors, previous, dropped, remaining }: FunnelResult,
index: number,
) => {
const isPage = type === 'page';
return (
<Grid key={index} columns="auto 1fr" gap="6">
<Column>
<Row
borderRadius="full"
backgroundColor="2"
width="40px"
height="40px"
justifyContent="center"
alignItems="center"
>
<Text weight="bold" size="4">
{index + 1}
</Text>
</Row>
</Column>
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted">
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
</Text>
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
</Row>
<Row alignItems="center" justifyContent="space-between" gap>
<Row alignItems="center" gap>
<Icon>{type === 'page' ? <File /> : <Lightning />}</Icon>
<Text>{value}</Text>
</Row>
<Row alignItems="center" gap>
{index > 0 && (
<ChangeLabel value={-dropped}>{formatLongNumber(dropped)}</ChangeLabel>
)}
<Icon>
<User />
</Icon>
<Text title={visitors.toString()}>{formatLongNumber(visitors)}</Text>
</Row>
</Row>
<Row alignItems="center" gap="6">
<ProgressBar
value={visitors || 0}
minValue={0}
maxValue={previous || 1}
style={{ width: '100%' }}
/>
<Text weight="bold" size="7">
{Math.round(remaining * 100)}%
</Text>
</Row>
</Column>
</Grid>
);
},
)}
</Grid>
</LoadingPanel>
);
}

View file

@ -0,0 +1,28 @@
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { FunnelEditForm } from './FunnelEditForm';
import { Plus } from '@/components/icons';
export function FunnelAddButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.funnel)}</Text>
</Button>
<Modal>
<Dialog
variant="modal"
title={formatMessage(labels.funnel)}
style={{ minHeight: 375, minWidth: 600 }}
>
{({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,158 @@
import {
Form,
FormField,
FormFieldArray,
TextField,
Grid,
FormController,
FormButtons,
FormSubmitButton,
Button,
RadioGroup,
Radio,
Text,
Icon,
Row,
Loading,
} from '@umami/react-zen';
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
import { File, Lightning, Close, Plus } from '@/components/icons';
const FUNNEL_STEPS_MAX = 8;
export function FunnelEditForm({
id,
websiteId,
onSave,
onClose,
}: {
id?: string;
websiteId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { touch } = useModified();
const { post, useMutation } = useApi();
const { data } = useReportQuery(id);
const { mutate, error, isPending } = useMutation({
mutationFn: (params: any) => post(`/reports${id ? `/${id}` : ''}`, params),
});
const handleSubmit = async ({ name, ...parameters }) => {
mutate(
{ ...data, id, name, type: 'funnel', websiteId, parameters },
{
onSuccess: async () => {
touch('reports:funnel');
onSave?.();
onClose?.();
},
},
);
};
if (id && !data) {
return <Loading position="page" />;
}
const defaultValues = {
name: data?.name || '',
window: data?.parameters?.window || 60,
steps: data?.parameters?.steps || [{ type: 'page', value: '/' }],
};
return (
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoFocus />
</FormField>
<FormField
name="window"
label={formatMessage(labels.window)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormFieldArray name="steps" label={formatMessage(labels.steps)}>
{({ fields, append, remove, control }) => {
return (
<Grid gap>
{fields.map((field: { id: string; type: string; value: string }, index: number) => {
return (
<Row key={field.id} alignItems="center" justifyContent="space-between" gap>
<FormController control={control} name={`steps.${index}.type`}>
{({ field }) => {
return (
<RadioGroup
orientation="horizontal"
variant="box"
value={field.value}
onChange={field.onChange}
>
<Grid columns="1fr 1fr" flexGrow={1} gap>
<Radio id="page" value="page">
<Icon>
<File />
</Icon>
<Text>{formatMessage(labels.page)}</Text>
</Radio>
<Radio id="event" value="event">
<Icon>
<Lightning />
</Icon>
<Text>{formatMessage(labels.event)}</Text>
</Radio>
</Grid>
</RadioGroup>
);
}}
</FormController>
<FormController control={control} name={`steps.${index}.value`}>
{({ field }) => {
return (
<TextField
value={field.value}
onChange={field.onChange}
defaultValue={field.value}
style={{ flexGrow: 1 }}
/>
);
}}
</FormController>
<Button variant="quiet" onPress={() => remove(index)}>
<Icon size="sm">
<Close />
</Icon>
</Button>
</Row>
);
})}
<Row>
<Button
onPress={() => append({ type: 'page', value: '/' })}
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.add)}</Text>
</Button>
</Row>
</Grid>
);
}}
</FormFieldArray>
<FormButtons>
<Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -0,0 +1,38 @@
'use client';
import { Grid, Loading } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton';
import { WebsiteControls } from '../WebsiteControls';
import { useDateRange, useReportsQuery } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { result } = useReportsQuery({ websiteId, type: 'funnel' });
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
if (!result) {
return <Loading position="page" />;
}
return (
<>
<WebsiteControls websiteId={websiteId} />
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
<Grid gap>
{result?.data?.map((report: any) => (
<Panel key={report.id}>
<Funnel {...report} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>
</LoadingPanel>
</>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { FunnelsPage } from './FunnelsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <FunnelsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Funnels',
};

View file

@ -1,26 +1,10 @@
import { useState } from 'react'; import { Grid, Row, Column, Text, Icon, 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>
</>
);
};

View file

@ -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>

View file

@ -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>
</> </>
); );

View file

@ -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>

View file

@ -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);
} }

View file

@ -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))) {

View file

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

View file

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

View file

@ -1,15 +1,15 @@
import { z } from 'zod'; import { 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);
} }

View file

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

View file

@ -1,15 +1,17 @@
import { z } from 'zod'; import { 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);
} }

View file

@ -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,
}); });
}, },

View file

@ -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,
}); });
}, },

View file

@ -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,
}); });

View file

@ -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,
},
}); });
} }

View file

@ -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,
}); });

View file

@ -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,

View file

@ -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,
}); });
}, },

View file

@ -0,0 +1,100 @@
import { ReactNode, useState } from 'react';
import { useMessages } from '@/components/hooks';
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
import {
AlertDialog,
Button,
Icon,
Menu,
MenuItem,
MenuTrigger,
Modal,
Popover,
Text,
Row,
} from '@umami/react-zen';
import { Edit, More, Trash } from '@/components/icons';
export function ReportEditButton({
id,
name,
type,
children,
onDelete,
}: {
id: string;
name: string;
type: string;
onDelete?: () => void;
children: ({ close }: { close: () => void }) => ReactNode;
}) {
const { formatMessage, labels, messages } = useMessages();
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const { mutate, touch } = useDeleteQuery(`/reports/${id}`);
const handleAction = (id: any) => {
if (id === 'edit') {
setShowEdit(true);
} else if (id === 'delete') {
setShowDelete(true);
}
};
const handleClose = () => {
setShowEdit(false);
setShowDelete(false);
};
const handleDelete = async () => {
mutate(null, {
onSuccess: async () => {
touch(`reports:${type}`);
setShowDelete(false);
onDelete?.();
},
});
};
return (
<>
<MenuTrigger>
<Button variant="quiet">
<Icon>
<More />
</Icon>
</Button>
<Popover placement="bottom">
<Menu onAction={handleAction}>
<MenuItem id="edit">
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</MenuItem>
<MenuItem id="delete">
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
<Modal isOpen={showEdit || showDelete} isDismissable={true}>
{showEdit && children({ close: handleClose })}
{showDelete && (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleDelete}
onCancel={handleClose}
>
<Row gap="1">
{formatMessage(messages.confirmDelete, { target: <b key={name}>{name}</b> })}
</Row>
</AlertDialog>
)}
</Modal>
</>
);
}

View file

@ -277,7 +277,6 @@ export const labels = defineMessages({
addStep: { id: 'label.add-step', defaultMessage: 'Add step' }, 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.',

View file

@ -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 {

View file

@ -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}>

View file

@ -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';

View file

@ -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',

View file

@ -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,
);

View file

@ -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 });
} }

View file

@ -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,
};
});
};

View file

@ -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;