diff --git a/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx b/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx index 6a5bb548e2a..a1e3daf8670 100644 --- a/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx +++ b/apps/dashboard/src/@/components/analytics/empty-chart-state.tsx @@ -36,7 +36,7 @@ export function LoadingChartState({ className }: { className?: string }) { } export function EmptyChartStateGetStartedCTA(props: { - link: { + link?: { label: string; href: string; }; @@ -59,16 +59,18 @@ export function EmptyChartStateGetStartedCTA(props: { )} - - - {props.link.label} - - + {props.link && ( + + + {props.link.label} + + + )} ); } diff --git a/apps/dashboard/src/@/types/analytics.ts b/apps/dashboard/src/@/types/analytics.ts index 44de5a59b70..93cadaea095 100644 --- a/apps/dashboard/src/@/types/analytics.ts +++ b/apps/dashboard/src/@/types/analytics.ts @@ -101,36 +101,20 @@ export interface X402SettlementsOverall { totalValueUSD: number; } -export interface X402SettlementsByChainId { - date: string; +export interface X402SettlementsByChainId extends X402SettlementsOverall { chainId: string; - totalRequests: number; - totalValue: number; - totalValueUSD: number; } -export interface X402SettlementsByPayer { - date: string; +export interface X402SettlementsByPayer extends X402SettlementsOverall { payer: string; - totalRequests: number; - totalValue: number; - totalValueUSD: number; } -interface X402SettlementsByReceiver { - date: string; +interface X402SettlementsByReceiver extends X402SettlementsOverall { receiver: string; - totalRequests: number; - totalValue: number; - totalValueUSD: number; } -export interface X402SettlementsByResource { - date: string; +export interface X402SettlementsByResource extends X402SettlementsOverall { resource: string; - totalRequests: number; - totalValue: number; - totalValueUSD: number; } interface X402SettlementsByAsset { diff --git a/apps/dashboard/src/@/utils/number.ts b/apps/dashboard/src/@/utils/number.ts index 8f22ede8969..eb5df7fd806 100644 --- a/apps/dashboard/src/@/utils/number.ts +++ b/apps/dashboard/src/@/utils/number.ts @@ -1,10 +1,10 @@ const usdCurrencyFormatter = new Intl.NumberFormat("en-US", { currency: "USD", - maximumFractionDigits: 6, // prefix with $ - minimumFractionDigits: 0, // don't show decimal places if value is a whole number - notation: "compact", // at max 2 decimal places - roundingMode: "halfEven", // round to nearest even number, standard practice for financial calculations - style: "currency", // shows 1.2M instead of 1,200,000, 1.2B instead of 1,200,000,000 + maximumFractionDigits: 2, + minimumFractionDigits: 0, + notation: "compact", + roundingMode: "halfEven", + style: "currency", }); export const toUSD = (value: number) => { diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.stories.tsx new file mode 100644 index 00000000000..2c42d9dac39 --- /dev/null +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.stories.tsx @@ -0,0 +1,158 @@ +import type { Meta } from "@storybook/nextjs"; +import { ResponsiveSearchParamsProvider } from "responsive-rsc"; +import type { + InAppWalletStats, + UniversalBridgeStats, + X402SettlementsOverall, +} from "@/types/analytics"; +import { ProjectHighlightsCard } from "./highlights-card-ui"; + +const meta = { + component: ProjectHighlightsCard, + decorators: [ + (Story) => ( + + + + + + ), + ], + title: "Analytics/ProjectHighlightsCard", + parameters: { + nextjs: { + appDirectory: true, + }, + }, +} satisfies Meta; + +export default meta; + +function generateDateSeries(days: number) { + const dates: string[] = []; + const today = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(today.getDate() - i); + dates.push(d.toISOString()); + } + return dates; +} + +function inAppWalletStub(days: number): InAppWalletStats[] { + const dates = generateDateSeries(days); + return dates.map((date) => { + return { + // main data + newUsers: randomValue(50, 100), + uniqueWalletsConnected: randomValue(500, 1200), + // others + authenticationMethod: "email", + date, + }; + }); +} + +// a few aggregated entries that will be summed for the "Active Users" stat +function aggregatedInAppWalletsStub(): InAppWalletStats[] { + return Array.from({ length: 3 }).map(() => { + return { + // main data + newUsers: randomValue(50, 100), + uniqueWalletsConnected: randomValue(500, 1200), + // others + date: new Date().toISOString(), + authenticationMethod: "email", + }; + }); +} + +function bridgeVolumeStub(days: number): UniversalBridgeStats[] { + const dates = generateDateSeries(days); + return dates.map((date) => { + return { + // main data + developerFeeUsdCents: randomValue(500 * 100, 2500 * 100), + status: "completed", + // others + chainId: 0, + count: 0, + date, + amountUsdCents: 0, + type: "onchain", + }; + }); +} + +function randomValue(min: number, max: number) { + return Math.max(0, Math.round(min + Math.random() * (max - min))); +} + +function generateX402Settlements(days: number): X402SettlementsOverall[] { + const dates = generateDateSeries(days); + return dates.map((date) => { + return { + date, + totalRequests: randomValue(10, 100), + totalValue: randomValue(500, 2500), + totalValueUSD: randomValue(500, 2500), + }; + }); +} + +export function ThirtyDays() { + const days = 30; + const data = { + aggregatedUserStats: aggregatedInAppWalletsStub(), + userStats: inAppWalletStub(days), + bridgeVolumeStats: bridgeVolumeStub(days), + x402Settlements: generateX402Settlements(days), + }; + + return ( + + ); +} + +export function SingleDayRevenue() { + const days = 1; + const data = { + aggregatedUserStats: [], + userStats: [], + bridgeVolumeStats: bridgeVolumeStub(days), + x402Settlements: generateX402Settlements(days), + }; + + return ( + + ); +} + +export function SingleDayRevenueNoX402() { + const days = 1; + const data = { + aggregatedUserStats: [], + userStats: [], + bridgeVolumeStats: bridgeVolumeStub(days), + x402Settlements: [], + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.tsx index b4ca1b95d41..26828ab3a43 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card-ui.tsx @@ -1,57 +1,66 @@ "use client"; -import { useSetResponsiveSearchParams } from "responsive-rsc"; -import { EmptyChartStateGetStartedCTA } from "@/components/analytics/empty-chart-state"; -import type { InAppWalletStats, UniversalBridgeStats } from "@/types/analytics"; -import { CombinedBarChartCard } from "../../../../components/Analytics/CombinedBarChartCard"; +import { useState } from "react"; +import type { + InAppWalletStats, + UniversalBridgeStats, + X402SettlementsOverall, +} from "@/types/analytics"; +import { + CombinedBarChartCard, + type CombinedBarChartConfig, +} from "../../../../components/Analytics/CombinedBarChartCard"; type AggregatedMetrics = { activeUsers: number; newUsers: number; feesCollected: number; + bridgeRevenue: number; + x402Revenue: number; }; export function ProjectHighlightsCard(props: { - selectedChart: string | undefined; aggregatedUserStats: InAppWalletStats[]; userStats: InAppWalletStats[]; volumeStats: UniversalBridgeStats[]; - teamSlug: string; - projectSlug: string; - selectedChartQueryParam: string; + x402Settlements: X402SettlementsOverall[]; }) { - const { - selectedChart, - aggregatedUserStats, + const { aggregatedUserStats, userStats, volumeStats, x402Settlements } = + props; + + const [selectedChart, setSelectedChart] = useState( + "activeUsers", + ); + + const timeSeriesData = processTimeSeriesData( userStats, volumeStats, - teamSlug, - projectSlug, - selectedChartQueryParam, - } = props; - - const timeSeriesData = processTimeSeriesData(userStats, volumeStats); - const setResponsiveSearchParams = useSetResponsiveSearchParams(); + x402Settlements, + ); - const chartConfig = { + const chartConfig: CombinedBarChartConfig = { activeUsers: { color: "hsl(var(--chart-1))", label: "Active Users" }, - newUsers: { color: "hsl(var(--chart-3))", label: "New Users" }, - feesCollected: { + newUsers: { color: "hsl(var(--chart-2))", label: "New Users" }, + bridgeRevenue: { + color: "hsl(var(--chart-3))", + isCurrency: true, + label: "Bridge", + hideAsTab: true, + }, + x402Revenue: { color: "hsl(var(--chart-4))", - emptyContent: ( - - ), isCurrency: true, - label: "Bridge Revenue", + label: "x402", + hideAsTab: true, + }, + feesCollected: { + color: "hsl(var(--chart-3))", + emptyContent: undefined, + isCurrency: true, + label: "Revenue", + stackedKeys: ["bridgeRevenue", "x402Revenue"], }, - } as const; + }; return ( { - setResponsiveSearchParams((v) => { - return { - ...v, - [selectedChartQueryParam]: key, - }; - }); + setSelectedChart(key); }} trendFn={(data, key) => data.filter((d) => (d[key] as number) > 0).length >= 2 @@ -93,6 +97,8 @@ export function ProjectHighlightsCard(props: { type TimeSeriesMetrics = AggregatedMetrics & { date: string; + bridgeRevenue: number; + x402Revenue: number; }; /** @@ -101,39 +107,51 @@ type TimeSeriesMetrics = AggregatedMetrics & { function processTimeSeriesData( userStats: InAppWalletStats[], volumeStats: UniversalBridgeStats[], + x402Settlements: X402SettlementsOverall[], ): TimeSeriesMetrics[] { const metrics: TimeSeriesMetrics[] = []; const dates = [ ...new Set([ - ...userStats.map((a) => new Date(a.date).toISOString().slice(0, 10)), - ...volumeStats.map((a) => new Date(a.date).toISOString().slice(0, 10)), + ...userStats.map((a) => ignoreTimePeriod(a.date)), + ...volumeStats.map((a) => ignoreTimePeriod(a.date)), + ...x402Settlements.map((a) => ignoreTimePeriod(a.date)), ]), ]; for (const date of dates) { const activeUsers = userStats - .filter((u) => new Date(u.date).toISOString().slice(0, 10) === date) + .filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date)) .reduce((acc, curr) => acc + curr.uniqueWalletsConnected, 0); const newUsers = userStats - .filter((u) => new Date(u.date).toISOString().slice(0, 10) === date) + .filter((u) => ignoreTimePeriod(u.date) === ignoreTimePeriod(date)) .reduce((acc, curr) => acc + curr.newUsers, 0); - const fees = volumeStats + const bridgeRevenue = volumeStats .filter( (v) => - new Date(v.date).toISOString().slice(0, 10) === date && + ignoreTimePeriod(v.date) === ignoreTimePeriod(date) && v.status === "completed", ) .reduce((acc, curr) => acc + curr.developerFeeUsdCents / 100, 0); + const x402Revenue = x402Settlements + .filter((x) => ignoreTimePeriod(x.date) === ignoreTimePeriod(date)) + .reduce((acc, curr) => acc + curr.totalValueUSD, 0); + metrics.push({ activeUsers: activeUsers, date: date, - feesCollected: fees, + feesCollected: bridgeRevenue + x402Revenue, + bridgeRevenue: bridgeRevenue, + x402Revenue: x402Revenue, newUsers: newUsers, }); } return metrics; } + +function ignoreTimePeriod(date: string) { + return new Date(date).toISOString().slice(0, 10); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card.tsx index fbe9a4d79b3..4e02ad969a1 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/highlights-card.tsx @@ -1,7 +1,11 @@ import { EmptyStateCard } from "app/(app)/team/components/Analytics/EmptyStateCard"; import { ResponsiveSuspense } from "responsive-rsc"; import type { ThirdwebClient } from "thirdweb"; -import { getInAppWalletUsage, getUniversalBridgeUsage } from "@/api/analytics"; +import { + getInAppWalletUsage, + getUniversalBridgeUsage, + getX402Settlements, +} from "@/api/analytics"; import type { Project } from "@/api/project/projects"; import type { Range } from "@/components/analytics/date-range-selector"; import { LoadingChartState } from "@/components/analytics/empty-chart-state"; @@ -20,7 +24,7 @@ export function ProjectHighlightCard(props: { return ( } - searchParamsUsed={["from", "to", "interval", "appHighlights"]} + searchParamsUsed={["from", "to", "interval"]} > ); @@ -44,45 +42,57 @@ async function AsyncAppHighlightsCard(props: { project: Project; range: Range; interval: "day" | "week"; - selectedChartQueryParam: string; - selectedChart: string | undefined; client: ThirdwebClient; params: PageParams; authToken: string; }) { - const [aggregatedUserStats, walletUserStatsTimeSeries, universalBridgeUsage] = - await Promise.allSettled([ - getInAppWalletUsage( - { - from: props.range.from, - period: "all", - projectId: props.project.id, - teamId: props.project.teamId, - to: props.range.to, - }, - props.authToken, - ), - getInAppWalletUsage( - { - from: props.range.from, - period: props.interval, - projectId: props.project.id, - teamId: props.project.teamId, - to: props.range.to, - }, - props.authToken, - ), - getUniversalBridgeUsage( - { - from: props.range.from, - period: props.interval, - projectId: props.project.id, - teamId: props.project.teamId, - to: props.range.to, - }, - props.authToken, - ), - ]); + const [ + aggregatedUserStats, + walletUserStatsTimeSeries, + universalBridgeUsage, + x402Settlements, + ] = await Promise.allSettled([ + getInAppWalletUsage( + { + from: props.range.from, + period: "all", + projectId: props.project.id, + teamId: props.project.teamId, + to: props.range.to, + }, + props.authToken, + ), + getInAppWalletUsage( + { + from: props.range.from, + period: props.interval, + projectId: props.project.id, + teamId: props.project.teamId, + to: props.range.to, + }, + props.authToken, + ), + getUniversalBridgeUsage( + { + from: props.range.from, + period: props.interval, + projectId: props.project.id, + teamId: props.project.teamId, + to: props.range.to, + }, + props.authToken, + ), + getX402Settlements( + { + from: props.range.from, + period: props.interval, + projectId: props.project.id, + teamId: props.project.teamId, + to: props.range.to, + }, + props.authToken, + ), + ]); if ( walletUserStatsTimeSeries.status === "fulfilled" && @@ -95,10 +105,6 @@ async function AsyncAppHighlightsCard(props: { ? aggregatedUserStats.value : [] } - selectedChart={props.selectedChart} - selectedChartQueryParam={props.selectedChartQueryParam} - teamSlug={props.params.team_slug} - projectSlug={props.params.project_slug} userStats={ walletUserStatsTimeSeries.status === "fulfilled" ? walletUserStatsTimeSeries.value @@ -109,6 +115,9 @@ async function AsyncAppHighlightsCard(props: { ? universalBridgeUsage.value : [] } + x402Settlements={ + x402Settlements.status === "fulfilled" ? x402Settlements.value : [] + } /> ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/types.ts b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/types.ts index b21cfb781e3..448003e642c 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/types.ts +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/_analytics/types.ts @@ -8,7 +8,6 @@ export type PageSearchParams = { to: string | undefined | string[]; type: string | undefined | string[]; interval: string | undefined | string[]; - appHighlights: string | undefined | string[]; client_transactions: string | undefined | string[]; totalSponsored: string | undefined | string[]; }; diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx index 6f83fd3f083..b04b80126e6 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/project-wallet/project-wallet-details.tsx @@ -632,7 +632,6 @@ function SwapProjectWalletModalContent( props: SwapProjectWalletModalContentProps, ) { const { - client, chainId, tokenAddress, walletAddress, diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx index 154e501d541..e923e272401 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/BarChart.tsx @@ -25,7 +25,11 @@ export function BarChart({ emptyChartContent, chartContentClassName, }: { - chartConfig: ChartConfig; + chartConfig: ChartConfig & { + [k: string]: { + stackedKeys?: string[]; + }; + }; data: { [key in string]: number | string }[]; activeKey: string; tooltipLabel?: string; @@ -33,6 +37,16 @@ export function BarChart({ emptyChartContent?: React.ReactNode; chartContentClassName?: string; }) { + const stackedKeys = + (chartConfig?.[activeKey] as { stackedKeys?: string[] } | undefined) + ?.stackedKeys || undefined; + + const isEmpty = + data.length === 0 || + (stackedKeys && stackedKeys.length > 0 + ? data.every((d) => stackedKeys.every((k) => (Number(d[k]) || 0) === 0)) + : data.every((d) => (Number(d[activeKey]) || 0) === 0)); + return ( - {data.length === 0 || data.every((d) => d[activeKey] === 0) ? ( + {isEmpty ? ( @@ -76,7 +90,9 @@ export function BarChart({ year: "numeric", }); }} - nameKey={activeKey} + nameKey={ + stackedKeys && stackedKeys.length > 0 ? undefined : activeKey + } valueFormatter={(v: unknown) => isCurrency || chartConfig[activeKey]?.isCurrency ? toUSD(v as number) @@ -85,13 +101,31 @@ export function BarChart({ /> } /> - + {stackedKeys && stackedKeys.length > 0 ? ( + stackedKeys.map((k) => ( + + )) + ) : ( + + )} )} diff --git a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx index b26bcd600b7..bd7ae9c8979 100644 --- a/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx +++ b/apps/dashboard/src/app/(app)/team/components/Analytics/CombinedBarChartCard.tsx @@ -4,12 +4,14 @@ import { toUSD } from "@/utils/number"; import { BarChart } from "./BarChart"; import { Stat } from "./Stat"; -type CombinedBarChartConfig = { +export type CombinedBarChartConfig = { [key in K]: { label: string; color: string; isCurrency?: boolean; emptyContent?: React.ReactNode; + hideAsTab?: boolean; + stackedKeys?: string[]; }; }; @@ -54,32 +56,34 @@ export function CombinedBarChartCard< - {Object.keys(chartConfig).map((chart: string) => { - const key = chart as K; - return ( - onSelect(key)} - key={chart} - > - - !chartConfig[chart as K]?.hideAsTab) + .map((chart: string) => { + const key = chart as K; + return ( + - - ); - })} + onClick={() => onSelect(key)} + key={chart} + > + + + + ); + })}