Commit f6f63b74 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into feat/mixpanel

parents 798c3bed ee02cfae
......@@ -32,13 +32,14 @@ NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=__PLACEHOLDER_FOR_NEXT_PUBLIC_FOOTER_TELEGRAM_L
NEXT_PUBLIC_FOOTER_STAKING_LINK=__PLACEHOLDER_FOR_NEXT_PUBLIC_FOOTER_STAKING_LINK__
NEXT_PUBLIC_FEATURED_NETWORKS=__PLACEHOLDER_FOR_NEXT_PUBLIC_FEATURED_NETWORKS__
NEXT_PUBLIC_NETWORK_EXPLORERS=__PLACEHOLDER_FOR_NEXT_PUBLIC_NETWORK_EXPLORERS__
NEXT_PUBLIC_OTHER_LINKS=__PLACEHOLDER_FOR_NEXT_PUBLIC_OTHER_LINKS__
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_MARKETPLACE_CONFIG_URL__
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=__PLACEHOLDER_FOR_NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM__
NEXT_PUBLIC_LOGOUT_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_LOGOUT_URL__
NEXT_PUBLIC_LOGOUT_RETURN_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_LOGOUT_RETURN_URL__
NEXT_PUBLIC_HOMEPAGE_CHARTS=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_CHARTS__
NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR__
NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT__
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND__
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER__
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=__PLACEHOLDER_FOR_NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME__
NEXT_PUBLIC_AD_DOMAIN_WITH_AD=__PLACEHOLDER_FOR_NEXT_PUBLIC_AD_DOMAIN_WITH_AD__
......@@ -55,6 +56,8 @@ NEXT_PUBLIC_API_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PORT__
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__
NEXT_PUBLIC_VISUALIZE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_VISUALIZE_API_HOST__
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_CONTRACT_INFO_API_HOST__
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_ADMIN_SERVICE_API_HOST__
NEXT_PUBLIC_API_SPEC_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_SPEC_URL__
# external services config
......
name: Checks
on:
workflow_dispatch:
pull_request:
push:
branches:
......@@ -9,6 +10,7 @@ jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps:
- name: Checkout repo
uses: actions/checkout@v3
......@@ -30,6 +32,7 @@ jobs:
type_check:
name: TypeScript
runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps:
- name: Checkout repo
uses: actions/checkout@v3
......
{
"typescript.tsdk": "node_modules/typescript/lib"
}
\ No newline at end of file
/* eslint-disable no-restricted-properties */
import type { NavItemExternal } from 'types/client/navigation-items';
import type { WalletType } from 'types/client/wallets';
import type { NetworkExplorer } from 'types/networks';
import type { ChainIndicatorId } from 'ui/home/indicators/types';
......@@ -11,6 +12,7 @@ const parseEnvJson = <DataType>(env: string | undefined): DataType | null => {
return null;
}
};
const stripTrailingSlash = (str: string) => str[str.length - 1] === '/' ? str.slice(0, -1) : str;
const getWeb3DefaultWallet = (): WalletType => {
const envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET);
......@@ -100,6 +102,7 @@ const config = Object.freeze({
telegram: getEnvValue(process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK),
staking: getEnvValue(process.env.NEXT_PUBLIC_FOOTER_STAKING_LINK),
},
otherLinks: parseEnvJson<Array<NavItemExternal>>(getEnvValue(process.env.NEXT_PUBLIC_OTHER_LINKS)) || [],
featuredNetworks: getEnvValue(process.env.NEXT_PUBLIC_FEATURED_NETWORKS),
blockScoutVersion: getEnvValue(process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION),
isAccountSupported: getEnvValue(process.env.NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED) === 'true',
......@@ -141,11 +144,19 @@ const config = Object.freeze({
endpoint: getEnvValue(process.env.NEXT_PUBLIC_VISUALIZE_API_HOST),
basePath: '',
},
contractInfoApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_CONTRACT_INFO_API_HOST),
basePath: '',
},
adminServiceApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_ADMIN_SERVICE_API_HOST),
basePath: '',
},
homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
plate: {
gradient: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT) ||
'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%)',
background: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND) ||
'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)',
textColor: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR) || 'white',
},
showGasTracker: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER) === 'false' ? false : true,
......
......@@ -13,5 +13,7 @@ NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs-test.k8s-dev.blockscout.com
# ui config
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
# network config
......
# ui config
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=
NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT=linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62
NEXT_PUBLIC_WEB3_DEFAULT_WALLET=coinbase
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=true
......
......@@ -13,7 +13,7 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cup']
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
# network config
......
......@@ -2,9 +2,9 @@
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cup']
#NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT=linear-gradient(136.9deg, \#235643 1.5%, \#16191E 77.77%)
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address','block':'/ethereum/poa/core/block'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
#NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND='no-repeat bottom 20% right 0px/100% url(https://neon-labs.org/images/index/banner.jpg)'
#NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=\#DCFE76
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-logos/poa.svg
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/network-icons/poa.svg
......
......@@ -11,10 +11,11 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cup']
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=
NEXT_PUBLIC_FEATURED_NETWORKS=
NEXT_PUBLIC_NETWORK_LOGO=
NEXT_PUBLIC_NETWORK_LOGO_DARK=
......@@ -41,5 +42,7 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://localhost:3000/marketplace-submit-fo
# api config
NEXT_PUBLIC_API_HOST=https://localhost:3003
NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_API_BASE_PATH=/
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -5,7 +5,7 @@ blockscout:
app: blockscout
enabled: true
image:
_default: &image blockscout/blockscout-optimism-l2-advanced:5.1.2-prerelease-465ba09e
_default: &image blockscout/blockscout-optimism-l2-advanced:5.1.4-prerelease-a8c134e4
replicas:
app: 1
# init container
......@@ -385,7 +385,7 @@ frontend:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: ''
NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT:
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND:
_default: "linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)"
NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: ''
......@@ -398,9 +398,13 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs']"
NEXT_PUBLIC_IS_TESTNET:
_default: "true"
_default: true
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -299,12 +299,12 @@ frontend:
NEXT_PUBLIC_FEATURED_NETWORKS:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
_default: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
# network config
NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum
_default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli
_default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum
NEXT_PUBLIC_NETWORK_LOGO:
......@@ -343,9 +343,13 @@ frontend:
NEXT_PUBLIC_API_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_STATS_API_HOST:
_default: https://stats-test.aws-k8s.blockscout.com/
_default: https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_LOGOUT_URL:
......@@ -355,7 +359,7 @@ frontend:
NEXT_PUBLIC_NETWORK_RPC_URL:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']"
_default: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL:
......
......@@ -113,7 +113,7 @@ frontend:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: ''
NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT:
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND:
_default: "linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)"
NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: ''
......@@ -126,9 +126,13 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs']"
NEXT_PUBLIC_IS_TESTNET:
_default: "true"
_default: true
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -39,6 +39,7 @@ frontend:
- "/csv-export"
- "/verified-contracts"
- "/graphiql"
- "/login"
resources:
limits:
......@@ -67,9 +68,9 @@ frontend:
NEXT_PUBLIC_FOOTER_STAKING_LINK:
_default: https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum
_default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli
_default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum
NEXT_PUBLIC_NETWORK_LOGO:
......@@ -97,6 +98,10 @@ frontend:
_default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH:
......@@ -109,13 +114,13 @@ frontend:
_default: https://blockscout.com/auth/logout
review: blockscout-main.test.aws-k8s.blockscout.com/auth/logout
NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']"
_default: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com
NEXT_PUBLIC_NETWORK_RPC_URL:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
_default: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL:
......
......@@ -29,6 +29,7 @@ The app instance could be customized by passing following variables to NodeJS en
| Variable | Type| Description | Is required | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_FEATURED_NETWORKS | `string` | URL of configuration file (`.json` format only) which contains list of featured networks that will be shown in the network menu. See [below](#featured-network-configuration-properties) list of available properties for particular network | - | - | `https://example.com/featured_networks_config.json` |
| NEXT_PUBLIC_OTHER_LINKS | `Array<{url: string; text: string}>` | List of links for the "Other" navigation menu | - | - | `[{'url':'https://blockscout.com','text':'Blockscout'}]` |
| NEXT_PUBLIC_BLOCKSCOUT_VERSION | `string` | Current running version of Blockscout (used to display link to release in the footer) | - | - | `v.5.1.0-beta`
| NEXT_PUBLIC_FOOTER_GITHUB_LINK | `string` | Link to Github in the footer | - | - | `https://github.com/blockscout/blockscout` |
| NEXT_PUBLIC_FOOTER_TWITTER_LINK | `string` | Link to Twitter in the footer | - | - | `https://www.twitter.com/blockscoutcom` |
......@@ -41,9 +42,9 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_AUTH_URL | `string` | Account auth base url; the login path (`/auth/auth0`) will be added to it at execution time. Required if account is supported for the app instance. | - | - | `https://blockscout.com` |
| NEXT_PUBLIC_LOGOUT_URL | `string` | Account logout url. Required if account is supported for the app instance. | - | - | `https://blockscoutcom.us.auth0.com/v2/logout` |
| NEXT_PUBLIC_LOGOUT_RETURN_URL | `string` | Account logout return url. Required if account is supported for the app instance. | - | - | `https://blockscout.com/poa/core/auth/logout` |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'market_cup'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cup']` |
| NEXT_PUBLIC_HOMEPAGE_CHARTS | `Array<'daily_txs' \| 'coin_price' \| 'market_cap'>` | List of charts displayed on the home page | - | - | `['daily_txs','coin_price','market_cap']` |
| NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR | `string` | Text color of the hero plate on the homepage (escape "#" symbol if you use HEX color codes) | `\#FFFFFF \| rgb(220, 254, 118)` | `\#DCFE76` |
| NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT | `string` | Gradient value for hero plate on the homepage (escape "#" symbol if you use HEX color codes) | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%), radial-gradient(at 72% 57%, hsla(14,95%,76%,1) 0px, transparent 50%)` |
| NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND | `string` | Background css value for hero plate on the homepage (escape "#" symbol if you use HEX color codes) | - | `radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)` | `radial-gradient(at 15% 86%, hsla(350,65%,70%,1) 0px, transparent 50%)` \| `no-repeat bottom 20% right 0px/100% url(https://placekitten/1400/200)` |
| NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER | `boolean` | Set to false if network doesn't have gas tracker | - | `true` | `false` |
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` |
| NEXT_PUBLIC_AD_DOMAIN_WITH_AD | `string` | The domain on which we display ads | - | - | `blockscout.com` |
......@@ -103,7 +104,7 @@ For each application, you need to specify the `MarketplaceCategoryId` to which i
| --- | --- | --- | --- | --- | --- |
| title | `string` | Displayed name of the explorer | yes | - | `Anyblock` |
| baseUrl | `string` | Base url of the explorer | yes | - | `https://explorer.anyblock.tools` |
| paths | `Record<'tx' \| 'block' \| 'address', string>` | Map of explorer entities and their paths | yes | - | `{'tx':'/ethereum/poa/core/tx'}` |
| paths | `Record<'tx' \| 'block' \| 'address' \| 'token', string>` | Map of explorer entities and their paths | yes | - | `{'tx':'/ethereum/poa/core/tx'}` |
*Note* The url of an entity will be constructed as `<baseUrl><paths[<entity-type>]><entity-id>`, e.g `https://explorer.anyblock.tools/ethereum/poa/core/tx/<tx-id>`
......@@ -128,6 +129,8 @@ For each application, you need to specify the `MarketplaceCategoryId` to which i
| NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL | `ws \| wss` | Main API websocket protocol | - | `wss` | `ws` |
| NEXT_PUBLIC_STATS_API_HOST | `string` | Stats API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_VISUALIZE_API_HOST | `string` | Visualize API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_CONTRACT_INFO_API_HOST | `string` | Contract Info API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | `string` | Admin Service API endpoint url | - | - | `https://my-host.com` |
## External services configuration
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.26 17.5H4.027a.47.47 0 0 1-.47-.469V5.312c0-.26.21-.469.47-.469h2.137V2.969c0-.259.21-.469.468-.469h5.89l.015.003.014.003a.449.449 0 0 1 .14.03.463.463 0 0 1 .156.095l3.452 3.31-.002.002a.466.466 0 0 1 .146.337v7.3c0 .87-.708 1.577-1.577 1.577h-1.029v.766c0 .87-.707 1.577-1.577 1.577Zm2.823-11.69L12.99 3.794v1.379a.64.64 0 0 0 .638.639h1.454Zm-2.755-2.648H6.826v11.333h8.316a.64.64 0 0 0 .639-.64V6.474H13.63c-.87 0-1.302-.432-1.302-1.301v-2.01ZM6.164 5.505H4.22v11.333h8.316c.352 0 .639-.563.639-.915v-.766H6.632a.469.469 0 0 1-.468-.469V5.505ZM8.85 8.5a.25.25 0 1 0 0 .5h4.7a.25.25 0 1 0 0-.5h-4.7Zm-.25 1.95a.25.25 0 0 1 .25-.25h4.7a.25.25 0 1 1 0 .5h-4.7a.25.25 0 0 1-.25-.25ZM8.85 12a.25.25 0 1 0 0 .5h4.7a.25.25 0 1 0 0-.5h-4.7Z" fill="currentColor"/>
<path d="M6.164 4.843v.35h.35v-.35h-.35Zm6.373-2.34-.098.336h.003l.095-.336Zm0 0 .099-.336h-.003l-.095.336Zm.014.003.02-.35-.02.35Zm.123.025.12-.33h-.004l-.115.33Zm.017.005.101-.335-.101.335Zm.025.009.145-.319h-.002l-.143.319Zm.13.086.243-.253-.243.252ZM16.3 5.94l.247.248.253-.253-.258-.247-.242.252Zm-.002.002-.247-.248-.252.252.257.248.242-.252Zm-2.46 9.214v-.35h-.35v.35h.35Zm-.846-11.364.242-.252-.593-.572v.824h.35Zm2.092 2.018v.35h.867l-.624-.602-.243.252ZM6.826 3.162v-.35h-.35v.35h.35Zm5.502 0h.35v-.35h-.35v.35ZM6.826 14.495h-.35v.35h.35v-.35Zm8.955-8.022h.35v-.35h-.35v.35ZM4.22 5.505v-.35h-.35v.35h.35Zm1.944 0h.35v-.35h-.35v.35ZM4.22 16.838h-.35v.35h.35v-.35Zm8.955-1.68h.35v-.35h-.35v.35ZM4.027 17.85h8.233v-.7H4.027v.7Zm-.82-.819a.82.82 0 0 0 .82.819v-.7a.12.12 0 0 1-.12-.119h-.7Zm0-11.719v11.719h.7V5.312h-.7Zm.82-.819a.82.82 0 0 0-.82.819h.7a.12.12 0 0 1 .12-.119v-.7Zm2.137 0H4.027v.7h2.137v-.7Zm-.35-1.524v1.874h.7V2.969h-.7Zm.818-.819a.819.819 0 0 0-.818.819h.7c0-.066.053-.119.118-.119v-.7Zm5.89 0h-5.89v.7h5.89v-.7Zm.114.017s-.015-.005-.033-.008a.389.389 0 0 0-.081-.009v.7a.313.313 0 0 1-.065-.007c-.013-.002-.022-.006-.018-.004l.197-.672Zm-.003 0-.191.673.191-.674Zm-.063-.01a.308.308 0 0 1 .053.007l.013.003-.197.672.027.007a.39.39 0 0 0 .066.01l.038-.7Zm.22.043a.8.8 0 0 0-.218-.044l-.041.7a.1.1 0 0 1 .028.005l.231-.66Zm.002 0 .001.001-.237.659c.015.005.029.01.033.01l.203-.67Zm.067.025c-.032-.014-.061-.022-.067-.024l-.203.67h.004-.002a.208.208 0 0 1-.018-.007l.286-.639Zm.23.153a.812.812 0 0 0-.228-.152l-.29.637a.11.11 0 0 1 .033.02l.485-.505Zm3.452 3.31-3.452-3.31-.485.505 3.453 3.311.484-.505Zm.004.502.001-.001-.495-.495-.001.001.495.495Zm.248.09a.818.818 0 0 0-.253-.59l-.485.505a.117.117 0 0 1 .038.085h.7Zm0 7.3v-7.3h-.7v7.3h.7Zm-1.927 1.927a1.929 1.929 0 0 0 1.927-1.927h-.7c0 .677-.551 1.227-1.227 1.227v.7Zm-1.029 0h1.029v-.7h-1.029v.7Zm.35.416v-.766h-.7v.766h.7ZM12.26 17.85a1.929 1.929 0 0 0 1.927-1.927h-.7c0 .676-.55 1.227-1.227 1.227v.7Zm.488-13.805 2.092 2.018.486-.504-2.093-2.018-.486.504Zm.593 1.127v-1.38h-.7v1.38h.7Zm.288.289a.29.29 0 0 1-.288-.29h-.7a.99.99 0 0 0 .988.99v-.7Zm1.454 0h-1.454v.7h1.454v-.7ZM6.826 3.512h5.502v-.7H6.826v.7Zm.35 10.983V3.162h-.7v11.333h.7Zm7.966-.35H6.826v.7h8.316v-.7Zm.289-.29a.29.29 0 0 1-.29.29v.7a.99.99 0 0 0 .99-.99h-.7Zm0-7.382v7.383h.7V6.473h-.7Zm-1.801.35h2.15v-.7h-2.15v.7Zm-1.652-1.651c0 .488.122.918.427 1.224.306.306.737.427 1.225.427v-.7c-.382 0-.602-.095-.73-.222-.127-.127-.222-.348-.222-.73h-.7Zm0-2.01v2.01h.7v-2.01h-.7ZM4.22 5.855h1.944v-.7H4.22v.7Zm.35 10.983V5.505h-.7v11.333h.7Zm7.966-.35H4.22v.7h8.316v-.7Zm.289-.565a.862.862 0 0 1-.134.404c-.099.156-.167.16-.155.16v.7c.364 0 .615-.276.748-.489.143-.229.24-.52.24-.775h-.7Zm0-.766v.766h.7v-.766h-.7Zm-6.193.35h6.543v-.7H6.632v.7Zm-.818-.819c0 .452.365.82.818.82v-.7a.119.119 0 0 1-.118-.12h-.7Zm0-9.183v9.183h.7V5.505h-.7ZM8.95 8.75a.1.1 0 0 1-.1.1v-.7a.6.6 0 0 0-.6.6h.7Zm-.1-.1a.1.1 0 0 1 .1.1h-.7a.6.6 0 0 0 .6.6v-.7Zm4.7 0h-4.7v.7h4.7v-.7Zm-.1.1a.1.1 0 0 1 .1-.1v.7a.6.6 0 0 0 .6-.6h-.7Zm.1.1a.1.1 0 0 1-.1-.1h.7a.6.6 0 0 0-.6-.6v.7Zm-4.7 0h4.7v-.7h-4.7v.7Zm0 1a.6.6 0 0 0-.6.6h.7a.1.1 0 0 1-.1.1v-.7Zm4.7 0h-4.7v.7h4.7v-.7Zm.6.6a.6.6 0 0 0-.6-.6v.7a.1.1 0 0 1-.1-.1h.7Zm-.6.6a.6.6 0 0 0 .6-.6h-.7a.1.1 0 0 1 .1-.1v.7Zm-4.7 0h4.7v-.7h-4.7v.7Zm-.6-.6a.6.6 0 0 0 .6.6v-.7a.1.1 0 0 1 .1.1h-.7Zm.7 1.8a.1.1 0 0 1-.1.1v-.7a.6.6 0 0 0-.6.6h.7Zm-.1-.1a.1.1 0 0 1 .1.1h-.7a.6.6 0 0 0 .6.6v-.7Zm4.7 0h-4.7v.7h4.7v-.7Zm-.1.1a.1.1 0 0 1 .1-.1v.7a.6.6 0 0 0 .6-.6h-.7Zm.1.1a.1.1 0 0 1-.1-.1h.7a.6.6 0 0 0-.6-.6v.7Zm-4.7 0h4.7v-.7h-4.7v.7Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 5H3v13.58h18V5ZM4.256 7.163v9.25L8.8 11.766 4.256 7.163Zm14.622 10.16H5.12l4.56-4.662.879.89c.782.788 2.156.78 2.925.006l.865-.87 4.528 4.637Zm.866-.91V7.257l-4.51 4.539 4.51 4.617ZM18.97 6.256H5.125l6.327 6.411c.29.292.864.286 1.142.005l6.375-6.416Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 201 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M127.992 76.305c0 3.301-.633 6.4-1.9 9.294-1.221 2.895-2.917 5.428-5.088 7.599-2.171 2.17-4.704 3.89-7.598 5.156-2.895 1.22-5.993 1.831-9.295 1.831h-4.138c-3.302 0-6.423-.61-9.362-1.831-2.895-1.267-5.428-2.986-7.599-5.156-2.17-2.171-3.89-4.704-5.156-7.599-1.221-2.895-1.832-5.993-1.832-9.294v-52.24c0-3.3.611-6.399 1.832-9.294 1.267-2.894 2.985-5.427 5.156-7.598 2.171-2.17 4.704-3.867 7.599-5.088 2.94-1.266 6.06-1.9 9.362-1.9h4.138c3.302 0 6.4.633 9.295 1.9 2.894 1.221 5.427 2.917 7.598 5.088 2.171 2.171 3.867 4.704 5.088 7.598 1.267 2.895 1.9 5.993 1.9 9.295v52.239Zm-8.752-52.24c0-2.17-.407-4.183-1.221-6.037-.814-1.9-1.922-3.55-3.324-4.953-1.402-1.402-3.053-2.51-4.953-3.324-1.854-.814-3.867-1.221-6.038-1.221h-3.324c-2.171 0-4.206.407-6.106 1.221a16.718 16.718 0 0 0-4.952 3.324c-1.403 1.402-2.51 3.053-3.325 4.953-.814 1.854-1.22 3.867-1.22 6.038v52.239c0 2.17.406 4.206 1.22 6.105a16.72 16.72 0 0 0 3.325 4.953 16.72 16.72 0 0 0 4.952 3.324c1.9.814 3.935 1.222 6.106 1.222h3.324c2.171 0 4.184-.408 6.038-1.222 1.9-.814 3.551-1.922 4.953-3.324a16.72 16.72 0 0 0 3.324-4.953c.814-1.9 1.221-3.934 1.221-6.105v-52.24Zm-71.411 57.94v16.824a8.752 8.752 0 0 1-8.752-8.751v-8.073H3.275a3.275 3.275 0 0 1-2.85-4.89L42.38 3.021a2.914 2.914 0 0 1 5.45 1.436v69.202h8.345a8.345 8.345 0 0 1-8.345 8.344ZM39.077 25.49 12.28 73.659h26.797V25.49ZM198.22 86.25c1.276-2.917 1.914-6.039 1.914-9.365v-6.7c0-2.324-.319-4.534-.957-6.63a22.47 22.47 0 0 0-2.598-5.948 22.916 22.916 0 0 0-3.965-4.922 22.222 22.222 0 0 0-5.195-3.76 25.193 25.193 0 0 0 5.195-3.76 24.48 24.48 0 0 0 3.965-4.99 22.69 22.69 0 0 0 2.598-5.878c.638-2.142.957-4.375.957-6.7v-3.35c0-3.326-.638-6.448-1.914-9.364-1.231-2.917-2.94-5.47-5.127-7.657-2.188-2.187-4.74-3.896-7.657-5.127C182.519.823 179.397.185 176.07.185h-4.17c-3.327 0-6.471.638-9.433 1.914-2.917 1.23-5.469 2.94-7.657 5.127-2.187 2.188-3.919 4.74-5.195 7.657-1.23 2.916-1.846 6.038-1.846 9.365a2.974 2.974 0 0 0 2.497 2.935l2.408.393a3.372 3.372 0 0 0 3.914-3.328c0-2.188.41-4.216 1.23-6.084.82-1.914 1.937-3.578 3.35-4.99a16.835 16.835 0 0 1 4.99-3.35c1.914-.82 3.965-1.23 6.152-1.23h3.35c2.187 0 4.215.41 6.084 1.23 1.914.82 3.577 1.937 4.99 3.35 1.413 1.412 2.529 3.076 3.35 4.99.82 1.868 1.23 3.896 1.23 6.084v4.785c0 2.188-.41 4.238-1.23 6.152a15.982 15.982 0 0 1-3.35 4.922c-1.413 1.413-3.076 2.53-4.99 3.35-1.869.82-3.897 1.23-6.084 1.23h-2.358a4.205 4.205 0 1 0 0 8.409h2.358c2.187 0 4.215.41 6.084 1.23 1.914.82 3.577 1.937 4.99 3.35 1.413 1.412 2.529 3.076 3.35 4.99.82 1.868 1.23 3.896 1.23 6.084v8.135c0 2.187-.41 4.238-1.23 6.152a16.856 16.856 0 0 1-3.35 4.99c-1.413 1.413-3.076 2.53-4.99 3.35-1.869.82-3.897 1.23-6.084 1.23h-3.35c-2.187 0-4.238-.41-6.152-1.23a16.834 16.834 0 0 1-4.99-3.35 16.837 16.837 0 0 1-3.35-4.99c-.82-1.914-1.23-3.965-1.23-6.153a3.34 3.34 0 0 0-3.851-3.299l-2.468.383a2.952 2.952 0 0 0-2.5 2.916c0 3.327.616 6.45 1.846 9.366 1.276 2.916 3.008 5.469 5.195 7.656 2.188 2.188 4.74 3.92 7.657 5.195 2.962 1.231 6.106 1.846 9.433 1.846h4.17c3.327 0 6.449-.615 9.365-1.846 2.917-1.276 5.469-3.007 7.657-5.195 2.187-2.188 3.896-4.74 5.127-7.656Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<g fill="currentColor" clip-path="url(#explorer_svg__a)">
<path d="M14.192 5.808a.624.624 0 0 0-.64-.15L7.927 7.531a.625.625 0 0 0-.395.395l-1.875 5.625a.625.625 0 0 0 .79.791l5.626-1.875a.626.626 0 0 0 .395-.395l1.875-5.625a.625.625 0 0 0-.151-.64Zm-6.954 6.954L8.62 8.619l2.762 2.762-4.143 1.38Z"/>
<path d="M10 18.75A8.75 8.75 0 1 1 18.75 10 8.76 8.76 0 0 1 10 18.75ZM10 2.5a7.5 7.5 0 1 0 7.5 7.5A7.509 7.509 0 0 0 10 2.5Z"/>
</g>
<defs>
<clipPath id="explorer_svg__a">
<path fill="currentColor" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#link_svg__a)">
<path d="M5.547 6.073c1.303-1.303 3.469-1.303 4.772 0 1.171 1.172 1.345 3.04.382 4.387l-.026.038a.75.75 0 0 1-1.221-.872l.026-.038a1.89 1.89 0 0 0-2.874-2.435l-2.63 2.632c-.738.717-.738 1.934 0 2.672a1.887 1.887 0 0 0 2.433.202l.038-.047a.772.772 0 0 1 1.045.194.75.75 0 0 1-.173 1.048l-.038.026a3.389 3.389 0 0 1-4.366-5.154l2.632-2.653Zm6.914 5.833A3.388 3.388 0 0 1 7.307 7.54l.026-.038c.22-.335.689-.415 1.045-.173.338.22.417.689.176 1.045l-.026.038c-.537.731-.452 1.781.202 2.435a1.893 1.893 0 0 0 2.671 0l2.63-2.632c.738-.738.738-1.955 0-2.672a1.887 1.887 0 0 0-2.433-.202l-.037.026c-.338.242-.806.143-1.046-.174a.749.749 0 0 1 .174-1.046l.037-.026a3.389 3.389 0 0 1 4.367 5.153l-2.632 2.632Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="link_svg__a">
<path fill="#fff" transform="translate(1.504 3)" d="M0 0h15v12H0z"/>
</clipPath>
</defs>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.17 3a5.073 5.073 0 0 0-3.568 1.424l-.01.01-1.401 1.393a1 1 0 0 0 1.41 1.419l1.396-1.388a3.073 3.073 0 0 1 4.346 4.344l-2.437 2.438a3.072 3.072 0 0 1-4.635-.332 1 1 0 1 0-1.601 1.198 5.074 5.074 0 0 0 7.65.548l2.444-2.444.012-.012A5.073 5.073 0 0 0 16.17 3Zm-5.34 5.657a5.073 5.073 0 0 0-3.95 1.474l-2.444 2.444-.012.012a5.073 5.073 0 0 0 7.174 7.174l.012-.013 1.393-1.393a1 1 0 1 0-1.414-1.414l-1.387 1.387a3.073 3.073 0 0 1-4.345-4.346l2.437-2.437a3.072 3.072 0 0 1 4.635.332 1 1 0 0 0 1.601-1.198 5.074 5.074 0 0 0-3.7-2.022Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 9.375a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" stroke="currentColor" stroke-width="1.4" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.25 9.938c-1.188.937-2.125 2.25-2.5 3.812h1.438c.937 0 1.625-.875 1.375-1.75a9.626 9.626 0 0 1-.375-2.688C6.25 5.876 7.813 2.939 10 1.875c2.188 1.063 3.75 4 3.75 7.375 0 .938-.125 1.875-.375 2.688-.25.874.438 1.75 1.375 1.75h1.5c-.375-1.563-1.313-2.938-2.5-3.813M10 13.75v5M8.125 15v2.5m3.75-2.5v2.5" stroke="currentColor" stroke-width="1.4" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2a9.996 9.996 0 0 1 10.014 10.026c0 5.483-4.523 10-10.092 9.974-5.385-.052-9.908-4.49-9.908-10 0-5.535 4.444-10.026 9.987-10ZM4.594 17.222c0 .104.052.156.105.235.392.548.836 1.07 1.359 1.514.235.21.47.418.732.6.732.47 1.49.914 2.327 1.202.68.235 1.385.418 2.091.496.55.052 1.124.078 1.673 0 .314-.052.654-.052.968-.183.052.026-.026-.958 0-.958.653-.105.392-.896 1.02-1.157 2.039-.887 2.797-1.828 3.79-3.812.89-1.75 1.726-2.767 1.438-4.7-.261-1.853-1.882-2.428-3.137-3.785-1.673-1.906-1.908-2.455-4.444-2.611-1.125-.079-1.15.81-2.223 1.175-1.751.574-3.059.391-4.235 1.827C4.724 8.71 4.776 9.911 4.697 12c-.026.522-.182 1.332-.104 1.854.157 1.175-1.02 1.801-.418 2.82.157.156.261.365.418.548Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.253 7.04c-.575-.105-1.15-.21-1.752-.183-.653.026-1.228.235-1.83.47-.705.26-1.359.626-1.882 1.174-.575.601-.889 1.332-1.02 2.168-.104.704-.13 1.41-.209 2.114-.13 1.515-.366 3.03-.993 4.413-.183-.157-.288-.392-.392-.575a8.801 8.801 0 0 1-1.15-3.315c-.079-.523-.131-1.019-.105-1.54.078-2.09.732-3.97 2.065-5.614C6.135 4.716 7.6 3.723 9.351 3.149a9.54 9.54 0 0 1 3.32-.444c2.51.157 4.654 1.175 6.353 3.029a9.16 9.16 0 0 1 2.301 4.83c.288 1.932 0 3.786-.889 5.535-.993 1.985-2.536 3.42-4.575 4.308a7.7 7.7 0 0 1-1.909.575c-.052 0-.078.078-.104.026-.157-.992.078-1.932.47-2.846.497-1.097 1.203-2.037 1.987-2.95.654-.758 1.412-1.463 2.144-2.168.418-.417.785-.835.994-1.383.366-.888.157-1.62-.628-2.194-.601-.443-1.307-.678-2.013-.887a26.36 26.36 0 0 1-2.013-.731c-.13-.444-.444-.731-.863-.888-.549-.13-1.098-.078-1.673.078Zm8.55 5.56h.052c.104-.365.104-.757.104-1.148 0-.444-.052-.888-.13-1.332a7.48 7.48 0 0 0-1.962-3.76c-.627-.652-1.333-1.2-2.039-1.775-.627-.522-1.307-.914-2.091-1.123-.758-.182-1.49-.287-2.275-.156-.052 0-.104 0-.104.052s.052.052.104.052c1.15.209 2.249.575 3.32.992 1.046.418 1.935 1.019 2.589 1.958.444.653.863 1.332 1.202 2.063.445.992.785 2.01.994 3.055.105.418.183.757.235 1.123Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.894 7.927c.993 0 1.778.81 1.778 1.775 0 .992-.81 1.776-1.804 1.802-.994 0-1.778-.81-1.778-1.802s.81-1.775 1.804-1.775Zm1.255 1.801c0-.705-.55-1.253-1.255-1.253-.706 0-1.255.548-1.255 1.253 0 .68.55 1.254 1.229 1.254h.026c.68 0 1.229-.549 1.255-1.254Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.358 13.41c.68.418 1.438.47 2.196.444a8.302 8.302 0 0 0 2.98-.705c.367-.157.733-.314 1.072-.522.027-.027.053-.053.105-.027-.026.079-.105.105-.183.157-1.02.679-2.144 1.097-3.346 1.332-.68.13-1.386.13-2.04-.105-.34-.13-.653-.34-.784-.574Zm-.104-6.37c.549-.158 1.124-.21 1.673 0 .418.156.732.443.862.887-.784-.418-1.647-.679-2.535-.888Z" fill="transparent"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.358 13.41c.68.417 1.438.469 2.196.443a8.304 8.304 0 0 0 2.98-.705c.367-.157.733-.313 1.073-.522.026-.026.052-.052.104-.026-.026.078-.104.104-.183.156-1.02.68-2.144 1.097-3.346 1.332-.68.13-1.386.13-2.04-.104-.34-.131-.653-.34-.784-.575Zm4.942-1.54a.402.402 0 0 1-.393-.392c0-.21.157-.392.366-.392.21 0 .392.157.418.365-.026.236-.183.418-.392.418Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.815 12.523a.056.056 0 0 1-.052.052c0-.052.026-.078.052-.052Zm.078-.052-.078.052-.026-.026c.052-.026.078-.026.104-.026Z" fill="transparent"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.59 14.774c.245-.016.482-.099.684-.238h.004c.197-.125.369-.288.511-.484.138-.662.211-1.349.211-2.052 0-5.523-4.477-10-10-10S2 6.477 2 12c0 1.55.352 3.017.981 4.326l5.643-9.058c.733-1.118 1.486-1.178 1.989-1.03.76.22 1.161.96 1.191 2.195v5.356l2.712-4.388.02-.034c.457-.73 1.59-2.544 3.1-2.102.907.264 1.449 1.239 1.449 2.608v2.852c0 .93.278 1.613.793 1.905.22.11.465.16.711.144Zm-.018 2.38a3.683 3.683 0 0 1-1.808-.442c-1.28-.717-2.009-2.17-2.009-3.987v-2.48c-.08.116-.17.25-.264.408l-3.013 4.877c-1.014 1.647-1.982 1.774-2.614 1.59-.633-.184-1.386-.813-1.386-2.758v-4.047L4.392 18.49A9.978 9.978 0 0 0 12 22a9.995 9.995 0 0 0 8.572-4.847Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m8.074 22 .128-3.79a13.266 13.266 0 0 1-5.491 1.181v.805a1.803 1.803 0 0 0 1.803 1.805h3.56Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.631 2h2.797a9.95 9.95 0 0 1 4.552 1.094 13.202 13.202 0 0 0-1.871 2.688.873.873 0 0 0-.633.03.942.942 0 0 0-1.806 0 .922.922 0 0 0-1.09.287.913.913 0 0 0-.126.207c-.24-.328-.586-.533-.96-.754-.309-.183-.637-.377-.937-.661a.266.266 0 0 0-.406.02.534.534 0 0 0-.058.54c.323.762.72 1.305 1.075 1.787.114.156.224.305.325.454a.667.667 0 0 0 .488.316 3.043 3.043 0 0 0-.417 1.13c-.458.124-.805.676-.805 1.341 0 .59.276 1.096.66 1.286l-.217 6.446a13.266 13.266 0 0 1-5.491 1.18V6.922A4.924 4.924 0 0 0 7.63 2Zm-.514 6.574a.457.457 0 0 0-.11-.345.446.446 0 0 0-.149-.11L5.591 7.55a.304.304 0 0 0-.304.524l1.127.814a.444.444 0 0 0 .522-.003.456.456 0 0 0 .125-.136.456.456 0 0 0 .056-.175Zm-.11 7.395a.446.446 0 0 0 .092-.16h-.002a.456.456 0 0 0-.038-.359.445.445 0 0 0-.646-.138l-1.128.812a.304.304 0 0 0 .305.525l1.267-.57a.446.446 0 0 0 .15-.11ZM4.33 12.372l.484.166a.544.544 0 1 0 0-1.032l-.484.166a.37.37 0 0 0 0 .7Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.98 3.094A9.993 9.993 0 0 1 20.422 12a9.997 9.997 0 0 1-5.394 8.877l-.304-9.017v-.096a.403.403 0 0 0 .065-.038c.352-.211.595-.692.595-1.247 0-.066-.003-.132-.01-.198-.061-.575-.382-1.032-.795-1.144a2.988 2.988 0 0 0-.409-1.13.666.666 0 0 0 .496-.316c.101-.15.211-.3.326-.458.35-.481.745-1.022 1.066-1.768l.018-.042a.365.365 0 0 0-.044-.38.373.373 0 0 0-.566-.035c-.294.28-.607.457-.9.623-.34.194-.652.37-.874.674a.97.97 0 0 0-.583-.523c.51-.97 1.138-1.873 1.871-2.688Zm3.348 9.442.486-.166a.37.37 0 0 0 0-.7l-.486-.166a.545.545 0 1 0 0 1.032Zm-.708 4.122a.304.304 0 0 0 .084-.536l-1.127-.813a.443.443 0 0 0-.645.139.443.443 0 0 0 .202.628l1.268.57a.294.294 0 0 0 .218.012ZM15.985 8.23a.448.448 0 0 0-.11.345.444.444 0 0 0 .182.313.447.447 0 0 0 .521.002l1.128-.814a.304.304 0 0 0-.304-.524l-1.268.57a.445.445 0 0 0-.15.108Z" fill="currentColor"/>
<path d="M6.19 5.48A4.924 4.924 0 0 0 7.63 2H4.514A1.803 1.803 0 0 0 2.71 3.803v3.119a4.92 4.92 0 0 0 3.479-1.443Zm6.286 5.224-.507.466a.314.314 0 0 0-.09.152c-.033.134-.099.397-.152.647a.152.152 0 0 1-.152.115.152.152 0 0 1-.152-.115l-.162-.647a.304.304 0 0 0-.09-.152l-.506-.466a.151.151 0 0 1-.033-.175.153.153 0 0 1 .156-.086l.76.103h.042l.06-.007.7-.096a.153.153 0 0 1 .153.085.152.152 0 0 1-.035.176m.76-.17a1.82 1.82 0 0 1 .271-.927.608.608 0 0 0 .05-.503l-.036.032a.888.888 0 0 1-1.086.009c0 .313-.286.572-.66.618a.988.988 0 0 1-.118 0c-.427 0-.776-.28-.776-.626a.888.888 0 0 1-1.086-.009.65.65 0 0 1-.114-.12.596.596 0 0 0-.034.628c.147.277.224.585.223.898 0 .327-.087.648-.253.93a.609.609 0 0 0 .056.689c.437.525 1.135.961 1.803 1.02h.165c.773 0 1.38-.482 1.813-1.114a.599.599 0 0 0 .026-.626 1.892 1.892 0 0 1-.228-.897" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-8.293-4.547c1.539.117 2.84 1.137 2.84 1.137s1.453 2.11 1.71 6.25c-1.464 1.691-3.69 1.703-3.69 1.703l-.466-.621a5.672 5.672 0 0 0 2.454-1.652c-.922.699-2.309 1.422-4.547 1.422-2.239 0-3.63-.727-4.547-1.422a5.672 5.672 0 0 0 2.453 1.652l-.465.621s-2.226-.012-3.691-1.703c.25-4.14 1.703-6.25 1.703-6.25s1.226-.984 2.84-1.137l.136.278c-1.27.285-2.027.828-2.695 1.425 1.149-.586 2.285-1.136 4.262-1.136 1.976 0 3.113.55 4.262 1.136-.668-.597-1.305-1.086-2.696-1.425l.137-.278Zm-4.55 5.114c0 .628.444 1.136.995 1.136.551 0 .996-.508.996-1.136 0-.63-.445-1.137-.996-1.137-.55 0-.996.508-.996 1.137Zm3.694 0c0 .628.446 1.136.997 1.136.546 0 .996-.508.996-1.136 0-.63-.446-1.137-.996-1.137-.551 0-.997.508-.997 1.137Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.122 12.061C22.122 6.505 17.618 2 12.062 2 6.504 2 2 6.505 2 12.061c0 5.022 3.68 9.184 8.49 9.939v-7.03H7.933v-2.91h2.555V9.845c0-2.522 1.502-3.915 3.8-3.915 1.101 0 2.252.197 2.252.197v2.476h-1.268c-1.25 0-1.64.775-1.64 1.57v1.888h2.79l-.445 2.908h-2.345V22c4.81-.755 8.49-4.917 8.49-9.939Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.005 2c-5.525 0-10 4.475-10 10a9.994 9.994 0 0 0 6.837 9.488c.5.087.688-.213.688-.476 0-.237-.013-1.024-.013-1.862-2.512.463-3.162-.612-3.362-1.175-.113-.288-.6-1.175-1.025-1.413-.35-.187-.85-.65-.013-.662.788-.013 1.35.725 1.538 1.025.9 1.512 2.337 1.087 2.912.825.088-.65.35-1.087.638-1.337-2.225-.25-4.55-1.113-4.55-4.938 0-1.088.387-1.987 1.025-2.688-.1-.25-.45-1.275.1-2.65 0 0 .837-.262 2.75 1.026a9.28 9.28 0 0 1 2.5-.338c.85 0 1.7.112 2.5.337 1.912-1.3 2.75-1.024 2.75-1.024.55 1.375.2 2.4.1 2.65.637.7 1.025 1.587 1.025 2.687 0 3.838-2.338 4.688-4.563 4.938.363.312.675.912.675 1.85 0 1.337-.012 2.412-.012 2.75 0 .262.187.574.687.474A10.016 10.016 0 0 0 22.005 12c0-5.525-4.475-10-10-10Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12 6.48 2 12 2ZM8.874 17.62V9.81H6.277v7.81h2.597Zm9.36 0v-4.478c0-2.4-1.281-3.515-2.989-3.515-1.377 0-1.994.757-2.34 1.29V9.81H10.31c.034.732 0 7.809 0 7.809h2.596v-4.361c0-.234.016-.467.085-.634.188-.466.615-.95 1.332-.95.939 0 1.315.717 1.315 1.767v4.178h2.596ZM7.593 6.045c-.888 0-1.469.584-1.469 1.35 0 .749.563 1.349 1.435 1.349h.016c.906 0 1.47-.6 1.47-1.35-.018-.765-.564-1.35-1.452-1.35Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.478 2 2 6.477 2 12s4.478 10 10 10c5.523 0 10-4.477 10-10S17.523 2 12 2Zm5.673 6.628h-.455c-.17 0-.409.244-.409.4v5.662c0 .156.24.369.409.369h.455v1.344h-4.127v-1.344h.864V9.108h-.043l-2.017 7.295h-1.562L8.797 9.108h-.05v5.951h.863v1.344H6.155v-1.344h.442c.182 0 .421-.213.421-.37v-5.66c0-.156-.239-.4-.421-.4h-.442V7.284h4.32l1.42 5.28h.038l1.432-5.28h4.308v1.344Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="opensea_filled_svg__a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="2" width="20" height="20">
<path d="M22 2H2v20h20V2Z" fill="#fff"/>
</mask>
<g mask="url(#opensea_filled_svg__a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.037 2.035c5.52 0 10 4.48 10 10s-4.48 10-10 10-10-4.48-10-10 4.48-10 10-10Zm2.732 7.245.053.068.075.098c.02.04.06.081.08.142.036.053.068.106.099.16l.026.045c.031.053.063.107.099.16.02.06.06.121.08.182.082.162.143.325.163.507.02.04.02.081.02.101.02.04.02.102.02.162.02.183 0 .345-.02.527-.007.03-.015.058-.023.085l-.015.054a2.655 2.655 0 0 0-.023.084c-.02.061-.04.142-.08.223-.062.142-.143.284-.224.426-.02.04-.06.101-.101.162-.04.061-.081.102-.101.162-.041.061-.102.122-.142.183-.04.06-.081.121-.142.182-.061.081-.142.162-.203.243-.04.041-.081.102-.142.142-.04.04-.08.102-.142.142-.06.061-.121.122-.182.162l-.122.102c-.02.02-.04.02-.06.02h-.893v1.135h1.115c.244 0 .487-.08.67-.243.06-.06.344-.304.688-.669.02-.02.02-.02.041-.02l3.081-.892c.122-.06.162-.02.162.04v.65c0 .04-.02.06-.06.08-.203.081-.913.405-1.197.81-.75 1.035-1.317 2.514-2.574 2.514h-5.29a3.396 3.396 0 0 1-3.385-3.405v-.06c0-.041.04-.082.08-.082h2.98c.061 0 .102.061.102.101-.02.183.02.386.101.568.182.365.547.568.933.568h1.459v-1.135h-1.44a.091.091 0 0 1-.08-.142.28.28 0 0 0 .06-.082c.142-.202.325-.486.527-.83.142-.224.264-.487.365-.73.02-.04.04-.081.061-.142.02-.081.061-.162.081-.223l.061-.183c.04-.202.06-.425.06-.668 0-.082 0-.183-.02-.284 0-.045-.003-.089-.008-.133l-.003-.038a1.319 1.319 0 0 1-.009-.133c0-.081-.02-.183-.04-.264a2.866 2.866 0 0 0-.081-.405l-.02-.04c-.02-.082-.041-.183-.082-.264a7.58 7.58 0 0 0-.283-.811l-.122-.304a7.912 7.912 0 0 0-.182-.406c-.02-.06-.061-.1-.082-.162-.02-.06-.06-.121-.08-.182l-.061-.122-.183-.324c-.02-.04.02-.101.06-.081l1.116.304.142.04.162.041.06.02v-.669c0-.324.264-.588.568-.588.162 0 .304.061.406.163a.566.566 0 0 1 .162.405V7.5l.121.04s.02 0 .02.02c.021.021.061.062.122.102.04.04.081.081.142.122.102.08.243.203.385.324.04.04.081.06.102.101.182.163.385.365.588.588.06.061.1.122.162.183.06.06.101.142.162.202l.075.098Zm-7.88 3.105.041-.06 2.635-4.116c.041-.06.122-.06.163.02.446.994.81 2.21.648 2.98-.08.325-.284.75-.527 1.136a.442.442 0 0 1-.101.162c-.02.02-.04.04-.081.04H6.95c-.061-.02-.102-.101-.061-.162Z" fill="currentColor"/>
</g>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12 6.48 2 12 2Zm6.667 10c0-.807-.655-1.462-1.462-1.462a1.4 1.4 0 0 0-1.006.41c-.994-.714-2.374-1.182-3.895-1.24l.667-3.123 2.163.456a1.042 1.042 0 0 0 2.082-.047c0-.573-.467-1.04-1.04-1.04-.41 0-.76.233-.925.584l-2.42-.515a.291.291 0 0 0-.2.035.285.285 0 0 0-.116.164l-.737 3.486c-1.556.046-2.948.502-3.953 1.24a1.476 1.476 0 0 0-1.006-.41 1.463 1.463 0 0 0-.597 2.795c-.023.14-.035.293-.035.445 0 2.245 2.608 4.058 5.836 4.058s5.837-1.813 5.837-4.058c0-.152-.012-.293-.035-.433.48-.234.842-.748.842-1.345Zm-4.188 3.79c-.713.713-2.07.76-2.467.76-.398 0-1.766-.059-2.468-.76a.275.275 0 0 1 0-.386.275.275 0 0 1 .386 0c.444.444 1.403.608 2.093.608s1.638-.164 2.094-.609a.275.275 0 0 1 .386 0 .3.3 0 0 1-.024.386Zm-5.812-2.75c0-.572.467-1.04 1.04-1.04.574 0 1.042.468 1.042 1.04 0 .574-.468 1.042-1.041 1.042a1.043 1.043 0 0 1-1.041-1.041Zm5.625 1.042a1.043 1.043 0 0 1-1.04-1.041c0-.573.467-1.041 1.04-1.041.574 0 1.041.468 1.041 1.04 0 .574-.467 1.042-1.04 1.042Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12 6.48 2 12 2Zm1.598 13.51c.7 0 1.272.572 1.272 1.273 0 .7-.572 1.272-1.272 1.272-.7 0-1.273-.572-1.273-1.272V15.51h1.273ZM9.13 13.599c0-.7.572-1.273 1.272-1.273.7 0 1.273.572 1.273 1.273v3.185c0 .7-.572 1.272-1.273 1.272-.7 0-1.272-.572-1.272-1.272v-3.186Zm-.64 0c0 .7-.572 1.272-1.273 1.272-.7 0-1.272-.572-1.272-1.272 0-.7.572-1.273 1.272-1.273H8.49v1.273Zm8.293 1.272c.7 0 1.272-.572 1.272-1.272 0-.7-.572-1.273-1.272-1.273h-3.186c-.7 0-1.272.572-1.272 1.273 0 .7.572 1.272 1.273 1.272h3.185Zm0-3.195c.7 0 1.272-.572 1.272-1.273 0-.7-.572-1.272-1.272-1.272-.7 0-1.273.572-1.273 1.272v1.273h1.273Zm-1.913-1.273c0 .7-.572 1.273-1.272 1.273-.7 0-1.273-.572-1.273-1.273V7.217c0-.7.572-1.272 1.273-1.272.7 0 1.272.572 1.272 1.272v3.186Zm-4.467 1.273c.7 0 1.272-.572 1.272-1.273 0-.7-.572-1.272-1.273-1.272H7.217c-.7 0-1.272.572-1.272 1.272 0 .7.572 1.273 1.272 1.273h3.186Zm1.272-3.186V7.217c0-.7-.572-1.272-1.273-1.272-.7 0-1.272.572-1.272 1.272 0 .7.572 1.272 1.272 1.272h1.273Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.01 12c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10 10 4.477 10 10Zm-9.73-3.495a525.959 525.959 0 0 0-6.445 2.776c-.523.208-.797.412-.822.61-.042.337.38.47.953.65l.241.076c.564.184 1.323.398 1.718.407.358.008.757-.14 1.198-.443 3.01-2.031 4.564-3.058 4.661-3.08.069-.016.164-.036.229.022.064.057.058.166.051.195-.041.178-1.694 1.715-2.55 2.51-.267.248-.456.424-.494.464-.087.09-.175.175-.26.257-.524.505-.917.884.021 1.503.452.297.813.543 1.173.789.393.267.786.535 1.293.867.13.085.253.173.374.259.457.326.869.62 1.377.573.295-.027.6-.305.755-1.133.366-1.957 1.086-6.197 1.252-7.944a1.942 1.942 0 0 0-.019-.435.465.465 0 0 0-.157-.3c-.132-.107-.337-.13-.428-.128-.416.007-1.053.23-4.122 1.505Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.005 22c5.523 0 10-4.477 10-10s-4.477-10-10-10-10 4.477-10 10 4.477 10 10 10Zm5.011-12.322c.483-.35.894-.774 1.234-1.27a4.938 4.938 0 0 1-1.416.38 2.39 2.39 0 0 0 1.081-1.355 4.823 4.823 0 0 1-1.56.593 2.371 2.371 0 0 0-1.798-.776c-.68 0-1.26.24-1.74.72a2.37 2.37 0 0 0-.72 1.739c0 .182.021.37.062.563a6.855 6.855 0 0 1-2.83-.757 6.971 6.971 0 0 1-2.241-1.816 2.4 2.4 0 0 0-.335 1.24 2.456 2.456 0 0 0 1.096 2.048 2.44 2.44 0 0 1-1.112-.312v.031c0 .594.187 1.115.56 1.564.373.45.844.732 1.412.849a2.542 2.542 0 0 1-.647.084c-.142 0-.297-.013-.464-.038.157.492.446.897.868 1.214a2.4 2.4 0 0 0 1.431.49 4.816 4.816 0 0 1-3.053 1.051c-.218 0-.416-.01-.594-.03a6.83 6.83 0 0 0 3.777 1.104 7.19 7.19 0 0 0 2.459-.415c.766-.277 1.421-.647 1.964-1.112a7.434 7.434 0 0 0 1.405-1.602 7.138 7.138 0 0 0 .88-1.892 6.983 6.983 0 0 0 .282-2.295Z" fill="currentColor"/>
</svg>
import type { UserInfo, CustomAbis, PublicTags, AddressTags, TransactionTags, ApiKeys, WatchlistAddress } from 'types/api/account';
import type {
UserInfo,
CustomAbis,
PublicTags,
AddressTags,
TransactionTags,
ApiKeys,
WatchlistAddress,
VerifiedAddressResponse,
TokenInfoApplicationConfig,
TokenInfoApplications,
} from 'types/api/account';
import type {
Address,
AddressCounters,
......@@ -36,6 +47,7 @@ import type {
TokenInventoryResponse,
TokenInstance,
TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token';
import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
......@@ -63,6 +75,9 @@ export const RESOURCES = {
user_info: {
path: '/api/account/v1/user/info',
},
email_resend: {
path: '/api/account/v1/email/resend',
},
custom_abi: {
path: '/api/account/v1/user/custom_abis/:id?',
pathParams: [ 'id' as const ],
......@@ -88,6 +103,35 @@ export const RESOURCES = {
pathParams: [ 'id' as const ],
},
// ACCOUNT: ADDRESS VERIFICATION & TOKEN INFO
address_verification: {
path: '/api/v1/chains/:chainId/verified-addresses:type',
pathParams: [ 'chainId' as const, 'type' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
verified_addresses: {
path: '/api/v1/chains/:chainId/verified-addresses',
pathParams: [ 'chainId' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
token_info_applications_config: {
path: '/api/v1/chains/:chainId/token-info-submissions/selectors',
pathParams: [ 'chainId' as const ],
endpoint: appConfig.adminServiceApi.endpoint,
basePath: appConfig.adminServiceApi.basePath,
},
token_info_applications: {
path: '/api/v1/chains/:chainId/token-info-submissions/:id?',
pathParams: [ 'chainId' as const, 'id' as const ],
endpoint: appConfig.adminServiceApi.endpoint,
basePath: appConfig.adminServiceApi.basePath,
},
// STATS
stats_counters: {
path: '/api/v1/counters',
......@@ -309,6 +353,12 @@ export const RESOURCES = {
path: '/api/v2/tokens/:hash',
pathParams: [ 'hash' as const ],
},
token_verified_info: {
path: '/api/v1/chains/:chainId/token-infos/:hash',
pathParams: [ 'chainId' as const, 'hash' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
token_counters: {
path: '/api/v2/tokens/:hash/counters',
pathParams: [ 'hash' as const ],
......@@ -524,6 +574,9 @@ Q extends 'private_tags_address' ? AddressTags :
Q extends 'private_tags_tx' ? TransactionTags :
Q extends 'api_keys' ? ApiKeys :
Q extends 'watchlist' ? Array<WatchlistAddress> :
Q extends 'verified_addresses' ? VerifiedAddressResponse :
Q extends 'token_info_applications_config' ? TokenInfoApplicationConfig :
Q extends 'token_info_applications' ? TokenInfoApplications :
Q extends 'homepage_stats' ? HomeStats :
Q extends 'homepage_chart_txs' ? ChartTransactionResponse :
Q extends 'homepage_chart_market' ? ChartMarketResponse :
......@@ -561,6 +614,7 @@ Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'address_withdrawals' ? AddressWithdrawalsResponse :
Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders :
......
import React from 'react';
import appConfig from 'configs/app/config';
import isNeedProxy from 'lib/api/isNeedProxy';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
......@@ -27,7 +27,7 @@ export default function useApiFetch() {
url,
{
credentials: 'include',
...(resource.endpoint && appConfig.host === 'localhost' ? {
...(resource.endpoint && isNeedProxy() ? {
headers: {
'x-endpoint': resource.endpoint,
},
......
......@@ -33,6 +33,8 @@ export function app(): CspDev.DirectiveDescriptor {
appConfig.api.socket,
appConfig.statsApi.endpoint,
appConfig.visualizeApi.endpoint,
appConfig.contractInfoApi.endpoint,
appConfig.adminServiceApi.endpoint,
// chain RPC server
appConfig.network.rpcUrl,
......
import getErrorCause from './getErrorCause';
export default function getErrorStatusCode(error: Error | undefined): number | undefined {
export default function getErrorCauseStatusCode(error: Error | undefined): number | undefined {
const cause = getErrorCause(error);
return cause && 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined;
}
export default function getErrorObj(error: unknown) {
if (typeof error !== 'object') {
return;
}
if (Array.isArray(error)) {
return;
}
if (error === null) {
return;
}
return error;
}
import getErrorObj from './getErrorObj';
export default function getErrorObjPayload<Payload extends object>(error: unknown): Payload | undefined {
const errorObj = getErrorObj(error);
if (!errorObj || !('payload' in errorObj)) {
return;
}
if (typeof errorObj.payload !== 'object') {
return;
}
if (errorObj === null) {
return;
}
if (Array.isArray(errorObj)) {
return;
}
return errorObj.payload as Payload;
}
import getErrorObj from './getErrorObj';
export default function getErrorObjStatusCode(error: unknown) {
const errorObj = getErrorObj(error);
if (!errorObj || !('status' in errorObj) || typeof errorObj.status !== 'number') {
return;
}
return errorObj.status;
}
import { useRouter } from 'next/router';
import type { Route } from 'nextjs-routes';
import React from 'react';
import type { NavItemInternal, NavItem, NavGroupItem } from 'types/client/navigation-items';
import appConfig from 'configs/app/config';
import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg';
......@@ -27,28 +28,6 @@ import verifiedIcon from 'icons/verified.svg';
import watchlistIcon from 'icons/watchlist.svg';
import { rightLineArrow } from 'lib/html-entities';
type NavItemCommon = {
text: string;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
}
type NavItemInternal = NavItemCommon & {
nextRoute: Route;
isActive?: boolean;
isNewUi?: boolean;
}
type NavItemExternal = NavItemCommon & {
url: string;
}
export type NavItem = NavItemInternal | NavItemExternal
export type NavGroupItem = NavItemCommon & {
isActive?: boolean;
subItems: Array<NavItem> | Array<Array<NavItem>>;
}
interface ReturnType {
mainNavItems: Array<NavItem | NavGroupItem>;
accountNavItems: Array<NavItem>;
......@@ -78,41 +57,38 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/accounts' as const },
icon: topAccountsIcon,
isActive: pathname === '/accounts',
isNewUi: true,
};
const blocks = {
text: 'Blocks',
nextRoute: { pathname: '/blocks' as const },
icon: blocksIcon,
isActive: pathname === '/blocks' || pathname === '/block/[height]',
isNewUi: true,
};
const txs = {
text: 'Transactions',
nextRoute: { pathname: '/txs' as const },
icon: transactionsIcon,
isActive: pathname === '/txs' || pathname === '/tx/[hash]',
isNewUi: true,
};
const verifiedContracts =
// eslint-disable-next-line max-len
{ text: 'Verified contracts', nextRoute: { pathname: '/verified-contracts' as const }, icon: verifiedIcon, isActive: pathname === '/verified-contracts', isNewUi: true };
{ text: 'Verified contracts', nextRoute: { pathname: '/verified-contracts' as const }, icon: verifiedIcon, isActive: pathname === '/verified-contracts' };
if (appConfig.L2.isL2Network) {
blockchainNavItems = [
[
txs,
// eslint-disable-next-line max-len
{ text: `Deposits (L1${ rightLineArrow }L2)`, nextRoute: { pathname: '/l2-deposits' as const }, icon: depositsIcon, isActive: pathname === '/l2-deposits', isNewUi: true },
{ text: `Deposits (L1${ rightLineArrow }L2)`, nextRoute: { pathname: '/l2-deposits' as const }, icon: depositsIcon, isActive: pathname === '/l2-deposits' },
// eslint-disable-next-line max-len
{ text: `Withdrawals (L2${ rightLineArrow }L1)`, nextRoute: { pathname: '/l2-withdrawals' as const }, icon: withdrawalsIcon, isActive: pathname === '/l2-withdrawals', isNewUi: true },
{ text: `Withdrawals (L2${ rightLineArrow }L1)`, nextRoute: { pathname: '/l2-withdrawals' as const }, icon: withdrawalsIcon, isActive: pathname === '/l2-withdrawals' },
],
[
blocks,
// eslint-disable-next-line max-len
{ text: 'Txn batches', nextRoute: { pathname: '/l2-txn-batches' as const }, icon: txnBatchIcon, isActive: pathname === '/l2-txn-batches', isNewUi: true },
{ text: 'Txn batches', nextRoute: { pathname: '/l2-txn-batches' as const }, icon: txnBatchIcon, isActive: pathname === '/l2-txn-batches' },
// eslint-disable-next-line max-len
{ text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: outputRootsIcon, isActive: pathname === '/l2-output-roots', isNewUi: true },
{ text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: outputRootsIcon, isActive: pathname === '/l2-output-roots' },
],
[
topAccounts,
......@@ -130,25 +106,22 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/withdrawals' as const },
icon: withdrawalsIcon,
isActive: pathname === '/withdrawals',
isNewUi: true,
},
].filter(Boolean);
}
const otherNavItems: Array<NavItem> = [
const apiNavItems: Array<NavItem> = [
hasAPIDocs ? {
text: 'REST API',
nextRoute: { pathname: '/api-docs' as const },
icon: apiDocsIcon,
isActive: pathname === '/api-docs',
isNewUi: true,
} : null,
{
text: 'GraphQL',
nextRoute: { pathname: '/graphiql' as const },
icon: graphQLIcon,
isActive: pathname === '/graphiql',
isNewUi: true,
},
{
text: 'RPC API',
......@@ -167,7 +140,6 @@ export default function useNavItems(): ReturnType {
text: 'Blockchain',
icon: globeIcon,
isActive: blockchainNavItems.flat().some(item => isInternalItem(item) && item.isActive),
isNewUi: true,
subItems: blockchainNavItems,
},
{
......@@ -175,24 +147,24 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/tokens' as const },
icon: tokensIcon,
isActive: pathname.startsWith('/token'),
isNewUi: true,
},
isMarketplaceAvailable ? {
text: 'Apps',
nextRoute: { pathname: '/apps' as const },
icon: appsIcon,
isActive: pathname.startsWith('/app'),
isNewUi: true,
} : null,
{ text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats', isNewUi: true },
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
otherNavItems.length > 0 ? {
{ text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats' },
{
text: 'API',
icon: apiDocsIcon,
isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive),
subItems: apiNavItems,
},
appConfig.otherLinks.length > 0 ? {
text: 'Other',
icon: gearIcon,
isActive: otherNavItems.some(item => isInternalItem(item) && item.isActive),
subItems: otherNavItems,
subItems: appConfig.otherLinks,
} : null,
].filter(Boolean) as Array<NavItem | NavGroupItem>;
......@@ -202,42 +174,42 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/account/watchlist' as const },
icon: watchlistIcon,
isActive: pathname === '/account/watchlist',
isNewUi: true,
},
{
text: 'Private tags',
nextRoute: { pathname: '/account/tag_address' as const },
icon: privateTagIcon,
isActive: pathname === '/account/tag_address',
isNewUi: true,
},
{
text: 'Public tags',
nextRoute: { pathname: '/account/public_tags_request' as const },
icon: publicTagIcon, isActive: pathname === '/account/public_tags_request',
isNewUi: true,
},
{
text: 'API keys',
nextRoute: { pathname: '/account/api_key' as const },
icon: apiKeysIcon, isActive: pathname === '/account/api_key',
isNewUi: true,
},
{
text: 'Custom ABI',
nextRoute: { pathname: '/account/custom_abi' as const },
icon: abiIcon,
isActive: pathname === '/account/custom_abi',
isNewUi: true,
},
];
appConfig.contractInfoApi.endpoint && appConfig.adminServiceApi.endpoint && {
text: 'Verified addrs',
nextRoute: { pathname: '/account/verified_addresses' as const },
icon: verifiedIcon,
isActive: pathname === '/account/verified_addresses',
},
].filter(Boolean);
const profileItem = {
text: 'My profile',
nextRoute: { pathname: '/auth/profile' as const },
icon: profileIcon,
isActive: pathname === '/auth/profile',
isNewUi: true,
};
return { mainNavItems, accountNavItems, profileItem };
......
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import useLoginUrl from 'lib/hooks/useLoginUrl';
export default function useRedirectIfNotAuth() {
const queryClient = useQueryClient();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
return React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
return true;
}
return false;
}, [ isAuth, loginUrl ]);
}
import appConfig from 'configs/app/config';
import { getServerSideProps as base } from '../getServerSideProps';
export const getServerSideProps: typeof base = async(...args) => {
if (!appConfig.isAccountSupported) {
return {
notFound: true,
};
}
return base(...args);
};
export const getServerSidePropsForVerifiedAddresses: typeof base = async(...args) => {
if (!appConfig.isAccountSupported || !appConfig.adminServiceApi.endpoint || !appConfig.contractInfoApi.endpoint) {
return {
notFound: true,
};
}
return base(...args);
};
export default function shortenString(string: string | null) {
if (!string) {
return '';
}
if (string.length <= 7) {
return string;
}
return string.slice(0, 4) + '...' + string.slice(-4);
}
export const EMAIL_REGEXP = /^[\w.%+-]+@[a-zA-Z\d-]+(?:\.[a-zA-Z\d-]+)+$/;
export const validator = (value: string) => EMAIL_REGEXP.test(value) ? true : 'Invalid email';
export const SIGNATURE_REGEXP = /^0x[a-fA-F\d]{130}$/;
export const validator = (value: string | undefined) => {
if (!value) {
return true;
}
try {
new URL(value);
return true;
} catch (error) {
return 'Incorrect URL';
}
};
import type { TokenInfoApplication, TokenInfoApplications, VerifiedAddress, VerifiedAddressResponse } from 'types/api/account';
import type { AddressValidationResponseSuccess } from 'ui/addressVerification/types';
export const SIGNATURE = '0x96491e0cd1b99c14951552361b7f6ff64f41651b5d1c12501914342c8a6847e21e08726c3505e11ba2af9a40ac0b05c8d113e7fd1f74594224b9c7276ebb3a661b';
export const VERIFIED_ADDRESS: Record<string, VerifiedAddress> = {
NEW_ITEM: {
userId: '1',
chainId: '99',
contractAddress: '0xF822070D07067D1519490dBf49448a7E30EE9ea5',
verifiedDate: '2022-09-01',
metadata: {
tokenName: 'Test Token',
tokenSymbol: 'TT',
},
},
ITEM_1: {
userId: '1',
chainId: '99',
contractAddress: '0xd0e3010d1ecdbd17aae178b2bf36eb413d8a7441',
verifiedDate: '2022-08-01',
metadata: {
tokenName: 'My Token',
tokenSymbol: 'MYT',
},
},
ITEM_2: {
userId: '1',
chainId: '99',
contractAddress: '0xa8FCe579a11E551635b9c9CB915BEcd873C51254',
verifiedDate: '2022-09-23',
metadata: {
tokenName: 'Cat Token',
tokenSymbol: 'CATT',
},
},
};
export const ADDRESS_CHECK_RESPONSE = {
SUCCESS: {
status: 'SUCCESS',
result: {
// eslint-disable-next-line max-len
signingMessage: '[eth-goerli.blockscout.com] [2023-04-18 18:47:40] I, hereby verify that I am the owner/creator of the address [0xf822070d07067d1519490dbf49448a7e30ee9ea5]',
contractCreator: '0xd0e3010d1ecdbd17aae178b2bf36eb413d8a7441',
contractOwner: '0xa8FCe579a11E551635b9c9CB915BEcd873C51254',
},
},
SOURCE_CODE_NOT_VERIFIED_ERROR: {
status: 'SOURCE_CODE_NOT_VERIFIED_ERROR',
},
};
export const ADDRESS_VERIFY_RESPONSE: Record<string, AddressValidationResponseSuccess> = {
SUCCESS: {
status: 'SUCCESS',
result: {
verifiedAddress: VERIFIED_ADDRESS.NEW_ITEM,
},
},
INVALID_SIGNER_ERROR: {
status: 'INVALID_SIGNER_ERROR',
invalidSigner: {
signer: '0xF822070D07067D1519490dBf49448a7E30EE9ea5',
},
},
};
export const VERIFIED_ADDRESS_RESPONSE: Record<string, VerifiedAddressResponse> = {
DEFAULT: {
verifiedAddresses: [
VERIFIED_ADDRESS.ITEM_1,
VERIFIED_ADDRESS.ITEM_2,
],
},
};
export const TOKEN_INFO_APPLICATION_BASE = {
id: '1',
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
status: 'APPROVED',
updatedAt: '2022-11-08 12:47:10.149148Z',
requesterName: 'Tom',
requesterEmail: 'tom@example.com',
projectName: 'My project',
projectWebsite: 'http://example.com',
projectEmail: 'token@example.com',
iconUrl: 'https://placekitten.com/100',
projectDescription: 'description',
projectSector: 'DeFi',
comment: '',
docs: 'https://example.com/docs',
github: 'https://github.com',
telegram: 'https://telegram.com',
linkedin: 'https://linkedin.com',
discord: 'https://discord.com',
slack: 'https://slack.com',
twitter: 'https://twitter.com',
openSea: 'https://opensea.com',
facebook: 'https://facebook.com',
medium: 'https://medium.com',
reddit: 'https://reddit.com',
support: 'support@example.com',
coinMarketCapTicker: 'https://coinmarketcap.com',
coinGeckoTicker: 'https://coingecko.com',
defiLlamaTicker: 'https://defillama.com',
};
export const TOKEN_INFO_APPLICATION: Record<string, TokenInfoApplication> = {
APPROVED: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
id: '1',
status: 'APPROVED',
updatedAt: '2022-11-08 12:47:10.149148Z',
},
IN_PROCESS: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_2.contractAddress,
id: '2',
status: 'IN_PROCESS',
updatedAt: '2022-11-10 08:11:10.149148Z',
},
UPDATED_ITEM: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
id: '1',
status: 'IN_PROCESS',
updatedAt: '2022-11-11 05:11:10.149148Z',
},
};
export const TOKEN_INFO_APPLICATIONS_RESPONSE: Record<string, TokenInfoApplications> = {
DEFAULT: {
submissions: [
TOKEN_INFO_APPLICATION.APPROVED,
TOKEN_INFO_APPLICATION.IN_PROCESS,
],
},
FOR_UPDATE: {
submissions: [
{
...TOKEN_INFO_APPLICATION.APPROVED,
status: 'UPDATE_REQUIRED',
},
TOKEN_INFO_APPLICATION.IN_PROCESS,
],
},
};
export const TOKEN_INFO_FORM_CONFIG = {
projectSectors: [
'Infra & Dev tooling',
'DeFi',
'Data',
'Bridge',
'NFT',
'Payments',
'Faucet',
'DAO',
'Games',
'Wallet',
],
};
import type { AddressTokenBalance } from 'types/api/address';
import * as tokens from 'mocks/tokens/tokenInfo';
import * as tokenInstance from 'mocks/tokens/tokenInstance';
export const erc20a: AddressTokenBalance = {
token: tokens.tokenInfoERC20a,
......@@ -59,24 +60,28 @@ export const erc721LongSymbol: AddressTokenBalance = {
export const erc1155a: AddressTokenBalance = {
token: tokens.tokenInfoERC1155a,
token_id: '42',
token_instance: tokenInstance.base,
value: '24',
};
export const erc1155b: AddressTokenBalance = {
token: tokens.tokenInfoERC1155b,
token_id: '100010000000001',
token_instance: tokenInstance.base,
value: '11',
};
export const erc1155withoutName: AddressTokenBalance = {
token: tokens.tokenInfoERC1155WithoutName,
token_id: '64532245',
token_instance: tokenInstance.base,
value: '42',
};
export const erc1155LongId: AddressTokenBalance = {
token: tokens.tokenInfoERC1155b,
token_id: '483200961027732618117991942553110860267520',
token_instance: tokenInstance.base,
value: '42',
};
......
......@@ -9,6 +9,7 @@ export const tokenInfo: TokenInfo = {
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '1235',
icon_url: null,
};
export const tokenCounters: TokenCounters = {
......@@ -25,6 +26,7 @@ export const tokenInfoERC20a: TokenInfo = {
symbol: 'HyFi',
total_supply: '369000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20b: TokenInfo = {
......@@ -36,6 +38,7 @@ export const tokenInfoERC20b: TokenInfo = {
symbol: 'USDC',
total_supply: '900000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20c: TokenInfo = {
......@@ -47,6 +50,7 @@ export const tokenInfoERC20c: TokenInfo = {
symbol: 'ETH',
total_supply: '1000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20d: TokenInfo = {
......@@ -58,6 +62,7 @@ export const tokenInfoERC20d: TokenInfo = {
symbol: 'ZETA',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20LongSymbol: TokenInfo = {
......@@ -69,6 +74,7 @@ export const tokenInfoERC20LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC721a: TokenInfo = {
......@@ -80,6 +86,7 @@ export const tokenInfoERC721a: TokenInfo = {
symbol: 'HYFI_ATHENA',
total_supply: '105',
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721b: TokenInfo = {
......@@ -91,6 +98,7 @@ export const tokenInfoERC721b: TokenInfo = {
symbol: 'WOWG',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721c: TokenInfo = {
......@@ -102,6 +110,7 @@ export const tokenInfoERC721c: TokenInfo = {
symbol: 'PUMA',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721LongSymbol: TokenInfo = {
......@@ -113,6 +122,7 @@ export const tokenInfoERC721LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC1155a: TokenInfo = {
......@@ -124,6 +134,7 @@ export const tokenInfoERC1155a: TokenInfo = {
symbol: 'HYFI_MEMBERSHIP',
total_supply: '482',
type: 'ERC-1155',
icon_url: null,
};
export const tokenInfoERC1155b: TokenInfo = {
......@@ -135,6 +146,7 @@ export const tokenInfoERC1155b: TokenInfo = {
symbol: 'WVC',
total_supply: '4943',
type: 'ERC-1155',
icon_url: null,
};
export const tokenInfoERC1155WithoutName: TokenInfo = {
......@@ -146,4 +158,5 @@ export const tokenInfoERC1155WithoutName: TokenInfo = {
symbol: null,
total_supply: '482',
type: 'ERC-1155',
icon_url: null,
};
......@@ -30,6 +30,7 @@ export const erc20: TokenTransfer = {
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '0',
icon_url: null,
},
total: {
decimals: '18',
......@@ -73,6 +74,7 @@ export const erc721: TokenTransfer = {
symbol: 'AriaSA',
type: 'ERC-721',
total_supply: '0',
icon_url: null,
},
total: {
token_id: '875879856',
......@@ -115,6 +117,7 @@ export const erc1155A: TokenTransfer = {
symbol: 'MY_SYMBOL_IS_VERY_LONG',
type: 'ERC-1155',
total_supply: '0',
icon_url: null,
},
total: {
token_id: '123',
......
......@@ -31,6 +31,7 @@ export const mintToken: TxStateChange = {
symbol: 'nMOONBIRD',
total_supply: '10645',
type: 'ERC-721',
icon_url: null,
},
type: 'token',
};
......@@ -66,6 +67,7 @@ export const receiveMintedToken: TxStateChange = {
symbol: 'nMOONBIRD',
total_supply: '10645',
type: 'ERC-721',
icon_url: null,
},
type: 'token',
};
......
......@@ -9,7 +9,7 @@ import type { ResourceError } from 'lib/api/resources';
import { AppContextProvider } from 'lib/appContext';
import { Chakra } from 'lib/Chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import getErrorStatusCode from 'lib/errors/getErrorStatusCode';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import useConfigSentry from 'lib/hooks/useConfigSentry';
import { SocketProvider } from 'lib/socket/context';
import theme from 'theme';
......@@ -29,7 +29,7 @@ function MyApp({ Component, pageProps }: AppProps) {
refetchOnWindowFocus: false,
retry: (failureCount, _error) => {
const error = _error as ResourceError<{ status: number }>;
const status = error?.status || error?.payload?.status;
const status = error?.payload?.status || error?.status;
if (status && status >= 400 && status < 500) {
// don't do retry for client error responses
return false;
......@@ -42,7 +42,7 @@ function MyApp({ Component, pageProps }: AppProps) {
}));
const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorStatusCode(error);
const statusCode = getErrorCauseStatusCode(error);
return (
<AppError
......
......@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import ApiKeys from 'ui/pages/ApiKeys';
import Page from 'ui/shared/Page/Page';
const ApiKeysPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<ApiKeys/>
<Page>
<ApiKeys/>
</Page>
</>
);
};
export default ApiKeysPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import CustomAbi from 'ui/pages/CustomAbi';
import Page from 'ui/shared/Page/Page';
const CustomAbiPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<CustomAbi/>
<Page>
<CustomAbi/>
</Page>
</>
);
};
export default CustomAbiPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PublicTags from 'ui/pages/PublicTags';
import Page from 'ui/shared/Page/Page';
const PublicTagsPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<PublicTags/>
<Page>
<PublicTags/>
</Page>
</>
);
};
export default PublicTagsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PrivateTags from 'ui/pages/PrivateTags';
import Page from 'ui/shared/Page/Page';
const AddressTagsPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<PrivateTags/>
<Page>
<PrivateTags/>
</Page>
</>
);
};
export default AddressTagsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import VerifiedAddresses from 'ui/pages/VerifiedAddresses';
import Page from 'ui/shared/Page/Page';
const VerifiedAddressesPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head><title>{ title }</title></Head>
<Page>
<VerifiedAddresses/>
</Page>
</>
);
};
export default VerifiedAddressesPage;
export { getServerSidePropsForVerifiedAddresses as getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -4,6 +4,7 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import WatchList from 'ui/pages/Watchlist';
import Page from 'ui/shared/Page/Page';
const WatchListPage: NextPage = () => {
const title = getNetworkTitle();
......@@ -12,11 +13,13 @@ const WatchListPage: NextPage = () => {
<Head>
<title>{ title }</title>
</Head>
<WatchList/>
<Page>
<WatchList/>
</Page>
</>
);
};
export default WatchListPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -15,7 +15,7 @@ const APIDocsPage: NextPage = () => {
return (
<Page>
<PageTitle text="API Documentation"/>
<PageTitle title="API Documentation"/>
<Head><title>{ `API for the ${ networkTitle }` }</title></Head>
<SwaggerUI/>
</Page>
......
......@@ -9,7 +9,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const MarketplacePage: NextPage = () => {
return (
<Page>
<PageTitle text="Marketplace"/>
<PageTitle title="Marketplace"/>
<Head><title>Blockscout | Marketplace</title></Head>
<Marketplace/>
......
......@@ -3,16 +3,19 @@ import Head from 'next/head';
import React from 'react';
import MyProfile from 'ui/pages/MyProfile';
import Page from 'ui/shared/Page/Page';
const MyProfilePage: NextPage = () => {
return (
<>
<Head><title>My profile</title></Head>
<MyProfile/>
<Page>
<MyProfile/>
</Page>
</>
);
};
export default MyProfilePage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import Graph from 'ui/pages/Graph';
const GraphPage: NextPage = () => {
return (
<>
<Head><title>Graph Page</title></Head>
<Graph/>
</>
);
};
export default GraphPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -16,7 +16,7 @@ const GraphiqlPage: NextPage = () => {
return (
<Page>
<Head><title>Graph Page</title></Head>
<PageTitle text="GraphQL playground"/>
<PageTitle title="GraphQL playground"/>
<GraphQL/>
</Page>
);
......
......@@ -7,7 +7,7 @@ interface Env {
}
// keep in mind that all passed variables here should be present in env config files (.env.pw or .env.poa)
export default function contextWithEnvs(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
export default function contextWithEnvsFixture(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
return async({ browser }, use) => {
const context = await createContextWithEnvs(browser, envs);
......
......@@ -14,6 +14,7 @@ export const TOKEN_INFO_ERC_20: TokenInfo<'ERC-20'> = {
symbol: 'STUB',
total_supply: '6000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const TOKEN_INFO_ERC_721: TokenInfo<'ERC-721'> = {
......
......@@ -50,6 +50,16 @@ const colors = {
'800': 'RGBA(16, 17, 18, 0.80)',
'900': 'RGBA(16, 17, 18, 0.92)',
},
github: '#171923',
telegram: '#2775CA',
linkedin: '#1564BA',
discord: '#9747FF',
slack: '#1BA27A',
twitter: '#63B3ED',
opensea: '#2081E2',
facebook: '#4460A0',
medium: '#231F20',
reddit: '#FF4500',
};
export default colors;
import { theme } from '@chakra-ui/react';
export const BODY_TYPEFACE = 'Inter';
export const HEADING_TYPEFACE = 'Poppins';
const typography = {
fonts: {
heading: `Poppins, ${ theme.fonts.heading }`,
heading: `${ HEADING_TYPEFACE }, ${ theme.fonts.heading }`,
body: `${ BODY_TYPEFACE }, ${ theme.fonts.body }`,
},
textStyles: {
......
......@@ -3,7 +3,6 @@ const zIndices = {
auto: 'auto',
base: 0,
docked: 10,
tooltip: 900,
dropdown: 1000,
sticky: 1100,
sticky1: 1101,
......@@ -12,6 +11,7 @@ const zIndices = {
overlay: 1300,
modal: 1400,
popover: 1500,
tooltip: 1550, // otherwise tooltips will not be visible in modals
skipLink: 1600,
toast: 1700,
};
......
......@@ -160,3 +160,58 @@ export type PublicTagErrors = {
full_name: Array<string>;
tags: Array<string>;
}
export interface VerifiedAddress {
userId: string;
chainId: string;
contractAddress: string;
verifiedDate: string;
metadata: {
tokenName: string | null;
tokenSymbol: string | null;
};
}
export interface VerifiedAddressResponse {
verifiedAddresses: Array<VerifiedAddress>;
}
export interface TokenInfoApplicationConfig {
projectSectors: Array<string>;
}
export interface TokenInfoApplication {
adminComments?: string;
coinGeckoTicker?: string;
coinMarketCapTicker?: string;
comment?: string;
defiLlamaTicker?: string;
discord?: string;
docs?: string;
facebook?: string;
github?: string;
iconUrl: string;
id: string;
linkedin?: string;
medium?: string;
openSea?: string;
projectDescription?: string;
projectEmail: string;
projectName?: string;
projectSector?: string;
projectWebsite: string;
reddit?: string;
requesterEmail: string;
requesterName: string;
slack?: string;
status: 'STATUS_UNKNOWN' | 'IN_PROCESS' | 'APPROVED' | 'REJECTED' | 'UPDATE_REQUIRED';
support?: string;
telegram?: string;
tokenAddress: string;
twitter?: string;
updatedAt: string;
}
export interface TokenInfoApplications {
submissions: Array<TokenInfoApplication>;
}
import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams';
import type { UserTags } from './addressParams';
import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenType } from './token';
import type { TokenInfo, TokenInstance, TokenType } from './token';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address {
export interface Address extends UserTags {
block_number_balance_updated_at: number | null;
coin_balance: string | null;
creator_address_hash: string | null;
......@@ -30,11 +30,8 @@ export interface Address {
is_contract: boolean;
is_verified: boolean;
name: string | null;
private_tags: Array<AddressTag> | null;
public_tags: Array<AddressTag> | null;
token: TokenInfo | null;
watchlist_address_id: number | null;
watchlist_names: Array<WatchlistName> | null;
}
export interface AddressCounters {
......@@ -48,6 +45,7 @@ export interface AddressTokenBalance {
token: TokenInfo;
token_id: string | null;
value: string;
token_instance?: TokenInstance;
}
export interface AddressTokensResponse {
......
......@@ -9,13 +9,16 @@ export interface WatchlistName {
display_name: string;
}
export interface AddressParam {
export interface UserTags {
private_tags: Array<AddressTag> | null;
watchlist_names: Array<WatchlistName> | null;
public_tags: Array<AddressTag> | null;
}
export interface AddressParam extends UserTags {
hash: string;
implementation_name: string | null;
name: string | null;
is_contract: boolean;
is_verified: boolean | null;
private_tags: Array<AddressTag> | null;
watchlist_names: Array<WatchlistName> | null;
public_tags: Array<AddressTag> | null;
}
......@@ -13,20 +13,20 @@ export interface SearchResultAddressOrContract {
type: 'address' | 'contract';
name: string | null;
address: string;
url: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export interface SearchResultBlock {
type: 'block';
block_number: number;
block_number: number | string;
block_hash: string;
url: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export interface SearchResultTx {
type: 'transaction';
tx_hash: string;
url: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx;
......
import type { TokenInfoApplication } from './account';
import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155';
......@@ -11,6 +12,7 @@ export interface TokenInfo<T extends TokenType = TokenType> {
holders: string | null;
exchange_rate: string | null;
total_supply: string | null;
icon_url: string | null;
}
export interface TokenCounters {
......@@ -57,3 +59,5 @@ export interface TokenInventoryResponse {
export type TokenInventoryPagination = {
unique_token: number;
}
export type TokenVerifiedInfo = Omit<TokenInfoApplication, 'id' | 'status'>;
import type { Route } from 'nextjs-routes';
type NavItemCommon = {
text: string;
icon?: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
}
export type NavItemInternal = NavItemCommon & {
nextRoute: Route;
isActive?: boolean;
}
export type NavItemExternal = NavItemCommon & {
url: string;
}
export type NavItem = NavItemInternal | NavItemExternal
export type NavGroupItem = NavItemCommon & {
isActive?: boolean;
subItems: Array<NavItem> | Array<Array<NavItem>>;
}
......@@ -15,6 +15,8 @@ export interface NetworkExplorer {
paths: {
tx?: string;
address?: string;
token?: string;
block?: string;
};
}
......
......@@ -10,6 +10,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/account/custom_abi">
| StaticRoute<"/account/public_tags_request">
| StaticRoute<"/account/tag_address">
| StaticRoute<"/account/verified_addresses">
| StaticRoute<"/account/watchlist">
| StaticRoute<"/accounts">
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
......@@ -25,7 +26,6 @@ declare module "nextjs-routes" {
| DynamicRoute<"/block/[height]", { "height": string }>
| StaticRoute<"/blocks">
| StaticRoute<"/csv-export">
| StaticRoute<"/graph">
| StaticRoute<"/graphiql">
| StaticRoute<"/">
| StaticRoute<"/l2-deposits">
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
addressHash?: string;
}
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contact_code');
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
}
}, [ tabs ]);
return (
<WagmiConfig client={ wagmiClient }>
<Web3ModalProvider fallback={ fallback }>
<ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
</Web3ModalProvider>
);
};
......
import { Box, Flex, Text, Icon, Grid } from '@chakra-ui/react';
import { Box, Text, Icon, Grid } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
......@@ -6,7 +6,6 @@ import React from 'react';
import type { Address as TAddress } from 'types/api/address';
import appConfig from 'configs/app/config';
import blockIcon from 'icons/block.svg';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
......@@ -18,7 +17,6 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal';
import AddressBalance from './details/AddressBalance';
......@@ -83,21 +81,11 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
return <AddressDetailsSkeleton/>;
}
const explorers = appConfig.network.explorers.filter(({ paths }) => paths.address);
const data = addressQuery.isError ? errorData : addressQuery.data;
return (
<Box>
<AddressHeadingInfo address={ data } token={ data.token } isLinkDisabled/>
{ explorers.length > 0 && (
<Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap">
<Text fontSize="sm">Verify with other explorers</Text>
{ explorers.map((explorer) => {
const url = new URL(explorer.paths.address + '/' + addressHash, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>{ explorer.title }</LinkExternal>;
}) }
</Flex>
) }
<Grid
mt={ 8 }
columnGap={ 8 }
......
......@@ -194,11 +194,17 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
</>
) : null;
const tokenData = React.useMemo(() => ({
address: tokenFilter || '',
name: '',
icon_url: '',
}), [ tokenFilter ]);
const tokenFilterComponent = tokenFilter && (
<Flex alignItems="center" flexWrap="wrap" mb={{ base: isActionBarHidden ? 3 : 6, lg: 0 }} mr={ 4 }>
<Text whiteSpace="nowrap" mr={ 2 } py={ 1 }>Filtered by token</Text>
<Flex alignItems="center" py={ 1 }>
<TokenLogo hash={ tokenFilter } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ tokenData } boxSize={ 6 } mr={ 2 }/>
{ isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter }
<Tooltip label="Reset filter">
<Flex>
......
......@@ -222,7 +222,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
<RawDataSnippet
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ data.is_verified ? null : verificationButton }
rightSlot={ data.is_verified || data.is_self_destructed ? null : verificationButton }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
......
......@@ -63,6 +63,12 @@ const AddressBalance = ({ data }: Props) => {
handler: handleNewCoinBalanceMessage,
});
const tokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return (
<DetailsInfoItem
title="Balance"
......@@ -71,8 +77,7 @@ const AddressBalance = ({ data }: Props) => {
alignItems="flex-start"
>
<TokenLogo
hash={ appConfig.network.currency.address }
name={ appConfig.network.currency.name }
data={ tokenData }
boxSize={ 5 }
mr={ 2 }
fontSize="sm"
......
......@@ -3,14 +3,14 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import type { ResourceError } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import useToast from 'lib/hooks/useToast';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
......@@ -25,18 +25,33 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
const deleteModalProps = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
const toast = useToast();
const redirectIfNotAuth = useRedirectIfNotAuth();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const profileState = queryClient.getQueryState<unknown, ResourceError<{ message: string }>>([ resourceKey('user_info') ]);
const handleClick = React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
if (profileState?.error?.status === 403) {
const isUnverifiedEmail = profileState.error.payload?.message.includes('Unverified email');
if (isUnverifiedEmail) {
toast({
position: 'top-right',
title: 'Error',
description: 'Unable to add address to watch list. Please go to the watch list page instead.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
return;
}
}
if (redirectIfNotAuth()) {
return;
}
watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, watchListId, isAuth, loginUrl ]);
}, [ profileState, redirectIfNotAuth, watchListId, deleteModalProps, addModalProps, toast ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => {
const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } });
......
......@@ -65,7 +65,7 @@ const TokenSelectItem = ({ data }: Props) => {
href={ url }
>
<Flex alignItems="center" w="100%">
<TokenLogo hash={ data.token.address } name={ data.token.name } boxSize={ 6 }/>
<TokenLogo data={ data.token } boxSize={ 6 }/>
<Text fontWeight={ 700 } ml={ 2 }>{ data.token.name || <HashStringShorten hash={ data.token.address }/> }</Text>
{ data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
</Flex>
......
......@@ -24,7 +24,7 @@ const ERC20TokensListItem = ({ token, value }: Props) => {
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
......
......@@ -27,7 +27,7 @@ const ERC20TokensTableItem = ({
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
</Td>
......
import { Flex, HStack, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
......@@ -12,14 +13,17 @@ import TokenLogo from 'ui/shared/TokenLogo';
type Props = AddressTokenBalance;
const ERC721TokensListItem = ({ token, value }: Props) => {
const router = useRouter();
const hash = router.query.hash?.toString() || '';
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
<AddressLink hash={ token.address } type="address" truncation="constant"/>
......
import { Tr, Td, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
......@@ -14,15 +15,17 @@ const ERC721TokensTableItem = ({
token,
value,
}: Props) => {
const router = useRouter();
const hash = router.query.hash?.toString() || '';
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
return (
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex>
</Td>
<Td verticalAlign="middle">
......
......@@ -4,13 +4,13 @@ import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
import NftImage from 'ui/shared/nft/NftImage';
import NftMedia from 'ui/shared/nft/NftMedia';
import TokenLogo from 'ui/shared/TokenLogo';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = AddressTokenBalance;
const NFTItem = ({ token, token_id: tokenId }: Props) => {
const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance }: Props) => {
const tokenLink = route({ pathname: '/token/[hash]', query: { hash: token.address } });
return (
......@@ -26,11 +26,10 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
lineHeight="20px"
>
<LinkOverlay href={ tokenLink }>
<NftImage
<NftMedia
mb="18px"
url={ null }
fallbackPadding="30px"
cursor="pointer"
imageUrl={ tokenInstance?.image_url || null }
animationUrl={ tokenInstance?.animation_url || null }
/>
</LinkOverlay>
{ tokenId && (
......@@ -50,7 +49,7 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
) }
{ token.name && (
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TokenLogo data={ token } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TruncatedTextTooltip label={ token.name }>
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text>
</TruncatedTextTooltip>
......
import { Icon, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Link } from '@chakra-ui/react';
import React from 'react';
import type { AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess } from './types';
import type { VerifiedAddress } from 'types/api/account';
import eastArrowIcon from 'icons/arrows/east.svg';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import AddressVerificationStepAddress from './steps/AddressVerificationStepAddress';
import AddressVerificationStepSignature from './steps/AddressVerificationStepSignature';
import AddressVerificationStepSuccess from './steps/AddressVerificationStepSuccess';
type StateData = AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess & { isToken?: boolean };
interface Props {
isOpen: boolean;
onClose: () => void;
onSubmit: (address: VerifiedAddress) => void;
onAddTokenInfoClick: (address: string) => void;
onShowListClick: () => void;
defaultAddress?: string;
}
const AddressVerificationModal = ({ defaultAddress, isOpen, onClose, onSubmit, onAddTokenInfoClick, onShowListClick }: Props) => {
const [ stepIndex, setStepIndex ] = React.useState(0);
const [ data, setData ] = React.useState<StateData>({ address: '', signingMessage: '' });
const handleGoToSecondStep = React.useCallback((firstStepResult: typeof data) => {
setData(firstStepResult);
setStepIndex((prev) => prev + 1);
}, []);
const handleGoToThirdStep = React.useCallback((address: VerifiedAddress) => {
onSubmit(address);
setStepIndex((prev) => prev + 1);
setData((prev) => ({ ...prev, isToken: Boolean(address.metadata.tokenName) }));
}, [ onSubmit ]);
const handleGoToPrevStep = React.useCallback(() => {
setStepIndex((prev) => prev - 1);
}, []);
const handleClose = React.useCallback(() => {
onClose();
setStepIndex(0);
setData({ address: '', signingMessage: '' });
}, [ onClose ]);
const handleAddTokenInfoClick = React.useCallback(() => {
onAddTokenInfoClick(data.address);
handleClose();
}, [ handleClose, data.address, onAddTokenInfoClick ]);
const steps = [
{
title: 'Verify new address ownership',
content: <AddressVerificationStepAddress onContinue={ handleGoToSecondStep } defaultAddress={ defaultAddress }/>,
},
{
title: 'Copy and sign message',
content: <AddressVerificationStepSignature { ...data } onContinue={ handleGoToThirdStep }/>,
fallback: <AddressVerificationStepSignature { ...data } onContinue={ handleGoToThirdStep } noWeb3Provider/>,
},
{
title: 'Congrats! Address is verified.',
content: (
<AddressVerificationStepSuccess
onShowListClick={ onShowListClick }
onAddTokenInfoClick={ handleAddTokenInfoClick }
isToken={ data.isToken }
address={ data.address }
/>
),
},
];
const step = steps[stepIndex];
return (
<Modal isOpen={ isOpen } onClose={ handleClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 6 }>
{ stepIndex !== 0 && (
<Link mr={ 3 } onClick={ handleGoToPrevStep }>
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" verticalAlign="middle"/>
</Link>
) }
<span>{ step.title }</span>
</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
<Web3ModalProvider fallback={ step?.fallback || step.content }>
{ step.content }
</Web3ModalProvider>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default React.memo(AddressVerificationModal);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormFirstStepFields, RootFields } from '../types';
import { ADDRESS_REGEXP, ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldAddress = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
const error = 'address' in formState.errors ? formState.errors.address : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor } mt={ 8 }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract address (0x...)" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldAddress);
import { FormControl, Textarea, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldMessage = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'message'>}) => {
const error = 'message' in formState.errors ? formState.errors.message : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Textarea
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled
autoComplete="off"
maxH={{ base: '140px', lg: '80px' }}
/>
<InputPlaceholder text="Message to sign" error={ error } isInModal/>
</FormControl>
);
}, [ formState.errors, backgroundColor ]);
return (
<Controller
defaultValue="some value"
name="message"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AddressVerificationFieldMessage);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import { SIGNATURE_REGEXP } from 'lib/validations/signature';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldSignature = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'signature'>}) => {
const error = 'signature' in formState.errors ? formState.errors.signature : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Signature hash" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="signature"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: SIGNATURE_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldSignature);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressVerificationStepAddress from './AddressVerificationStepAddress';
const CHECK_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':prepare' });
test('base view', async({ mount, page }) => {
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SUCCESS),
}));
const props = {
onContinue: () => {},
defaultAddress: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
};
await mount(
<TestApp>
<AddressVerificationStepAddress { ...props }/>
</TestApp>,
);
await expect(page).toHaveScreenshot();
});
test('SOURCE_CODE_NOT_VERIFIED_ERROR view +@mobile', async({ mount, page }) => {
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SOURCE_CODE_NOT_VERIFIED_ERROR),
}));
const props = {
onContinue: () => {},
};
await mount(
<TestApp>
<AddressVerificationStepAddress { ...props }/>
</TestApp>,
);
const addressInput = page.getByLabel(/smart contract address/i);
await addressInput.focus();
await addressInput.type(mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress);
await page.getByRole('button', { name: /continue/i }).click();
await expect(page).toHaveScreenshot();
});
import { Alert, Box, Button, Flex } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type {
AddressVerificationResponseError,
AddressCheckResponseSuccess,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
} from '../types';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import LinkInternal from 'ui/shared/LinkInternal';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldAddress';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
defaultAddress?: string;
onContinue: (data: AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess) => void;
}
const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) => {
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: {
address: defaultAddress,
},
});
const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const address = watch('address');
React.useEffect(() => {
clearErrors('root');
}, [ address, clearErrors ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: data.address,
};
const response = await apiFetch<'address_verification', AddressCheckResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':prepare' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_ERROR' : response.status;
const message = ('payload' in response ? response.payload?.message : undefined) || 'Oops! Something went wrong';
return setError('root', { type, message });
}
onContinue({ ...response.result, address: data.address });
} catch (_error) {
const error = _error as ResourceError<AddressVerificationResponseError>;
setError('root', { type: 'manual', message: error.payload?.message || 'Oops! Something went wrong' });
}
}, [ apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_ADDRESS_ERROR': {
return <span>Specified address either does not exist or is EOA.</span>;
}
case 'IS_OWNER_ERROR': {
return <span>Ownership of this contract address is already verified by this account.</span>;
}
case 'OWNERSHIP_VERIFIED_ERROR': {
return <span>Ownership of this contract address is already verified by another account.</span>;
}
case 'SOURCE_CODE_NOT_VERIFIED_ERROR': {
const href = route({ pathname: '/address/[hash]/contract_verification', query: { hash: address } });
return (
<Box>
<span>The contract source code you entered is not yet verified. Please follow these steps to </span>
<LinkInternal href={ href }>verify the contract</LinkInternal>
<span>.</span>
</Box>
);
}
case undefined: {
return null;
}
default: {
return formState.errors.root?.message;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
<Box>Enter the contract address you are verifying ownership for.</Box>
{ rootError && <Alert status="warning" mt={ 3 }>{ rootError }</Alert> }
<AddressVerificationFieldAddress formState={ formState } control={ control }/>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
<Button size="lg" type="submit" isDisabled={ formState.isSubmitting } flexShrink={ 0 }>
Continue
</Button>
<AdminSupportText/>
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepAddress);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressVerificationStepSignature from './AddressVerificationStepSignature';
const VERIFY_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':verify' });
test('base view', async({ mount, page }) => {
await page.route(VERIFY_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.SUCCESS),
}));
const props = {
onContinue: () => {},
noWeb3Provider: true,
address: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
signingMessage: mocks.ADDRESS_CHECK_RESPONSE.SUCCESS.result.signingMessage,
};
await mount(
<TestApp>
<AddressVerificationStepSignature { ...props }/>
</TestApp>,
);
await expect(page).toHaveScreenshot();
});
test('INVALID_SIGNER_ERROR view +@mobile', async({ mount, page }) => {
await page.route(VERIFY_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.INVALID_SIGNER_ERROR),
}));
const props = {
onContinue: () => {},
noWeb3Provider: true,
address: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
...mocks.ADDRESS_CHECK_RESPONSE.SUCCESS.result,
};
await mount(
<TestApp>
<AddressVerificationStepSignature { ...props }/>
</TestApp>,
);
const signatureInput = page.getByLabel(/signature hash/i);
await signatureInput.fill(mocks.SIGNATURE);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveScreenshot();
});
import { Alert, Box, Button, chakra, Flex, Link, Radio, RadioGroup } from '@chakra-ui/react';
import { useWeb3Modal } from '@web3modal/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useSignMessage, useAccount } from 'wagmi';
import type {
AddressVerificationFormSecondStepFields,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
AddressVerificationResponseError,
AddressValidationResponseSuccess,
} from '../types';
import type { VerifiedAddress } from 'types/api/account';
import appConfig from 'configs/app/config';
import useApiFetch from 'lib/api/useApiFetch';
import shortenString from 'lib/shortenString';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldMessage from '../fields/AddressVerificationFieldMessage';
import AddressVerificationFieldSignature from '../fields/AddressVerificationFieldSignature';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props extends AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess{
onContinue: (newItem: VerifiedAddress) => void;
noWeb3Provider?: boolean;
}
const AddressVerificationStepSignature = ({ address, signingMessage, contractCreator, contractOwner, onContinue, noWeb3Provider }: Props) => {
const [ signMethod, setSignMethod ] = React.useState<'wallet' | 'manually'>(noWeb3Provider ? 'manually' : 'wallet');
const { open: openWeb3Modal } = useWeb3Modal();
const { isConnected } = useAccount();
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: {
message: signingMessage,
},
});
const { handleSubmit, formState, control, setValue, getValues, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const signature = watch('signature');
React.useEffect(() => {
clearErrors('root');
}, [ clearErrors, signature ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: address,
message: data.message,
signature: data.signature,
};
const response = await apiFetch<'address_verification', AddressValidationResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':verify' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_STATUS' : response.status;
return setError('root', { type, message: response.status === 'INVALID_SIGNER_ERROR' ? response.invalidSigner.signer : undefined });
}
onContinue(response.result.verifiedAddress);
} catch (error) {
setError('root', { type: 'UNKNOWN_STATUS' });
}
}, [ address, apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const { signMessage, isLoading: isSigning } = useSignMessage({
onSuccess: (data) => {
setValue('signature', data);
onSubmit();
},
onError: (error) => {
return setError('root', { type: 'SIGNING_FAIL', message: (error as Error)?.message || 'Oops! Something went wrong' });
},
});
const handleSignMethodChange = React.useCallback((value: typeof signMethod) => {
setSignMethod(value);
clearErrors('root');
}, [ clearErrors ]);
const handleOpenWeb3Modal = React.useCallback(() => {
openWeb3Modal();
}, [ openWeb3Modal ]);
const handleWeb3SignClick = React.useCallback(() => {
if (!isConnected) {
return setError('root', { type: 'manual', message: 'Please connect to your Web3 wallet first' });
}
const message = getValues('message');
signMessage({ message });
}, [ getValues, signMessage, isConnected, setError ]);
const handleManualSignClick = React.useCallback(() => {
onSubmit();
}, [ onSubmit ]);
const button = (() => {
if (signMethod === 'manually') {
return (
<Button
size="lg"
onClick={ handleManualSignClick }
isLoading={ formState.isSubmitting }
loadingText="Verifying"
>
Verify
</Button>
);
}
return (
<Button
size="lg"
onClick={ isConnected ? handleWeb3SignClick : handleOpenWeb3Modal }
isLoading={ formState.isSubmitting || isSigning }
loadingText={ isSigning ? 'Signing' : 'Verifying' }
>
{ isConnected ? 'Sign and verify' : 'Connect wallet' }
</Button>
);
})();
const contactUsLink = <span>contact us <Link href="mailto:help@blockscout.com">help@blockscout.com</Link></span>;
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_SIGNATURE_ERROR': {
return <span>The signature could not be processed.</span>;
}
case 'VALIDITY_EXPIRED_ERROR': {
return <span>This verification message has expired. Add the contract address to restart the process.</span>;
}
case 'SIGNING_FAIL': {
return <span>{ formState.errors.root.message }</span>;
}
case 'INVALID_SIGNER_ERROR': {
const signer = shortenString(formState.errors.root.message || '');
const expectedSigners = [ contractCreator, contractOwner ].filter(Boolean).map(shortenString).join(', ');
return (
<Box>
<span>This address </span>
<span>{ signer }</span>
<span> is not a creator/owner of the requested contract and cannot claim ownership. Only </span>
<span>{ expectedSigners }2</span>
<span> can verify ownership of this contract.</span>
</Box>
);
}
case 'UNKNOWN_STATUS': {
return (
<Box>
<span>We are not able to process the verify account ownership for this contract address. Kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
);
}
case undefined: {
return null;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
<span>Please select the address to sign and copy the message and sign it using the Blockscout message provider of your choice. </span>
<Link href="https://docs.blockscout.com/for-users/my-account/verified-addresses/copy-and-sign-message" target="_blank">
Additional instructions
</Link>
<span>. If you do not see your address here but are sure that you are the owner of the contract, kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
{ (contractOwner || contractCreator) && (
<Flex flexDir="column" rowGap={ 4 } mb={ 4 }>
{ contractCreator && (
<Box>
<chakra.span fontWeight={ 600 }>Contract creator: </chakra.span>
<chakra.span>{ contractCreator }</chakra.span>
</Box>
) }
{ contractOwner && (
<Box>
<chakra.span fontWeight={ 600 }>Contract owner: </chakra.span>
<chakra.span>{ contractOwner }</chakra.span>
</Box>
) }
</Flex>
) }
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<AddressVerificationFieldMessage formState={ formState } control={ control }/>
</div>
{ !noWeb3Provider && (
<RadioGroup onChange={ handleSignMethodChange } value={ signMethod } display="flex" flexDir="column" rowGap={ 4 }>
<Radio value="wallet">Sign via Web3 wallet</Radio>
<Radio value="manually">Sign manually</Radio>
</RadioGroup>
) }
{ signMethod === 'manually' && <AddressVerificationFieldSignature formState={ formState } control={ control }/> }
</Flex>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
{ button }
<AdminSupportText/>
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepSignature);
import { Alert, Box, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
interface Props {
onShowListClick: () => void;
onAddTokenInfoClick: () => void;
isToken?: boolean;
address: string;
}
const AddressVerificationStepSuccess = ({ onAddTokenInfoClick, onShowListClick, isToken, address }: Props) => {
return (
<Box>
<Alert status="success" flexWrap="wrap" whiteSpace="pre-wrap" wordBreak="break-word" mb={ 3 } display="inline-block">
<span>The address ownership for </span>
<chakra.span fontWeight={ 700 }>{ address }</chakra.span>
<span> is verified.</span>
</Alert>
<p>You may now submit the “Add token information” request</p>
<Flex alignItems="center" mt={ 8 } columnGap={ 5 } flexWrap="wrap" rowGap={ 5 }>
<Button size="lg" variant={ isToken ? 'outline' : 'solid' } onClick={ onShowListClick }>
View my verified addresses
</Button>
{ isToken && (
<Button size="lg" onClick={ onAddTokenInfoClick }>
Add token information
</Button>
) }
</Flex>
</Box>
);
};
export default React.memo(AddressVerificationStepSuccess);
import type { VerifiedAddress } from 'types/api/account';
export interface AddressVerificationFormFirstStepFields {
address: string;
}
export interface AddressVerificationFormSecondStepFields {
signature: string;
message: string;
}
export interface RootFields {
root: string;
}
export interface AddressCheckStatusSuccess {
contractCreator?: string;
contractOwner?: string;
signingMessage: string;
}
export type AddressCheckResponseSuccess = {
status: 'SUCCESS';
result: AddressCheckStatusSuccess;
} |
{ status: 'IS_OWNER_ERROR' } |
{ status: 'OWNERSHIP_VERIFIED_ERROR' } |
{ status: 'SOURCE_CODE_NOT_VERIFIED_ERROR' } |
{ status: 'INVALID_ADDRESS_ERROR' };
export interface AddressVerificationResponseError {
code: number;
message: string;
}
export type AddressValidationResponseSuccess = {
status: 'SUCCESS';
result: {
verifiedAddress: VerifiedAddress;
};
} |
{
status: 'INVALID_SIGNER_ERROR';
invalidSigner: {
signer: string;
};
} |
{ status: 'VALIDITY_EXPIRED_ERROR' } |
{ status: 'INVALID_SIGNATURE_ERROR' } |
{ status: 'UNKNOWN_STATUS' }
import { useToken, Button, Box } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
import ethTokenTransferData from 'data/charts_eth_token_transfer.json';
import ethTxsData from 'data/charts_eth_txs.json';
import dayjs from 'lib/date/dayjs';
import useClientRect from 'lib/hooks/useClientRect';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
import ChartGridLine from 'ui/shared/chart/ChartGridLine';
import ChartLegend from 'ui/shared/chart/ChartLegend';
import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartSelectionX from 'ui/shared/chart/ChartSelectionX';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
// import useBrushX from 'ui/shared/chart/useBrushX';
import useChartLegend from 'ui/shared/chart/useChartLegend';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const CHART_OFFSET = {
y: 26, // legend height
};
const RANGE_DEFAULT_START_DATE = dayjs.min(dayjs(ethTokenTransferData[0].date), dayjs(ethTxsData[0].date)).toDate();
const RANGE_DEFAULT_LAST_DATE = dayjs.max(dayjs(ethTokenTransferData.at(-1)?.date), dayjs(ethTxsData.at(-1)?.date)).toDate();
const EthereumChart = () => {
const overlayRef = React.useRef<SVGRectElement>(null);
const [ rect, ref ] = useClientRect<SVGSVGElement>();
const { innerWidth, innerHeight } = calculateInnerSize(rect, CHART_MARGIN, CHART_OFFSET);
const [ range, setRange ] = React.useState<[ Date, Date ]>([ RANGE_DEFAULT_START_DATE, RANGE_DEFAULT_LAST_DATE ]);
const data: TimeChartData = [
{
name: 'Daily txs',
color: useToken('colors', 'blue.500'),
items: ethTxsData
.map((d) => ({ ...d, date: new Date(d.date) }))
.filter((d) => d.date >= range[0] && d.date <= range[1]),
},
{
name: 'ERC-20 tr.',
color: useToken('colors', 'green.500'),
items: ethTokenTransferData
.map((d) => ({ ...d, date: new Date(d.date) }))
.filter((d) => d.date >= range[0] && d.date <= range[1]),
},
];
const { selectedLines, handleLegendItemClick } = useChartLegend(data.length);
const filteredData = data.filter((_, index) => selectedLines.includes(index));
const { yTickFormat, xScale, yScale } = useTimeChartController({
data: filteredData.length === 0 ? data : filteredData,
width: innerWidth,
height: innerHeight,
});
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
setRange([ nextRange[0], nextRange[1] ]);
}, [ ]);
const handleZoomReset = React.useCallback(() => {
setRange([ RANGE_DEFAULT_START_DATE, RANGE_DEFAULT_LAST_DATE ]);
}, [ ]);
// uncomment if we need brush the chart
// const brushLimits = React.useMemo(() => (
// [ [ 0, innerHeight ], [ innerWidth, height ] ] as [[number, number], [number, number]]
// ), [ height, innerHeight, innerWidth ]);
// useBrushX({ anchor: ref.current, limits: brushLimits, setRange });
return (
<Box display="inline-block" position="relative" width="100%" height="100%">
<svg width="100%" height="calc(100% - 26px)" ref={ ref }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ rect ? 1 : 0 }>
{ /* BASE GRID LINE */ }
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 1 }
size={ innerWidth }
disableAnimation
/>
{ /* GIRD LINES */ }
<ChartGridLine
type="vertical"
scale={ xScale }
ticks={ 5 }
size={ innerHeight }
transform={ `translate(0, ${ innerHeight })` }
disableAnimation
/>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 5 }
size={ innerWidth }
disableAnimation
/>
{ /* GRAPH */ }
{ filteredData.map((d) => (
<ChartLine
key={ d.name }
data={ d.items }
xScale={ xScale }
yScale={ yScale }
stroke={ d.color }
animation="left"
/>
)) }
{ filteredData.map((d) => (
<ChartArea
key={ d.name }
data={ d.items }
color={ d.color }
xScale={ xScale }
yScale={ yScale }
/>
)) }
{ /* AXISES */ }
<ChartAxis
type="left"
scale={ yScale }
ticks={ 5 }
tickFormatGenerator={ yTickFormat }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ 5 }
anchorEl={ overlayRef.current }
disableAnimation
/>
{ filteredData.length > 0 && (
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
xScale={ xScale }
yScale={ yScale }
data={ filteredData }
/>
) }
{ filteredData.length > 0 && (
<ChartSelectionX
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ xScale }
data={ filteredData }
onSelect={ handleRangeSelect }
/>
) }
</ChartOverlay>
</g>
</svg>
{ (range[0] !== RANGE_DEFAULT_START_DATE || range[1] !== RANGE_DEFAULT_LAST_DATE) && (
<Button
size="sm"
variant="outline"
position="absolute"
top={ `${ CHART_MARGIN?.top || 0 }px` }
right={ `${ CHART_MARGIN?.right || 0 }px` }
onClick={ handleZoomReset }
>
Reset zoom
</Button>
) }
<ChartLegend data={ data } selectedIndexes={ selectedLines } onClick={ handleLegendItemClick }/>
</Box>
);
};
export default React.memo(EthereumChart);
import { useToken } from '@chakra-ui/react';
import React from 'react';
import ethTxsData from 'data/charts_eth_txs.json';
import useClientRect from 'lib/hooks/useClientRect';
import ChartLine from 'ui/shared/chart/ChartLine';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import calculateInnerSize from 'ui/shared/chart/utils/calculateInnerSize';
import { BlueLineGradient } from 'ui/shared/chart/utils/gradients';
const CHART_MARGIN = { bottom: 0, left: 0, right: 0, top: 0 };
const DATA = ethTxsData.slice(-30).map((d) => ({ ...d, date: new Date(d.date) }));
const SplineChartExample = () => {
const [ rect, ref ] = useClientRect<SVGSVGElement>();
const { innerWidth, innerHeight } = calculateInnerSize(rect, CHART_MARGIN);
const color = useToken('colors', 'blue.500');
const { xScale, yScale } = useTimeChartController({
data: [ { items: DATA, name: 'spline', color } ],
width: innerWidth,
height: innerHeight,
});
return (
<svg width="100%" height="100%" ref={ ref }>
<defs>
<BlueLineGradient.defs/>
</defs>
<ChartLine
data={ DATA }
xScale={ xScale }
yScale={ yScale }
stroke={ `url(#${ BlueLineGradient.id })` }
animation="left"
strokeWidth={ 3 }
/>
</svg>
);
};
export default SplineChartExample;
import { test as base, expect } from '@playwright/experimental-ct-react';
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as statsMock from 'mocks/stats/index';
import * as txMock from 'mocks/txs/tx';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
......@@ -13,11 +12,29 @@ export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(buildApiUrl('homepage_stats'), (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('default view', async({ mount, page }) => {
await page.route(buildApiUrl('homepage_txs'), (route) => route.fulfill({
status: 200,
body: JSON.stringify([
txMock.base,
txMock.withContractCreation,
txMock.withTokenTransfer,
]),
}));
const component = await mount(
<TestApp>
<LatestTxs/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
});
test('default view +@dark-mode', async({ mount, page }) => {
await page.route(buildApiUrl('homepage_txs'), (route) => route.fulfill({
status: 200,
body: JSON.stringify([
......@@ -47,10 +64,6 @@ test.describe('socket', () => {
};
test('new item', async({ mount, page, createSocket }) => {
await page.route(buildApiUrl('homepage_stats'), (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(buildApiUrl('homepage_txs'), (route) => route.fulfill({
status: 200,
body: JSON.stringify([
......
......@@ -11,7 +11,7 @@ const LatestTxsItemSkeleton = () => {
return (
<Box
width="100%"
minW="700px"
minW={{ base: 'unset', lg: '700px' }}
borderTop="1px solid"
borderColor="divider"
py={ 4 }
......
......@@ -5,7 +5,7 @@ import type { ResourcePayload } from 'lib/api/resources';
export type ChartsResources = 'homepage_chart_txs' | 'homepage_chart_market';
export type ChainIndicatorId = 'daily_txs' | 'coin_price' | 'market_cup';
export type ChainIndicatorId = 'daily_txs' | 'coin_price' | 'market_cap';
export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId;
......
......@@ -27,11 +27,17 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = {
},
};
const nativeTokenData = {
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
};
const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
id: 'coin_price',
title: `${ appConfig.network.currency.symbol } price`,
value: (stats) => '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
icon: <TokenLogo hash={ appConfig.network.currency.address || '' } name={ appConfig.network.currency.name } boxSize={ 6 }/>,
icon: <TokenLogo data={ nativeTokenData } boxSize={ 6 }/>,
hint: `${ appConfig.network.currency.symbol } token daily price in USD.`,
api: {
resourceName: 'homepage_chart_market',
......@@ -46,7 +52,7 @@ const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
};
const marketPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
id: 'market_cup',
id: 'market_cap',
title: 'Market cap',
value: (stats) => '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 0, notation: 'compact' }),
icon: <Icon as={ globeIcon } boxSize={ 6 } bgColor="#6A5DCC" borderRadius="base" color="white"/>,
......
......@@ -51,7 +51,7 @@ const Accounts = () => {
return (
<Page>
<PageTitle text="Top accounts" withTextAd/>
<PageTitle title="Top accounts" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react';
import { Skeleton, Box, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -10,6 +10,7 @@ import iconSuccess from 'icons/status/success.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
......@@ -22,6 +23,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import TextAd from 'ui/shared/ad/TextAd';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
......@@ -37,11 +40,9 @@ const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
const tabsScrollRef = React.useRef<HTMLDivElement>(null);
const hash = getQueryParamString(router.query.hash);
......@@ -50,17 +51,6 @@ const AddressPageContent = () => {
queryOptions: { enabled: Boolean(hash) },
});
const tags = [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
...(addressQuery.data?.private_tags || []),
...(addressQuery.data?.public_tags || []),
...(addressQuery.data?.watchlist_names || []),
]
.filter(Boolean)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const contractTabs = useContractTabs(addressQuery.data);
const tabs: Array<RoutedTab> = React.useMemo(() => {
......@@ -99,10 +89,36 @@ const AddressPageContent = () => {
].filter(Boolean);
}, [ addressQuery.data, contractTabs, hash ]);
const tagsNode = tags.length > 0 ? <Flex columnGap={ 2 }>{ tags }</Flex> : null;
const tags = (
<EntityTags
data={ addressQuery.data }
isLoading={ addressQuery.isPlaceholderData }
tagsBefore={ [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
] }
contentAfter={
<NetworkExplorers type="address" pathParam={ hash } ml="auto" hideText={ isMobile }/>
}
/>
);
const content = addressQuery.isError ? null : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/>;
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to top accounts list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
......@@ -110,10 +126,9 @@ const AddressPageContent = () => {
<Skeleton h={ 10 } w="260px" mb={ 6 }/>
) : (
<PageTitle
text={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
additionalsRight={ tagsNode }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to top accounts list"
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
backLink={ backLink }
contentAfter={ tags }
/>
) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
......
......@@ -13,7 +13,6 @@ import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
......@@ -29,7 +28,7 @@ const ApiKeysPage: React.FC = () => {
const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>();
const { data, isLoading, isError } = useApiQuery('api_keys');
const { data, isLoading, isError, error } = useApiQuery('api_keys');
const onEditClick = useCallback((data: ApiKey) => {
setApiKeyModalData(data);
......@@ -76,6 +75,9 @@ const ApiKeysPage: React.FC = () => {
}
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......@@ -130,12 +132,10 @@ const ApiKeysPage: React.FC = () => {
})();
return (
<Page>
<Box h="100%">
<PageTitle text="API keys"/>
{ content }
</Box>
</Page>
<>
<PageTitle title="API keys"/>
{ content }
</>
);
};
......
......@@ -13,6 +13,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import TextAd from 'ui/shared/ad/TextAd';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
......@@ -82,7 +83,18 @@ const BlockPageContent = () => {
pagination = blockWithdrawalsQuery.pagination;
}
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to blocks list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
......@@ -91,9 +103,9 @@ const BlockPageContent = () => {
<Skeleton h={ 10 } w="300px" mb={ 6 }/>
) : (
<PageTitle
text={ `Block #${ blockQuery.data?.height }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to blocks list"
title={ `Block #${ blockQuery.data?.height }` }
backLink={ backLink }
contentAfter={ <NetworkExplorers type="block" pathParam={ height } ml={{ base: 'initial', lg: 'auto' }}/> }
/>
) }
{ blockQuery.isLoading ? <SkeletonTabs/> : (
......
......@@ -42,7 +42,7 @@ const BlocksPageContent = () => {
return (
<Page>
<PageTitle text="Blocks" withTextAd/>
<PageTitle title="Blocks" withTextAd/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
......@@ -20,7 +20,6 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
......@@ -83,12 +82,24 @@ const ContractVerification = () => {
);
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to contract',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="New smart contract verification"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to contract"
title="New smart contract verification"
backLink={ backLink }
/>
{ hash && (
<Address mb={ 12 }>
......
......@@ -50,7 +50,6 @@ const CsvExport = () => {
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash },
......@@ -59,6 +58,19 @@ const CsvExport = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
}
......@@ -78,9 +90,8 @@ const CsvExport = () => {
return (
<Page>
<PageTitle
text="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
title="Export data to CSV file"
backLink={ backLink }
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
......
......@@ -12,7 +12,6 @@ import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
......@@ -26,7 +25,7 @@ const CustomAbiPage: React.FC = () => {
const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>();
const [ deleteModalData, setDeleteModalData ] = useState<CustomAbi>();
const { data, isLoading, isError } = useApiQuery('custom_abi');
const { data, isLoading, isError, error } = useApiQuery('custom_abi');
const onEditClick = useCallback((data: CustomAbi) => {
setCustomAbiModalData(data);
......@@ -72,6 +71,9 @@ const CustomAbiPage: React.FC = () => {
}
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......@@ -113,12 +115,10 @@ const CustomAbiPage: React.FC = () => {
})();
return (
<Page>
<Box h="100%">
<PageTitle text="Custom ABI"/>
{ content }
</Box>
</Page>
<>
<PageTitle title="Custom ABI"/>
{ content }
</>
);
};
......
import { Box, Heading } from '@chakra-ui/react';
import React from 'react';
import EthereumChart from 'ui/charts/EthereumChart';
import SplineChartExample from 'ui/charts/SplineChartExample';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const Graph = () => {
return (
<Page>
<PageTitle text="Charts"/>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 }>Ethereum Daily Transactions & ERC-20 Token Transfer Chart</Heading>
<Box w="100%" h="400px">
<EthereumChart/>
</Box>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 } mt="80px">Ethereum Daily Transactions For Last Month</Heading>
<Box w="240px" h="150px">
<SplineChartExample/>
</Box>
</Page>
);
};
export default Graph;
......@@ -5,6 +5,7 @@ import * as blockMock from 'mocks/blocks/block';
import * as dailyTxsMock from 'mocks/stats/daily_txs';
import * as statsMock from 'mocks/stats/index';
import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder';
......@@ -47,6 +48,35 @@ test('default view -@default +@desktop-xl +@dark-mode', async({ mount, page }) =
await expect(component.locator('main')).toHaveScreenshot();
});
test.describe('custom hero plate background', () => {
const IMAGE_URL = 'https://localhost:3000/my-image.png';
const extendedTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND', value: `no-repeat center/cover url(${ IMAGE_URL })` },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
extendedTest('default view', async({ mount, page }) => {
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/giant_duck_long.jpg',
});
});
const component = await mount(
<TestApp>
<Home/>
</TestApp>,
);
const heroPlate = component.locator('div[data-label="hero plate"]');
await expect(heroPlate).toHaveScreenshot();
});
});
// had to separate mobile test, otherwise all the tests fell on CI
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
......
......@@ -17,11 +17,11 @@ const Home = () => {
<Page isHomePage>
<Box
w="100%"
backgroundImage={ appConfig.homepage.plate.gradient }
backgroundColor="blue.400"
background={ appConfig.homepage.plate.background }
borderRadius="24px"
padding={{ base: '24px', lg: '48px' }}
minW={{ base: 'unset', lg: '900px' }}
data-label="hero plate"
>
<Flex mb={{ base: 6, lg: 8 }} justifyContent="space-between">
<Heading
......
......@@ -68,7 +68,7 @@ const L2Deposits = () => {
return (
<Page>
<PageTitle text={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<PageTitle title={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -70,7 +70,7 @@ const L2OutputRoots = () => {
return (
<Page>
<PageTitle text="Output roots" withTextAd/>
<PageTitle title="Output roots" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -71,7 +71,7 @@ const L2TxnBatches = () => {
return (
<Page>
<PageTitle text={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/>
<PageTitle title={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -68,7 +68,7 @@ const L2Withdrawals = () => {
return (
<Page>
<PageTitle text={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<PageTitle title={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -61,7 +61,7 @@ const Login = () => {
return (
<Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text="Login page 😂"/>
<PageTitle title="Login page 😂"/>
{ isFormVisible && (
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
......
......@@ -5,12 +5,11 @@ import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import UserAvatar from 'ui/shared/UserAvatar';
const MyProfile = () => {
const { data, isLoading, isError, isFetched } = useFetchProfileInfo();
const { data, isLoading, isError, error, isFetched } = useFetchProfileInfo();
useRedirectForInvalidAuthToken();
const content = (() => {
......@@ -19,6 +18,9 @@ const MyProfile = () => {
}
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......@@ -54,10 +56,10 @@ const MyProfile = () => {
})();
return (
<Page>
<PageTitle text="My profile"/>
<>
<PageTitle title="My profile"/>
{ content }
</Page>
</>
);
};
......
......@@ -5,7 +5,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags';
import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
......@@ -18,10 +17,10 @@ const PrivateTags = () => {
useRedirectForInvalidAuthToken();
return (
<Page>
<PageTitle text="Private tags"/>
<>
<PageTitle title="Private tags"/>
<RoutedTabs tabs={ TABS }/>
</Page>
</>
);
};
......
import { Link, Text, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account';
import eastArrowIcon from 'icons/arrows/east.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString';
import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
type TScreen = 'data' | 'form';
......@@ -23,13 +21,21 @@ const toastDescriptions = {
} as Record<TToastAction, string>;
const PublicTagsComponent: React.FC = () => {
const [ screen, setScreen ] = useState<TScreen>('data');
const [ formData, setFormData ] = useState<PublicTag>();
const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ screen, setScreen ] = useState<TScreen>(addressHash ? 'form' : 'data');
const [ formData, setFormData ] = useState<Partial<PublicTag> | undefined>(addressHash ? { addresses: [ addressHash ] } : undefined);
const toast = useToast();
const isMobile = useIsMobile();
useRedirectForInvalidAuthToken();
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/public_tags_request' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const showToast = useCallback((action: TToastAction) => {
toast({
position: 'top-right',
......@@ -77,17 +83,20 @@ const PublicTagsComponent: React.FC = () => {
header = formData ? 'Request to edit a public tag/label' : 'Request a public tag/label';
}
const backLink = {
label: 'Public tags',
onClick: onGoBack,
};
return (
<Page>
{ screen === 'form' && (
<Link display="inline-flex" alignItems="center" mb={ 6 } onClick={ onGoBack }>
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)"/>
{ isMobile && <Text variant="inherit" fontSize="sm" ml={ 2 }>Public tags</Text> }
</Link>
) }
<PageTitle text={ header } display={{ base: 'block', lg: 'inline-flex' }} ml={{ base: 0, lg: 3 }}/>
<>
<PageTitle
title={ header }
backLink={ screen === 'form' ? backLink : undefined }
display={{ base: 'block', lg: 'inline-flex' }}
/>
{ content }
</Page>
</>
);
};
......
......@@ -152,7 +152,7 @@ const SearchResultsPageContent = () => {
<Page renderHeader={ renderHeader }>
{ isLoading || redirectCheckQuery.isLoading ?
<Skeleton h={ 10 } mb={ 6 } w="100%" maxW="222px"/> :
<PageTitle text="Search results"/>
<PageTitle title="Search results"/>
}
{ bar }
{ content }
......
......@@ -17,14 +17,25 @@ const Sol2Uml = () => {
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
title="Solidity UML diagram"
backLink={ backLink }
/>
<Flex mb={ 10 }>
<span>For contract</span>
......
......@@ -26,7 +26,7 @@ const Stats = () => {
return (
<Page>
<PageTitle text={ `${ appConfig.network.name } stats` }/>
<PageTitle title={ `${ appConfig.network.name } stats` }/>
<Box mb={{ base: 6, sm: 8 }}>
<NumberWidgetsList/>
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import * as socketServer from 'playwright/fixtures/socketServer';
......@@ -16,7 +17,7 @@ const TOKEN_TRANSFERS_API_URL = buildApiUrl('token_transfers', { hash: '1' });
const ADDRESS_API_URL = buildApiUrl('address', { hash: '1' });
const hooksConfig = {
router: {
query: { hash: 1, tab: 'token_transfers' },
query: { hash: '1', tab: 'token_transfers' },
isReady: true,
},
};
......@@ -70,6 +71,30 @@ test('base view', async({ mount, page, createSocket }) => {
await expect(component).toHaveScreenshot();
});
test('with verified info', async({ mount, page, createSocket }) => {
const VERIFIED_INFO_URL = buildApiUrl('token_verified_info', { chainId: '1', hash: '1' });
await page.route(VERIFIED_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED),
}));
const component = await mount(
<TestApp withSocket>
<Token/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await insertAdPlaceholder(page);
await page.getByRole('button', { name: /project info/i }).click();
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page, createSocket }) => {
......@@ -88,4 +113,26 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot();
});
test('with verified info', async({ mount, page, createSocket }) => {
const VERIFIED_INFO_URL = buildApiUrl('token_verified_info', { chainId: '1', hash: '1' });
await page.route(VERIFIED_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED),
}));
const component = await mount(
<TestApp withSocket>
<Token/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
});
......@@ -7,12 +7,14 @@ import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import iconSuccess from 'icons/status/success.svg';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
......@@ -20,7 +22,8 @@ import * as addressStubs from 'stubs/address';
import * as tokenStubs from 'stubs/token';
import AddressContract from 'ui/address/AddressContract';
import TextAd from 'ui/shared/ad/TextAd';
import Tag from 'ui/shared/chakra/Tag';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
......@@ -32,6 +35,7 @@ import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
......@@ -43,11 +47,9 @@ const TokenPageContent = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = router.query.hash?.toString();
const hashString = getQueryParamString(router.query.hash);
const queryClient = useQueryClient();
......@@ -147,6 +149,12 @@ const TokenPageContent = () => {
},
});
const isVerifiedInfoEnabled = Boolean(appConfig.contractInfoApi.endpoint);
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: hashString, chainId: appConfig.network.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && isVerifiedInfoEnabled },
});
const contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [
......@@ -207,20 +215,52 @@ const TokenPageContent = () => {
};
}, [ isMobile ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const tags = (
<EntityTags
data={ contractQuery.data }
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
] }
contentAfter={
<NetworkExplorers type="token" pathParam={ hashString } ml="auto" hideText={ isMobile }/>
}
flexGrow={ 1 }
/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
isLoading={ tokenQuery.isPlaceholderData }
text={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to tokens list"
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData }/>
backLink={ backLink }
beforeTitle={ (
<TokenLogo data={ tokenQuery.data } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData } display="inline-block" mr={ 2 }/>
) }
additionalsRight={ <Tag isLoading={ tokenQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag> }
afterTitle={
verifiedInfoQuery.data?.tokenAddress ?
<Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> :
<Box boxSize={ 4 } display="inline-block"/>
}
contentAfter={ tags }
/>
<TokenContractInfo tokenQuery={ tokenQuery } contractQuery={ contractQuery }/>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery } isVerifiedInfoEnabled={ isVerifiedInfoEnabled }/>
<TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
......
......@@ -7,7 +7,7 @@ import TokensList from 'ui/tokens/Tokens';
const Tokens = () => {
return (
<Page>
<PageTitle text="Tokens" withTextAd/>
<PageTitle title="Tokens" withTextAd/>
<TokensList/>
</Page>
);
......
import { Flex, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -6,10 +5,11 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import networkExplorers from 'lib/networks/networkExplorers';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import TextAd from 'ui/shared/ad/TextAd';
import LinkExternal from 'ui/shared/LinkExternal';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
......@@ -32,47 +32,45 @@ const TABS: Array<RoutedTab> = [
const TransactionPageContent = () => {
const router = useRouter();
const appProps = useAppContext();
const isMobile = useIsMobile();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
const hash = getQueryParamString(router.query.hash);
const { data } = useApiQuery('tx', {
const { data, isPlaceholderData } = useApiQuery('tx', {
pathParams: { hash },
queryOptions: { enabled: Boolean(hash) },
});
const explorersLinks = networkExplorers
.filter((explorer) => explorer.paths.tx)
.map((explorer) => {
const url = new URL(explorer.paths.tx + '/' + hash, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>Open in { explorer.title }</LinkExternal>;
});
const additionals = (
<Flex justifyContent="space-between" alignItems="center" flexGrow={ 1 } flexWrap="wrap">
{ data?.tx_tag && <Tag my={ 2 }>{ data.tx_tag }</Tag> }
{ explorersLinks.length > 0 && (
<Flex
alignItems="center"
flexWrap="wrap"
columnGap={ 6 }
rowGap={ 3 }
ml={{ base: 'initial', lg: 'auto' }}
>
{ explorersLinks }
</Flex>
) }
</Flex>
const tags = (
<EntityTags
isLoading={ isPlaceholderData }
tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] }
contentAfter={
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 'initial', lg: 'auto' }} hideText={ isMobile && Boolean(data?.tx_tag) }/>
}
/>
);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to transactions list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<TextAd mb={ 6 }/>
<PageTitle
text="Transaction details"
additionalsRight={ additionals }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to transactions list"
title="Transaction details"
backLink={ backLink }
contentAfter={ tags }
/>
<RoutedTabs tabs={ TABS }/>
</Page>
......
......@@ -73,7 +73,7 @@ const Transactions = () => {
return (
<Page>
<Box h="100%">
<PageTitle text="Transactions" withTextAd/>
<PageTitle title="Transactions" withTextAd/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import VerifiedAddresses from './VerifiedAddresses';
const VERIFIED_ADDRESS_URL = buildApiUrl('verified_addresses', { chainId: '1' });
const TOKEN_INFO_APPLICATIONS_URL = buildApiUrl('token_info_applications', { chainId: '1', id: undefined });
test.beforeEach(async({ context }) => {
await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
});
test('base view +@mobile', async({ mount, page }) => {
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
const component = await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('address verification flow', async({ mount, page }) => {
const CHECK_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':prepare' });
const VERIFY_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':verify' });
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SUCCESS),
}));
await page.route(VERIFY_ADDRESS_URL, (route) => {
return route.fulfill({
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.SUCCESS),
});
});
await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
// open modal
await page.getByRole('button', { name: /add address/i }).click();
// fill first step
const addressInput = page.getByLabel(/smart contract address/i);
await addressInput.fill(mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress);
await page.getByRole('button', { name: /continue/i }).click();
// fill second step
const option = page.getByText(/sign manually/i);
option.click();
const signatureInput = page.getByLabel(/signature hash/i);
await signatureInput.fill(mocks.SIGNATURE);
await page.getByRole('button', { name: /verify/i }).click();
// success screen
await page.getByRole('button', { name: /view my verified addresses/i }).click();
await expect(page).toHaveScreenshot();
});
test('application update flow', async({ mount, page }) => {
const TOKEN_INFO_APPLICATION_URL = buildApiUrl('token_info_applications', { chainId: '1', id: mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM.id });
const FORM_CONFIG_URL = buildApiUrl('token_info_applications_config', { chainId: '1' });
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.FOR_UPDATE),
}));
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
// PUT request
await page.route(TOKEN_INFO_APPLICATION_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM),
}));
await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
// open form
await page.locator('tr').filter({ hasText: 'waiting for update' }).locator('button[aria-label="edit"]').click();
// change project name
const addressInput = page.getByLabel(/project name/i);
await addressInput.fill('New name');
await page.getByRole('button', { name: /send request/i }).click();
const locator = page.locator('tr').filter({ hasText: 'in progress' }).filter({ hasText: 'nov 11, 2022' });
await expect(locator).toBeVisible();
});
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Box, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, VerifiedAddressResponse } from 'types/api/account';
import appConfig from 'configs/app/config';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import TokenInfoForm from 'ui/tokenInfo/TokenInfoForm';
import VerifiedAddressesListItem from 'ui/verifiedAddresses/VerifiedAddressesListItem';
import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable';
const VerifiedAddresses = () => {
useRedirectForInvalidAuthToken();
const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ selectedAddress, setSelectedAddress ] = React.useState<string | undefined>(addressHash);
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/verified_addresses' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const modalProps = useDisclosure();
const addressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: undefined },
queryOptions: {
select: (data) => {
return {
...data,
submissions: data.submissions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
};
},
},
});
const queryClient = useQueryClient();
const handleGoBack = React.useCallback(() => {
setSelectedAddress(undefined);
}, []);
const handleItemAdd = React.useCallback((address: string) => {
setSelectedAddress(address);
}, []);
const handleItemEdit = React.useCallback((address: string) => {
setSelectedAddress(address);
}, []);
const handleAddressSubmit = React.useCallback((newItem: VerifiedAddress) => {
queryClient.setQueryData(
getResourceKey('verified_addresses', { pathParams: { chainId: appConfig.network.id } }),
(prevData: VerifiedAddressResponse | undefined) => {
if (!prevData) {
return { verifiedAddresses: [ newItem ] };
}
return {
verifiedAddresses: [ newItem, ...prevData.verifiedAddresses ],
};
});
}, [ queryClient ]);
const handleApplicationSubmit = React.useCallback((newItem: TokenInfoApplication) => {
setSelectedAddress(undefined);
queryClient.setQueryData(
getResourceKey('token_info_applications', { pathParams: { chainId: appConfig.network.id, id: undefined } }),
(prevData: TokenInfoApplications | undefined) => {
if (!prevData) {
return { submissions: [ newItem ] };
}
const isExisting = prevData.submissions.some((item) => item.id.toLowerCase() === newItem.id.toLowerCase());
const submissions = isExisting ?
prevData.submissions.map((item) => item.id.toLowerCase() === newItem.id.toLowerCase() ? newItem : item) :
[ newItem, ...prevData.submissions ];
return { submissions };
});
}, [ queryClient ]);
const addButton = (
<Box marginTop={ 8 }>
<Button size="lg" onClick={ modalProps.onOpen }>
Add address
</Button>
</Box>
);
const skeleton = (
<>
<Box display={{ base: 'block', lg: 'none' }}>
<SkeletonListAccount/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
<Box display={{ base: 'none', lg: 'block' }}>
<SkeletonTable columns={ [ '100%', '180px', '260px', '160px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
</>
);
const backLink = React.useMemo(() => {
if (!selectedAddress) {
return;
}
return {
label: 'Back to my verified addresses',
onClick: handleGoBack,
};
}, [ handleGoBack, selectedAddress ]);
if (selectedAddress) {
const addressInfo = addressesQuery.data?.verifiedAddresses.find(({ contractAddress }) => contractAddress.toLowerCase() === selectedAddress.toLowerCase());
const tokenName = addressInfo ? `${ addressInfo.metadata.tokenName } (${ addressInfo.metadata.tokenSymbol })` : '';
return (
<>
<PageTitle title="Token info application form" backLink={ backLink }/>
<TokenInfoForm
address={ selectedAddress }
tokenName={ tokenName }
application={ applicationsQuery.data?.submissions.find(({ tokenAddress }) => tokenAddress.toLowerCase() === selectedAddress.toLowerCase()) }
onSubmit={ handleApplicationSubmit }
/>
</>
);
}
const content = addressesQuery.data?.verifiedAddresses ? (
<>
<Show below="lg" key="content-mobile" ssr={ false }>
{ addressesQuery.data.verifiedAddresses.map((item) => (
<VerifiedAddressesListItem
key={ item.contractAddress }
item={ item }
application={ applicationsQuery.data?.submissions?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) }
onAdd={ handleItemAdd }
onEdit={ handleItemEdit }
/>
)) }
</Show>
<Hide below="lg" key="content-desktop" ssr={ false }>
<VerifiedAddressesTable
data={ addressesQuery.data.verifiedAddresses }
applications={ applicationsQuery.data?.submissions }
onItemEdit={ handleItemEdit }
onItemAdd={ handleItemAdd }
/>
</Hide>
</>
) : null;
return (
<>
<PageTitle title="My verified addresses"/>
<AccountPageDescription allowCut={ false }>
<span>
Verify ownership of a smart contract address to easily update information in Blockscout.
You will sign a single message to verify contract ownership.
Once verified, you can update token information, address name tags, and address labels from the
Blockscout console without needing to sign additional messages.
</span>
<chakra.p fontWeight={ 600 } mt={ 5 }>
Before starting, make sure that:
</chakra.p>
<OrderedList ml={ 6 }>
<ListItem>The source code for the smart contract is deployed on “{ appConfig.network.name }”.</ListItem>
<ListItem>
<span>The source code is verified (if not yet verified, you can use </span>
<Link href="https://docs.blockscout.com/for-users/verifying-a-smart-contract" target="_blank">this tool</Link>
<span>).</span>
</ListItem>
</OrderedList>
<chakra.div mt={ 5 }>
Once these steps are complete, click the Add address button below to get started.
</chakra.div>
<AdminSupportText mt={ 5 }/>
</AccountPageDescription>
<DataListDisplay
isLoading={ addressesQuery.isLoading || applicationsQuery.isLoading }
isError={ addressesQuery.isError || applicationsQuery.isError }
items={ addressesQuery.data?.verifiedAddresses }
content={ content }
emptyText=""
skeletonProps={{ customSkeleton: skeleton }}
/>
{ addButton }
<AddressVerificationModal
isOpen={ modalProps.isOpen }
onClose={ modalProps.onClose }
onSubmit={ handleAddressSubmit }
onAddTokenInfoClick={ handleItemAdd }
onShowListClick={ modalProps.onClose }
/>
</>
);
};
export default React.memo(VerifiedAddresses);
......@@ -117,7 +117,7 @@ const VerifiedContracts = () => {
return (
<Box>
<PageTitle text="Verified contracts" withTextAd/>
<PageTitle title="Verified contracts" withTextAd/>
<VerifiedContractsCounters/>
<DataListDisplay
isError={ isError }
......
......@@ -5,13 +5,13 @@ import React, { useCallback, useState } from 'react';
import type { WatchlistAddress, WatchlistTokensResponse } from 'types/api/account';
import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import type { ResourceError } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
......@@ -22,31 +22,27 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => {
const apiFetch = useApiFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, TWatchlist>([ resourceKey('watchlist') ], async() => {
try {
const watchlistAddresses = await apiFetch<'watchlist', Array<WatchlistAddress>>('watchlist');
const { data, isLoading, isError, error } = useQuery<unknown, ResourceError, TWatchlist>([ resourceKey('watchlist') ], async() => {
const watchlistAddresses = await apiFetch<'watchlist', Array<WatchlistAddress>>('watchlist');
if (!Array.isArray(watchlistAddresses)) {
throw Error();
}
const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => {
if (!address?.hash) {
return Promise.resolve(0);
}
return apiFetch<'old_api', WatchlistTokensResponse>('old_api', { queryParams: { address: address.hash, module: 'account', action: 'tokenlist' } })
.then((response) => {
if ('result' in response && Array.isArray(response.result)) {
return response.result.length;
}
return 0;
});
}));
return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] }));
} catch (error) {
return error;
if (!Array.isArray(watchlistAddresses)) {
return;
}
const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => {
if (!address?.hash) {
return Promise.resolve(0);
}
return apiFetch<'old_api', WatchlistTokensResponse>('old_api', { queryParams: { address: address.hash, module: 'account', action: 'tokenlist' } })
.then((response) => {
if ('result' in response && Array.isArray(response.result)) {
return response.result.length;
}
return 0;
});
}));
return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] }));
});
const queryClient = useQueryClient();
......@@ -96,24 +92,30 @@ const WatchList: React.FC = () => {
</AccountPageDescription>
);
let content;
if (isLoading && !data) {
const loader = isMobile ? <SkeletonListAccount showFooterSlot/> : (
<>
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
const content = (() => {
if (isLoading && !data) {
const loader = isMobile ? <SkeletonListAccount showFooterSlot/> : (
<>
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
content = (
<>
{ description }
{ loader }
</>
);
} else if (isError) {
content = <DataFetchAlert/>;
} else {
const list = isMobile ? (
<Box>
{ data.map((item) => (
......@@ -133,7 +135,7 @@ const WatchList: React.FC = () => {
/>
);
content = (
return (
<>
{ description }
{ Boolean(data?.length) && list }
......@@ -142,7 +144,7 @@ const WatchList: React.FC = () => {
size="lg"
onClick={ addressModalProps.onOpen }
>
Add address
Add address
</Button>
</Box>
<AddressModal
......@@ -162,15 +164,13 @@ const WatchList: React.FC = () => {
) }
</>
);
}
})();
return (
<Page>
<Box h="100%">
<PageTitle text="Watch list"/>
{ content }
</Box>
</Page>
<>
<PageTitle title="Watch list"/>
{ content }
</>
);
};
......
......@@ -70,7 +70,7 @@ const Withdrawals = () => {
return (
<Page>
<PageTitle text="Withdrawals" withTextAd/>
<PageTitle title="Withdrawals" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -3,7 +3,7 @@ import {
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
......@@ -11,7 +11,6 @@ import { useForm, Controller } from 'react-hook-form';
import type { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
......@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput';
const TAG_MAX_LENGTH = 35;
type Props = {
data?: AddressTag;
data?: Partial<AddressTag>;
onClose: () => void;
onSuccess: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void;
}
......@@ -31,7 +31,7 @@ type Inputs = {
tag: string;
}
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const apiFetch = useApiFetch();
const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
......@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => {
const body = {
name: formData?.tag,
......@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
setAlertVisible(true);
}
},
onSuccess: () => {
queryClient.refetchQueries([ resourceKey('private_tags_address') ]).then(() => {
onClose();
setPending(false);
});
onSuccess: async() => {
await onSuccess();
onClose();
setPending(false);
},
});
......
......@@ -9,18 +9,19 @@ import AddressForm from './AddressForm';
type Props = {
isOpen: boolean;
onClose: () => void;
data?: AddressTag;
onSuccess: () => Promise<void>;
data?: Partial<AddressTag>;
}
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit address tag' : 'New address tag';
const text = !data ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const AddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data }) => {
const title = data?.id ? 'Edit address tag' : 'New address tag';
const text = !data?.id ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return <AddressForm data={ data } onClose={ onClose } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose, onSuccess ]);
return (
<FormModal<AddressTag>
isOpen={ isOpen }
......
......@@ -15,7 +15,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
const PrivateAddressTags = () => {
const { data: addressTagsData, isError, isPlaceholderData } = useApiQuery('private_tags_address', {
const { data: addressTagsData, isError, error, isPlaceholderData, refetch } = useApiQuery('private_tags_address', {
queryOptions: {
refetchOnMount: false,
placeholderData: Array(3).fill(PRIVATE_TAG_ADDRESS),
......@@ -34,6 +34,10 @@ const PrivateAddressTags = () => {
addressModalProps.onOpen();
}, [ addressModalProps ]);
const onAddOrEditSuccess = useCallback(async() => {
await refetch();
}, [ refetch ]);
const onAddressModalClose = useCallback(() => {
setAddressModalData(undefined);
addressModalProps.onClose();
......@@ -50,6 +54,9 @@ const PrivateAddressTags = () => {
}, [ deleteModalProps ]);
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......@@ -91,7 +98,7 @@ const PrivateAddressTags = () => {
</Button>
</Skeleton>
</Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData } onSuccess={ onAddOrEditSuccess }/>
{ deleteModalData && (
<DeletePrivateTagModal
{ ...deleteModalProps }
......
......@@ -16,7 +16,7 @@ import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem
import TransactionTagTable from './TransactionTagTable/TransactionTagTable';
const PrivateTransactionTags = () => {
const { data: transactionTagsData, isLoading, isError } = useApiQuery('private_tags_tx', { queryOptions: { refetchOnMount: false } });
const { data: transactionTagsData, isLoading, isError, error } = useApiQuery('private_tags_tx', { queryOptions: { refetchOnMount: false } });
const transactionModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -69,6 +69,9 @@ const PrivateTransactionTags = () => {
}
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......
......@@ -24,7 +24,7 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const isMobile = useIsMobile();
const { data, isLoading, isError } = useApiQuery('public_tags');
const { data, isLoading, isError, error } = useApiQuery('public_tags');
const onDeleteModalClose = useCallback(() => {
setDeleteModalData(undefined);
......@@ -70,6 +70,9 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
}
if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
......
......@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput';
type Props = {
changeToDataScreen: (success?: boolean) => void;
data?: PublicTag;
data?: Partial<PublicTag>;
}
export type Inputs = {
......@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
email: data?.email || '',
companyName: data?.company || '',
companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
tags: data?.tags?.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses?.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
......
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import React, { useRef, useEffect, useState, useCallback } from 'react';
const CUT_HEIGHT = 144;
const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
const AccountPageDescription = ({ children, allowCut = true }: { children: React.ReactNode; allowCut?: boolean }) => {
const ref = useRef<HTMLParagraphElement>(null);
const [ needCut, setNeedCut ] = useState(false);
......@@ -20,6 +20,10 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
}, [ needCut ]);
useEffect(() => {
if (!allowCut) {
return;
}
calculateCut();
const resizeHandler = _debounce(calculateCut, 300);
window.addEventListener('resize', resizeHandler);
......@@ -40,15 +44,14 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
return (
<Box position="relative" marginBottom={{ base: 6, lg: 8 }}>
<Text
<Box
ref={ ref }
maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' }
overflow="hidden"
style={ needCut && !expanded ? { WebkitLineClamp: '6', WebkitBoxOrient: 'vertical', display: '-webkit-box' } : {} }
>
{ children }
</Text>
</Box>
{ needCut && !expanded && (
<Box
position="absolute"
......
import { Button, Menu, MenuButton, MenuList, Icon, Flex, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import iconArrow from 'icons/arrows/east-mini.svg';
import getQueryParamString from 'lib/router/getQueryParamString';
import PrivateTagMenuItem from './PrivateTagMenuItem';
import PublicTagMenuItem from './PublicTagMenuItem';
import TokenInfoMenuItem from './TokenInfoMenuItem';
interface Props {
isLoading?: boolean;
}
const AddressActions = ({ isLoading }: Props) => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const isTokenPage = router.pathname === '/token/[hash]';
return (
<Menu>
<Skeleton isLoaded={ !isLoading } ml={ 2 } borderRadius="base">
<MenuButton
as={ Button }
size="sm"
variant="outline"
>
<Flex alignItems="center">
<span>More</span>
<Icon as={ iconArrow } transform="rotate(-90deg)" boxSize={ 5 } ml={ 1 }/>
</Flex>
</MenuButton>
</Skeleton>
<MenuList minWidth="180px" zIndex="popover">
{ isTokenPage && appConfig.contractInfoApi.endpoint && appConfig.adminServiceApi.endpoint && appConfig.isAccountSupported &&
<TokenInfoMenuItem py={ 2 } px={ 4 } hash={ hash }/> }
<PrivateTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
<PublicTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
</MenuList>
</Menu>
);
};
export default React.memo(AddressActions);
import { MenuItem, Icon, chakra, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import iconPrivateTags from 'icons/privattags.svg';
import { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import PrivateTagModal from 'ui/privateTags/AddressModal/AddressModal';
interface Props {
className?: string;
hash: string;
}
const PrivateTagMenuItem = ({ className, hash }: Props) => {
const modal = useDisclosure();
const queryClient = useQueryClient();
const redirectIfNotAuth = useRedirectIfNotAuth();
const queryKey = getResourceKey('address', { pathParams: { hash } });
const addressData = queryClient.getQueryData<Address>(queryKey);
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddPrivateTag = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey });
modal.onClose();
}, [ queryClient, queryKey, modal ]);
const formData = React.useMemo(() => {
return {
address_hash: hash,
};
}, [ hash ]);
if (addressData?.private_tags?.length) {
return null;
}
return (
<>
<MenuItem className={ className } onClick={ handleClick }>
<Icon as={ iconPrivateTags } boxSize={ 6 } mr={ 2 }/>
<span>Add private tag</span>
</MenuItem>
<PrivateTagModal isOpen={ modal.isOpen } onClose={ modal.onClose } onSuccess={ handleAddPrivateTag } data={ formData }/>
</>
);
};
export default React.memo(chakra(PrivateTagMenuItem));
import { MenuItem, Icon, chakra } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import iconPublicTags from 'icons/publictags.svg';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
interface Props {
className?: string;
hash: string;
}
const PublicTagMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const redirectIfNotAuth = useRedirectIfNotAuth();
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
router.push({ pathname: '/account/public_tags_request', query: { address: hash } });
}, [ hash, redirectIfNotAuth, router ]);
return (
<MenuItem className={ className }onClick={ handleClick }>
<Icon as={ iconPublicTags } boxSize={ 6 } mr={ 2 }/>
<span>Add public tag</span>
</MenuItem>
);
};
export default React.memo(chakra(PublicTagMenuItem));
import { MenuItem, Icon, chakra, useDisclosure } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import iconEdit from 'icons/edit.svg';
import useApiQuery from 'lib/api/useApiQuery';
import useHasAccount from 'lib/hooks/useHasAccount';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
interface Props {
className?: string;
hash: string;
}
const TokenInfoMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const modal = useDisclosure();
const redirectIfNotAuth = useRedirectIfNotAuth();
const isAuth = useHasAccount();
const verifiedAddressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
queryOptions: {
enabled: isAuth,
},
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: undefined },
queryOptions: {
enabled: isAuth,
},
});
const tokenInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash, chainId: appConfig.network.id },
queryOptions: {
refetchOnMount: false,
},
});
const handleAddAddressClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddApplicationClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses', query: { address: hash } });
}, [ hash, router ]);
const handleVerifiedAddressSubmit = React.useCallback(async() => {
await verifiedAddressesQuery.refetch();
}, [ verifiedAddressesQuery ]);
const handleShowMyAddressesClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses' });
}, [ router ]);
const icon = <Icon as={ iconEdit } boxSize={ 6 } mr={ 2 } p={ 1 }/>;
const content = (() => {
if (!verifiedAddressesQuery.data?.verifiedAddresses.find(({ contractAddress }) => contractAddress.toLowerCase() === hash.toLowerCase())) {
return (
<MenuItem className={ className } onClick={ handleAddAddressClick }>
{ icon }
<span>{ tokenInfoQuery.data?.tokenAddress ? 'Update token info' : 'Add token info' }</span>
</MenuItem>
);
}
const hasApplication = applicationsQuery.data?.submissions.some(({ tokenAddress }) => tokenAddress.toLowerCase() === hash.toLowerCase());
return (
<MenuItem className={ className } onClick={ handleAddApplicationClick }>
{ icon }
<span>
{
hasApplication || tokenInfoQuery.data?.tokenAddress ?
'Update token info' :
'Add token info'
}
</span>
</MenuItem>
);
})();
return (
<>
{ content }
<AddressVerificationModal
defaultAddress={ hash }
isOpen={ modal.isOpen }
onClose={ modal.onClose }
onSubmit={ handleVerifiedAddressSubmit }
onAddTokenInfoClick={ handleAddApplicationClick }
onShowListClick={ handleShowMyAddressesClick }
/>
</>
);
};
export default React.memo(chakra(TokenInfoMenuItem));
......@@ -4,13 +4,13 @@ import React from 'react';
import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile';
import appConfig from 'configs/app/config';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import AddressActionsMenu from 'ui/shared/AddressActions/Menu';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
......@@ -21,8 +21,6 @@ interface Props {
}
const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props) => {
const isMobile = useIsMobile();
return (
<Flex alignItems="center">
<AddressIcon address={ address } isLoading={ isLoading }/>
......@@ -31,17 +29,18 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props
hash={ address.hash }
ml={ 2 }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
truncation={ isMobile ? 'constant' : 'none' }
isDisabled={ isLinkDisabled }
isLoading={ isLoading }
/>
<CopyToClipboard text={ address.hash } isLoading={ isLoading }/>
{ !isLoading && address.is_contract && token && <AddressAddToWallet ml={ 2 } token={ token }/> }
{ !isLoading && !address.is_contract && config.isAccountSupported && (
{ !isLoading && !address.is_contract && appConfig.isAccountSupported && (
<AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id } ml={ 3 }/>
) }
<AddressQrCode hash={ address.hash } ml={ 2 } isLoading={ isLoading }/>
{ appConfig.isAccountSupported && <AddressActionsMenu isLoading={ isLoading }/> }
</Flex>
);
};
......
......@@ -9,7 +9,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
address: AddressParam;
address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name'>;
subtitle?: string;
isLoading?: boolean;
}
......@@ -20,7 +20,7 @@ const AddressSnippet = ({ address, subtitle, isLoading }: Props) => {
<Address>
<AddressIcon address={ address } isLoading={ isLoading }/>
<AddressLink type="address" hash={ address.hash } fontWeight="600" ml={ 2 } isLoading={ isLoading }/>
<CopyToClipboard text={ address.hash } ml={ 1 } isLoading={ isLoading }/>
<CopyToClipboard text={ address.hash } isLoading={ isLoading }/>
</Address>
{ subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 } ml={ 8 }>{ subtitle }</Text> }
</Box>
......
......@@ -4,7 +4,7 @@ import React from 'react';
import txIcon from 'icons/transactions.svg';
const ErrorInvalidTxHash = () => {
const AppErrorInvalidTxHash = () => {
const textColor = useColorModeValue('gray.500', 'gray.400');
const snippet = {
borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'),
......@@ -54,4 +54,4 @@ const ErrorInvalidTxHash = () => {
);
};
export default ErrorInvalidTxHash;
export default AppErrorInvalidTxHash;
import { Box, Text, Button, Heading, Icon, chakra } from '@chakra-ui/react';
import React from 'react';
import icon403 from 'icons/error-pages/403.svg';
import useApiFetch from 'lib/api/useApiFetch';
import dayjs from 'lib/date/dayjs';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useToast from 'lib/hooks/useToast';
interface Props {
className?: string;
email?: string;
}
const AppErrorUnverifiedEmail = ({ className, email }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const handleButtonClick = React.useCallback(async() => {
const toastId = 'resend-email-error';
try {
await apiFetch('email_resend');
toast({
id: toastId,
position: 'top-right',
title: 'Success',
description: 'Email successfully resent.',
status: 'success',
variant: 'subtle',
isClosable: true,
});
} catch (error) {
const statusCode = getErrorObjStatusCode(error);
const message = (() => {
if (statusCode !== 429) {
return;
}
const payload = getErrorObjPayload<{ seconds_before_next_resend: number }>(error);
if (!payload) {
return;
}
const timeUntilNextResend = dayjs().add(payload.seconds_before_next_resend, 'seconds').fromNow();
return `Email resend is available ${ timeUntilNextResend }.`;
})();
!toast.isActive(toastId) && toast({
id: toastId,
position: 'top-right',
title: 'Error',
description: message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ apiFetch, toast ]);
return (
<Box className={ className }>
<Icon as={ icon403 } width="200px" height="auto"/>
<Heading mt={ 8 } size="2xl" fontFamily="body">Email is not verified</Heading>
<Text variant="secondary" mt={ 3 }>
<span>Please confirm your email address to use the My Account feature. A confirmation email was sent to </span>
<span>{ email || 'your email address' }</span>
<span> on signup. { `Didn't receive?` }</span>
</Text>
<Button
mt={ 8 }
size="lg"
variant="outline"
onClick={ handleButtonClick }
>
Resend verification email
</Button>
</Box>
);
};
export default chakra(AppErrorUnverifiedEmail);
......@@ -68,7 +68,7 @@ const DataListDisplay = (props: Props) => {
}
if (!props.items?.length) {
return <Text as="span">{ props.emptyText }</Text>;
return props.emptyText ? <Text as="span">{ props.emptyText }</Text> : null;
}
return (
......
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody } from '@chakra-ui/react';
import React from 'react';
import type { UserTags } from 'types/api/addressParams';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
}
interface Props {
className?: string;
data?: UserTags;
isLoading?: boolean;
tagsBefore?: Array<TagData | undefined>;
tagsAfter?: Array<TagData | undefined>;
contentAfter?: React.ReactNode;
}
const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
...(data?.watchlist_names || []),
...tagsAfter,
]
.filter(Boolean);
if (tags.length === 0 && !contentAfter) {
return null;
}
const content = (() => {
if (isMobile && tags.length > 2) {
return (
<>
{
tags
.slice(0, 2)
.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
{ tag.display_name }
</Tag>
))
}
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag onClick={ onToggle }>+{ tags.length - 1 }</Tag>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{
tags
.slice(2)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>)
}
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return tags.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
{ tag.display_name }
</Tag>
));
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
{ contentAfter }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import { Tooltip } from '@chakra-ui/react';
import React from 'react';
import shortenString from 'lib/shortenString';
interface Props {
hash: string;
isTooltipDisabled?: boolean;
......@@ -13,7 +15,7 @@ const HashStringShorten = ({ hash, isTooltipDisabled }: Props) => {
return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled }>
{ hash.slice(0, 4) + '...' + hash.slice(-4) }
{ shortenString(hash) }
</Tooltip>
);
};
......
......@@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import type { FontFace } from 'use-font-face-observer';
import useFontFaceObserver from 'use-font-face-observer';
import { BODY_TYPEFACE } from 'theme/foundations/typography';
import { BODY_TYPEFACE, HEADING_TYPEFACE } from 'theme/foundations/typography';
const TAIL_LENGTH = 4;
const HEAD_MIN_LENGTH = 4;
......@@ -31,6 +31,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled
const isFontFaceLoaded = useFontFaceObserver([
{ family: BODY_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
{ family: HEADING_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
]);
const calculateString = useCallback(() => {
......
......@@ -11,7 +11,7 @@ interface Props {
const LinkExternal = ({ href, children, className }: Props) => {
return (
<Link className={ className } fontSize="sm" display="inline-block" alignItems="center" target="_blank" href={ href }>
<Link className={ className } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center" target="_blank" href={ href }>
{ children }
<Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle"/>
</Link>
......
import { Flex, Button, Icon, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { NetworkExplorer as TNetworkExplorer } from 'types/networks';
import appConfig from 'configs/app/config';
import arrowIcon from 'icons/arrows/east-mini.svg';
import explorerIcon from 'icons/explorer.svg';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
className?: string;
type: keyof TNetworkExplorer['paths'];
pathParam: string;
hideText?: boolean;
}
const NetworkExplorers = ({ className, type, pathParam, hideText }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const explorersLinks = appConfig.network.explorers
.filter((explorer) => explorer.paths[type])
.map((explorer) => {
const url = new URL(explorer.paths[type] + '/' + pathParam, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>{ explorer.title }</LinkExternal>;
});
if (explorersLinks.length === 0) {
return null;
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
className={ className }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Verify in other explorers"
fontWeight={ 500 }
px={ 2 }
h="30px"
>
<Icon as={ explorerIcon } boxSize={ 5 } mr={ hideText ? 0 : 1 }/>
{ !hideText && <span>Explorers</span> }
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">Verify with other explorers</chakra.span>
<Flex
alignItems="center"
flexWrap="wrap"
columnGap={ 6 }
rowGap={ 3 }
mt={ 3 }
>
{ explorersLinks }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(chakra(NetworkExplorers));
import { Flex } from '@chakra-ui/react';
import React from 'react';
import getErrorStatusCode from 'lib/errors/getErrorStatusCode';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import useAdblockDetect from 'lib/hooks/useAdblockDetect';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel';
import AppError from 'ui/shared/AppError/AppError';
import AppErrorBlockConsensus from 'ui/shared/AppError/AppErrorBlockConsensus';
import AppErrorInvalidTxHash from 'ui/shared/AppError/AppErrorInvalidTxHash';
import AppErrorUnverifiedEmail from 'ui/shared/AppError/AppErrorUnverifiedEmail';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import ErrorInvalidTxHash from 'ui/shared/ErrorInvalidTxHash';
import PageContent from 'ui/shared/Page/PageContent';
import Header from 'ui/snippets/header/Header';
import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop';
......@@ -36,7 +37,7 @@ const Page = ({
mixpanel.useLogPageView(isMixpanelInited);
const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorStatusCode(error) || 500;
const statusCode = getErrorCauseStatusCode(error) || 500;
const resourceErrorPayload = getResourceErrorPayload(error);
const messageInPayload = resourceErrorPayload && 'message' in resourceErrorPayload && typeof resourceErrorPayload.message === 'string' ?
resourceErrorPayload.message :
......@@ -44,9 +45,17 @@ const Page = ({
const isInvalidTxHash = error?.message.includes('Invalid tx hash');
const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
const isUnverifiedEmail = statusCode === 403 && messageInPayload?.includes('Unverified email');
if (isInvalidTxHash) {
return <PageContent isHomePage={ isHomePage }><ErrorInvalidTxHash/></PageContent>;
return <PageContent isHomePage={ isHomePage }><AppErrorInvalidTxHash/></PageContent>;
}
if (isUnverifiedEmail) {
const email = resourceErrorPayload && 'email' in resourceErrorPayload && typeof resourceErrorPayload.email === 'string' ?
resourceErrorPayload.email :
undefined;
return <PageContent isHomePage={ isHomePage }><AppErrorUnverifiedEmail mt="50px" email={ email }/></PageContent>;
}
if (isBlockConsensus) {
......
// import { Icon } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
// import plusIcon from 'icons/plus.svg';
import * as textAdMock from 'mocks/ad/textAd';
import TestApp from 'playwright/TestApp';
import PageTitle from './PageTitle';
import DefaultView from './specs/DefaultView';
import LongNameAndManyTags from './specs/LongNameAndManyTags';
import WithTextAd from './specs/WithTextAd';
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
......@@ -19,51 +19,38 @@ test.beforeEach(async({ page }) => {
path: './playwright/image_s.jpg',
});
});
await page.route('https://example.com/logo.png', (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
});
test('default view +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="Title"
/>
<DefaultView/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with text ad, back link and addons +@mobile +@dark-mode', async({ mount }) => {
// https://github.com/microsoft/playwright/issues/15620
// not possible to pass component as a prop in tests
// const left = <Icon as={ plusIcon }/>;
test('with text ad +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="Title"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
// additionalsLeft={ left }
additionalsRight="Privet"
/>
<WithTextAd/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('long title with text ad, back link and addons +@mobile', async({ mount }) => {
test('with long name and many tags +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="This title is long, really long"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
additionalsRight="Privet, kak dela?"
/>
<LongNameAndManyTags/>
</TestApp>,
);
......
import { Heading, Flex, Grid, Tooltip, Icon, chakra, Skeleton } from '@chakra-ui/react';
import { Heading, Flex, Tooltip, Icon, Link, chakra, Box, Skeleton } from '@chakra-ui/react';
import React from 'react';
import eastArrowIcon from 'icons/arrows/east.svg';
import TextAd from 'ui/shared/ad/TextAd';
import LinkInternal from 'ui/shared/LinkInternal';
type BackLinkProp = { label: string; url: string } | { label: string; onClick: () => void };
type Props = {
text: string;
additionalsLeft?: React.ReactNode;
additionalsRight?: React.ReactNode;
withTextAd?: boolean;
title: string;
className?: string;
backLinkLabel?: string;
backLinkUrl?: string;
backLink?: BackLinkProp;
beforeTitle?: React.ReactNode;
afterTitle?: React.ReactNode;
contentAfter?: React.ReactNode;
isLoading?: boolean;
withTextAd?: boolean;
}
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLinkUrl, backLinkLabel, className, isLoading }: Props) => {
const title = (
<Skeleton isLoaded={ !isLoading }>
<Heading
as="h1"
size="lg"
flex="none"
wordBreak="break-word"
>
{ text }
</Heading>
</Skeleton>
const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => {
if (!props) {
return null;
}
if (props.isLoading) {
return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } isLoaded={ !props.isLoading }/>;
}
const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>;
if ('url' in props) {
return (
<Tooltip label={ props.label }>
<LinkInternal display="inline-flex" href={ props.url } h="40px" mr={ 3 }>
{ icon }
</LinkInternal>
</Tooltip>
);
}
return (
<Tooltip label={ props.label }>
<Link display="inline-flex" onClick={ props.onClick } h="40px" mr={ 3 }>
{ icon }
</Link>
</Tooltip>
);
};
const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoading, afterTitle, beforeTitle }: Props) => {
return (
<Flex
columnGap={ 3 }
rowGap={ 3 }
alignItems={{ base: 'start', lg: 'center' }}
flexDirection={{ base: 'column', lg: 'row' }}
mb={ 6 }
justifyContent="space-between"
className={ className }
mb={ 6 }
flexDir="row"
flexWrap="wrap"
rowGap={ 3 }
columnGap={ 3 }
alignItems="center"
>
<Flex flexWrap="wrap" columnGap={ 3 } alignItems="center" width={ withTextAd ? 'unset' : '100%' } flexShrink={ 0 }>
<Grid
templateColumns={ [ backLinkUrl && 'auto', additionalsLeft && 'auto', '1fr' ].filter(Boolean).join(' ') }
columnGap={ 3 }
<Box>
{ backLink && <BackLink { ...backLink } isLoading={ isLoading }/> }
{ beforeTitle }
<Skeleton
isLoaded={ !isLoading }
display="inline"
verticalAlign={ isLoading ? 'super' : undefined }
>
{ backLinkUrl && (
<Tooltip label={ backLinkLabel }>
<LinkInternal display="inline-flex" href={ backLinkUrl } h="40px">
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>
</LinkInternal>
</Tooltip>
) }
{ additionalsLeft !== undefined && (
<Flex h="40px" alignItems="center">
{ additionalsLeft }
</Flex>
) }
{ title }
</Grid>
{ additionalsRight }
</Flex>
{ withTextAd && <TextAd flexShrink={ 100 }/> }
<Heading
as="h1"
size="lg"
display="inline"
wordBreak="break-word"
w="100%"
>
{ title }
</Heading>
</Skeleton>
{ afterTitle }
</Box>
{ contentAfter }
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto"/> }
</Flex>
);
};
......
import { Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import iconSuccess from 'icons/status/success.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import TokenLogo from 'ui/shared/TokenLogo';
import PageTitle from '../PageTitle';
const DefaultView = () => {
const isMobile = useIsMobile();
const tokenData: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHAAAAAAAAAAAAA',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: 'https://example.com/logo.png',
};
const backLink = {
label: 'Back to tokens list',
url: 'https://localhost:3000/tokens',
};
const contentAfter = (
<EntityTags
tagsBefore={ [
{ label: 'example', display_name: 'Example label' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto" hideText={ isMobile }/> }
flexGrow={ 1 }
/>
);
return (
<PageTitle
title="Shavukha Token (SHVKH) token"
beforeTitle={ (
<TokenLogo data={ tokenData } boxSize={ 6 } display="inline-block" mr={ 2 }/>
) }
afterTitle={ <Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> }
backLink={ backLink }
contentAfter={ contentAfter }
/>
);
};
export default DefaultView;
/* eslint-disable max-len */
import { Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import iconSuccess from 'icons/status/success.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import TokenLogo from 'ui/shared/TokenLogo';
import PageTitle from '../PageTitle';
const LongNameAndManyTags = () => {
const isMobile = useIsMobile();
const tokenData: TokenInfo = {
address: '0xa77A39CC9680B10C00af5D4ABFc92e1F07406c64',
decimals: null,
exchange_rate: null,
holders: '294',
icon_url: null,
name: 'Ring ding ding daa baa Baa aramba baa bom baa barooumba Wh-wha-what&#39;s going on-on? Ding, ding This is the Crazy Frog Ding, ding Bem, bem! Ring ding ding ding ding ding Ring ding ding ding bem bem bem Ring ding ding ding ding ding Ring ding ding ding baa b',
symbol: 'BatcoiRing ding ding daa baa Baa aramba baa bom baa barooumba Wh-wha-what&#39;s going on-on? Ding, ding This is the Crazy Frog Ding, ding Bem, bem! Ring ding ding ding ding ding Ring ding ding ding bem bem bem Ring ding ding ding ding ding Ring ding ding ding',
total_supply: '13747',
type: 'ERC-721',
};
const contentAfter = (
<EntityTags
data={{
private_tags: [ privateTag ],
public_tags: [ publicTag ],
watchlist_names: [ watchlistName ],
}}
tagsBefore={ [
{ label: 'example', display_name: 'Example with long name' },
] }
tagsAfter={ [
{ label: 'after_1', display_name: 'Another tag' },
{ label: 'after_2', display_name: 'And yet more' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto" hideText={ isMobile }/> }
flexGrow={ 1 }
/>
);
const tokenSymbolText = ` (${ trimTokenSymbol(tokenData.symbol) })`;
return (
<PageTitle
title={ `${ tokenData?.name }${ tokenSymbolText } token` }
beforeTitle={ (
<TokenLogo data={ tokenData } boxSize={ 6 } display="inline-block" mr={ 2 }/>
) }
afterTitle={ <Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> }
contentAfter={ contentAfter }
/>
);
};
export default LongNameAndManyTags;
import React from 'react';
import Tag from 'ui/shared/chakra/Tag';
import PageTitle from '../PageTitle';
const WithTextAd = () => {
const backLink = {
label: 'Back to Home',
url: 'https://localhost:3000',
};
return (
<PageTitle
title="Block"
backLink={ backLink }
contentAfter={ <Tag key="custom" colorScheme="orange" variant="solid">Awesome</Tag> }
withTextAd
/>
);
};
export default WithTextAd;
import { Image, chakra, useColorModeValue, Icon, Skeleton } from '@chakra-ui/react';
import { Image, chakra, Skeleton } from '@chakra-ui/react';
import React from 'react';
import appConfig from 'configs/app/config';
import tokenPlaceholderIcon from 'icons/token-placeholder.svg';
const EmptyElement = ({ className }: { className?: string }) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
import type { TokenInfo } from 'types/api/token';
return (
<Icon
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
import appConfig from 'configs/app/config';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
hash?: string;
name?: string | null;
export interface Props {
data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name'>;
className?: string;
isLoading?: boolean;
}
const TokenLogo = ({ hash, name, className, isLoading }: Props) => {
const TokenLogo = ({ className, isLoading, data }: Props) => {
if (isLoading) {
return <Skeleton className={ className } borderRadius="base"/>;
}
const logoSrc = appConfig.network.assetsPathname && hash ? [
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/',
appConfig.network.assetsPathname,
'/assets/',
hash,
'/logo.png',
].join('') : undefined;
const logoSrc = (() => {
if (data?.icon_url) {
return data.icon_url;
}
if (appConfig.network.assetsPathname && data?.address) {
return [
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/',
appConfig.network.assetsPathname,
'/assets/',
data.address,
'/logo.png',
].join('');
}
})();
return (
<Image
borderRadius="base"
className={ className }
src={ logoSrc }
alt={ `${ name || 'token' } logo` }
fallback={ <EmptyElement className={ className }/> }
alt={ `${ data?.name || 'token' } logo` }
fallback={ <TokenLogoPlaceholder className={ className }/> }
/>
);
};
......
import { chakra, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import tokenPlaceholderIcon from 'icons/token-placeholder.svg';
const TokenLogoPlaceholder = ({ className }: { className?: string }) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
return (
<Icon
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
export default chakra(TokenLogoPlaceholder);
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import TestApp from 'playwright/TestApp';
import TokenSnippet from './TokenSnippet';
const API_URL = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x363574E6C5C71c343d7348093D84320c76d5Dd29/logo.png';
test.use(devices['iPhone 13 Pro']);
test('unnamed', async({ mount }) => {
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'xDAI',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: null,
};
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" symbol="xDAI"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
......@@ -20,16 +31,40 @@ test('unnamed', async({ mount }) => {
});
test('named', async({ mount }) => {
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHA',
name: 'Shavuha token',
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: null,
};
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" name="Shavuha token" symbol="SHA"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with logo', async({ mount, page }) => {
test('with logo and long symbol', async({ mount, page }) => {
const API_URL = 'https://example.com/logo.png';
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHAAAAAAAAAAAAA',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: API_URL,
};
await page.route(API_URL, (route) => {
return route.fulfill({
status: 200,
......@@ -39,7 +74,7 @@ test('with logo', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
......
import { Flex, Text, chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
symbol?: string | null;
hash: string;
name?: string | null;
data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name' | 'symbol'>;
className?: string;
logoSize?: number;
isDisabled?: boolean;
hideSymbol?: boolean;
}
const TokenSnippet = ({ symbol, hash, name, className, logoSize = 6, isDisabled }: Props) => {
const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol }: Props) => {
return (
<Flex className={ className } alignItems="center" columnGap={ 2 } w="100%">
<TokenLogo boxSize={ logoSize } hash={ hash } name={ name }/>
<AddressLink hash={ hash } alias={ name } type="token" isDisabled={ isDisabled }/>
{ symbol && <Text variant="secondary">({ symbol })</Text> }
<TokenLogo boxSize={ logoSize } data={ data }/>
<AddressLink hash={ data?.address || '' } alias={ data?.name || 'Unnamed token' } type="token" isDisabled={ isDisabled }/>
{ data?.symbol && !hideSymbol && <Text variant="secondary">({ trimTokenSymbol(data.symbol) })</Text> }
</Flex>
);
};
......
......@@ -52,7 +52,7 @@ const TokenTransferListItem = ({
<ListItemMobile rowGap={ 3 } isAnimated>
<Flex w="100%" justifyContent="space-between">
<Flex flexWrap="wrap" rowGap={ 1 } mr={ showTxInfo && txHash ? 2 : 0 }>
<TokenSnippet hash={ token.address } w="auto" maxW="calc(100% - 140px)" name={ token.name || 'Unnamed token' }/>
<TokenSnippet data={ token } w="auto" maxW="calc(100% - 140px)" hideSymbol/>
<Tag flexShrink={ 0 } ml={ 2 } mr={ 2 }>{ token.type }</Tag>
<Tag colorScheme="orange">{ getTokenTransferTypeText(type) }</Tag>
</Flex>
......
......@@ -45,7 +45,7 @@ const TokenTransferTableItem = ({
) }
<Td>
<Flex flexDir="column" alignItems="flex-start">
<TokenSnippet hash={ token.address } name={ token.name || 'Unnamed token' } lineHeight="30px"/>
<TokenSnippet data={ token } lineHeight="30px" hideSymbol/>
<Tag mt={ 1 }>{ token.type }</Tag>
<Tag colorScheme="orange" mt={ 2 }>{ getTokenTransferTypeText(type) }</Tag>
</Flex>
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import appConfig from 'configs/app/config';
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
interface Props {
children: React.ReactNode;
fallback?: JSX.Element | (() => JSX.Element);
}
const Web3ModalProvider = ({ children, fallback }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
if (!wagmiClient || !ethereumClient) {
return typeof fallback === 'function' ? fallback() : (fallback || null);
}
return (
<WagmiConfig client={ wagmiClient }>
{ children }
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
);
};
export default Web3ModalProvider;
......@@ -8,7 +8,7 @@ interface Props extends TagProps {
isLoading?: boolean;
}
const Tag = ({ isLoading, ...props }: Props) => {
const Tag = ({ isLoading, ...props }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
if (props.isTruncated && typeof props.children === 'string') {
if (!props.children) {
......@@ -18,16 +18,16 @@ const Tag = ({ isLoading, ...props }: Props) => {
return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<TruncatedTextTooltip label={ props.children }>
<ChakraTag { ...props }/>
<ChakraTag { ...props } ref={ ref }/>
</TruncatedTextTooltip>
</Skeleton>
);
}
return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<ChakraTag { ...props }/>
<ChakraTag { ...props } ref={ ref }/>
</Skeleton>
);
};
export default React.memo(Tag);
export default React.memo(React.forwardRef(Tag));
......@@ -17,6 +17,12 @@ interface Props<V extends FieldValues, N extends Path<V>> {
const FileInput = <Values extends FieldValues, Names extends Path<Values>>({ children, accept, multiple, field }: Props<Values, Names>) => {
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (!multiple && field.value?.length === 0 && ref.current?.value) {
ref.current.value = '';
}
}, [ field.value?.length, multiple ]);
const onChange = React.useCallback((files: Array<File>) => {
field.onChange([ ...(field.value || []), ...files ]);
}, [ field ]);
......
import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
import React from 'react';
......@@ -35,7 +36,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
return;
}
const url = route({ pathname: '/api/media-type', query: { url: animationUrl } });
const url = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url: animationUrl } });
fetch(url)
.then((response) => response.json())
.then((_data) => {
......
import { Box, Link, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
className?: string;
}
const AdminSupportText = ({ className }: Props) => {
return (
<Box className={ className }>
<span>Need help? Contact admin team at </span>
<Link href="mailto:help@blockscout.com">help@blockscout.com</Link>
<span> for assistance!</span>
</Box>
);
};
export default chakra(AdminSupportText);
......@@ -44,7 +44,7 @@ const Burger = () => {
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerBody p={ 6 } display="flex" flexDirection="column">
<Icon as={ testnetIcon } h="14px" w="auto" color="red.400" alignSelf="flex-start"/>
{ appConfig.network.isTestnet && <Icon as={ testnetIcon } h="14px" w="auto" color="red.400" alignSelf="flex-start"/> }
<Flex alignItems="center" justifyContent="space-between">
<NetworkLogo onClick={ handleNetworkLogoClick }/>
{ appConfig.featuredNetworks ? (
......
......@@ -3,8 +3,9 @@ import NextLink from 'next/link';
import { route } from 'nextjs-routes';
import React from 'react';
import type { NavItem } from 'types/client/navigation-items';
import useIsMobile from 'lib/hooks/useIsMobile';
import type { NavItem } from 'lib/hooks/useNavItems';
import { isInternalItem } from 'lib/hooks/useNavItems';
import useColors from './useColors';
......@@ -20,26 +21,18 @@ type Props = {
const NavLink = ({ item, isCollapsed, px, className }: Props) => {
const isMobile = useIsMobile();
const colors = useColors();
const isExpanded = isCollapsed === false;
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalItem(item) && item.isActive });
const isExpanded = isCollapsed === false;
const isInternalLink = isInternalItem(item);
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive });
const isXLScreen = useBreakpointValue({ base: false, xl: true });
let href: string| undefined;
const isInternal = isInternalItem(item);
if (isInternal) {
href = !item.isNewUi ? route(item.nextRoute) : undefined;
} else {
href = item.url;
}
const href = isInternalLink ? route(item.nextRoute) : item.url;
const content = (
<Link
href={ href }
target={ isInternal ? '_self' : '_blank' }
target={ isInternalLink ? '_self' : '_blank' }
{ ...styleProps.itemProps }
w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }}
display="flex"
......@@ -54,10 +47,10 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => {
placement="right"
variant="nav"
gutter={ 20 }
color={ isInternalItem(item) && item.isActive ? colors.text.active : colors.text.hover }
color={ isInternalLink && item.isActive ? colors.text.active : colors.text.hover }
>
<HStack spacing={ 3 } overflow="hidden">
<Icon as={ item.icon } boxSize="30px"/>
{ item.icon && <Icon as={ item.icon } boxSize="30px"/> }
<Text { ...styleProps.textProps }>
{ item.text }
</Text>
......@@ -68,9 +61,7 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => {
return (
<Box as="li" listStyleType="none" w="100%" className={ className }>
{ /* why not NextLink in all cases? since prev UI and new one are hosting in the same domain and global routing is managed by nginx */ }
{ /* we have to hard reload page on every transition between urls from different part of the app */ }
{ isInternalItem(item) && item.isNewUi ? (
{ isInternalLink ? (
<NextLink href={ item.nextRoute } passHref legacyBehavior>
{ content }
</NextLink>
......
......@@ -12,8 +12,9 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { NavGroupItem } from 'types/client/navigation-items';
import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import NavLink from './NavLink';
import useNavLinkStyleProps from './useNavLinkStyleProps';
......
......@@ -7,8 +7,9 @@ import {
} from '@chakra-ui/react';
import React from 'react';
import type { NavGroupItem } from 'types/client/navigation-items';
import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import useNavLinkStyleProps from './useNavLinkStyleProps';
......
......@@ -61,7 +61,7 @@ const NavigationDesktop = () => {
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
{ ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) }
>
<Icon as={ testnetIcon } h="14px" w="auto" color="red.400" pl={ 3 } alignSelf="flex-start"/>
{ appConfig.network.isTestnet && <Icon as={ testnetIcon } h="14px" w="auto" color="red.400" pl={ 3 } alignSelf="flex-start"/> }
<Box
as="header"
display="flex"
......
......@@ -4,5 +4,4 @@ export interface NetworkLink {
pathname: string;
name: string;
icon?: FunctionComponent<SVGAttributes<SVGElement>>;
isNewUi?: boolean;
}
......@@ -132,3 +132,39 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search with simple match', async({ mount, page }) => {
const API_URL = buildApiUrl('search') + `?q=${ searchMock.tx1.tx_hash }`;
const API_CHECK_REDIRECT_URL = buildApiUrl('search_check_redirect') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.tx1,
],
}),
}));
await page.route(API_CHECK_REDIRECT_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
parameter: searchMock.tx1.tx_hash,
redirect: true,
type: 'transaction',
}),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
const resultText = page.getByText('Found 1 matching result');
expect(resultText).toBeTruthy();
const links = page.getByText(searchMock.tx1.tx_hash);
await expect(links).toHaveCount(1);
});
......@@ -24,7 +24,7 @@ const SearchBar = ({ isHomepage }: Props) => {
const isMobile = useIsMobile();
const router = useRouter();
const { searchTerm, handleSearchTermChange, query, pathname } = useSearchQuery();
const { searchTerm, handleSearchTermChange, query, pathname, redirectCheckQuery } = useSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
......@@ -115,7 +115,7 @@ const SearchBar = ({ isHomepage }: Props) => {
</PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 }>
<SearchBarSuggest query={ query } searchTerm={ searchTerm } onItemClick={ handleItemClick }/>
<SearchBarSuggest query={ query } redirectCheckQuery={ redirectCheckQuery } searchTerm={ searchTerm } onItemClick={ handleItemClick }/>
</PopoverBody>
</PopoverContent>
</Popover>
......
import { Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import _uniqBy from 'lodash/uniqBy';
import React from 'react';
import type { SearchResult } from 'types/api/search';
import type { SearchRedirectResult, SearchResult, SearchResultItem } from 'types/api/search';
import useIsMobile from 'lib/hooks/useIsMobile';
import TextAd from 'ui/shared/ad/TextAd';
......@@ -11,34 +12,86 @@ import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SearchBarSuggestItem from './SearchBarSuggestItem';
const getUniqueIdentifier = (item: SearchResultItem) => {
switch (item.type) {
case 'contract':
case 'address': {
return item.address;
}
case 'transaction': {
return item.tx_hash;
}
case 'block': {
return item.block_hash || item.block_number;
}
case 'token': {
return item.address;
}
}
};
interface Props {
query: UseQueryResult<SearchResult> & {
pagination: PaginationProps;
};
redirectCheckQuery: UseQueryResult<SearchRedirectResult>;
searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const SearchBarSuggest = ({ query, searchTerm, onItemClick }: Props) => {
const SearchBarSuggest = ({ query, redirectCheckQuery, searchTerm, onItemClick }: Props) => {
const isMobile = useIsMobile();
const simpleMatch: SearchResultItem | undefined = React.useMemo(() => {
if (!redirectCheckQuery.data || !redirectCheckQuery.data.redirect || !redirectCheckQuery.data.parameter) {
return;
}
switch (redirectCheckQuery.data?.type) {
case 'address': {
return {
type: 'address',
name: '',
address: redirectCheckQuery.data.parameter,
};
}
case 'transaction': {
return {
type: 'transaction',
tx_hash: redirectCheckQuery.data.parameter,
};
}
}
}, [ redirectCheckQuery.data ]);
const items = React.useMemo(() => {
return _uniqBy(
[
simpleMatch,
...(query.data?.items || []),
].filter(Boolean),
getUniqueIdentifier,
);
}, [ query.data?.items, simpleMatch ]);
const content = (() => {
if (query.isLoading) {
return <ContentLoader text="We are searching, please wait... "/>;
if (query.isLoading && !simpleMatch) {
return <ContentLoader text="We are searching, please wait... " fontSize="sm"/>;
}
if (query.isError) {
if (query.isError && !simpleMatch) {
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
}
const num = query.data.next_page_params ? '50+' : query.data.items.length;
const resultText = query.data.items.length > 1 || query.pagination.page > 1 ? 'results' : 'result';
const num = query.data?.next_page_params ? '50+' : items.length;
const resultText = items.length > 1 || query.pagination.page > 1 ? 'results' : 'result';
return (
<>
<Text fontWeight={ 500 } fontSize="sm">Found <Text fontWeight={ 700 } as="span">{ num }</Text> matching { resultText }</Text>
{ query.data.items.map((item, index) =>
{ items.map((item, index) =>
<SearchBarSuggestItem key={ index } data={ item } isMobile={ isMobile } searchTerm={ searchTerm } onClick={ onItemClick }/>) }
{ query.isLoading && <ContentLoader text="We are still searching, please wait... " fontSize="sm" mt={ 5 }/> }
</>
);
})();
......
......@@ -24,8 +24,11 @@ export default function useSearchQuery(isSearchPage = false) {
});
const redirectCheckQuery = useApiQuery('search_check_redirect', {
queryParams: { q: q.current },
queryOptions: { enabled: isSearchPage && Boolean(q) },
// on search result page we check redirect only once on mount
// on pages with regular search bar we check redirect on every search term change
// in order to prepend its result to suggest list since this resource is much faster than regular search
queryParams: { q: isSearchPage ? q.current : debouncedSearchTerm },
queryOptions: { enabled: Boolean(isSearchPage ? q.current : debouncedSearchTerm) },
});
useUpdateValueEffect(() => {
......
import { Flex, Link, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import iconDocs from 'icons/docs.svg';
import iconEmail from 'icons/email.svg';
import iconLink from 'icons/link.svg';
import iconCoinGecko from 'icons/social/coingecko.svg';
import iconCoinMarketCap from 'icons/social/coinmarketcap.svg';
import iconDefiLlama from 'icons/social/defi_llama.svg';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import LinkExternal from 'ui/shared/LinkExternal';
import TextSeparator from 'ui/shared/TextSeparator';
interface Props {
data: TokenVerifiedInfo;
}
interface TServiceLink {
field: keyof TokenVerifiedInfo;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
hint: string;
}
const SOCIAL_LINKS: Array<TServiceLink> = [
{ field: 'github', icon: iconGithub, hint: 'Github account' },
{ field: 'twitter', icon: iconTwitter, hint: 'Twitter account' },
{ field: 'telegram', icon: iconTelegram, hint: 'Telegram account' },
{ field: 'openSea', icon: iconOpenSea, hint: 'OpenSea page' },
{ field: 'linkedin', icon: iconLinkedIn, hint: 'LinkedIn page' },
{ field: 'facebook', icon: iconFacebook, hint: 'Facebook account' },
{ field: 'discord', icon: iconDiscord, hint: 'Discord account' },
{ field: 'medium', icon: iconMedium, hint: 'Medium account' },
{ field: 'slack', icon: iconSlack, hint: 'Slack account' },
{ field: 'reddit', icon: iconReddit, hint: 'Reddit account' },
];
const PRICE_TICKERS: Array<TServiceLink> = [
{ field: 'coinGeckoTicker', icon: iconCoinGecko, hint: 'Coin Gecko' },
{ field: 'coinMarketCapTicker', icon: iconCoinMarketCap, hint: 'Coin Market Cap' },
{ field: 'defiLlamaTicker', icon: iconDefiLlama, hint: 'Defi Llama' },
];
const ServiceLink = ({ href, hint, icon }: TServiceLink & { href: string | undefined }) => (
<Link
href={ href }
variant="secondary"
boxSize={ 5 }
aria-label={ hint }
title={ hint }
target="_blank"
>
<Icon as={ icon } boxSize={ 5 }/>
</Link>
);
// todo_tom DELETE ME
const TokenDetailsVerifiedInfo = ({ data }: Props) => {
const websiteLink = (() => {
try {
const url = new URL(data.projectWebsite);
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconLink } boxSize={ 6 }/>
<LinkExternal href={ data.projectWebsite } fontSize="md">{ url.host }</LinkExternal>
</Flex>
);
} catch (error) {
return null;
}
})();
const docsLink = (() => {
if (!data.docs) {
return null;
}
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconDocs } boxSize={ 6 }/>
<LinkExternal href={ data.docs } fontSize="md">Documentation</LinkExternal>
</Flex>
);
})();
const supportLink = (() => {
if (!data.support) {
return null;
}
const isEmail = data.support.includes('@');
const href = isEmail ? `mailto:${ data.support }` : data.support;
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconEmail } boxSize={ 6 }/>
<Link href={ href } target="_blank">
{ data.support }
</Link>
</Flex>
);
})();
const socialLinks = SOCIAL_LINKS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
const priceTickersLinks = PRICE_TICKERS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
return (
<DetailsInfoItem
title="Links"
hint="Links to the project's official website and social media channels."
>
<Flex flexDir="column" rowGap={ 5 }>
<Flex
flexDir={{ base: 'column', lg: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
columnGap={ 6 }
rowGap={ 2 }
>
{ websiteLink }
{ docsLink }
{ supportLink }
</Flex>
{ (socialLinks.length > 0 || priceTickersLinks.length > 0) && (
<Flex
columnGap={ 2 }
rowGap={ 2 }
flexWrap="wrap"
>
{ socialLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
{ priceTickersLinks.length > 0 && (
<>
<TextSeparator color="divider"/>
{ priceTickersLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</>
) }
</Flex>
) }
</Flex>
</DetailsInfoItem>
);
};
export default React.memo(TokenDetailsVerifiedInfo);
import {
Popover, PopoverTrigger, PopoverContent, PopoverBody,
Modal, ModalContent, ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import Content from './TokenProjectInfo/Content';
import TriggerButton from './TokenProjectInfo/TriggerButton';
interface Props {
data: TokenVerifiedInfo;
}
const TokenProjectInfo = ({ data }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
if (isMobile) {
return (
<>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
<Content data={ data }/>
</ModalContent>
</Modal>
</>
);
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
<Content data={ data }/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(TokenProjectInfo);
import { Flex, Text, Box, Grid } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import iconCoinGecko from 'icons/social/coingecko.svg';
import iconCoinMarketCap from 'icons/social/coinmarketcap.svg';
import iconDefiLlama from 'icons/social/defi_llama.svg';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import DocsLink from './DocsLink';
import type { Props as ServiceLinkProps } from './ServiceLink';
import ServiceLink from './ServiceLink';
import SupportLink from './SupportLink';
interface Props {
data: TokenVerifiedInfo;
}
const SOCIAL_LINKS: Array<Omit<ServiceLinkProps, 'href'>> = [
{ field: 'github', icon: iconGithub, title: 'Github' },
{ field: 'twitter', icon: iconTwitter, title: 'Twitter' },
{ field: 'telegram', icon: iconTelegram, title: 'Telegram' },
{ field: 'openSea', icon: iconOpenSea, title: 'OpenSea' },
{ field: 'linkedin', icon: iconLinkedIn, title: 'LinkedIn' },
{ field: 'facebook', icon: iconFacebook, title: 'Facebook' },
{ field: 'discord', icon: iconDiscord, title: 'Discord' },
{ field: 'medium', icon: iconMedium, title: 'Medium' },
{ field: 'slack', icon: iconSlack, title: 'Slack' },
{ field: 'reddit', icon: iconReddit, title: 'Reddit' },
];
const PRICE_TICKERS: Array<Omit<ServiceLinkProps, 'href'>> = [
{ field: 'coinGeckoTicker', icon: iconCoinGecko, title: 'CoinGecko' },
{ field: 'coinMarketCapTicker', icon: iconCoinMarketCap, title: 'CoinMarketCap' },
{ field: 'defiLlamaTicker', icon: iconDefiLlama, title: 'DefiLlama' },
];
const Content = ({ data }: Props) => {
const docs = <DocsLink href={ data.docs }/>;
const support = <SupportLink url={ data.support }/>;
const description = data.projectDescription ? <Text fontSize="sm" mt={ 3 }>{ data.projectDescription }</Text> : null;
const socialLinks = SOCIAL_LINKS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
const priceTickersLinks = PRICE_TICKERS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
return (
<Box fontSize="sm">
{ (description || docs || support) && (
<>
<Text variant="secondary" fontSize="xs">Description and support info</Text>
{ description }
{ (docs || support) && (
<Flex alignItems="center" flexWrap="wrap" columnGap={ 6 } mt={ 3 }>
{ support }
{ docs }
</Flex>
) }
</>
) }
{ socialLinks.length > 0 && (
<>
<Text variant="secondary" fontSize="xs" mt={ 5 }>Links</Text>
<Grid templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} columnGap={ 4 } rowGap={ 3 } mt={ 3 }>
{ socialLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</Grid>
</>
) }
{ priceTickersLinks.length > 0 && (
<>
<Text variant="secondary" fontSize="xs" mt={ 5 }>Crypto markets</Text>
<Grid templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} columnGap={ 4 } rowGap={ 3 } mt={ 3 }>
{ priceTickersLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</Grid>
</>
) }
</Box>
);
};
export default Content;
import { Icon, Link } from '@chakra-ui/react';
import React from 'react';
import iconDocs from 'icons/docs.svg';
interface Props {
href?: string;
}
const DocsLink = ({ href }: Props) => {
if (!href) {
return null;
}
return (
<Link
href={ href }
target="_blank"
display="inline-flex"
alignItems="center"
columnGap={ 1 }
>
<Icon as={ iconDocs } boxSize={ 6 } color="text_secondary"/>
<span>Documentation</span>
</Link>
);
};
export default DocsLink;
import { Link, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
export interface Props {
field: keyof TokenVerifiedInfo;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
title: string;
href?: string;
}
const ServiceLink = ({ href, title, icon }: Props) => {
return (
<Link
href={ href }
aria-label={ title }
title={ title }
target="_blank"
display="inline-flex"
alignItems="center"
>
<Icon as={ icon } boxSize={ 5 } mr={ 2 } color="text_secondary"/>
<span>{ title }</span>
</Link>
);
};
export default ServiceLink;
import { Icon, Link } from '@chakra-ui/react';
import React from 'react';
import iconEmail from 'icons/email.svg';
import iconLink from 'icons/link.svg';
interface Props {
url?: string;
}
const SupportLink = ({ url }: Props) => {
if (!url) {
return null;
}
const isEmail = url.includes('@');
const href = isEmail ? `mailto:${ url }` : url;
const icon = isEmail ? iconEmail : iconLink;
return (
<Link
href={ href }
target="_blank"
display="inline-flex"
alignItems="center"
columnGap={ 1 }
>
<Icon as={ icon } boxSize={ 6 } color="text_secondary"/>
<span>{ url }</span>
</Link>
);
};
export default SupportLink;
import { Button, Icon } from '@chakra-ui/react';
import React from 'react';
import arrowIcon from 'icons/arrows/east-mini.svg';
import rocketIcon from 'icons/rocket.svg';
interface Props {
onClick: () => void;
isOpen: boolean;
}
const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onClick }
aria-label="Show project info"
fontWeight={ 500 }
px={ 2 }
h="30px"
>
<Icon as={ rocketIcon } boxSize={ 4 } mr={ 1 }/>
<span>Project Info</span>
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button>
);
};
export default React.forwardRef(TriggerButton);
import { Text, Flex, Icon, useColorModeValue, Skeleton } from '@chakra-ui/react';
import { Flex, Icon, useColorModeValue, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -63,13 +63,11 @@ const TokenTransferListItem = ({
</Address>
</Flex>
{ timestamp && (
<Text variant="secondary" fontWeight="400" fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>
{ timeAgo }
</span>
</Skeleton>
</Text>
<Skeleton isLoaded={ !isLoading } display="inline-block" fontWeight="400" fontSize="sm" color="text_secondary">
<span>
{ timeAgo }
</span>
</Skeleton>
) }
</Flex>
{ method && <Tag isLoading={ isLoading }>{ method }</Tag> }
......
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TokenVerifiedInfo as TTokenVerifiedInfo } from 'types/api/token';
import LinkExternal from 'ui/shared/LinkExternal';
import TokenProjectInfo from './TokenProjectInfo';
interface Props {
verifiedInfoQuery: UseQueryResult<TTokenVerifiedInfo>;
isVerifiedInfoEnabled: boolean;
}
const TokenVerifiedInfo = ({ verifiedInfoQuery, isVerifiedInfoEnabled }: Props) => {
const { data, isLoading, isError } = verifiedInfoQuery;
const websiteLinkBg = useColorModeValue('gray.100', 'gray.700');
const content = (() => {
if (!isVerifiedInfoEnabled) {
return null;
}
if (isLoading) {
return (
<>
<Skeleton w="130px" h="30px" borderRadius="base"/>
<Skeleton w="130px" h="30px" borderRadius="base"/>
<Skeleton w="120px" h="30px" borderRadius="base"/>
</>
);
}
if (isError) {
return null;
}
const websiteLink = (() => {
try {
const url = new URL(data.projectWebsite);
return (
<LinkExternal href={ data.projectWebsite } px="10px" py="5px" bgColor={ websiteLinkBg } borderRadius="base">{ url.host }</LinkExternal>
);
} catch (error) {
return null;
}
})();
return (
<>
{ websiteLink }
{ Boolean(data.tokenAddress) && <TokenProjectInfo data={ data }/> }
</>
);
})();
return <Flex columnGap={ 3 } rowGap={ 3 } mt={ 5 } flexWrap="wrap" _empty={{ display: 'none' }}>{ content }</Flex>;
};
export default React.memo(TokenVerifiedInfo);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TokenInfoForm from './TokenInfoForm';
const FORM_CONFIG_URL = buildApiUrl('token_info_applications_config', { chainId: '1' });
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
});
});
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
const props = {
address: mocks.VERIFIED_ADDRESS.ITEM_1.contractAddress,
tokenName: 'Test Token (TT)',
application: mocks.TOKEN_INFO_APPLICATION.APPROVED,
onSubmit: () => {},
};
const component = await mount(
<TestApp>
<TokenInfoForm { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('status IN_PROCESS', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
});
});
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
const props = {
address: mocks.VERIFIED_ADDRESS.ITEM_1.contractAddress,
tokenName: 'Test Token (TT)',
application: mocks.TOKEN_INFO_APPLICATION.IN_PROCESS,
onSubmit: () => {},
};
const component = await mount(
<TestApp>
<TokenInfoForm { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Button, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { Fields } from './types';
import type { TokenInfoApplication } from 'types/api/account';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
import useToast from 'lib/hooks/useToast';
import useUpdateEffect from 'lib/hooks/useUpdateEffect';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TokenInfoFieldAddress from './fields/TokenInfoFieldAddress';
import TokenInfoFieldComment from './fields/TokenInfoFieldComment';
import TokenInfoFieldDocs from './fields/TokenInfoFieldDocs';
import TokenInfoFieldIconUrl from './fields/TokenInfoFieldIconUrl';
import TokenInfoFieldPriceTicker from './fields/TokenInfoFieldPriceTicker';
import TokenInfoFieldProjectDescription from './fields/TokenInfoFieldProjectDescription';
import TokenInfoFieldProjectEmail from './fields/TokenInfoFieldProjectEmail';
import TokenInfoFieldProjectName from './fields/TokenInfoFieldProjectName';
import TokenInfoFieldProjectSector from './fields/TokenInfoFieldProjectSector';
import TokenInfoFieldProjectWebsite from './fields/TokenInfoFieldProjectWebsite';
import TokenInfoFieldRequesterEmail from './fields/TokenInfoFieldRequesterEmail';
import TokenInfoFieldRequesterName from './fields/TokenInfoFieldRequesterName';
import TokenInfoFieldSocialLink from './fields/TokenInfoFieldSocialLink';
import TokenInfoFieldSupport from './fields/TokenInfoFieldSupport';
import TokenInfoFieldTokenName from './fields/TokenInfoFieldTokenName';
import TokenInfoFormSectionHeader from './TokenInfoFormSectionHeader';
import TokenInfoFormStatusText from './TokenInfoFormStatusText';
import { getFormDefaultValues, prepareRequestBody } from './utils';
interface Props {
address: string;
tokenName: string;
application?: TokenInfoApplication;
onSubmit: (application: TokenInfoApplication) => void;
}
const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) => {
const containerRef = React.useRef<HTMLFormElement>(null);
const apiFetch = useApiFetch();
const toast = useToast();
const configQuery = useApiQuery('token_info_applications_config', {
pathParams: { chainId: appConfig.network.id },
});
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: getFormDefaultValues(address, tokenName, application),
});
const { handleSubmit, formState, control, trigger } = formApi;
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const submission = prepareRequestBody(data);
const isNewApplication = !application?.id || [ 'REJECTED', 'APPROVED' ].includes(application.status);
const result = await apiFetch<'token_info_applications', TokenInfoApplication, { message: string }>('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: !isNewApplication ? application.id : undefined },
fetchParams: {
method: isNewApplication ? 'POST' : 'PUT',
body: { submission },
},
});
if ('id' in result) {
onSubmit(result);
} else {
throw result;
}
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: (error as ResourceError<{ message: string }>)?.payload?.message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ apiFetch, application?.id, application?.status, onSubmit, toast ]);
useUpdateEffect(() => {
if (formState.submitCount > 0 && !formState.isValid) {
containerRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ formState.isValid, formState.submitCount ]);
if (configQuery.isError) {
return <DataFetchAlert/>;
}
if (configQuery.isLoading) {
return <ContentLoader/>;
}
const fieldProps = { control, isReadOnly: application?.status === 'IN_PROCESS' };
return (
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<TokenInfoFormStatusText application={ application }/>
<Grid mt={ 8 } gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }} columnGap={ 5 } rowGap={ 5 }>
<TokenInfoFieldTokenName { ...fieldProps }/>
<TokenInfoFieldAddress { ...fieldProps }/>
<TokenInfoFieldRequesterName { ...fieldProps }/>
<TokenInfoFieldRequesterEmail { ...fieldProps }/>
<TokenInfoFormSectionHeader>Project info</TokenInfoFormSectionHeader>
<TokenInfoFieldProjectName { ...fieldProps }/>
<TokenInfoFieldProjectSector { ...fieldProps } config={ configQuery.data.projectSectors }/>
<TokenInfoFieldProjectEmail { ...fieldProps }/>
<TokenInfoFieldProjectWebsite { ...fieldProps }/>
<TokenInfoFieldDocs { ...fieldProps }/>
<TokenInfoFieldSupport { ...fieldProps }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldIconUrl { ...fieldProps } trigger={ trigger }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldProjectDescription { ...fieldProps }/>
</GridItem>
<TokenInfoFormSectionHeader>Links</TokenInfoFormSectionHeader>
<TokenInfoFieldSocialLink { ...fieldProps } name="github"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="twitter"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="telegram"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="opensea"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="linkedin"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="facebook"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="discord"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="medium"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="slack"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="reddit"/>
<TokenInfoFormSectionHeader>Price data</TokenInfoFormSectionHeader>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_market_cap" label="CoinMarketCap URL"/>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_gecko" label="CoinGecko URL"/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_defi_llama" label="DefiLlama URL "/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldComment { ...fieldProps }/>
</GridItem>
</Grid>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ application?.status === 'IN_PROCESS' }
>
Send request
</Button>
</form>
);
};
export default React.memo(TokenInfoForm);
import { GridItem } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const TokenInfoFormSectionHeader = ({ children }: Props) => {
return (
<GridItem colSpan={{ base: 1, lg: 2 }} fontFamily="heading" fontSize="lg" fontWeight={ 500 } mt={ 3 }>
{ children }
</GridItem>
);
};
export default TokenInfoFormSectionHeader;
import { Alert } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
interface Props {
application?: TokenInfoApplication;
}
const TokenInfoFormStatusText = ({ application }: Props) => {
if (!application) {
return null;
}
switch (application.status) {
case 'IN_PROCESS': {
return (
<div>
<div>Requests are sent to a moderator for review and approval. This process can take several days.</div>
<Alert status="warning" mt={ 6 }>Request in progress. Once an admin approves your request you can edit token info.</Alert>
</div>
);
}
case 'UPDATE_REQUIRED': {
return (
<div>
{ application.adminComments && <Alert status="warning" mt={ 6 }>{ application.adminComments }</Alert> }
</div>
);
}
case 'REJECTED': {
return (
<div>
{ application.adminComments && <Alert status="warning" mt={ 6 }>{ application.adminComments }</Alert> }
</div>
);
}
default:
return null;
}
};
export default React.memo(TokenInfoFormStatusText);
import { Center, Image, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
url: string | undefined;
onLoad?: () => void;
onError?: () => void;
isInvalid: boolean;
}
const TokenInfoIconPreview = ({ url, onError, onLoad, isInvalid }: Props) => {
const borderColor = useColorModeValue('gray.100', 'gray.700');
const borderColorFilled = useColorModeValue('gray.300', 'gray.600');
const borderColorError = useColorModeValue('red.400', 'red.300');
const borderColorActive = isInvalid ? borderColorError : borderColorFilled;
return (
<Center
boxSize={{ base: '60px', lg: '80px' }}
flexShrink={ 0 }
borderWidth="2px"
borderColor={ url ? borderColorActive : borderColor }
borderRadius="base"
>
<Image
borderRadius="base"
src={ url }
alt="Token logo preview"
boxSize={{ base: 10, lg: 12 }}
objectFit="cover"
fallback={ url && !isInvalid ? <Skeleton boxSize={{ base: 10, lg: 12 }}/> : <TokenLogoPlaceholder boxSize={{ base: 10, lg: 12 }}/> }
onError={ onError }
onLoad={ onLoad }
/>
</Center>
);
};
export default React.memo(TokenInfoIconPreview);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldAddress = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isDisabled
/>
<InputPlaceholder text="Token contract address"/>
</FormControl>
);
}, []);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldAddress);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldComment = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'comment'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Comment" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="comment"
control={ control }
render={ renderControl }
rules={{ maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldComment);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldDocs = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'docs'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Docs" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="docs"
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldDocs);
import { FormControl, Flex, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, UseFormTrigger } from 'react-hook-form';
import { useController } from 'react-hook-form';
import type { Fields } from '../types';
import { times } from 'lib/html-entities';
import { validator as validateUrl } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import TokenInfoIconPreview from '../TokenInfoIconPreview';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
trigger: UseFormTrigger<Fields>;
}
const TokenInfoFieldIconUrl = ({ control, isReadOnly, trigger }: Props) => {
const validatePreview = React.useCallback(() => {
return imageLoadError.current ? 'Unable to load image' : true;
}, [ ]);
const { field, formState, fieldState } = useController({
name: 'icon_url',
control,
rules: {
required: true,
validate: { url: validateUrl, preview: validatePreview },
},
});
const [ valueForPreview, setValueForPreview ] = React.useState<string>(field.value);
const imageLoadError = React.useRef(false);
const handleImageLoadSuccess = React.useCallback(() => {
imageLoadError.current = false;
trigger('icon_url');
}, [ trigger ]);
const handleImageLoadError = React.useCallback(() => {
imageLoadError.current = true;
trigger('icon_url');
}, [ trigger ]);
const handleBlur = React.useCallback(() => {
field.onBlur();
const isValidUrl = validateUrl(field.value);
isValidUrl === true && setValueForPreview(field.value);
}, [ field ]);
return (
<Flex columnGap={ 5 }>
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
onBlur={ handleBlur }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` } error={ fieldState.error }/>
</FormControl>
<TokenInfoIconPreview
url={ fieldState.error?.type === 'url' ? undefined : valueForPreview }
onLoad={ handleImageLoadSuccess }
onError={ !isReadOnly ? handleImageLoadError : undefined }
isInvalid={ fieldState.error?.type === 'preview' }
/>
</Flex>
);
};
export default React.memo(TokenInfoFieldIconUrl);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields, TickerUrlFields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
name: keyof TickerUrlFields;
label: string;
}
const TokenInfoFieldPriceTicker = ({ control, isReadOnly, name, label }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ label } error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly, label ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldPriceTicker);
import { FormControl, Text, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectDescription = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_description'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Textarea
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Project description" error={ fieldState.error }/>
<Text variant="secondary" fontSize="sm" mt={ 1 }>
Introduce or summarize the project’s operation/goals in a maximum of 300 characters.
The description should be written in a neutral point of view and must exclude unsubstantiated claims unless proven otherwise.
</Text>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_description"
control={ control }
render={ renderControl }
rules={{ required: true, maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldProjectDescription);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Official project email address" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldProjectEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Project name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldProjectName);
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import type { TokenInfoApplicationConfig } from 'types/api/account';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
config: TokenInfoApplicationConfig['projectSectors'];
}
const TokenInfoFieldProjectSector = ({ control, isReadOnly, config }: Props) => {
const isMobile = useIsMobile();
const options = React.useMemo(() => {
return config.map((option) => ({ label: option, value: option }));
}, [ config ]);
const renderControl: ControllerProps<Fields, 'project_sector'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Project industry"
isDisabled={ formState.isSubmitting || isReadOnly }
error={ fieldState.error }
/>
);
}, [ isReadOnly, options, isMobile ]);
return (
<Controller
name="project_sector"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldProjectSector);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectWebsite = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_website'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text="Official project website" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_website"
control={ control }
render={ renderControl }
rules={{ required: true, validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldProjectWebsite);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterName);
import { FormControl, Icon, Input, InputRightElement, InputGroup } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields, SocialLinkFields } from '../types';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Item {
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
label: string;
color: string;
}
const SETTINGS: Record<keyof SocialLinkFields, Item> = {
github: { label: 'GitHub', icon: iconGithub, color: 'inherit' },
telegram: { label: 'Telegram', icon: iconTelegram, color: 'telegram' },
linkedin: { label: 'LinkedIn', icon: iconLinkedIn, color: 'linkedin' },
discord: { label: 'Discord', icon: iconDiscord, color: 'discord' },
slack: { label: 'Slack', icon: iconSlack, color: 'slack' },
twitter: { label: 'Twitter', icon: iconTwitter, color: 'twitter' },
opensea: { label: 'OpenSea', icon: iconOpenSea, color: 'opensea' },
facebook: { label: 'Facebook', icon: iconFacebook, color: 'facebook' },
medium: { label: 'Medium', icon: iconMedium, color: 'inherit' },
reddit: { label: 'Reddit', icon: iconReddit, color: 'reddit' },
};
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
name: keyof SocialLinkFields;
}
const TokenInfoFieldSocialLink = ({ control, isReadOnly, name }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} sx={{ '.chakra-input__group input': { pr: '60px' } }}>
<InputGroup>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ SETTINGS[name].label } error={ fieldState.error }/>
<InputRightElement h="100%">
<Icon as={ SETTINGS[name].icon } boxSize={ 6 } color={ field.value ? SETTINGS[name].color : '#718096' }/>
</InputRightElement>
</InputGroup>
</FormControl>
);
}, [ isReadOnly, name ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldSocialLink);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator as emailValidator } from 'lib/validations/email';
import { validator as urlValidator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldSupport = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'support'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Support URL or email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
const validate = React.useCallback((newValue: string | undefined) => {
const urlValidationResult = urlValidator(newValue);
const emailValidationResult = emailValidator(newValue || '');
if (urlValidationResult === true || emailValidationResult === true) {
return true;
}
return 'Invalid format';
}, []);
return (
<Controller
name="support"
control={ control }
render={ renderControl }
rules={{ validate }}
/>
);
};
export default React.memo(TokenInfoFieldSupport);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldTokenName = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'token_name'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isDisabled
/>
<InputPlaceholder text="Token name"/>
</FormControl>
);
}, []);
return (
<Controller
name="token_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldTokenName);
import type { Option } from 'ui/shared/FancySelect/types';
export interface Fields extends SocialLinkFields, TickerUrlFields {
address: string;
token_name: string;
requester_name: string;
requester_email: string;
project_name?: string;
project_sector: Option | null;
project_email: string;
project_website: string;
project_description: string;
docs?: string;
support?: string;
icon_url: string;
comment?: string;
}
export interface TickerUrlFields {
ticker_coin_gecko?: string;
ticker_coin_market_cap?: string;
ticker_defi_llama?: string;
}
export interface SocialLinkFields {
github?: string;
telegram?: string;
linkedin?: string;
discord?: string;
slack?: string;
twitter?: string;
opensea?: string;
facebook?: string;
medium?: string;
reddit?: string;
}
import type { Fields } from './types';
import type { TokenInfoApplication } from 'types/api/account';
export function getFormDefaultValues(address: string, tokenName: string, application: TokenInfoApplication | undefined): Partial<Fields> {
if (!application) {
return { address, token_name: tokenName };
}
return {
address,
token_name: tokenName,
requester_name: application.requesterName,
requester_email: application.requesterEmail,
project_name: application.projectName,
project_sector: application.projectSector ? { value: application.projectSector, label: application.projectSector } : null,
project_email: application.projectEmail,
project_website: application.projectWebsite,
project_description: application.projectDescription || '',
docs: application.docs || '',
support: application.support || '',
icon_url: application.iconUrl,
ticker_coin_gecko: application.coinGeckoTicker || '',
ticker_coin_market_cap: application.coinMarketCapTicker,
ticker_defi_llama: application.defiLlamaTicker,
github: application.github || '',
telegram: application.telegram || '',
linkedin: application.linkedin || '',
discord: application.discord || '',
slack: application.slack || '',
twitter: application.twitter || '',
opensea: application.openSea || '',
facebook: application.facebook || '',
medium: application.medium || '',
reddit: application.reddit || '',
comment: application.comment || '',
};
}
export function prepareRequestBody(data: Fields): Omit<TokenInfoApplication, 'id' | 'status' | 'updatedAt'> {
return {
coinGeckoTicker: data.ticker_coin_gecko,
coinMarketCapTicker: data.ticker_coin_market_cap,
defiLlamaTicker: data.ticker_defi_llama,
discord: data.discord,
docs: data.docs,
facebook: data.facebook,
github: data.github,
iconUrl: data.icon_url,
linkedin: data.linkedin,
medium: data.medium,
openSea: data.opensea,
projectDescription: data.project_description,
projectEmail: data.project_email,
projectName: data.project_name,
projectSector: data.project_sector?.value,
projectWebsite: data.project_website,
reddit: data.reddit,
requesterEmail: data.requester_email,
requesterName: data.requester_name,
slack: data.slack,
support: data.support,
telegram: data.telegram,
tokenAddress: data.address,
twitter: data.twitter,
comment: data.comment,
};
}
......@@ -35,8 +35,6 @@ const TokenInstanceContent = () => {
const id = router.query.id?.toString();
const tab = router.query.tab?.toString();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
......@@ -64,6 +62,19 @@ const TokenInstanceContent = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [
{
id: 'token_transfers',
......@@ -84,7 +95,7 @@ const TokenInstanceContent = () => {
return <TokenInstanceSkeleton/>;
}
const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 }/>;
const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 } mr={ 2 }/>;
const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = {
hash: hash || '',
......@@ -134,11 +145,10 @@ const TokenInstanceContent = () => {
<>
<TextAd mb={ 6 }/>
<PageTitle
text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to token page"
additionalsLeft={ nftShieldIcon }
additionalsRight={ tokenTag }
title={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLink={ backLink }
beforeTitle={ nftShieldIcon }
contentAfter={ tokenTag }
/>
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
......
......@@ -58,7 +58,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
title="Token"
hint="Token name"
>
<TokenSnippet hash={ data.token.address } name={ data.token.name }/>
<TokenSnippet data={ data.token }/>
</DetailsInfoItem>
{ data.is_unique && data.owner && (
<DetailsInfoItem
......
......@@ -47,7 +47,7 @@ const TokensTableItem = ({
>
<GridItem display="flex">
<Flex overflow="hidden" mr={ 3 } alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
<Tag flexShrink={ 0 } ml={ 3 }>{ type }</Tag>
</Flex>
......
......@@ -53,7 +53,7 @@ const TokensTableItem = ({
</Text>
<Box>
<Flex alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" width="136px" justifyContent="space-between" ml={ 8 } mt={ 2 }>
......
......@@ -2,22 +2,22 @@ import { Flex, Link, Text, Icon, Box } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import nftIcon from 'icons/nft_shield.svg';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
interface Props {
token: TokenInfo;
value: string;
tokenId: string;
hash: string;
name?: string | null;
symbol?: string | null;
}
const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props) => {
const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
const num = value === '1' ? '' : value;
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: hash, id: tokenId } });
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } });
return (
<Flex alignItems="center" columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
......@@ -28,10 +28,10 @@ const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props)
{ tokenId.length > 8 ? <HashStringShorten hash={ tokenId }/> : tokenId }
</Link>
</Box>
{ name ? (
<TokenSnippet symbol={ symbol } hash={ hash } name={ name } w="auto" logoSize={ 5 } columnGap={ 1 }/>
{ token.name ? (
<TokenSnippet data={ token } w="auto" logoSize={ 5 } columnGap={ 1 }/>
) : (
<AddressLink hash={ hash } truncation="constant" type="token"/>
<AddressLink hash={ token.address } truncation="constant" type="token"/>
) }
</Flex>
);
......
......@@ -7,7 +7,6 @@ import type { TxAction, TxActionGeneral } from 'types/api/txAction';
import appConfig from 'configs/app/config';
import uniswapIcon from 'icons/uniswap.svg';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
......@@ -39,6 +38,20 @@ const TxDetailsAction = ({ action }: Props) => {
const amount0 = BigNumber(data.amount0).toFormat();
const amount1 = BigNumber(data.amount1).toFormat();
const [ text0, text1 ] = getActionText(type);
const token0 = {
address: data.symbol0 === 'Ether' ? appConfig.network.currency.address || '' : data.address0,
name: data.symbol0 === 'Ether' ? appConfig.network.currency.symbol || null : data.symbol0,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
const token1 = {
address: data.symbol1 === 'Ether' ? appConfig.network.currency.address || '' : data.address1,
name: data.symbol1 === 'Ether' ? appConfig.network.currency.symbol || null : data.symbol1,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
return (
<Flex flexWrap="wrap" columnGap={ 1 } rowGap={ 2 } alignItems="center">
......@@ -46,8 +59,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount0 }</chakra.span>
<TokenSnippet
name={ data.symbol0 === 'Ether' ? appConfig.network.currency.symbol : data.symbol0 }
hash={ data.symbol0 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
data={ token0 }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......@@ -58,8 +70,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount1 }</chakra.span>
<TokenSnippet
name={ data.symbol1 === 'Ether' ? appConfig.network.currency.symbol : data.symbol1 }
hash={ data.symbol1 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
data={ token1 }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......@@ -76,14 +87,20 @@ const TxDetailsAction = ({ action }: Props) => {
}
case 'mint_nft' : {
const token = {
address: data.address,
name: data.name,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
return (
<div>
<Flex rowGap={ 2 } flexWrap="wrap" alignItems="center" whiteSpace="pre-wrap">
<chakra.span>Mint of </chakra.span>
<TokenSnippet
name={ data.name }
hash={ data.address }
symbol={ trimTokenSymbol(data.symbol) }
data={ token }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......
......@@ -5,7 +5,6 @@ import type { TokenTransfer as TTokenTransfer, Erc20TotalPayload, Erc721TotalPay
import rightArrowIcon from 'icons/arrows/east.svg';
import { space } from 'lib/html-entities';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import CurrencyValue from 'ui/shared/CurrencyValue';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
......@@ -27,9 +26,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
<CurrencyValue value={ total.value } exchangeRate={ data.token.exchange_rate } fontWeight={ 600 } decimals={ total.decimals }/>
</Text>
<TokenSnippet
symbol={ trimTokenSymbol(data.token.symbol) }
hash={ data.token.address }
name={ data.token.name }
data={ data.token }
w="auto"
flexGrow="1"
columnGap={ 1 }
......@@ -43,11 +40,9 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc721TotalPayload;
return (
<NftTokenTransferSnippet
name={ data.token.name }
token={ data.token }
tokenId={ total.token_id }
value="1"
hash={ data.token.address }
symbol={ trimTokenSymbol(data.token.symbol) }
/>
);
}
......@@ -56,12 +51,10 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc1155TotalPayload;
return (
<NftTokenTransferSnippet
name={ data.token.name }
key={ total.token_id }
token={ data.token }
tokenId={ total.token_id }
value={ total.value }
hash={ data.token.address }
symbol={ trimTokenSymbol(data.token.symbol) }
/>
);
}
......
import { Icon, IconButton, Link, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import editIcon from 'icons/edit.svg';
import dayjs from 'lib/date/dayjs';
import AddressSnippet from 'ui/shared/AddressSnippet';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import VerifiedAddressesStatus from './VerifiedAddressesStatus';
import VerifiedAddressesTokenSnippet from './VerifiedAddressesTokenSnippet';
interface Props {
item: VerifiedAddress;
application: TokenInfoApplication | undefined;
onAdd: (address: string) => void;
onEdit: (address: string) => void;
}
const VerifiedAddressesListItem = ({ item, application, onAdd, onEdit }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
const handleEditClick = React.useCallback(() => {
onEdit(item.contractAddress);
}, [ item, onEdit ]);
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label>Address</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value py="3px">
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</ListItemMobileGrid.Value>
{ item.metadata.tokenName && (
<>
<ListItemMobileGrid.Label>Token Info</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value py={ application ? '3px' : '5px' } display="flex" alignItems="center">
{ application ? (
<>
<VerifiedAddressesTokenSnippet application={ application } name={ item.metadata.tokenName }/>
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="simple"
boxSize={ 5 }
borderRadius="none"
flexShrink={ 0 }
onClick={ handleEditClick }
icon={ <Icon as={ editIcon }/> }
/>
</Tooltip>
</>
) : (
<Link onClick={ handleAddClick }>Add details</Link>
) }
</ListItemMobileGrid.Value>
</>
) }
{ item.metadata.tokenName && application && (
<>
<ListItemMobileGrid.Label>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<VerifiedAddressesStatus status={ application.status }/>
</ListItemMobileGrid.Value>
</>
) }
{ item.metadata.tokenName && application && (
<>
<ListItemMobileGrid.Label>Date</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ dayjs(application.updatedAt).format('MMM DD, YYYY') }
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default React.memo(VerifiedAddressesListItem);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
interface Props {
status?: TokenInfoApplication['status'];
}
const VerifiedAddressesStatus = ({ status }: Props) => {
switch (status) {
case 'IN_PROCESS': {
return <chakra.span fontWeight={ 500 }>In progress</chakra.span>;
}
case 'APPROVED': {
return <chakra.span fontWeight={ 500 } color="green.500">Approved</chakra.span>;
}
case 'UPDATE_REQUIRED': {
return <chakra.span fontWeight={ 500 } color="orange.500">Waiting for update</chakra.span>;
}
case 'REJECTED': {
return <chakra.span fontWeight={ 500 } color="red.500">Rejected</chakra.span>;
}
default:
return null;
}
};
export default VerifiedAddressesStatus;
import { Table, Tbody, Th, Thead, Tr } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import VerifiedAddressesTableItem from './VerifiedAddressesTableItem';
interface Props {
data: Array<VerifiedAddress>;
applications: Array<TokenInfoApplication> | undefined;
onItemAdd: (address: string) => void;
onItemEdit: (address: string) => void;
}
const VerifiedAddressesTable = ({ data, applications, onItemEdit, onItemAdd }: Props) => {
return (
<Table variant="simple">
<Thead>
<Tr>
<Th>Address</Th>
<Th w="168px" pr={ 1 }>Token info</Th>
<Th w="36px" pl="0"></Th>
<Th w="160px">Request status</Th>
<Th w="150px">Date</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<VerifiedAddressesTableItem
key={ item.contractAddress }
item={ item }
application={ applications?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) }
onAdd={ onItemAdd }
onEdit={ onItemEdit }
/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(VerifiedAddressesTable);
import { Td, Tr, Link, Tooltip, IconButton, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import editIcon from 'icons/edit.svg';
import dayjs from 'lib/date/dayjs';
import AddressSnippet from 'ui/shared/AddressSnippet';
import VerifiedAddressesStatus from './VerifiedAddressesStatus';
import VerifiedAddressesTokenSnippet from './VerifiedAddressesTokenSnippet';
interface Props {
item: VerifiedAddress;
application: TokenInfoApplication | undefined;
onAdd: (address: string) => void;
onEdit: (address: string) => void;
}
const VerifiedAddressesTableItem = ({ item, application, onAdd, onEdit }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
const handleEditClick = React.useCallback(() => {
onEdit(item.contractAddress);
}, [ item, onEdit ]);
const tokenInfo = (() => {
if (!item.metadata.tokenName) {
return <span>Not a token</span>;
}
if (!application) {
return <Link onClick={ handleAddClick }>Add details</Link>;
}
return <VerifiedAddressesTokenSnippet application={ application } name={ item.metadata.tokenName }/>;
})();
return (
<Tr>
<Td>
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</Td>
<Td fontSize="sm" verticalAlign="middle" pr={ 1 }>
{ tokenInfo }
</Td>
<Td pl="0">
{ item.metadata.tokenName && application ? (
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="simple"
boxSize={ 5 }
borderRadius="none"
flexShrink={ 0 }
onClick={ handleEditClick }
icon={ <Icon as={ editIcon }/> }
/>
</Tooltip>
) : null }
</Td>
<Td fontSize="sm"><VerifiedAddressesStatus status={ item.metadata.tokenName ? application?.status : undefined }/></Td>
<Td fontSize="sm" color="text_secondary">{ item.metadata.tokenName && application ? dayjs(application.updatedAt).format('MMM DD, YYYY') : null }</Td>
</Tr>
);
};
export default React.memo(VerifiedAddressesTableItem);
import { Image, Flex } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
application: TokenInfoApplication;
name: string;
}
const VerifiedAddressesTokenSnippet = ({ application, name }: Props) => {
return (
<Flex alignItems="center" columnGap={ 2 } w="100%">
<Image
borderRadius="base"
boxSize={ 6 }
objectFit="cover"
src={ application.iconUrl }
alt="Token logo"
fallback={ <TokenLogoPlaceholder boxSize={ 6 }/> }
/>
<AddressLink
hash={ application.tokenAddress }
alias={ name }
type="token"
isDisabled={ application.status === 'IN_PROCESS' }
fontWeight={ 500 }
/>
</Flex>
);
};
export default React.memo(VerifiedAddressesTokenSnippet);
......@@ -14,14 +14,19 @@ import TokenLogo from 'ui/shared/TokenLogo';
const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const infoItemsPaddingLeft = { base: 1, lg: 8 };
const nativeTokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return (
<VStack spacing={ 2 } align="stretch" fontWeight={ 500 }>
<AddressSnippet address={ item.address }/>
<Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }>
{ appConfig.network.currency.address && (
<TokenLogo
hash={ appConfig.network.currency.address }
name={ appConfig.network.name }
data={ nativeTokenData }
boxSize={ 4 }
borderRadius="sm"
mr={ 2 }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment