diff --git a/src/app/(main)/reports/ReportsPage.tsx b/src/app/(main)/reports/ReportsPage.tsx
index 6a63c2cb..a76a6a47 100644
--- a/src/app/(main)/reports/ReportsPage.tsx
+++ b/src/app/(main)/reports/ReportsPage.tsx
@@ -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',
};
diff --git a/src/app/(main)/reports/[reportId]/ReportDetails.tsx b/src/app/(main)/reports/[reportId]/ReportDetails.tsx
deleted file mode 100644
index 40d58e92..00000000
--- a/src/app/(main)/reports/[reportId]/ReportDetails.tsx
+++ /dev/null
@@ -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 ;
-}
diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx
index 227efa8d..7ecebd31 100644
--- a/src/app/(main)/reports/[reportId]/ReportPage.tsx
+++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx
@@ -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 ;
+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 ;
}
diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx
index ae84046a..1bd84aec 100644
--- a/src/app/(main)/reports/create/ReportTemplates.tsx
+++ b/src/app/(main)/reports/create/ReportTemplates.tsx
@@ -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: ,
},
+ {
+ title: formatMessage(labels.utm),
+ description: formatMessage(labels.utmDescription),
+ url: renderTeamUrl('/reports/utm'),
+ icon: ,
+ },
];
return (
diff --git a/src/app/(main)/reports/insights/InsightsParameters.tsx b/src/app/(main)/reports/insights/InsightsParameters.tsx
index 4aaa9abd..a3e4e72f 100644
--- a/src/app/(main)/reports/insights/InsightsParameters.tsx
+++ b/src/app/(main)/reports/insights/InsightsParameters.tsx
@@ -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 } });
diff --git a/src/app/(main)/reports/retention/RetentionReportPage.tsx b/src/app/(main)/reports/retention/RetentionReportPage.tsx
index f2500fb2..4d3e19e9 100644
--- a/src/app/(main)/reports/retention/RetentionReportPage.tsx
+++ b/src/app/(main)/reports/retention/RetentionReportPage.tsx
@@ -1,6 +1,11 @@
'use client';
+import { Metadata } from 'next';
import RetentionReport from './RetentionReport';
export default function RetentionReportPage() {
return ;
}
+
+export const metadata: Metadata = {
+ title: 'Retention Report',
+};
diff --git a/src/app/(main)/reports/utm/UTMParameters.tsx b/src/app/(main)/reports/utm/UTMParameters.tsx
new file mode 100644
index 00000000..c76df77d
--- /dev/null
+++ b/src/app/(main)/reports/utm/UTMParameters.tsx
@@ -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 (
+
+ );
+}
+
+export default UTMParameters;
diff --git a/src/app/(main)/reports/utm/UTMReport.tsx b/src/app/(main)/reports/utm/UTMReport.tsx
new file mode 100644
index 00000000..7183b9f7
--- /dev/null
+++ b/src/app/(main)/reports/utm/UTMReport.tsx
@@ -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 (
+
+ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/reports/utm/UTMReportPage.tsx b/src/app/(main)/reports/utm/UTMReportPage.tsx
new file mode 100644
index 00000000..926a4263
--- /dev/null
+++ b/src/app/(main)/reports/utm/UTMReportPage.tsx
@@ -0,0 +1,5 @@
+import UTMReport from './UTMReport';
+
+export default function UTMReportPage() {
+ return ;
+}
diff --git a/src/app/(main)/reports/utm/UTMView.module.css b/src/app/(main)/reports/utm/UTMView.module.css
new file mode 100644
index 00000000..e595503e
--- /dev/null
+++ b/src/app/(main)/reports/utm/UTMView.module.css
@@ -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;
+}
diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx
new file mode 100644
index 00000000..11a64edb
--- /dev/null
+++ b/src/app/(main)/reports/utm/UTMView.tsx
@@ -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 (
+
+ {Object.keys(data).map(key => {
+ return (
+
+
{key}
+
+ {toArray(data[key]).map(({ name, value }) => {
+ return (
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(main)/reports/utm/page.tsx b/src/app/(main)/reports/utm/page.tsx
new file mode 100644
index 00000000..12b6cc5b
--- /dev/null
+++ b/src/app/(main)/reports/utm/page.tsx
@@ -0,0 +1,10 @@
+import { Metadata } from 'next';
+import UTMReportPage from './UTMReportPage';
+
+export default function () {
+ return ;
+}
+
+export const metadata: Metadata = {
+ title: 'UTM Report',
+};
diff --git a/src/assets/bookmark.svg b/src/assets/bookmark.svg
new file mode 100644
index 00000000..5abc5ed2
--- /dev/null
+++ b/src/assets/bookmark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/flag.svg b/src/assets/flag.svg
new file mode 100644
index 00000000..c3750585
--- /dev/null
+++ b/src/assets/flag.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/speaker.svg b/src/assets/speaker.svg
new file mode 100644
index 00000000..f243a494
--- /dev/null
+++ b/src/assets/speaker.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/tag.svg b/src/assets/tag.svg
new file mode 100644
index 00000000..0e0f3668
--- /dev/null
+++ b/src/assets/tag.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/visitor.svg b/src/assets/visitor.svg
index 4aeceafc..829eb8e1 100644
--- a/src/assets/visitor.svg
+++ b/src/assets/visitor.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/components/hooks/queries/useReport.ts b/src/components/hooks/queries/useReport.ts
index 38061761..ef571d00 100644
--- a/src/components/hooks/queries/useReport.ts
+++ b/src/components/hooks/queries/useReport.ts
@@ -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();
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 36bdf057..32d315dd 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -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({
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index b5d30a98..21a4aa5f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -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',
diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts
index 9c113514..db7d0bcc 100644
--- a/src/pages/api/reports/[reportId].ts
+++ b/src/pages/api/reports/[reportId].ts
@@ -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),
diff --git a/src/pages/api/reports/index.ts b/src/pages/api/reports/index.ts
index 701ef649..d231f0b7 100644
--- a/src/pages/api/reports/index.ts
+++ b/src/pages/api/reports/index.ts
@@ -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
diff --git a/src/pages/api/reports/utm.ts b/src/pages/api/reports/utm.ts
new file mode 100644
index 00000000..59399ee4
--- /dev/null
+++ b/src/pages/api/reports/utm.ts
@@ -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, 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);
+};
diff --git a/src/queries/analytics/reports/getUTM.ts b/src/queries/analytics/reports/getUTM.ts
new file mode 100644
index 00000000..118caa23
--- /dev/null
+++ b/src/queries/analytics/reports/getUTM.ts
@@ -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;
+ }, {});
+}
diff --git a/src/queries/index.ts b/src/queries/index.ts
index afec8e4e..f0002881 100644
--- a/src/queries/index.ts
+++ b/src/queries/index.ts
@@ -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';