mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 16:45:35 +01:00
Goals report CH
This commit is contained in:
parent
1dda711401
commit
60e7257656
20 changed files with 758 additions and 4 deletions
|
|
@ -4,6 +4,7 @@ import EventDataReport from '../event-data/EventDataReport';
|
||||||
import InsightsReport from '../insights/InsightsReport';
|
import InsightsReport from '../insights/InsightsReport';
|
||||||
import RetentionReport from '../retention/RetentionReport';
|
import RetentionReport from '../retention/RetentionReport';
|
||||||
import UTMReport from '../utm/UTMReport';
|
import UTMReport from '../utm/UTMReport';
|
||||||
|
import GoalReport from '../goals/GoalsReport';
|
||||||
import { useReport } from 'components/hooks';
|
import { useReport } from 'components/hooks';
|
||||||
|
|
||||||
const reports = {
|
const reports = {
|
||||||
|
|
@ -12,6 +13,7 @@ const reports = {
|
||||||
insights: InsightsReport,
|
insights: InsightsReport,
|
||||||
retention: RetentionReport,
|
retention: RetentionReport,
|
||||||
utm: UTMReport,
|
utm: UTMReport,
|
||||||
|
goals: GoalReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReportPage({ reportId }: { reportId: string }) {
|
export default function ReportPage({ reportId }: { reportId: string }) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
|
||||||
url: renderTeamUrl('/reports/utm'),
|
url: renderTeamUrl('/reports/utm'),
|
||||||
icon: <Tag />,
|
icon: <Tag />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: formatMessage(labels.goals),
|
||||||
|
description: formatMessage(labels.goalsDescription),
|
||||||
|
url: renderTeamUrl('/reports/goals'),
|
||||||
|
icon: <Tag />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
7
src/app/(main)/reports/goals/GoalsAddForm.module.css
Normal file
7
src/app/(main)/reports/goals/GoalsAddForm.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.dropdown {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
95
src/app/(main)/reports/goals/GoalsAddForm.tsx
Normal file
95
src/app/(main)/reports/goals/GoalsAddForm.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useMessages } from 'components/hooks';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Dropdown, Flexbox, FormRow, Item, TextField } from 'react-basics';
|
||||||
|
import styles from './GoalsAddForm.module.css';
|
||||||
|
|
||||||
|
export function GoalsAddForm({
|
||||||
|
type: defaultType = 'url',
|
||||||
|
value: defaultValue = '',
|
||||||
|
goal: defaultGoal = 10,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
goal?: number;
|
||||||
|
onChange?: (step: { type: string; value: string; goal: number }) => void;
|
||||||
|
}) {
|
||||||
|
const [type, setType] = useState(defaultType);
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const [goal, setGoal] = useState(defaultGoal);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const items = [
|
||||||
|
{ label: formatMessage(labels.url), value: 'url' },
|
||||||
|
{ label: formatMessage(labels.event), value: 'event' },
|
||||||
|
];
|
||||||
|
const isDisabled = !type || !value;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onChange({ type, value, goal });
|
||||||
|
setValue('');
|
||||||
|
setGoal(10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e, set) => {
|
||||||
|
set(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTypeValue = (value: any) => {
|
||||||
|
return items.find(item => item.value === value)?.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox direction="column" gap={10}>
|
||||||
|
<FormRow label={formatMessage(defaultValue ? labels.update : labels.add)}>
|
||||||
|
<Flexbox gap={10}>
|
||||||
|
<Dropdown
|
||||||
|
className={styles.dropdown}
|
||||||
|
items={items}
|
||||||
|
value={type}
|
||||||
|
renderValue={renderTypeValue}
|
||||||
|
onChange={(value: any) => setType(value)}
|
||||||
|
>
|
||||||
|
{({ value, label }) => {
|
||||||
|
return <Item key={value}>{label}</Item>;
|
||||||
|
}}
|
||||||
|
</Dropdown>
|
||||||
|
<TextField
|
||||||
|
className={styles.input}
|
||||||
|
value={value}
|
||||||
|
onChange={e => handleChange(e, setValue)}
|
||||||
|
autoFocus={true}
|
||||||
|
autoComplete="off"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</Flexbox>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label={formatMessage(labels.goal)}>
|
||||||
|
<Flexbox gap={10}>
|
||||||
|
<TextField
|
||||||
|
className={styles.input}
|
||||||
|
value={goal?.toString()}
|
||||||
|
onChange={e => handleChange(e, setGoal)}
|
||||||
|
autoFocus={true}
|
||||||
|
autoComplete="off"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
.
|
||||||
|
</Flexbox>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
|
||||||
|
{formatMessage(defaultValue ? labels.update : labels.add)}
|
||||||
|
</Button>
|
||||||
|
</FormRow>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoalsAddForm;
|
||||||
87
src/app/(main)/reports/goals/GoalsChart.module.css
Normal file
87
src/app/(main)/reports/goals/GoalsChart.module.css
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
49
src/app/(main)/reports/goals/GoalsChart.tsx
Normal file
49
src/app/(main)/reports/goals/GoalsChart.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useMessages } from 'components/hooks';
|
||||||
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
import { formatLongNumber } from 'lib/format';
|
||||||
|
import styles from './GoalsChart.module.css';
|
||||||
|
|
||||||
|
export function GoalsChart({ className }: { className?: string; isLoading?: boolean }) {
|
||||||
|
const { report } = useContext(ReportContext);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const { data } = report || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.chart, className)}>
|
||||||
|
{data?.map(({ type, value, goal, result }, 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(result)}</span>
|
||||||
|
{formatMessage(labels.visitors)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.visitors}>{formatLongNumber(goal)}</span>
|
||||||
|
{formatMessage(labels.goal)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.percent}>{(result / goal).toFixed(2)}%</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.track}>
|
||||||
|
<div className={styles.bar} style={{ width: `${result / goal}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoalsChart;
|
||||||
26
src/app/(main)/reports/goals/GoalsParameters.module.css
Normal file
26
src/app/(main)/reports/goals/GoalsParameters.module.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
color: var(--base700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: flex;
|
||||||
|
align-self: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal {
|
||||||
|
color: var(--blue900);
|
||||||
|
background-color: var(--blue100);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
123
src/app/(main)/reports/goals/GoalsParameters.tsx
Normal file
123
src/app/(main)/reports/goals/GoalsParameters.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useMessages } from 'components/hooks';
|
||||||
|
import Icons from 'components/icons';
|
||||||
|
import { formatNumber } from 'lib/format';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormButtons,
|
||||||
|
FormRow,
|
||||||
|
Icon,
|
||||||
|
Popup,
|
||||||
|
PopupTrigger,
|
||||||
|
SubmitButton,
|
||||||
|
} from 'react-basics';
|
||||||
|
import BaseParameters from '../[reportId]/BaseParameters';
|
||||||
|
import ParameterList from '../[reportId]/ParameterList';
|
||||||
|
import PopupForm from '../[reportId]/PopupForm';
|
||||||
|
import { ReportContext } from '../[reportId]/Report';
|
||||||
|
import GoalsAddForm from './GoalsAddForm';
|
||||||
|
import styles from './GoalsParameters.module.css';
|
||||||
|
|
||||||
|
export function GoalsParameters() {
|
||||||
|
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||||
|
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 (
|
||||||
|
<PopupTrigger>
|
||||||
|
<Button>
|
||||||
|
<Icon>
|
||||||
|
<Icons.Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Popup alignment="start">
|
||||||
|
<PopupForm>
|
||||||
|
<GoalsAddForm onChange={handleAddGoals} />
|
||||||
|
</PopupForm>
|
||||||
|
</Popup>
|
||||||
|
</PopupTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||||
|
<BaseParameters allowWebsiteSelect={!id} />
|
||||||
|
<FormRow label={formatMessage(labels.goals)} action={<AddGoalsButton />}>
|
||||||
|
<ParameterList>
|
||||||
|
{goals.map((goal: { type: string; value: string; goal: number }, index: number) => {
|
||||||
|
return (
|
||||||
|
<PopupTrigger key={index}>
|
||||||
|
<ParameterList.Item
|
||||||
|
className={styles.item}
|
||||||
|
onRemove={() => handleRemoveGoals(index)}
|
||||||
|
>
|
||||||
|
<div className={styles.value}>
|
||||||
|
<div className={styles.type}>
|
||||||
|
<Icon>{goal.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
|
||||||
|
</div>
|
||||||
|
<div>{goal.value}</div>
|
||||||
|
<div className={styles.goal}>{formatNumber(goal.goal)}</div>
|
||||||
|
</div>
|
||||||
|
</ParameterList.Item>
|
||||||
|
<Popup alignment="start">
|
||||||
|
{(close: () => void) => (
|
||||||
|
<PopupForm>
|
||||||
|
<GoalsAddForm
|
||||||
|
type={goal.type}
|
||||||
|
value={goal.value}
|
||||||
|
goal={goal.goal}
|
||||||
|
onChange={handleUpdateGoals.bind(null, close, index)}
|
||||||
|
/>
|
||||||
|
</PopupForm>
|
||||||
|
)}
|
||||||
|
</Popup>
|
||||||
|
</PopupTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ParameterList>
|
||||||
|
</FormRow>
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
|
||||||
|
{formatMessage(labels.runQuery)}
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoalsParameters;
|
||||||
10
src/app/(main)/reports/goals/GoalsReport.module.css
Normal file
10
src/app/(main)/reports/goals/GoalsReport.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
27
src/app/(main)/reports/goals/GoalsReport.tsx
Normal file
27
src/app/(main)/reports/goals/GoalsReport.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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 Goals from 'assets/funnel.svg';
|
||||||
|
import { REPORT_TYPES } from 'lib/constants';
|
||||||
|
|
||||||
|
const defaultParameters = {
|
||||||
|
type: REPORT_TYPES.goals,
|
||||||
|
parameters: { goals: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GoalsReport({ reportId }: { reportId?: string }) {
|
||||||
|
return (
|
||||||
|
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||||
|
<ReportHeader icon={<Goals />} />
|
||||||
|
<ReportMenu>
|
||||||
|
<GoalsParameters />
|
||||||
|
</ReportMenu>
|
||||||
|
<ReportBody>
|
||||||
|
<GoalsChart />
|
||||||
|
</ReportBody>
|
||||||
|
</Report>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/app/(main)/reports/goals/GoalsReportPage.tsx
Normal file
6
src/app/(main)/reports/goals/GoalsReportPage.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
'use client';
|
||||||
|
import GoalReport from './GoalsReport';
|
||||||
|
|
||||||
|
export default function GoalReportPage() {
|
||||||
|
return <GoalReport />;
|
||||||
|
}
|
||||||
10
src/app/(main)/reports/goals/page.tsx
Normal file
10
src/app/(main)/reports/goals/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import GoalsReportPage from './GoalsReportPage';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return <GoalsReportPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Goals Report',
|
||||||
|
};
|
||||||
|
|
@ -6,5 +6,5 @@ export default function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'UTM Report',
|
title: 'Goals Report',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,13 @@ export const labels = defineMessages({
|
||||||
},
|
},
|
||||||
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
steps: { id: 'label.steps', defaultMessage: 'Steps' },
|
||||||
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
|
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
|
||||||
|
goal: { id: 'label.goal', defaultMessage: 'Goal' },
|
||||||
|
goals: { id: 'label.goals', defaultMessage: 'Goals' },
|
||||||
|
goalsDescription: {
|
||||||
|
id: 'label.goals-description',
|
||||||
|
defaultMessage: 'Track your goals for pageviews or events.',
|
||||||
|
},
|
||||||
|
count: { id: 'label.count', defaultMessage: 'Count' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,10 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rawQuery(query: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
async function rawQuery<T = unknown>(
|
||||||
|
query: string,
|
||||||
|
params: Record<string, unknown> = {},
|
||||||
|
): Promise<T> {
|
||||||
if (process.env.LOG_QUERY) {
|
if (process.env.LOG_QUERY) {
|
||||||
log('QUERY:\n', query);
|
log('QUERY:\n', query);
|
||||||
log('PARAMETERS:\n', params);
|
log('PARAMETERS:\n', params);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ export const DATA_TYPES = {
|
||||||
|
|
||||||
export const REPORT_TYPES = {
|
export const REPORT_TYPES = {
|
||||||
funnel: 'funnel',
|
funnel: 'funnel',
|
||||||
|
goals: 'goals',
|
||||||
insights: 'insights',
|
insights: 'insights',
|
||||||
retention: 'retention',
|
retention: 'retention',
|
||||||
utm: 'utm',
|
utm: 'utm',
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const schema: YupRequest = {
|
||||||
websiteId: yup.string().uuid().required(),
|
websiteId: yup.string().uuid().required(),
|
||||||
type: yup
|
type: yup
|
||||||
.string()
|
.string()
|
||||||
.matches(/funnel|insights|retention|utm/i)
|
.matches(/funnel|insights|retention|utm|goals/i)
|
||||||
.required(),
|
.required(),
|
||||||
name: yup.string().max(200).required(),
|
name: yup.string().max(200).required(),
|
||||||
description: yup.string().max(500),
|
description: yup.string().max(500),
|
||||||
|
|
|
||||||
70
src/pages/api/reports/goals.ts
Normal file
70
src/pages/api/reports/goals.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { canViewWebsite } from 'lib/auth';
|
||||||
|
import { useAuth, useCors, useValidate } from 'lib/middleware';
|
||||||
|
import { NextApiRequestQueryBody } from 'lib/types';
|
||||||
|
import { TimezoneTest } from 'lib/yup';
|
||||||
|
import { NextApiResponse } from 'next';
|
||||||
|
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||||
|
import { getGoals } from 'queries/analytics/reports/getGoals';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export interface RetentionRequestBody {
|
||||||
|
websiteId: string;
|
||||||
|
dateRange: { startDate: string; endDate: string; timezone: string };
|
||||||
|
goals: { type: string; value: string; goal: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
POST: yup.object().shape({
|
||||||
|
websiteId: yup.string().uuid().required(),
|
||||||
|
dateRange: yup
|
||||||
|
.object()
|
||||||
|
.shape({
|
||||||
|
startDate: yup.date().required(),
|
||||||
|
endDate: yup.date().required(),
|
||||||
|
timezone: TimezoneTest,
|
||||||
|
})
|
||||||
|
.required(),
|
||||||
|
goals: yup
|
||||||
|
.array()
|
||||||
|
.of(
|
||||||
|
yup.object().shape({
|
||||||
|
type: yup.string().required(),
|
||||||
|
value: yup.string().required(),
|
||||||
|
goal: yup.number().required(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.required(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async (
|
||||||
|
req: NextApiRequestQueryBody<any, RetentionRequestBody>,
|
||||||
|
res: NextApiResponse,
|
||||||
|
) => {
|
||||||
|
await useCors(req, res);
|
||||||
|
await useAuth(req, res);
|
||||||
|
await useValidate(schema, req, res);
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const {
|
||||||
|
websiteId,
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
goals,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!(await canViewWebsite(req.auth, websiteId))) {
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getGoals(websiteId, {
|
||||||
|
startDate: new Date(startDate),
|
||||||
|
endDate: new Date(endDate),
|
||||||
|
goals,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok(res, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return methodNotAllowed(res);
|
||||||
|
};
|
||||||
|
|
@ -27,7 +27,7 @@ const schema = {
|
||||||
name: yup.string().max(200).required(),
|
name: yup.string().max(200).required(),
|
||||||
type: yup
|
type: yup
|
||||||
.string()
|
.string()
|
||||||
.matches(/funnel|insights|retention|utm/i)
|
.matches(/funnel|insights|retention|utm|goals/i)
|
||||||
.required(),
|
.required(),
|
||||||
description: yup.string().max(500),
|
description: yup.string().max(500),
|
||||||
parameters: yup
|
parameters: yup
|
||||||
|
|
|
||||||
225
src/queries/analytics/reports/getGoals.ts
Normal file
225
src/queries/analytics/reports/getGoals.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import clickhouse from 'lib/clickhouse';
|
||||||
|
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
|
||||||
|
export async function getGoals(
|
||||||
|
...args: [
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
goals: { type: string; value: string; goal: number }[];
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
return runQuery({
|
||||||
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function relationalQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
goals: { type: string; value: string; goal: number }[];
|
||||||
|
},
|
||||||
|
): Promise<any> {
|
||||||
|
const { startDate, endDate, goals } = criteria;
|
||||||
|
const { rawQuery } = prisma;
|
||||||
|
|
||||||
|
const hasUrl = goals.some(a => a.type === 'url');
|
||||||
|
const hasEvent = goals.some(a => a.type === 'event');
|
||||||
|
|
||||||
|
function getParameters(goals: { type: string; value: string; goal: number }[]) {
|
||||||
|
const urls = goals
|
||||||
|
.filter(a => a.type === 'url')
|
||||||
|
.reduce((acc, cv, i) => {
|
||||||
|
acc[`${cv.type}${i}`] = cv.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const events = goals
|
||||||
|
.filter(a => a.type === 'event')
|
||||||
|
.reduce((acc, cv, i) => {
|
||||||
|
acc[`${cv.type}${i}`] = cv.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: { ...urls, startDate, endDate, websiteId },
|
||||||
|
events: { ...events, startDate, endDate, websiteId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumns(goals: { type: string; value: string; goal: number }[]) {
|
||||||
|
const urls = goals
|
||||||
|
.filter(a => a.type === 'url')
|
||||||
|
.map((a, i) => `COUNT(CASE WHEN url_path = {{url${i}}} THEN 1 END) AS URL${i}`)
|
||||||
|
.join('\n');
|
||||||
|
const events = goals
|
||||||
|
.filter(a => a.type === 'event')
|
||||||
|
.map((a, i) => `COUNT(CASE WHEN url_path = {{event${i}}} THEN 1 END) AS EVENT${i}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return { urls, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWhere(goals: { type: string; value: string; goal: number }[]) {
|
||||||
|
const urls = goals
|
||||||
|
.filter(a => a.type === 'url')
|
||||||
|
.map((a, i) => `{{url${i}}}`)
|
||||||
|
.join(',');
|
||||||
|
const events = goals
|
||||||
|
.filter(a => a.type === 'event')
|
||||||
|
.map((a, i) => `{{event${i}}}`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
return { urls: `and url_path in (${urls})`, events: `and event_name in (${events})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = getParameters(goals);
|
||||||
|
const columns = getColumns(goals);
|
||||||
|
const where = getWhere(goals);
|
||||||
|
|
||||||
|
const urls = hasUrl
|
||||||
|
? await rawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
${columns.urls}
|
||||||
|
from website_event
|
||||||
|
where websiteId = {{websiteId::uuid}}
|
||||||
|
${where.urls}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
`,
|
||||||
|
parameters.urls,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const events = hasEvent
|
||||||
|
? await rawQuery(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
${columns.events}
|
||||||
|
from website_event
|
||||||
|
where websiteId = {{websiteId::uuid}}
|
||||||
|
${where.events}
|
||||||
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
`,
|
||||||
|
parameters.events,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...urls, ...events];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickhouseQuery(
|
||||||
|
websiteId: string,
|
||||||
|
criteria: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
goals: { type: string; value: string; goal: number }[];
|
||||||
|
},
|
||||||
|
): Promise<{ type: string; value: string; goal: number; result: number }[]> {
|
||||||
|
const { startDate, endDate, goals } = criteria;
|
||||||
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
|
const urls = goals.filter(a => a.type === 'url');
|
||||||
|
const events = goals.filter(a => a.type === 'event');
|
||||||
|
|
||||||
|
const hasUrl = urls.length > 0;
|
||||||
|
const hasEvent = events.length > 0;
|
||||||
|
|
||||||
|
function getParameters(
|
||||||
|
urls: { type: string; value: string; goal: number }[],
|
||||||
|
events: { type: string; value: string; goal: number }[],
|
||||||
|
) {
|
||||||
|
const urlParam = urls.reduce((acc, cv, i) => {
|
||||||
|
acc[`${cv.type}${i}`] = cv.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const eventParam = events.reduce((acc, cv, i) => {
|
||||||
|
acc[`${cv.type}${i}`] = cv.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: { ...urlParam, startDate, endDate, websiteId },
|
||||||
|
events: { ...eventParam, startDate, endDate, websiteId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumns(
|
||||||
|
urls: { type: string; value: string; goal: number }[],
|
||||||
|
events: { type: string; value: string; goal: number }[],
|
||||||
|
) {
|
||||||
|
const urlColumns = urls
|
||||||
|
.map((a, i) => `countIf(url_path = {url${i}:String}) AS URL${i},`)
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, -1);
|
||||||
|
const eventColumns = events
|
||||||
|
.map((a, i) => `countIf(event_name = {event${i}:String}) AS EVENT${i}`)
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, -1);
|
||||||
|
|
||||||
|
return { url: urlColumns, events: eventColumns };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWhere(
|
||||||
|
urls: { type: string; value: string; goal: number }[],
|
||||||
|
events: { type: string; value: string; goal: number }[],
|
||||||
|
) {
|
||||||
|
const urlWhere = urls.map((a, i) => `{url${i}:String}`).join(',');
|
||||||
|
const eventWhere = events.map((a, i) => `{event${i}:String}`).join(',');
|
||||||
|
|
||||||
|
return { urls: `and url_path in (${urlWhere})`, events: `and event_name in (${eventWhere})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameters = getParameters(urls, events);
|
||||||
|
const columns = getColumns(urls, events);
|
||||||
|
const where = getWhere(urls, events);
|
||||||
|
|
||||||
|
const urlResults = hasUrl
|
||||||
|
? await rawQuery<any>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
${columns.url}
|
||||||
|
from website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
${where.urls}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
`,
|
||||||
|
parameters.urls,
|
||||||
|
).then(a => {
|
||||||
|
const results = a[0];
|
||||||
|
|
||||||
|
return Object.keys(results).map((key, i) => {
|
||||||
|
return { ...urls[i], result: results[key] };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const eventResults = hasEvent
|
||||||
|
? await rawQuery<any>(
|
||||||
|
`
|
||||||
|
select
|
||||||
|
${columns.events}
|
||||||
|
from website_event
|
||||||
|
where website_id = {websiteId:UUID}
|
||||||
|
${where.events}
|
||||||
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||||
|
`,
|
||||||
|
parameters.events,
|
||||||
|
).then(a => {
|
||||||
|
const results = a[0];
|
||||||
|
|
||||||
|
return Object.keys(results).map((key, i) => {
|
||||||
|
return { ...events[i], result: results[key] };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...urlResults, ...eventResults];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue