port wallet detail routes (#670)

Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
marcelines
2025-06-26 13:58:53 +09:00
committed by GitHub
co-authored by Felicio
parent 4740bd7dda
commit dec09cf966
26 changed files with 1481 additions and 255 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@status-im/wallet': patch
'portfolio': patch
'wallet': patch
---
port wallet detail routes
-3
View File
@@ -1,9 +1,6 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"npm.packageManager": "pnpm",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.useESLintClass": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
@@ -5,7 +5,9 @@ import { BuyIcon, ReceiveBlurIcon } from '@status-im/icons/20'
import {
Balance,
CurrencyAmount,
NetworkBreakdown,
StickyHeaderContainer,
TokenLogo,
} from '@status-im/wallet/components'
import { cx } from 'class-variance-authority'
import { notFound } from 'next/navigation'
@@ -19,8 +21,6 @@ import { ReceiveCryptoDrawer } from '../../../../_components/receive-crypto-draw
import { TokenAmount } from '../../../../_components/token-amount'
import { Chart } from '../_components/chart'
import { Loading } from '../_components/chart/loading'
import { NetworkBreakdown } from './_components/network-breakdown'
import { TokenLogo } from './_components/token-logo'
import type { ApiOutput, NetworkType } from '@status-im/wallet/data'
+5
View File
@@ -54,9 +54,14 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"rehype-react": "^8.0.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"superjson": "^2.2.1",
"trpc-chrome": "^1.0.0",
"ts-pattern": "^5.7.1",
"unified": "^11.0.5",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.23.8"
},
@@ -0,0 +1,347 @@
import {
Children,
cloneElement,
type ComponentProps,
type ReactElement,
type ReactNode,
} from 'react'
import { Step } from '@status-im/components'
import { BulletIcon, CheckIcon } from '@status-im/icons/20'
import { Link } from '@tanstack/react-router'
import { cx } from 'class-variance-authority'
import { match } from 'ts-pattern'
export function renderText(params: {
children: React.ReactNode | React.ReactNode[]
weight?: string
color?: string
parent?: string
}) {
const {
children,
weight = 'font-regular',
color = 'text-neutral-100',
parent,
} = params
return Children.map(children, child => {
if (typeof child === 'string') {
return (
<span
className={cx(
'text-15',
weight,
color,
// 'break-word hyphens-manual break-all'
)}
>
{child}
</span>
)
}
if (parent) {
return cloneElement(child as ReactElement<{ parent?: string }>, {
parent,
})
}
return child
})
}
const paragraphMarginTop = '[&+p]:!mt-[1.359375rem]'
const paragraphMarginVertical = 'mt-5 [&+:not(:is(p))]:pt-5'
const paragraphTextSize = 'text-15'
const blockquoteParagraphTextSize = '[&>p>*]:text-15'
const markdownComponents = {
strong: (props: ComponentProps<'strong'>) => {
return (
<strong {...props} className={cx('font-semibold', paragraphTextSize)}>
{props.children}
</strong>
)
},
del: (props: ComponentProps<'del'>) => {
return (
<del {...props} className={cx('line-through', paragraphTextSize)}>
{props.children}
</del>
)
},
em: (props: ComponentProps<'em'>) => {
return (
<em
{...props}
className={cx(
'font-[Inter,-apple-system,system-ui]',
'italic',
paragraphTextSize,
)}
>
{props.children}
</em>
)
},
h1: (props: ComponentProps<'h1'>) => {
return (
<h1 {...props} className="group relative text-40 font-bold">
{props.children}
</h1>
)
},
h2: (props: ComponentProps<'h2'> & { mb?: string; mt?: string }) => {
const { children, id, mb = 'mb-3', mt = 'mt-5', ...rest } = props
return (
<h2
id={id}
{...rest}
className={cx(
mb,
mt,
'group relative scroll-m-[100px] text-15 font-semibold',
)}
>
{children}
</h2>
)
},
h3: (props: ComponentProps<'h3'>) => {
return (
<h3
{...props}
className="group relative mb-3 mt-5 scroll-m-[100px] text-15 font-semibold"
>
{props.children}
</h3>
)
},
h4: (props: ComponentProps<'h4'>) => {
return (
<h4
{...props}
className="group relative mb-3 mt-5 scroll-m-[100px] text-15 font-semibold"
>
{props.children}
</h4>
)
},
h5: (props: ComponentProps<'h5'>) => {
return (
<h5
{...props}
className="group relative mb-3 mt-5 scroll-m-[100px] text-15 font-semibold"
>
{props.children}
</h5>
)
},
h6: (props: ComponentProps<'h6'>) => {
return (
<h6
{...props}
className="group relative mb-3 mt-5 scroll-m-[100px] text-15 font-semibold"
>
{props.children}
</h6>
)
},
blockquote: (props: ComponentProps<'blockquote'>) => {
const { children, ...rest } = props
const blockquoteChildren = Children.toArray(children).filter(
child => child !== '\n',
) as (ReactElement | string)[]
return (
<blockquote
{...rest}
className={cx(
blockquoteParagraphTextSize,
'mt-5 border-l border-dashed border-neutral-30 !pt-0 pl-6',
)}
>
{Children.map(blockquoteChildren, (item: ReactElement | string) => {
if (typeof item === 'string') {
return renderText({ children: item })
}
if (item.type === 'p') {
return cloneElement(item)
}
return item
})}
</blockquote>
)
},
a: (props: ComponentProps<'a'>) => {
if (!props.href) {
const { children, ...rest } = props
return (
<a
{...rest}
className={cx('text-customisation-blue-50', paragraphTextSize)}
>
{children}
</a>
)
}
if (props.href.startsWith('/')) {
return (
<Link
to={props.href}
className={cx('text-customisation-blue-50', paragraphTextSize)}
>
{props.children}
</Link>
)
}
return (
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
className={cx('text-customisation-blue-50', paragraphTextSize)}
>
{props.children}
</a>
)
},
p: (props: ComponentProps<'p'> & { parent?: string }) => {
const { children } = props
if (
(children as { type?: { name?: string } })?.type?.name === 'img' ||
props.parent === 'li'
) {
return <>{children}</>
}
return (
<p
className={cx(
paragraphMarginVertical,
paragraphMarginTop,
'[:is(h1,h2,h3,h4,h5,h6)+&]:!mt-0', // immediately follows a heading as a sibling element
// '[&:not(:has(+*))]:!mb-0', // not followed by any sibling element
'[:is(div,td,blockquote)>&:first-child]:!mt-0', // is a first child of selected parent element
)}
>
{renderText({ children })}
</p>
)
},
ul: (props: ComponentProps<'ul'>) => {
return (
<ul className="flex flex-col gap-3 [:is(ul)+&]:mt-5 [ul_&]:mt-3">
{props.children}
</ul>
)
},
ol: (props: ComponentProps<'ol'> & { parent?: string }) => {
const listItems = Children.toArray(props.children).filter(
child => typeof child === 'object',
)
return (
<ol
className="group flex flex-col gap-3 [:is(ol)+&]:mt-5 [ol_&]:mt-3"
{...props}
>
{Children.map(listItems, (item: ReactNode, index) =>
cloneElement(
item as ReactElement<{ order?: number; parent?: string }>,
{
order: index + 1,
parent: props.parent ?? 'ol',
},
),
)}
</ol>
)
},
li: (
props: ComponentProps<'li'> & {
parent?: 'ol' | 'AwaitedList'
order?: number
variant?: React.ComponentProps<typeof Step>['variant']
},
) => {
const icon = match(props.parent)
.with('ol', () => (
<Step variant={props.variant ?? 'primary'} value={props.order!} />
))
.with('AwaitedList', () => <CheckIcon className="text-success-50" />)
.otherwise(() => <BulletIcon className="text-neutral-50" />)
return (
<li className="flex items-start gap-2">
<div className={cx('flex shrink-0 items-center', 'h-[24px]')}>
{icon}
</div>
<div className="w-full">
{renderText({
children: props.children,
parent: 'li',
})}
</div>
</li>
)
},
// handled conditionally per use case with divider component
hr: () => {
return <></>
},
img: (props: ComponentProps<'img'>) => {
return <img {...props} alt={props.alt || ''} className="my-5 rounded-20" />
},
pre: (props: ComponentProps<'pre'>) => (
<pre {...props} className="overflow-scroll scrollbar-none" />
),
div: (props: ComponentProps<'div'>) => {
return <div {...props} />
},
code: (props: ComponentProps<'code'>) => {
const multiline = Children.toArray(props.children).length > 1
if (
!multiline &&
(typeof props.children === 'string' ||
(Array.isArray(props.children) &&
typeof props.children[0] === 'string'))
) {
return (
// note: https://www.figma.com/file/qSIh8wh9EVdY8S2sZce15n/Composer-for-Desktop?type=design&node-id=7850-672452&mode=design&t=V9tDjCw6RLuPF4F6-4
<code
{...props}
className="inline-block rounded-10 border border-neutral-10 bg-neutral-5 px-2 font-regular"
/>
)
}
// todo?: https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?type=design&node-id=5626-159428&mode=design&t=stTlBeUAUUi4JR0v-4
// note: http://localhost:3000/help/getting-started/download-status-for-linux example for scrolling
return <code className="w-fit" {...props} />
},
figure: (props: ComponentProps<'figure'>) => (
<figure {...props} className="my-5" />
),
iframe: (props: React.ComponentProps<'iframe'>) => {
// todo?: match youtube props to use aspect-video
return (
<iframe
{...props}
title={props.title}
className="aspect-video size-full rounded-20"
/>
)
},
}
export { markdownComponents }
+13 -10
View File
@@ -1,11 +1,9 @@
import { Avatar } from '@status-im/components'
import { Balance, StickyHeaderContainer } from '@status-im/wallet/components'
import type { Account } from '@status-im/wallet/components'
type Props = {
list: React.ReactNode
detail: React.ReactNode
detail?: React.ReactNode
isLoading?: boolean
}
@@ -45,12 +43,10 @@ const actionsButtonsData = {
},
}
// Mock data. todo? Replace with actual data
const account: Account = {
name: 'Peachy Wallet',
const account = {
name: 'Account 1',
emoji: '🍑',
color: 'magenta',
address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
}
const SplittedLayout = (props: Props) => {
@@ -152,9 +148,16 @@ const SplittedLayout = (props: Props) => {
</div>
</div>
<div className="hidden basis-1/2 flex-col bg-neutral-10 2xl:flex">
{/* {detail} */}
{detail}
<div className="relative hidden basis-1/2 flex-col bg-white-100 2xl:flex">
<div className="relative z-20">{detail}</div>
<div
className="absolute z-10 size-full"
style={{
backgroundColor: 'rgba(245, 246, 248, 0.24)',
}}
/>
<div className="absolute z-0 size-full bg-gradient-to-r from-[#F5F6F83D] to-transparent" />
</div>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
import { useQuery } from '@tanstack/react-query'
type Props = {
isWalletLoading: boolean
address?: string
}
// todo: export trpc client with api router and used instead
// todo: cache
const useAssets = (props: Props) => {
const { address, isWalletLoading } = props
return useQuery({
queryKey: ['assets', address],
queryFn: async () => {
if (!address) {
throw new Error('No wallet address available')
}
const url = new URL('http://localhost:3030/api/trpc/assets.all')
url.searchParams.set(
'input',
JSON.stringify({
json: {
address,
networks: [
'ethereum',
'optimism',
'arbitrum',
'base',
'polygon',
'bsc',
],
},
}),
)
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.assets
},
enabled: !!address && !isWalletLoading,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
}
export { useAssets }
+128
View File
@@ -0,0 +1,128 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import type { NetworkType } from '@status-im/wallet/data'
const PAGE_LIMIT = 20
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
type Props = {
isWalletLoading: boolean
address?: string
}
const getCollectibles = async (
address: string,
networks: NetworkType[],
search?: string,
sort?: {
column: 'name' | 'collection'
direction: 'asc' | 'desc'
},
offset = 0,
) => {
const url = new URL('http://localhost:3030/api/trpc/collectibles.page')
url.searchParams.set(
'input',
JSON.stringify({
json: {
address,
networks,
limit: PAGE_LIMIT,
offset,
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
}
const useCollectibles = (props: Props) => {
const { address, isWalletLoading } = props
const searchParams = new URLSearchParams(window.location.search)
const search = searchParams.get('search') ?? undefined
const sortParam = searchParams.get('sort')
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',
]
return useInfiniteQuery({
queryKey: ['collectibles', address, networks, search, sort],
queryFn: async ({ pageParam = 0 }) => {
if (!address) {
throw new Error('No wallet address available')
}
const offset = pageParam * PAGE_LIMIT
const collectibles = await getCollectibles(
address,
networks as NetworkType[],
search,
sort,
offset,
)
return {
collectibles,
nextPage: pageParam + 1,
}
},
getNextPageParam: lastPage => lastPage.nextPage,
initialPageParam: 0,
enabled: !!address && !isWalletLoading,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
}
export { useCollectibles }
+24
View File
@@ -0,0 +1,24 @@
import production from 'react/jsx-runtime'
import rehypeReact from 'rehype-react'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
import { markdownComponents as components } from '@/components/content/markdown'
import type { ReactElement } from 'react'
export const renderMarkdown = async (
markdown: string,
): Promise<ReactElement> => {
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
// @ts-expect-error - rehype-react types are not compatible with unified types
.use(rehypeReact, {
...production,
components,
})
.process(markdown)
return result.result as ReactElement
}
+55 -1
View File
@@ -18,6 +18,8 @@ import { Route as OnboardingNewImport } from './routes/onboarding/new'
import { Route as OnboardingImportImport } from './routes/onboarding/import'
import { Route as PortfolioCollectiblesIndexImport } from './routes/portfolio/collectibles/index'
import { Route as PortfolioAssetsIndexImport } from './routes/portfolio/assets/index'
import { Route as PortfolioAssetsTickerImport } from './routes/portfolio/assets/$ticker'
import { Route as PortfolioCollectiblesNetworkContractIdImport } from './routes/portfolio/collectibles/$network/$contract/$id'
// Create/Update Routes
@@ -65,6 +67,19 @@ const PortfolioAssetsIndexRoute = PortfolioAssetsIndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const PortfolioAssetsTickerRoute = PortfolioAssetsTickerImport.update({
id: '/portfolio/assets/$ticker',
path: '/portfolio/assets/$ticker',
getParentRoute: () => rootRoute,
} as any)
const PortfolioCollectiblesNetworkContractIdRoute =
PortfolioCollectiblesNetworkContractIdImport.update({
id: '/portfolio/collectibles/$network/$contract/$id',
path: '/portfolio/collectibles/$network/$contract/$id',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@@ -104,6 +119,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OnboardingIndexImport
parentRoute: typeof OnboardingLayoutImport
}
'/portfolio/assets/$ticker': {
id: '/portfolio/assets/$ticker'
path: '/portfolio/assets/$ticker'
fullPath: '/portfolio/assets/$ticker'
preLoaderRoute: typeof PortfolioAssetsTickerImport
parentRoute: typeof rootRoute
}
'/portfolio/assets/': {
id: '/portfolio/assets/'
path: '/portfolio/assets'
@@ -118,6 +140,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PortfolioCollectiblesIndexImport
parentRoute: typeof rootRoute
}
'/portfolio/collectibles/$network/$contract/$id': {
id: '/portfolio/collectibles/$network/$contract/$id'
path: '/portfolio/collectibles/$network/$contract/$id'
fullPath: '/portfolio/collectibles/$network/$contract/$id'
preLoaderRoute: typeof PortfolioCollectiblesNetworkContractIdImport
parentRoute: typeof rootRoute
}
}
}
@@ -144,8 +173,10 @@ export interface FileRoutesByFullPath {
'/onboarding/import': typeof OnboardingImportRoute
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding/': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/assets': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
}
export interface FileRoutesByTo {
@@ -153,8 +184,10 @@ export interface FileRoutesByTo {
'/onboarding/import': typeof OnboardingImportRoute
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/assets': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
}
export interface FileRoutesById {
@@ -164,8 +197,10 @@ export interface FileRoutesById {
'/onboarding/import': typeof OnboardingImportRoute
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding/': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/assets/': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles/': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
}
export interface FileRouteTypes {
@@ -176,16 +211,20 @@ export interface FileRouteTypes {
| '/onboarding/import'
| '/onboarding/new'
| '/onboarding/'
| '/portfolio/assets/$ticker'
| '/portfolio/assets'
| '/portfolio/collectibles'
| '/portfolio/collectibles/$network/$contract/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/onboarding/import'
| '/onboarding/new'
| '/onboarding'
| '/portfolio/assets/$ticker'
| '/portfolio/assets'
| '/portfolio/collectibles'
| '/portfolio/collectibles/$network/$contract/$id'
id:
| '__root__'
| '/'
@@ -193,23 +232,30 @@ export interface FileRouteTypes {
| '/onboarding/import'
| '/onboarding/new'
| '/onboarding/'
| '/portfolio/assets/$ticker'
| '/portfolio/assets/'
| '/portfolio/collectibles/'
| '/portfolio/collectibles/$network/$contract/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OnboardingLayoutRoute: typeof OnboardingLayoutRouteWithChildren
PortfolioAssetsTickerRoute: typeof PortfolioAssetsTickerRoute
PortfolioAssetsIndexRoute: typeof PortfolioAssetsIndexRoute
PortfolioCollectiblesIndexRoute: typeof PortfolioCollectiblesIndexRoute
PortfolioCollectiblesNetworkContractIdRoute: typeof PortfolioCollectiblesNetworkContractIdRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OnboardingLayoutRoute: OnboardingLayoutRouteWithChildren,
PortfolioAssetsTickerRoute: PortfolioAssetsTickerRoute,
PortfolioAssetsIndexRoute: PortfolioAssetsIndexRoute,
PortfolioCollectiblesIndexRoute: PortfolioCollectiblesIndexRoute,
PortfolioCollectiblesNetworkContractIdRoute:
PortfolioCollectiblesNetworkContractIdRoute,
}
export const routeTree = rootRoute
@@ -224,8 +270,10 @@ export const routeTree = rootRoute
"children": [
"/",
"/onboarding",
"/portfolio/assets/$ticker",
"/portfolio/assets/",
"/portfolio/collectibles/"
"/portfolio/collectibles/",
"/portfolio/collectibles/$network/$contract/$id"
]
},
"/": {
@@ -251,11 +299,17 @@ export const routeTree = rootRoute
"filePath": "onboarding/index.tsx",
"parent": "/onboarding"
},
"/portfolio/assets/$ticker": {
"filePath": "portfolio/assets/$ticker.tsx"
},
"/portfolio/assets/": {
"filePath": "portfolio/assets/index.tsx"
},
"/portfolio/collectibles/": {
"filePath": "portfolio/collectibles/index.tsx"
},
"/portfolio/collectibles/$network/$contract/$id": {
"filePath": "portfolio/collectibles/$network/$contract/$id.tsx"
}
}
}
@@ -0,0 +1,78 @@
import { Suspense } from 'react'
import { AssetsList } from '@status-im/wallet/components'
import {
createFileRoute,
useRouter,
useRouterState,
} from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useAssets } from '@/hooks/use-assets'
import { useWallet } from '../../../providers/wallet-context'
import { Token } from './-components/token'
export const Route = createFileRoute('/portfolio/assets/$ticker')({
component: Component,
})
function Component() {
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const params = Route.useParams()
const ticker = params.ticker
const router = useRouter()
const routerState = useRouterState()
const pathname = routerState.location.pathname
const address = currentWallet?.activeAccounts[0].address
const { data: assets, isLoading } = useAssets({
address,
isWalletLoading,
})
if (!currentWallet || !address) {
return <div>No wallet selected</div>
}
return (
<>
<div className="hidden 2xl:block">
<SplittedLayout
list={
assets ? (
<AssetsList
assets={assets}
onSelect={url => {
const ticker = url.split('/').pop()
if (!ticker) return
router.navigate({
to: '/portfolio/assets/$ticker',
params: { ticker },
})
}}
clearSearch={() => {
console.log('Search cleared')
}}
searchParams={new URLSearchParams()}
pathname={pathname}
/>
) : (
<div className="mt-4 flex flex-col gap-3">Empty state</div>
)
}
detail={
<Suspense fallback={<p>Loading token...</p>}>
<Token ticker={ticker} />
</Suspense>
}
isLoading={isLoading}
/>
</div>
<div className="block 2xl:hidden">
<Token ticker={ticker} />
</div>
</>
)
}
@@ -0,0 +1,304 @@
import { useEffect, useState } from 'react'
import { Button, Tooltip } from '@status-im/components'
import { BuyIcon, ReceiveBlurIcon } from '@status-im/icons/20'
import {
Balance,
CurrencyAmount,
NetworkBreakdown,
StickyHeaderContainer,
TokenAmount,
TokenLogo,
} from '@status-im/wallet/components'
import { useQuery } from '@tanstack/react-query'
import { cx } from 'class-variance-authority'
import { renderMarkdown } from '@/lib/markdown'
import type { ApiOutput, NetworkType } from '@status-im/wallet/data'
type Props = {
ticker: string
}
const Token = (props: Props) => {
const { ticker } = props
const [markdownContent, setMarkdownContent] = useState<React.ReactNode>(null)
const token = useQuery<
ApiOutput['assets']['token'] | ApiOutput['assets']['nativeToken']
>({
queryKey: ['token', ticker],
queryFn: async () => {
const endpoint = ticker.startsWith('0x')
? 'assets.token'
: 'assets.nativeToken'
const url = new URL(`http://localhost:3030/api/trpc/${endpoint}`)
url.searchParams.set(
'input',
JSON.stringify({
json: {
address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
networks: [
'ethereum',
'optimism',
'arbitrum',
'base',
'polygon',
'bsc',
] as NetworkType[],
...(ticker.startsWith('0x')
? { contract: ticker }
: { symbol: ticker }),
},
}),
)
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
},
staleTime: 60 * 60 * 1000, // 1 hour
gcTime: 60 * 60 * 1000, // 1 hour
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const { data: typedToken, isLoading } = token
useEffect(() => {
const processMarkdown = async () => {
if (typedToken) {
const metadata = Object.values(typedToken.assets)[0].metadata
const content = await renderMarkdown(
metadata.about || 'No description available.',
)
setMarkdownContent(content)
}
}
processMarkdown()
}, [typedToken])
if (isLoading || !typedToken) {
return <p>Loading</p>
}
const metadata = Object.values(typedToken.assets)[0].metadata
const uppercasedTicker = typedToken.summary.symbol
const icon = typedToken.summary.icon
return (
<StickyHeaderContainer
className="-translate-x-0 !py-3 !pl-3 pr-[50px] 2xl:w-auto 2xl:!px-12 2xl:!py-4"
leftSlot={
<TokenLogo
variant="small"
name={typedToken.summary.name}
ticker={uppercasedTicker}
icon={icon}
/>
}
rightSlot={
<div className="flex items-center gap-1 pt-px">
<Button size="32" iconBefore={<BuyIcon />}>
<span className="block max-w-20 truncate">
Buy {typedToken.summary.name}
</span>
</Button>
<Button size="32" iconBefore={<ReceiveBlurIcon />}>
Receive
</Button>
</div>
}
>
<div className="-mt-8 grid gap-10 p-4 pt-0 2xl:mt-0 2xl:p-12 2xl:pt-0">
<div>
<TokenLogo
name={typedToken.summary.name}
ticker={uppercasedTicker}
icon={icon}
/>
<div className="my-6 2xl:mt-0">
<Balance variant="token" summary={typedToken.summary} />
</div>
<div className="flex items-center gap-1">
<Button size="32" iconBefore={<BuyIcon />} variant="primary">
Buy {typedToken.summary.name}
</Button>
<Button
size="32"
variant="outline"
iconBefore={<ReceiveBlurIcon />}
>
Receive
</Button>
</div>
</div>
{typedToken.summary.total_balance > 0 && (
<NetworkBreakdown token={typedToken} />
)}
{/* <ErrorBoundary fallback={<div>Error loading chart</div>}>
<Suspense
key={keyHash}
fallback={
<div className="mt-8">
<Loading />
</div>
}
>
<AssetChart
address={address}
slug={slug}
symbol={token.summary.symbol}
/>
</Suspense>
</ErrorBoundary> */}
<div>
<div className="grid grid-cols-2 2xl:grid-cols-4">
{[
{
label: 'Market Cap',
value: (
<CurrencyAmount
value={metadata.market_cap}
format="compact"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.market_cap}
format="standard"
/>
),
},
{
label: 'Fully diluted',
value: (
<CurrencyAmount
value={metadata.fully_dilluted}
format="compact"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.fully_dilluted}
format="standard"
/>
),
},
{
label: 'Circulation',
value: (
<TokenAmount value={metadata.circulation} format="compact" />
),
tooltip: (
<TokenAmount value={metadata.circulation} format="standard" />
),
},
{
label: 'Total supply',
value: (
<TokenAmount value={metadata.total_supply} format="compact" />
),
tooltip: (
<TokenAmount
value={metadata.total_supply}
format="standard"
/>
),
},
{
label: 'All time high',
value: (
<CurrencyAmount
value={metadata.all_time_high}
format="standard"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.all_time_high}
format="standard"
/>
),
},
{
label: 'All time low',
value: (
<CurrencyAmount
value={metadata.all_time_low}
format="standard"
/>
),
tooltip: (
<CurrencyAmount
value={metadata.all_time_low}
format="standard"
/>
),
},
{
label: '24h Volume',
value: (
<TokenAmount value={metadata.volume_24} format="compact" />
),
tooltip: (
<TokenAmount value={metadata.volume_24} format="standard" />
),
},
{
label: 'Rank by Mcap',
value: metadata?.rank_by_market_cap,
},
].map((item, index) => (
<div
key={index}
className={cx(
'border-dashed border-neutral-10 py-4',
index % 2 !== 0 && 'pl-4',
index % 2 !== 1 && 'border-r pr-4',
index % 4 !== 3 && '2xl:border-r 2xl:pr-4',
index < 6 && 'border-b',
index < 4 && '2xl:border-b',
)}
>
<Tooltip content={item.tooltip} side="top">
<div>
<div className="text-13 font-regular text-neutral-50">
{item.label}
</div>
<div className="text-13 font-medium text-neutral-100">
{item.value}
</div>
</div>
</Tooltip>
</div>
))}
</div>
<div className="mt-5 flex-col gap-2">
<div className="text-neutral-100">{markdownContent}</div>
</div>
</div>
</div>
</StickyHeaderContainer>
)
}
export { Token }
@@ -1,89 +1,29 @@
// import { Suspense } from 'react'
import { AssetsList, PinExtension } from '@status-im/wallet/components'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useAssets } from '@/hooks/use-assets'
import { usePinExtension } from '@/hooks/use-pin-extension'
import { useWallet } from '../../../providers/wallet-context'
export const Route = createFileRoute('/portfolio/assets/')({
component: RouteComponent,
head: () => ({
meta: [
{
title: 'Extension | Wallet | Portfolio',
},
],
}),
component: Component,
})
function RouteComponent() {
function Component() {
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const { isPinExtension, handleClose } = usePinExtension()
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)
}
const address = currentWallet?.activeAccounts[0].address
// todo: export trpc client with api router and used instead
// todo: cache
const { data: assets, isLoading } = useQuery({
queryKey: ['assets', currentWallet?.activeAccounts[0].address],
queryFn: async () => {
if (!currentWallet?.activeAccounts[0].address) {
throw new Error('No wallet address available')
}
const url = new URL(
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/assets.all`,
)
url.searchParams.set(
'input',
// encodeURIComponent(
JSON.stringify({
json: {
address: currentWallet.activeAccounts[0].address,
networks: [
'ethereum',
'optimism',
'arbitrum',
'base',
'polygon',
'bsc',
],
},
}),
// ),
)
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error('Failed to fetch assets.')
}
const body = await response.json()
return body.result.data.json.assets
},
enabled: !!currentWallet?.activeAccounts[0].address && !isWalletLoading,
staleTime: 60 * 60 * 1000, // 1 hour
gcTime: 60 * 60 * 1000, // 1 hour
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
const router = useRouter()
const { data: assets, isLoading } = useAssets({
address,
isWalletLoading,
})
if (!currentWallet) {
if (!currentWallet || !address) {
return <div>No wallet selected</div>
}
@@ -94,18 +34,24 @@ function RouteComponent() {
assets ? (
<AssetsList
assets={assets}
onSelect={handleSelect}
onSelect={url => {
const ticker = url.split('/').pop()
if (!ticker) return
router.navigate({
to: '/portfolio/assets/$ticker',
params: { ticker },
})
}}
clearSearch={() => {
console.log('Search cleared')
}}
searchParams={new URLSearchParams()}
pathname="/portfolio/"
pathname="/portfolio/assets"
/>
) : (
<div className="mt-4 flex flex-col gap-3">Empty state</div>
)
}
detail={<>Detail for asset</>}
isLoading={isLoading}
/>
{isPinExtension && (
@@ -0,0 +1,101 @@
import { Suspense } from 'react'
import { CollectiblesGrid as CollectiblesList } from '@status-im/wallet/components'
import {
createFileRoute,
useRouter,
useRouterState,
} from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useWallet } from '../../../../../providers/wallet-context'
import { Collectible } from '../../-components/collectible'
import { LinkCollectible } from '../../-components/link-collectibe'
import type { NetworkType } from '@status-im/wallet/data'
export const Route = createFileRoute(
'/portfolio/collectibles/$network/$contract/$id',
)({
component: Component,
})
function Component() {
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const router = useRouter()
const routerState = useRouterState()
const params = Route.useParams()
const { network, contract, id } = params
const searchParams = new URLSearchParams(window.location.search)
const search = searchParams.get('search') ?? undefined
const pathname = routerState.location.pathname
const address = currentWallet?.activeAccounts[0].address
const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
useCollectibles({
address,
isWalletLoading,
})
const collectibles = useMemo(() => {
return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
}, [data?.pages])
if (!currentWallet || !address) {
return <div>No wallet selected</div>
}
return (
<>
<div className="hidden 2xl:block">
<SplittedLayout
list={
<CollectiblesList
LinkComponent={LinkCollectible}
address={address}
collectibles={collectibles}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
pathname={pathname}
search={search}
searchParams={searchParams}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
hasNextPage={hasNextPage}
onSelect={url => {
const [network, contract, id] = url.split('/').slice(-3)
router.navigate({
to: '/portfolio/collectibles/$network/$contract/$id',
params: { network, contract, id },
})
}}
/>
}
isLoading={isLoading}
detail={
<Suspense fallback={<p>Loading collectible...</p>}>
<Collectible
network={network as NetworkType}
contract={contract}
id={id}
/>
</Suspense>
}
/>
</div>
<div className="block 2xl:hidden">
<Collectible
network={network as NetworkType}
contract={contract}
id={id}
/>
</div>
</>
)
}
@@ -0,0 +1,185 @@
import { Button } from '@status-im/components'
import { ExternalIcon, OptionsIcon, SadIcon } from '@status-im/icons/20'
import { OpenseaIcon } from '@status-im/icons/social'
import { CurrencyAmount, NetworkLogo } from '@status-im/wallet/components'
import { useQuery } from '@tanstack/react-query'
import type { NetworkType } from '@status-im/wallet/data'
type Props = {
network: NetworkType
contract: string
id: string
}
const Collectible = (props: Props) => {
const { network, contract, id } = props
const { data: collectible, isLoading } = useQuery({
queryKey: ['collectible', network, contract, id],
queryFn: async () => {
const url = new URL(
'http://localhost:3030/api/trpc/collectibles.collectible',
)
url.searchParams.set(
'input',
JSON.stringify({
json: {
contract,
tokenId: id,
network,
},
}),
)
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
},
})
if (isLoading || !collectible) {
return <p>Loading</p>
}
const imageUrl = collectible.image || collectible.thumbnail
const imageAlt = collectible.name || 'Collectible image'
return (
<div className="overflow-auto p-4 pr-3 2xl:p-12">
<div className="mb-10 flex gap-4">
<div className="flex-1">
<div className="2xl:mb-6">
<div className="mb-2 flex items-center gap-1.5">
<div className="text-15 font-semibold text-neutral-100">
{collectible.collection.name}
</div>
</div>
<div className="mb-1 mt-6 2xl:mt-0">
<div className="text-27 font-semibold text-neutral-100">
{collectible.name}
</div>
</div>
{collectible.floor_price && collectible.price_eur && (
<div className="mb-6 flex items-center gap-1.5">
<div className="flex items-center gap-1">
<div className="text-13 font-medium text-neutral-50">
{collectible.floor_price} {collectible.currency}
</div>
<div
className="size-0.5 rounded-full bg-neutral-40"
aria-hidden
/>
<div className="text-13 font-medium text-neutral-50">
<CurrencyAmount
value={collectible.price_eur}
format="standard"
/>
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Button
size="32"
variant="outline"
iconBefore={<OpenseaIcon className="text-social-opensea" />}
iconAfter={<ExternalIcon />}
href={collectible.links.opensea}
>
View on OpenSea
</Button>
<Button
size="32"
variant="outline"
icon={<OptionsIcon />}
aria-label="More options"
/>
</div>
</div>
</div>
{imageUrl ? (
<div className="aspect-square size-[140px] rounded-16">
<img
src={imageUrl}
alt={imageAlt}
className="size-full rounded-16 object-cover"
/>
</div>
) : (
<div className="flex aspect-square size-[140px] flex-col items-center justify-center gap-1 rounded-16 border border-dashed border-neutral-20 bg-neutral-2.5 p-1 text-13 font-semibold text-neutral-40">
<SadIcon />
No image available
</div>
)}
</div>
<div className="grid gap-8">
<div>
<div className="mb-1 text-15 font-semibold text-neutral-100">
About
</div>
<div className="mb-5">{collectible.about}</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(155px,1fr))] gap-3">
<div className="flex items-center gap-1">
<NetworkLogo name={collectible.network} size={16} />
<span className="capitalize">{collectible.network}</span>
</div>
{collectible.standard !== 'NOT_A_CONTRACT' && (
<>
<div className="font-mono">{collectible.contract}</div>
<div>{collectible.standard}</div>
</>
)}
{collectible.collection.size && (
<div>{collectible.collection.size}</div>
)}
</div>
</div>
<div
className="h-px w-full border-t border-dashed border-neutral-20"
aria-hidden="true"
/>
{collectible.traits && (
<div>
<div className="mb-3 text-15 font-semibold text-neutral-100">
Traits
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(155px,1fr))] gap-3">
{Object.entries(collectible.traits as Record<string, string>).map(
([trait, value], index) => (
<div key={index}>
<div className="text-13 font-medium text-neutral-50">
{trait}
</div>
<div className="text-13 font-medium text-neutral-100">
{value}
</div>
</div>
),
)}
</div>
</div>
)}
</div>
</div>
)
}
export { Collectible }
@@ -0,0 +1,31 @@
import { Link as LinkBase, useRouter } from '@tanstack/react-router'
type LinkProps = {
href: string
className?: string
children: React.ReactNode
}
const LinkCollectible = (props: LinkProps) => {
const { href, className, children } = props
const router = useRouter()
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
const [network, contract, id] = href.split('/').slice(-3)
router.navigate({
to: '/portfolio/collectibles/$network/$contract/$id',
params: { network, contract, id },
})
}
return (
<LinkBase to={href} className={className} onClick={handleClick}>
{children}
</LinkBase>
)
}
export { LinkCollectible }
@@ -1,49 +1,18 @@
import { CollectiblesGrid as CollectiblesList } from '@status-im/wallet/components'
import { useInfiniteQuery } from '@tanstack/react-query'
import { createFileRoute, Link as LinkBase } from '@tanstack/react-router'
import {
CollectiblesGrid as CollectiblesList,
PinExtension,
} from '@status-im/wallet/components'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useCollectibles } from '@/hooks/use-collectibles'
import { usePinExtension } from '@/hooks/use-pin-extension'
import { LinkCollectible } from '@/routes/portfolio/collectibles/-components/link-collectibe'
import { useWallet } from '../../../providers/wallet-context'
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
type LinkProps = {
href: string
className?: string
children: React.ReactNode
}
const Link = (props: LinkProps) => {
const { href, className, children } = props
return (
<LinkBase to={href} className={className}>
{children}
</LinkBase>
)
}
export const Route = createFileRoute('/portfolio/collectibles/')({
component: RouteComponent,
component: Component,
head: () => ({
meta: [
{
@@ -53,140 +22,66 @@ export const Route = createFileRoute('/portfolio/collectibles/')({
}),
})
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() {
function Component() {
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const { isPinExtension, handleClose } = usePinExtension()
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)
}
const router = useRouter()
// 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 pathname = window.location.pathname
const address = currentWallet?.activeAccounts[0].address
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 }) => {
if (!address) {
throw new Error('No wallet address available')
}
const collectibles = await getCollectibles(
address,
networks as NetworkType[],
search,
sort,
)
return {
collectibles,
nextPage: pageParam + 1,
}
},
getNextPageParam: lastPage => lastPage.nextPage,
initialPageParam: 0,
enabled: !!address && !isWalletLoading,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
const { data, fetchNextPage, isFetchingNextPage, isLoading, hasNextPage } =
useCollectibles({
address,
isWalletLoading,
})
const collectibles = useMemo(() => {
return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
}, [data?.pages])
if (!currentWallet) {
if (!currentWallet || !address) {
return <div>No wallet selected</div>
}
return (
<SplittedLayout
list={
<CollectiblesList
LinkComponent={Link}
address={address!}
collectibles={collectibles}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
pathname={pathname}
search={search}
searchParams={searchParams}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
hasNextPage={hasNextPage}
onSelect={handleSelect}
/>
}
detail={<>Detail</>}
isLoading={isLoading}
/>
<>
<SplittedLayout
list={
<CollectiblesList
LinkComponent={LinkCollectible}
address={address}
collectibles={collectibles}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
pathname={pathname}
search={search}
searchParams={searchParams}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
hasNextPage={hasNextPage}
onSelect={url => {
const [network, contract, id] = url.split('/').slice(-3)
router.navigate({
to: '/portfolio/collectibles/$network/$contract/$id',
params: { network, contract, id },
})
}}
/>
}
isLoading={isLoading}
/>
{isPinExtension && (
<div className="absolute right-5 top-20">
<PinExtension onClose={handleClose} />
</div>
)}
</>
)
}
+1
View File
@@ -12,6 +12,7 @@
"source": "./src/index.tsx",
"exports": {
"./tailwind.config": {
"types": "./dist/tailwind.config.d.ts",
"import": "./dist/tailwind.config.es.js",
"require": "./dist/tailwind.config.cjs.js"
},
@@ -150,7 +150,7 @@ const CollectiblesGrid = (props: Props) => {
target="_blank"
size="32"
rel="noopener noreferrer"
onClick={() => clearSearch()}
onClick={clearSearch}
>
Clear search
</Button>
+3
View File
@@ -20,6 +20,7 @@ export {
} from './import-recovery-phrase-form'
export { Logo, type LogoProps } from './logo'
export { Navbar } from './nav-bar'
export { NetworkBreakdown } from './network-breakdown'
export { NetworkExplorerLogo } from './network-explorer-logo'
export { NetworkLogo } from './network-logo'
export { PercentageChange } from './percentage-change'
@@ -34,4 +35,6 @@ export {
export { Slider, type SliderProps } from './slider'
export { StickyHeaderContainer } from './sticky-header-container'
export { getTabLinkClassName, TabLink } from './tab-link'
export { TokenAmount } from './token-amount'
export { TokenLogo } from './token-logo'
export { Tooltip } from './tooltip'
@@ -1,6 +1,7 @@
'use client'
import { CurrencyAmount, NetworkLogo } from '@status-im/wallet/components'
import { CurrencyAmount } from '../currency-amount'
import { NetworkLogo } from '../network-logo'
import type { ApiOutput, NetworkType } from '@status-im/wallet/data'
@@ -110,7 +110,7 @@ const StickyHeaderContainer = (props: Props) => {
return (
<div
ref={containerRef}
className="relative h-[calc(100vh-56px)] overflow-auto scrollbar-stable"
className="relative h-[calc(100vh-56px)] w-full overflow-auto scrollbar-stable"
>
<div
className={cx(
@@ -1,6 +1,3 @@
// We need to add this eslint-disable rule since we can't use next/image in this use case because we don't know the source of the image. Therefore we use the native <img> tag.
/* eslint-disable @next/next/no-img-element */
import { cx } from 'class-variance-authority'
type Props = {
+61 -4
View File
@@ -924,6 +924,18 @@ importers:
react-hook-form:
specifier: ^7.54.2
version: 7.56.3(react@19.1.0)
rehype-react:
specifier: ^8.0.0
version: 8.0.0
rehype-stringify:
specifier: ^10.0.0
version: 10.0.1
remark-parse:
specifier: ^11.0.0
version: 11.0.0
remark-rehype:
specifier: ^11.0.0
version: 11.1.2
superjson:
specifier: ^2.2.1
version: 2.2.2
@@ -933,6 +945,9 @@ importers:
ts-pattern:
specifier: ^5.7.1
version: 5.7.1
unified:
specifier: ^11.0.5
version: 11.0.5
vite-plugin-node-polyfills:
specifier: ^0.23.0
version: 0.23.0(rollup@4.40.2)(vite@6.3.5(@types/node@22.7.5)(jiti@2.4.2)(less@4.2.0)(lightningcss@1.27.0)(sass@1.80.4)(tsx@4.19.4)(yaml@2.5.1))
@@ -11823,6 +11838,9 @@ packages:
hast-util-to-html@8.0.4:
resolution: {integrity: sha512-4tpQTUOr9BMjtYyNlt0P50mH7xj0Ks2xpo8M943Vykljf99HW6EzulIoJP1N3eKOSScEHzyzi9dm7/cn0RfGwA==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@@ -11893,6 +11911,9 @@ packages:
html-void-elements@2.0.1:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html-whitespace-sensitive-tag-names@2.0.0:
resolution: {integrity: sha512-SQdIvTTtnHAx72xGUIUudvVOCjeWvV1U7rvSFnNGxTGRw3ZC7RES4Gw6dm1nMYD60TXvm6zjk/bWqgNc5pjQaw==}
@@ -15479,6 +15500,9 @@ packages:
peerDependencies:
'@types/react': 19.1.0
rehype-react@8.0.0:
resolution: {integrity: sha512-vzo0YxYbB2HE+36+9HWXVdxNoNDubx63r5LBzpxBGVWM8s9mdnMdbmuJBAX6TTyuGdZjZix6qU3GcSuKCIWivw==}
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
@@ -15488,6 +15512,9 @@ packages:
rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
rehype-stringify@10.0.1:
resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
rehype-stringify@9.0.4:
resolution: {integrity: sha512-Uk5xu1YKdqobe5XpSskwPvo1XeHUUucWEQSl8hTrXt5selvca1e8K1EZ37E6YoZ4BT8BCqCdVfQW7OfHfthtVQ==}
@@ -21117,7 +21144,7 @@ snapshots:
bufferutil: 4.0.9
cross-fetch: 4.1.0
date-fns: 2.30.0
debug: 4.3.7
debug: 4.4.0(supports-color@5.5.0)
eciesjs: 0.4.14
eventemitter2: 6.4.9
readable-stream: 3.6.2
@@ -32515,6 +32542,20 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.7
@@ -32609,6 +32650,8 @@ snapshots:
html-void-elements@2.0.1: {}
html-void-elements@3.0.0: {}
html-whitespace-sensitive-tag-names@2.0.0: {}
htmlnano@2.1.1(postcss@8.4.47)(svgo@2.8.0)(typescript@5.2.2):
@@ -34965,7 +35008,7 @@ snapshots:
micromark@2.11.4:
dependencies:
debug: 4.3.7
debug: 4.4.0(supports-color@5.5.0)
parse-entities: 2.0.0
transitivePeerDependencies:
- supports-color
@@ -34995,7 +35038,7 @@ snapshots:
micromark@4.0.0:
dependencies:
'@types/debug': 4.1.12
debug: 4.3.7
debug: 4.4.0(supports-color@5.5.0)
decode-named-character-reference: 1.0.2
devlop: 1.1.0
micromark-core-commonmark: 2.0.1
@@ -37135,6 +37178,14 @@ snapshots:
hast-util-whitespace: 2.0.1
unified: 10.1.2
rehype-react@8.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-jsx-runtime: 2.3.6
unified: 11.0.5
transitivePeerDependencies:
- supports-color
rehype-recma@1.0.0:
dependencies:
'@types/estree': 1.0.7
@@ -37161,6 +37212,12 @@ snapshots:
hast-util-to-string: 3.0.0
unist-util-visit: 5.0.0
rehype-stringify@10.0.1:
dependencies:
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
unified: 11.0.5
rehype-stringify@9.0.4:
dependencies:
'@types/hast': 2.3.10
@@ -39361,7 +39418,7 @@ snapshots:
bundle-require: 4.2.1(esbuild@0.18.20)
cac: 6.7.14
chokidar: 3.6.0
debug: 4.3.7
debug: 4.4.0(supports-color@5.5.0)
esbuild: 0.18.20
execa: 5.1.1
globby: 11.1.0