+
+
+ {/* {nav} */}
+ nav
+
+
+
+ {/* {list} */}
+
+
+
+ Assets
+
+
+ Collectibles
+
+
+
{isLoading ? (
-
Loading...
+
) : (
)}
+
-
Detail
+
+ {/* {detail} */}
diff --git a/apps/wallet/src/routes/portfolio/collectibles/index.tsx b/apps/wallet/src/routes/portfolio/collectibles/index.tsx
new file mode 100644
index 000000000..9d2d77a45
--- /dev/null
+++ b/apps/wallet/src/routes/portfolio/collectibles/index.tsx
@@ -0,0 +1,199 @@
+import {
+ CollectiblesGrid as CollectiblesList,
+ TabLink,
+} from '@status-im/wallet/components'
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { createFileRoute, useRouterState } from '@tanstack/react-router'
+
+import { Link } from '@/components/link'
+
+import type { NetworkType } from '@status-im/wallet/data'
+
+const DEFAULT_SORT = {
+ assets: { column: 'name', direction: 'asc' as const },
+ collectibles: { column: 'name', direction: 'asc' as const },
+} as const
+
+export const SORT_OPTIONS = {
+ assets: {
+ name: 'Name',
+ balance: 'Balance',
+ '24h': '24H%',
+ value: 'Value',
+ price: 'Price',
+ },
+ collectibles: {
+ name: 'Name',
+ collection: 'Collection',
+ },
+} as const
+
+export const Route = createFileRoute('/portfolio/collectibles/')({
+ component: RouteComponent,
+ head: () => ({
+ meta: [
+ {
+ title: 'Extension | Wallet | Portfolio',
+ },
+ ],
+ }),
+})
+
+const getCollectibles = async (
+ address: string,
+ networks: NetworkType[],
+ search?: string,
+ sort?: {
+ column: 'name' | 'collection'
+ direction: 'asc' | 'desc'
+ },
+) => {
+ const url = new URL('http://localhost:3030/api/trpc/collectibles.page')
+
+ url.searchParams.set(
+ 'input',
+ JSON.stringify({
+ json: {
+ address,
+ networks,
+ limit: 20,
+ offset: 0,
+ search,
+ sort,
+ },
+ }),
+ )
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch.')
+ }
+
+ const body = await response.json()
+ return body.result.data.json.collectibles
+}
+
+function RouteComponent() {
+ const { location } = useRouterState()
+ const pathname = location.pathname
+
+ const handleSelect = (url: string, options?: { scroll?: boolean }) => {
+ // Handle the selection of an asset
+ console.log('Selected asset URL:', url)
+ console.log('Scroll option:', options?.scroll)
+ }
+
+ // todo: export trpc client with api router and used instead
+ // todo: cache
+ const searchParams = new URLSearchParams(window.location.search)
+ const search = searchParams.get('search') ?? undefined
+ const sortParam = searchParams.get('sort')
+
+ const address = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
+ const sort = {
+ column:
+ (sortParam?.split(',')[0] as 'name' | 'collection') ||
+ DEFAULT_SORT.collectibles.column,
+ direction:
+ (sortParam?.split(',')[1] as 'asc' | 'desc') ||
+ DEFAULT_SORT.collectibles.direction,
+ }
+
+ const networks = searchParams.get('networks')?.split(',') ?? [
+ 'ethereum',
+ 'optimism',
+ 'arbitrum',
+ 'base',
+ 'polygon',
+ 'bsc',
+ ]
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
+ useInfiniteQuery({
+ queryKey: ['collectibles', address, networks, search, sort],
+ queryFn: async ({ pageParam = 0 }) => {
+ const collectibles = await getCollectibles(
+ address,
+ networks as NetworkType[],
+ search,
+ sort,
+ )
+ return {
+ collectibles,
+ nextPage: pageParam + 1,
+ }
+ },
+ getNextPageParam: lastPage => lastPage.nextPage,
+ initialPageParam: 0,
+ staleTime: 60 * 60 * 1000,
+ gcTime: 60 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+
+ const collectibles = useMemo(() => {
+ return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
+ }, [data?.pages])
+
+ return (
+
+
+ {/* {nav} */}
+ nav
+
+
+
+ {/* {list} */}
+
+
+
+ Assets
+
+
+ Collectibles
+
+
+
+ {isLoading ? (
+
+ ) : (
+
{
+ // Clear the search input
+ console.log('Search cleared')
+ }}
+ hasNextPage={hasNextPage}
+ onSelect={handleSelect}
+ />
+ )}
+
+
+
+
+ {/* {detail} */}
+
+
+
+ )
+}
diff --git a/packages/wallet/package.json b/packages/wallet/package.json
index 10f9b0b54..3037a5857 100644
--- a/packages/wallet/package.json
+++ b/packages/wallet/package.json
@@ -25,6 +25,11 @@
"types": "./dist/src/components/index.d.ts",
"import": "./dist/components/index.es.js",
"require": "./dist/components/index.cjs.js"
+ },
+ "./hooks": {
+ "types": "./dist/src/hooks/index.d.ts",
+ "import": "./dist/hooks/index.es.js",
+ "require": "./dist/hooks/index.cjs.js"
}
},
"files": [
diff --git a/packages/wallet/src/components/assets-list/index.tsx b/packages/wallet/src/components/assets-list/index.tsx
index 015f1f4e4..c5b535faf 100644
--- a/packages/wallet/src/components/assets-list/index.tsx
+++ b/packages/wallet/src/components/assets-list/index.tsx
@@ -73,8 +73,8 @@ const AssetsList = (props: Props) => {
}, [assets, searchParamValue, orderByColumn, ascending])
return (
- <>
-
+
+
{filteredAssets.length !== 0 && (
@@ -158,7 +158,7 @@ const AssetsList = (props: Props) => {
)}
-
+
{filteredAssets.map(asset => {
const href = `${pathname?.replace(
@@ -183,7 +183,7 @@ const AssetsList = (props: Props) => {
src={asset.icon}
/>
-
+
{asset.name}
@@ -215,7 +215,7 @@ const AssetsList = (props: Props) => {
})}
- >
+
)
}
diff --git a/packages/wallet/src/components/collectibles-grid/index.tsx b/packages/wallet/src/components/collectibles-grid/index.tsx
new file mode 100644
index 000000000..38c07e1be
--- /dev/null
+++ b/packages/wallet/src/components/collectibles-grid/index.tsx
@@ -0,0 +1,197 @@
+import { Button, Skeleton } from '@status-im/components'
+import { SadIcon } from '@status-im/icons/20'
+import { cx } from 'class-variance-authority'
+
+import { useInfiniteLoading } from '../../hooks/use-infinite-loading'
+
+import type { Collectible } from '@status-im/wallet/data'
+import type { ComponentType, ReactNode } from 'react'
+
+export const GRADIENTS = [
+ 'linear-gradient(120deg, #F6B03C, #1992D7, #7140FD)',
+ 'linear-gradient(190deg, #FF7D46, #7140FD, #2A4AF5)',
+ 'linear-gradient(145deg, #2A4AF5, #EC266C, #F6B03C)',
+ 'linear-gradient(195deg, #216266, #FF7D46, #2A4AF5)',
+ 'linear-gradient(45deg, #7140FD, #216266, #F6B03C)',
+ 'linear-gradient(145deg, #F6B03C, #1992D7, #7140FD)',
+ 'linear-gradient(45deg, #F6B03C, #1992D7, #7140FD)',
+ 'linear-gradient(145deg, #FF7D46, #7140FD, #2A4AF5)',
+ 'linear-gradient(45deg, #2A4AF5, #EC266C, #F6B03C)',
+ 'linear-gradient(125deg, #216266, #FF7D46, #2A4AF5)',
+ 'linear-gradient(145deg, #F6B03C, #1992D7, #7140FD)',
+ 'linear-gradient(145deg, #F6B03C, #1992D7, #7140FD)',
+]
+type LinkComponentProps = {
+ href: string
+ className?: string
+ children: ReactNode
+}
+
+type Props = {
+ collectibles: Collectible[]
+ address: string
+ pathname: string
+ onSelect: (url: string, options?: { scroll?: boolean }) => void
+ search?: string
+ hasNextPage?: boolean
+ fetchNextPage: () => void
+ isFetchingNextPage: boolean
+ searchParams: URLSearchParams
+ clearSearch: () => void
+ LinkComponent: ComponentType
+}
+
+const FallbackImage = () => {
+ return (
+
+
+
+ No image available
+
+
+ )
+}
+
+const CollectibleImage = ({ url, name }: { url: string; name: string }) => {
+ return (
+
+
+
+ )
+}
+
+const CollectiblesGrid = (props: Props) => {
+ const {
+ collectibles,
+ address,
+ fetchNextPage,
+ isFetchingNextPage,
+ pathname,
+ search,
+ searchParams,
+ clearSearch,
+ hasNextPage,
+ LinkComponent,
+ } = props
+
+ const { endOfPageRef, isLoading } = useInfiniteLoading({
+ rootMargin: '200px',
+ fetchNextPage,
+ isFetchingNextPage,
+ hasNextPage: hasNextPage ?? false,
+ })
+
+ return (
+ <>
+
+ {collectibles.map(collectible => {
+ const href = `/${address}/collectibles/${collectible.network}/${collectible.contract}/${collectible.id}`
+ const search = searchParams.toString()
+ const query = search ? `?${search}` : ''
+ // Checking if the collectible.id is in the pathname by splitting the pathname and checking if the last element is the collectible.id and if it is same contract
+ const isActive =
+ pathname.split('/').pop() === collectible.id &&
+ pathname.includes(collectible.contract)
+
+ const imageUrl = collectible.thumbnail ?? collectible.image
+
+ return (
+
+ {imageUrl ? (
+
+ ) : (
+
+ )}
+
+ {/* {collectible.collection.image && (
+
+ )} */}
+
+ {collectible.name}
+
+
+
+ )
+ })}
+ {collectibles.length === 0 && !!search && (
+
+
+ No collectibles found
+
+
+ We didn't find any collectibles that match your search
+
+
clearSearch()}
+ >
+ Clear search
+
+
+ )}
+
+ {isLoading && hasNextPage && (
+
+ {GRADIENTS.slice(0, 4).map((gradient, index) => {
+ return (
+
+ )
+ })}
+
+ )}
+ {hasNextPage &&
}
+ >
+ )
+}
+
+export { CollectiblesGrid }
diff --git a/packages/wallet/src/components/index.tsx b/packages/wallet/src/components/index.tsx
index 9e84d1bd7..b0c75fd13 100644
--- a/packages/wallet/src/components/index.tsx
+++ b/packages/wallet/src/components/index.tsx
@@ -3,6 +3,7 @@ export * from '../utils/variants'
export { AccountMenu } from './account-menu'
export { type Account, Address, type AddressProps } from './address'
export { AssetsList } from './assets-list'
+export { CollectiblesGrid } from './collectibles-grid'
export { CurrencyAmount } from './currency-amount'
export { DeleteAddressAlert } from './delete-address-alert'
export { Image, type ImageProps } from './image'
@@ -19,4 +20,5 @@ export {
} from './shorten-address'
export { Slider, type SliderProps } from './slider'
export { StickyHeaderContainer } from './sticky-header-container'
+export { TabLink } from './tab-link'
export { Tooltip } from './tooltip'
diff --git a/packages/wallet/src/components/tab-link/index.tsx b/packages/wallet/src/components/tab-link/index.tsx
new file mode 100644
index 000000000..3653dc5a5
--- /dev/null
+++ b/packages/wallet/src/components/tab-link/index.tsx
@@ -0,0 +1,40 @@
+import { cx } from 'class-variance-authority'
+
+import type { ComponentType, ReactNode } from 'react'
+
+type LinkComponentProps = {
+ href: string
+ className?: string
+ children: ReactNode
+}
+
+type Props = {
+ href: string
+ children: ReactNode
+ className?: string
+ isActive?: boolean
+ LinkComponent: ComponentType
+}
+
+const TabLink = ({
+ href,
+ children,
+ className,
+ LinkComponent,
+ isActive,
+}: Props) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export { TabLink }
diff --git a/packages/wallet/src/hooks/index.ts b/packages/wallet/src/hooks/index.ts
new file mode 100644
index 000000000..fd15fbbe9
--- /dev/null
+++ b/packages/wallet/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './use-infinite-loading'
+export * from './use-intersection-observer'
diff --git a/apps/portfolio/src/app/_hooks/use-infinite-loading.ts b/packages/wallet/src/hooks/use-infinite-loading.ts
similarity index 98%
rename from apps/portfolio/src/app/_hooks/use-infinite-loading.ts
rename to packages/wallet/src/hooks/use-infinite-loading.ts
index 42e9b058d..430cd2ea8 100644
--- a/apps/portfolio/src/app/_hooks/use-infinite-loading.ts
+++ b/packages/wallet/src/hooks/use-infinite-loading.ts
@@ -1,3 +1,5 @@
+'use client'
+
import { useEffect, useRef } from 'react'
import { useIntersectionObserver } from './use-intersection-observer'
diff --git a/apps/portfolio/src/app/_hooks/use-intersection-observer.ts b/packages/wallet/src/hooks/use-intersection-observer.ts
similarity index 99%
rename from apps/portfolio/src/app/_hooks/use-intersection-observer.ts
rename to packages/wallet/src/hooks/use-intersection-observer.ts
index 1d548866c..1f9119743 100644
--- a/apps/portfolio/src/app/_hooks/use-intersection-observer.ts
+++ b/packages/wallet/src/hooks/use-intersection-observer.ts
@@ -13,7 +13,7 @@ export function useIntersectionObserver(
root = null,
rootMargin = '0%',
freezeOnceVisible = false,
- }: Args
+ }: Args,
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState()
diff --git a/packages/wallet/tailwind.config.ts b/packages/wallet/tailwind.config.ts
index 59658440b..877ee772e 100644
--- a/packages/wallet/tailwind.config.ts
+++ b/packages/wallet/tailwind.config.ts
@@ -1,12 +1,12 @@
-import statusComponentsConfig from '@status-im/components/config'
+import config from '@status-im/components/config'
import { scrollbarGutter, scrollbarWidth } from 'tailwind-scrollbar-utilities'
import plugin from 'tailwindcss/plugin'
-import animatePlugin from 'tailwindcss-animate'
+import * as animatePlugin from 'tailwindcss-animate'
import type { Config } from 'tailwindcss'
-const config: Config = {
- presets: [statusComponentsConfig],
+const tailwindConfig: Config = {
+ presets: [config],
future: {
hoverOnlyWhenSupported: true,
@@ -255,8 +255,6 @@ const config: Config = {
},
},
plugins: [
- animatePlugin,
-
// add scrollbar utilities before lands in tailwindcss
// @see https://github.com/tailwindlabs/tailwindcss/pull/5732
scrollbarWidth(),
@@ -286,8 +284,9 @@ const config: Config = {
// )
}),
+ animatePlugin,
// reactAriaComponentsPlugin,
],
}
-export default config
+export default tailwindConfig
diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json
index 1b48b8ce5..d433b64a7 100644
--- a/packages/wallet/tsconfig.json
+++ b/packages/wallet/tsconfig.json
@@ -8,6 +8,8 @@
"**/*.json"
],
"compilerOptions": {
+ "rootDir": ".",
+ // "composite": true,
"jsx": "preserve",
"outDir": "./dist",
"allowJs": true,
diff --git a/packages/wallet/vite.config.ts b/packages/wallet/vite.config.ts
index 986f5882f..7b4835086 100644
--- a/packages/wallet/vite.config.ts
+++ b/packages/wallet/vite.config.ts
@@ -19,6 +19,7 @@ export default defineConfig(({ mode }) => {
'components/index': './src/components/index.tsx',
'tailwind.config': './tailwind.config.ts',
'data/index': './src/data/index.ts',
+ 'hooks/index': './src/hooks/index.ts',
},
formats: ['es', 'cjs'],
fileName: format => {