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