diff --git a/src/app/(main)/console/TestConsole.tsx b/src/app/(main)/console/TestConsole.tsx index 37075cf9..d0afb14c 100644 --- a/src/app/(main)/console/TestConsole.tsx +++ b/src/app/(main)/console/TestConsole.tsx @@ -29,6 +29,7 @@ export function TestConsole({ websiteId }: { websiteId: string }) { boolean: true, booleanError: 'true', time: new Date(), + user: `user${Math.round(Math.random() * 10)}`, number: 1, number2: Math.random() * 100, time2: new Date().toISOString(), diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx index 0965b20d..da1a0342 100644 --- a/src/app/(main)/reports/[reportId]/ReportPage.tsx +++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx @@ -7,6 +7,7 @@ import InsightsReport from '../insights/InsightsReport'; import JourneyReport from '../journey/JourneyReport'; import RetentionReport from '../retention/RetentionReport'; import UTMReport from '../utm/UTMReport'; +import RevenueReport from '../revenue/RevenueReport'; const reports = { funnel: FunnelReport, @@ -16,6 +17,7 @@ const reports = { utm: UTMReport, goals: GoalReport, journey: JourneyReport, + revenue: RevenueReport, }; 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 e37efc03..2314f7d6 100644 --- a/src/app/(main)/reports/create/ReportTemplates.tsx +++ b/src/app/(main)/reports/create/ReportTemplates.tsx @@ -51,6 +51,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) url: renderTeamUrl('/reports/journey'), icon: , }, + { + title: formatMessage(labels.revenue), + description: formatMessage(labels.revenueDescription), + url: renderTeamUrl('/reports/revenue'), + icon: , + }, ]; return ( diff --git a/src/app/(main)/reports/revenue/RevenueChart.tsx b/src/app/(main)/reports/revenue/RevenueChart.tsx new file mode 100644 index 00000000..602ab31c --- /dev/null +++ b/src/app/(main)/reports/revenue/RevenueChart.tsx @@ -0,0 +1,98 @@ +import BarChart, { BarChartProps } from 'components/charts/BarChart'; +import { useLocale, useMessages } from 'components/hooks'; +import MetricCard from 'components/metrics/MetricCard'; +import MetricsBar from 'components/metrics/MetricsBar'; +import { renderDateLabels } from 'lib/charts'; +import { formatLongNumber } from 'lib/format'; +import { useContext, useMemo } from 'react'; +import { ReportContext } from '../[reportId]/Report'; + +export interface PageviewsChartProps extends BarChartProps { + isLoading?: boolean; +} + +export function RevenueChart({ isLoading, ...props }: PageviewsChartProps) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { report } = useContext(ReportContext); + const { data, parameters } = report || {}; + + const chartData = useMemo(() => { + if (!data) { + return {}; + } + + return { + datasets: [ + { + label: formatMessage(labels.average), + data: data?.chart.map(a => ({ x: a.time, y: a.avg })), + borderWidth: 2, + backgroundColor: '#8601B0', + borderColor: '#8601B0', + order: 1, + }, + { + label: formatMessage(labels.total), + data: data?.chart.map(a => ({ x: a.time, y: a.sum })), + borderWidth: 2, + backgroundColor: '#f15bb5', + borderColor: '#f15bb5', + order: 2, + }, + ], + }; + }, [data, locale]); + + const metricData = useMemo(() => { + if (!data) { + return []; + } + + const { sum, avg, count, uniqueCount } = data.total; + + return [ + { + value: sum, + label: formatMessage(labels.total), + formatValue: formatLongNumber, + }, + { + value: avg, + label: formatMessage(labels.average), + formatValue: formatLongNumber, + }, + { + value: count, + label: formatMessage(labels.transactions), + formatValue: formatLongNumber, + }, + { + value: uniqueCount, + label: formatMessage(labels.uniqueCustomers), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + + return ( + <> + + {metricData?.map(({ label, value, formatValue }) => { + return ; + })} + + {data && ( + + )} + + ); +} + +export default RevenueChart; diff --git a/src/app/(main)/reports/revenue/RevenueParameters.tsx b/src/app/(main)/reports/revenue/RevenueParameters.tsx new file mode 100644 index 00000000..ef6ed209 --- /dev/null +++ b/src/app/(main)/reports/revenue/RevenueParameters.tsx @@ -0,0 +1,51 @@ +import { useMessages } from 'components/hooks'; +import { useContext } from 'react'; +import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics'; +import BaseParameters from '../[reportId]/BaseParameters'; +import { ReportContext } from '../[reportId]/Report'; + +export function RevenueParameters() { + 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 RevenueParameters; diff --git a/src/app/(main)/reports/revenue/RevenueReport.module.css b/src/app/(main)/reports/revenue/RevenueReport.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/src/app/(main)/reports/revenue/RevenueReport.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/revenue/RevenueReport.tsx b/src/app/(main)/reports/revenue/RevenueReport.tsx new file mode 100644 index 00000000..d82f424a --- /dev/null +++ b/src/app/(main)/reports/revenue/RevenueReport.tsx @@ -0,0 +1,27 @@ +import RevenueChart from './RevenueChart'; +import RevenueParameters from './RevenueParameters'; +import Report from '../[reportId]/Report'; +import ReportHeader from '../[reportId]/ReportHeader'; +import ReportMenu from '../[reportId]/ReportMenu'; +import ReportBody from '../[reportId]/ReportBody'; +import Target from 'assets/target.svg'; +import { REPORT_TYPES } from 'lib/constants'; + +const defaultParameters = { + type: REPORT_TYPES.revenue, + parameters: { Revenue: [] }, +}; + +export default function RevenueReport({ reportId }: { reportId?: string }) { + return ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/revenue/RevenueReportPage.tsx b/src/app/(main)/reports/revenue/RevenueReportPage.tsx new file mode 100644 index 00000000..e48c29d2 --- /dev/null +++ b/src/app/(main)/reports/revenue/RevenueReportPage.tsx @@ -0,0 +1,6 @@ +'use client'; +import RevenueReport from './RevenueReport'; + +export default function RevenueReportPage() { + return ; +} diff --git a/src/app/(main)/reports/revenue/page.tsx b/src/app/(main)/reports/revenue/page.tsx new file mode 100644 index 00000000..a8b79f08 --- /dev/null +++ b/src/app/(main)/reports/revenue/page.tsx @@ -0,0 +1,10 @@ +import RevenueReportPage from './RevenueReportPage'; +import { Metadata } from 'next'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Revenue Report', +}; diff --git a/src/components/hooks/queries/useReport.ts b/src/components/hooks/queries/useReport.ts index 3aacabb4..2e63e4e6 100644 --- a/src/components/hooks/queries/useReport.ts +++ b/src/components/hooks/queries/useReport.ts @@ -6,7 +6,7 @@ import { useMessages } from '../useMessages'; export function useReport( reportId: string, - defaultParameters: { type: string; parameters: { [key: string]: any } }, + defaultParameters?: { type: string; parameters: { [key: string]: any } }, ) { const [report, setReport] = useState(null); const [isRunning, setIsRunning] = useState(false); diff --git a/src/components/messages.ts b/src/components/messages.ts index 15fccda6..fa515c66 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -114,6 +114,8 @@ export const labels = defineMessages({ none: { id: 'label.none', defaultMessage: 'None' }, clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' }, property: { id: 'label.property', defaultMessage: 'Property' }, + revenueProperty: { id: 'label.revenue-property', defaultMessage: 'Revenue Property' }, + userProperty: { id: 'label.user-property', defaultMessage: 'User Property' }, today: { id: 'label.today', defaultMessage: 'Today' }, lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, @@ -155,6 +157,11 @@ export const labels = defineMessages({ id: 'label.funnel-description', defaultMessage: 'Understand the conversion and drop-off rate of users.', }, + revenue: { id: 'label.revenue', defaultMessage: 'Revenue' }, + revenueDescription: { + id: 'label.revenue-description', + defaultMessage: 'Look into your revenue across time.', + }, url: { id: 'label.url', defaultMessage: 'URL' }, urls: { id: 'label.urls', defaultMessage: 'URLs' }, add: { id: 'label.add', defaultMessage: 'Add' }, @@ -222,6 +229,8 @@ export const labels = defineMessages({ select: { id: 'label.select', defaultMessage: 'Select' }, myAccount: { id: 'label.my-account', defaultMessage: 'My account' }, transfer: { id: 'label.transfer', defaultMessage: 'Transfer' }, + transactions: { id: 'label.transactions', defaultMessage: 'Transactions' }, + uniqueCustomers: { id: 'label.uniqueCustomers', defaultMessage: 'Unique Customers' }, viewedPage: { id: 'message.viewed-page', defaultMessage: 'Viewed page', 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 a8b2b0f5..71274c75 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -122,6 +122,7 @@ export const REPORT_TYPES = { retention: 'retention', utm: 'utm', journey: 'journey', + revenue: 'revenue', } as const; export const REPORT_PARAMETERS = { diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts index 3a7c4c53..91b5fb51 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|goals|journey/i) + .matches(/funnel|insights|retention|utm|goals|journey|revenue/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 63e9c1d5..38996b7a 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|goals|journey/i) + .matches(/funnel|insights|retention|utm|goals|journey|revenue/i) .required(), description: yup.string().max(500), parameters: yup diff --git a/src/pages/api/reports/revenue.ts b/src/pages/api/reports/revenue.ts new file mode 100644 index 00000000..ac4dc6b3 --- /dev/null +++ b/src/pages/api/reports/revenue.ts @@ -0,0 +1,71 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { TimezoneTest, UnitTypeTest } from 'lib/yup'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getRevenue } from 'queries/analytics/reports/getRevenue'; +import * as yup from 'yup'; + +export interface RetentionRequestBody { + websiteId: string; + dateRange: { startDate: string; endDate: string; unit?: string; timezone?: string }; + eventName: string; + revenueProperty: string; + userProperty: string; +} + +const schema = { + POST: yup.object().shape({ + websiteId: yup.string().uuid().required(), + dateRange: yup + .object() + .shape({ + startDate: yup.date().required(), + endDate: yup.date().required(), + unit: UnitTypeTest, + timezone: TimezoneTest, + }) + .required(), + eventName: yup.string().required(), + revenueProperty: yup.string().required(), + userProperty: yup.string(), + }), +}; + +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, unit, timezone }, + eventName, + revenueProperty, + userProperty, + } = req.body; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const data = await getRevenue(websiteId, { + startDate: new Date(startDate), + endDate: new Date(endDate), + unit, + timezone, + eventName, + revenueProperty, + userProperty, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/queries/analytics/reports/getRevenue.ts b/src/queries/analytics/reports/getRevenue.ts new file mode 100644 index 00000000..6b151bb7 --- /dev/null +++ b/src/queries/analytics/reports/getRevenue.ts @@ -0,0 +1,189 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getRevenue( + ...args: [ + websiteId: string, + criteria: { + startDate: Date; + endDate: Date; + unit: string; + timezone: string; + eventName: string; + revenueProperty: string; + userProperty: string; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + startDate: Date; + endDate: Date; + unit: string; + timezone: string; + eventName: string; + revenueProperty: string; + userProperty: string; + }, +): Promise<{ + chart: { time: string; sum: number; avg: number; count: number; uniqueCount: number }[]; + total: { sum: number; avg: number; count: number; uniqueCount: number }; +}> { + const { + startDate, + endDate, + eventName, + revenueProperty, + userProperty, + timezone = 'UTC', + unit = 'day', + } = criteria; + const { getDateQuery, rawQuery } = prisma; + + const chartRes = await rawQuery( + ` + select + ${getDateQuery('website_event.created_at', unit, timezone)} time, + sum(case when data_key = {{revenueProperty}} then number_value else 0 end) sum, + avg(case when data_key = {{revenueProperty}} then number_value else 0 end) avg, + count(case when data_key = {{revenueProperty}} then 1 else 0 end) count, + count(distinct {{userProperty}}) uniqueCount + from event_data + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and event_name = {{eventType}} + and data_key in ({{revenueProperty}} , {{userProperty}}) + group by 1 + `, + { websiteId, startDate, endDate, eventName, revenueProperty, userProperty }, + ); + + const totalRes = await rawQuery( + ` + select + sum(case when data_key = {{revenueProperty}} then number_value else 0 end) sum, + avg(case when data_key = {{revenueProperty}} then number_value else 0 end) avg, + count(case when data_key = {{revenueProperty}} then 1 else 0 end) count, + count(distinct {{userProperty}}) uniqueCount + from event_data + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and event_name = {{eventType}} + and data_key in ({{revenueProperty}} , {{userProperty}}) + group by 1 + `, + { websiteId, startDate, endDate, eventName, revenueProperty, userProperty }, + ); + + return { chart: chartRes, total: totalRes }; +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + startDate: Date; + endDate: Date; + eventName: string; + revenueProperty: string; + userProperty: string; + unit: string; + timezone: string; + }, +): Promise<{ + chart: { time: string; sum: number; avg: number; count: number; uniqueCount: number }[]; + total: { sum: number; avg: number; count: number; uniqueCount: number }; +}> { + const { + startDate, + endDate, + eventName, + revenueProperty, + userProperty = '', + timezone = 'UTC', + unit = 'day', + } = criteria; + const { getDateStringQuery, getDateQuery, rawQuery } = clickhouse; + + const chartRes = await rawQuery<{ + time: string; + sum: number; + avg: number; + count: number; + uniqueCount: number; + }>( + ` + select + ${getDateStringQuery('g.time', unit)} as time, + g.sum as sum, + g.avg as avg, + g.count as count, + g.uniqueCount as uniqueCount + from ( + select + ${getDateQuery('created_at', unit, timezone)} as time, + sumIf(number_value, data_key = {revenueProperty:String}) as sum, + avgIf(number_value, data_key = {revenueProperty:String}) as avg, + countIf(data_key = {revenueProperty:String}) as count, + uniqExactIf(string_value, data_key = {userProperty:String}) as uniqueCount + from event_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_name = {eventName:String} + and data_key in ({revenueProperty:String}, {userProperty:String}) + group by time + ) as g + order by time + `, + { websiteId, startDate, endDate, eventName, revenueProperty, userProperty }, + ).then(result => { + return Object.values(result).map((a: any) => { + return { + time: a.time, + sum: Number(a.sum), + avg: Number(a.avg), + count: Number(a.count), + uniqueCount: Number(!a.avg ? 0 : a.uniqueCount), + }; + }); + }); + + const totalRes = await rawQuery<{ + sum: number; + avg: number; + count: number; + uniqueCount: number; + }>( + ` + select + sumIf(number_value, data_key = {revenueProperty:String}) as sum, + avgIf(number_value, data_key = {revenueProperty:String}) as avg, + countIf(data_key = {revenueProperty:String}) as count, + uniqExactIf(string_value, data_key = {userProperty:String}) as uniqueCount + from event_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_name = {eventName:String} + and data_key in ({revenueProperty:String}, {userProperty:String}) + `, + { websiteId, startDate, endDate, eventName, revenueProperty, userProperty }, + ).then(results => { + const result = results[0]; + + return { + sum: Number(result.sum), + avg: Number(result.avg), + count: Number(result.count), + uniqueCount: Number(!result.avg ? 0 : result.uniqueCount), + }; + }); + + return { chart: chartRes, total: totalRes }; +}