22
33import { createColumnHelper } from "@tanstack/react-table" ;
44import { 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" ;
613import Papa from "papaparse" ;
714import { useCallback , useMemo , useState } from "react" ;
815import type { ThirdwebClient } from "thirdweb" ;
916import type { WalletUser } from "thirdweb/wallets" ;
17+ import { StatCard } from "@/components/analytics/stat" ;
18+ import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors" ;
1019import { TWTable } from "@/components/blocks/TWTable" ;
1120import { WalletAddress } from "@/components/blocks/wallet-address" ;
21+ import { Badge } from "@/components/ui/badge" ;
1222import { Button } from "@/components/ui/button" ;
23+ import { Progress } from "@/components/ui/progress" ;
1324import { Spinner } from "@/components/ui/Spinner" ;
1425import {
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" ;
2540import { CopyTextButton } from "../ui/CopyTextButton" ;
2641import { AdvancedSearchInput } from "./AdvancedSearchInput" ;
2742import { 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