Skip to content

Commit 6679888

Browse files
committed
[Dashboard] Feature: Adds ecosystem analytics (#5254)
<!-- start pr-codex --> ## PR-Codex overview This PR enhances the `Ecosystem` analytics feature by introducing new components for wallet statistics, improving data fetching, and updating routing. It refines the structure for better maintainability and adds new functionalities for displaying wallet usage insights. ### Detailed summary - Added `EcosystemWalletStats` interface. - Introduced `EcosystemPermissionsPage` and `EcosystemAnalyticsPage` components. - Implemented `RangeSelector` for date range selection. - Updated routing to redirect to analytics. - Enhanced data fetching for wallet usage. - Refactored paths for type imports. - Improved `InAppWalletUsersChartCard` for better data visualization. - Added error handling and loading states for analytics components. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 842ea50 commit 6679888

36 files changed

+602
-58
lines changed

apps/dashboard/src/@3rdweb-sdk/react/hooks/useApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ export interface InAppWalletStats {
244244
uniqueWalletsConnected: number;
245245
}
246246

247+
export interface EcosystemWalletStats extends InAppWalletStats {}
248+
247249
export interface UserOpStats {
248250
date: string;
249251
successful: number;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
type Range,
3+
getLastNDaysRange,
4+
} from "components/analytics/date-range-selector";
5+
import { RangeSelector } from "components/analytics/range-selector";
6+
import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem";
7+
import { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
8+
9+
export async function EcosystemAnalyticsPage({
10+
ecosystemId,
11+
interval,
12+
range,
13+
}: { ecosystemId: string; interval: "day" | "week"; range?: Range }) {
14+
if (!range) {
15+
range = getLastNDaysRange("last-120");
16+
}
17+
18+
const stats = await getEcosystemWalletUsage({
19+
ecosystemId,
20+
from: range.from,
21+
to: range.to,
22+
period: interval,
23+
}).catch(() => null);
24+
25+
return (
26+
<div>
27+
<RangeSelector range={range} interval={interval} />
28+
29+
<div className="h-6" />
30+
31+
<div className="flex flex-col gap-4 lg:gap-6">
32+
<EcosystemWalletUsersChartCard
33+
ecosystemWalletStats={stats || []}
34+
isPending={false}
35+
/>
36+
</div>
37+
</div>
38+
);
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"use client";
2+
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
3+
import {
4+
type ChartConfig,
5+
ChartContainer,
6+
ChartLegend,
7+
ChartLegendContent,
8+
ChartTooltip,
9+
ChartTooltipContent,
10+
} from "@/components/ui/chart";
11+
import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
12+
import {
13+
EmptyChartState,
14+
LoadingChartState,
15+
} from "components/analytics/empty-chart-state";
16+
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
17+
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
18+
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
19+
import { DocLink } from "components/shared/DocLink";
20+
import { format } from "date-fns";
21+
import { formatTickerNumber } from "lib/format-utils";
22+
import { useMemo } from "react";
23+
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
24+
25+
type ChartData = Record<string, number> & {
26+
time: string; // human readable date
27+
};
28+
const defaultLabel = "Unknown Auth";
29+
30+
export function EcosystemWalletUsersChartCard(props: {
31+
ecosystemWalletStats: EcosystemWalletStats[];
32+
isPending: boolean;
33+
}) {
34+
const { ecosystemWalletStats } = props;
35+
36+
const topChainsToShow = 10;
37+
38+
const { chartConfig, chartData } = useMemo(() => {
39+
const _chartConfig: ChartConfig = {};
40+
const _chartDataMap: Map<string, ChartData> = new Map();
41+
const authMethodToVolumeMap: Map<string, number> = new Map();
42+
// for each stat, add it in _chartDataMap
43+
for (const stat of ecosystemWalletStats) {
44+
const chartData = _chartDataMap.get(stat.date);
45+
const { authenticationMethod } = stat;
46+
47+
// if no data for current day - create new entry
48+
if (!chartData) {
49+
_chartDataMap.set(stat.date, {
50+
time: format(new Date(stat.date), "MMM dd"),
51+
[authenticationMethod || defaultLabel]: stat.uniqueWalletsConnected,
52+
} as ChartData);
53+
} else if (chartData) {
54+
chartData[authenticationMethod || defaultLabel] =
55+
(chartData[authenticationMethod || defaultLabel] || 0) +
56+
stat.uniqueWalletsConnected;
57+
}
58+
59+
authMethodToVolumeMap.set(
60+
authenticationMethod || defaultLabel,
61+
stat.uniqueWalletsConnected +
62+
(authMethodToVolumeMap.get(authenticationMethod || defaultLabel) ||
63+
0),
64+
);
65+
}
66+
67+
const authMethodsSorted = Array.from(authMethodToVolumeMap.entries())
68+
.sort((a, b) => b[1] - a[1])
69+
.map((w) => w[0]);
70+
71+
const authMethodsToShow = authMethodsSorted.slice(0, topChainsToShow);
72+
const authMethodsAsOther = authMethodsSorted.slice(topChainsToShow);
73+
74+
// replace chainIdsToTagAsOther chainId with "other"
75+
for (const data of _chartDataMap.values()) {
76+
for (const authMethod in data) {
77+
if (authMethodsAsOther.includes(authMethod)) {
78+
data.others = (data.others || 0) + (data[authMethod] || 0);
79+
delete data[authMethod];
80+
}
81+
}
82+
}
83+
84+
authMethodsToShow.forEach((walletType, i) => {
85+
_chartConfig[walletType] = {
86+
label: authMethodsToShow[i],
87+
color: `hsl(var(--chart-${(i % 10) + 1}))`,
88+
};
89+
});
90+
91+
if (authMethodsToShow.length > topChainsToShow) {
92+
// Add Other
93+
authMethodsToShow.push("others");
94+
_chartConfig.others = {
95+
label: "Others",
96+
color: "hsl(var(--muted-foreground))",
97+
};
98+
}
99+
100+
return {
101+
chartData: Array.from(_chartDataMap.values()).sort(
102+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
103+
),
104+
chartConfig: _chartConfig,
105+
};
106+
}, [ecosystemWalletStats]);
107+
108+
const uniqueAuthMethods = Object.keys(chartConfig);
109+
const disableActions =
110+
props.isPending ||
111+
chartData.length === 0 ||
112+
uniqueAuthMethods.every((authMethod) =>
113+
chartData.every((data) => data[authMethod] === 0),
114+
);
115+
116+
return (
117+
<div className="relative w-full rounded-lg border border-border bg-muted/50 p-4 md:p-6">
118+
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
119+
Unique Users
120+
</h3>
121+
<p className="mb-3 text-muted-foreground text-sm">
122+
The total number of active users in your ecosystem for each period.
123+
</p>
124+
125+
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
126+
<ExportToCSVButton
127+
className="bg-background"
128+
fileName="Connect Wallets"
129+
disabled={disableActions}
130+
getData={async () => {
131+
// Shows the number of each type of wallet connected on all dates
132+
const header = ["Date", ...uniqueAuthMethods];
133+
const rows = chartData.map((data) => {
134+
const { time, ...rest } = data;
135+
return [
136+
time,
137+
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
138+
];
139+
});
140+
return { header, rows };
141+
}}
142+
/>
143+
</div>
144+
145+
{/* Chart */}
146+
<ChartContainer
147+
config={chartConfig}
148+
className="h-[250px] w-full md:h-[350px]"
149+
>
150+
{props.isPending ? (
151+
<LoadingChartState />
152+
) : chartData.length === 0 ||
153+
uniqueAuthMethods.every((authMethod) =>
154+
chartData.every((data) => data[authMethod] === 0),
155+
) ? (
156+
<EmptyChartState>
157+
<div className="flex flex-col items-center justify-center">
158+
<span className="mb-6 text-lg">
159+
Connect users to your app with social logins
160+
</span>
161+
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
162+
<DocLink
163+
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
164+
label="TypeScript"
165+
icon={TypeScriptIcon}
166+
/>
167+
<DocLink
168+
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
169+
label="React"
170+
icon={ReactIcon}
171+
/>
172+
<DocLink
173+
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
174+
label="React Native"
175+
icon={ReactIcon}
176+
/>
177+
<DocLink
178+
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
179+
label="Unity"
180+
icon={UnityIcon}
181+
/>
182+
</div>
183+
</div>
184+
</EmptyChartState>
185+
) : (
186+
<BarChart
187+
accessibilityLayer
188+
data={chartData}
189+
margin={{
190+
top: 20,
191+
}}
192+
>
193+
<CartesianGrid vertical={false} />
194+
195+
<XAxis
196+
dataKey="time"
197+
tickLine={false}
198+
tickMargin={10}
199+
axisLine={false}
200+
/>
201+
202+
<YAxis
203+
dataKey={(data) =>
204+
Object.entries(data)
205+
.filter(([key]) => key !== "time")
206+
.map(([, value]) => value)
207+
.reduce((acc, current) => Number(acc) + Number(current), 0)
208+
}
209+
tickLine={false}
210+
axisLine={false}
211+
tickFormatter={(value) => formatTickerNumber(value)}
212+
/>
213+
214+
<ChartTooltip
215+
cursor={true}
216+
content={
217+
<ChartTooltipContent
218+
valueFormatter={(value) => formatTickerNumber(Number(value))}
219+
/>
220+
}
221+
/>
222+
<ChartLegend content={<ChartLegendContent />} />
223+
{uniqueAuthMethods.map((authMethod) => {
224+
return (
225+
<Bar
226+
key={authMethod}
227+
dataKey={authMethod}
228+
fill={chartConfig[authMethod]?.color}
229+
radius={4}
230+
stackId="a"
231+
strokeWidth={1.5}
232+
className="stroke-muted"
233+
/>
234+
);
235+
})}
236+
</BarChart>
237+
)}
238+
</ChartContainer>
239+
</div>
240+
);
241+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
2+
import { Stat } from "components/analytics/stat";
3+
import { ActivityIcon, UserIcon } from "lucide-react";
4+
5+
export function EcosystemWalletsSummary(props: {
6+
allTimeStats: EcosystemWalletStats[] | undefined;
7+
monthlyStats: EcosystemWalletStats[] | undefined;
8+
}) {
9+
const allTimeStats = props.allTimeStats?.reduce(
10+
(acc, curr) => {
11+
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
12+
return acc;
13+
},
14+
{
15+
uniqueWalletsConnected: 0,
16+
},
17+
);
18+
19+
const monthlyStats = props.monthlyStats?.reduce(
20+
(acc, curr) => {
21+
acc.uniqueWalletsConnected += curr.uniqueWalletsConnected;
22+
return acc;
23+
},
24+
{
25+
uniqueWalletsConnected: 0,
26+
},
27+
);
28+
29+
return (
30+
<div className="grid grid-cols-2 gap-4 lg:gap-6">
31+
<Stat
32+
label="Total Users"
33+
value={allTimeStats?.uniqueWalletsConnected || 0}
34+
icon={ActivityIcon}
35+
/>
36+
<Stat
37+
label="Monthly Active Users"
38+
value={monthlyStats?.uniqueWalletsConnected || 0}
39+
icon={UserIcon}
40+
/>
41+
</div>
42+
);
43+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Range } from "components/analytics/date-range-selector";
2+
import { fetchApiServer } from "data/analytics/fetch-api-server";
3+
import { FetchError } from "utils/error";
4+
import type { Ecosystem } from "../../../types";
5+
import { EcosystemAnalyticsPage } from "./components/EcosystemAnalyticsPage";
6+
7+
export default async function Page({
8+
params,
9+
searchParams,
10+
}: {
11+
params: { slug: string };
12+
searchParams: {
13+
interval?: "day" | "week";
14+
range?: Range;
15+
};
16+
}) {
17+
const ecosystem = await getEcosystem(params.slug);
18+
19+
return (
20+
<EcosystemAnalyticsPage
21+
ecosystemId={ecosystem.id}
22+
interval={searchParams.interval || "week"}
23+
range={searchParams.range}
24+
/>
25+
);
26+
}
27+
28+
async function getEcosystem(ecosystemSlug: string) {
29+
const res = await fetchApiServer(`v1/ecosystem-wallet/${ecosystemSlug}`);
30+
31+
if (!res.ok) {
32+
const data = await res.json();
33+
console.error(data);
34+
throw new FetchError(
35+
res,
36+
data?.message ?? data?.error?.message ?? "Failed to fetch ecosystems",
37+
);
38+
}
39+
40+
const data = (await res.json()) as { result: Ecosystem };
41+
return data.result;
42+
}

0 commit comments

Comments
 (0)