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:
Felicio
2025-08-26 03:09:11 +09:00
committed by GitHub
co-authored by Jakub Copilot jinhojang6 marcelines
parent 30be360b4f
commit 8467e461f1
132 changed files with 3650 additions and 1146 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@status-im/wallet': patch
'wallet': patch
---
Add value chart
+6
View File
@@ -0,0 +1,6 @@
---
"@status-im/wallet": patch
"wallet": patch
---
Add CTA to main view
+6
View File
@@ -0,0 +1,6 @@
---
'@status-im/wallet': patch
'wallet': patch
---
integrate pending wallet activity
+5
View File
@@ -0,0 +1,5 @@
---
"wallet": patch
---
Persistent recovery phrase
+6
View File
@@ -0,0 +1,6 @@
---
"@status-im/wallet": patch
"wallet": patch
---
Rename tabs
+5
View File
@@ -0,0 +1,5 @@
---
'@status-im/components': patch
---
Adds close toast action and multiple toasts
+10 -1
View File
@@ -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
}
}
+14 -23
View File
@@ -6,23 +6,23 @@ Packages for building user interfaces, websites, web applications, dapps, browse
## Packages
| Name | `npm` | Description |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`@status-im/colors`](./packages/colors) | [![npm version](https://img.shields.io/npm/v/@status-im/colors.svg)](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) | [![npm version](https://img.shields.io/npm/v/@status-im/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) | [![npm version](https://img.shields.io/npm/v/@status-im/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) | [![npm version](https://img.shields.io/npm/v/@status-im/js)](https://www.npmjs.com/package/@status-im/js) | |
| [`@status-im/wallet`](./packages/wallet) | | |
| [`@status-im/eslint-config`](./packages/eslint-config) | [![npm version](https://img.shields.io/npm/v/@status-im/eslint-config.svg)](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) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/colors) | [![npm version](https://img.shields.io/npm/v/@status-im/colors.svg)](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) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/icons) | [![npm version](https://img.shields.io/npm/v/@status-im/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) | [![vercel](https://img.shields.io/badge/vercel-black)](https://status-components.vercel.app/?path=/story/components) | [![npm version](https://img.shields.io/npm/v/@status-im/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) | | [![npm version](https://img.shields.io/npm/v/@status-im/js)](https://www.npmjs.com/package/@status-im/js) | |
| [`@status-im/wallet`](./packages/wallet) | | |
| [`@status-im/eslint-config`](./packages/eslint-config) | | [![npm version](https://img.shields.io/npm/v/@status-im/eslint-config.svg)](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) | [![chrome web store](https://img.shields.io/badge/chrome-grey)](https://chromewebstore.google.com/detail/kahehnbpamjplefhpkhafinaodkkenpg) | [![jenkins job](https://img.shields.io/badge/jenkins-grey)](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) | [![chrome web store](https://img.shields.io/badge/chrome-grey)](https://chromewebstore.google.com/detail/opkfeajbclhjdneghppfnfiannideafj) | [![jenkins job](https://img.shields.io/badge/jenkins-grey)](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.
+23 -13
View File
@@ -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',
},
})
}
+7 -2
View File
@@ -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"
}
]
}
+1 -1
View File
@@ -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..."
}
+5
View File
@@ -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
+13 -4
View File
@@ -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"
}
]
}
+1 -1
View File
@@ -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..."
}
+3 -3
View File
@@ -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
+6 -6
View File
@@ -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
}
+4 -2
View File
@@ -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

+41 -16
View File
@@ -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>
)
}
+49
View File
@@ -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,
}
+1 -1
View File
@@ -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,
+151 -48
View File
@@ -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'
+5 -1
View File
@@ -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>
-3
View File
@@ -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,
})
}
+1 -4
View File
@@ -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,
})
}
+1 -4
View File
@@ -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,
})
}
+5 -4
View File
@@ -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'] })
-3
View File
@@ -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,
})
}
+1 -1
View File
@@ -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,
}
}
+47 -8
View File
@@ -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])
}
-1
View File
@@ -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,
+8
View File
@@ -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>
)
}
+21 -14
View File
@@ -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 (
+94 -46
View File
@@ -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>
+6 -5
View File
@@ -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>
+3 -3
View File
@@ -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"
+8 -12
View File
@@ -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>
)}
+16 -4
View File
@@ -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",
},
],
}
+11 -3
View File
@@ -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
View File
@@ -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",
+5 -6
View File
@@ -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",
+5 -2
View File
@@ -3,6 +3,9 @@
"include": ["./src"],
"compilerOptions": {
"outDir": "./dist"
}
"composite": true,
"outDir": "./dist",
"noEmit": false
},
"references": []
}
+1 -2
View File
@@ -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",
+15 -1
View File
@@ -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],
)
}
+7 -3
View File
@@ -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>
)
}
+23
View File
@@ -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',
},
},
+11 -2
View File
@@ -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"
}
]
}
+1 -1
View File
@@ -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"
}
+11 -8
View File
@@ -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",
+10 -1
View File
@@ -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": []
}
+1 -2
View File
@@ -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",
+4 -1
View File
@@ -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": []
}
+7 -3
View File
@@ -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&apos;t find any assets that match your search
We didn&apos;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,
},
})
},
+24 -11
View File
@@ -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&currency=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