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 (
+
+ );
+}
+
+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 };
+}