From 7662b77ce3a9b20a7ab71e1ab5c1c89847ce10b4 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sat, 24 May 2025 17:08:47 -0700 Subject: [PATCH] Added revenue screen. --- package.json | 2 +- pnpm-lock.yaml | 18 +- src/app/(main)/SideNav.tsx | 5 - .../websites/[websiteId]/WebsiteControls.tsx | 12 +- .../[websiteId]/revenue/RevenuePage.tsx | 13 ++ .../[websiteId]/revenue/RevenueTable.tsx | 27 +++ .../[websiteId]/revenue/RevenueView.tsx | 169 ++++++++++++++++++ .../websites/[websiteId]/revenue/page.tsx | 12 ++ .../websites/[websiteId]/utm/UTMPage.tsx | 2 +- .../api/websites/[websiteId]/revenue/route.ts | 67 +++++++ src/components/charts/BarChart.tsx | 4 +- src/components/charts/Chart.tsx | 3 +- src/components/hooks/index.ts | 1 + .../hooks/queries/useRevenueQuery.ts | 39 ++++ src/components/input/WebsiteDateFilter.tsx | 6 +- 15 files changed, 351 insertions(+), 29 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/revenue/RevenuePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/revenue/RevenueTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/revenue/RevenueView.tsx create mode 100644 src/app/(main)/websites/[websiteId]/revenue/page.tsx create mode 100644 src/app/api/websites/[websiteId]/revenue/route.ts create mode 100644 src/components/hooks/queries/useRevenueQuery.ts 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 && (