UTM report.

This commit is contained in:
Mike Cao 2024-03-14 02:45:00 -07:00
parent e602aedd21
commit bca9c87021
25 changed files with 379 additions and 39 deletions

View File

@ -1,4 +1,5 @@
'use client';
import { Metadata } from 'next';
import ReportsHeader from './ReportsHeader';
import ReportsDataTable from './ReportsDataTable';
@ -10,6 +11,7 @@ export default function ReportsPage({ teamId }: { teamId: string }) {
</>
);
}
export const metadata = {
export const metadata: Metadata = {
title: 'Reports',
};

View File

@ -1,28 +0,0 @@
import FunnelReport from '../funnel/FunnelReport';
import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport';
import RetentionReport from '../retention/RetentionReport';
import { useApi } from 'components/hooks';
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
insights: InsightsReport,
retention: RetentionReport,
};
export default function ReportDetails({ reportId }: { reportId: string }) {
const { get, useQuery } = useApi();
const { data: report } = useQuery({
queryKey: ['reports', reportId],
queryFn: () => get(`/reports/${reportId}`),
});
if (!report) {
return null;
}
const ReportComponent = reports[report.type];
return <ReportComponent reportId={reportId} />;
}

View File

@ -1,6 +1,27 @@
'use client';
import ReportDetails from './ReportDetails';
import FunnelReport from '../funnel/FunnelReport';
import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport';
import RetentionReport from '../retention/RetentionReport';
import UTMReport from '../utm/UTMReport';
import { useReport } from 'components/hooks';
export default function ReportPage({ reportId }) {
return <ReportDetails reportId={reportId} />;
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
};
export default function ReportPage({ reportId }: { reportId: string }) {
const { report } = useReport(reportId);
if (!report) {
return null;
}
const ReportComponent = reports[report.type];
return <ReportComponent reportId={reportId} />;
}

View File

@ -4,6 +4,7 @@ import PageHeader from 'components/layout/PageHeader';
import Funnel from 'assets/funnel.svg';
import Lightbulb from 'assets/lightbulb.svg';
import Magnet from 'assets/magnet.svg';
import Tag from 'assets/tag.svg';
import styles from './ReportTemplates.module.css';
import { useMessages, useTeamUrl } from 'components/hooks';
@ -30,6 +31,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/retention'),
icon: <Magnet />,
},
{
title: formatMessage(labels.utm),
description: formatMessage(labels.utmDescription),
url: renderTeamUrl('/reports/utm'),
icon: <Tag />,
},
];
return (

View File

@ -53,11 +53,11 @@ export function InsightsParameters() {
filters,
};
const handleSubmit = values => {
const handleSubmit = (values: any) => {
runReport(values);
};
const handleAdd = (id, value) => {
const handleAdd = (id: string | number, value: { name: any }) => {
const data = parameterData[id];
if (!data.find(({ name }) => name === value.name)) {
@ -65,7 +65,7 @@ export function InsightsParameters() {
}
};
const handleRemove = (id, index) => {
const handleRemove = (id: string, index: number) => {
const data = [...parameterData[id]];
data.splice(index, 1);
updateReport({ parameters: { [id]: data } });

View File

@ -1,6 +1,11 @@
'use client';
import { Metadata } from 'next';
import RetentionReport from './RetentionReport';
export default function RetentionReportPage() {
return <RetentionReport />;
}
export const metadata: Metadata = {
title: 'Retention Report',
};

View File

@ -0,0 +1,36 @@
import { useContext } from 'react';
import { useMessages } from 'components/hooks';
import { Form, FormButtons, SubmitButton } from 'react-basics';
import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters';
export function UTMParameters() {
const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const queryDisabled = !websiteId || !dateRange;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default UTMParameters;

View File

@ -0,0 +1,28 @@
'use client';
import Report from '../[reportId]/Report';
import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[reportId]/ReportBody';
import UTMParameters from './UTMParameters';
import UTMView from './UTMView';
import Tag from 'assets/tag.svg';
import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
type: REPORT_TYPES.utm,
parameters: {},
};
export default function UTMReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Tag />} />
<ReportMenu>
<UTMParameters />
</ReportMenu>
<ReportBody>
<UTMView />
</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,5 @@
import UTMReport from './UTMReport';
export default function UTMReportPage() {
return <UTMReport />;
}

View File

@ -0,0 +1,24 @@
.title {
font-size: 18px;
font-weight: 700;
}
.params {
display: grid;
gap: 10px;
padding: 20px 0;
}
.row {
display: flex;
gap: 20px;
}
.label {
min-width: 200px;
}
.value {
min-width: 50px;
text-align: right;
}

View File

@ -0,0 +1,43 @@
import { useContext } from 'react';
import { firstBy } from 'thenby';
import { ReportContext } from '../[reportId]/Report';
import styles from './UTMView.module.css';
function toArray(data: { [key: string]: number }) {
return Object.keys(data)
.map(key => {
return { name: key, value: data[key] };
})
.sort(firstBy('value', -1));
}
export default function UTMView() {
const { report } = useContext(ReportContext);
const { data } = report || {};
if (!data) {
return null;
}
return (
<div>
{Object.keys(data).map(key => {
return (
<div key={key}>
<div className={styles.title}>{key}</div>
<div className={styles.params}>
{toArray(data[key]).map(({ name, value }) => {
return (
<div key={name} className={styles.row}>
<div className={styles.label}>{name}</div>
<div className={styles.value}>{value}</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}

View File

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

1
src/assets/bookmark.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M3.515 22.875a1 1 0 0 0 1.015-.027L12 18.179l7.47 4.669A1 1 0 0 0 21 22V4a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v18a1 1 0 0 0 .515.875zM5 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v16.2l-6.47-4.044a1 1 0 0 0-1.06 0L5 20.2z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

1
src/assets/flag.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 510 510" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m393.159 121.41 69.152-86.44c-16.753-2.022-149.599-37.363-282.234-8.913V0h-30v361.898c-25.85 6.678-45 30.195-45 58.102v1.509c-34.191 6.969-60 37.272-60 73.491v15h240v-15c0-36.22-25.809-66.522-60-73.491V420c0-27.906-19.15-51.424-45-58.102V237.165c153.335-30.989 264.132 7.082 284.847 9.834zM252.506 480H77.647c6.19-17.461 22.873-30 42.43-30h90c19.556 0 36.238 12.539 42.429 30zm-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30zm-15-213.427V56.771c66.329-15.269 141.099-15.756 227.537-1.455l-50.619 63.274 48.8 85.4c-75.047-12.702-150.759-11.841-225.718 2.583z"/></svg>

After

Width:  |  Height:  |  Size: 670 B

1
src/assets/speaker.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M232.011 88.828c-5.664-5.664-13.217-8.784-21.269-8.784s-15.605 3.12-21.269 8.783c-9.917 9.917-11.446 25.09-4.593 36.632-23.293 86.372-34.167 96.094-78.604 135.776-15.831 14.138-35.533 31.731-61.302 57.5-5.434 5.434-8.426 12.673-8.426 20.383s2.993 14.949 8.426 20.383l70.981 70.98c5.434 5.435 12.672 8.427 20.382 8.427a28.7 28.7 0 0 0 14.046-3.637l72.768 72.768c2.574 2.574 6.09 3.962 9.896 3.961.789 0 1.59-.06 2.398-.181 3.883-.581 7.662-2.543 10.641-5.521l25.329-25.329c6.918-6.919 7.684-16.993 1.741-22.936l-39.164-39.164c11.586-20.762 9.203-46.431-6.187-64.762 29.684-32.251 46.532-43.128 122.192-63.532a30.076 30.076 0 0 0 15.361 4.203c7.703 0 15.405-2.933 21.269-8.796 11.728-11.729 11.728-30.811 0-42.539zM127.268 419.167l-70.981-70.981c-2.412-2.411-3.74-5.632-3.74-9.068s1.328-6.657 3.74-9.068c17.786-17.786 32.665-31.645 45.371-43.163l86.911 86.911c-11.519 12.706-25.378 27.585-43.164 45.371-2.412 2.411-5.632 3.74-9.068 3.74-3.437-.001-6.657-1.33-9.069-3.742zM260.1 469.653l-25.33 25.33a4.096 4.096 0 0 1-1.197.85L162.45 424.71a1243.745 1243.745 0 0 0 26.786-27.968l71.714 71.713a4.047 4.047 0 0 1-.85 1.198zm-38.055-62.731-21.982-21.981a2607.916 2607.916 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779zm-19.759-48.401-3.004 3.362-85.711-85.711 3.361-3.003c44.419-39.665 57.85-51.661 80.687-133.656l138.322 138.322c-81.993 22.837-93.99 36.268-133.655 80.686zm173.027-83.854c-5.489 5.49-14.422 5.49-19.911 0L200.786 120.052c-5.489-5.489-5.489-14.421 0-19.91 2.642-2.643 6.178-4.098 9.956-4.098s7.313 1.455 9.955 4.098l154.616 154.615c5.489 5.489 5.489 14.421 0 19.91zm-22.558-151.968a8 8 0 0 1 0-11.314l43.904-43.904a8 8 0 0 1 11.313 11.314l-43.904 43.904c-1.562 1.562-3.609 2.343-5.657 2.343s-4.094-.781-5.656-2.343zm122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8zM237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/assets/tag.svg Normal file
View File

@ -0,0 +1 @@
<svg height="437pt" viewBox="0 0 437.004 437" width="437pt" xmlns="http://www.w3.org/2000/svg"><path d="M229 14.645A50.173 50.173 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.215 50.215 0 0 0 14.625 36.633l193.367 193.36c19.539 19.495 51.168 19.495 70.707 0l143.644-143.645c19.528-19.524 19.528-51.184 0-70.711zm179.219 249.933-143.645 143.64c-11.722 11.7-30.703 11.7-42.426 0L28.785 214.86a30.131 30.131 0 0 1-8.777-21.98l3.566-140.074c.403-15.973 13.254-28.828 29.227-29.227l140.074-3.57c.254-.004.5-.008.754-.008a30.129 30.129 0 0 1 21.223 8.79l193.367 193.362c11.695 11.723 11.695 30.703 0 42.426zm0 0"/><path d="M130.719 82.574c-26.59 0-48.145 21.555-48.149 48.145 0 26.59 21.559 48.144 48.145 48.144 26.59 0 48.144-21.554 48.144-48.144-.03-26.574-21.566-48.114-48.14-48.145zm0 76.29c-15.547 0-28.145-12.602-28.149-28.145 0-15.543 12.602-28.145 28.145-28.145s28.144 12.602 28.144 28.145c-.015 15.535-12.605 28.125-28.14 28.144zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 984 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0zM423.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0zm167.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805z"/></svg>

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 452 B

View File

@ -4,7 +4,7 @@ import { useApi } from './useApi';
import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages';
export function useReport(reportId: string, defaultParameters: { [key: string]: any }) {
export function useReport(reportId: string, defaultParameters: { [key: string]: any } = {}) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();

View File

@ -222,6 +222,11 @@ export const labels = defineMessages({
id: 'message.visitors-dropped-off',
defaultMessage: 'Visitors dropped off',
},
utm: { id: 'label.utm', defaultMessage: 'UTM' },
utmDescription: {
id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.',
},
});
export const messages = defineMessages({

View File

@ -106,6 +106,7 @@ export const REPORT_TYPES = {
funnel: 'funnel',
insights: 'insights',
retention: 'retention',
utm: 'utm',
} as const;
export const REPORT_PARAMETERS = {
@ -227,6 +228,8 @@ export const URL_LENGTH = 500;
export const PAGE_TITLE_LENGTH = 500;
export const EVENT_NAME_LENGTH = 50;
export const UTM_PARAMS = ['source', 'medium', 'campaign', 'term', 'content'];
export const DESKTOP_OS = [
'BeOS',
'Chrome OS',

View File

@ -27,7 +27,7 @@ const schema: YupRequest = {
websiteId: yup.string().uuid().required(),
type: yup
.string()
.matches(/funnel|insights|retention/i)
.matches(/funnel|insights|retention|utm/i)
.required(),
name: yup.string().max(200).required(),
description: yup.string().max(500),

View File

@ -27,7 +27,7 @@ const schema = {
name: yup.string().max(200).required(),
type: yup
.string()
.matches(/funnel|insights|retention/i)
.matches(/funnel|insights|retention|utm/i)
.required(),
description: yup.string().max(500),
parameters: yup

View File

@ -0,0 +1,54 @@
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 { getUTM } from 'queries';
import * as yup from 'yup';
export interface UTMRequestBody {
websiteId: string;
dateRange: { startDate: string; endDate: string; timezone: string };
}
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(),
}),
};
export default async (req: NextApiRequestQueryBody<any, UTMRequestBody>, res: NextApiResponse) => {
await useCors(req, res);
await useAuth(req, res);
await useValidate(schema, req, res);
if (req.method === 'POST') {
const {
websiteId,
dateRange: { startDate, endDate, timezone },
} = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getUTM(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
timezone,
});
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -0,0 +1,120 @@
import clickhouse from 'lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
export async function getUTM(
...args: [
websiteId: string,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
): Promise<
{
date: string;
day: number;
visitors: number;
returnVisitors: number;
percentage: number;
}[]
> {
const { startDate, endDate } = filters;
const { rawQuery } = prisma;
return rawQuery(
`
select url_query, count(*) as "num"
from website_event
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
and url_query is not null
group by 1
`,
{
websiteId,
startDate,
endDate,
},
).then(results => {
return results;
});
}
async function clickhouseQuery(
websiteId: string,
filters: {
startDate: Date;
endDate: Date;
timezone?: string;
},
): Promise<
{
date: string;
day: number;
visitors: number;
returnVisitors: number;
percentage: number;
}[]
> {
const { startDate, endDate } = filters;
const { rawQuery } = clickhouse;
return rawQuery(
`
select url_query, count(*) as "num"
from website_event
where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and url_query != ''
group by 1
`,
{
websiteId,
startDate,
endDate,
},
).then(result => parseParameters(result as any[]));
}
function parseParameters(result: any[]) {
return Object.values(result).reduce((data, { url_query, num }) => {
const params = url_query.split('&').map(n => decodeURIComponent(n));
for (const param of params) {
const [key, value] = param.split('=');
const match = key.match(/^utm_(\w+)$/);
if (match) {
const group = match[1];
const name = decodeURIComponent(value);
if (!data[group]) {
data[group] = { [name]: +num };
} else if (!data[group][name]) {
data[group][name] = +num;
} else {
data[group][name] += +num;
}
}
}
return data;
}, {});
}

View File

@ -14,6 +14,7 @@ export * from './analytics/events/saveEvent';
export * from './analytics/reports/getFunnel';
export * from './analytics/reports/getRetention';
export * from './analytics/reports/getInsights';
export * from './analytics/reports/getUTM';
export * from './analytics/pageviews/getPageviewMetrics';
export * from './analytics/pageviews/getPageviewStats';
export * from './analytics/sessions/createSession';