diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx index 7ecebd31..f28942b7 100644 --- a/src/app/(main)/reports/[reportId]/ReportPage.tsx +++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx @@ -4,6 +4,7 @@ import EventDataReport from '../event-data/EventDataReport'; import InsightsReport from '../insights/InsightsReport'; import RetentionReport from '../retention/RetentionReport'; import UTMReport from '../utm/UTMReport'; +import GoalReport from '../goals/GoalsReport'; import { useReport } from 'components/hooks'; const reports = { @@ -12,6 +13,7 @@ const reports = { insights: InsightsReport, retention: RetentionReport, utm: UTMReport, + goals: GoalReport, }; export default function ReportPage({ reportId }: { reportId: string }) { diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx index 1bd84aec..ac29f5c3 100644 --- a/src/app/(main)/reports/create/ReportTemplates.tsx +++ b/src/app/(main)/reports/create/ReportTemplates.tsx @@ -37,6 +37,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) url: renderTeamUrl('/reports/utm'), icon: , }, + { + title: formatMessage(labels.goals), + description: formatMessage(labels.goalsDescription), + url: renderTeamUrl('/reports/goals'), + icon: , + }, ]; return ( diff --git a/src/app/(main)/reports/goals/GoalsAddForm.module.css b/src/app/(main)/reports/goals/GoalsAddForm.module.css new file mode 100644 index 00000000..a254ff08 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsAddForm.module.css @@ -0,0 +1,7 @@ +.dropdown { + width: 140px; +} + +.input { + width: 200px; +} diff --git a/src/app/(main)/reports/goals/GoalsAddForm.tsx b/src/app/(main)/reports/goals/GoalsAddForm.tsx new file mode 100644 index 00000000..fdfa7aad --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsAddForm.tsx @@ -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 ( + + + + setType(value)} + > + {({ value, label }) => { + return {label}; + }} + + handleChange(e, setValue)} + autoFocus={true} + autoComplete="off" + onKeyDown={handleKeyDown} + /> + + + + + handleChange(e, setGoal)} + autoFocus={true} + autoComplete="off" + onKeyDown={handleKeyDown} + /> + . + + + + + + + ); +} + +export default GoalsAddForm; diff --git a/src/app/(main)/reports/goals/GoalsChart.module.css b/src/app/(main)/reports/goals/GoalsChart.module.css new file mode 100644 index 00000000..c9f158d2 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsChart.module.css @@ -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; +} diff --git a/src/app/(main)/reports/goals/GoalsChart.tsx b/src/app/(main)/reports/goals/GoalsChart.tsx new file mode 100644 index 00000000..7cebbe21 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsChart.tsx @@ -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 ( +
+ {data?.map(({ type, value, goal, result }, index: number) => { + return ( +
+
{index + 1}
+
+
+ + {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)} + + {value} +
+
+
+ {formatLongNumber(result)} + {formatMessage(labels.visitors)} +
+
+ {formatLongNumber(goal)} + {formatMessage(labels.goal)} +
+
{(result / goal).toFixed(2)}%
+
+
+
+
+
+
+ ); + })} +
+ ); +} + +export default GoalsChart; diff --git a/src/app/(main)/reports/goals/GoalsParameters.module.css b/src/app/(main)/reports/goals/GoalsParameters.module.css new file mode 100644 index 00000000..421433f0 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsParameters.module.css @@ -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; +} diff --git a/src/app/(main)/reports/goals/GoalsParameters.tsx b/src/app/(main)/reports/goals/GoalsParameters.tsx new file mode 100644 index 00000000..b4743d2d --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsParameters.tsx @@ -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 ( + + + + + + + + + ); + }; + + return ( +
+ + }> + + {goals.map((goal: { type: string; value: string; goal: number }, index: number) => { + return ( + + handleRemoveGoals(index)} + > +
+
+ {goal.type === 'url' ? : } +
+
{goal.value}
+
{formatNumber(goal.goal)}
+
+
+ + {(close: () => void) => ( + + + + )} + +
+ ); + })} +
+
+ + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default GoalsParameters; diff --git a/src/app/(main)/reports/goals/GoalsReport.module.css b/src/app/(main)/reports/goals/GoalsReport.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsReport.module.css @@ -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; +} diff --git a/src/app/(main)/reports/goals/GoalsReport.tsx b/src/app/(main)/reports/goals/GoalsReport.tsx new file mode 100644 index 00000000..d6171c31 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsReport.tsx @@ -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 ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/goals/GoalsReportPage.tsx b/src/app/(main)/reports/goals/GoalsReportPage.tsx new file mode 100644 index 00000000..cbab8bd0 --- /dev/null +++ b/src/app/(main)/reports/goals/GoalsReportPage.tsx @@ -0,0 +1,6 @@ +'use client'; +import GoalReport from './GoalsReport'; + +export default function GoalReportPage() { + return ; +} diff --git a/src/app/(main)/reports/goals/page.tsx b/src/app/(main)/reports/goals/page.tsx new file mode 100644 index 00000000..112ae47c --- /dev/null +++ b/src/app/(main)/reports/goals/page.tsx @@ -0,0 +1,10 @@ +import GoalsReportPage from './GoalsReportPage'; +import { Metadata } from 'next'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Goals Report', +}; diff --git a/src/app/(main)/reports/utm/page.tsx b/src/app/(main)/reports/utm/page.tsx index 12b6cc5b..7fa50660 100644 --- a/src/app/(main)/reports/utm/page.tsx +++ b/src/app/(main)/reports/utm/page.tsx @@ -6,5 +6,5 @@ export default function () { } export const metadata: Metadata = { - title: 'UTM Report', + title: 'Goals Report', }; diff --git a/src/components/messages.ts b/src/components/messages.ts index 7a11aa89..1413549f 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -235,6 +235,13 @@ export const labels = defineMessages({ }, steps: { id: 'label.steps', defaultMessage: 'Steps' }, 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({ diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index c1b9d25f..35634841 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -119,7 +119,10 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio }; } -async function rawQuery(query: string, params: Record = {}): Promise { +async function rawQuery( + query: string, + params: Record = {}, +): Promise { if (process.env.LOG_QUERY) { log('QUERY:\n', query); log('PARAMETERS:\n', params); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0b6f7d26..697a4836 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -111,6 +111,7 @@ export const DATA_TYPES = { export const REPORT_TYPES = { funnel: 'funnel', + goals: 'goals', insights: 'insights', retention: 'retention', utm: 'utm', diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts index db7d0bcc..be2db82f 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|utm/i) + .matches(/funnel|insights|retention|utm|goals/i) .required(), name: yup.string().max(200).required(), description: yup.string().max(500), diff --git a/src/pages/api/reports/goals.ts b/src/pages/api/reports/goals.ts new file mode 100644 index 00000000..bb766775 --- /dev/null +++ b/src/pages/api/reports/goals.ts @@ -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, + 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); +}; diff --git a/src/pages/api/reports/index.ts b/src/pages/api/reports/index.ts index d231f0b7..186a1821 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|utm/i) + .matches(/funnel|insights|retention|utm|goals/i) .required(), description: yup.string().max(500), parameters: yup diff --git a/src/queries/analytics/reports/getGoals.ts b/src/queries/analytics/reports/getGoals.ts new file mode 100644 index 00000000..f275d604 --- /dev/null +++ b/src/queries/analytics/reports/getGoals.ts @@ -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 { + 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( + ` + 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( + ` + 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]; +}