mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
wallet@0.0.1-beta (#746)
Co-authored-by: Jakub <520927+jkbktl@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jinhojang6 <jinho5051@gmail.com> Co-authored-by: marcelines <marcio@status.im>
This commit is contained in:
co-authored by
Jakub
Copilot
jinhojang6
marcelines
parent
30be360b4f
commit
8467e461f1
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
Add value chart
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"wallet": patch
|
||||
---
|
||||
|
||||
Add CTA to main view
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@status-im/wallet': patch
|
||||
'wallet': patch
|
||||
---
|
||||
|
||||
integrate pending wallet activity
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"wallet": patch
|
||||
---
|
||||
|
||||
Persistent recovery phrase
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@status-im/wallet": patch
|
||||
"wallet": patch
|
||||
---
|
||||
|
||||
Rename tabs
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@status-im/components': patch
|
||||
---
|
||||
|
||||
Adds close toast action and multiple toasts
|
||||
Vendored
+10
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"npm.packageManager": "pnpm",
|
||||
"eslint.useESLintClass": true,
|
||||
"editor.formatOnSave": true,
|
||||
@@ -33,5 +36,11 @@
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
]
|
||||
],
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/bower_components": true,
|
||||
"**/*.code-search": true,
|
||||
"**/.output": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,23 @@ Packages for building user interfaces, websites, web applications, dapps, browse
|
||||
|
||||
## Packages
|
||||
|
||||
| Name | `npm` | Description |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`@status-im/colors`](./packages/colors) | [](https://www.npmjs.com/package/@status-im/colors) | Auto-generated color palette based on our [design system](https://www.figma.com/design/v98g9ZiaSHYUdKWrbFg9eM/Foundations?node-id=619-5995&node-type=canvas&m=dev). |
|
||||
| [`@status-im/icons`](./packages/icons) | [](https://www.npmjs.com/package/@status-im/icons) | Auto-generated icon library based on our [design system](https://www.figma.com/design/qLLuMLfpGxK9OfpIavwsmK/Iconset?node-id=3239-987&node-type=frame&t=0h8iIiZ3Sf0g4MRV-11). |
|
||||
| [`@status-im/components`](./packages/components) | [](https://www.npmjs.com/package/@status-im/components) | Component library built with Radix UI, React Aria, Tailwind CSS. |
|
||||
| [`@status-im/js`](./packages/status-js) | [](https://www.npmjs.com/package/@status-im/js) | |
|
||||
| [`@status-im/wallet`](./packages/wallet) | | |
|
||||
| [`@status-im/eslint-config`](./packages/eslint-config) | [](https://www.npmjs.com/package/@status-im/eslint-config) | Shared ESLint configuration for consistent code style across projects. |
|
||||
| Name | Deployments | Builds | Description |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [`@status-im/colors`](./packages/colors) | [](https://status-components.vercel.app/?path=/story/colors) | [](https://www.npmjs.com/package/@status-im/colors) | Auto-generated color palette based on our [design system](https://www.figma.com/design/v98g9ZiaSHYUdKWrbFg9eM/Foundations?node-id=619-5995&node-type=canvas&m=dev). |
|
||||
| [`@status-im/icons`](./packages/icons) | [](https://status-components.vercel.app/?path=/story/icons) | [](https://www.npmjs.com/package/@status-im/icons) | Auto-generated icon library based on our [design system](https://www.figma.com/design/qLLuMLfpGxK9OfpIavwsmK/Iconset?node-id=3239-987&node-type=frame&t=0h8iIiZ3Sf0g4MRV-11). |
|
||||
| [`@status-im/components`](./packages/components) | [](https://status-components.vercel.app/?path=/story/components) | [](https://www.npmjs.com/package/@status-im/components) | Component library built with Radix UI, React Aria, Tailwind CSS. |
|
||||
| [`@status-im/js`](./packages/status-js) | | [](https://www.npmjs.com/package/@status-im/js) | |
|
||||
| [`@status-im/wallet`](./packages/wallet) | | |
|
||||
| [`@status-im/eslint-config`](./packages/eslint-config) | | [](https://www.npmjs.com/package/@status-im/eslint-config) | Shared ESLint configuration for consistent code style across projects. |
|
||||
|
||||
## Apps
|
||||
|
||||
| Name | Description |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [`./apps/connector`](./apps/connector) | Status Desktop Wallet extended to decentralised applications in your browser. |
|
||||
| [`./apps/portfolio`](./apps/portfolio) | |
|
||||
| [`./apps/wallet`](./apps/wallet) | |
|
||||
| [`./apps/api`](./apps/api) | |
|
||||
| Name | Deployments | Builds | Description |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| [`./apps/connector`](./apps/connector) | [](https://chromewebstore.google.com/detail/kahehnbpamjplefhpkhafinaodkkenpg) | [](https://ci.status.im/job/status-web/job/main/job/connector/) | Status Desktop Wallet extended to decentralised applications in your browser. |
|
||||
| [`./apps/portfolio`](./apps/portfolio) | | | |
|
||||
| [`./apps/wallet`](./apps/wallet) | [](https://chromewebstore.google.com/detail/opkfeajbclhjdneghppfnfiannideafj) | [](https://ci.status.im/job/status-web/job/main/job/wallet/) | Easily view and manage your crypto portfolio in real time — Beta crypto wallet and Web3 portfolio tracker in one. |
|
||||
| [`./apps/api`](./apps/api) | | | |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -31,11 +31,6 @@ Required:
|
||||
- **[Node.js](https://nodejs.org/)** v20.x
|
||||
- **[pnpm](https://pnpm.io)** v9.12.x
|
||||
|
||||
Recommended:
|
||||
|
||||
- **[Visual Studio Code](https://code.visualstudio.com/)**
|
||||
- install extensions listed in `.vscode/extensions.json` for optimal development experience
|
||||
|
||||
## Stack
|
||||
|
||||
- **Turborepo**: Manages our monorepo and speeds up builds
|
||||
@@ -91,10 +86,6 @@ pnpm storybook
|
||||
|
||||
This will start the Storybook server, allowing you to browse and test components in isolation.
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The builds of `main` branch are available in [Jenkins CI](https://ci.status.im/job/status-web/job/main/).
|
||||
|
||||
## Sponsors
|
||||
|
||||
This project is sponsored by Browserstack.
|
||||
|
||||
@@ -18,7 +18,10 @@ export const runtime = 'edge'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function handler(request: NextRequest) {
|
||||
return fetchRequestHandler({
|
||||
// let error: Error | undefined
|
||||
|
||||
// const response = await fetchRequestHandler({
|
||||
return await fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
router: apiRouter,
|
||||
req: request,
|
||||
@@ -28,10 +31,17 @@ async function handler(request: NextRequest) {
|
||||
|
||||
return { headers }
|
||||
},
|
||||
/**
|
||||
* @see https://trpc.io/docs/v10/server/error-handling#handling-errors
|
||||
*/
|
||||
// onError: opts => {
|
||||
// // console.error('opts::', opts)
|
||||
// error = opts.error.cause
|
||||
// },
|
||||
responseMeta: opts => {
|
||||
// note: opts.error does not have original cause (status code), contrary to onError
|
||||
// note!: status code is inferred from TRPCError.code (TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, etc.)
|
||||
// const error = opts.errors?.[0]
|
||||
|
||||
let cacheControl = 'public, max-age=3600'
|
||||
|
||||
if (
|
||||
@@ -40,6 +50,7 @@ async function handler(request: NextRequest) {
|
||||
'nodes.broadcastTransaction',
|
||||
'nodes.getNonce',
|
||||
'nodes.getTransactionCount',
|
||||
'nodes.getFeeRate',
|
||||
'activities.page',
|
||||
'activities.activities',
|
||||
'assets.all',
|
||||
@@ -54,6 +65,7 @@ async function handler(request: NextRequest) {
|
||||
}
|
||||
|
||||
return {
|
||||
// status: 429,
|
||||
headers: {
|
||||
'cache-control': cacheControl,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
@@ -62,18 +74,16 @@ async function handler(request: NextRequest) {
|
||||
},
|
||||
}
|
||||
},
|
||||
// unstable_onChunk: undefined,
|
||||
})
|
||||
|
||||
// const result = await response.json()
|
||||
|
||||
// return Response.json(
|
||||
// result
|
||||
// // { status: result.httpStatus }
|
||||
// // { status: 429 }
|
||||
// )
|
||||
}
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
// @see https://github.com/orgs/pnpm/discussions/4331#discussioncomment-3379386 for pnpm's config recommendation
|
||||
@@ -45,5 +45,10 @@
|
||||
".next/types/**/*.ts",
|
||||
"global.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
"exclude": ["node_modules", ".next"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/wallet"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"ignoreCommand": "git diff --quiet HEAD^ HEAD ../../{patches,package.json,turbo.json} ../../packages/wallet ./",
|
||||
"installCommand": "pnpm install --dir ../../ --frozen-lockfile",
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=api..."
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=./apps/api..."
|
||||
}
|
||||
|
||||
@@ -12,4 +12,9 @@
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/colors",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -111,9 +111,7 @@ async function Token({
|
||||
<div className="flex items-center gap-1 pt-px">
|
||||
<BuyCryptoDrawer>
|
||||
<Button size="32" iconBefore={<BuyIcon />}>
|
||||
<span className="block max-w-20 truncate">
|
||||
Buy {token.summary.name}
|
||||
</span>
|
||||
<span className="block max-w-20 truncate">Buy</span>
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
<ReceiveCryptoDrawer>
|
||||
@@ -138,7 +136,7 @@ async function Token({
|
||||
<div className="flex items-center gap-1">
|
||||
<BuyCryptoDrawer>
|
||||
<Button size="32" iconBefore={<BuyIcon />}>
|
||||
Buy {token.summary.name}
|
||||
Buy
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
<ReceiveCryptoDrawer>
|
||||
|
||||
@@ -58,7 +58,7 @@ const Chart = ({
|
||||
const [activeTimeFrame, setActiveTimeFrame] = useState<TimeFrame>('24H')
|
||||
|
||||
// Todo: Currency should be dynamic
|
||||
const currency = 'EUR'
|
||||
const currency = 'USD'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -48,7 +48,6 @@ const CollectiblesGrid = ({
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
refetchOnWindowFocus: false,
|
||||
queryKey: ['collectibles', address, networks, search, sort],
|
||||
queryFn: async ({ pageParam = { offset: 0 } }) => {
|
||||
const response = await getCollectibles({
|
||||
|
||||
@@ -25,7 +25,7 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
name: provider,
|
||||
network,
|
||||
address: account.address,
|
||||
asset: asset || 'EUR',
|
||||
asset: asset || 'USD',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const NETWORKS = [
|
||||
},
|
||||
] as const
|
||||
|
||||
// const CURRENCY = 'EUR'
|
||||
// const CURRENCY = 'USD'
|
||||
|
||||
// const formatCurrency = (amount: number, currency?: string) => {
|
||||
// return Intl.NumberFormat('en-US', {
|
||||
|
||||
@@ -55,7 +55,6 @@ export const AccountsProvider = ({ children }: { children: ReactNode }) => {
|
||||
queryFn: () => getAccountsData(addresses, networks),
|
||||
enabled: addresses.length > 0,
|
||||
staleTime: 60000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const addAccount = (account: Account) => {
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
// @see https://github.com/orgs/pnpm/discussions/4331#discussioncomment-3379386 for pnpm's config recommendation
|
||||
@@ -48,8 +48,17 @@
|
||||
"exclude": ["node_modules", ".next"],
|
||||
// @see https://github.com/nrwl/nx/issues/3106#issuecomment-703154400
|
||||
"references": [
|
||||
// {
|
||||
// "path": "../../packages/wallet"
|
||||
// }
|
||||
{
|
||||
"path": "../../packages/wallet"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/colors"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/components"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/icons"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"ignoreCommand": "git diff --quiet HEAD^ HEAD ../../{patches,package.json,turbo.json} ../../packages/{colors,icons,components,wallet} ./",
|
||||
"installCommand": "pnpm install --dir ../../ --frozen-lockfile",
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=portfolio..."
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=./apps/portfolio..."
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ Google Chrome > Window > Extensions > Load unpacked > select build (.output/chro
|
||||
|
||||
#### Visit
|
||||
|
||||
Google Chrome > Extensions > Status Portfolio Wallet > Inspect views > service worker
|
||||
Google Chrome > Extensions > !Status Portfolio Wallet (Beta) > Inspect views > service worker
|
||||
|
||||
Google Chrome > Toolbar > Extensions > Status Portfolio Wallet
|
||||
Google Chrome > Toolbar > Extensions > !Status Portfolio Wallet (Beta)
|
||||
|
||||
Google Chrome > Toolbar > Extensions > Status Portfolio Wallet > Open side panel
|
||||
Google Chrome > Toolbar > Extensions > !Status Portfolio Wallet (Beta) > Open side panel
|
||||
|
||||
chrome-extension://\[ID]/page.html#/portfolio
|
||||
|
||||
|
||||
Vendored
+6
-6
@@ -1,10 +1,10 @@
|
||||
// /// <reference types="vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// // import 'vite/client'
|
||||
interface ImportMetaEnv {
|
||||
readonly WXT_STATUS_API_URL: string
|
||||
readonly WXT_GETBLOCK_API_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: {
|
||||
readonly WXT_STATUS_API_URL: string
|
||||
readonly WXT_GETBLOCK_API_KEY: string
|
||||
}
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
"dev": "pnpm dev:chrome",
|
||||
"dev:chrome": "wxt -b chrome -p 4000 --mode development",
|
||||
"dev:firefox": "wxt -b firefox -p 4000 --mode development",
|
||||
"build": "pnpm build:chrome",
|
||||
"build": "pnpm build:chrome && pnpm build:types",
|
||||
"build:chrome": "wxt build -b chrome --mode production",
|
||||
"build:firefox": "wxt build -b firefox --mode production",
|
||||
"build:types": "tsc --noEmit",
|
||||
"zip:chrome": "wxt zip -b chrome",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"format": "prettier --write .",
|
||||
"clean": "rimraf .wxt .output node_modules .turbo"
|
||||
@@ -50,6 +50,7 @@
|
||||
"bitcore-lib": "^10.8.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"ethers": "^6.15.0",
|
||||
"long": "^5.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -75,6 +76,7 @@
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@wxt-dev/auto-icons": "^1.0.2",
|
||||
"@wxt-dev/module-react": "^1.1.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"globals": "^15.12.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -6,7 +6,7 @@ import { useState } from 'react'
|
||||
|
||||
import { Button } from '@status-im/components'
|
||||
import { RefreshIcon } from '@status-im/icons/20'
|
||||
import { DropdownSort } from '@status-im/wallet/components'
|
||||
// import { DropdownSort } from '@status-im/wallet/components'
|
||||
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { useRefetchToast } from '../hooks/use-refetch-toast'
|
||||
@@ -23,7 +23,7 @@ import { TabLink } from './tab-link'
|
||||
// }
|
||||
|
||||
// const placeholderText = {
|
||||
// assets: 'Search asset name or symbol',
|
||||
// assets: 'Search token name or symbol',
|
||||
// collectibles: 'Search collection or collectible name',
|
||||
// } as const
|
||||
|
||||
@@ -40,14 +40,14 @@ type Props = {
|
||||
}
|
||||
|
||||
const ActionButtons = (props: Props) => {
|
||||
const { address, searchAndSortValues } = props
|
||||
const {
|
||||
address,
|
||||
// searchAndSortValues
|
||||
} = props
|
||||
const queryClient = useQueryClient()
|
||||
const [isManualRefreshing, setIsManualRefreshing] = useState(false)
|
||||
|
||||
const totalFetchingCount = useIsFetching()
|
||||
const isAnyQueryFetching = totalFetchingCount > 0
|
||||
|
||||
// Use the refetch toast hook for manual refresh
|
||||
// Use the refetch toast hook for manual refreshes
|
||||
useRefetchToast({
|
||||
isRefreshing: isManualRefreshing,
|
||||
queryKeys: [
|
||||
@@ -57,8 +57,31 @@ const ActionButtons = (props: Props) => {
|
||||
['collectible'],
|
||||
['token'],
|
||||
],
|
||||
onComplete: () => {
|
||||
setIsManualRefreshing(false)
|
||||
},
|
||||
})
|
||||
|
||||
const isAnyQueryFetching =
|
||||
useIsFetching({
|
||||
predicate: query => {
|
||||
const key = query.queryKey
|
||||
return (
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'assets' &&
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'collectibles' &&
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'activities' &&
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) && key[0] === 'collectible') ||
|
||||
(Array.isArray(key) && key[0] === 'token')
|
||||
)
|
||||
},
|
||||
}) > 0
|
||||
|
||||
// const placeholder = placeholderText[checkPathnameAndReturnTabValue(pathname)]
|
||||
|
||||
const handleRefresh = async () => {
|
||||
@@ -70,11 +93,15 @@ const ActionButtons = (props: Props) => {
|
||||
const hasObservers = query.getObserversCount() > 0
|
||||
|
||||
const isWalletQuery =
|
||||
(Array.isArray(key) && key[0] === 'assets' && key[1] === address) ||
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'assets' &&
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'collectibles' &&
|
||||
key[1] === address) ||
|
||||
(Array.isArray(key) && key[0] === 'activities' && key[1] === address) ||
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) &&
|
||||
key[0] === 'activities' &&
|
||||
(key[1] === address || key[1] === undefined)) ||
|
||||
(Array.isArray(key) && key[0] === 'collectible') ||
|
||||
(Array.isArray(key) && key[0] === 'token')
|
||||
|
||||
@@ -86,16 +113,14 @@ const ActionButtons = (props: Props) => {
|
||||
queryClient.refetchQueries({ queryKey: query.queryKey }),
|
||||
),
|
||||
)
|
||||
|
||||
setTimeout(() => setIsManualRefreshing(false), 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex place-content-between">
|
||||
<div className="flex gap-1.5">
|
||||
<TabLink href={`/portfolio/assets`}>Assets</TabLink>
|
||||
<TabLink href={`/portfolio/assets`}>Tokens</TabLink>
|
||||
<TabLink href={`/portfolio/collectibles`}>Collectibles</TabLink>
|
||||
<TabLink href={`/portfolio/activity`}>Activity</TabLink>
|
||||
<TabLink href={`/portfolio/activity`}>History</TabLink>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <Input
|
||||
@@ -123,12 +148,12 @@ const ActionButtons = (props: Props) => {
|
||||
aria-label="Refresh data"
|
||||
disabled={isAnyQueryFetching}
|
||||
/>
|
||||
<DropdownSort
|
||||
{/* <DropdownSort
|
||||
data={searchAndSortValues.sortOptions}
|
||||
onOrderByChange={searchAndSortValues.onOrderByChange}
|
||||
orderByColumn={searchAndSortValues.orderByColumn}
|
||||
ascending={searchAndSortValues.ascending}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import { NegativeStateIcon } from '@status-im/icons/20'
|
||||
@@ -7,44 +7,41 @@ import {
|
||||
RecoveryPhraseDialog,
|
||||
SignTransactionDialog,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
|
||||
import { apiClient } from '@/providers/api-client'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
import { useRecoveryPhraseBackup } from '../../hooks/use-recovery-phrase-backup'
|
||||
|
||||
export function RecoveryPhraseBackup() {
|
||||
const { currentWallet, mnemonic, setMnemonic } = useWallet()
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false)
|
||||
const { currentWallet } = useWallet()
|
||||
const {
|
||||
needsBackup,
|
||||
showRecoveryDialog,
|
||||
setShowRecoveryDialog,
|
||||
markAsBackedUp,
|
||||
} = useRecoveryPhraseBackup()
|
||||
const [mnemonic, setMnemonic] = useState<string | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (mnemonic) {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
return "You haven't backed up your wallet! Your recovery phrase will be lost forever."
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () =>
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [mnemonic])
|
||||
|
||||
const onPasswordConfirm = async (password: string) => {
|
||||
if (!currentWallet?.id) {
|
||||
toast.negative('No wallet selected', { duration: 3000 })
|
||||
toast.negative(ERROR_MESSAGES.NO_WALLET_SELECTED, { duration: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.wallet.get.query({
|
||||
const { mnemonic } = await apiClient.wallet.get.query({
|
||||
password: password,
|
||||
walletId: currentWallet?.id,
|
||||
})
|
||||
|
||||
setMnemonic(mnemonic)
|
||||
setShowRecoveryDialog(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.negative('Invalid password', {
|
||||
toast.negative(ERROR_MESSAGES.INVALID_PASSWORD, {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
@@ -55,14 +52,15 @@ export function RecoveryPhraseBackup() {
|
||||
}
|
||||
|
||||
const onComplete = async () => {
|
||||
await markAsBackedUp()
|
||||
setMnemonic(null)
|
||||
toast.positive(
|
||||
'Your recovery phrase has been deleted from the wallet interface.',
|
||||
'Your recovery phrase has been removed from this wallet interface.',
|
||||
{ duration: 3000 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!mnemonic) {
|
||||
if (!needsBackup) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -156,10 +156,10 @@ const HeaderRightSlot = () => {
|
||||
href="/portfolio/assets"
|
||||
className="w-full justify-center text-center sm:w-fit"
|
||||
>
|
||||
Assets
|
||||
Tokens
|
||||
</TabLink>
|
||||
<TabLink href="/portfolio/collectibles">Collectibles</TabLink>
|
||||
<TabLink href="/portfolio/activity">Activity</TabLink>
|
||||
<TabLink href="/portfolio/activity">History</TabLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,6 +320,55 @@ const apiRouter = router({
|
||||
maxInclusionFeePerGas: input.maxInclusionFeePerGas,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
}
|
||||
}),
|
||||
|
||||
sendErc20: t.procedure
|
||||
.input(
|
||||
z.object({
|
||||
walletId: z.string(),
|
||||
password: z.string(),
|
||||
fromAddress: z.string(),
|
||||
toAddress: z.string(),
|
||||
gasLimit: z.string(),
|
||||
maxFeePerGas: z.string(),
|
||||
maxInclusionFeePerGas: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { keyStore, walletCore } = ctx
|
||||
|
||||
const wallet = await keyStore.load(input.walletId)
|
||||
|
||||
const account = wallet.activeAccounts.find(
|
||||
account => account.address === input.fromAddress,
|
||||
)
|
||||
|
||||
if (!account) {
|
||||
throw new Error('From address not found')
|
||||
}
|
||||
|
||||
const privateKey = await keyStore.getKey(
|
||||
wallet.id,
|
||||
input.password,
|
||||
account,
|
||||
)
|
||||
|
||||
const id = await ethereum.sendErc20({
|
||||
walletCore,
|
||||
walletPrivateKey: privateKey,
|
||||
chainID: '01',
|
||||
toAddress: input.toAddress,
|
||||
fromAddress: input.fromAddress,
|
||||
gasLimit: input.gasLimit,
|
||||
maxFeePerGas: input.maxFeePerGas,
|
||||
maxInclusionFeePerGas: input.maxInclusionFeePerGas,
|
||||
data: input.data,
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function send({
|
||||
const utxo = selectedUtxos.map(utxo => {
|
||||
return encoder.Bitcoin.Proto.UnspentTransaction.create({
|
||||
outPoint: {
|
||||
hash: Buffer.from(utxo.txid, 'hex').reverse(),
|
||||
hash: new Uint8Array(Buffer.from(utxo.txid, 'hex').reverse()),
|
||||
index: utxo.vout,
|
||||
sequence: 4294967295, // default sequence
|
||||
// sequence: UINT32_MAX,
|
||||
|
||||
@@ -4,6 +4,10 @@ import { encoder } from '../encoder'
|
||||
|
||||
import type { WalletCore } from '@trustwallet/wallet-core'
|
||||
|
||||
const BROADCAST_TRANSACTION_URL = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.broadcastTransaction`,
|
||||
)
|
||||
|
||||
export async function send({
|
||||
walletCore,
|
||||
walletPrivateKey,
|
||||
@@ -27,54 +31,24 @@ export async function send({
|
||||
maxFeePerGas: string
|
||||
maxInclusionFeePerGas: string
|
||||
}) {
|
||||
const nonceUrl = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getNonce`,
|
||||
)
|
||||
nonceUrl.searchParams.set(
|
||||
'input',
|
||||
JSON.stringify({ json: { address: fromAddress, network } }),
|
||||
)
|
||||
const nonceResponse = await fetch(nonceUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
// Fetch nonce
|
||||
const nonceHex = await getNonceHex(fromAddress, network)
|
||||
const chainIdHex = getChainIdHex(chainID)
|
||||
|
||||
if (!nonceResponse.ok) {
|
||||
throw new Error('Failed to fetch nonce')
|
||||
}
|
||||
const cleanAmount = amount.replace(/^0x/, '').padStart(16, '0')
|
||||
|
||||
const nonceBody = await nonceResponse.json()
|
||||
|
||||
const nonce = nonceBody.result.data.json
|
||||
|
||||
// const feeRate = nodes.getFeeRate
|
||||
|
||||
// fixme: calc nonce and fees
|
||||
const txInput = encoder.Ethereum.Proto.SigningInput.create({
|
||||
chainId: Uint8Array.from(Buffer.from(chainID, 'hex')),
|
||||
// chainId: Buffer.from('01', 'hex'),
|
||||
// gasPrice: Buffer.from(feeRate.replace('0x', ''), 'hex'),
|
||||
// nonce: Buffer.from('09', 'hex'),
|
||||
// nonce: Buffer.from('00', 'hex'),
|
||||
nonce: Uint8Array.from(Buffer.from(nonce.replace(/^0x/, '0'), 'hex')),
|
||||
// maxFeePerGas: Buffer.from(feeRate, 'hex'),
|
||||
// // maxInclusionFeePerGas: Buffer.from('3b9aca00', 'hex'),
|
||||
// maxInclusionFeePerGas: Buffer.from('01', 'hex'),
|
||||
// gasLimit: Buffer.from('5208', 'hex'),
|
||||
// gasPrice: Buffer.from('04a817c800', 'hex'),
|
||||
// gasLimit: Buffer.from('5208', 'hex'),
|
||||
gasLimit: Uint8Array.from(Buffer.from(gasLimit, 'hex')),
|
||||
maxFeePerGas: Uint8Array.from(Buffer.from(maxFeePerGas, 'hex')),
|
||||
chainId: Uint8Array.from(Buffer.from(chainIdHex, 'hex')),
|
||||
nonce: Uint8Array.from(Buffer.from(nonceHex, 'hex')),
|
||||
gasLimit: Uint8Array.from(Buffer.from(gasLimit.replace(/^0x/, ''), 'hex')),
|
||||
maxFeePerGas: Uint8Array.from(Buffer.from(padHex(maxFeePerGas), 'hex')),
|
||||
maxInclusionFeePerGas: Uint8Array.from(
|
||||
Buffer.from(maxInclusionFeePerGas, 'hex'),
|
||||
Buffer.from(padHex(maxInclusionFeePerGas), 'hex'),
|
||||
),
|
||||
toAddress: toAddress,
|
||||
toAddress,
|
||||
transaction: {
|
||||
transfer: {
|
||||
amount: Uint8Array.from(Buffer.from(amount, 'hex')),
|
||||
amount: Uint8Array.from(Buffer.from(cleanAmount, 'hex')),
|
||||
},
|
||||
},
|
||||
privateKey: walletPrivateKey.data(),
|
||||
@@ -83,7 +57,6 @@ export async function send({
|
||||
|
||||
const inputEncoded =
|
||||
encoder.Ethereum.Proto.SigningInput.encode(txInput).finish()
|
||||
|
||||
// sign
|
||||
const outputData = walletCore.AnySigner.sign(
|
||||
inputEncoded,
|
||||
@@ -93,11 +66,7 @@ export async function send({
|
||||
const rawTx = walletCore.HexCoding.encode(output.encoded)
|
||||
|
||||
// broadcast
|
||||
const url = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.broadcastTransaction`,
|
||||
)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await fetch(BROADCAST_TRANSACTION_URL.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -105,7 +74,7 @@ export async function send({
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
txHex: rawTx,
|
||||
network: 'ethereum',
|
||||
network,
|
||||
},
|
||||
}),
|
||||
cache: 'no-store',
|
||||
@@ -122,3 +91,137 @@ export async function send({
|
||||
txid,
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendErc20({
|
||||
walletCore,
|
||||
walletPrivateKey,
|
||||
chainID,
|
||||
toAddress,
|
||||
fromAddress,
|
||||
network = 'ethereum',
|
||||
gasLimit,
|
||||
maxFeePerGas,
|
||||
maxInclusionFeePerGas,
|
||||
data,
|
||||
}: {
|
||||
walletCore: WalletCore
|
||||
walletPrivateKey: InstanceType<WalletCore['PrivateKey']>
|
||||
chainID: string
|
||||
toAddress: string
|
||||
fromAddress: string
|
||||
network?: string
|
||||
gasLimit: string
|
||||
maxFeePerGas: string
|
||||
maxInclusionFeePerGas: string
|
||||
data: string
|
||||
}) {
|
||||
// Fetch nonce
|
||||
const nonceHex = await getNonceHex(fromAddress, network)
|
||||
const chainIdHex = getChainIdHex(chainID)
|
||||
// For erc20Transfer, we need to extract the recipient address and amount from the data field
|
||||
// data contains function signature (4 bytes) + to address (32 bytes) + amount (32 bytes)
|
||||
const cleanData = data.replace(/^0x/, '')
|
||||
// Extract recipientAddress from cleanData (bytes 4-36, but only last 20 bytes are used)
|
||||
const recipientAddress = '0x' + cleanData.slice(32, 72)
|
||||
//Extract amount (bytes 72-104) which is 32 bytes
|
||||
const tokenAmount = cleanData.slice(72, 136)
|
||||
|
||||
const txInput = encoder.Ethereum.Proto.SigningInput.create({
|
||||
chainId: Uint8Array.from(Buffer.from(chainIdHex, 'hex')),
|
||||
nonce: Uint8Array.from(Buffer.from(nonceHex, 'hex')),
|
||||
gasLimit: Uint8Array.from(Buffer.from(padHex(gasLimit), 'hex')),
|
||||
maxFeePerGas: Uint8Array.from(Buffer.from(padHex(maxFeePerGas), 'hex')),
|
||||
maxInclusionFeePerGas: Uint8Array.from(
|
||||
Buffer.from(padHex(maxInclusionFeePerGas), 'hex'),
|
||||
),
|
||||
toAddress,
|
||||
transaction: {
|
||||
erc20Transfer: {
|
||||
to: recipientAddress,
|
||||
amount: Uint8Array.from(Buffer.from(tokenAmount, 'hex')),
|
||||
},
|
||||
},
|
||||
privateKey: walletPrivateKey.data(),
|
||||
txMode: encoder.Ethereum.Proto.TransactionMode.Enveloped,
|
||||
})
|
||||
|
||||
const inputEncoded =
|
||||
encoder.Ethereum.Proto.SigningInput.encode(txInput).finish()
|
||||
|
||||
const outputData = walletCore.AnySigner.sign(
|
||||
inputEncoded,
|
||||
walletCore.CoinType.ethereum,
|
||||
)
|
||||
const output = encoder.Ethereum.Proto.SigningOutput.decode(outputData)
|
||||
const rawTx = walletCore.HexCoding.encode(output.encoded)
|
||||
|
||||
// broadcast
|
||||
const response = await fetch(BROADCAST_TRANSACTION_URL.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
txHex: rawTx,
|
||||
network,
|
||||
},
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to broadcast transaction')
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
const txid = body.result.data.json
|
||||
|
||||
return {
|
||||
txid,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILS
|
||||
*/
|
||||
|
||||
// Function to pad hex strings to even length
|
||||
const padHex = (hexStr: string) => {
|
||||
hexStr = hexStr.replace(/^0x/, '')
|
||||
return hexStr.length % 2 === 1 ? '0' + hexStr : hexStr
|
||||
}
|
||||
|
||||
// Fetch the nonce for the given address and network
|
||||
const getNonceHex = async (
|
||||
fromAddress: string,
|
||||
network: string,
|
||||
): Promise<string> => {
|
||||
const nonceUrl = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getNonce`,
|
||||
)
|
||||
nonceUrl.searchParams.set(
|
||||
'input',
|
||||
JSON.stringify({ json: { address: fromAddress, network } }),
|
||||
)
|
||||
|
||||
const nonceResponse = await fetch(nonceUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!nonceResponse.ok) {
|
||||
throw new Error('Failed to fetch nonce')
|
||||
}
|
||||
|
||||
const nonceBody = await nonceResponse.json()
|
||||
return nonceBody.result.data.json.replace(/^0x/, '').padStart(2, '0')
|
||||
}
|
||||
|
||||
// Get the chain ID in hex format
|
||||
const getChainIdHex = (chainID: string): string => {
|
||||
return BigInt(chainID).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
/// <reference path="../../.wxt/wxt.d.ts" />
|
||||
|
||||
import { Buffer } from 'buffer'
|
||||
import { defineBackground } from 'wxt/sandbox'
|
||||
|
||||
// import { browser as wxtBrowser } from 'wxt/browser'
|
||||
import { createAPI } from '../data/api'
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Set the `action.default_title` in the manifest -->
|
||||
<title>Status Portfolio Wallet</title>
|
||||
<title>!Status Portfolio Wallet (Beta)</title>
|
||||
<!-- Customize the manifest options -->
|
||||
<!-- @see https://github.com/wxt-dev/wxt/issues/1144 -->
|
||||
<!-- @see https://stackoverflow.com/questions/67651633/how-to-add-react-devtools-for-chrome-extension-development -->
|
||||
<!-- @see https://github.com/facebook/react-devtools/issues/91 -->
|
||||
<!-- <script src="http://localhost:8097"></script> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -89,8 +89,5 @@ export const useActivities = ({ address }: Props) => {
|
||||
initialPageParam: {},
|
||||
staleTime: QUERY_STALE_TIME_MS,
|
||||
gcTime: QUERY_GC_TIME_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const useAssets = (
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch.')
|
||||
throw new Error(response.statusText, { cause: response.status })
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
@@ -55,9 +55,6 @@ const useAssets = (
|
||||
enabled: !!address && !isWalletLoading,
|
||||
staleTime: QUERY_STALE_TIME_MS,
|
||||
gcTime: QUERY_GC_TIME_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const getCollectibles = async (
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch.')
|
||||
throw new Error(response.statusText, { cause: response.status })
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
@@ -118,9 +118,6 @@ const useCollectibles = (props: Props) => {
|
||||
enabled: !!address && !isWalletLoading,
|
||||
staleTime: QUERY_STALE_TIME_MS,
|
||||
gcTime: QUERY_GC_TIME_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { useAPI } from '../providers/api-client'
|
||||
import { useRecoveryPhraseBackup } from './use-recovery-phrase-backup'
|
||||
|
||||
export const useCreateWallet = () => {
|
||||
const api = useAPI()
|
||||
const queryClient = useQueryClient()
|
||||
const { markAsNeedsBackup } = useRecoveryPhraseBackup()
|
||||
|
||||
const { mutate, mutateAsync, ...result } = useMutation({
|
||||
mutationKey: ['create-wallet'],
|
||||
mutationFn: async (password: string) => {
|
||||
const { mnemonic } = await api.wallet.add.mutate({
|
||||
await api.wallet.add.mutate({
|
||||
password,
|
||||
name: 'Created Wallet',
|
||||
name: 'Account 1',
|
||||
})
|
||||
|
||||
return mnemonic
|
||||
await markAsNeedsBackup()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['wallets'] })
|
||||
|
||||
@@ -39,8 +39,5 @@ export const useEthBalance = (address: string, enabled: boolean = true) => {
|
||||
enabled,
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const useImportWallet = () => {
|
||||
await api.wallet.import.mutate({
|
||||
mnemonic,
|
||||
password,
|
||||
name: 'Imported Wallet',
|
||||
name: 'Account 1',
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { getTransactionHash } from '@status-im/wallet/utils'
|
||||
|
||||
import { usePendingTransactions } from '../providers/pending-transactions-context'
|
||||
|
||||
import type { ApiOutput } from '@status-im/wallet/data'
|
||||
|
||||
type Activity = ApiOutput['activities']['activities']['activities'][0]
|
||||
|
||||
export const usePendingTransactionsCleanup = (activities: Activity[]) => {
|
||||
const { pendingTransactions, removePendingTransaction } =
|
||||
usePendingTransactions()
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTransactions.length === 0 || activities.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmedHashes = new Set(
|
||||
activities.map(activity => getTransactionHash(activity.hash)),
|
||||
)
|
||||
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
pendingTransactions.forEach(pendingTx => {
|
||||
const pendingHash = getTransactionHash(pendingTx.hash)
|
||||
|
||||
if (confirmedHashes.has(pendingHash)) {
|
||||
removePendingTransaction(pendingHash)
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = pendingTx.metadata?.blockTimestamp
|
||||
if (timestamp) {
|
||||
const txTime = new Date(timestamp).getTime()
|
||||
if (!isNaN(txTime) && now - txTime > TEN_MINUTES_MS) {
|
||||
removePendingTransaction(pendingHash)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [activities, pendingTransactions, removePendingTransaction])
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
|
||||
export const useRecoveryPhraseBackup = () => {
|
||||
const toast = useToast()
|
||||
const [needsBackup, setNeedsBackup] = useState<boolean>(false)
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState<boolean>(false)
|
||||
|
||||
const markAsBackedUp = async () => {
|
||||
try {
|
||||
await chrome.storage.local.remove(['recovery-phrase:needs-backup'])
|
||||
setNeedsBackup(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to mark recovery phrase as backed up', error)
|
||||
toast.negative(ERROR_MESSAGES.RECOVERY_PHRASE_BACKUP, {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const markAsNeedsBackup = async () => {
|
||||
try {
|
||||
await chrome.storage.local.set({ 'recovery-phrase:needs-backup': true })
|
||||
setNeedsBackup(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to mark recovery phrase as needing backup', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function checkSettings() {
|
||||
try {
|
||||
const { 'recovery-phrase:needs-backup': needsBackup } =
|
||||
await chrome.storage.local.get(['recovery-phrase:needs-backup'])
|
||||
if (needsBackup) {
|
||||
setNeedsBackup(true)
|
||||
return
|
||||
}
|
||||
setNeedsBackup(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to get recovery phrase backup status', error)
|
||||
setNeedsBackup(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkSettings()
|
||||
}, [])
|
||||
|
||||
return {
|
||||
needsBackup,
|
||||
markAsBackedUp,
|
||||
markAsNeedsBackup,
|
||||
showRecoveryDialog,
|
||||
setShowRecoveryDialog,
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,26 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import { RefreshIcon } from '@status-im/icons/20'
|
||||
import { useIsFetching } from '@tanstack/react-query'
|
||||
import { useIsFetching, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
interface UseRefetchToastOptions {
|
||||
isRefreshing: boolean
|
||||
queryKeys?: string[][]
|
||||
onComplete?: () => void
|
||||
showLoadingAndSuccess?: boolean
|
||||
}
|
||||
|
||||
export function useRefetchToast({
|
||||
isRefreshing,
|
||||
queryKeys,
|
||||
onComplete,
|
||||
showLoadingAndSuccess = true,
|
||||
}: UseRefetchToastOptions) {
|
||||
const toast = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const hasShownLoadingToast = useRef(false)
|
||||
const hasShownSuccessToast = useRef(false)
|
||||
const hasShownErrorToast = useRef(false)
|
||||
|
||||
const fetchingCount = useIsFetching(
|
||||
queryKeys
|
||||
@@ -30,28 +36,61 @@ export function useRefetchToast({
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const hasError = queryKeys
|
||||
? queryClient
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.some(
|
||||
query =>
|
||||
queryKeys.some(
|
||||
key =>
|
||||
query.queryKey.length >= key.length &&
|
||||
key.every((k, index) => query.queryKey[index] === k),
|
||||
) && query.state.status === 'error',
|
||||
)
|
||||
: false
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRefreshing) {
|
||||
hasShownLoadingToast.current = false
|
||||
hasShownSuccessToast.current = false
|
||||
hasShownErrorToast.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (fetchingCount > 0 && !hasShownLoadingToast.current) {
|
||||
hasShownLoadingToast.current = true
|
||||
toast.custom(
|
||||
'Refreshing prices and balances',
|
||||
<RefreshIcon style={{ animation: 'spin 1s linear infinite' }} />,
|
||||
)
|
||||
if (showLoadingAndSuccess) {
|
||||
toast.custom(
|
||||
'Refreshing prices and balances',
|
||||
<RefreshIcon style={{ animation: 'spin 1s linear infinite' }} />,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fetchingCount === 0 &&
|
||||
hasShownLoadingToast.current &&
|
||||
!hasShownSuccessToast.current
|
||||
!hasShownSuccessToast.current &&
|
||||
!hasError
|
||||
) {
|
||||
hasShownSuccessToast.current = true
|
||||
toast.positive('Prices and balances have been updated')
|
||||
if (showLoadingAndSuccess) {
|
||||
toast.positive('Prices and balances have been updated')
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}, [fetchingCount, isRefreshing, toast])
|
||||
|
||||
if (hasError && !hasShownErrorToast.current) {
|
||||
hasShownErrorToast.current = true
|
||||
toast.negative('Failed to update prices and balances')
|
||||
|
||||
if (onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}, [fetchingCount, isRefreshing, toast, hasError, onComplete])
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRefetchToast } from './use-refetch-toast'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 15 * 1000
|
||||
const REFRESH_COMPLETE_DELAY_MS = 50
|
||||
|
||||
export function useSynchronizedRefetch(address: string) {
|
||||
const queryClient = useQueryClient()
|
||||
@@ -15,6 +14,10 @@ export function useSynchronizedRefetch(address: string) {
|
||||
const refetchQueries = useCallback(async () => {
|
||||
if (!address) return
|
||||
|
||||
if (has429Error(address, queryClient)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsAutoRefreshing(true)
|
||||
|
||||
const queries = queryClient.getQueryCache().getAll()
|
||||
@@ -23,6 +26,9 @@ export function useSynchronizedRefetch(address: string) {
|
||||
const key = query.queryKey
|
||||
const hasObservers = query.getObserversCount() > 0
|
||||
|
||||
const hasValidAddress =
|
||||
Array.isArray(key) && key[1] && key[1] !== 'undefined'
|
||||
|
||||
const isWalletQuery =
|
||||
(Array.isArray(key) && key[0] === 'assets' && key[1] === address) ||
|
||||
(Array.isArray(key) &&
|
||||
@@ -32,29 +38,33 @@ export function useSynchronizedRefetch(address: string) {
|
||||
(Array.isArray(key) && key[0] === 'collectible') ||
|
||||
(Array.isArray(key) && key[0] === 'token')
|
||||
|
||||
return hasObservers && isWalletQuery
|
||||
return hasObservers && isWalletQuery && hasValidAddress
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
activeQueries.map(query =>
|
||||
queryClient.refetchQueries({ queryKey: query.queryKey }),
|
||||
),
|
||||
activeQueries.map(async query => {
|
||||
await queryClient.invalidateQueries({ queryKey: query.queryKey })
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: query.queryKey,
|
||||
exact: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
setTimeout(() => setIsAutoRefreshing(false), REFRESH_COMPLETE_DELAY_MS)
|
||||
}, [address, queryClient])
|
||||
|
||||
useRefetchToast({
|
||||
isRefreshing: isAutoRefreshing,
|
||||
queryKeys: address
|
||||
? [
|
||||
['assets', address],
|
||||
['collectibles', address],
|
||||
['activities', address],
|
||||
['collectible'],
|
||||
['token'],
|
||||
]
|
||||
: [],
|
||||
queryKeys: [
|
||||
['assets', address],
|
||||
['collectibles', address],
|
||||
['activities', address],
|
||||
['collectible'],
|
||||
['token'],
|
||||
],
|
||||
showLoadingAndSuccess: false,
|
||||
onComplete: () => {
|
||||
setIsAutoRefreshing(false)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,3 +92,57 @@ export function useSynchronizedRefetch(address: string) {
|
||||
return () => clearInterval(interval)
|
||||
}, [isWindowActive, address, queryClient, refetchQueries])
|
||||
}
|
||||
|
||||
function has429Error(
|
||||
address: string,
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
) {
|
||||
const queries = queryClient.getQueryCache().getAll()
|
||||
return queries.some(query => {
|
||||
const key = query.queryKey
|
||||
const isRelevant =
|
||||
(Array.isArray(key) && key[0] === 'assets' && key[1] === address) ||
|
||||
(Array.isArray(key) && key[0] === 'collectibles' && key[1] === address) ||
|
||||
(Array.isArray(key) && key[0] === 'activities' && key[1] === address) ||
|
||||
(Array.isArray(key) && key[0] === 'collectible') ||
|
||||
(Array.isArray(key) && key[0] === 'token')
|
||||
|
||||
if (!isRelevant) {
|
||||
return false
|
||||
}
|
||||
|
||||
const error = query.state.error
|
||||
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (error.cause === 429) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
if (
|
||||
'status' in error &&
|
||||
typeof (error as { status?: unknown }).status === 'number' &&
|
||||
(error as { status: number }).status === 429
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
'message' in error &&
|
||||
typeof (error as { message?: unknown }).message === 'string' &&
|
||||
(error as { message: string }).message.includes('429')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'string' && (error as string).includes('429')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { ChartDataType } from '@status-im/wallet/components'
|
||||
import type { ApiOutput } from '@status-im/wallet/data'
|
||||
|
||||
type UseValueChartDataParams = {
|
||||
activeDataType: ChartDataType
|
||||
priceData: ApiOutput['assets']['tokenPriceChart'] | null | undefined
|
||||
balanceData: ApiOutput['assets']['tokenBalanceChart'] | null | undefined
|
||||
}
|
||||
|
||||
export function useValueChartData({
|
||||
activeDataType,
|
||||
priceData,
|
||||
balanceData,
|
||||
}: UseValueChartDataParams) {
|
||||
return useMemo(() => {
|
||||
if (
|
||||
activeDataType !== 'value' ||
|
||||
!priceData ||
|
||||
!balanceData ||
|
||||
balanceData.length === 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const balancePoints = balanceData
|
||||
.map(point => [new Date(point.date).getTime(), point.price] as const)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
|
||||
const findBalance = (targetTime: number): number => {
|
||||
let left = 0
|
||||
let right = balancePoints.length - 1
|
||||
let bestBalance = 0
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const [pointTime, balance] = balancePoints[mid]
|
||||
|
||||
if (pointTime <= targetTime) {
|
||||
bestBalance = balance
|
||||
left = mid + 1
|
||||
} else {
|
||||
right = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return bestBalance
|
||||
}
|
||||
|
||||
return priceData.map(pricePoint => {
|
||||
const targetTime = new Date(pricePoint.date).getTime()
|
||||
const balance = findBalance(targetTime)
|
||||
const value = balance * pricePoint.price
|
||||
|
||||
return {
|
||||
date: pricePoint.date,
|
||||
price: pricePoint.price,
|
||||
balance: balance,
|
||||
value: value,
|
||||
}
|
||||
})
|
||||
}, [activeDataType, priceData, balanceData])
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export const renderMarkdown = async (
|
||||
const result = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype)
|
||||
// @ts-expect-error - rehype-react types are not compatible with unified types
|
||||
.use(rehypeReact, {
|
||||
...production,
|
||||
components,
|
||||
|
||||
@@ -9,6 +9,14 @@ import { QueryClientProvider } from './providers/query-client'
|
||||
import { RouterProvider } from './providers/router'
|
||||
import { StatusProvider } from './providers/status'
|
||||
|
||||
// if (import.meta.env.MODE !== 'production') {
|
||||
// const script = document.createElement('script')
|
||||
// script.src = 'http://localhost:8097'
|
||||
// // document.body.appendChild(script)
|
||||
// // document.documentElement.appendChild(script)
|
||||
// document.body.append(script)
|
||||
// }
|
||||
|
||||
const rootElement = document.getElementById('root')!
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { getTransactionHash } from '@status-im/wallet/utils'
|
||||
|
||||
import { Storage } from '../data/storage'
|
||||
|
||||
import type { ApiOutput } from '@status-im/wallet/data'
|
||||
|
||||
type Activity = ApiOutput['activities']['activities']['activities'][0]
|
||||
|
||||
type PendingTransaction = Activity & {
|
||||
status: 'pending'
|
||||
uniqueId: string
|
||||
}
|
||||
|
||||
type PendingTransactionsContext = {
|
||||
pendingTransactions: PendingTransaction[]
|
||||
addPendingTransaction: (
|
||||
transaction: Omit<PendingTransaction, 'uniqueId'>,
|
||||
) => void
|
||||
removePendingTransaction: (hash: string) => void
|
||||
clearPendingTransactions: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const PendingTransactionsContext = createContext<
|
||||
PendingTransactionsContext | undefined
|
||||
>(undefined)
|
||||
|
||||
export function usePendingTransactions() {
|
||||
const context = useContext(PendingTransactionsContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'usePendingTransactions must be used within PendingTransactionsProvider',
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function PendingTransactionsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [pendingTransactions, setPendingTransactions] = useState<
|
||||
PendingTransaction[]
|
||||
>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [storage] = useState(() => new Storage('pending-transactions'))
|
||||
|
||||
useEffect(() => {
|
||||
const loadPendingTransactions = async () => {
|
||||
try {
|
||||
const result = await storage.get(['transactions'])
|
||||
const stored = result.transactions as PendingTransaction[] | undefined
|
||||
|
||||
if (stored && Array.isArray(stored)) {
|
||||
setPendingTransactions(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load pending transactions:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadPendingTransactions()
|
||||
}, [storage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
storage.set({ transactions: pendingTransactions }).catch(error => {
|
||||
console.error('Failed to save pending transactions:', error)
|
||||
})
|
||||
}
|
||||
}, [pendingTransactions, storage, isLoading])
|
||||
|
||||
const addPendingTransaction = (
|
||||
transaction: Omit<PendingTransaction, 'uniqueId'>,
|
||||
) => {
|
||||
const newTransaction: PendingTransaction = {
|
||||
...transaction,
|
||||
uniqueId: `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
}
|
||||
|
||||
setPendingTransactions(prev => [...prev, newTransaction])
|
||||
}
|
||||
|
||||
const removePendingTransaction = (hash: string) => {
|
||||
setPendingTransactions(prev =>
|
||||
prev.filter(tx => getTransactionHash(tx.hash) !== hash),
|
||||
)
|
||||
}
|
||||
|
||||
const clearPendingTransactions = () => {
|
||||
setPendingTransactions([])
|
||||
}
|
||||
|
||||
const contextValue: PendingTransactionsContext = {
|
||||
pendingTransactions,
|
||||
addPendingTransaction,
|
||||
removePendingTransaction,
|
||||
clearPendingTransactions,
|
||||
isLoading,
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingTransactionsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PendingTransactionsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// import { useSynchronizedRefetch } from '../hooks/use-synchronized-refetch'
|
||||
import { useSynchronizedRefetch } from '../hooks/use-synchronized-refetch'
|
||||
import { apiClient } from './api-client'
|
||||
|
||||
import type { KeyStore } from '@trustwallet/wallet-core'
|
||||
@@ -15,8 +22,6 @@ type WalletContext = {
|
||||
isLoading: boolean
|
||||
hasWallets: boolean
|
||||
setCurrentWallet: (id: Wallet['id']) => void
|
||||
setMnemonic: (mnemonic: string | null) => void
|
||||
mnemonic: string | null
|
||||
}
|
||||
|
||||
const WalletContext = createContext<WalletContext | undefined>(undefined)
|
||||
@@ -31,16 +36,12 @@ export function useWallet() {
|
||||
|
||||
export function WalletProvider({ children }: { children: React.ReactNode }) {
|
||||
const [selectedWalletId, setSelectedWalletId] = useState<string | null>(null)
|
||||
const [mnemonic, setMnemonic] = useState<string | null>(null)
|
||||
|
||||
const { data: wallets = [], isLoading } = useQuery({
|
||||
queryKey: ['wallets'],
|
||||
queryFn: () => apiClient.wallet.all.query(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
|
||||
const hasWallets = wallets.length > 0
|
||||
@@ -64,12 +65,20 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [hasWallets, selectedWalletId, wallets])
|
||||
|
||||
const setCurrentWallet = (id: string) => {
|
||||
setSelectedWalletId(id)
|
||||
}
|
||||
const setCurrentWallet = useCallback(
|
||||
(id: string) => {
|
||||
const walletExists = wallets.some(w => w.id === id)
|
||||
if (walletExists) {
|
||||
setSelectedWalletId(id)
|
||||
} else {
|
||||
console.error(`Wallet with id ${id} not found`)
|
||||
}
|
||||
},
|
||||
[wallets],
|
||||
)
|
||||
|
||||
// Auto-refresh
|
||||
// useSynchronizedRefetch(currentWallet?.activeAccounts[0]?.address ?? '')
|
||||
useSynchronizedRefetch(currentWallet?.activeAccounts[0]?.address ?? '')
|
||||
|
||||
const contextValue: WalletContext = {
|
||||
currentWallet,
|
||||
@@ -77,8 +86,6 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
|
||||
isLoading,
|
||||
hasWallets,
|
||||
setCurrentWallet,
|
||||
setMnemonic,
|
||||
mnemonic,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,18 +10,18 @@ import {
|
||||
// Navigate,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
// import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
// import { NotAllowed } from '../../../portfolio/src/app/_components/not-allowed'
|
||||
// import { AccountsProvider } from '../../../portfolio/src/app/_providers/accounts-context'
|
||||
// import { ConnectKitProvider } from '../../../portfolio/src/app/_providers/connectkit-provider'
|
||||
// import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider'
|
||||
import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider'
|
||||
// import { StatusProvider } from '../../../portfolio/src/app/_providers/status-provider'
|
||||
import { WagmiProvider } from '../../../portfolio/src/app/_providers/wagmi-provider'
|
||||
import { Link } from '../components/link'
|
||||
import { apiClient } from '../providers/api-client'
|
||||
import { PendingTransactionsProvider } from '../providers/pending-transactions-context'
|
||||
import { WalletProvider } from '../providers/wallet-context'
|
||||
|
||||
// import { Inter } from 'next/font/google'
|
||||
@@ -38,23 +38,36 @@ export const Route = createRootRouteWithContext<{
|
||||
queryClient: QueryClient
|
||||
}>()({
|
||||
beforeLoad: async ({ location }) => {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
const hasWallets = wallets && wallets.length > 0
|
||||
try {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
const hasWallets = Array.isArray(wallets) && wallets.length > 0
|
||||
|
||||
if (location.pathname === '/') {
|
||||
if (hasWallets) {
|
||||
throw redirect({ to: '/portfolio/assets' })
|
||||
} else {
|
||||
if (location.pathname === '/') {
|
||||
if (hasWallets) {
|
||||
throw redirect({ to: '/portfolio/assets' })
|
||||
} else {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/portfolio') && !hasWallets) {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/portfolio') && !hasWallets) {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/onboarding') && hasWallets) {
|
||||
throw redirect({ to: '/portfolio/assets' })
|
||||
if (location.pathname.startsWith('/onboarding') && hasWallets) {
|
||||
throw redirect({ to: '/portfolio/assets' })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'isRedirect' in error) {
|
||||
throw error
|
||||
}
|
||||
console.error('Error loading wallets in beforeLoad:', error)
|
||||
if (
|
||||
location.pathname === '/' ||
|
||||
location.pathname.startsWith('/portfolio')
|
||||
) {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
}
|
||||
},
|
||||
head: () => ({
|
||||
@@ -67,17 +80,55 @@ export const Route = createRootRouteWithContext<{
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'Status Portfolio Wallet',
|
||||
title: '!Status Portfolio Wallet (Beta)',
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
/**
|
||||
* what
|
||||
*
|
||||
* - Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
|
||||
* - etc.
|
||||
*
|
||||
* when
|
||||
*
|
||||
* - on refreshing
|
||||
*/
|
||||
errorComponent: function RootErrorComponent({ error }) {
|
||||
console.error('RootComponent: Error:', error)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-[56px] items-center px-2">
|
||||
<Navbar hasFeedback linkComponent={Link} />
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="flex-1 flex-col 2md:flex xl:pb-1">
|
||||
<div className="flex h-[calc(100vh-60px)] flex-col items-center justify-center overflow-y-hidden rounded-[24px] bg-white-100">
|
||||
{/* todo: global error illustration or split view */}
|
||||
<h2 className="pt-[68px] text-15 font-semibold text-neutral-100 first-line:mb-0.5">
|
||||
Something went wrong.
|
||||
</h2>
|
||||
<p className="mb-5 text-13 font-regular text-neutral-100">
|
||||
Refresh or try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
notFoundComponent: function RootNotFoundComponent() {
|
||||
console.info('RootNotFoundComponent: Info: not found')
|
||||
|
||||
// todo: global not found illustration
|
||||
// fixme?: show feedback in navbar
|
||||
return
|
||||
},
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
const routerState = useRouterState()
|
||||
const pathname = routerState.location.pathname
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <div className="min-h-screen bg-neutral-100 text-white-100">
|
||||
@@ -98,34 +149,31 @@ function RootComponent() {
|
||||
<div id="app" className="isolate" data-customisation="blue">
|
||||
{/* <StatusProvider> */}
|
||||
<WagmiProvider>
|
||||
{/* <QueryClientProvider> */}
|
||||
{/* <Suspense fallback={<div>Loading...</div>}> */}
|
||||
{/* <AccountsProvider> */}
|
||||
{/* <ConnectKitProvider> */}
|
||||
<WalletProvider>
|
||||
<div className="flex min-h-[56px] items-center px-2">
|
||||
<Navbar
|
||||
hasFeedback={/^\/portfolio\/(assets|collectibles)\/[^/]+$/.test(
|
||||
pathname ?? '',
|
||||
)}
|
||||
linkComponent={Link}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="flex-1 flex-col 2md:flex xl:pb-1">
|
||||
<div className="flex h-[calc(100vh-60px)] flex-col overflow-y-auto rounded-[24px] bg-white-100">
|
||||
{/* <OnboardingPage /> */}
|
||||
<Outlet />
|
||||
<QueryClientProvider>
|
||||
{/* <Suspense fallback={<div>Loading...</div>}> */}
|
||||
{/* <AccountsProvider> */}
|
||||
{/* <ConnectKitProvider> */}
|
||||
<WalletProvider>
|
||||
<PendingTransactionsProvider>
|
||||
<div className="flex min-h-[56px] items-center px-2">
|
||||
<Navbar hasFeedback linkComponent={Link} />
|
||||
</div>
|
||||
</div>
|
||||
{/* <NotAllowed /> */}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</WalletProvider>
|
||||
{/* </ConnectKitProvider> */}
|
||||
{/* </AccountsProvider> */}
|
||||
{/* </Suspense> */}
|
||||
{/* </QueryClientProvider> */}
|
||||
<div className="px-1">
|
||||
<div className="flex-1 flex-col 2md:flex xl:pb-1">
|
||||
<div className="flex h-[calc(100vh-60px)] flex-col overflow-y-hidden rounded-[24px] bg-white-100">
|
||||
{/* <OnboardingPage /> */}
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
{/* <NotAllowed /> */}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</PendingTransactionsProvider>
|
||||
</WalletProvider>
|
||||
{/* </ConnectKitProvider> */}
|
||||
{/* </AccountsProvider> */}
|
||||
{/* </Suspense> */}
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
{/* </StatusProvider> */}
|
||||
</div>
|
||||
|
||||
@@ -116,20 +116,21 @@ function CreatePassword({
|
||||
}) {
|
||||
const { importWalletAsync } = useImportWallet()
|
||||
const navigate = useNavigate()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit: SubmitHandler<CreatePasswordFormValues> = async data => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await importWalletAsync({
|
||||
mnemonic,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
startTransition(() => {
|
||||
navigate({ to: '/portfolio/assets' })
|
||||
})
|
||||
navigate({ to: '/portfolio/assets' })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ function CreatePassword({
|
||||
|
||||
<CreatePasswordForm
|
||||
onSubmit={handleSubmit}
|
||||
loading={isPending}
|
||||
loading={isLoading}
|
||||
confirmButtonLabel="Import Wallet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +33,16 @@ function RouteComponent() {
|
||||
By continuing you agree with Status
|
||||
<br />
|
||||
<a
|
||||
href="https://github.com/status-im/status-software-legal-documents/blob/master/terms-of-use.md"
|
||||
href="https://github.com/status-im/status-software-legal-documents/blob/master/browser-extension-wallet-terms-of-use.md"
|
||||
className="text-neutral-100"
|
||||
target="_blank"
|
||||
rel="noopenernoreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Terms of use
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://github.com/status-im/status-software-legal-documents/blob/master/privacy-policy.md"
|
||||
href="https://github.com/status-im/status-software-legal-documents/blob/master/browser-extension-wallet-privacy-policy.md"
|
||||
className="text-neutral-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useTransition } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@status-im/components'
|
||||
import { ArrowLeftIcon } from '@status-im/icons/20'
|
||||
import { CreatePasswordForm } from '@status-im/wallet/components'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
import { useCreateWallet } from '../../hooks/use-create-wallet'
|
||||
|
||||
import type { CreatePasswordFormValues } from '@status-im/wallet/components'
|
||||
@@ -18,20 +16,18 @@ export const Route = createFileRoute('/onboarding/new')({
|
||||
|
||||
function RouteComponent() {
|
||||
const { createWalletAsync } = useCreateWallet()
|
||||
const { setMnemonic } = useWallet()
|
||||
const navigate = useNavigate()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit: SubmitHandler<CreatePasswordFormValues> = async data => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const mnemonic = await createWalletAsync(data.password)
|
||||
|
||||
startTransition(() => {
|
||||
setMnemonic(mnemonic)
|
||||
navigate({ to: '/portfolio/assets' })
|
||||
})
|
||||
await createWalletAsync(data.password)
|
||||
navigate({ to: '/portfolio/assets' })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +49,7 @@ function RouteComponent() {
|
||||
only on your device. Status can't recover it.
|
||||
</div>
|
||||
|
||||
<CreatePasswordForm onSubmit={handleSubmit} loading={isPending} />
|
||||
<CreatePasswordForm onSubmit={handleSubmit} loading={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import {
|
||||
ActivityList,
|
||||
ActivityListSkeleton,
|
||||
EmptyState,
|
||||
FeedbackSection,
|
||||
EmptyStateActions,
|
||||
PinExtension,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import SplittedLayout from '@/components/splitted-layout'
|
||||
import { useActivities } from '@/hooks/use-activities'
|
||||
import { usePendingTransactionsCleanup } from '@/hooks/use-pending-transactions-cleanup'
|
||||
import { usePinExtension } from '@/hooks/use-pin-extension'
|
||||
import { usePendingTransactions } from '@/providers/pending-transactions-context'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
export const Route = createFileRoute('/portfolio/activity/')({
|
||||
@@ -16,36 +24,65 @@ export const Route = createFileRoute('/portfolio/activity/')({
|
||||
|
||||
function RouteComponent() {
|
||||
const { currentWallet, isLoading: isWalletLoading } = useWallet()
|
||||
const { isPinExtension, handleClose } = usePinExtension()
|
||||
const { pendingTransactions } = usePendingTransactions()
|
||||
const address = currentWallet?.activeAccounts[0].address
|
||||
|
||||
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useActivities({ address })
|
||||
const toast = useToast()
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useActivities({ address })
|
||||
const activities = data?.pages.flatMap(page => page.activities) ?? []
|
||||
|
||||
usePendingTransactionsCleanup(activities)
|
||||
|
||||
// Show error toast if there is an error fetching activities
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.negative(ERROR_MESSAGES.ACTIVITIES)
|
||||
}
|
||||
}, [isError, toast])
|
||||
|
||||
if (!currentWallet || !address) return null
|
||||
|
||||
return (
|
||||
<SplittedLayout
|
||||
list={
|
||||
activities.length > 0 && address ? (
|
||||
<ActivityList
|
||||
activities={activities}
|
||||
userAddress={address}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingMore={isFetchingNextPage}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{!address ? (
|
||||
<p className="mt-4">No wallet selected</p>
|
||||
) : (
|
||||
<EmptyState variant="activity" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
loadingState={<ActivityListSkeleton />}
|
||||
detail={<FeedbackSection />}
|
||||
isLoading={isLoading || isWalletLoading}
|
||||
/>
|
||||
<>
|
||||
<SplittedLayout
|
||||
list={
|
||||
activities.length > 0 && address ? (
|
||||
<ActivityList
|
||||
activities={activities}
|
||||
userAddress={address}
|
||||
onLoadMore={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoadingMore={isFetchingNextPage}
|
||||
pendingTransactions={pendingTransactions}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{!address ? (
|
||||
<p className="mt-4">No wallet selected</p>
|
||||
) : (
|
||||
<EmptyState variant="activity" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
loadingState={<ActivityListSkeleton />}
|
||||
detail={<EmptyStateActions address={address} />}
|
||||
isLoading={isLoading || isWalletLoading}
|
||||
/>
|
||||
{isPinExtension && (
|
||||
<div className="absolute right-2 top-14 z-20">
|
||||
<PinExtension onClose={handleClose} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,29 +41,25 @@ function Component() {
|
||||
<div className="hidden 2xl:block">
|
||||
<SplittedLayout
|
||||
list={
|
||||
assets ? (
|
||||
<AssetsList
|
||||
assets={assets.assets}
|
||||
onSelect={url => {
|
||||
const ticker = url.split('/').pop()
|
||||
if (!ticker) return
|
||||
router.navigate({
|
||||
to: '/portfolio/assets/$ticker',
|
||||
params: { ticker },
|
||||
...(!isDesktop && {
|
||||
viewTransition: true,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
clearSearch={() => {
|
||||
console.log('Search cleared')
|
||||
}}
|
||||
searchParams={new URLSearchParams()}
|
||||
pathname={pathname}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-3">Empty state</div>
|
||||
)
|
||||
<AssetsList
|
||||
assets={assets?.assets ?? []}
|
||||
onSelect={url => {
|
||||
const ticker = url.split('/').pop()
|
||||
if (!ticker) return
|
||||
router.navigate({
|
||||
to: '/portfolio/assets/$ticker',
|
||||
params: { ticker },
|
||||
...(!isDesktop && {
|
||||
viewTransition: true,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
clearSearch={() => {
|
||||
console.log('Search cleared')
|
||||
}}
|
||||
searchParams={new URLSearchParams()}
|
||||
pathname={pathname}
|
||||
/>
|
||||
}
|
||||
loadingState={<AssetsListLoading />}
|
||||
detail={<Token ticker={ticker} address={address} />}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { notFound } from '@tanstack/react-router'
|
||||
|
||||
import { useValueChartData } from '../../../../hooks/use-value-chart-data'
|
||||
|
||||
import type {
|
||||
ChartDataType,
|
||||
ChartTimeFrame,
|
||||
@@ -91,11 +93,9 @@ function AssetChart({
|
||||
const body = await response.json()
|
||||
return body.result.data.json
|
||||
},
|
||||
enabled: activeDataType === 'price' || activeDataType === 'value',
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
|
||||
const balanceChart = useQuery<ApiOutput['assets']['tokenBalanceChart']>({
|
||||
@@ -134,18 +134,33 @@ function AssetChart({
|
||||
const body = await response.json()
|
||||
return body.result.data.json
|
||||
},
|
||||
enabled: activeDataType === 'balance' || activeDataType === 'value',
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
|
||||
if (priceChart.isLoading || balanceChart.isLoading) {
|
||||
const valueChartData = useValueChartData({
|
||||
activeDataType,
|
||||
priceData: priceChart.data,
|
||||
balanceData: balanceChart.data,
|
||||
})
|
||||
|
||||
const isLoading =
|
||||
(activeDataType === 'price' && priceChart.isLoading) ||
|
||||
(activeDataType === 'balance' && balanceChart.isLoading) ||
|
||||
(activeDataType === 'value' &&
|
||||
(priceChart.isLoading || balanceChart.isLoading))
|
||||
|
||||
if (isLoading) {
|
||||
return <ChartLoading />
|
||||
}
|
||||
|
||||
if (priceChart.error || balanceChart.error) {
|
||||
const hasError =
|
||||
(activeDataType === 'price' && priceChart.error) ||
|
||||
(activeDataType === 'balance' && balanceChart.error) ||
|
||||
(activeDataType === 'value' && (priceChart.error || balanceChart.error))
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -160,6 +175,7 @@ function AssetChart({
|
||||
<Chart
|
||||
price={priceChart.data || []}
|
||||
balance={balanceChart.data || []}
|
||||
value={valueChartData}
|
||||
activeTimeFrame={timeFrame as ChartTimeFrame}
|
||||
activeDataType={activeDataType}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button, SegmentedControl, Tooltip } from '@status-im/components'
|
||||
import {
|
||||
Button,
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from '@status-im/components'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BuyIcon,
|
||||
@@ -25,14 +30,17 @@ import {
|
||||
TokenLogo,
|
||||
TokenSkeleton,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { useCopyToClipboard } from '@status-im/wallet/hooks'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { Interface, parseUnits } from 'ethers'
|
||||
|
||||
import { useEthBalance } from '@/hooks/use-eth-balance'
|
||||
import { renderMarkdown } from '@/lib/markdown'
|
||||
import { apiClient } from '@/providers/api-client'
|
||||
import { usePendingTransactions } from '@/providers/pending-transactions-context'
|
||||
import { useWallet } from '@/providers/wallet-context'
|
||||
|
||||
import { AssetChart } from './asset-chart'
|
||||
@@ -52,6 +60,8 @@ type Props = {
|
||||
|
||||
const NETWORKS = ['ethereum'] as const
|
||||
|
||||
const erc20 = new Interface(['function transfer(address to, uint256 amount)'])
|
||||
|
||||
function matchesAsset(asset: AssetData, ticker: string): boolean {
|
||||
if (ticker.startsWith('0x')) {
|
||||
return (
|
||||
@@ -74,12 +84,21 @@ const Token = (props: Props) => {
|
||||
const [activeTimeFrame, setActiveTimeFrame] =
|
||||
useState<ChartTimeFrame>(DEFAULT_TIME_FRAME)
|
||||
const { currentWallet } = useWallet()
|
||||
const { addPendingTransaction } = usePendingTransactions()
|
||||
const [gasInput, setGasInput] = useState<{
|
||||
to: string
|
||||
value: string
|
||||
} | null>(null)
|
||||
const gasEstimateTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const { data } = useQuery<AssetsResponse>({
|
||||
useEffect(() => {
|
||||
setActiveDataType(DEFAULT_DATA_TYPE)
|
||||
setActiveTimeFrame(DEFAULT_TIME_FRAME)
|
||||
}, [ticker])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const { data, isError: hasErrorFetchingAssets } = useQuery<AssetsResponse>({
|
||||
queryKey: ['assets', address],
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
@@ -112,14 +131,22 @@ const Token = (props: Props) => {
|
||||
enabled: !!address,
|
||||
staleTime: 15 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
|
||||
// Show error toast if fetching assets fails
|
||||
useEffect(() => {
|
||||
if (hasErrorFetchingAssets) {
|
||||
toast.negative(ERROR_MESSAGES.ASSETS_FETCH)
|
||||
}
|
||||
}, [hasErrorFetchingAssets, toast])
|
||||
|
||||
const asset = data?.assets?.find((a: AssetData) => matchesAsset(a, ticker))
|
||||
|
||||
const { data: tokenDetail, isLoading: isTokenLoading } = useQuery<TokenData>({
|
||||
const {
|
||||
data: tokenDetail,
|
||||
isLoading: isTokenLoading,
|
||||
isError: hasErrorFetchingToken,
|
||||
} = useQuery<TokenData>({
|
||||
queryKey: ['token', ticker],
|
||||
queryFn: async () => {
|
||||
const endpoint = ticker.startsWith('0x')
|
||||
@@ -158,11 +185,15 @@ const Token = (props: Props) => {
|
||||
enabled: !!asset,
|
||||
staleTime: 15 * 1000,
|
||||
gcTime: 60 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
|
||||
// Show error toast if fetching token detail fails
|
||||
useEffect(() => {
|
||||
if (hasErrorFetchingToken) {
|
||||
toast.negative(ERROR_MESSAGES.TOKEN_INFO)
|
||||
}
|
||||
}, [hasErrorFetchingToken, toast])
|
||||
|
||||
const isLoading = !data?.assets || isTokenLoading || !tokenDetail
|
||||
|
||||
const needsEthBalance = tokenDetail?.summary.symbol !== 'ETH'
|
||||
@@ -177,8 +208,35 @@ const Token = (props: Props) => {
|
||||
const gasFeeQuery = useQuery({
|
||||
queryKey: ['gas-fees', address, gasInput?.to, gasInput?.value],
|
||||
queryFn: async ({ queryKey }) => {
|
||||
const isNative = tokenDetail?.summary.symbol === 'ETH'
|
||||
const [, from, to, value] = queryKey as [string, string, string, string]
|
||||
|
||||
let params
|
||||
|
||||
if (isNative) {
|
||||
params = {
|
||||
from,
|
||||
to,
|
||||
value,
|
||||
}
|
||||
} else {
|
||||
const contract = tokenDetail?.summary.contracts.ethereum
|
||||
if (!contract) {
|
||||
throw new Error('Contract address not found')
|
||||
}
|
||||
|
||||
const amount = BigInt(value)
|
||||
|
||||
const data = erc20.encodeFunctionData('transfer', [to, amount])
|
||||
|
||||
params = {
|
||||
from,
|
||||
to: contract,
|
||||
value: '0x0',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/nodes.getFeeRate`,
|
||||
)
|
||||
@@ -188,7 +246,7 @@ const Token = (props: Props) => {
|
||||
JSON.stringify({
|
||||
json: {
|
||||
network: 'ethereum',
|
||||
params: { from, to, value },
|
||||
params,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -203,13 +261,31 @@ const Token = (props: Props) => {
|
||||
const body = await response.json()
|
||||
return body.result.data.json
|
||||
},
|
||||
enabled: !!gasInput?.to && !!gasInput?.value && !!address,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!gasInput?.to && !!gasInput?.value && !!address && !!tokenDetail,
|
||||
})
|
||||
|
||||
// Show error toast if fetching gas fees fails
|
||||
useEffect(() => {
|
||||
if (gasFeeQuery.isError) {
|
||||
toast.negative(ERROR_MESSAGES.GAS_FEES_FETCH)
|
||||
}
|
||||
}, [gasFeeQuery.isError, toast])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (gasEstimateTimeoutRef.current) {
|
||||
clearTimeout(gasEstimateTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
const prepareGasEstimate = (to: string, value: string) => {
|
||||
setGasInput({ to, value })
|
||||
if (gasEstimateTimeoutRef.current) {
|
||||
clearTimeout(gasEstimateTimeoutRef.current)
|
||||
}
|
||||
|
||||
gasEstimateTimeoutRef.current = setTimeout(() => {
|
||||
setGasInput({ to, value })
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleOpenTab = (url: string) => {
|
||||
@@ -269,12 +345,13 @@ const Token = (props: Props) => {
|
||||
symbol: tokenDetail.summary.symbol,
|
||||
ethBalance,
|
||||
network: (Object.keys(tokenDetail.assets)[0] ?? 'ethereum') as NetworkType,
|
||||
decimals: asset.decimals ?? 18,
|
||||
}
|
||||
|
||||
// Mock wallet data. Replace with actual wallet data from the user's account.
|
||||
const account: Account = {
|
||||
address,
|
||||
name: 'Account 1',
|
||||
name: currentWallet?.name || 'Account 1',
|
||||
emoji: '🍑',
|
||||
color: 'magenta',
|
||||
}
|
||||
@@ -282,27 +359,117 @@ const Token = (props: Props) => {
|
||||
const signTransaction = async (
|
||||
formData: SendAssetsFormData & { password: string },
|
||||
) => {
|
||||
const amountHex = BigInt(
|
||||
Math.floor(parseFloat(formData.amount) * 1e18),
|
||||
).toString(16)
|
||||
const isNative = tokenDetail?.summary.symbol === 'ETH'
|
||||
|
||||
const result = await apiClient.wallet.account.ethereum.send.mutate({
|
||||
amount: amountHex,
|
||||
toAddress: formData.to,
|
||||
fromAddress: address,
|
||||
password: formData.password,
|
||||
walletId: currentWallet?.id || '',
|
||||
gasLimit: gasFeeQuery.data.txParams.gasLimit.replace(/^0x/, ''),
|
||||
maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas.replace(/^0x/, ''),
|
||||
maxInclusionFeePerGas:
|
||||
gasFeeQuery.data.txParams.maxPriorityFeePerGas.replace(/^0x/, ''),
|
||||
})
|
||||
if (isNative) {
|
||||
const amountHex = parseUnits(formData.amount, 18).toString(16)
|
||||
const params = {
|
||||
amount: amountHex,
|
||||
toAddress: formData.to,
|
||||
fromAddress: address,
|
||||
password: formData.password,
|
||||
walletId: currentWallet?.id || '',
|
||||
gasLimit: gasFeeQuery.data.txParams.gasLimit,
|
||||
maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas,
|
||||
maxInclusionFeePerGas: gasFeeQuery.data.txParams.maxPriorityFeePerGas,
|
||||
}
|
||||
|
||||
if (!result.id || !result.id.txid) {
|
||||
throw new Error('Transaction failed')
|
||||
const result = await apiClient.wallet.account.ethereum.send.mutate(params)
|
||||
if (!result.id || result.id.txid?.error) {
|
||||
console.error(result.id.txid?.error)
|
||||
toast.negative(ERROR_MESSAGES.TX_FAILED)
|
||||
throw new Error('Transaction failed')
|
||||
}
|
||||
|
||||
const txHash = typeof result.id === 'string' ? result.id : result.id.txid
|
||||
|
||||
if (!txHash) {
|
||||
toast.negative(ERROR_MESSAGES.TX_FAILED)
|
||||
throw new Error('Transaction hash not found')
|
||||
}
|
||||
|
||||
addPendingTransaction({
|
||||
hash: txHash,
|
||||
from: address,
|
||||
to: formData.to,
|
||||
value: parseFloat(formData.amount),
|
||||
asset: tokenDetail.summary.symbol,
|
||||
network: 'ethereum',
|
||||
status: 'pending',
|
||||
category: 'external',
|
||||
blockNum: '0',
|
||||
metadata: {
|
||||
blockTimestamp: new Date().toISOString(),
|
||||
},
|
||||
rawContract: {
|
||||
value: amountHex,
|
||||
address: ticker.startsWith('0x') ? ticker : null,
|
||||
decimal: '18',
|
||||
},
|
||||
eurRate: 0,
|
||||
})
|
||||
} else {
|
||||
const tokenDecimals = asset.decimals ?? 18
|
||||
const amount = parseUnits(formData.amount, tokenDecimals)
|
||||
const amountHex = amount.toString(16)
|
||||
const contractAddress = tokenDetail?.summary.contracts.ethereum
|
||||
|
||||
if (!contractAddress) {
|
||||
throw new Error('Token contract address not found')
|
||||
}
|
||||
|
||||
const data = erc20.encodeFunctionData('transfer', [formData.to, amount])
|
||||
|
||||
const params = {
|
||||
amount: '0',
|
||||
toAddress: contractAddress,
|
||||
fromAddress: address,
|
||||
password: formData.password,
|
||||
walletId: currentWallet?.id || '',
|
||||
gasLimit: gasFeeQuery.data.txParams.gasLimit,
|
||||
maxFeePerGas: gasFeeQuery.data.txParams.maxFeePerGas,
|
||||
maxInclusionFeePerGas: gasFeeQuery.data.txParams.maxPriorityFeePerGas,
|
||||
data,
|
||||
}
|
||||
|
||||
const result =
|
||||
await apiClient.wallet.account.ethereum.sendErc20.mutate(params)
|
||||
|
||||
if (!result.id || result.id.txid?.error) {
|
||||
console.error(result.id.txid?.error)
|
||||
toast.negative(ERROR_MESSAGES.TX_FAILED)
|
||||
throw new Error('Transaction failed')
|
||||
}
|
||||
|
||||
const txHash = typeof result.id === 'string' ? result.id : result.id.txid
|
||||
|
||||
if (!txHash) {
|
||||
toast.negative(ERROR_MESSAGES.TX_FAILED)
|
||||
throw new Error('Transaction hash not found')
|
||||
}
|
||||
|
||||
addPendingTransaction({
|
||||
hash: txHash,
|
||||
from: address,
|
||||
to: formData.to,
|
||||
value: parseFloat(formData.amount),
|
||||
asset: tokenDetail.summary.symbol,
|
||||
network: 'ethereum',
|
||||
status: 'pending',
|
||||
category: 'external',
|
||||
blockNum: '0',
|
||||
metadata: {
|
||||
blockTimestamp: new Date().toISOString(),
|
||||
},
|
||||
rawContract: {
|
||||
value: amountHex,
|
||||
address: ticker.startsWith('0x') ? ticker : null,
|
||||
decimal: asset.decimals?.toString() ?? '18',
|
||||
},
|
||||
eurRate: 0,
|
||||
})
|
||||
return result.id.txid
|
||||
}
|
||||
|
||||
return result.id.txid
|
||||
}
|
||||
|
||||
const verifyPassword = async (inputPassword: string): Promise<boolean> => {
|
||||
@@ -332,9 +499,13 @@ const Token = (props: Props) => {
|
||||
}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-1 pt-px">
|
||||
<BuyCryptoDrawer account={account} onOpenTab={handleOpenTab}>
|
||||
<BuyCryptoDrawer
|
||||
account={account}
|
||||
onOpenTab={handleOpenTab}
|
||||
symbol={tokenDetail.summary.symbol}
|
||||
>
|
||||
<Button size="32" iconBefore={<BuyIcon />}>
|
||||
<span className="block max-w-20 truncate">Buy {name}</span>
|
||||
<span className="block max-w-20 truncate">Buy</span>
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
<ReceiveCryptoDrawer account={account} onCopy={copy}>
|
||||
@@ -358,7 +529,7 @@ const Token = (props: Props) => {
|
||||
isLoadingFees={gasFeeQuery.isFetching}
|
||||
onEstimateGas={prepareGasEstimate}
|
||||
>
|
||||
<Button size="32" iconBefore={<SendBlurIcon />}>
|
||||
<Button size="32" iconBefore={<SendBlurIcon />} variant="outline">
|
||||
Send
|
||||
</Button>
|
||||
</SendAssetsModal>
|
||||
@@ -381,9 +552,13 @@ const Token = (props: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<BuyCryptoDrawer account={account} onOpenTab={handleOpenTab}>
|
||||
<BuyCryptoDrawer
|
||||
account={account}
|
||||
onOpenTab={handleOpenTab}
|
||||
symbol={tokenDetail.summary.symbol}
|
||||
>
|
||||
<Button size="32" iconBefore={<BuyIcon />} variant="primary">
|
||||
Buy {name}
|
||||
Buy
|
||||
</Button>
|
||||
</BuyCryptoDrawer>
|
||||
|
||||
@@ -423,7 +598,7 @@ const Token = (props: Props) => {
|
||||
onValueChange={value =>
|
||||
setActiveDataType(value as ChartDataType)
|
||||
}
|
||||
size="24"
|
||||
size="32"
|
||||
>
|
||||
<SegmentedControl.Item value="price">
|
||||
Price
|
||||
@@ -431,6 +606,9 @@ const Token = (props: Props) => {
|
||||
<SegmentedControl.Item value="balance">
|
||||
Balance
|
||||
</SegmentedControl.Item>
|
||||
{/* <SegmentedControl.Item value="value">
|
||||
Value
|
||||
</SegmentedControl.Item> */}
|
||||
</SegmentedControl.Root>
|
||||
</div>
|
||||
<div className="inline-flex">
|
||||
@@ -439,7 +617,7 @@ const Token = (props: Props) => {
|
||||
onValueChange={value =>
|
||||
setActiveTimeFrame(value as ChartTimeFrame)
|
||||
}
|
||||
size="24"
|
||||
size="32"
|
||||
>
|
||||
{TIME_FRAMES.map(frame => (
|
||||
<SegmentedControl.Item key={frame} value={frame}>
|
||||
@@ -524,7 +702,7 @@ const Token = (props: Props) => {
|
||||
tooltip: (
|
||||
<CurrencyAmount
|
||||
value={metadata.all_time_high}
|
||||
format="standard"
|
||||
format="precise"
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -539,17 +717,20 @@ const Token = (props: Props) => {
|
||||
tooltip: (
|
||||
<CurrencyAmount
|
||||
value={metadata.all_time_low}
|
||||
format="standard"
|
||||
format="precise"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: '24h Volume',
|
||||
value: (
|
||||
<TokenAmount value={metadata.volume_24} format="compact" />
|
||||
<CurrencyAmount value={metadata.volume_24} format="compact" />
|
||||
),
|
||||
tooltip: (
|
||||
<TokenAmount value={metadata.volume_24} format="standard" />
|
||||
<CurrencyAmount
|
||||
value={metadata.volume_24}
|
||||
format="standard"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -568,7 +749,7 @@ const Token = (props: Props) => {
|
||||
index < 4 && '2xl:border-b',
|
||||
)}
|
||||
>
|
||||
<Tooltip content={item.tooltip} side="top">
|
||||
<OptionalTooltip content={item.tooltip}>
|
||||
<div>
|
||||
<div className="text-13 font-regular text-neutral-50">
|
||||
{item.label}
|
||||
@@ -577,7 +758,7 @@ const Token = (props: Props) => {
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</OptionalTooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -590,4 +771,21 @@ const Token = (props: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const OptionalTooltip = ({
|
||||
content,
|
||||
children,
|
||||
}: {
|
||||
content?: React.ReactNode
|
||||
children: React.ReactElement
|
||||
}) => {
|
||||
if (!content) {
|
||||
return children
|
||||
}
|
||||
return (
|
||||
<Tooltip content={content} side="top">
|
||||
{children}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export { Token }
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import {
|
||||
AssetsList,
|
||||
AssetsListLoading,
|
||||
FeedbackSection,
|
||||
EmptyStateActions,
|
||||
PinExtension,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import SplittedLayout from '@/components/splitted-layout'
|
||||
@@ -21,15 +25,24 @@ function Component() {
|
||||
const { currentWallet, isLoading: isWalletLoading } = useWallet()
|
||||
const { isPinExtension, handleClose } = usePinExtension()
|
||||
|
||||
const address = currentWallet?.activeAccounts[0].address
|
||||
const toast = useToast()
|
||||
|
||||
const address = currentWallet?.activeAccounts?.[0]?.address
|
||||
|
||||
const router = useRouter()
|
||||
const { data, isLoading } = useAssets({
|
||||
const { data, isLoading, isError } = useAssets({
|
||||
address,
|
||||
isWalletLoading,
|
||||
})
|
||||
const isDesktop = useMediaQuery('xl')
|
||||
|
||||
// Show error toast if there is an error fetching assets
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.negative(ERROR_MESSAGES.ASSETS_FETCH)
|
||||
}
|
||||
}, [isError, toast])
|
||||
|
||||
if (!currentWallet || !address) return null
|
||||
|
||||
return (
|
||||
@@ -39,15 +52,22 @@ function Component() {
|
||||
<AssetsList
|
||||
assets={data?.assets ?? []}
|
||||
onSelect={url => {
|
||||
const ticker = url.split('/').pop()
|
||||
if (!ticker) return
|
||||
router.navigate({
|
||||
to: '/portfolio/assets/$ticker',
|
||||
params: { ticker },
|
||||
...(!isDesktop && {
|
||||
viewTransition: true,
|
||||
}),
|
||||
})
|
||||
try {
|
||||
const ticker = url.split('/').pop()
|
||||
if (!ticker) {
|
||||
console.error('Invalid ticker from URL:', url)
|
||||
return
|
||||
}
|
||||
router.navigate({
|
||||
to: '/portfolio/assets/$ticker',
|
||||
params: { ticker },
|
||||
...(!isDesktop && {
|
||||
viewTransition: true,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error)
|
||||
}
|
||||
}}
|
||||
clearSearch={() => {
|
||||
console.log('Search cleared')
|
||||
@@ -56,12 +76,12 @@ function Component() {
|
||||
pathname="/portfolio/assets"
|
||||
/>
|
||||
}
|
||||
detail={<FeedbackSection />}
|
||||
detail={<EmptyStateActions address={address} />}
|
||||
loadingState={<AssetsListLoading />}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{isPinExtension && (
|
||||
<div className="absolute right-5 top-20">
|
||||
<div className="absolute right-2 top-14 z-20">
|
||||
<PinExtension onClose={handleClose} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Suspense } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import {
|
||||
CollectiblesGrid as CollectiblesList,
|
||||
CollectiblesGridSkeleton,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { createFileRoute, useRouterState } from '@tanstack/react-router'
|
||||
|
||||
import SplittedLayout from '@/components/splitted-layout'
|
||||
import { useCollectibles } from '@/hooks/use-collectibles'
|
||||
|
||||
import { useWallet } from '../../../../../providers/wallet-context'
|
||||
import { Collectible } from '../../-components/collectible'
|
||||
@@ -27,22 +30,53 @@ function Component() {
|
||||
const params = Route.useParams()
|
||||
const { network, contract, id } = params
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
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 {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetchNextPageError,
|
||||
isFetchPreviousPageError,
|
||||
} = useCollectibles({
|
||||
address,
|
||||
isWalletLoading,
|
||||
})
|
||||
|
||||
const collectibles = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
|
||||
}, [data?.pages])
|
||||
|
||||
// Show error toast if there is an error fetching collectibles
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES)
|
||||
}
|
||||
}, [isError, toast])
|
||||
|
||||
// Show error toast if there is an error fetching next page of collectibles
|
||||
useEffect(() => {
|
||||
if (isFetchNextPageError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL)
|
||||
}
|
||||
}, [isFetchNextPageError, toast])
|
||||
|
||||
// Show error toast if there is an error fetching previous page of collectibles
|
||||
useEffect(() => {
|
||||
if (isFetchPreviousPageError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL)
|
||||
}
|
||||
}, [isFetchPreviousPageError, toast])
|
||||
|
||||
if (!currentWallet || !address) {
|
||||
return null
|
||||
}
|
||||
@@ -71,13 +105,11 @@ function Component() {
|
||||
loadingState={<CollectiblesGridSkeleton />}
|
||||
isLoading={isLoading}
|
||||
detail={
|
||||
<Suspense fallback={<p>Loading collectible...</p>}>
|
||||
<Collectible
|
||||
network={network as NetworkType}
|
||||
contract={contract}
|
||||
id={id}
|
||||
/>
|
||||
</Suspense>
|
||||
<Collectible
|
||||
network={network as NetworkType}
|
||||
contract={contract}
|
||||
id={id}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ExternalIcon } from '@status-im/icons/20'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
href?: string
|
||||
}
|
||||
|
||||
const CardDetail = (props: Props) => {
|
||||
const { title, children } = props
|
||||
|
||||
const isLink = Boolean(props.href)
|
||||
|
||||
const Element = isLink ? 'a' : 'div'
|
||||
const elementProps = isLink
|
||||
? {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
}
|
||||
: {}
|
||||
|
||||
return (
|
||||
<Element
|
||||
className="relative flex flex-col rounded-12 border border-neutral-10 bg-neutral-2.5 px-3 py-2"
|
||||
{...elementProps}
|
||||
>
|
||||
<div className="z-10 text-13 font-medium capitalize text-neutral-50">
|
||||
{title}
|
||||
</div>
|
||||
{isLink && (
|
||||
<ExternalIcon className="absolute right-2 top-2 size-4 text-neutral-50" />
|
||||
)}
|
||||
{children}
|
||||
</Element>
|
||||
)
|
||||
}
|
||||
|
||||
export { CardDetail }
|
||||
@@ -1,19 +1,24 @@
|
||||
import { Button } from '@status-im/components'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { Button, useToast } from '@status-im/components'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ExternalIcon,
|
||||
OptionsIcon,
|
||||
// OptionsIcon,
|
||||
SadIcon,
|
||||
} from '@status-im/icons/20'
|
||||
import { OpenseaIcon } from '@status-im/icons/social'
|
||||
import {
|
||||
CollectibleSkeleton,
|
||||
CurrencyAmount,
|
||||
// CurrencyAmount,
|
||||
NetworkLogo,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { CardDetail } from './card-detail'
|
||||
|
||||
import type { NetworkType } from '@status-im/wallet/data'
|
||||
|
||||
type Props = {
|
||||
@@ -25,7 +30,13 @@ type Props = {
|
||||
const Collectible = (props: Props) => {
|
||||
const { network, contract, id } = props
|
||||
|
||||
const { data: collectible, isLoading } = useQuery({
|
||||
const toast = useToast()
|
||||
|
||||
const {
|
||||
data: collectible,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['collectible', network, contract, id],
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
@@ -50,7 +61,7 @@ const Collectible = (props: Props) => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch.')
|
||||
throw new Error(response.statusText, { cause: response.status })
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
@@ -58,20 +69,24 @@ const Collectible = (props: Props) => {
|
||||
},
|
||||
staleTime: 15 * 1000, // 15 seconds
|
||||
gcTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
|
||||
// Show error toast if fetching fails
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLE_INFO)
|
||||
}
|
||||
}, [isError, toast])
|
||||
|
||||
if (isLoading || !collectible) {
|
||||
return <CollectibleSkeleton />
|
||||
}
|
||||
|
||||
const imageUrl = collectible.image || collectible.thumbnail
|
||||
const imageUrl = collectible.thumbnail || collectible.image
|
||||
const imageAlt = collectible.name || 'Collectible image'
|
||||
|
||||
return (
|
||||
<div className="overflow-auto p-4 pr-3 2xl:p-12">
|
||||
<div className="relative flex h-[calc(100vh-56px)] w-full flex-col overflow-auto p-4 pr-3 2xl:p-12">
|
||||
<Link
|
||||
to="/portfolio/collectibles"
|
||||
viewTransition
|
||||
@@ -89,13 +104,13 @@ const Collectible = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 mt-6 2xl:mt-0">
|
||||
<div className="mb-6 2xl:mt-0">
|
||||
<div className="text-27 font-semibold text-neutral-100">
|
||||
{collectible.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{collectible.floor_price && collectible.price_eur && (
|
||||
{/* {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">
|
||||
@@ -113,7 +128,7 @@ const Collectible = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -125,12 +140,12 @@ const Collectible = (props: Props) => {
|
||||
>
|
||||
View on OpenSea
|
||||
</Button>
|
||||
<Button
|
||||
{/* <Button
|
||||
size="32"
|
||||
variant="outline"
|
||||
icon={<OptionsIcon />}
|
||||
aria-label="More options"
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,52 +173,83 @@ const Collectible = (props: Props) => {
|
||||
</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>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(155px,1fr))] gap-3 pb-8">
|
||||
<CardDetail title="Network">
|
||||
<div className="flex items-center gap-1">
|
||||
<NetworkLogo name={collectible.network} size={16} />
|
||||
<div className="capitalize">{collectible.network}</div>
|
||||
</div>
|
||||
</CardDetail>
|
||||
{collectible.standard !== 'NOT_A_CONTRACT' && (
|
||||
<>
|
||||
<div className="font-mono">{collectible.contract}</div>
|
||||
<div>{collectible.standard}</div>
|
||||
<CardDetail
|
||||
title="Contract"
|
||||
href={`https://etherscan.io/address/${collectible.contract}`}
|
||||
>
|
||||
<div className="font-mono">
|
||||
{truncateAddress(collectible.contract)}
|
||||
</div>
|
||||
</CardDetail>
|
||||
<CardDetail title="Token Standard">
|
||||
<div className="font-mono">{collectible.standard}</div>
|
||||
</CardDetail>
|
||||
</>
|
||||
)}
|
||||
{collectible.collection.size && (
|
||||
<div>{collectible.collection.size}</div>
|
||||
<CardDetail title="Collection size">
|
||||
<div>{collectible.collection.size}</div>
|
||||
</CardDetail>
|
||||
)}
|
||||
</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
|
||||
className="h-px w-full border-t border-dashed border-neutral-20 pb-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{collectible.traits && Object.keys(collectible.traits).length > 0 && (
|
||||
<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, unknown>)
|
||||
.filter(([, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const valueObj = value as Record<string, unknown>
|
||||
if ('parent' in valueObj && 'child' in valueObj) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(([trait, value], index) => {
|
||||
let displayValue: string
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
displayValue = JSON.stringify(value)
|
||||
} else {
|
||||
displayValue = String(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<CardDetail key={index} title={trait}>
|
||||
<div className="text-13 font-medium text-neutral-100">
|
||||
{displayValue}
|
||||
</div>
|
||||
</CardDetail>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collectible }
|
||||
|
||||
const truncateAddress = (address: string, chars = 5) =>
|
||||
`${address.slice(0, chars)}...${address.slice(-chars)}`
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { useToast } from '@status-im/components'
|
||||
import {
|
||||
CollectiblesGrid as CollectiblesList,
|
||||
CollectiblesGridSkeleton,
|
||||
EmptyState,
|
||||
FeedbackSection,
|
||||
EmptyStateActions,
|
||||
PinExtension,
|
||||
} from '@status-im/wallet/components'
|
||||
import { ERROR_MESSAGES } from '@status-im/wallet/constants'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import SplittedLayout from '@/components/splitted-layout'
|
||||
@@ -24,22 +26,53 @@ function Component() {
|
||||
const { currentWallet, isLoading: isWalletLoading } = useWallet()
|
||||
const { isPinExtension, handleClose } = usePinExtension()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const search = searchParams.get('search') ?? undefined
|
||||
|
||||
const pathname = window.location.pathname
|
||||
const address = currentWallet?.activeAccounts[0].address
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, isLoading, hasNextPage } =
|
||||
useCollectibles({
|
||||
address,
|
||||
isWalletLoading,
|
||||
})
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetchNextPageError,
|
||||
isFetchPreviousPageError,
|
||||
} = useCollectibles({
|
||||
address,
|
||||
isWalletLoading,
|
||||
})
|
||||
|
||||
const collectibles = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
|
||||
}, [data?.pages])
|
||||
|
||||
// Show error toast if there is an error fetching collectibles
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES)
|
||||
}
|
||||
}, [isError, toast])
|
||||
|
||||
// Show error toast if there is an error fetching next page of collectibles
|
||||
useEffect(() => {
|
||||
if (isFetchNextPageError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL)
|
||||
}
|
||||
}, [isFetchNextPageError, toast])
|
||||
|
||||
// Show error toast if there is an error fetching previous page of collectibles
|
||||
useEffect(() => {
|
||||
if (isFetchPreviousPageError) {
|
||||
toast.negative(ERROR_MESSAGES.COLLECTIBLES_PARTIAL)
|
||||
}
|
||||
}, [isFetchPreviousPageError, toast])
|
||||
|
||||
if (!currentWallet || !address) {
|
||||
return null
|
||||
}
|
||||
@@ -69,11 +102,11 @@ function Component() {
|
||||
)
|
||||
}
|
||||
loadingState={<CollectiblesGridSkeleton />}
|
||||
detail={<FeedbackSection />}
|
||||
detail={<EmptyStateActions address={address} />}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
{isPinExtension && (
|
||||
<div className="absolute right-5 top-20">
|
||||
<div className="absolute right-2 top-14 z-20">
|
||||
<PinExtension onClose={handleClose} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,25 @@
|
||||
"jsx": "react-jsx",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleResolution": "bundler",
|
||||
// "paths": {
|
||||
// "@portfolio/*": ["../portfolio/src/*"],
|
||||
// // "@/*": ["../portfolio/src/*"],
|
||||
// "@status-im/wallet/*": ["../../packages/wallet/src/*"],
|
||||
// },
|
||||
// "types": ["vite/client"],
|
||||
},
|
||||
"include": ["./**/*", "./.wxt/wxt.d.ts", "global.d.ts", "env.d.ts"],
|
||||
"exclude": ["node_modules", ".output"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../packages/wallet",
|
||||
},
|
||||
{
|
||||
"path": "../../packages/colors",
|
||||
},
|
||||
{
|
||||
"path": "../../packages/components",
|
||||
},
|
||||
{
|
||||
"path": "../../packages/icons",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -7,19 +7,27 @@ import { defineConfig } from 'wxt'
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
imports: false,
|
||||
srcDir: 'src',
|
||||
// extensionApi: 'chrome',
|
||||
// extensionApi: 'webextension-polyfill',
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
modules: ['@wxt-dev/module-react', '@wxt-dev/auto-icons'],
|
||||
manifestVersion: 3,
|
||||
manifest: ({ mode }) => {
|
||||
const scriptSrc =
|
||||
mode === 'production'
|
||||
? "'self' 'wasm-unsafe-eval'"
|
||||
: "'self' 'wasm-unsafe-eval' http://localhost:4000/ http://localhost:8097/"
|
||||
const connectSrc =
|
||||
mode === 'production'
|
||||
? 'https://status-api-status-im-web.vercel.app/ https://status-api-status-im-web.vercel.app/api/'
|
||||
: 'ws: http://localhost:3030/ https://localhost:3030/'
|
||||
|
||||
return {
|
||||
name: 'Status Portfolio Wallet',
|
||||
version: '0.1.0',
|
||||
name: '!Status Portfolio Wallet (Beta)',
|
||||
description:
|
||||
'Easily view and manage your crypto portfolio in real time — Beta crypto wallet and Web3 portfolio tracker in one.',
|
||||
permissions: ['storage'],
|
||||
action: {},
|
||||
web_accessible_resources: [
|
||||
@@ -30,7 +38,7 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
content_security_policy: {
|
||||
extension_pages: `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ${connectSrc}`,
|
||||
extension_pages: `script-src ${scriptSrc}; object-src 'self'; connect-src 'self' ${connectSrc}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
+5
-3
@@ -26,7 +26,8 @@
|
||||
"typecheck": "turbo run typecheck",
|
||||
"format": "prettier --write .",
|
||||
"clean": "turbo run clean && rimraf node_modules .turbo",
|
||||
"storybook": "pnpm --filter @status-im/components storybook"
|
||||
"storybook": "pnpm --filter=@status-im/components storybook",
|
||||
"devtools": "react-devtools"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "19.1.0",
|
||||
@@ -41,14 +42,15 @@
|
||||
"@status-im/eslint-config": "workspace:*",
|
||||
"@tsconfig/strictest": "^2.0.0",
|
||||
"@types/prettier": "^2.7.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.1",
|
||||
"eslint": "^9.14.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-devtools": "^6.1.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"rimraf": "^4.4.1",
|
||||
"turbo": "^2.3.0",
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"source": "src/index.ts",
|
||||
"main": "./dist/index.cjs.js",
|
||||
"module": "./dist/index.es.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"source": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -27,8 +27,7 @@
|
||||
"sync": "rimraf src && vite-node scripts/sync.ts",
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build:types": "tsc --noEmit false --emitDeclarationOnly",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm build:types",
|
||||
"build": "vite build && pnpm build:types",
|
||||
"#test": "vitest",
|
||||
"typecheck": "tsc",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"include": ["./src"],
|
||||
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
}
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"noEmit": false
|
||||
},
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm build:types",
|
||||
"build": "vite build && pnpm build:types",
|
||||
"build:types": "tsc --noEmit false --emitDeclarationOnly",
|
||||
"typecheck": "tsc",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -44,7 +44,19 @@ function Button(
|
||||
|
||||
const { link } = useConfig()
|
||||
|
||||
const Element = props.href ? link : 'button'
|
||||
const isExternal =
|
||||
typeof props.href === 'string' && props.href.startsWith('http')
|
||||
const Element = isExternal ? 'a' : props.href ? link : 'button'
|
||||
|
||||
const linkProps = props.href
|
||||
? {
|
||||
href: props.href,
|
||||
...(isExternal && {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
}),
|
||||
}
|
||||
: {}
|
||||
|
||||
if ('icon' in rest) {
|
||||
const { icon, ...buttonProps } = rest
|
||||
@@ -52,6 +64,7 @@ function Button(
|
||||
<Element
|
||||
onClick={onClick}
|
||||
{...buttonProps}
|
||||
{...linkProps}
|
||||
ref={ref}
|
||||
className={styles({ variant, size, iconOnly: true })}
|
||||
>
|
||||
@@ -68,6 +81,7 @@ function Button(
|
||||
<Element
|
||||
onClick={onClick}
|
||||
{...buttonProps}
|
||||
{...linkProps}
|
||||
ref={ref}
|
||||
className={styles({ variant, size })}
|
||||
>
|
||||
|
||||
@@ -102,7 +102,7 @@ const useToast = () => {
|
||||
custom: store.custom,
|
||||
dismiss: store.dismiss,
|
||||
}),
|
||||
[store],
|
||||
[store.positive, store.negative, store.custom, store.dismiss],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cloneElement, forwardRef } from 'react'
|
||||
|
||||
import { Action, Description } from '@radix-ui/react-toast'
|
||||
import { Action, Close, Description } from '@radix-ui/react-toast'
|
||||
import { CloseIcon } from '@status-im/icons/16'
|
||||
import { CorrectIcon, IncorrectIcon } from '@status-im/icons/20'
|
||||
import { match, P } from 'ts-pattern'
|
||||
|
||||
@@ -20,9 +21,9 @@ const Toast = (props: Props, ref: React.Ref<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex min-h-[40px] w-[351px] flex-row items-center justify-between gap-3 rounded-12 bg-neutral-80/70 p-2 pr-3 backdrop-blur-[20px]"
|
||||
className="flex min-h-[40px] w-[351px] flex-row items-start justify-between gap-3 rounded-12 bg-neutral-80/70 p-[10px] pr-3 backdrop-blur-[20px]"
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center gap-1">
|
||||
<div className="flex flex-1 flex-row items-start gap-1">
|
||||
<div className="shrink-0">
|
||||
{match(props)
|
||||
.with({ type: 'positive' }, () => <CorrectIcon />)
|
||||
@@ -45,6 +46,9 @@ const Toast = (props: Props, ref: React.Ref<HTMLDivElement>) => {
|
||||
</Action>
|
||||
</div>
|
||||
)}
|
||||
<Close>
|
||||
<CloseIcon className="cursor-pointer text-white-100" />
|
||||
</Close>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,9 +144,32 @@ export default {
|
||||
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||
'50%': { backgroundPosition: '100% 50%' },
|
||||
},
|
||||
'toast-slide-in': {
|
||||
from: {
|
||||
transform: 'translateX(calc(100% + var(--viewport-padding)))',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
},
|
||||
'toast-hide': {
|
||||
from: { opacity: '1' },
|
||||
to: { opacity: '0' },
|
||||
},
|
||||
'toast-swipe-out': {
|
||||
from: {
|
||||
transform: 'translateX(var(--radix-toast-swipe-end-x))',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateX(calc(100% + var(--viewport-padding)))',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
skeleton: 'skeleton 1.5s ease infinite',
|
||||
'toast-slide-in': 'toast-slide-in 300ms cubic-bezier(0.16,1,0.3,1)',
|
||||
'toast-hide': 'toast-hide 200ms ease-in',
|
||||
'toast-swipe-out': 'toast-swipe-out 200ms ease-out',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
"extends": "../../tsconfig.base",
|
||||
"include": ["src", "tailwind.config.ts", ".storybook"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "preserve",
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"noEmit": false
|
||||
},
|
||||
"references": []
|
||||
"references": [
|
||||
{
|
||||
"path": "../icons"
|
||||
},
|
||||
{
|
||||
"path": "../colors"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"ignoreCommand": "git diff --quiet HEAD^ HEAD ../../{patches,package.json,turbo.json} ../../packages/{colors,icons} ./",
|
||||
"installCommand": "pnpm install --dir ../../ --frozen-lockfile",
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=components... && pnpm storybook:build"
|
||||
"buildCommand": "turbo run build --cwd ../../ --filter=@status-im/components... && pnpm storybook:build"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"dist",
|
||||
"svg"
|
||||
],
|
||||
"main": "./dist/index.cjs.js",
|
||||
"module": "./dist/index.es.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"source": "./index.js",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
@@ -13,27 +17,27 @@
|
||||
"require": "./index.js"
|
||||
},
|
||||
"./12": {
|
||||
"types": "./dist/12/index.d.ts",
|
||||
"types": "./dist/src/12/index.d.ts",
|
||||
"import": "./dist/12/index.es.js",
|
||||
"require": "./dist/12/index.cjs.js"
|
||||
},
|
||||
"./16": {
|
||||
"types": "./dist/16/index.d.ts",
|
||||
"types": "./dist/src/16/index.d.ts",
|
||||
"import": "./dist/16/index.es.js",
|
||||
"require": "./dist/16/index.cjs.js"
|
||||
},
|
||||
"./20": {
|
||||
"types": "./dist/20/index.d.ts",
|
||||
"types": "./dist/src/20/index.d.ts",
|
||||
"import": "./dist/20/index.es.js",
|
||||
"require": "./dist/20/index.cjs.js"
|
||||
},
|
||||
"./social": {
|
||||
"types": "./dist/social/index.d.ts",
|
||||
"types": "./dist/src/social/index.d.ts",
|
||||
"import": "./dist/social/index.es.js",
|
||||
"require": "./dist/social/index.cjs.js"
|
||||
},
|
||||
"./reactions": {
|
||||
"types": "./dist/reactions/index.d.ts",
|
||||
"types": "./dist/src/reactions/index.d.ts",
|
||||
"import": "./dist/reactions/index.es.js",
|
||||
"require": "./dist/reactions/index.cjs.js"
|
||||
},
|
||||
@@ -61,9 +65,8 @@
|
||||
"sync": "vite-node scripts/sync.ts && prettier --parser=html --write 'svg/**/*.svg'",
|
||||
"generate": "rimraf src && svgr --no-prettier svg --out-dir src && pnpm lint:fix && pnpm format",
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm build:types",
|
||||
"build:types": "tsc src/**/index.ts --emitDeclarationOnly --declaration --jsx react-jsx --skipLibCheck --declarationDir ./dist",
|
||||
"build": "vite build && pnpm build:types",
|
||||
"build:types": "tsc --emitDeclarationOnly --declaration --jsx react-jsx --skipLibCheck --declarationDir ./dist",
|
||||
"#test": "vitest",
|
||||
"typecheck": "tsc",
|
||||
"lint": "eslint src",
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["./src"]
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist",
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"noEmit": false
|
||||
},
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@
|
||||
"test": "vitest run",
|
||||
"test:update": "vitest run --update",
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm build:types",
|
||||
"build": "vite build && pnpm build:types",
|
||||
"build:types": "tsc --noEmit false --emitDeclarationOnly || true",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc",
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"declarationDir": "./dist/types",
|
||||
"noEmit": false,
|
||||
|
||||
// todo: turn on?
|
||||
"noImplicitReturns": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
},
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
"types": "./dist/src/constants/index.d.ts",
|
||||
"import": "./dist/constants/index.es.js",
|
||||
"require": "./dist/constants/index.cjs.js"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/src/utils/index.d.ts",
|
||||
"import": "./dist/utils/index.es.js",
|
||||
"require": "./dist/utils/index.cjs.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -47,14 +52,13 @@
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm build:types",
|
||||
"build": "vite build && pnpm build:types",
|
||||
"build:types": "tsc --noEmit false --emitDeclarationOnly",
|
||||
"typecheck": "tsc",
|
||||
"env:check": "node -r dotenv/config ./src/config/env.server.mjs dotenv_config_path=./.env && :",
|
||||
"lint": "eslint src",
|
||||
"format": "prettier --write .",
|
||||
"sync:cloudinary": "DOTENV_CONFIG_PATH='.env.local' node -r dotenv/config scripts/generate-cloudinary-type.mjs && prettier -w 'src/image/types.ts'",
|
||||
"sync:cloudinary": "DOTENV_CONFIG_PATH='.env.local' node -r dotenv/config scripts/generate-cloudinary-type.mjs && prettier -w 'src/components/image/types.ts'",
|
||||
"sync:tokens:erc20": "DOTENV_CONFIG_PATH='.env.local' node -r dotenv/config scripts/generate-erc20-token-list.mjs && prettier -w 'src/constants/erc20.json'",
|
||||
"clean": "rimraf node_modules dist .turbo",
|
||||
"prepack": "pnpm build"
|
||||
|
||||
@@ -60,7 +60,7 @@ const [images, videos, zipFiles] = await Promise.all(
|
||||
)
|
||||
|
||||
fs.writeFile(
|
||||
'src/image/types.ts',
|
||||
'src/components/image/types.ts',
|
||||
`
|
||||
export type ImageType =
|
||||
${images
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import { keccak256 } from 'ethereum-cryptography/keccak'
|
||||
import { utf8ToBytes, bytesToHex } from 'ethereum-cryptography/utils'
|
||||
import { toChecksumAddress } from '../src/utils/to-checksum-address.ts'
|
||||
|
||||
const supportedNetworks = [
|
||||
42161, // Arbitrum
|
||||
@@ -61,22 +60,6 @@ const statusTokenLists = [
|
||||
|
||||
const outputFilePath = './src/constants/erc20.json'
|
||||
|
||||
function toChecksumAddress(address) {
|
||||
address = address.toLowerCase().replace('0x', '')
|
||||
const hash = bytesToHex(keccak256(utf8ToBytes(address)))
|
||||
let checksumAddress = '0x'
|
||||
|
||||
for (let i = 0; i < address.length; i++) {
|
||||
if (parseInt(hash[i], 16) >= 8) {
|
||||
checksumAddress += address[i].toUpperCase()
|
||||
} else {
|
||||
checksumAddress += address[i]
|
||||
}
|
||||
}
|
||||
|
||||
return checksumAddress
|
||||
}
|
||||
|
||||
async function fetchStandardTokenList(url) {
|
||||
try {
|
||||
console.log(`Fetching token list from: ${url}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ContextTag } from '@status-im/components'
|
||||
import { ExternalIcon } from '@status-im/icons/20'
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
import { getTransactionHash } from '../../../utils/transaction-hash'
|
||||
import { CurrencyAmount } from '../../currency-amount'
|
||||
import { NetworkLogo } from '../../network-logo'
|
||||
import { RelativeDate } from '../../relative-date'
|
||||
@@ -42,6 +43,7 @@ function getTokenActivityLabel(activity: Activity): string {
|
||||
const ActivityItem = (props: ActivityItemProps) => {
|
||||
const { activity, userAddress } = props
|
||||
|
||||
const transactionHash = getTransactionHash(activity.hash)
|
||||
const outgoingTransaction =
|
||||
activity.from.toLowerCase() === userAddress.toLowerCase()
|
||||
const assetSymbol =
|
||||
@@ -50,7 +52,7 @@ const ActivityItem = (props: ActivityItemProps) => {
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://etherscan.io/tx/${activity.hash}`}
|
||||
href={`https://etherscan.io/tx/${transactionHash}`}
|
||||
target="_blank"
|
||||
className="grid grid-cols-[2fr_1fr_1fr] gap-4 rounded-12 p-3 transition-colors focus-within:bg-neutral-5 hover:bg-neutral-5"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useInfiniteLoading } from '../../hooks/use-infinite-loading'
|
||||
import { ActivityItem } from './components/activity-item'
|
||||
|
||||
@@ -13,11 +15,27 @@ export type ActivityListProps = {
|
||||
onLoadMore: () => void
|
||||
hasNextPage: boolean
|
||||
isLoadingMore: boolean
|
||||
pendingTransactions?: (Activity & { status: 'pending' })[]
|
||||
}
|
||||
|
||||
const sortActivitiesByTime = (activities: Activity[]) => {
|
||||
return activities.sort(
|
||||
(a, b) =>
|
||||
new Date(b.metadata.blockTimestamp).getTime() -
|
||||
new Date(a.metadata.blockTimestamp).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
const ActivityList = (props: ActivityListProps) => {
|
||||
const { activities, userAddress, onLoadMore, hasNextPage, isLoadingMore } =
|
||||
props
|
||||
const {
|
||||
activities,
|
||||
userAddress,
|
||||
onLoadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
pendingTransactions = [],
|
||||
} = props
|
||||
const [allActivities, setAllActivities] = useState<Activity[]>([])
|
||||
|
||||
const { endOfPageRef, isLoading } = useInfiniteLoading({
|
||||
rootMargin: '200px',
|
||||
@@ -26,11 +44,16 @@ const ActivityList = (props: ActivityListProps) => {
|
||||
hasNextPage: hasNextPage,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const combined = [...pendingTransactions, ...activities]
|
||||
setAllActivities(sortActivitiesByTime(combined))
|
||||
}, [activities, pendingTransactions])
|
||||
|
||||
return (
|
||||
<div className="pb-10">
|
||||
<div className="flex min-h-[calc(100svh-362px)] w-full overflow-auto">
|
||||
<div className="w-full">
|
||||
{activities.map(activity => {
|
||||
{allActivities.map(activity => {
|
||||
return (
|
||||
<ActivityItem
|
||||
key={activity.uniqueId}
|
||||
@@ -46,7 +69,7 @@ const ActivityList = (props: ActivityListProps) => {
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-13 font-400 text-neutral-50">
|
||||
Loading more activities...
|
||||
Loading more history...
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-1" />
|
||||
@@ -56,7 +79,7 @@ const ActivityList = (props: ActivityListProps) => {
|
||||
|
||||
{!hasNextPage && activities.length > 0 && (
|
||||
<div className="py-8 text-center text-13 font-400 text-neutral-40">
|
||||
No more activities to load
|
||||
No more history to load
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,16 +6,16 @@ const AssetsListLoading = () => {
|
||||
return (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-label="Loading assets"
|
||||
aria-label="Loading tokens"
|
||||
className="min-h-[calc(100svh-362px)]"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.HeaderCell>Assets</Table.HeaderCell>
|
||||
<Table.HeaderCell>Tokens</Table.HeaderCell>
|
||||
<Table.HeaderCell>Price</Table.HeaderCell>
|
||||
<Table.HeaderCell>24H%</Table.HeaderCell>
|
||||
<Table.HeaderCell>Balance</Table.HeaderCell>
|
||||
<Table.HeaderCell>Total</Table.HeaderCell>
|
||||
<Table.HeaderCell>Value</Table.HeaderCell>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{Array.from({ length: 12 }).map((_, index) => {
|
||||
|
||||
@@ -78,11 +78,11 @@ const AssetsList = (props: Props) => {
|
||||
{filteredAssets.length !== 0 && (
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.HeaderCell>Assets</Table.HeaderCell>
|
||||
<Table.HeaderCell>Tokens</Table.HeaderCell>
|
||||
<Table.HeaderCell>Price</Table.HeaderCell>
|
||||
<Table.HeaderCell>24H%</Table.HeaderCell>
|
||||
<Table.HeaderCell>Balance</Table.HeaderCell>
|
||||
<Table.HeaderCell>Total</Table.HeaderCell>
|
||||
<Table.HeaderCell>Value</Table.HeaderCell>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{filteredAssets.map(asset => {
|
||||
@@ -141,10 +141,10 @@ const AssetsList = (props: Props) => {
|
||||
{filteredAssets.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center py-8">
|
||||
<h2 className="pt-[68px] text-15 font-semibold text-neutral-100 first-line:mb-0.5">
|
||||
No assets found
|
||||
No tokens found
|
||||
</h2>
|
||||
<p className="mb-5 text-13 font-regular text-neutral-100">
|
||||
We didn't find any assets that match your search
|
||||
We didn't find any tokens that match your search
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cva } from 'class-variance-authority'
|
||||
|
||||
import { CurrencyAmount } from '../currency-amount'
|
||||
import { PercentageChange } from '../percentage-change'
|
||||
import { TokenAmount } from '../token-amount'
|
||||
|
||||
import type { ApiOutput } from '@status-im/wallet/data'
|
||||
|
||||
@@ -35,9 +36,11 @@ export const Balance = (props: Props) => {
|
||||
/>
|
||||
|
||||
{variant === 'token' && (
|
||||
<div className="self-end text-15 font-medium text-neutral-50">
|
||||
{summary.total_balance}
|
||||
</div>
|
||||
<TokenAmount
|
||||
className="self-end text-15 font-medium text-neutral-50"
|
||||
value={summary.total_balance}
|
||||
format="precise"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Avatar, useToast } from '@status-im/components'
|
||||
import { FeesIcon } from '@status-im/icons/12'
|
||||
import { ExternalIcon } from '@status-im/icons/20'
|
||||
|
||||
import { ERROR_MESSAGES } from '../../constants'
|
||||
import * as Drawer from '../drawer'
|
||||
import { type ImageId } from '../image'
|
||||
import { Image, type ImageId } from '../image'
|
||||
|
||||
import type { Account } from '../address'
|
||||
|
||||
@@ -42,7 +43,7 @@ export type BuyCryptoDrawerProps = {
|
||||
name: string
|
||||
description: string
|
||||
fee: string
|
||||
image: string
|
||||
image: ImageId
|
||||
}>
|
||||
onProviderSelect?: (
|
||||
provider: Provider,
|
||||
@@ -50,6 +51,7 @@ export type BuyCryptoDrawerProps = {
|
||||
asset?: string,
|
||||
) => Promise<{ url: string }>
|
||||
onOpenTab?: (url: string) => void | Promise<void>
|
||||
symbol?: string
|
||||
}
|
||||
|
||||
type Props = BuyCryptoDrawerProps
|
||||
@@ -61,29 +63,30 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
providers = [
|
||||
{
|
||||
name: 'mercuryo',
|
||||
description: 'Buy crypto within 15 seconds',
|
||||
fee: '4.5%',
|
||||
image: '/images/providers/mercuryo.png',
|
||||
// description: 'Buy crypto within 15 seconds',
|
||||
fee: '1% - 4.5%',
|
||||
image: 'Wallet/Icons/Logos/mercuryo:64:64',
|
||||
},
|
||||
{
|
||||
name: 'moonpay',
|
||||
description: 'The new standard for fiat to crypto',
|
||||
// description: 'The new standard for fiat to crypto',
|
||||
fee: '1% - 4.5%',
|
||||
image: '/images/providers/moonpay.png',
|
||||
image: 'Wallet/Icons/Logos/moonpay:64:64',
|
||||
},
|
||||
],
|
||||
onProviderSelect,
|
||||
onOpenTab,
|
||||
symbol,
|
||||
} = props
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const network: NetworkOptions[0] = NETWORKS[0]
|
||||
const currency: Currency = {
|
||||
contract_address: '',
|
||||
code: 'EUR',
|
||||
label: 'Euro',
|
||||
code: symbol || 'ETH',
|
||||
label: symbol || 'Ethereum',
|
||||
network: 'ETHEREUM',
|
||||
imageUrl: '/images/tokens/eur.png',
|
||||
imageUrl: '/images/tokens/usd.png',
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
@@ -105,7 +108,7 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
|
||||
url = providerUrls[provider]
|
||||
if (!url) {
|
||||
toast.negative('Provider not supported')
|
||||
toast.negative(ERROR_MESSAGES.PROVIDER_NOT_SUPPORTED)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -116,15 +119,11 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
const newTab = window.open(url, '_blank', 'noopener,noreferrer')
|
||||
|
||||
if (!newTab) {
|
||||
toast.negative(
|
||||
'Unable to open a new tab. Please check your browser settings.',
|
||||
)
|
||||
toast.negative(ERROR_MESSAGES.NEW_TAB)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.negative(
|
||||
'Unable to open a new tab. Please check your browser settings.',
|
||||
)
|
||||
toast.negative(ERROR_MESSAGES.NEW_TAB)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +139,7 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
|
||||
<Drawer.Content className="p-3">
|
||||
<Drawer.Header className="sticky top-1 flex flex-col gap-2 bg-white-60 backdrop-blur-[20px]">
|
||||
<Drawer.Title className="!pb-0 pl-1">Buy crypto</Drawer.Title>
|
||||
<Drawer.Title className="!pb-0 pl-1">Ways to buy tokens</Drawer.Title>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -177,11 +176,9 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={provider.image}
|
||||
<Image
|
||||
id={provider.image}
|
||||
alt={provider.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 rounded-full"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
@@ -189,9 +186,9 @@ export const BuyCryptoDrawer = (props: Props) => {
|
||||
<div className="text-15 font-600 capitalize">
|
||||
{provider.name}
|
||||
</div>
|
||||
<div className="text-13 text-neutral-50">
|
||||
{/* <div className="text-13 text-neutral-50">
|
||||
{provider.description}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { animated, config, useSpring } from '@react-spring/web'
|
||||
import { curveCatmullRom } from '@visx/curve'
|
||||
import { LinearGradient } from '@visx/gradient'
|
||||
import { AreaClosed, LinePath } from '@visx/shape'
|
||||
|
||||
import { negativeColors, positiveColors } from '../constants'
|
||||
import { formatSmallNumber } from '../utils'
|
||||
import { formatChartValue } from '../utils'
|
||||
|
||||
import type { BaseChartProps, ChartDatum } from '../utils'
|
||||
import type { ScaleLinear, ScaleTime } from 'd3-scale'
|
||||
@@ -12,9 +11,8 @@ import type { ScaleLinear, ScaleTime } from 'd3-scale'
|
||||
type Props = BaseChartProps & {
|
||||
yScale: ScaleLinear<number, number, never>
|
||||
xScale: ScaleTime<number, number, never>
|
||||
innerHeight: number
|
||||
innerWidth: number
|
||||
pricesData: ChartDatum[]
|
||||
currency?: string
|
||||
}
|
||||
|
||||
const AnimatedAreaClosed = animated(AreaClosed)
|
||||
@@ -25,9 +23,8 @@ const Content = (props: Props) => {
|
||||
pricesData,
|
||||
xScale,
|
||||
yScale,
|
||||
innerHeight,
|
||||
innerWidth,
|
||||
isPositive = true,
|
||||
dataType = 'price',
|
||||
} = props
|
||||
|
||||
const colors = isPositive ? positiveColors : negativeColors
|
||||
@@ -43,8 +40,10 @@ const Content = (props: Props) => {
|
||||
|
||||
const tagX = xScale(lastData.date)
|
||||
const tagY = yScale(lastData.value)
|
||||
const formattedValue = formatSmallNumber(lastData.value)
|
||||
const formattedValue = formatChartValue(lastData.value, dataType)
|
||||
const tagWidth = Math.min(40 + formattedValue.length * 4, 80)
|
||||
const tagXOffset = Math.max(0, 20 - tagWidth / 2)
|
||||
const textX = tagXOffset + tagWidth / 2
|
||||
|
||||
return (
|
||||
<g>
|
||||
@@ -62,20 +61,6 @@ const Content = (props: Props) => {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<LinearGradient
|
||||
id="gradient-columns"
|
||||
from="rgba(255, 255, 255, 0)"
|
||||
to="rgba(255, 255, 255, 1)"
|
||||
fromOpacity={0}
|
||||
toOpacity={1}
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={innerWidth}
|
||||
height={innerHeight}
|
||||
fill="url(#gradient-columns)"
|
||||
/>
|
||||
|
||||
<AnimatedLinePath
|
||||
data={pricesData}
|
||||
@@ -106,7 +91,7 @@ const Content = (props: Props) => {
|
||||
|
||||
<g transform={`translate(${tagX}, ${tagY})`}>
|
||||
<rect
|
||||
x={20 - tagWidth / 2}
|
||||
x={tagXOffset}
|
||||
y={-10}
|
||||
width={tagWidth}
|
||||
height={20}
|
||||
@@ -114,7 +99,7 @@ const Content = (props: Props) => {
|
||||
rx={6}
|
||||
/>
|
||||
<text
|
||||
x={20}
|
||||
x={textX}
|
||||
y={5}
|
||||
fill={colors.white}
|
||||
fontSize={12}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { scaleLinear, scaleTime } from '@visx/scale'
|
||||
import { extent } from 'd3-array'
|
||||
|
||||
import { useTokenChartTooltip } from '../hooks/use-token-chart-tooltip'
|
||||
import { calculateChartRange } from '../utils'
|
||||
import { calculateChartRange, getChartValue } from '../utils'
|
||||
import { Content } from './content'
|
||||
import { Marker } from './marker'
|
||||
import { ChartTooltip } from './tooltip'
|
||||
@@ -14,21 +14,24 @@ import { XAxis } from './x-axis'
|
||||
import { YAxis } from './y-axis'
|
||||
|
||||
import type { BaseChartProps, ChartDataPoint, TimeFrame } from '../utils'
|
||||
import type React from 'react'
|
||||
|
||||
type TokenChartProps = BaseChartProps & {
|
||||
data: ChartDataPoint[]
|
||||
width: number
|
||||
timeFrame?: TimeFrame
|
||||
emptyState: React.ReactNode
|
||||
}
|
||||
|
||||
const TokenChart = ({
|
||||
data,
|
||||
currency,
|
||||
width,
|
||||
dataType,
|
||||
dataType = 'price',
|
||||
timeFrame,
|
||||
emptyState,
|
||||
}: TokenChartProps) => {
|
||||
const innerWidth = width - 60
|
||||
const innerWidth = Math.max(100, width - 70)
|
||||
const innerHeight = 256
|
||||
|
||||
const sortedData = useMemo(
|
||||
@@ -50,7 +53,7 @@ const TokenChart = ({
|
||||
nice: false,
|
||||
})
|
||||
|
||||
const { min, max, ticks } = calculateChartRange(sortedData)
|
||||
const { min, max, ticks } = calculateChartRange(sortedData, 0.1, dataType)
|
||||
const yScale = scaleLinear({
|
||||
domain: [min, max],
|
||||
range: [innerHeight, 0],
|
||||
@@ -58,14 +61,20 @@ const TokenChart = ({
|
||||
})
|
||||
|
||||
const priceData = useMemo(
|
||||
() => sortedData.map(d => ({ date: new Date(d.date), value: d.price })),
|
||||
[sortedData],
|
||||
() =>
|
||||
sortedData.map(d => ({
|
||||
date: new Date(d.date),
|
||||
value: getChartValue(d, dataType),
|
||||
})),
|
||||
[sortedData, dataType],
|
||||
)
|
||||
|
||||
const isPositive = useMemo(() => {
|
||||
if (dataType === 'balance') return true
|
||||
if (sortedData.length === 0) return true
|
||||
return sortedData[sortedData.length - 1].price >= sortedData[0].price
|
||||
const lastValue = getChartValue(sortedData[sortedData.length - 1], dataType)
|
||||
const firstValue = getChartValue(sortedData[0], dataType)
|
||||
return lastValue >= firstValue
|
||||
}, [sortedData, dataType])
|
||||
|
||||
const { tooltipData, updateTooltip, hideTooltip, tooltipOpen } =
|
||||
@@ -78,7 +87,7 @@ const TokenChart = ({
|
||||
|
||||
const circleSpring = useSpring({
|
||||
x: xScale(new Date(tooltipData?.date)),
|
||||
y: yScale(tooltipData?.price),
|
||||
y: yScale(tooltipData ? getChartValue(tooltipData, dataType) : 0),
|
||||
config: config.gentle,
|
||||
})
|
||||
|
||||
@@ -90,16 +99,12 @@ const TokenChart = ({
|
||||
|
||||
const tooltipAnimation = useSpring({
|
||||
x: xScale(new Date(tooltipData?.date)),
|
||||
y: yScale(tooltipData?.price),
|
||||
y: yScale(tooltipData ? getChartValue(tooltipData, dataType) : 0),
|
||||
config: config.gentle,
|
||||
})
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="relative mt-4 flex h-[296px] w-full items-center justify-center">
|
||||
<p className="text-15 font-600 text-neutral-50">No data available</p>
|
||||
</div>
|
||||
)
|
||||
return <div className="mt-[72px]">{emptyState}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -118,9 +123,9 @@ const TokenChart = ({
|
||||
pricesData={priceData}
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
innerHeight={innerHeight}
|
||||
innerWidth={innerWidth}
|
||||
isPositive={isPositive}
|
||||
dataType={dataType}
|
||||
currency={currency}
|
||||
/>
|
||||
|
||||
{tooltipOpen && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { animated } from '@react-spring/web'
|
||||
import { defaultStyles, TooltipWithBounds } from '@visx/tooltip'
|
||||
|
||||
import { formatTokenAmount } from '../../token-amount'
|
||||
import { formatChartValue } from '../utils'
|
||||
|
||||
import type { TooltipData } from '../hooks/use-token-chart-tooltip'
|
||||
@@ -19,33 +20,95 @@ const ChartTooltip = ({
|
||||
tooltipData,
|
||||
opacityAnimation,
|
||||
tooltipAnimation,
|
||||
currency,
|
||||
dataType = 'price',
|
||||
}: Props) => {
|
||||
const formattedValue = formatChartValue(tooltipData.price, dataType, currency)
|
||||
const getFormattedValue = () => {
|
||||
switch (dataType) {
|
||||
case 'price':
|
||||
return formatChartValue(tooltipData.price, 'price')
|
||||
case 'balance':
|
||||
return formatChartValue(
|
||||
tooltipData.balance ?? tooltipData.price,
|
||||
'balance',
|
||||
)
|
||||
case 'value':
|
||||
return formatChartValue(tooltipData.value ?? tooltipData.price, 'value')
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
dataType === 'value' &&
|
||||
tooltipData.value !== undefined &&
|
||||
tooltipData.balance !== undefined
|
||||
) {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipAnimation={tooltipAnimation}
|
||||
opacityAnimation={opacityAnimation}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-19 font-semibold text-neutral-100">
|
||||
{formatChartValue(tooltipData.value, 'value')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-13 font-medium text-neutral-50">
|
||||
Price: {formatChartValue(tooltipData.price, 'price')}
|
||||
</p>
|
||||
<p className="text-13 font-medium text-neutral-50">
|
||||
Balance: {formatTokenAmount(tooltipData.balance, 'precise')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-13 font-medium text-neutral-50">
|
||||
{tooltipData.formattedDate}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedTooltip
|
||||
top={tooltipAnimation.y}
|
||||
left={tooltipAnimation.x}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
minWidth: 159,
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#FFF',
|
||||
border: '1px solid #F0F2F5',
|
||||
boxShadow: '0px 2px 20px rgba(9, 16, 28, 0.04)',
|
||||
borderRadius: 10,
|
||||
zIndex: 100,
|
||||
opacity: opacityAnimation.opacity,
|
||||
}}
|
||||
<TooltipWrapper
|
||||
tooltipAnimation={tooltipAnimation}
|
||||
opacityAnimation={opacityAnimation}
|
||||
>
|
||||
<p className="text-19 font-semibold text-neutral-100">{formattedValue}</p>
|
||||
<p className="text-19 font-semibold text-neutral-100">
|
||||
{getFormattedValue()}
|
||||
</p>
|
||||
<p className="text-13 font-medium text-neutral-50">
|
||||
{tooltipData.formattedDate}
|
||||
</p>
|
||||
</AnimatedTooltip>
|
||||
</TooltipWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type TooltipWrapperProps = {
|
||||
tooltipAnimation: { x: SpringValue<number>; y: SpringValue<number> }
|
||||
opacityAnimation: { opacity: SpringValue<number> }
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const TooltipWrapper = ({
|
||||
tooltipAnimation,
|
||||
opacityAnimation,
|
||||
children,
|
||||
}: TooltipWrapperProps) => (
|
||||
<AnimatedTooltip
|
||||
top={tooltipAnimation.y}
|
||||
left={tooltipAnimation.x}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
minWidth: 159,
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#FFF',
|
||||
border: '1px solid #F0F2F5',
|
||||
boxShadow: '0px 2px 20px rgba(9, 16, 28, 0.04)',
|
||||
borderRadius: 10,
|
||||
zIndex: 100,
|
||||
opacity: opacityAnimation.opacity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AnimatedTooltip>
|
||||
)
|
||||
|
||||
export { ChartTooltip }
|
||||
|
||||
@@ -67,17 +67,18 @@ const XAxis = (props: Props) => {
|
||||
<div
|
||||
key={day}
|
||||
className="absolute text-center"
|
||||
style={{ left: xPosition - 25, width: 50 }}
|
||||
style={{
|
||||
left: Math.max(
|
||||
0,
|
||||
Math.min(xPosition - 25, availableWidth - 50),
|
||||
),
|
||||
width: 50,
|
||||
}}
|
||||
>
|
||||
<p className="text-11 font-medium uppercase text-neutral-40">
|
||||
{activeRange === '7D'
|
||||
? format(new Date(d.date), 'MMM d')
|
||||
: activeRange === 'All' &&
|
||||
previousDate &&
|
||||
new Date(d.date).getFullYear() !==
|
||||
previousDate.getFullYear()
|
||||
? format(new Date(d.date), 'yyyy')
|
||||
: format(new Date(d.date), 'MMM')}
|
||||
<p className="text-11 font-medium text-neutral-40">
|
||||
{activeRange === '7D' || activeRange === '1M'
|
||||
? format(new Date(d.date), 'd MMM')
|
||||
: format(new Date(d.date), 'MMM yy')}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
@@ -85,10 +86,16 @@ const XAxis = (props: Props) => {
|
||||
<div
|
||||
key={day}
|
||||
className="absolute text-center"
|
||||
style={{ left: xPosition - 12, width: 24 }}
|
||||
style={{
|
||||
left: Math.max(
|
||||
0,
|
||||
Math.min(xPosition - 12, availableWidth - 24),
|
||||
),
|
||||
width: 50,
|
||||
}}
|
||||
>
|
||||
<p className="text-11 font-medium text-neutral-40">
|
||||
{format(new Date(d.date), 'd')}
|
||||
{format(new Date(d.date), 'd MMM')}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
@@ -96,7 +103,13 @@ const XAxis = (props: Props) => {
|
||||
<div
|
||||
key={day}
|
||||
className="absolute text-center"
|
||||
style={{ left: xPosition - 25, width: 50 }}
|
||||
style={{
|
||||
left: Math.max(
|
||||
0,
|
||||
Math.min(xPosition - 25, availableWidth - 50),
|
||||
),
|
||||
width: 50,
|
||||
}}
|
||||
>
|
||||
<p className="whitespace-nowrap text-11 font-medium text-neutral-40">
|
||||
{format(new Date(d.date), 'HH:mm')}
|
||||
@@ -104,7 +117,13 @@ const XAxis = (props: Props) => {
|
||||
</div>
|
||||
))
|
||||
.with('bullet', () => (
|
||||
<div key={day} className="absolute" style={{ left: xPosition - 2 }}>
|
||||
<div
|
||||
key={day}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: Math.max(0, Math.min(xPosition - 2, availableWidth - 4)),
|
||||
}}
|
||||
>
|
||||
<p className="text-13 font-medium text-neutral-10">•</p>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { danger, success, white } from '@status-im/colors'
|
||||
|
||||
export const positiveColors = {
|
||||
line: success[50],
|
||||
background: white[100],
|
||||
background: 'transparent',
|
||||
marker: success['50/30'],
|
||||
fill: success['50/5'],
|
||||
white: white[100],
|
||||
@@ -10,7 +10,7 @@ export const positiveColors = {
|
||||
|
||||
export const negativeColors = {
|
||||
line: danger[50],
|
||||
background: white[100],
|
||||
background: 'transparent',
|
||||
marker: danger['50/30'],
|
||||
fill: danger['50/5'],
|
||||
white: white[100],
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useTooltip } from '@visx/tooltip'
|
||||
import { extent } from 'd3-array'
|
||||
import { timeFormat } from 'd3-time-format'
|
||||
|
||||
import type { ChartDataPoint as PriceType } from '../utils'
|
||||
import type { ChartDataPoint } from '../utils'
|
||||
import type { EventType } from '@visx/event/lib/types'
|
||||
|
||||
type Props = {
|
||||
data: PriceType[]
|
||||
data: ChartDataPoint[]
|
||||
dates: Date[]
|
||||
innerWidth: number
|
||||
currency?: string
|
||||
@@ -22,17 +22,13 @@ type Props = {
|
||||
}
|
||||
}
|
||||
|
||||
type TooltipData = {
|
||||
date: string
|
||||
price: number
|
||||
type TooltipData = ChartDataPoint & {
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
// Eg. 'Jan 17th, 2024 - 14:45 '
|
||||
const formatDate = timeFormat('%b %d, %Y - %H:%M')
|
||||
|
||||
const getPrice = (d: PriceType) => d.price
|
||||
|
||||
const useTokenChartTooltip = (props: Props) => {
|
||||
const { data, margin, dates, innerWidth } = props
|
||||
|
||||
@@ -42,13 +38,15 @@ const useTokenChartTooltip = (props: Props) => {
|
||||
tooltipData: {
|
||||
date: '',
|
||||
price: 0,
|
||||
balance: 0,
|
||||
value: 0,
|
||||
formattedDate: '',
|
||||
},
|
||||
})
|
||||
|
||||
const filteredDates = dates.filter(Boolean) // filters out undefined values
|
||||
|
||||
const getDate = useCallback((d: PriceType) => new Date(d?.date), [])
|
||||
const getDate = useCallback((d: ChartDataPoint) => new Date(d?.date), [])
|
||||
|
||||
const xDomain = extent(filteredDates) as [Date, Date]
|
||||
const xScale = scaleTime({
|
||||
@@ -74,13 +72,11 @@ const useTokenChartTooltip = (props: Props) => {
|
||||
}
|
||||
|
||||
const d = data[closestIndex]
|
||||
const price = getPrice(d)
|
||||
|
||||
showTooltip({
|
||||
tooltipData: {
|
||||
date: d?.date,
|
||||
...d,
|
||||
formattedDate: formatDate(getDate(d)),
|
||||
price,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { ParentSize } from '@visx/responsive'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import { EmptyState } from '../empty-state'
|
||||
import { ChartLoading, TokenChart } from './components'
|
||||
|
||||
import type { DataType, TimeFrame } from './utils'
|
||||
@@ -12,6 +14,7 @@ type ChartProps = {
|
||||
| ApiOutput['assets']['nativeTokenPriceChart']
|
||||
| ApiOutput['assets']['tokenPriceChart']
|
||||
balance: ApiOutput['assets']['tokenBalanceChart']
|
||||
value: { date: string; price: number }[]
|
||||
activeTimeFrame: TimeFrame
|
||||
activeDataType: DataType
|
||||
}
|
||||
@@ -19,23 +22,33 @@ type ChartProps = {
|
||||
const Chart = ({
|
||||
price,
|
||||
balance,
|
||||
value,
|
||||
activeTimeFrame,
|
||||
activeDataType,
|
||||
}: ChartProps) => {
|
||||
const currency = 'EUR'
|
||||
const currency = 'USD'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ParentSize className="w-full bg-white-100">
|
||||
{({ width }) => (
|
||||
<TokenChart
|
||||
data={activeDataType === 'price' ? price : balance}
|
||||
width={width}
|
||||
currency={currency}
|
||||
timeFrame={activeTimeFrame}
|
||||
dataType={activeDataType}
|
||||
/>
|
||||
)}
|
||||
<ParentSize className="w-full bg-transparent">
|
||||
{({ width }) => {
|
||||
const data = match(activeDataType)
|
||||
.with('balance', () => balance)
|
||||
.with('value', () => value)
|
||||
.with('price', () => price)
|
||||
.exhaustive()
|
||||
|
||||
return (
|
||||
<TokenChart
|
||||
data={data}
|
||||
width={width}
|
||||
currency={currency}
|
||||
timeFrame={activeTimeFrame}
|
||||
dataType={activeDataType}
|
||||
emptyState={<EmptyState variant={activeDataType} />}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</ParentSize>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { differenceInCalendarMonths } from 'date-fns'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
export const TIME_FRAMES = ['24H', '7D', '1M', '3M', '1Y', 'All'] as const
|
||||
export type TimeFrame = (typeof TIME_FRAMES)[number]
|
||||
|
||||
export type DataType = 'price' | 'balance'
|
||||
export type ChartDataPoint = { date: string; price: number }
|
||||
export type DataType = 'price' | 'balance' | 'value'
|
||||
export type ChartDataPoint = {
|
||||
date: string
|
||||
price: number
|
||||
balance?: number
|
||||
value?: number
|
||||
}
|
||||
export type ChartDatum = { date: Date; value: number }
|
||||
|
||||
export const DEFAULT_TIME_FRAME: TimeFrame = TIME_FRAMES[0]
|
||||
@@ -31,8 +36,6 @@ export const checkDateOutput = (
|
||||
): 'bullet' | 'month' | 'day' | 'hour' | 'empty' => {
|
||||
const {
|
||||
date,
|
||||
previousDate,
|
||||
firstDate,
|
||||
index,
|
||||
variant,
|
||||
totalDataPoints = 0,
|
||||
@@ -49,8 +52,6 @@ export const checkDateOutput = (
|
||||
return Math.max(1, Math.ceil(totalDataPoints / maxLabels))
|
||||
}
|
||||
|
||||
const isFirstDataPoint = index === 0
|
||||
|
||||
if (variant === '24H') {
|
||||
const hourInterval = getSafeInterval(60)
|
||||
if (index % hourInterval === 0) {
|
||||
@@ -60,22 +61,20 @@ export const checkDateOutput = (
|
||||
}
|
||||
|
||||
if (variant === '7D') {
|
||||
const currentDay = date.getDate()
|
||||
const previousDay = previousDate ? previousDate.getDate() : -1
|
||||
const isDayTransition = currentDay !== previousDay
|
||||
const dayInterval = getSafeInterval(70)
|
||||
|
||||
if (isFirstDataPoint || isDayTransition) {
|
||||
if (index % dayInterval === 0) {
|
||||
return 'month'
|
||||
}
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
if (variant === '1M') {
|
||||
const interval = getSafeInterval(40)
|
||||
const interval = getSafeInterval(60)
|
||||
|
||||
if (index % interval === 0) {
|
||||
const currentDayOfMonth = date.getDate()
|
||||
if (isFirstDataPoint || currentDayOfMonth === 1) {
|
||||
if (currentDayOfMonth === 1) {
|
||||
return 'month'
|
||||
}
|
||||
return 'day'
|
||||
@@ -85,14 +84,6 @@ export const checkDateOutput = (
|
||||
}
|
||||
|
||||
if (variant === '3M') {
|
||||
const currentMonth = date.getMonth()
|
||||
const previousMonth = previousDate ? previousDate.getMonth() : -1
|
||||
const isMonthTransition = currentMonth !== previousMonth
|
||||
|
||||
if (isFirstDataPoint || isMonthTransition) {
|
||||
return 'month'
|
||||
}
|
||||
|
||||
const dayInterval = Math.max(10, getSafeInterval(60))
|
||||
if (index % dayInterval === 0) {
|
||||
return 'day'
|
||||
@@ -102,11 +93,9 @@ export const checkDateOutput = (
|
||||
}
|
||||
|
||||
if (variant === '1Y') {
|
||||
const currentMonth = date.getMonth()
|
||||
const previousMonth = previousDate ? previousDate.getMonth() : -1
|
||||
const isMonthTransition = currentMonth !== previousMonth
|
||||
const monthInterval = getSafeInterval(50)
|
||||
|
||||
if (isFirstDataPoint || isMonthTransition) {
|
||||
if (index > 0 && index % monthInterval === 0) {
|
||||
return 'month'
|
||||
}
|
||||
|
||||
@@ -114,104 +103,108 @@ export const checkDateOutput = (
|
||||
}
|
||||
|
||||
if (variant === 'All') {
|
||||
if (isFirstDataPoint) {
|
||||
const yearInterval = getSafeInterval(50)
|
||||
|
||||
if (index > 0 && index % yearInterval === 0) {
|
||||
return 'month'
|
||||
}
|
||||
|
||||
const currentYear = date.getFullYear()
|
||||
const previousYear = previousDate ? previousDate.getFullYear() : -1
|
||||
const isYearTransition = currentYear !== previousYear
|
||||
|
||||
if (isYearTransition) {
|
||||
return 'month'
|
||||
}
|
||||
|
||||
const monthInterval = Math.max(6, getSafeInterval(60))
|
||||
const monthsFromReference = differenceInCalendarMonths(date, firstDate)
|
||||
|
||||
return monthsFromReference % monthInterval === 0 ? 'month' : 'empty'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
return 'bullet'
|
||||
}
|
||||
|
||||
export const formatChartValue = (
|
||||
value: number,
|
||||
dataType: 'price' | 'balance',
|
||||
currency?: string,
|
||||
): string => {
|
||||
const fractionalDigits = value.toString().split('.')[1]?.length || 0
|
||||
const priceFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
notation: 'standard',
|
||||
currency: 'USD',
|
||||
minimumSignificantDigits: 4,
|
||||
maximumSignificantDigits: 4,
|
||||
roundingPriority: 'morePrecision',
|
||||
})
|
||||
|
||||
if (dataType === 'balance') {
|
||||
return value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: fractionalDigits,
|
||||
maximumFractionDigits: fractionalDigits,
|
||||
})
|
||||
}
|
||||
const balanceFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'decimal',
|
||||
notation: 'standard',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
minimumSignificantDigits: 1,
|
||||
maximumSignificantDigits: 4,
|
||||
roundingPriority: 'morePrecision',
|
||||
})
|
||||
|
||||
return value.toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
minimumFractionDigits: fractionalDigits,
|
||||
maximumFractionDigits: fractionalDigits,
|
||||
})
|
||||
const valueFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
notation: 'standard',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
|
||||
export const formatChartValue = (value: number, dataType: DataType): string => {
|
||||
return match(dataType)
|
||||
.with('balance', () => balanceFormatter.format(value))
|
||||
.with('price', () => priceFormatter.format(value))
|
||||
.with('value', () => valueFormatter.format(value))
|
||||
.exhaustive()
|
||||
}
|
||||
|
||||
export const formatSmallNumber = (value: number): string => {
|
||||
if (value === 0) return '0'
|
||||
export const getChartValue = (
|
||||
point: ChartDataPoint,
|
||||
dataType: DataType,
|
||||
): number => {
|
||||
return match(dataType)
|
||||
.with('price', () => point.price)
|
||||
.with('balance', () => point.balance ?? point.price)
|
||||
.with('value', () => point.value ?? point.price)
|
||||
.exhaustive()
|
||||
}
|
||||
|
||||
if (value < 0.01) {
|
||||
const str = value.toString()
|
||||
const match = str.match(/0\.0*/)
|
||||
if (match) {
|
||||
const leadingZeros = match[0].length - 2
|
||||
const decimalPlaces = leadingZeros + 2
|
||||
return value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: Math.min(decimalPlaces, 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
export const createChartDataPoint = (
|
||||
date: string,
|
||||
value: number,
|
||||
dataType: DataType,
|
||||
additionalData?: { price?: number; balance?: number },
|
||||
): ChartDataPoint => {
|
||||
return match(dataType)
|
||||
.with('price', () => ({ date, price: value }))
|
||||
.with('balance', () => ({ date, price: value, balance: value }))
|
||||
.with('value', () => ({
|
||||
date,
|
||||
price: additionalData?.price ?? 0,
|
||||
balance: additionalData?.balance ?? 0,
|
||||
value,
|
||||
}))
|
||||
.exhaustive()
|
||||
}
|
||||
|
||||
export const calculateChartRange = (
|
||||
data: Array<{ price: number }>,
|
||||
data: ChartDataPoint[],
|
||||
marginFactor = 0.1,
|
||||
dataType: DataType = 'price',
|
||||
) => {
|
||||
if (data.length === 0) return { min: 0, max: 1, ticks: [] }
|
||||
|
||||
const prices = data.map(d => d.price)
|
||||
const maxPrice = Math.max(...prices)
|
||||
const minPrice = Math.min(...prices)
|
||||
const values = data.map(d => getChartValue(d, dataType))
|
||||
const maxPrice = Math.max(...values)
|
||||
const minPrice = Math.min(...values)
|
||||
const priceRange = maxPrice - minPrice
|
||||
|
||||
const adjustedMin = minPrice - priceRange * marginFactor
|
||||
const adjustedMax = maxPrice + priceRange * marginFactor
|
||||
|
||||
const finalMin = minPrice > 0 && adjustedMin < 0 ? 0 : adjustedMin
|
||||
const finalMin = Math.max(0, adjustedMin)
|
||||
const finalMax = adjustedMax
|
||||
|
||||
// Generate ticks
|
||||
const tickCount = 7
|
||||
const tickInterval = (finalMax - finalMin) / (tickCount - 1)
|
||||
const maxDecimals = Math.min(
|
||||
Math.max(
|
||||
...data.map(d =>
|
||||
d.price % 1 !== 0 ? d.price.toString().split('.')[1]?.length || 0 : 0,
|
||||
),
|
||||
),
|
||||
4,
|
||||
)
|
||||
|
||||
const ticks = Array.from({ length: tickCount }, (_, i) => {
|
||||
const tickValue = finalMin + i * tickInterval
|
||||
if (maxDecimals === 0) return Math.round(tickValue).toString()
|
||||
return tickValue.toFixed(tickValue === 0 ? 0 : maxDecimals)
|
||||
|
||||
return formatChartValue(tickValue, dataType)
|
||||
})
|
||||
|
||||
return { min: finalMin, max: finalMax, ticks }
|
||||
|
||||
@@ -175,7 +175,8 @@ const CreatePasswordForm = ({
|
||||
!isPasswordValid ||
|
||||
!doPasswordsMatch ||
|
||||
!!form.formState.errors.password ||
|
||||
!!form.formState.errors.confirmPassword
|
||||
!!form.formState.errors.confirmPassword ||
|
||||
loading
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
}
|
||||
|
||||
// TODO: get this from the user's settings
|
||||
const SYMBOL: 'EUR' | 'USD' = 'EUR'
|
||||
const SYMBOL: 'EUR' | 'USD' = 'USD'
|
||||
const MIN_VALUE = 0.01
|
||||
|
||||
export const CurrencyAmount = (props: Props) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ExternalIcon } from '@status-im/icons/12'
|
||||
import { cx } from 'class-variance-authority'
|
||||
|
||||
import { Image, type ImageId } from '../../image'
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
}
|
||||
|
||||
const BuyTokens = (props: Props) => {
|
||||
const { address } = props
|
||||
|
||||
const PROVIDERS: ProviderProps[] = [
|
||||
{
|
||||
name: 'MoonPay',
|
||||
image: 'Wallet/Icons/Logos/moonpay-bigger:144:144',
|
||||
list: [
|
||||
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +9 more',
|
||||
'Fees: from 1%',
|
||||
'Supported Countries: 180',
|
||||
],
|
||||
url: 'https://buy.moonpay.com/v2/buy?apiKey=pk_live_YQC6CQPA5qqDu0unEwHJyAYQyeIqFGR',
|
||||
},
|
||||
{
|
||||
name: 'Mercuryo',
|
||||
image: 'Wallet/Icons/Logos/mercuryo-bigger:144:144',
|
||||
list: [
|
||||
'Pay with Credit/Debit Card, Bank Transfer, Apple/Google Pay, SEPA, +10 more',
|
||||
'Fees: from 1%',
|
||||
'Supported Countries: 135+',
|
||||
],
|
||||
url: `https://exchange.mercuryo.io/?type=buy&network=ETHEREUM¤cy=ETH&address=${address}&hide_address=false&fix_address=true&widget_id=6a7eb330-2b09-49b7-8fd3-1c77cfb6cd47`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start gap-4 rounded-16 bg-customisation-blue-50/5">
|
||||
<div className="flex flex-col gap-1 p-4 pb-1">
|
||||
<span className="text-19 font-600 text-neutral-100">
|
||||
Ways to buy tokens
|
||||
</span>
|
||||
<p className="text-15 font-400 text-neutral-100">
|
||||
Credit card and bank transfer
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 p-3">
|
||||
{PROVIDERS.map(provider => (
|
||||
<Provider key={provider.name} {...provider} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProviderProps = {
|
||||
name: string
|
||||
image: ImageId
|
||||
list: string[]
|
||||
url: string
|
||||
}
|
||||
|
||||
const Provider = (props: ProviderProps) => {
|
||||
const { name, image, list, url } = props
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-16 bg-white-100 p-4">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Image id={image} className="size-[72px]" />
|
||||
<h2 className="text-15 font-600">{name}</h2>
|
||||
</div>
|
||||
<ul className="mt-2 items-start text-13/[21px] font-400 text-neutral-50">
|
||||
{list.map(item => (
|
||||
<li key={item} className="ml-3 list-disc">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-center">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cx(
|
||||
'inline-flex h-6 shrink-0 cursor-pointer items-center justify-center gap-1',
|
||||
'rounded-8 border border-neutral-30 bg-transparent px-2 pr-[6px] text-13 font-500 text-neutral-100 outline-none transition-all',
|
||||
'active:border-neutral-20 active:bg-neutral-10 active:text-neutral-100 hover:border-neutral-40',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-80 focus-visible:ring-offset-2',
|
||||
'disabled:cursor-default disabled:opacity-[.3]',
|
||||
)}
|
||||
>
|
||||
Go to {name}
|
||||
<ExternalIcon className="shrink-0 text-neutral-50 [&>svg]:size-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BuyTokens }
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { IconButton, useToast } from '@status-im/components'
|
||||
import { CheckIcon, CopyIcon } from '@status-im/icons/20'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
|
||||
import { useCopyToClipboard } from '../../../hooks'
|
||||
import { NetworkLogo } from '../../network-logo'
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
}
|
||||
|
||||
const DepositTokens = (props: Props) => {
|
||||
const { address } = props
|
||||
const [, copy] = useCopyToClipboard()
|
||||
const [success, setSuccess] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSuccess(false), 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [success])
|
||||
|
||||
const handleCopy = () => {
|
||||
try {
|
||||
copy(address)
|
||||
setSuccess(true)
|
||||
} catch {
|
||||
toast.negative('Failed to copy address.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-16 bg-white-100 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-19 font-600 text-neutral-100">
|
||||
Deposit tokens
|
||||
</span>
|
||||
<p className="text-15 font-400 text-neutral-100">
|
||||
Send ETH, tokens or collectibles (NFTs) to this address from another
|
||||
wallet or exchange
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-neutral-50">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<p className="font-mono text-15 font-500 text-neutral-50">
|
||||
{address}
|
||||
</p>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onPress={handleCopy}
|
||||
icon={
|
||||
success ? (
|
||||
<CheckIcon className="text-success-50" />
|
||||
) : (
|
||||
<CopyIcon className="text-neutral-50" />
|
||||
)
|
||||
}
|
||||
aria-label="Copy code"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex size-[200px] flex-col items-center justify-center gap-3 rounded-16 bg-customisation-orange-50/5 p-3">
|
||||
<div className="flex items-center justify-center rounded-[14px] bg-white-100 p-[14px] shadow-3">
|
||||
<QRCodeSVG value={address} size={148} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-13 font-500 text-neutral-100">
|
||||
Supported network:
|
||||
</span>
|
||||
<span className="flex h-6 items-center gap-1 rounded-8 border border-neutral-30 bg-white-100 px-2 py-1 text-13 font-500 text-neutral-100">
|
||||
<NetworkLogo name="ethereum" size={12} />
|
||||
Ethereum
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DepositTokens }
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BuyTokens } from './components/buy-tokens'
|
||||
import { DepositTokens } from './components/deposit-tokens'
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
}
|
||||
|
||||
const EmptyStateActions = (props: Props) => {
|
||||
const { address } = props
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[calc(100vh-56px)] flex-col gap-4 overflow-y-auto p-8 scrollbar-stable">
|
||||
<BuyTokens address={address} />
|
||||
<DepositTokens address={address} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { EmptyStateActions }
|
||||
@@ -1,11 +1,24 @@
|
||||
import { cva } from 'cva'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
import { Image } from '../image'
|
||||
|
||||
import type { ImageId } from '../image'
|
||||
|
||||
const styles = cva({
|
||||
variants: {
|
||||
variant: {
|
||||
activity: 'size-20',
|
||||
collectible: 'size-20',
|
||||
balance: 'h-[112px]',
|
||||
price: 'h-[112px]',
|
||||
value: 'h-[112px]',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Props = {
|
||||
variant: 'activity' | 'collectible'
|
||||
variant: 'activity' | 'collectible' | 'balance' | 'price' | 'value'
|
||||
}
|
||||
|
||||
const EmptyState = (props: Props) => {
|
||||
@@ -14,8 +27,8 @@ const EmptyState = (props: Props) => {
|
||||
const content = match(variant)
|
||||
.with('activity', () => ({
|
||||
imageId: 'admin/empty/devices:241:240' as ImageId,
|
||||
imageAlt: 'No activity',
|
||||
title: 'No Activity',
|
||||
imageAlt: 'No history',
|
||||
title: 'No history',
|
||||
description: 'Transaction history will be visible on this page',
|
||||
}))
|
||||
.with('collectible', () => ({
|
||||
@@ -24,11 +37,33 @@ const EmptyState = (props: Props) => {
|
||||
title: 'No Collectibles',
|
||||
description: 'Your collectibles will be visible on this page',
|
||||
}))
|
||||
.with('balance', () => ({
|
||||
imageId: 'Portfolio/Empty States/No_Balance:750:232' as ImageId,
|
||||
imageAlt: 'No balance',
|
||||
title: 'No balance',
|
||||
description: 'Hold this asset to view your balance history here',
|
||||
}))
|
||||
.with('price', () => ({
|
||||
imageId: 'Portfolio/Empty States/No_Balance:750:232' as ImageId,
|
||||
imageAlt: 'No prices',
|
||||
title: 'No prices',
|
||||
description: 'Prices will be visible on this page',
|
||||
}))
|
||||
.with('value', () => ({
|
||||
imageId: 'Portfolio/Empty States/No_Balance:750:232' as ImageId,
|
||||
imageAlt: 'No value',
|
||||
title: 'No value',
|
||||
description: 'Hold this asset to view your value history here',
|
||||
}))
|
||||
.exhaustive()
|
||||
|
||||
return (
|
||||
<div className="m-auto flex flex-col items-center justify-center text-center">
|
||||
<Image id={content.imageId} alt={content.imageAlt} className="size-20" />
|
||||
<Image
|
||||
id={content.imageId}
|
||||
alt={content.imageAlt}
|
||||
className={styles({ variant })}
|
||||
/>
|
||||
<h2 className="mb-0.5 text-15 font-600">{content.title}</h2>
|
||||
<p className="text-13">{content.description}</p>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user