mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Improvements to revenue screen
This commit is contained in:
@@ -36,6 +36,7 @@ pm2.yml
|
||||
.claude
|
||||
.agents
|
||||
tmpclaude*
|
||||
CLAUDE.md
|
||||
nul
|
||||
|
||||
# debug
|
||||
|
||||
@@ -338,6 +338,8 @@
|
||||
"aov": "AOV",
|
||||
"arpu": "ARPU",
|
||||
"uniqueCustomers": "Unique Customers",
|
||||
"customers": "Customers",
|
||||
"orders": "Orders",
|
||||
"unknown": "Unknown",
|
||||
"untitled": "Untitled",
|
||||
"update": "Update",
|
||||
|
||||
@@ -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 (
|
||||
<DataGrid query={queryResult} allowPaging allowSearch>
|
||||
{({ data }) => (
|
||||
<SessionsTable
|
||||
data={data}
|
||||
websiteId={websiteId}
|
||||
getSessionHref={row => updateParams({ session: row.id })}
|
||||
/>
|
||||
)}
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RevenueProps {
|
||||
websiteId: string;
|
||||
@@ -56,18 +88,30 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||
{
|
||||
value: average,
|
||||
label: t(labels.aov),
|
||||
tooltip: (
|
||||
<>
|
||||
<div>Average Order Value</div>
|
||||
<div>(Total Revenue / Orders)</div>
|
||||
</>
|
||||
),
|
||||
change: comparison ? average - comparison.average : 0,
|
||||
formatValue: (n: number) => formatLongCurrency(n, currency),
|
||||
},
|
||||
{
|
||||
value: arpu,
|
||||
label: t(labels.arpu),
|
||||
tooltip: (
|
||||
<>
|
||||
<div>Average Revenue Per User</div>
|
||||
<div>(Total Revenue / All Sessions)</div>
|
||||
</>
|
||||
),
|
||||
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 && (
|
||||
<Column gap>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, change, formatValue }) => {
|
||||
{metrics?.map(({ label, value, change, formatValue, tooltip }) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
showChange={!isAllTime}
|
||||
@@ -204,9 +249,14 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
<Panel>
|
||||
<Heading size="2xl">{t(labels.customers)}</Heading>
|
||||
<RevenueSessionsDataTable websiteId={websiteId} currency={currency} />
|
||||
</Panel>
|
||||
</Column>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
<SessionModal websiteId={websiteId} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="id" label={t(labels.session)} width="100px">
|
||||
{(row: any) => (
|
||||
<Link href={`/websites/${websiteId}/sessions/${row.id}`}>
|
||||
<Link
|
||||
href={
|
||||
getSessionHref ? getSessionHref(row) : `/websites/${websiteId}/sessions/${row.id}`
|
||||
}
|
||||
>
|
||||
<Avatar seed={row.id} size={32} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, string | number>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -258,6 +258,8 @@ export const labels: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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 && (
|
||||
<Text weight="bold" wrap="nowrap">
|
||||
{label}
|
||||
</Text>
|
||||
<Row justifyContent="space-between" alignItems="flex-start">
|
||||
<Text weight="bold" wrap="nowrap">
|
||||
{label}
|
||||
</Text>
|
||||
{tooltip && (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button size="sm" variant="quiet">
|
||||
<Icon size="sm">
|
||||
<Info />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip placement="top">{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
<Text size="4xl" weight="bold" wrap="nowrap">
|
||||
<AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user