From 9f9410a77a4a4f565a1855967f2e3a527aab54c6 Mon Sep 17 00:00:00 2001 From: gregfromstl Date: Thu, 7 Nov 2024 18:33:42 +0000 Subject: [PATCH] [Dashboard] Feature: Project Overview (Analytics) Page (#5340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CNCT-2182 CNCT-2180 CNCT-2177 CNCT-2179 CNCT-2178 CNCT-2176 https://github.com/user-attachments/assets/5c676ec4-ca2f-4796-880a-944636f9f0d5 --- ## PR-Codex overview This PR focuses on enhancing the analytics components in the dashboard by introducing new stats interfaces, updating existing components to utilize these stats, and improving the layout and functionality of various charts and cards. ### Detailed summary - Added `WalletUserStats` interface. - Updated `ProjectOverviewHeader` to accept `interval` and `range`. - Enhanced `Stat` component to display trends with badges. - Introduced `PieChart`, `BarChart`, and `CombinedBarChartCard` components. - Updated `EmptyState` messaging. - Improved layout in several components for better responsiveness. - Added new stories for `PieChartCard`, `StatBreakdownCard`, and others in Storybook. - Refactored data fetching functions to support new stats. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../src/@3rdweb-sdk/react/hooks/useApi.ts | 7 + .../components/BarChart.stories.tsx | 75 +++ .../[project_slug]/components/BarChart.tsx | 91 ++++ .../CombinedBarChartCard.stories.tsx | 103 ++++ .../components/CombinedBarChartCard.tsx | 86 +++ .../[project_slug]/components/EmptyState.tsx | 6 +- .../components/PieChart.stories.tsx | 33 ++ .../[project_slug]/components/PieChart.tsx | 48 ++ .../components/PieChartCard.stories.tsx | 64 +++ .../components/PieChartCard.tsx | 73 +++ .../ProjectOverviewHeader.stories.tsx | 9 +- .../components/ProjectOverviewHeader.tsx | 9 +- .../components/Stat.stories.tsx | 56 ++ .../[project_slug]/components/Stat.tsx | 28 + .../components/StatBreakdownCard.stories.tsx | 165 ++++++ .../components/StatBreakdownCard.tsx | 154 ++++++ .../team/[team_slug]/[project_slug]/page.tsx | 507 +++++++++++++++++- .../analytics/interval-selector.tsx | 2 +- 18 files changed, 1496 insertions(+), 20 deletions(-) create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.stories.tsx create mode 100644 apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.tsx diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index 2d7d2a27b1d..caadf46dfe1 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -238,6 +238,13 @@ export interface WalletStats { walletType: string; } +export interface WalletUserStats { + date: string; + newUsers: number; + returningUsers: number; + totalUsers: number; +} + export interface InAppWalletStats { date: string; authenticationMethod: string; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.stories.tsx new file mode 100644 index 00000000000..711973f4ab2 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BadgeContainer } from "stories/utils"; +import { BarChart } from "./BarChart"; + +const meta = { + title: "project/Overview/BarChart", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +const chartConfig = { + views: { + label: "Daily Views", + color: "hsl(var(--chart-1))", + }, + users: { + label: "Active Users", + color: "hsl(var(--chart-2))", + }, +}; + +const generateDailyData = (days: number) => { + const data = []; + const today = new Date(); + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + + data.push({ + date: date.toISOString(), + views: Math.floor(Math.random() * 1000) + 500, + users: Math.floor(Math.random() * 800) + 200, + }); + } + + return data; +}; + +function Component() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.tsx new file mode 100644 index 00000000000..69375c6017a --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/BarChart.tsx @@ -0,0 +1,91 @@ +"use client"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { formatTickerNumber } from "lib/format-utils"; +import { + Bar, + CartesianGrid, + BarChart as RechartsBarChart, + XAxis, + YAxis, +} from "recharts"; + +export function BarChart({ + chartConfig, + data, + activeKey, + tooltipLabel, +}: { + chartConfig: ChartConfig; + data: { [key in string]: number | string }[]; + activeKey: string; + tooltipLabel?: string; +}) { + return ( + + + + { + const date = new Date(value); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }} + /> + formatTickerNumber(value)} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }} + /> + } + /> + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.stories.tsx new file mode 100644 index 00000000000..2688a4f019c --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.stories.tsx @@ -0,0 +1,103 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BadgeContainer, mobileViewport } from "stories/utils"; +import { CombinedBarChartCard } from "./CombinedBarChartCard"; + +const meta = { + title: "project/Overview/CombinedBarChartCard", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +export const Mobile: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + viewport: mobileViewport("iphone14"), + }, +}; + +const chartConfig = { + dailyUsers: { + label: "Daily Active Users", + color: "hsl(var(--chart-1))", + }, + monthlyUsers: { + label: "Monthly Active Users", + color: "hsl(var(--chart-2))", + }, + annualUsers: { + label: "Annual Active Users", + color: "hsl(var(--chart-3))", + }, +}; + +const generateTimeSeriesData = (days: number) => { + const data = []; + const today = new Date(); + + let dailyBase = 1000; + let monthlyBase = 5000; + let annualBase = 30000; + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + + // Add some random variation + const dailyVariation = Math.random() * 200 - 100; + const monthlyVariation = Math.random() * 500 - 250; + const annualVariation = Math.random() * 500 - 250; + + // Trend upwards slightly + dailyBase += 10; + monthlyBase += 50; + annualBase += 50; + + data.push({ + date: date.toISOString(), + dailyUsers: Math.max(0, Math.round(dailyBase + dailyVariation)), + monthlyUsers: Math.max(0, Math.round(monthlyBase + monthlyVariation)), + annualUsers: Math.max(0, Math.round(annualBase + annualVariation)), + }); + } + + return data; +}; + +function Component() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.tsx new file mode 100644 index 00000000000..f904ad08c84 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/CombinedBarChartCard.tsx @@ -0,0 +1,86 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link"; +import { BarChart } from "./BarChart"; +import { Stat } from "./Stat"; + +type CombinedBarChartConfig = { + [key in K]: { label: string; color: string }; +}; + +export function CombinedBarChartCard< + T extends string, + K extends Exclude, +>({ + title, + chartConfig, + data, + activeChart, + aggregateFn = (data, key) => + data[data.length - 1]?.[key] as number | undefined, + trendFn = (data, key) => + data.filter((d) => (d[key] as number) > 0).length >= 2 + ? ((data[data.length - 1]?.[key] as number) ?? 0) / + ((data[data.length - 2]?.[key] as number) ?? 0) - + 1 + : undefined, + existingQueryParams, +}: { + title?: string; + chartConfig: CombinedBarChartConfig; + data: { [key in T]: number | string }[]; + activeChart: K; + aggregateFn?: (d: typeof data, key: K) => number | undefined; + trendFn?: (d: typeof data, key: K) => number | undefined; + existingQueryParams?: { [key: string]: string | string[] | undefined }; +}) { + return ( + + + {title && ( +
+ {title} +
+ )} +
+
+ {Object.keys(chartConfig).map((chart: string) => { + const key = chart as K; + return ( + + +
+ + ); + })} +
+
+ + + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/EmptyState.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/EmptyState.tsx index 437b23990bd..7a641f7bf47 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/EmptyState.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/EmptyState.tsx @@ -17,16 +17,16 @@ import walletsIcon from "../../../../../../public/assets/tw-icons/wallets.svg"; export function EmptyState() { return ( -
+

- Project Overview is Coming Soon + Get Started with the Connect SDK

- Understand how users are interacting with your project + Add the Connect SDK to your app to start collecting analytics.

diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.stories.tsx new file mode 100644 index 00000000000..027ccd75803 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BadgeContainer } from "stories/utils"; +import { PieChart } from "./PieChart"; + +const meta = { + title: "project/Overview/PieChart", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = {}; + +const chartData = [ + { label: "Chrome", value: 275, fill: "hsl(var(--chart-1))" }, + { label: "Safari", value: 200, fill: "hsl(var(--chart-2))" }, + { label: "Firefox", value: 187, fill: "hsl(var(--chart-3))" }, + { label: "Edge", value: 173, fill: "hsl(var(--chart-4))" }, +]; + +function Component() { + return ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.tsx new file mode 100644 index 00000000000..ccce7e4028f --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChart.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Pie, PieChart as RechartsPieChart } from "recharts"; + +export function PieChart({ + title, + data, +}: { + title: string; + data: { value: number; label: string; fill?: string }[]; +}) { + const chartConfig: ChartConfig = Object.fromEntries( + Object.entries(data).map(([name, value]) => [ + value.label, + { + label: value.label, + color: value.fill, + name, + }, + ]), + ); + + return ( + + + } + /> + + + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.stories.tsx new file mode 100644 index 00000000000..acde5303370 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from "@storybook/react/*"; +import { BadgeContainer, mobileViewport } from "stories/utils"; +import { PieChartCard } from "./PieChartCard"; + +const meta = { + title: "project/Overview/PieChartCard", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + parameters: { + viewport: { defaultViewport: "desktop" }, + }, +}; + +export const Mobile: Story = { + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +const baseChartData = [ + { value: 275, fill: "hsl(var(--chart-1))", label: "Chrome" }, + { value: 200, fill: "hsl(var(--chart-2))", label: "Safari" }, + { value: 187, fill: "hsl(var(--chart-3))", label: "Firefox" }, + { value: 173, fill: "hsl(var(--chart-4))", label: "Edge" }, +]; + +const manyItemsChartData = [ + { value: 275, fill: "hsl(var(--chart-1))", label: "Chrome" }, + { value: 200, fill: "hsl(var(--chart-2))", label: "Safari" }, + { value: 187, fill: "hsl(var(--chart-3))", label: "Firefox" }, + { value: 173, fill: "hsl(var(--chart-4))", label: "Edge" }, + { value: 150, fill: "hsl(var(--chart-5))", label: "Opera" }, + { value: 125, fill: "hsl(var(--chart-6))", label: "Brave" }, + { value: 100, fill: "hsl(var(--chart-7))", label: "Samsung Internet" }, + { value: 75, fill: "hsl(var(--chart-8))", label: "UC Browser" }, + { value: 50, fill: "hsl(var(--chart-9))", label: "QQ Browser" }, + { value: 40, fill: "hsl(var(--chart-10))", label: "Yandex" }, + { value: 30, fill: "hsl(var(--chart-1))", label: "Maxthon" }, + { value: 25, fill: "hsl(var(--chart-2))", label: "Vivaldi" }, + { value: 20, fill: "hsl(var(--chart-3))", label: "Tor Browser" }, + { value: 15, fill: "hsl(var(--chart-4))", label: "Pale Moon" }, +]; + +function Component() { + return ( +
+ + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.tsx new file mode 100644 index 00000000000..fa2a9032545 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/PieChartCard.tsx @@ -0,0 +1,73 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "@/components/ui/card"; +import { PieChart } from "./PieChart"; +import { Stat } from "./Stat"; + +type ChartData = { + value: number; + label: string; + fill?: string; +}; +export function PieChartCard({ + title, + data, + aggregateFn = (data) => data.reduce((acc, curr) => acc + curr.value, 0), +}: { + title: string; + data: ChartData[]; + aggregateFn?: (data: ChartData[]) => number; +}) { + const processedData = (() => { + if (data.length <= 9) return data; + + // Sort by value descending + const sorted = [...data].sort((a, b) => b.value - a.value); + + // Take top 9 + const top10 = sorted.slice(0, 9); + + // Aggregate the rest + const otherValue = sorted + .slice(9) + .reduce((sum, item) => sum + item.value, 0); + + if (otherValue > 0) { + top10.push({ + label: "Other", + value: otherValue, + fill: "orange", + }); + } + + return top10; + })(); + return ( + + + + + + + + +
+ {processedData.map( + ({ label, fill }: { label: string; fill?: string }) => ( +
+
+ {label} +
+ ), + )} +
+ + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.stories.tsx index 313d71dd4c2..f87fc30cec6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { getLastNDaysRange } from "components/analytics/date-range-selector"; import { projectStub } from "stories/stubs"; import { BadgeContainer, mobileViewport } from "stories/utils"; import { ProjectOverviewHeader } from "./ProjectOverviewHeader"; @@ -33,9 +34,13 @@ export const Mobile: Story = { function Component() { return ( -
+
- +
); diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.tsx index 541a78e5762..92dec5cd35a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/ProjectOverviewHeader.tsx @@ -1,19 +1,22 @@ import type { Project } from "@/api/projects"; +import type { Range } from "components/analytics/date-range-selector"; import { RangeSelector } from "components/analytics/range-selector"; export function ProjectOverviewHeader(props: { project: Project; + interval: "day" | "week"; + range: Range; }) { - const { project } = props; + const { project, interval, range } = props; return ( -
+

{project.name}

- +
); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.stories.tsx new file mode 100644 index 00000000000..a9d72b8ff01 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BadgeContainer } from "stories/utils"; +import { Stat } from "./Stat"; + +const meta = { + title: "project/Overview/Stat", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +export const WithPositiveTrend: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +export const WithNegativeTrend: Story = { + parameters: { + nextjs: { + appDirectory: true, + }, + }, +}; + +function Component() { + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.tsx new file mode 100644 index 00000000000..8b3e0aea553 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/Stat.tsx @@ -0,0 +1,28 @@ +import { Badge } from "@/components/ui/badge"; + +export function Stat({ + label, + value, + trend, +}: { label: string; value: string | number; trend?: number }) { + return ( +
+ {label} +
+ + {value.toLocaleString()} + + {trend && ( + // trend is rounded to 1 decimal place max + 0 ? "success" : "destructive"} + > + {trend > 0 ? "+" : "-"} + {Number(Math.abs(trend * 100).toFixed(1)).toLocaleString()}% + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.stories.tsx new file mode 100644 index 00000000000..fd4b542d859 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.stories.tsx @@ -0,0 +1,165 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BadgeContainer, mobileViewport } from "stories/utils"; +import { StatBreakdownCard } from "./StatBreakdownCard"; + +const meta = { + title: "project/Overview/StatBreakdownCard", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + parameters: { + viewport: { defaultViewport: "desktop" }, + }, +}; + +export const Mobile: Story = { + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +const baseChartData = [ + { value: 3753420.21, fill: "hsl(var(--chart-1))", label: "Base" }, + { value: 2134521, fill: "hsl(var(--chart-2))", label: "Xai" }, + { value: 423455.32, fill: "hsl(var(--chart-3))", label: "Ethereum" }, + { value: 134234.1, fill: "hsl(var(--chart-4))", label: "Polygon" }, +]; + +const manyRowsData = [ + { value: 5000000, fill: "hsl(var(--chart-1))", label: "Bitcoin" }, + { value: 4500000, fill: "hsl(var(--chart-2))", label: "Ethereum" }, + { value: 4000000, fill: "hsl(var(--chart-3))", label: "Polygon" }, + { value: 3500000, fill: "hsl(var(--chart-4))", label: "Arbitrum" }, + { value: 3000000, fill: "hsl(var(--chart-5))", label: "Optimism" }, + { value: 2500000, fill: "hsl(var(--chart-6))", label: "Avalanche" }, + { value: 2000000, fill: "hsl(var(--chart-7))", label: "Binance" }, + { value: 1500000, fill: "hsl(var(--chart-8))", label: "Solana" }, + { value: 1000000, fill: "hsl(var(--chart-9))", label: "Cardano" }, + { value: 750000, fill: "hsl(var(--chart-10))", label: "Polkadot" }, + { value: 500000, fill: "hsl(var(--chart-1))", label: "Cosmos" }, + { value: 250000, fill: "hsl(var(--chart-2))", label: "Near" }, +]; + +const withIconsData = [ + { + value: 3753420.21, + fill: "hsl(var(--chart-1))", + label: "Chrome", + icon: ( + // biome-ignore lint/a11y/noSvgWithoutTitle: This is a test icon + + + + + + + + ), + }, + { + value: 2134521, + fill: "hsl(var(--chart-2))", + label: "Safari", + icon: ( + // biome-ignore lint/a11y/noSvgWithoutTitle: This is a test icon + + + + + + + ), + }, + { + value: 423455.32, + fill: "hsl(var(--chart-3))", + label: "Firefox", + icon: ( + // biome-ignore lint/a11y/noSvgWithoutTitle: This is a test icon + + + + + ), + }, +]; + +function Component() { + return ( +
+ + + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value) + } + /> + + + + + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value) + } + /> + + + + new Intl.NumberFormat("en-US").format(value)} + /> + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.tsx new file mode 100644 index 00000000000..936a041f5dc --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/components/StatBreakdownCard.tsx @@ -0,0 +1,154 @@ +"use client"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { useId } from "react"; +import { Stat } from "./Stat"; + +type Data = { + label: string; + value: number; + fill?: string; + icon?: React.ReactNode; +}; +export function StatBreakdownCard({ + title, + data, + // this prop allows us to leverage the formatter prop for currency formatting between server-side and client-side (since we can't pass functions to the client) + isCurrency = false, + formatter = isCurrency + ? (value: number) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value) + : undefined, +}: { + title: string; + data: Data[]; + isCurrency?: boolean; + formatter?: (value: number) => string; +}) { + const cardId = useId(); + const processedData = (() => { + if (data.length <= 4) return data; + + // Sort by value descending + const sorted = [...data].sort((a, b) => b.value - a.value); + + // Take top 4 + const top4 = sorted.slice(0, 4); + + // Aggregate the rest + const otherValue = sorted + .slice(4) + .reduce((sum, item) => sum + item.value, 0); + + if (otherValue > 0) { + top4.push({ + label: "Other", + value: otherValue, + fill: "hsl(var(--muted-foreground))", + }); + } + + return top4; + })(); + + const sum = processedData.reduce((acc, curr) => acc + curr.value, 0); + + return ( + + + + + +
+
+ {processedData.map((item, index) => ( +
{ + const bars = document.querySelectorAll( + `[data-index^="${cardId}"]`, + ); + for (const bar of bars) { + if ( + bar.getAttribute("data-index") === `${cardId}-${index}` + ) { + bar.classList.add("bg-muted/50"); + bar.classList.remove("opacity-40"); + } else { + bar.classList.remove("bg-muted/50"); + bar.classList.add("opacity-40"); + } + } + }} + onMouseLeave={() => { + const bars = document.querySelectorAll( + `[data-index^="${cardId}"]`, + ); + for (const bar of bars) { + bar.classList.remove("bg-muted/50"); + bar.classList.remove("opacity-40"); + } + }} + /> + ))} +
+
+ + + {processedData.map((item, index) => ( + { + const bars = document.querySelectorAll( + `[data-index^="${cardId}"]`, + ); + for (const bar of bars) { + if ( + bar.getAttribute("data-index") === `${cardId}-${index}` + ) { + bar.classList.remove("opacity-40"); + } else { + bar.classList.add("opacity-40"); + } + } + }} + onMouseLeave={() => { + const bars = document.querySelectorAll( + `[data-index^="${cardId}"]`, + ); + for (const bar of bars) { + bar.classList.remove("opacity-40"); + } + }} + > + + {item.icon ?? ( +
+ )} + {item.label} + + + {formatter ? formatter(item.value) : item.value} + + + ))} + +
+ + + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx index 5c9c52f9087..5bb89331f15 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx @@ -1,28 +1,513 @@ -import { getProjects } from "@/api/projects"; import { notFound } from "next/navigation"; + +import { getProject } from "@/api/projects"; +import { getThirdwebClient } from "@/constants/thirdweb.server"; +import { fetchAnalytics } from "data/analytics/fetch-analytics"; + +import type { + InAppWalletStats, + UserOpStatsByChain, + WalletStats, + WalletUserStats, +} from "@3rdweb-sdk/react/hooks/useApi"; +import { + type DurationId, + type Range, + getLastNDaysRange, +} from "components/analytics/date-range-selector"; + +import { + type ChainMetadata, + defineChain, + getChainMetadata, +} from "thirdweb/chains"; +import { type WalletId, getWalletInfo } from "thirdweb/wallets"; + +import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler"; +import { CombinedBarChartCard } from "./components/CombinedBarChartCard"; import { EmptyState } from "./components/EmptyState"; +import { PieChartCard } from "./components/PieChartCard"; import { ProjectOverviewHeader } from "./components/ProjectOverviewHeader"; +import { StatBreakdownCard } from "./components/StatBreakdownCard"; -export default async function ProjectOverviewPage(props: { - params: Promise<{ team_slug: string; project_slug: string }>; -}) { - const params = await props.params; - const projects = await getProjects(params.team_slug); +type PageParams = { + team_slug: string; + project_slug: string; +}; + +type PageSearchParams = { + usersChart?: string; + from?: string; + to?: string; + type?: string; + interval?: string; +}; - const project = projects.find((p) => p.slug === params.project_slug); +type PageProps = { + params: Promise; + searchParams: Promise; +}; + +export default async function ProjectOverviewPage(props: PageProps) { + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]); + + const project = await getProject(params.team_slug, params.project_slug); + const interval = (searchParams.interval as "day" | "week") ?? "week"; + const rangeType = (searchParams.type as DurationId) || "last-120"; + const range: Range = { + from: new Date(searchParams.from ?? getLastNDaysRange("last-120").from), + to: new Date(searchParams.to ?? getLastNDaysRange("last-120").to), + type: rangeType, + }; if (!project) { notFound(); } + // Fetch all analytics data in parallel + const [ + walletConnections, + walletUserStatsTimeSeries, + inAppWalletUsage, + userOpUsage, + ] = await Promise.all([ + // Aggregated wallet connections + getWalletConnections({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: "all", + }), + // Time series data for wallet users + getWalletUsers({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: interval, + }), + // In-app wallet usage + getInAppWalletUsage({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: "all", + }), + // User operations usage + getUserOpUsage({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: "all", + }), + ]); + + const isEmpty = + !walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) && + walletConnections.length === 0 && + inAppWalletUsage.length === 0 && + userOpUsage.length === 0; + return (
-
- -
+
- + {isEmpty ? ( +
+ +
+ ) : ( +
+ {walletUserStatsTimeSeries.some((w) => w.totalUsers !== 0) && ( +
+ +
+ )} +
+ {walletConnections.length > 0 && ( + + )} + {inAppWalletUsage.length > 0 && ( + + )} +
+ {userOpUsage.length > 0 && ( +
+ + +
+ )} +
+ )}
); } + +type UserMetrics = { + totalUsers: number; + activeUsers: number; + newUsers: number; + returningUsers: number; +}; + +type TimeSeriesMetrics = UserMetrics & { + date: string; +}; + +/** + * Processes time series data to combine wallet and user statistics + */ +function processTimeSeriesData( + userStats: WalletUserStats[], +): TimeSeriesMetrics[] { + const metrics: TimeSeriesMetrics[] = []; + + let cumulativeUsers = 0; + for (const stat of userStats) { + cumulativeUsers += stat.totalUsers ?? 0; + metrics.push({ + date: stat.date, + activeUsers: stat.totalUsers ?? 0, + returningUsers: stat.returningUsers ?? 0, + newUsers: stat.newUsers ?? 0, + totalUsers: cumulativeUsers, + }); + } + + return metrics; +} + +function UsersChartCard({ + chartKey, + userStats, + searchParams, +}: { + chartKey: keyof UserMetrics; + userStats: WalletUserStats[]; + searchParams?: { [key: string]: string | string[] | undefined }; +}) { + const timeSeriesData = processTimeSeriesData(userStats); + + const chartConfig = { + activeUsers: { label: "Active Users", color: "hsl(var(--chart-1))" }, + totalUsers: { label: "Total Users", color: "hsl(var(--chart-2))" }, + newUsers: { label: "New Users", color: "hsl(var(--chart-3))" }, + returningUsers: { + label: "Returning Users", + color: "hsl(var(--chart-4))", + }, + } as const; + + return ( + + timeSeriesData[timeSeriesData.length - 1]?.[key] + } + existingQueryParams={searchParams} + /> + ); +} + +async function WalletDistributionCard({ data }: { data: WalletStats[] }) { + const formattedData = await Promise.all( + data + .filter( + (w) => + w.walletType !== "smart" && + w.walletType !== "unknown" && + w.walletType !== "inApp", + ) + .map(async (w) => { + const wallet = await getWalletInfo(w.walletType as WalletId).catch( + () => ({ name: w.walletType }), + ); + return { + walletType: w.walletType, + uniqueWalletsConnected: w.uniqueWalletsConnected, + totalConnections: w.totalConnections, + walletName: wallet.name, + }; + }), + ); + + return ( + { + return { + value: uniqueWalletsConnected, + label: walletName, + // Multiply by 2 to avoid colors used by authentication methods + fill: `hsl(var(--chart-${(index + 1) * 2}))`, + }; + }, + )} + /> + ); +} + +function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) { + return ( + ({ + value: uniqueWalletsConnected, + label: authenticationMethod, + // Multiply by 2 and add 1 to avoid colors used by wallets connected + fill: `hsl(var(--chart-${index * 2 + 1}))`, + }), + )} + /> + ); +} + +async function TotalSponsoredCard({ data }: { data: UserOpStatsByChain[] }) { + const chains = await Promise.all( + data.map( + (item) => + // eslint-disable-next-line no-restricted-syntax + item.chainId && getChainMetadata(defineChain(Number(item.chainId))), + ), + ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + + return ( + b.sponsoredUsd - a.sponsoredUsd) + .map((item, index) => { + const chain = chains.find((c) => c.chainId === Number(item.chainId)); + return { + label: chain?.name || item.chainId || "Unknown", + value: item.sponsoredUsd, + icon: chain?.icon?.url ? ( + + ) : undefined, + fill: `hsl(var(--chart-${index + 1}))`, + }; + })} + /> + ); +} + +async function UserOpUsageCard({ data }: { data: UserOpStatsByChain[] }) { + const chains = await Promise.all( + data.map( + (item) => + // eslint-disable-next-line no-restricted-syntax + item.chainId && getChainMetadata(defineChain(Number(item.chainId))), + ), + ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + + return ( + b.successful - a.successful) + .map((item, index) => { + const chain = chains.find((c) => c.chainId === Number(item.chainId)); + + return { + label: chain?.name || item.chainId || "Unknown", + value: item.successful + item.failed, + icon: chain?.icon?.url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : undefined, + fill: `hsl(var(--chart-${index + 1}))`, + }; + })} + /> + ); +} + +export async function getWalletConnections(args: { + clientId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}): Promise { + const { clientId, from, to, period } = args; + + const searchParams = new URLSearchParams(); + searchParams.append("clientId", clientId); + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + const res = await fetchAnalytics(`v1/wallets?${searchParams.toString()}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (res?.status !== 200) { + console.error("Failed to fetch wallet connections"); + return []; + } + + const json = await res.json(); + + return json.data as WalletStats[]; +} + +export async function getInAppWalletUsage(args: { + clientId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}): Promise { + const { clientId, from, to, period } = args; + + const searchParams = new URLSearchParams(); + searchParams.append("clientId", clientId); + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + const res = await fetchAnalytics( + `v1/wallets/in-app?${searchParams.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (res?.status !== 200) { + console.error("Failed to fetch in-app wallet usage"); + return []; + } + + const json = await res.json(); + + return json.data as InAppWalletStats[]; +} + +async function getUserOpUsage(args: { + clientId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}) { + const { clientId, from, to, period } = args; + + const searchParams = new URLSearchParams(); + searchParams.append("clientId", clientId); + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + const res = await fetchAnalytics(`v1/user-ops?${searchParams.toString()}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + + if (res.status !== 200) { + console.error("Failed to fetch user ops usage"); + return []; + } + + return json.data as UserOpStatsByChain[]; +} + +async function getWalletUsers(args: { + clientId: string; + from?: Date; + to?: Date; + period?: "day" | "week" | "month" | "year" | "all"; +}): Promise { + const { clientId, from, to, period } = args; + + const searchParams = new URLSearchParams(); + searchParams.append("clientId", clientId); + if (from) { + searchParams.append("from", from.toISOString()); + } + if (to) { + searchParams.append("to", to.toISOString()); + } + if (period) { + searchParams.append("period", period); + } + const res = await fetchAnalytics( + `v1/wallets/users?${searchParams.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (res?.status !== 200) { + console.error("Failed to fetch wallet user stats"); + return []; + } + + const json = await res.json(); + + return json.data as WalletUserStats[]; +} diff --git a/apps/dashboard/src/components/analytics/interval-selector.tsx b/apps/dashboard/src/components/analytics/interval-selector.tsx index 70a3bced689..065bef8d54d 100644 --- a/apps/dashboard/src/components/analytics/interval-selector.tsx +++ b/apps/dashboard/src/components/analytics/interval-selector.tsx @@ -17,7 +17,7 @@ export function IntervalSelector(props: { props.setIntervalType(value); }} > - +