From bca9c8702117bda1c8736487a377d73806b746d9 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Thu, 14 Mar 2024 02:45:00 -0700 Subject: [PATCH] UTM report. --- src/app/(main)/reports/ReportsPage.tsx | 4 +- .../reports/[reportId]/ReportDetails.tsx | 28 ---- .../(main)/reports/[reportId]/ReportPage.tsx | 27 +++- .../(main)/reports/create/ReportTemplates.tsx | 7 + .../reports/insights/InsightsParameters.tsx | 6 +- .../reports/retention/RetentionReportPage.tsx | 5 + src/app/(main)/reports/utm/UTMParameters.tsx | 36 ++++++ src/app/(main)/reports/utm/UTMReport.tsx | 28 ++++ src/app/(main)/reports/utm/UTMReportPage.tsx | 5 + src/app/(main)/reports/utm/UTMView.module.css | 24 ++++ src/app/(main)/reports/utm/UTMView.tsx | 43 +++++++ src/app/(main)/reports/utm/page.tsx | 10 ++ src/assets/bookmark.svg | 1 + src/assets/flag.svg | 1 + src/assets/speaker.svg | 1 + src/assets/tag.svg | 1 + src/assets/visitor.svg | 2 +- src/components/hooks/queries/useReport.ts | 2 +- src/components/messages.ts | 5 + src/lib/constants.ts | 3 + src/pages/api/reports/[reportId].ts | 2 +- src/pages/api/reports/index.ts | 2 +- src/pages/api/reports/utm.ts | 54 ++++++++ src/queries/analytics/reports/getUTM.ts | 120 ++++++++++++++++++ src/queries/index.ts | 1 + 25 files changed, 379 insertions(+), 39 deletions(-) delete mode 100644 src/app/(main)/reports/[reportId]/ReportDetails.tsx create mode 100644 src/app/(main)/reports/utm/UTMParameters.tsx create mode 100644 src/app/(main)/reports/utm/UTMReport.tsx create mode 100644 src/app/(main)/reports/utm/UTMReportPage.tsx create mode 100644 src/app/(main)/reports/utm/UTMView.module.css create mode 100644 src/app/(main)/reports/utm/UTMView.tsx create mode 100644 src/app/(main)/reports/utm/page.tsx create mode 100644 src/assets/bookmark.svg create mode 100644 src/assets/flag.svg create mode 100644 src/assets/speaker.svg create mode 100644 src/assets/tag.svg create mode 100644 src/pages/api/reports/utm.ts create mode 100644 src/queries/analytics/reports/getUTM.ts 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 ( +
+ + + + {formatMessage(labels.runQuery)} + + + + ); +} + +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 ( +
+
{name}
+
{value}
+
+ ); + })} +
+
+ ); + })} +
+ ); +} 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';