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