diff --git a/.gitignore b/.gitignore
index eac6c566f..5c98531ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ pm2.yml
.claude
.agents
tmpclaude*
+CLAUDE.md
nul
# debug
diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json
index 66d837cb5..285169750 100644
--- a/public/intl/messages/en-US.json
+++ b/public/intl/messages/en-US.json
@@ -338,6 +338,8 @@
"aov": "AOV",
"arpu": "ARPU",
"uniqueCustomers": "Unique Customers",
+ "customers": "Customers",
+ "orders": "Orders",
"unknown": "Unknown",
"untitled": "Untitled",
"update": "Update",
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
index edfe04c77..6dce11d8c 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
@@ -1,9 +1,16 @@
import { Column, Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useMemo, useState } from 'react';
+import { DataGrid } from '@/components/common/DataGrid';
import { GridRow } from '@/components/common/GridRow';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
-import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
+import {
+ useDateRange,
+ useMessages,
+ useNavigation,
+ useResultQuery,
+ useRevenueSessionsQuery,
+} from '@/components/hooks';
import { CurrencySelect } from '@/components/input/CurrencySelect';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
@@ -13,6 +20,31 @@ import { RevenueChart } from '@/components/metrics/RevenueChart';
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
+import { SessionModal } from '../../sessions/SessionModal';
+import { SessionsTable } from '../../sessions/SessionsTable';
+
+function RevenueSessionsDataTable({
+ websiteId,
+ currency,
+}: {
+ websiteId: string;
+ currency: string;
+}) {
+ const { updateParams } = useNavigation();
+ const queryResult = useRevenueSessionsQuery(websiteId, currency);
+
+ return (
+
+ {({ data }) => (
+ updateParams({ session: row.id })}
+ />
+ )}
+
+ );
+}
export interface RevenueProps {
websiteId: string;
@@ -56,18 +88,30 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{
value: average,
label: t(labels.aov),
+ tooltip: (
+ <>
+
Average Order Value
+ (Total Revenue / Orders)
+ >
+ ),
change: comparison ? average - comparison.average : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
},
{
value: arpu,
label: t(labels.arpu),
+ tooltip: (
+ <>
+ Average Revenue Per User
+ (Total Revenue / All Sessions)
+ >
+ ),
change: comparison ? arpu - (comparison.arpu ?? 0) : 0,
formatValue: (n: number) => formatLongCurrency(n, currency),
},
{
value: count,
- label: t(labels.transactions),
+ label: t(labels.orders),
change: comparison ? count - comparison.count : 0,
formatValue: formatLongNumber,
},
@@ -91,12 +135,13 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
{data && (
- {metrics?.map(({ label, value, change, formatValue }) => {
+ {metrics?.map(({ label, value, change, formatValue, tooltip }) => {
return (
+
+ {t(labels.customers)}
+
+
)}
+
);
}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
index 4180db130..b4c684a47 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
@@ -5,7 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon';
import { useFormat, useMessages } from '@/components/hooks';
-export function SessionsTable({ websiteId, ...props }: DataTableProps & { websiteId: string }) {
+export function SessionsTable({
+ websiteId,
+ getSessionHref,
+ ...props
+}: DataTableProps & { websiteId: string; getSessionHref?: (row: any) => string }) {
const { t, labels } = useMessages();
const { formatValue } = useFormat();
@@ -13,7 +17,11 @@ export function SessionsTable({ websiteId, ...props }: DataTableProps & { websit
{(row: any) => (
-
+
)}
diff --git a/src/app/api/websites/[websiteId]/revenue/sessions/route.ts b/src/app/api/websites/[websiteId]/revenue/sessions/route.ts
new file mode 100644
index 000000000..78fad488e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/revenue/sessions/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, pagingParams, searchParams, withDateRange } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getRevenueSessions } from '@/queries/sql/reports/getRevenueSessions';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = withDateRange({
+ currency: z.string(),
+ ...filterParams,
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { currency, ...rest } = query;
+ const filters = await getQueryFilters(rest, websiteId);
+
+ const data = await getRevenueSessions(websiteId, currency, filters);
+
+ return json(data);
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index 4365a0cef..b35d842ca 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -31,6 +31,7 @@ export * from './queries/useReplaysQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useResultQuery';
+export * from './queries/useRevenueSessionsQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataPropertiesQuery';
export * from './queries/useSessionDataQuery';
diff --git a/src/components/hooks/queries/useRevenueSessionsQuery.ts b/src/components/hooks/queries/useRevenueSessionsQuery.ts
new file mode 100644
index 000000000..a1c12190d
--- /dev/null
+++ b/src/components/hooks/queries/useRevenueSessionsQuery.ts
@@ -0,0 +1,36 @@
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useRevenueSessionsQuery(
+ websiteId: string,
+ currency: string,
+ params?: Record,
+) {
+ const { get } = useApi();
+ const { modified } = useModified(`revenue-sessions`);
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return usePagedQuery({
+ queryKey: [
+ 'revenue-sessions',
+ { websiteId, currency, modified, startAt, endAt, unit, timezone, ...params, ...filters },
+ ],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/revenue/sessions`, {
+ currency,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...pageParams,
+ ...params,
+ pageSize: 20,
+ });
+ },
+ });
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 867623231..536e920de 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -258,6 +258,8 @@ export const labels: Record = {
aov: 'label.aov',
arpu: 'label.arpu',
uniqueCustomers: 'label.uniqueCustomers',
+ customers: 'label.customers',
+ orders: 'label.orders',
viewedPage: 'message.viewed-page',
collectedData: 'message.collected-data',
triggeredEvent: 'message.triggered-event',
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
index e60e76911..24768e3f4 100644
--- a/src/components/metrics/MetricCard.tsx
+++ b/src/components/metrics/MetricCard.tsx
@@ -1,6 +1,8 @@
import { useSpring } from '@react-spring/web';
-import { Column, Text } from '@umami/react-zen';
+import { Button, Column, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import type { ReactNode } from 'react';
import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { Info } from '@/components/icons';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
import { formatNumber } from '@/lib/format';
@@ -9,6 +11,7 @@ export interface MetricCardProps {
previousValue?: number;
change?: number;
label?: string;
+ tooltip?: ReactNode;
reverseColors?: boolean;
formatValue?: (n: any) => string;
showLabel?: boolean;
@@ -19,6 +22,7 @@ export const MetricCard = ({
value = 0,
change = 0,
label,
+ tooltip,
reverseColors = false,
formatValue = formatNumber,
showLabel = true,
@@ -40,9 +44,21 @@ export const MetricCard = ({
gap="4"
>
{showLabel && (
-
- {label}
-
+
+
+ {label}
+
+ {tooltip && (
+
+
+ {tooltip}
+
+ )}
+
)}
{props?.x?.to(x => formatValue(x))}
diff --git a/src/queries/sql/reports/getRevenueSessions.ts b/src/queries/sql/reports/getRevenueSessions.ts
new file mode 100644
index 000000000..65b84e355
--- /dev/null
+++ b/src/queries/sql/reports/getRevenueSessions.ts
@@ -0,0 +1,146 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getRevenueSessions';
+
+export async function getRevenueSessions(
+ ...args: [websiteId: string, currency: string, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, currency: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters } = prisma;
+ const { search } = filters;
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ currency,
+ search: search ? `%${search}%` : undefined,
+ });
+
+ const searchQuery = search
+ ? `and (session.browser ilike {{search}}
+ or session.os ilike {{search}}
+ or session.device ilike {{search}}
+ or session.city ilike {{search}})`
+ : '';
+
+ return pagedRawQuery(
+ `
+ select
+ session.session_id as "id",
+ session.website_id as "websiteId",
+ website_event.hostname,
+ session.browser,
+ session.os,
+ session.device,
+ session.screen,
+ session.language,
+ session.country,
+ session.region,
+ session.city,
+ min(website_event.created_at) as "firstAt",
+ max(website_event.created_at) as "lastAt",
+ count(distinct website_event.visit_id) as "visits",
+ sum(case when website_event.event_type = 1 then 1 else 0 end) as "views",
+ sum(case when website_event.event_type = 2 then 1 else 0 end) as "events",
+ max(website_event.created_at) as "createdAt"
+ from website_event
+ ${cohortQuery}
+ join session
+ on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ and website_event.session_id in (
+ select distinct session_id
+ from revenue
+ where website_id = {{websiteId::uuid}}
+ ${dateQuery}
+ and upper(currency) = {{currency}}
+ )
+ group by
+ session.session_id,
+ session.website_id,
+ website_event.hostname,
+ session.browser,
+ session.os,
+ session.device,
+ session.screen,
+ session.language,
+ session.country,
+ session.region,
+ session.city
+ order by max(website_event.created_at) desc
+ `,
+ queryParams,
+ filters,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, currency: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse;
+ const { search } = filters;
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ currency,
+ });
+
+ const searchQuery = search
+ ? `and ((positionCaseInsensitive(browser, {search:String}) > 0)
+ or (positionCaseInsensitive(city, {search:String}) > 0)
+ or (positionCaseInsensitive(os, {search:String}) > 0)
+ or (positionCaseInsensitive(device, {search:String}) > 0))`
+ : '';
+
+ return pagedRawQuery(
+ `
+ select
+ session_id as id,
+ website_id as websiteId,
+ hostname,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ ${getDateStringSQL('min(created_at)')} as firstAt,
+ ${getDateStringSQL('max(created_at)')} as lastAt,
+ uniq(visit_id) as visits,
+ sumIf(1, event_type = 1) as views,
+ sumIf(1, event_type = 2) as events,
+ max(created_at) as createdAt
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ and session_id in (
+ select distinct session_id
+ from website_revenue
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ and upper(currency) = {currency:String}
+ )
+ group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
+ order by max(created_at) desc
+ `,
+ queryParams,
+ filters,
+ FUNCTION_NAME,
+ );
+}