diff --git a/package.json b/package.json
index ad067168..2e93d58c 100644
--- a/package.json
+++ b/package.json
@@ -78,7 +78,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6",
- "@umami/react-zen": "^0.116.0",
+ "@umami/react-zen": "^0.117.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e8dfce21..fa4add0b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -42,8 +42,8 @@ importers:
specifier: ^5.28.6
version: 5.76.1(react@19.1.0)
'@umami/react-zen':
- specifier: ^0.116.0
- version: 0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
+ specifier: ^0.117.0
+ version: 0.117.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
'@umami/redis-client':
specifier: ^0.27.0
version: 0.27.0
@@ -3035,8 +3035,8 @@ packages:
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@umami/react-zen@0.116.0':
- resolution: {integrity: sha512-/OjfYgwA9/4JpfKjf/b3HinVoeEoyOfLHJk8Uv0opBx+Jy2I6WPM4ZwvaRVxIbNwzpw/JZekC46TJs6bQNzbGg==}
+ '@umami/react-zen@0.117.0':
+ resolution: {integrity: sha512-vo1i25cBpMsAWNHJ4RPFDJzlaH93NXwx8VotmnoAWgP39Yy+fdUwmi+dDXNBOKvWzoHO9BFf0nIYiT16klnR6g==}
'@umami/redis-client@0.27.0':
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
@@ -7459,8 +7459,8 @@ packages:
react:
optional: true
- zustand@5.0.4:
- resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
+ zustand@5.0.5:
+ resolution: {integrity: sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
@@ -10860,7 +10860,7 @@ snapshots:
'@typescript-eslint/types': 8.32.1
eslint-visitor-keys: 4.2.0
- '@umami/react-zen@0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
+ '@umami/react-zen@0.117.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.5
'@internationalized/date': 3.8.1
@@ -10877,7 +10877,7 @@ snapshots:
react-hook-form: 7.56.4(react@19.1.0)
react-icons: 5.5.0(react@19.1.0)
thenby: 1.3.4
- zustand: 5.0.4(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
+ zustand: 5.0.5(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
transitivePeerDependencies:
- '@babel/core'
- '@opentelemetry/api'
@@ -16046,7 +16046,7 @@ snapshots:
immer: 9.0.21
react: 19.1.0
- zustand@5.0.4(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)):
+ zustand@5.0.5(@types/react@19.1.4)(immer@9.0.21)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)):
optionalDependencies:
'@types/react': 19.1.4
immer: 9.0.21
diff --git a/src/app/(main)/SideNav.tsx b/src/app/(main)/SideNav.tsx
index 80664ca1..0aa27250 100644
--- a/src/app/(main)/SideNav.tsx
+++ b/src/app/(main)/SideNav.tsx
@@ -15,11 +15,6 @@ export function SideNav(props: any) {
href: renderTeamUrl('/dashboard'),
icon: ,
},
- {
- label: formatMessage(labels.reports),
- href: renderTeamUrl('/reports'),
- icon: ,
- },
{
label: formatMessage(labels.websites),
href: renderTeamUrl('/websites'),
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
index fef80c8b..fb84df87 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
@@ -5,19 +5,19 @@ import { FilterBar } from '@/components/input/FilterBar';
export function WebsiteControls({
websiteId,
- showFilter = true,
- showCompare,
+ allowFilter = true,
+ allowCompare,
}: {
websiteId: string;
- showFilter?: boolean;
- showCompare?: boolean;
+ allowFilter?: boolean;
+ allowCompare?: boolean;
}) {
return (
- {showFilter && }
+ {allowFilter && }
-
+
diff --git a/src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx
new file mode 100644
index 00000000..0c2a0c6c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx
@@ -0,0 +1,13 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { RevenueView } from './RevenueView';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+
+export function RevenuePage({ websiteId }: { websiteId: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx
new file mode 100644
index 00000000..d547f6ec
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx
@@ -0,0 +1,27 @@
+import { DataColumn, DataTable } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { formatLongCurrency } from '@/lib/format';
+
+export function RevenueTable({ data = [] }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+ {(row: any) => row.currency}
+
+
+ {(row: any) => formatLongCurrency(row.sum, row.currency)}
+
+
+ {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
+
+
+ {(row: any) => row.count}
+
+
+ {(row: any) => row.unique_count}
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx b/src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx
new file mode 100644
index 00000000..4c19a693
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx
@@ -0,0 +1,169 @@
+import classNames from 'classnames';
+import { colord } from 'colord';
+import { BarChart } from '@/components/charts/BarChart';
+import { PieChart } from '@/components/charts/PieChart';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useRevenueQuery,
+ useDateRange,
+} from '@/components/hooks';
+import { GridRow } from '@/components/common/GridRow';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+import { useCallback, useMemo } from 'react';
+import { RevenueTable } from './RevenueTable';
+import { Panel } from '@/components/common/Panel';
+import { Column } from '@umami/react-zen';
+
+export interface RevenueViewProps {
+ websiteId: string;
+ isLoading?: boolean;
+}
+
+export function RevenueView({ websiteId, isLoading }: RevenueViewProps) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+
+ const { data } = useRevenueQuery(websiteId);
+ const currency = 'USD';
+ const { dateRange } = useDateRange(websiteId);
+
+ const renderCountryName = useCallback(
+ ({ x: code }) => (
+
+
+ {countryNames[code]}
+
+ ),
+ [countryNames, locale],
+ );
+
+ const chartData = useMemo(() => {
+ if (!data) return [];
+
+ const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: map[key],
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ };
+ }, [data]);
+
+ const countryData = useMemo(() => {
+ if (!data) return [];
+
+ const labels = data.country.map(({ name }) => name);
+ const datasets = [
+ {
+ data: data.country.map(({ value }) => value),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ];
+
+ return { labels, datasets };
+ }, [data]);
+
+ const metricData = useMemo(() => {
+ if (!data) return [];
+
+ const { sum, count, unique_count } = data.total;
+
+ return [
+ {
+ value: sum,
+ label: formatMessage(labels.total),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count ? sum / count : 0,
+ label: formatMessage(labels.average),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count,
+ label: formatMessage(labels.transactions),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: unique_count,
+ label: formatMessage(labels.uniqueCustomers),
+ formatValue: formatLongNumber,
+ },
+ ] as any;
+ }, [data, locale]);
+
+ return (
+ <>
+
+
+
+ {metricData?.map(({ label, value, formatValue }) => {
+ return (
+
+ );
+ })}
+
+
+ {data && (
+ <>
+
+
+
+
+
+ ({
+ x: name,
+ y: Number(value),
+ z: (value / data?.total.sum) * 100,
+ }))}
+ renderLabel={renderCountryName}
+ />
+
+
+
+ >
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/revenue/page.tsx
new file mode 100644
index 00000000..485d3f25
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/revenue/page.tsx
@@ -0,0 +1,12 @@
+import { Metadata } from 'next';
+import { RevenuePage } from './RevenuePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return ;
+}
+
+export const metadata: Metadata = {
+ title: 'Revenue UTM Parameters',
+};
diff --git a/src/app/(main)/websites/[websiteId]/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/utm/UTMPage.tsx
index 34dad79f..8fb6cab5 100644
--- a/src/app/(main)/websites/[websiteId]/utm/UTMPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/utm/UTMPage.tsx
@@ -6,7 +6,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
export function UTMPage({ websiteId }: { websiteId: string }) {
return (
-
+
);
diff --git a/src/app/api/websites/[websiteId]/revenue/route.ts b/src/app/api/websites/[websiteId]/revenue/route.ts
new file mode 100644
index 00000000..11a6e5fc
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/revenue/route.ts
@@ -0,0 +1,67 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { getRequestDateRange, parseRequest } from '@/lib/request';
+import { filterParams, unitParam, timezoneParam } from '@/lib/schema';
+import { getRevenue } from '@/queries/sql/reports/getRevenue';
+import { getRevenueValues } from '@/queries/sql/reports/getRevenueValues';
+
+export async function __GET(request: Request) {
+ const { auth, query, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, startDate, endDate } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getRevenueValues(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ });
+
+ return json(data);
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ currency: z.string(),
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ unit: unitParam,
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { currency, timezone, unit } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { startDate, endDate } = await getRequestDateRange(query);
+
+ const data = await getRevenue(websiteId, {
+ startDate,
+ endDate,
+ unit,
+ timezone,
+ currency,
+ });
+
+ return json(data);
+}
diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx
index f0cdc779..653c8793 100644
--- a/src/components/charts/BarChart.tsx
+++ b/src/components/charts/BarChart.tsx
@@ -28,8 +28,8 @@ export interface BarChartProps extends ChartProps {
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
- minDate?: number | string;
- maxDate?: number | string;
+ minDate?: Date;
+ maxDate?: Date;
isAllTime?: boolean;
}
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
index e9e834cc..62084d14 100644
--- a/src/components/charts/Chart.tsx
+++ b/src/components/charts/Chart.tsx
@@ -1,9 +1,8 @@
import { useState, useRef, useEffect, useMemo } from 'react';
-import { Loading, Box, Column } from '@umami/react-zen';
+import { Loading, Box, Column, BoxProps } from '@umami/react-zen';
import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
import { Legend } from '@/components/metrics/Legend';
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
-import type { BoxProps } from '@umami/react-zen/Box';
export interface ChartProps extends BoxProps {
type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index 5d2ebf26..f4df36a2 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -8,6 +8,7 @@ export * from './queries/useRealtimeQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useRetentionQuery';
+export * from './queries/useRevenueQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataQuery';
export * from './queries/useSessionDataPropertiesQuery';
diff --git a/src/components/hooks/queries/useRevenueQuery.ts b/src/components/hooks/queries/useRevenueQuery.ts
new file mode 100644
index 00000000..665fbf18
--- /dev/null
+++ b/src/components/hooks/queries/useRevenueQuery.ts
@@ -0,0 +1,39 @@
+import { useApi } from '../useApi';
+import { useFilterParams } from '../useFilterParams';
+import { UseQueryOptions } from '@tanstack/react-query';
+
+interface RevenueData {
+ chart: any[];
+ country: any[];
+ total: {
+ sum: number;
+ count: number;
+ unique_count: number;
+ };
+ table: any[];
+}
+
+export function useRevenueQuery(
+ websiteId: string,
+ queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
+ options?: Omit<
+ UseQueryOptions & { onDataLoad?: (data: any) => void },
+ 'queryKey' | 'queryFn'
+ >,
+) {
+ const { get, useQuery } = useApi();
+ const filterParams = useFilterParams(websiteId);
+ const currency = 'USD';
+
+ return useQuery({
+ queryKey: ['revenue', websiteId, { ...filterParams, ...queryParams }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/revenue`, {
+ currency,
+ ...filterParams,
+ ...queryParams,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
index faa164a1..b17db463 100644
--- a/src/components/input/WebsiteDateFilter.tsx
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -19,13 +19,13 @@ export function WebsiteDateFilter({
websiteId,
showAllTime = true,
showButtons = true,
- showCompare = true,
+ allowCompare = true,
}: {
websiteId: string;
compare?: string;
showAllTime?: boolean;
showButtons?: boolean;
- showCompare?: boolean;
+ allowCompare?: boolean;
}) {
const { dateRange, saveDateRange } = useDateRange(websiteId);
const { value, startDate, endDate, offset } = dateRange;
@@ -92,7 +92,7 @@ export function WebsiteDateFilter({
)}
- {!isAllTime && showCompare && (
+ {!isAllTime && allowCompare && (