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