diff --git a/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts b/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts index ea7ffef8dd5..39062fdfdc9 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/cache-keys.ts @@ -21,6 +21,21 @@ export const accountKeys = { to, period, ] as const, + userOpStats: ( + walletAddress: string, + clientId: string, + from: string, + to: string, + period: string, + ) => + [ + ...accountKeys.wallet(walletAddress), + "userOps", + clientId, + from, + to, + period, + ] as const, credits: (walletAddress: string) => [...accountKeys.wallet(walletAddress), "credits"] as const, billingSession: (walletAddress: string) => diff --git a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts index d11f88615c7..1401786d28d 100644 --- a/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts +++ b/apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts @@ -234,6 +234,13 @@ export interface WalletStats { walletType: string; } +export interface UserOpStats { + date: string; + successful: number; + failed: number; + sponsoredUsd: number; +} + interface BillingProduct { name: string; id: string; @@ -383,6 +390,100 @@ async function getWalletUsage(args: { return json.data; } +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 fetch( + `${THIRDWEB_ANALYTICS_API_HOST}/v1/user-ops?${searchParams.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + const json = await res.json(); + + if (res.status !== 200) { + throw new Error(json.message); + } + + return json.data; +} + +export function useUserOpUsageAggregate(args: { + clientId: string; + from?: Date; + to?: Date; +}) { + const { clientId, from, to } = args; + const { user, isLoggedIn } = useLoggedInUser(); + + return useQuery({ + queryKey: accountKeys.userOpStats( + user?.address as string, + clientId as string, + from?.toISOString() || "", + to?.toISOString() || "", + "all", + ), + queryFn: async () => { + return getUserOpUsage({ + clientId, + from, + to, + period: "all", + }); + }, + enabled: !!clientId && !!user?.address && isLoggedIn, + }); +} + +export function useUserOpUsagePeriod(args: { + clientId: string; + from?: Date; + to?: Date; + period: "day" | "week" | "month" | "year"; +}) { + const { clientId, from, to, period } = args; + const { user, isLoggedIn } = useLoggedInUser(); + + return useQuery({ + queryKey: accountKeys.userOpStats( + user?.address as string, + clientId as string, + from?.toISOString() || "", + to?.toISOString() || "", + period, + ), + queryFn: async () => { + return getUserOpUsage({ + clientId, + from, + to, + period, + }); + }, + enabled: !!clientId && !!user?.address && isLoggedIn, + }); +} + export function useWalletUsageAggregate(args: { clientId: string; from?: Date; diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/ConnectAnalyticsDashboard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/ConnectAnalyticsDashboard.tsx index e1cf34d7280..de28f942af4 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/ConnectAnalyticsDashboard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/ConnectAnalyticsDashboard.tsx @@ -8,6 +8,8 @@ import { SelectValue, } from "@/components/ui/select"; import { + useUserOpUsageAggregate, + useUserOpUsagePeriod, useWalletUsageAggregate, useWalletUsagePeriod, } from "@3rdweb-sdk/react/hooks/useApi"; @@ -42,6 +44,19 @@ export function ConnectAnalyticsDashboard(props: { to: range.to, }); + const userOpUsageQuery = useUserOpUsagePeriod({ + clientId: props.clientId, + from: range.from, + to: range.to, + period: intervalType, + }); + + const userOpUsageAggregateQuery = useUserOpUsageAggregate({ + clientId: props.clientId, + from: range.from, + to: range.to, + }); + return (
@@ -62,6 +77,8 @@ export function ConnectAnalyticsDashboard(props: { { @@ -22,9 +31,20 @@ export function ConnectAnalyticsDashboardUI(props: { ); }, [props.aggregateWalletUsage]); + const { totalSponsoredTransactions, totalSponsoredUsd } = useMemo(() => { + return props.aggregateUserOpUsage.reduce( + (acc, curr) => { + acc.totalSponsoredTransactions += curr.successful; + acc.totalSponsoredUsd += curr.sponsoredUsd; + return acc; + }, + { totalSponsoredTransactions: 0, totalSponsoredUsd: 0 }, + ); + }, [props.aggregateUserOpUsage]); + return (
- {/* Summary Stat Cards */} + {/* Connections */}
+ + {/* Connections */} +
+ + + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value) + } + icon={CoinsIcon} + /> +
+ + + +
); } @@ -56,12 +106,13 @@ const Stat: React.FC<{ label: string; value?: number; icon: React.FC<{ className?: string }>; -}> = ({ label, value, icon: Icon }) => { + formatter?: (value: number) => string; +}> = ({ label, value, formatter, icon: Icon }) => { return (
- {value?.toLocaleString()} + {value && formatter ? formatter(value) : value?.toLocaleString()}
{label} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/ConnectAnalyticsDashboard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/ConnectAnalyticsDashboard.stories.tsx index a958f48713d..53293795993 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/ConnectAnalyticsDashboard.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/ConnectAnalyticsDashboard.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { mobileViewport } from "../../../../../../../stories/utils"; import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI"; -import { createWalletStatsStub } from "./storyUtils"; +import { createUserOpStatsStub, createWalletStatsStub } from "./storyUtils"; const meta = { title: "Charts/Connect/Analytics Dashboard", @@ -31,6 +31,8 @@ function Component() {
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.stories.tsx index be116961dd6..b416e9015d6 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.stories.tsx @@ -7,7 +7,7 @@ import { DailyConnectionsChartCard } from "./DailyConnectionsChartCard"; import { createWalletStatsStub } from "./storyUtils"; const meta = { - title: "Charts/Connect/DailyConnections", + title: "Charts/Connect/Daily Connections", component: Component, parameters: { layout: "centered", diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.tsx index 4df7ca163f2..2ffc1133cb9 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/DailyConnectionsChartCard.tsx @@ -117,7 +117,7 @@ export function DailyConnectionsChartCard(props: { ]); return { header, rows }; }} - fileName="DialyConnections" + fileName="DailyConnections" />
diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.stories.tsx new file mode 100644 index 00000000000..2fb321c7173 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + BadgeContainer, + mobileViewport, +} from "../../../../../../../stories/utils"; +import { SponsoredTransactionsChartCard } from "./SponsoredTransactionsChartCard"; +import { createUserOpStatsStub } from "./storyUtils"; + +const meta = { + title: "Charts/Connect/Sponsored Transactions", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Component() { + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.tsx new file mode 100644 index 00000000000..b852ec4ae9e --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/SponsoredTransactionsChartCard.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { UserOpStats } from "@3rdweb-sdk/react/hooks/useApi"; +import { format } from "date-fns"; +import { useMemo } from "react"; +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; +import { EmptyChartState, LoadingChartState } from "./EmptyChartState"; + +type ChartData = { + time: string; // human readable date + failed: number; + successful: number; +}; + +const chartConfig = { + successful: { + label: "Successful", + color: "hsl(var(--chart-1))", + }, + failed: { + label: "Failed", + color: "red", + }, +}; +export function SponsoredTransactionsChartCard(props: { + userOpStats: UserOpStats[]; + isPending: boolean; +}) { + const { userOpStats } = props; + + const barChartData: ChartData[] = useMemo(() => { + const chartDataMap: Map = new Map(); + + for (const data of userOpStats) { + const chartData = chartDataMap.get(data.date); + if (!chartData) { + chartDataMap.set(data.date, { + time: format(new Date(data.date), "MMM dd"), + successful: data.successful, + failed: data.failed, + }); + } else { + chartData.successful += data.successful; + chartData.failed += data.failed; + } + } + + return Array.from(chartDataMap.values()); + }, [userOpStats]); + + const disableActions = props.isPending || barChartData.length === 0; + + return ( +
+

+ Sponsored Transactions +

+ +
+ { + const header = ["Date", "Successful", "Failed"]; + const rows = barChartData.map((data) => { + const { time, successful, failed } = data; + return [time, successful.toString(), failed.toString()]; + }); + return { header, rows }; + }} + /> +
+ + {/* Chart */} + + {props.isPending ? ( + + ) : barChartData.length === 0 ? ( + + ) : ( + + + + + + } /> + } /> + {(["failed", "successful"] as const).map((result) => { + return ( + + ); + })} + + )} + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.stories.tsx new file mode 100644 index 00000000000..842430d2c29 --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + BadgeContainer, + mobileViewport, +} from "../../../../../../../stories/utils"; +import { TotalSponsoredChartCard } from "./TotalSponsoredChartCard"; +import { createUserOpStatsStub } from "./storyUtils"; + +const meta = { + title: "Charts/Connect/Total Sponsored", + component: Component, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: {}, +}; + +export const Mobile: Story = { + args: {}, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Component() { + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.tsx new file mode 100644 index 00000000000..5f642a4168a --- /dev/null +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/TotalSponsoredChartCard.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { UserOpStats } from "@3rdweb-sdk/react/hooks/useApi"; +import { format } from "date-fns"; +import { useMemo } from "react"; +import { Bar, BarChart, CartesianGrid, LabelList, XAxis } from "recharts"; +import { EmptyChartState, LoadingChartState } from "./EmptyChartState"; + +type ChartData = { + time: string; // human readable date + sponsoredUsd: number; +}; + +const chartConfig = { + sponsoredUsd: { + label: "Total Sponsored", + color: "hsl(var(--chart-1))", + }, +} satisfies ChartConfig; + +export function TotalSponsoredChartCard(props: { + userOpStats: UserOpStats[]; + isPending: boolean; +}) { + const { userOpStats } = props; + const barChartData: ChartData[] = useMemo(() => { + const chartDataMap: Map = new Map(); + + for (const data of userOpStats) { + const chartData = chartDataMap.get(data.date); + if (!chartData) { + chartDataMap.set(data.date, { + time: format(new Date(data.date), "MMM dd"), + sponsoredUsd: data.sponsoredUsd, + }); + } else { + chartData.sponsoredUsd += data.sponsoredUsd; + } + } + + return Array.from(chartDataMap.values()); + }, [userOpStats]); + + const disableActions = props.isPending || barChartData.length === 0; + + return ( +
+

+ Gas Sponsored +

+

+ The total amount of gas sponsored in USD. +

+ +
+ { + const header = ["Date", "Total Sponsored"]; + const rows = barChartData.map((row) => [ + row.time, + row.sponsoredUsd.toString(), + ]); + return { header, rows }; + }} + fileName="Total Sponsored" + /> +
+ + {/* Chart */} + + {props.isPending ? ( + + ) : barChartData.length === 0 ? ( + + ) : ( + + + + + + } /> + + + {barChartData.length < 50 && ( + + )} + + + )} + +
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/WalletConnectorsChartChart.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/WalletConnectorsChartCard.stories.tsx similarity index 100% rename from apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/WalletConnectorsChartChart.stories.tsx rename to apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/WalletConnectorsChartCard.stories.tsx diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/storyUtils.ts b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/storyUtils.ts index e78f6f0c064..69d50240e71 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/storyUtils.ts +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/connect/analytics/_components/storyUtils.ts @@ -1,4 +1,4 @@ -import type { WalletStats } from "@3rdweb-sdk/react/hooks/useApi"; +import type { UserOpStats, WalletStats } from "@3rdweb-sdk/react/hooks/useApi"; import type { WalletId } from "thirdweb/wallets"; const walletsToPickFrom: WalletId[] = [ @@ -49,3 +49,26 @@ export function createWalletStatsStub(days: number): WalletStats[] { return stubbedData; } + +export function createUserOpStatsStub(days: number): UserOpStats[] { + const stubbedData: UserOpStats[] = []; + + let d = days; + while (d !== 0) { + const successful = Math.floor(Math.random() * 100); + const failed = Math.floor(Math.random() * 100); + const sponsoredUsd = Math.floor(Math.random() * 100); + stubbedData.push({ + date: new Date(2024, 1, d).toLocaleString(), + successful, + failed, + sponsoredUsd, + }); + + if (Math.random() > 0.7) { + d--; + } + } + + return stubbedData; +}