Skip to content

Commit 4e375d3

Browse files
committed
feat(dashboard): adds ecosystem wallet active users chart
1 parent f40d247 commit 4e375d3

37 files changed

+616
-63
lines changed

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

Lines changed: 13 additions & 11 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;
@@ -277,17 +279,17 @@ export interface BillingCredit {
277279

278280
interface UseAccountInput {
279281
refetchInterval?:
280-
| number
281-
| false
282-
| ((
283-
query: Query<
284-
Account,
285-
Error,
286-
Account,
287-
readonly ["account", string, "me"]
288-
>,
289-
) => number | false | undefined)
290-
| undefined;
282+
| number
283+
| false
284+
| ((
285+
query: Query<
286+
Account,
287+
Error,
288+
Account,
289+
readonly ["account", string, "me"]
290+
>,
291+
) => number | false | undefined)
292+
| undefined;
291293
}
292294

293295
export function useAccount({ refetchInterval }: UseAccountInput = {}) {
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 { EcosystemWalletUsersChartCard } from "./EcosystemWalletUsersChartCard";
6+
import { RangeSelector } from "components/analytics/range-selector";
7+
import { getEcosystemWalletUsage } from "data/analytics/wallets/ecosystem";
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: "e7f67837-6fa4-49fa-9f86-00546788e3d5",
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,237 @@
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 {
12+
EmptyChartState,
13+
LoadingChartState,
14+
} from "components/analytics/empty-chart-state";
15+
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
16+
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
17+
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
18+
import { DocLink } from "components/shared/DocLink";
19+
import { format } from "date-fns";
20+
import { useMemo } from "react";
21+
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
22+
import { formatTickerNumber } from "lib/format-utils";
23+
import { EcosystemWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
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+
chartData.every((data) => data.sponsoredUsd === 0);
113+
114+
return (
115+
<div className="relative w-full rounded-lg border border-border bg-muted/50 p-4 md:p-6">
116+
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
117+
Unique Users
118+
</h3>
119+
<p className="mb-3 text-muted-foreground text-sm">
120+
The total number of active users in your ecosystem.
121+
</p>
122+
123+
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
124+
<ExportToCSVButton
125+
className="bg-background"
126+
fileName="Connect Wallets"
127+
disabled={disableActions}
128+
getData={async () => {
129+
// Shows the number of each type of wallet connected on all dates
130+
const header = ["Date", ...uniqueAuthMethods];
131+
const rows = chartData.map((data) => {
132+
const { time, ...rest } = data;
133+
return [
134+
time,
135+
...uniqueAuthMethods.map((w) => (rest[w] || 0).toString()),
136+
];
137+
});
138+
return { header, rows };
139+
}}
140+
/>
141+
</div>
142+
143+
{/* Chart */}
144+
<ChartContainer
145+
config={chartConfig}
146+
className="h-[250px] w-full md:h-[350px]"
147+
>
148+
{props.isPending ? (
149+
<LoadingChartState />
150+
) : chartData.length === 0 ||
151+
chartData.every((data) => data.sponsoredUsd === 0) ? (
152+
<EmptyChartState>
153+
<div className="flex flex-col items-center justify-center">
154+
<span className="mb-6 text-lg">
155+
Connect users to your app with social logins
156+
</span>
157+
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
158+
<DocLink
159+
link="https://portal.thirdweb.com/typescript/v5/ecosystemWallet"
160+
label="TypeScript"
161+
icon={TypeScriptIcon}
162+
/>
163+
<DocLink
164+
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
165+
label="React"
166+
icon={ReactIcon}
167+
/>
168+
<DocLink
169+
link="https://portal.thirdweb.com/react/v5/ecosystem-wallet/get-started"
170+
label="React Native"
171+
icon={ReactIcon}
172+
/>
173+
<DocLink
174+
link="https://portal.thirdweb.com/unity/v5/wallets/ecosystem-wallet"
175+
label="Unity"
176+
icon={UnityIcon}
177+
/>
178+
</div>
179+
</div>
180+
</EmptyChartState>
181+
) : (
182+
<BarChart
183+
accessibilityLayer
184+
data={chartData}
185+
margin={{
186+
top: 20,
187+
}}
188+
>
189+
<CartesianGrid vertical={false} />
190+
191+
<XAxis
192+
dataKey="time"
193+
tickLine={false}
194+
tickMargin={10}
195+
axisLine={false}
196+
/>
197+
198+
<YAxis
199+
dataKey={(data) =>
200+
Object.entries(data)
201+
.filter(([key]) => key !== "time")
202+
.map(([, value]) => value)
203+
.reduce((acc, current) => Number(acc) + Number(current), 0)
204+
}
205+
tickLine={false}
206+
axisLine={false}
207+
tickFormatter={(value) => formatTickerNumber(value)}
208+
/>
209+
210+
<ChartTooltip
211+
cursor={true}
212+
content={
213+
<ChartTooltipContent
214+
valueFormatter={(value) => formatTickerNumber(Number(value))}
215+
/>
216+
}
217+
/>
218+
<ChartLegend content={<ChartLegendContent />} />
219+
{uniqueAuthMethods.map((authMethod) => {
220+
return (
221+
<Bar
222+
key={authMethod}
223+
dataKey={authMethod}
224+
fill={chartConfig[authMethod]?.color}
225+
radius={4}
226+
stackId="a"
227+
strokeWidth={1.5}
228+
className="stroke-muted"
229+
/>
230+
);
231+
})}
232+
</BarChart>
233+
)}
234+
</ChartContainer>
235+
</div>
236+
);
237+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { InAppWalletStats } from "@3rdweb-sdk/react/hooks/useApi";
2+
import { Stat } from "components/analytics/stat";
3+
import { ActivityIcon, UserIcon } from "lucide-react";
4+
5+
export function InAppWalletsSummary(props: {
6+
allTimeStats: InAppWalletStats[] | undefined;
7+
monthlyStats: InAppWalletStats[] | 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+
}

0 commit comments

Comments
 (0)