Skip to content

Commit 5f0e25b

Browse files
authored
Add wallet portfolio fetching and display to user table (#8570)
1 parent aad3e64 commit 5f0e25b

File tree

4 files changed

+555
-48
lines changed

4 files changed

+555
-48
lines changed

apps/dashboard/src/@/components/analytics/stat.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
66
icon: React.FC<{ className?: string }>;
77
formatter?: (value: number) => string;
88
isPending: boolean;
9-
}> = ({ label, value, formatter, icon: Icon, isPending }) => {
9+
emptyText?: string;
10+
}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
1011
return (
1112
<dl className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-4 pr-6">
1213
<div>
1314
<dd className="mb-0.5 font-semibold text-2xl tracking-tight">
1415
{isPending ? (
1516
<Skeleton className="h-8 w-20" />
17+
) : emptyText ? (
18+
<span className="text-muted-foreground">{emptyText}</span>
1619
) : value !== undefined && formatter ? (
1720
formatter(value)
1821
) : (

apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx

Lines changed: 264 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@
22

33
import { createColumnHelper } from "@tanstack/react-table";
44
import { format } from "date-fns";
5-
import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
5+
import {
6+
ArrowLeftIcon,
7+
ArrowRightIcon,
8+
DollarSignIcon,
9+
RefreshCwIcon,
10+
UserIcon,
11+
WalletIcon,
12+
} from "lucide-react";
613
import Papa from "papaparse";
714
import { useCallback, useMemo, useState } from "react";
815
import type { ThirdwebClient } from "thirdweb";
916
import type { WalletUser } from "thirdweb/wallets";
17+
import { StatCard } from "@/components/analytics/stat";
18+
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
1019
import { TWTable } from "@/components/blocks/TWTable";
1120
import { WalletAddress } from "@/components/blocks/wallet-address";
21+
import { Badge } from "@/components/ui/badge";
1222
import { Button } from "@/components/ui/button";
23+
import { Progress } from "@/components/ui/progress";
1324
import { Spinner } from "@/components/ui/Spinner";
1425
import {
1526
ToolTipLabel,
@@ -22,6 +33,10 @@ import {
2233
useAllEmbeddedWallets,
2334
useEmbeddedWallets,
2435
} from "@/hooks/useEmbeddedWallets";
36+
import {
37+
useFetchAllPortfolios,
38+
type WalletPortfolioData,
39+
} from "@/hooks/useWalletPortfolio";
2540
import { CopyTextButton } from "../ui/CopyTextButton";
2641
import { AdvancedSearchInput } from "./AdvancedSearchInput";
2742
import { SearchResults } from "./SearchResults";
@@ -74,6 +89,101 @@ export function UserWalletsTable(
7489
| { ecosystemSlug: string; projectClientId?: never }
7590
),
7691
) {
92+
const [activePage, setActivePage] = useState(1);
93+
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
94+
const [isSearching, setIsSearching] = useState(false);
95+
const [hasSearchResults, setHasSearchResults] = useState(false);
96+
97+
// Portfolio state
98+
const [selectedChains, setSelectedChains] = useState<number[]>([1]); // Default to Ethereum
99+
const [portfolioMap, setPortfolioMap] = useState<
100+
Map<string, WalletPortfolioData>
101+
>(new Map());
102+
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
103+
const [fetchProgress, setFetchProgress] = useState({
104+
completed: 0,
105+
total: 0,
106+
});
107+
108+
const walletsQuery = useEmbeddedWallets({
109+
authToken: props.authToken,
110+
clientId: props.projectClientId,
111+
ecosystemSlug: props.ecosystemSlug,
112+
teamId: props.teamId,
113+
page: activePage,
114+
});
115+
const wallets = walletsQuery?.data?.users || [];
116+
const { mutateAsync: getAllEmbeddedWallets, isPending: isLoadingAllWallets } =
117+
useAllEmbeddedWallets({
118+
authToken: props.authToken,
119+
});
120+
121+
const fetchPortfoliosMutation = useFetchAllPortfolios();
122+
123+
const handleFetchBalances = useCallback(async () => {
124+
if (selectedChains.length === 0) return;
125+
126+
try {
127+
// First get all wallets
128+
const allWallets = await getAllEmbeddedWallets({
129+
clientId: props.projectClientId,
130+
ecosystemSlug: props.ecosystemSlug,
131+
teamId: props.teamId,
132+
});
133+
134+
const allAddresses = allWallets
135+
.map((w) => w.wallets[0]?.address)
136+
.filter((a): a is string => !!a);
137+
138+
if (allAddresses.length === 0) {
139+
setPortfolioLoaded(true);
140+
return;
141+
}
142+
143+
setFetchProgress({ completed: 0, total: allAddresses.length });
144+
145+
const results = await fetchPortfoliosMutation.mutateAsync({
146+
addresses: allAddresses,
147+
chainIds: selectedChains,
148+
authToken: props.authToken,
149+
teamId: props.teamId,
150+
clientId: props.projectClientId,
151+
ecosystemSlug: props.ecosystemSlug,
152+
onProgress: (completed, total) => {
153+
setFetchProgress({ completed, total });
154+
},
155+
});
156+
157+
setPortfolioMap(results);
158+
setPortfolioLoaded(true);
159+
} catch (error) {
160+
console.error("Failed to fetch balances:", error);
161+
}
162+
}, [
163+
selectedChains,
164+
getAllEmbeddedWallets,
165+
props.projectClientId,
166+
props.ecosystemSlug,
167+
props.teamId,
168+
props.authToken,
169+
fetchPortfoliosMutation,
170+
]);
171+
172+
const isFetchingBalances =
173+
isLoadingAllWallets || fetchPortfoliosMutation.isPending;
174+
175+
const aggregatedStats = useMemo(() => {
176+
let fundedWallets = 0;
177+
let totalValue = 0;
178+
portfolioMap.forEach((data) => {
179+
if (data.totalUsdValue > 0) {
180+
fundedWallets++;
181+
totalValue += data.totalUsdValue;
182+
}
183+
});
184+
return { fundedWallets, totalValue };
185+
}, [portfolioMap]);
186+
77187
const columns = useMemo(() => {
78188
return [
79189
columnHelper.accessor("id", {
@@ -129,6 +239,79 @@ export function UserWalletsTable(
129239
header: "Address",
130240
id: "address",
131241
}),
242+
columnHelper.accessor("wallets", {
243+
id: "total_balance",
244+
header: "Total Balance",
245+
cell: (cell) => {
246+
const address = cell.getValue()[0]?.address;
247+
if (!address) return "N/A";
248+
if (!portfolioLoaded) {
249+
return <span className="text-muted-foreground text-sm"></span>;
250+
}
251+
const data = portfolioMap.get(address);
252+
if (!data) {
253+
return <span className="text-muted-foreground text-sm"></span>;
254+
}
255+
return (
256+
<span className="text-sm">
257+
{new Intl.NumberFormat("en-US", {
258+
style: "currency",
259+
currency: "USD",
260+
}).format(data.totalUsdValue)}
261+
</span>
262+
);
263+
},
264+
}),
265+
columnHelper.accessor("wallets", {
266+
id: "tokens",
267+
header: "Tokens",
268+
cell: (cell) => {
269+
const address = cell.getValue()[0]?.address;
270+
if (!address) return "N/A";
271+
if (!portfolioLoaded) {
272+
return <span className="text-muted-foreground text-sm"></span>;
273+
}
274+
const data = portfolioMap.get(address);
275+
if (!data || data.tokens.length === 0) {
276+
return <span className="text-muted-foreground text-sm">None</span>;
277+
}
278+
279+
const topTokens = data.tokens
280+
.sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0))
281+
.slice(0, 3)
282+
.map((t) => t.symbol)
283+
.join(", ");
284+
285+
return (
286+
<TooltipProvider>
287+
<Tooltip>
288+
<TooltipTrigger className="text-sm">
289+
{topTokens}
290+
{data.tokens.length > 3 ? "..." : ""}
291+
</TooltipTrigger>
292+
<TooltipContent>
293+
<div className="flex flex-col gap-1">
294+
{data.tokens.map((t) => (
295+
<div
296+
key={`${t.tokenAddress}-${t.chainId}`}
297+
className="flex justify-between gap-4 text-xs"
298+
>
299+
<span>{t.symbol}</span>
300+
<span>
301+
{new Intl.NumberFormat("en-US", {
302+
style: "currency",
303+
currency: "USD",
304+
}).format(t.usdValue || 0)}
305+
</span>
306+
</div>
307+
))}
308+
</div>
309+
</TooltipContent>
310+
</Tooltip>
311+
</TooltipProvider>
312+
);
313+
},
314+
}),
132315
columnHelper.accessor("linkedAccounts", {
133316
cell: (cell) => {
134317
const email = getPrimaryEmail(cell.getValue());
@@ -201,24 +384,7 @@ export function UserWalletsTable(
201384
id: "login_methods",
202385
}),
203386
];
204-
}, [props.client]);
205-
206-
const [activePage, setActivePage] = useState(1);
207-
const [searchResults, setSearchResults] = useState<WalletUser[]>([]);
208-
const [isSearching, setIsSearching] = useState(false);
209-
const [hasSearchResults, setHasSearchResults] = useState(false);
210-
const walletsQuery = useEmbeddedWallets({
211-
authToken: props.authToken,
212-
clientId: props.projectClientId,
213-
ecosystemSlug: props.ecosystemSlug,
214-
teamId: props.teamId,
215-
page: activePage,
216-
});
217-
const wallets = walletsQuery?.data?.users || [];
218-
const { mutateAsync: getAllEmbeddedWallets, isPending } =
219-
useAllEmbeddedWallets({
220-
authToken: props.authToken,
221-
});
387+
}, [props.client, portfolioMap, portfolioLoaded]);
222388

223389
const handleSearch = async (searchType: SearchType, query: string) => {
224390
setIsSearching(true);
@@ -315,11 +481,11 @@ export function UserWalletsTable(
315481
</div>
316482
<Button
317483
className="gap-2 bg-background rounded-full"
318-
disabled={wallets.length === 0 || isPending}
484+
disabled={wallets.length === 0 || isLoadingAllWallets}
319485
onClick={downloadCSV}
320486
variant="outline"
321487
>
322-
{isPending && <Spinner className="size-4" />}
488+
{isLoadingAllWallets && <Spinner className="size-4" />}
323489
Download as .csv
324490
</Button>
325491
</div>
@@ -330,6 +496,83 @@ export function UserWalletsTable(
330496
<SearchResults results={searchResults} client={props.client} />
331497
) : (
332498
<>
499+
{/* Chain Selector and Fetch Button */}
500+
<div className="flex items-center gap-3 px-4 lg:px-6 pb-4 border-b border-border">
501+
<div className="flex items-center gap-2">
502+
<MultiNetworkSelector
503+
client={props.client}
504+
selectedChainIds={selectedChains}
505+
onChange={setSelectedChains}
506+
disableChainId
507+
hideTestnets
508+
popoverContentClassName="max-h-[300px]"
509+
/>
510+
<Button
511+
onClick={() => handleFetchBalances()}
512+
disabled={
513+
isFetchingBalances ||
514+
selectedChains.length === 0 ||
515+
walletsQuery.isPending
516+
}
517+
className="gap-2"
518+
>
519+
{isFetchingBalances ? (
520+
<Spinner className="size-4" />
521+
) : (
522+
<RefreshCwIcon className="size-4" />
523+
)}
524+
{isFetchingBalances
525+
? `Fetching... ${fetchProgress.total > 0 ? Math.round((fetchProgress.completed / fetchProgress.total) * 100) : 0}%`
526+
: "Fetch All Balances"}
527+
</Button>
528+
</div>
529+
530+
{isFetchingBalances && (
531+
<div className="flex flex-col gap-1 flex-1 min-w-[150px]">
532+
{fetchProgress.total > 0 && (
533+
<Progress
534+
value={
535+
(fetchProgress.completed / fetchProgress.total) * 100
536+
}
537+
className="h-2"
538+
/>
539+
)}
540+
<p className="text-xs text-muted-foreground">
541+
This may take a few minutes
542+
</p>
543+
</div>
544+
)}
545+
546+
{portfolioLoaded && !isFetchingBalances && (
547+
<Badge variant="success" className="ml-auto">
548+
Balances loaded for {portfolioMap.size} wallets
549+
</Badge>
550+
)}
551+
</div>
552+
553+
{/* Stats Section */}
554+
<div className="grid grid-cols-2 gap-4 px-4 lg:px-6 py-4">
555+
<StatCard
556+
label="Funded Wallets"
557+
value={portfolioLoaded ? aggregatedStats.fundedWallets : 0}
558+
icon={WalletIcon}
559+
isPending={isFetchingBalances}
560+
emptyText={!portfolioLoaded ? "—" : undefined}
561+
/>
562+
<StatCard
563+
label="Total Value"
564+
value={portfolioLoaded ? aggregatedStats.totalValue : 0}
565+
icon={DollarSignIcon}
566+
formatter={(value) =>
567+
new Intl.NumberFormat("en-US", {
568+
style: "currency",
569+
currency: "USD",
570+
}).format(value)
571+
}
572+
isPending={isFetchingBalances}
573+
emptyText={!portfolioLoaded ? "—" : undefined}
574+
/>
575+
</div>
333576
<TWTable
334577
columns={columns}
335578
data={wallets}

0 commit comments

Comments
 (0)