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 ...@@ -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_FOOTER_STAKING_LINK=__PLACEHOLDER_FOR_NEXT_PUBLIC_FOOTER_STAKING_LINK__
NEXT_PUBLIC_FEATURED_NETWORKS=__PLACEHOLDER_FOR_NEXT_PUBLIC_FEATURED_NETWORKS__ NEXT_PUBLIC_FEATURED_NETWORKS=__PLACEHOLDER_FOR_NEXT_PUBLIC_FEATURED_NETWORKS__
NEXT_PUBLIC_NETWORK_EXPLORERS=__PLACEHOLDER_FOR_NEXT_PUBLIC_NETWORK_EXPLORERS__ 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_CONFIG_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_MARKETPLACE_CONFIG_URL__
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=__PLACEHOLDER_FOR_NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM__ 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_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_LOGOUT_URL__
NEXT_PUBLIC_LOGOUT_RETURN_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_LOGOUT_RETURN_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_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_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_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_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__ 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__ ...@@ -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_API_WEBSOCKET_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__ 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_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__ NEXT_PUBLIC_API_SPEC_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_SPEC_URL__
# external services config # external services config
......
name: Checks name: Checks
on: on:
workflow_dispatch:
pull_request: pull_request:
push: push:
branches: branches:
...@@ -9,6 +10,7 @@ jobs: ...@@ -9,6 +10,7 @@ jobs:
lint: lint:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
...@@ -30,6 +32,7 @@ jobs: ...@@ -30,6 +32,7 @@ jobs:
type_check: type_check:
name: TypeScript name: TypeScript
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v3
......
{
"typescript.tsdk": "node_modules/typescript/lib"
}
\ No newline at end of file
/* eslint-disable no-restricted-properties */ /* eslint-disable no-restricted-properties */
import type { NavItemExternal } from 'types/client/navigation-items';
import type { WalletType } from 'types/client/wallets'; import type { WalletType } from 'types/client/wallets';
import type { NetworkExplorer } from 'types/networks'; import type { NetworkExplorer } from 'types/networks';
import type { ChainIndicatorId } from 'ui/home/indicators/types'; import type { ChainIndicatorId } from 'ui/home/indicators/types';
...@@ -11,6 +12,7 @@ const parseEnvJson = <DataType>(env: string | undefined): DataType | null => { ...@@ -11,6 +12,7 @@ const parseEnvJson = <DataType>(env: string | undefined): DataType | null => {
return null; return null;
} }
}; };
const stripTrailingSlash = (str: string) => str[str.length - 1] === '/' ? str.slice(0, -1) : str; const stripTrailingSlash = (str: string) => str[str.length - 1] === '/' ? str.slice(0, -1) : str;
const getWeb3DefaultWallet = (): WalletType => { const getWeb3DefaultWallet = (): WalletType => {
const envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET); const envValue = getEnvValue(process.env.NEXT_PUBLIC_WEB3_DEFAULT_WALLET);
...@@ -100,6 +102,7 @@ const config = Object.freeze({ ...@@ -100,6 +102,7 @@ const config = Object.freeze({
telegram: getEnvValue(process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK), telegram: getEnvValue(process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK),
staking: getEnvValue(process.env.NEXT_PUBLIC_FOOTER_STAKING_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), featuredNetworks: getEnvValue(process.env.NEXT_PUBLIC_FEATURED_NETWORKS),
blockScoutVersion: getEnvValue(process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION), blockScoutVersion: getEnvValue(process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION),
isAccountSupported: getEnvValue(process.env.NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED) === 'true', isAccountSupported: getEnvValue(process.env.NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED) === 'true',
...@@ -141,11 +144,19 @@ const config = Object.freeze({ ...@@ -141,11 +144,19 @@ const config = Object.freeze({
endpoint: getEnvValue(process.env.NEXT_PUBLIC_VISUALIZE_API_HOST), endpoint: getEnvValue(process.env.NEXT_PUBLIC_VISUALIZE_API_HOST),
basePath: '', 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: { homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [], charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
plate: { plate: {
gradient: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT) || 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%)', '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', textColor: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR) || 'white',
}, },
showGasTracker: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER) === 'false' ? false : true, 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 ...@@ -13,5 +13,7 @@ NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout
# api config # api config
NEXT_PUBLIC_API_HOST=blockscout.com NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com NEXT_PUBLIC_STATS_API_HOST=https://stats-test.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.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 # ui config
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json 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 NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
# network config # network config
......
# ui config # ui config
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/base-goerli.json NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS= 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_GRAPHIQL_TRANSACTION=0x4a0ed8ddf751a7cb5297f827699117b0f6d21a0b2907594d300dc9fed75c7e62
NEXT_PUBLIC_WEB3_DEFAULT_WALLET=coinbase NEXT_PUBLIC_WEB3_DEFAULT_WALLET=coinbase
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=true NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=true
......
...@@ -13,7 +13,7 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout ...@@ -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_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_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_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 NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
# network config # network config
......
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking 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_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_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_cup'] NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
#NEXT_PUBLIC_HOMEPAGE_PLATE_GRADIENT=linear-gradient(136.9deg, \#235643 1.5%, \#16191E 77.77%) #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_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_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 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 ...@@ -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_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking 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_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_cup'] NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND=
NEXT_PUBLIC_FEATURED_NETWORKS= NEXT_PUBLIC_FEATURED_NETWORKS=
NEXT_PUBLIC_NETWORK_LOGO= NEXT_PUBLIC_NETWORK_LOGO=
NEXT_PUBLIC_NETWORK_LOGO_DARK= NEXT_PUBLIC_NETWORK_LOGO_DARK=
...@@ -41,5 +42,7 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://localhost:3000/marketplace-submit-fo ...@@ -41,5 +42,7 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://localhost:3000/marketplace-submit-fo
# api config # api config
NEXT_PUBLIC_API_HOST=https://localhost:3003 NEXT_PUBLIC_API_HOST=https://localhost:3003
NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004 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_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_API_BASE_PATH=/ 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: ...@@ -5,7 +5,7 @@ blockscout:
app: blockscout app: blockscout
enabled: true enabled: true
image: 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: replicas:
app: 1 app: 1
# init container # init container
...@@ -385,7 +385,7 @@ frontend: ...@@ -385,7 +385,7 @@ frontend:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json _default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS: NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: '' _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%)" _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: NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: '' _default: ''
...@@ -398,9 +398,13 @@ frontend: ...@@ -398,9 +398,13 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS: NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs']" _default: "['daily_txs']"
NEXT_PUBLIC_IS_TESTNET: NEXT_PUBLIC_IS_TESTNET:
_default: "true" _default: true
NEXT_PUBLIC_VISUALIZE_API_HOST: NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com _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: NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true" _default: "true"
NEXT_PUBLIC_L1_BASE_URL: NEXT_PUBLIC_L1_BASE_URL:
......
...@@ -299,12 +299,12 @@ frontend: ...@@ -299,12 +299,12 @@ frontend:
NEXT_PUBLIC_FEATURED_NETWORKS: NEXT_PUBLIC_FEATURED_NETWORKS:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-goerli.json _default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS: 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 # network config
NEXT_PUBLIC_NETWORK_NAME: NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum _default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME: NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli _default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME: NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum _default: ethereum
NEXT_PUBLIC_NETWORK_LOGO: NEXT_PUBLIC_NETWORK_LOGO:
...@@ -343,9 +343,13 @@ frontend: ...@@ -343,9 +343,13 @@ frontend:
NEXT_PUBLIC_API_HOST: NEXT_PUBLIC_API_HOST:
_default: blockscout.com/eth/goerli _default: blockscout.com/eth/goerli
NEXT_PUBLIC_STATS_API_HOST: 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: NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com _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: NEXT_PUBLIC_APP_HOST:
_default: blockscout.com/eth/goerli _default: blockscout.com/eth/goerli
NEXT_PUBLIC_LOGOUT_URL: NEXT_PUBLIC_LOGOUT_URL:
...@@ -355,7 +359,7 @@ frontend: ...@@ -355,7 +359,7 @@ frontend:
NEXT_PUBLIC_NETWORK_RPC_URL: NEXT_PUBLIC_NETWORK_RPC_URL:
_default: https://rpc.ankr.com/eth_goerli _default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_HOMEPAGE_CHARTS: NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']" _default: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_API_SPEC_URL: NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml _default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: NEXT_PUBLIC_MARKETPLACE_CONFIG_URL:
......
...@@ -113,7 +113,7 @@ frontend: ...@@ -113,7 +113,7 @@ frontend:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json _default: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS: NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: '' _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%)" _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: NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: '' _default: ''
...@@ -126,9 +126,13 @@ frontend: ...@@ -126,9 +126,13 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS: NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs']" _default: "['daily_txs']"
NEXT_PUBLIC_IS_TESTNET: NEXT_PUBLIC_IS_TESTNET:
_default: "true" _default: true
NEXT_PUBLIC_VISUALIZE_API_HOST: NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com _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: NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true" _default: "true"
NEXT_PUBLIC_L1_BASE_URL: NEXT_PUBLIC_L1_BASE_URL:
......
...@@ -39,6 +39,7 @@ frontend: ...@@ -39,6 +39,7 @@ frontend:
- "/csv-export" - "/csv-export"
- "/verified-contracts" - "/verified-contracts"
- "/graphiql" - "/graphiql"
- "/login"
resources: resources:
limits: limits:
...@@ -67,9 +68,9 @@ frontend: ...@@ -67,9 +68,9 @@ frontend:
NEXT_PUBLIC_FOOTER_STAKING_LINK: NEXT_PUBLIC_FOOTER_STAKING_LINK:
_default: https://duneanalytics.com/maxaleks/xdai-staking _default: https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_NAME: NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum _default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME: NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli _default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME: NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum _default: ethereum
NEXT_PUBLIC_NETWORK_LOGO: NEXT_PUBLIC_NETWORK_LOGO:
...@@ -97,6 +98,10 @@ frontend: ...@@ -97,6 +98,10 @@ frontend:
_default: https://stats-test.aws-k8s.blockscout.com/ _default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com _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: NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com _default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH: NEXT_PUBLIC_API_BASE_PATH:
...@@ -109,13 +114,13 @@ frontend: ...@@ -109,13 +114,13 @@ frontend:
_default: https://blockscout.com/auth/logout _default: https://blockscout.com/auth/logout
review: blockscout-main.test.aws-k8s.blockscout.com/auth/logout review: blockscout-main.test.aws-k8s.blockscout.com/auth/logout
NEXT_PUBLIC_HOMEPAGE_CHARTS: NEXT_PUBLIC_HOMEPAGE_CHARTS:
_default: "['daily_txs','coin_price','market_cup']" _default: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_APP_HOST: NEXT_PUBLIC_APP_HOST:
_default: blockscout.com _default: blockscout.com
NEXT_PUBLIC_NETWORK_RPC_URL: NEXT_PUBLIC_NETWORK_RPC_URL:
_default: https://rpc.ankr.com/eth_goerli _default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS: 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: NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml _default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: NEXT_PUBLIC_MARKETPLACE_CONFIG_URL:
......
...@@ -29,6 +29,7 @@ The app instance could be customized by passing following variables to NodeJS en ...@@ -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 | | 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_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_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_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` | | 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 ...@@ -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_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_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_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_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_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_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` | | 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 ...@@ -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` | | title | `string` | Displayed name of the explorer | yes | - | `Anyblock` |
| baseUrl | `string` | Base url of the explorer | yes | - | `https://explorer.anyblock.tools` | | 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>` *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 ...@@ -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_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_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_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 ## 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"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#link_svg__a)"> <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"/>
<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> </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 { import type {
Address, Address,
AddressCounters, AddressCounters,
...@@ -36,6 +47,7 @@ import type { ...@@ -36,6 +47,7 @@ import type {
TokenInventoryResponse, TokenInventoryResponse,
TokenInstance, TokenInstance,
TokenInstanceTransfersCount, TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token'; } from 'types/api/token';
import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens'; import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer'; import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
...@@ -63,6 +75,9 @@ export const RESOURCES = { ...@@ -63,6 +75,9 @@ export const RESOURCES = {
user_info: { user_info: {
path: '/api/account/v1/user/info', path: '/api/account/v1/user/info',
}, },
email_resend: {
path: '/api/account/v1/email/resend',
},
custom_abi: { custom_abi: {
path: '/api/account/v1/user/custom_abis/:id?', path: '/api/account/v1/user/custom_abis/:id?',
pathParams: [ 'id' as const ], pathParams: [ 'id' as const ],
...@@ -88,6 +103,35 @@ export const RESOURCES = { ...@@ -88,6 +103,35 @@ export const RESOURCES = {
pathParams: [ 'id' as const ], 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
stats_counters: { stats_counters: {
path: '/api/v1/counters', path: '/api/v1/counters',
...@@ -309,6 +353,12 @@ export const RESOURCES = { ...@@ -309,6 +353,12 @@ export const RESOURCES = {
path: '/api/v2/tokens/:hash', path: '/api/v2/tokens/:hash',
pathParams: [ 'hash' as const ], 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: { token_counters: {
path: '/api/v2/tokens/:hash/counters', path: '/api/v2/tokens/:hash/counters',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
...@@ -524,6 +574,9 @@ Q extends 'private_tags_address' ? AddressTags : ...@@ -524,6 +574,9 @@ Q extends 'private_tags_address' ? AddressTags :
Q extends 'private_tags_tx' ? TransactionTags : Q extends 'private_tags_tx' ? TransactionTags :
Q extends 'api_keys' ? ApiKeys : Q extends 'api_keys' ? ApiKeys :
Q extends 'watchlist' ? Array<WatchlistAddress> : 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_stats' ? HomeStats :
Q extends 'homepage_chart_txs' ? ChartTransactionResponse : Q extends 'homepage_chart_txs' ? ChartTransactionResponse :
Q extends 'homepage_chart_market' ? ChartMarketResponse : Q extends 'homepage_chart_market' ? ChartMarketResponse :
...@@ -561,6 +614,7 @@ Q extends 'address_logs' ? LogsResponseAddress : ...@@ -561,6 +614,7 @@ Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse : Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'address_withdrawals' ? AddressWithdrawalsResponse : Q extends 'address_withdrawals' ? AddressWithdrawalsResponse :
Q extends 'token' ? TokenInfo : Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo :
Q extends 'token_counters' ? TokenCounters : Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse : Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders : Q extends 'token_holders' ? TokenHolders :
......
import React from 'react'; 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 type { Params as FetchParams } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
...@@ -27,7 +27,7 @@ export default function useApiFetch() { ...@@ -27,7 +27,7 @@ export default function useApiFetch() {
url, url,
{ {
credentials: 'include', credentials: 'include',
...(resource.endpoint && appConfig.host === 'localhost' ? { ...(resource.endpoint && isNeedProxy() ? {
headers: { headers: {
'x-endpoint': resource.endpoint, 'x-endpoint': resource.endpoint,
}, },
......
...@@ -33,6 +33,8 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -33,6 +33,8 @@ export function app(): CspDev.DirectiveDescriptor {
appConfig.api.socket, appConfig.api.socket,
appConfig.statsApi.endpoint, appConfig.statsApi.endpoint,
appConfig.visualizeApi.endpoint, appConfig.visualizeApi.endpoint,
appConfig.contractInfoApi.endpoint,
appConfig.adminServiceApi.endpoint,
// chain RPC server // chain RPC server
appConfig.network.rpcUrl, appConfig.network.rpcUrl,
......
import getErrorCause from './getErrorCause'; 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); const cause = getErrorCause(error);
return cause && 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined; 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 { useRouter } from 'next/router';
import type { Route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
import type { NavItemInternal, NavItem, NavGroupItem } from 'types/client/navigation-items';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import abiIcon from 'icons/ABI.svg'; import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg'; import apiKeysIcon from 'icons/API.svg';
...@@ -27,28 +28,6 @@ import verifiedIcon from 'icons/verified.svg'; ...@@ -27,28 +28,6 @@ import verifiedIcon from 'icons/verified.svg';
import watchlistIcon from 'icons/watchlist.svg'; import watchlistIcon from 'icons/watchlist.svg';
import { rightLineArrow } from 'lib/html-entities'; 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 { interface ReturnType {
mainNavItems: Array<NavItem | NavGroupItem>; mainNavItems: Array<NavItem | NavGroupItem>;
accountNavItems: Array<NavItem>; accountNavItems: Array<NavItem>;
...@@ -78,41 +57,38 @@ export default function useNavItems(): ReturnType { ...@@ -78,41 +57,38 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/accounts' as const }, nextRoute: { pathname: '/accounts' as const },
icon: topAccountsIcon, icon: topAccountsIcon,
isActive: pathname === '/accounts', isActive: pathname === '/accounts',
isNewUi: true,
}; };
const blocks = { const blocks = {
text: 'Blocks', text: 'Blocks',
nextRoute: { pathname: '/blocks' as const }, nextRoute: { pathname: '/blocks' as const },
icon: blocksIcon, icon: blocksIcon,
isActive: pathname === '/blocks' || pathname === '/block/[height]', isActive: pathname === '/blocks' || pathname === '/block/[height]',
isNewUi: true,
}; };
const txs = { const txs = {
text: 'Transactions', text: 'Transactions',
nextRoute: { pathname: '/txs' as const }, nextRoute: { pathname: '/txs' as const },
icon: transactionsIcon, icon: transactionsIcon,
isActive: pathname === '/txs' || pathname === '/tx/[hash]', isActive: pathname === '/txs' || pathname === '/tx/[hash]',
isNewUi: true,
}; };
const verifiedContracts = const verifiedContracts =
// eslint-disable-next-line max-len // 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) { if (appConfig.L2.isL2Network) {
blockchainNavItems = [ blockchainNavItems = [
[ [
txs, txs,
// eslint-disable-next-line max-len // 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 // 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, blocks,
// eslint-disable-next-line max-len // 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 // 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, topAccounts,
...@@ -130,25 +106,22 @@ export default function useNavItems(): ReturnType { ...@@ -130,25 +106,22 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/withdrawals' as const }, nextRoute: { pathname: '/withdrawals' as const },
icon: withdrawalsIcon, icon: withdrawalsIcon,
isActive: pathname === '/withdrawals', isActive: pathname === '/withdrawals',
isNewUi: true,
}, },
].filter(Boolean); ].filter(Boolean);
} }
const otherNavItems: Array<NavItem> = [ const apiNavItems: Array<NavItem> = [
hasAPIDocs ? { hasAPIDocs ? {
text: 'REST API', text: 'REST API',
nextRoute: { pathname: '/api-docs' as const }, nextRoute: { pathname: '/api-docs' as const },
icon: apiDocsIcon, icon: apiDocsIcon,
isActive: pathname === '/api-docs', isActive: pathname === '/api-docs',
isNewUi: true,
} : null, } : null,
{ {
text: 'GraphQL', text: 'GraphQL',
nextRoute: { pathname: '/graphiql' as const }, nextRoute: { pathname: '/graphiql' as const },
icon: graphQLIcon, icon: graphQLIcon,
isActive: pathname === '/graphiql', isActive: pathname === '/graphiql',
isNewUi: true,
}, },
{ {
text: 'RPC API', text: 'RPC API',
...@@ -167,7 +140,6 @@ export default function useNavItems(): ReturnType { ...@@ -167,7 +140,6 @@ export default function useNavItems(): ReturnType {
text: 'Blockchain', text: 'Blockchain',
icon: globeIcon, icon: globeIcon,
isActive: blockchainNavItems.flat().some(item => isInternalItem(item) && item.isActive), isActive: blockchainNavItems.flat().some(item => isInternalItem(item) && item.isActive),
isNewUi: true,
subItems: blockchainNavItems, subItems: blockchainNavItems,
}, },
{ {
...@@ -175,24 +147,24 @@ export default function useNavItems(): ReturnType { ...@@ -175,24 +147,24 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/tokens' as const }, nextRoute: { pathname: '/tokens' as const },
icon: tokensIcon, icon: tokensIcon,
isActive: pathname.startsWith('/token'), isActive: pathname.startsWith('/token'),
isNewUi: true,
}, },
isMarketplaceAvailable ? { isMarketplaceAvailable ? {
text: 'Apps', text: 'Apps',
nextRoute: { pathname: '/apps' as const }, nextRoute: { pathname: '/apps' as const },
icon: appsIcon, icon: appsIcon,
isActive: pathname.startsWith('/app'), isActive: pathname.startsWith('/app'),
isNewUi: true,
} : null, } : null,
{ text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats', isNewUi: true }, { text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats' },
// 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/ text: 'API',
// at this stage custom menu items is under development, we will implement it later icon: apiDocsIcon,
otherNavItems.length > 0 ? { isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive),
subItems: apiNavItems,
},
appConfig.otherLinks.length > 0 ? {
text: 'Other', text: 'Other',
icon: gearIcon, icon: gearIcon,
isActive: otherNavItems.some(item => isInternalItem(item) && item.isActive), subItems: appConfig.otherLinks,
subItems: otherNavItems,
} : null, } : null,
].filter(Boolean) as Array<NavItem | NavGroupItem>; ].filter(Boolean) as Array<NavItem | NavGroupItem>;
...@@ -202,42 +174,42 @@ export default function useNavItems(): ReturnType { ...@@ -202,42 +174,42 @@ export default function useNavItems(): ReturnType {
nextRoute: { pathname: '/account/watchlist' as const }, nextRoute: { pathname: '/account/watchlist' as const },
icon: watchlistIcon, icon: watchlistIcon,
isActive: pathname === '/account/watchlist', isActive: pathname === '/account/watchlist',
isNewUi: true,
}, },
{ {
text: 'Private tags', text: 'Private tags',
nextRoute: { pathname: '/account/tag_address' as const }, nextRoute: { pathname: '/account/tag_address' as const },
icon: privateTagIcon, icon: privateTagIcon,
isActive: pathname === '/account/tag_address', isActive: pathname === '/account/tag_address',
isNewUi: true,
}, },
{ {
text: 'Public tags', text: 'Public tags',
nextRoute: { pathname: '/account/public_tags_request' as const }, nextRoute: { pathname: '/account/public_tags_request' as const },
icon: publicTagIcon, isActive: pathname === '/account/public_tags_request', icon: publicTagIcon, isActive: pathname === '/account/public_tags_request',
isNewUi: true,
}, },
{ {
text: 'API keys', text: 'API keys',
nextRoute: { pathname: '/account/api_key' as const }, nextRoute: { pathname: '/account/api_key' as const },
icon: apiKeysIcon, isActive: pathname === '/account/api_key', icon: apiKeysIcon, isActive: pathname === '/account/api_key',
isNewUi: true,
}, },
{ {
text: 'Custom ABI', text: 'Custom ABI',
nextRoute: { pathname: '/account/custom_abi' as const }, nextRoute: { pathname: '/account/custom_abi' as const },
icon: abiIcon, icon: abiIcon,
isActive: pathname === '/account/custom_abi', 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 = { const profileItem = {
text: 'My profile', text: 'My profile',
nextRoute: { pathname: '/auth/profile' as const }, nextRoute: { pathname: '/auth/profile' as const },
icon: profileIcon, icon: profileIcon,
isActive: pathname === '/auth/profile', isActive: pathname === '/auth/profile',
isNewUi: true,
}; };
return { mainNavItems, accountNavItems, profileItem }; 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 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 type { AddressTokenBalance } from 'types/api/address';
import * as tokens from 'mocks/tokens/tokenInfo'; import * as tokens from 'mocks/tokens/tokenInfo';
import * as tokenInstance from 'mocks/tokens/tokenInstance';
export const erc20a: AddressTokenBalance = { export const erc20a: AddressTokenBalance = {
token: tokens.tokenInfoERC20a, token: tokens.tokenInfoERC20a,
...@@ -59,24 +60,28 @@ export const erc721LongSymbol: AddressTokenBalance = { ...@@ -59,24 +60,28 @@ export const erc721LongSymbol: AddressTokenBalance = {
export const erc1155a: AddressTokenBalance = { export const erc1155a: AddressTokenBalance = {
token: tokens.tokenInfoERC1155a, token: tokens.tokenInfoERC1155a,
token_id: '42', token_id: '42',
token_instance: tokenInstance.base,
value: '24', value: '24',
}; };
export const erc1155b: AddressTokenBalance = { export const erc1155b: AddressTokenBalance = {
token: tokens.tokenInfoERC1155b, token: tokens.tokenInfoERC1155b,
token_id: '100010000000001', token_id: '100010000000001',
token_instance: tokenInstance.base,
value: '11', value: '11',
}; };
export const erc1155withoutName: AddressTokenBalance = { export const erc1155withoutName: AddressTokenBalance = {
token: tokens.tokenInfoERC1155WithoutName, token: tokens.tokenInfoERC1155WithoutName,
token_id: '64532245', token_id: '64532245',
token_instance: tokenInstance.base,
value: '42', value: '42',
}; };
export const erc1155LongId: AddressTokenBalance = { export const erc1155LongId: AddressTokenBalance = {
token: tokens.tokenInfoERC1155b, token: tokens.tokenInfoERC1155b,
token_id: '483200961027732618117991942553110860267520', token_id: '483200961027732618117991942553110860267520',
token_instance: tokenInstance.base,
value: '42', value: '42',
}; };
......
...@@ -9,6 +9,7 @@ export const tokenInfo: TokenInfo = { ...@@ -9,6 +9,7 @@ export const tokenInfo: TokenInfo = {
symbol: 'ARIA', symbol: 'ARIA',
type: 'ERC-20', type: 'ERC-20',
total_supply: '1235', total_supply: '1235',
icon_url: null,
}; };
export const tokenCounters: TokenCounters = { export const tokenCounters: TokenCounters = {
...@@ -25,6 +26,7 @@ export const tokenInfoERC20a: TokenInfo = { ...@@ -25,6 +26,7 @@ export const tokenInfoERC20a: TokenInfo = {
symbol: 'HyFi', symbol: 'HyFi',
total_supply: '369000000000000000000000000', total_supply: '369000000000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const tokenInfoERC20b: TokenInfo = { export const tokenInfoERC20b: TokenInfo = {
...@@ -36,6 +38,7 @@ export const tokenInfoERC20b: TokenInfo = { ...@@ -36,6 +38,7 @@ export const tokenInfoERC20b: TokenInfo = {
symbol: 'USDC', symbol: 'USDC',
total_supply: '900000000000000000000000000', total_supply: '900000000000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const tokenInfoERC20c: TokenInfo = { export const tokenInfoERC20c: TokenInfo = {
...@@ -47,6 +50,7 @@ export const tokenInfoERC20c: TokenInfo = { ...@@ -47,6 +50,7 @@ export const tokenInfoERC20c: TokenInfo = {
symbol: 'ETH', symbol: 'ETH',
total_supply: '1000000000000000000000000', total_supply: '1000000000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const tokenInfoERC20d: TokenInfo = { export const tokenInfoERC20d: TokenInfo = {
...@@ -58,6 +62,7 @@ export const tokenInfoERC20d: TokenInfo = { ...@@ -58,6 +62,7 @@ export const tokenInfoERC20d: TokenInfo = {
symbol: 'ZETA', symbol: 'ZETA',
total_supply: '2100000000000000000000000000', total_supply: '2100000000000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const tokenInfoERC20LongSymbol: TokenInfo = { export const tokenInfoERC20LongSymbol: TokenInfo = {
...@@ -69,6 +74,7 @@ export const tokenInfoERC20LongSymbol: TokenInfo = { ...@@ -69,6 +74,7 @@ export const tokenInfoERC20LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: '2100000000000000000000000000', total_supply: '2100000000000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const tokenInfoERC721a: TokenInfo = { export const tokenInfoERC721a: TokenInfo = {
...@@ -80,6 +86,7 @@ export const tokenInfoERC721a: TokenInfo = { ...@@ -80,6 +86,7 @@ export const tokenInfoERC721a: TokenInfo = {
symbol: 'HYFI_ATHENA', symbol: 'HYFI_ATHENA',
total_supply: '105', total_supply: '105',
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}; };
export const tokenInfoERC721b: TokenInfo = { export const tokenInfoERC721b: TokenInfo = {
...@@ -91,6 +98,7 @@ export const tokenInfoERC721b: TokenInfo = { ...@@ -91,6 +98,7 @@ export const tokenInfoERC721b: TokenInfo = {
symbol: 'WOWG', symbol: 'WOWG',
total_supply: null, total_supply: null,
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}; };
export const tokenInfoERC721c: TokenInfo = { export const tokenInfoERC721c: TokenInfo = {
...@@ -102,6 +110,7 @@ export const tokenInfoERC721c: TokenInfo = { ...@@ -102,6 +110,7 @@ export const tokenInfoERC721c: TokenInfo = {
symbol: 'PUMA', symbol: 'PUMA',
total_supply: null, total_supply: null,
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}; };
export const tokenInfoERC721LongSymbol: TokenInfo = { export const tokenInfoERC721LongSymbol: TokenInfo = {
...@@ -113,6 +122,7 @@ export const tokenInfoERC721LongSymbol: TokenInfo = { ...@@ -113,6 +122,7 @@ export const tokenInfoERC721LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY', symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: null, total_supply: null,
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}; };
export const tokenInfoERC1155a: TokenInfo = { export const tokenInfoERC1155a: TokenInfo = {
...@@ -124,6 +134,7 @@ export const tokenInfoERC1155a: TokenInfo = { ...@@ -124,6 +134,7 @@ export const tokenInfoERC1155a: TokenInfo = {
symbol: 'HYFI_MEMBERSHIP', symbol: 'HYFI_MEMBERSHIP',
total_supply: '482', total_supply: '482',
type: 'ERC-1155', type: 'ERC-1155',
icon_url: null,
}; };
export const tokenInfoERC1155b: TokenInfo = { export const tokenInfoERC1155b: TokenInfo = {
...@@ -135,6 +146,7 @@ export const tokenInfoERC1155b: TokenInfo = { ...@@ -135,6 +146,7 @@ export const tokenInfoERC1155b: TokenInfo = {
symbol: 'WVC', symbol: 'WVC',
total_supply: '4943', total_supply: '4943',
type: 'ERC-1155', type: 'ERC-1155',
icon_url: null,
}; };
export const tokenInfoERC1155WithoutName: TokenInfo = { export const tokenInfoERC1155WithoutName: TokenInfo = {
...@@ -146,4 +158,5 @@ export const tokenInfoERC1155WithoutName: TokenInfo = { ...@@ -146,4 +158,5 @@ export const tokenInfoERC1155WithoutName: TokenInfo = {
symbol: null, symbol: null,
total_supply: '482', total_supply: '482',
type: 'ERC-1155', type: 'ERC-1155',
icon_url: null,
}; };
...@@ -30,6 +30,7 @@ export const erc20: TokenTransfer = { ...@@ -30,6 +30,7 @@ export const erc20: TokenTransfer = {
symbol: 'ARIA', symbol: 'ARIA',
type: 'ERC-20', type: 'ERC-20',
total_supply: '0', total_supply: '0',
icon_url: null,
}, },
total: { total: {
decimals: '18', decimals: '18',
...@@ -73,6 +74,7 @@ export const erc721: TokenTransfer = { ...@@ -73,6 +74,7 @@ export const erc721: TokenTransfer = {
symbol: 'AriaSA', symbol: 'AriaSA',
type: 'ERC-721', type: 'ERC-721',
total_supply: '0', total_supply: '0',
icon_url: null,
}, },
total: { total: {
token_id: '875879856', token_id: '875879856',
...@@ -115,6 +117,7 @@ export const erc1155A: TokenTransfer = { ...@@ -115,6 +117,7 @@ export const erc1155A: TokenTransfer = {
symbol: 'MY_SYMBOL_IS_VERY_LONG', symbol: 'MY_SYMBOL_IS_VERY_LONG',
type: 'ERC-1155', type: 'ERC-1155',
total_supply: '0', total_supply: '0',
icon_url: null,
}, },
total: { total: {
token_id: '123', token_id: '123',
......
...@@ -31,6 +31,7 @@ export const mintToken: TxStateChange = { ...@@ -31,6 +31,7 @@ export const mintToken: TxStateChange = {
symbol: 'nMOONBIRD', symbol: 'nMOONBIRD',
total_supply: '10645', total_supply: '10645',
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}, },
type: 'token', type: 'token',
}; };
...@@ -66,6 +67,7 @@ export const receiveMintedToken: TxStateChange = { ...@@ -66,6 +67,7 @@ export const receiveMintedToken: TxStateChange = {
symbol: 'nMOONBIRD', symbol: 'nMOONBIRD',
total_supply: '10645', total_supply: '10645',
type: 'ERC-721', type: 'ERC-721',
icon_url: null,
}, },
type: 'token', type: 'token',
}; };
......
...@@ -9,7 +9,7 @@ import type { ResourceError } from 'lib/api/resources'; ...@@ -9,7 +9,7 @@ import type { ResourceError } from 'lib/api/resources';
import { AppContextProvider } from 'lib/appContext'; import { AppContextProvider } from 'lib/appContext';
import { Chakra } from 'lib/Chakra'; import { Chakra } from 'lib/Chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; 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 useConfigSentry from 'lib/hooks/useConfigSentry';
import { SocketProvider } from 'lib/socket/context'; import { SocketProvider } from 'lib/socket/context';
import theme from 'theme'; import theme from 'theme';
...@@ -29,7 +29,7 @@ function MyApp({ Component, pageProps }: AppProps) { ...@@ -29,7 +29,7 @@ function MyApp({ Component, pageProps }: AppProps) {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: (failureCount, _error) => { retry: (failureCount, _error) => {
const error = _error as ResourceError<{ status: number }>; 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) { if (status && status >= 400 && status < 500) {
// don't do retry for client error responses // don't do retry for client error responses
return false; return false;
...@@ -42,7 +42,7 @@ function MyApp({ Component, pageProps }: AppProps) { ...@@ -42,7 +42,7 @@ function MyApp({ Component, pageProps }: AppProps) {
})); }));
const renderErrorScreen = React.useCallback((error?: Error) => { const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorStatusCode(error); const statusCode = getErrorCauseStatusCode(error);
return ( return (
<AppError <AppError
......
...@@ -4,17 +4,20 @@ import React from 'react'; ...@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import ApiKeys from 'ui/pages/ApiKeys'; import ApiKeys from 'ui/pages/ApiKeys';
import Page from 'ui/shared/Page/Page';
const ApiKeysPage: NextPage = () => { const ApiKeysPage: NextPage = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
return ( return (
<> <>
<Head><title>{ title }</title></Head> <Head><title>{ title }</title></Head>
<Page>
<ApiKeys/> <ApiKeys/>
</Page>
</> </>
); );
}; };
export default ApiKeysPage; export default ApiKeysPage;
export { getServerSideProps } from 'lib/next/getServerSideProps'; export { getServerSideProps } from 'lib/next/account/getServerSideProps';
...@@ -4,17 +4,20 @@ import React from 'react'; ...@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import CustomAbi from 'ui/pages/CustomAbi'; import CustomAbi from 'ui/pages/CustomAbi';
import Page from 'ui/shared/Page/Page';
const CustomAbiPage: NextPage = () => { const CustomAbiPage: NextPage = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
return ( return (
<> <>
<Head><title>{ title }</title></Head> <Head><title>{ title }</title></Head>
<Page>
<CustomAbi/> <CustomAbi/>
</Page>
</> </>
); );
}; };
export default CustomAbiPage; export default CustomAbiPage;
export { getServerSideProps } from 'lib/next/getServerSideProps'; export { getServerSideProps } from 'lib/next/account/getServerSideProps';
...@@ -4,17 +4,20 @@ import React from 'react'; ...@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PublicTags from 'ui/pages/PublicTags'; import PublicTags from 'ui/pages/PublicTags';
import Page from 'ui/shared/Page/Page';
const PublicTagsPage: NextPage = () => { const PublicTagsPage: NextPage = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
return ( return (
<> <>
<Head><title>{ title }</title></Head> <Head><title>{ title }</title></Head>
<Page>
<PublicTags/> <PublicTags/>
</Page>
</> </>
); );
}; };
export default PublicTagsPage; export default PublicTagsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps'; export { getServerSideProps } from 'lib/next/account/getServerSideProps';
...@@ -4,17 +4,20 @@ import React from 'react'; ...@@ -4,17 +4,20 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PrivateTags from 'ui/pages/PrivateTags'; import PrivateTags from 'ui/pages/PrivateTags';
import Page from 'ui/shared/Page/Page';
const AddressTagsPage: NextPage = () => { const AddressTagsPage: NextPage = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
return ( return (
<> <>
<Head><title>{ title }</title></Head> <Head><title>{ title }</title></Head>
<Page>
<PrivateTags/> <PrivateTags/>
</Page>
</> </>
); );
}; };
export default AddressTagsPage; 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'; ...@@ -4,6 +4,7 @@ import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle'; import getNetworkTitle from 'lib/networks/getNetworkTitle';
import WatchList from 'ui/pages/Watchlist'; import WatchList from 'ui/pages/Watchlist';
import Page from 'ui/shared/Page/Page';
const WatchListPage: NextPage = () => { const WatchListPage: NextPage = () => {
const title = getNetworkTitle(); const title = getNetworkTitle();
...@@ -12,11 +13,13 @@ const WatchListPage: NextPage = () => { ...@@ -12,11 +13,13 @@ const WatchListPage: NextPage = () => {
<Head> <Head>
<title>{ title }</title> <title>{ title }</title>
</Head> </Head>
<Page>
<WatchList/> <WatchList/>
</Page>
</> </>
); );
}; };
export default WatchListPage; export default WatchListPage;
export { getServerSideProps } from 'lib/next/getServerSideProps'; export { getServerSideProps } from 'lib/next/account/getServerSideProps';
...@@ -15,7 +15,7 @@ const APIDocsPage: NextPage = () => { ...@@ -15,7 +15,7 @@ const APIDocsPage: NextPage = () => {
return ( return (
<Page> <Page>
<PageTitle text="API Documentation"/> <PageTitle title="API Documentation"/>
<Head><title>{ `API for the ${ networkTitle }` }</title></Head> <Head><title>{ `API for the ${ networkTitle }` }</title></Head>
<SwaggerUI/> <SwaggerUI/>
</Page> </Page>
......
...@@ -9,7 +9,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -9,7 +9,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const MarketplacePage: NextPage = () => { const MarketplacePage: NextPage = () => {
return ( return (
<Page> <Page>
<PageTitle text="Marketplace"/> <PageTitle title="Marketplace"/>
<Head><title>Blockscout | Marketplace</title></Head> <Head><title>Blockscout | Marketplace</title></Head>
<Marketplace/> <Marketplace/>
......
...@@ -3,16 +3,19 @@ import Head from 'next/head'; ...@@ -3,16 +3,19 @@ import Head from 'next/head';
import React from 'react'; import React from 'react';
import MyProfile from 'ui/pages/MyProfile'; import MyProfile from 'ui/pages/MyProfile';
import Page from 'ui/shared/Page/Page';
const MyProfilePage: NextPage = () => { const MyProfilePage: NextPage = () => {
return ( return (
<> <>
<Head><title>My profile</title></Head> <Head><title>My profile</title></Head>
<Page>
<MyProfile/> <MyProfile/>
</Page>
</> </>
); );
}; };
export default MyProfilePage; 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 = () => { ...@@ -16,7 +16,7 @@ const GraphiqlPage: NextPage = () => {
return ( return (
<Page> <Page>
<Head><title>Graph Page</title></Head> <Head><title>Graph Page</title></Head>
<PageTitle text="GraphQL playground"/> <PageTitle title="GraphQL playground"/>
<GraphQL/> <GraphQL/>
</Page> </Page>
); );
......
...@@ -7,7 +7,7 @@ interface Env { ...@@ -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) // 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) => { return async({ browser }, use) => {
const context = await createContextWithEnvs(browser, envs); const context = await createContextWithEnvs(browser, envs);
......
...@@ -14,6 +14,7 @@ export const TOKEN_INFO_ERC_20: TokenInfo<'ERC-20'> = { ...@@ -14,6 +14,7 @@ export const TOKEN_INFO_ERC_20: TokenInfo<'ERC-20'> = {
symbol: 'STUB', symbol: 'STUB',
total_supply: '6000000000000000000', total_supply: '6000000000000000000',
type: 'ERC-20', type: 'ERC-20',
icon_url: null,
}; };
export const TOKEN_INFO_ERC_721: TokenInfo<'ERC-721'> = { export const TOKEN_INFO_ERC_721: TokenInfo<'ERC-721'> = {
......
...@@ -50,6 +50,16 @@ const colors = { ...@@ -50,6 +50,16 @@ const colors = {
'800': 'RGBA(16, 17, 18, 0.80)', '800': 'RGBA(16, 17, 18, 0.80)',
'900': 'RGBA(16, 17, 18, 0.92)', '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; export default colors;
import { theme } from '@chakra-ui/react'; import { theme } from '@chakra-ui/react';
export const BODY_TYPEFACE = 'Inter'; export const BODY_TYPEFACE = 'Inter';
export const HEADING_TYPEFACE = 'Poppins';
const typography = { const typography = {
fonts: { fonts: {
heading: `Poppins, ${ theme.fonts.heading }`, heading: `${ HEADING_TYPEFACE }, ${ theme.fonts.heading }`,
body: `${ BODY_TYPEFACE }, ${ theme.fonts.body }`, body: `${ BODY_TYPEFACE }, ${ theme.fonts.body }`,
}, },
textStyles: { textStyles: {
......
...@@ -3,7 +3,6 @@ const zIndices = { ...@@ -3,7 +3,6 @@ const zIndices = {
auto: 'auto', auto: 'auto',
base: 0, base: 0,
docked: 10, docked: 10,
tooltip: 900,
dropdown: 1000, dropdown: 1000,
sticky: 1100, sticky: 1100,
sticky1: 1101, sticky1: 1101,
...@@ -12,6 +11,7 @@ const zIndices = { ...@@ -12,6 +11,7 @@ const zIndices = {
overlay: 1300, overlay: 1300,
modal: 1400, modal: 1400,
popover: 1500, popover: 1500,
tooltip: 1550, // otherwise tooltips will not be visible in modals
skipLink: 1600, skipLink: 1600,
toast: 1700, toast: 1700,
}; };
......
...@@ -160,3 +160,58 @@ export type PublicTagErrors = { ...@@ -160,3 +160,58 @@ export type PublicTagErrors = {
full_name: Array<string>; full_name: Array<string>;
tags: 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 { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams'; import type { UserTags } from './addressParams';
import type { Block } from './block'; import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction'; import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenType } from './token'; import type { TokenInfo, TokenInstance, TokenType } from './token';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer'; import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address { export interface Address extends UserTags {
block_number_balance_updated_at: number | null; block_number_balance_updated_at: number | null;
coin_balance: string | null; coin_balance: string | null;
creator_address_hash: string | null; creator_address_hash: string | null;
...@@ -30,11 +30,8 @@ export interface Address { ...@@ -30,11 +30,8 @@ export interface Address {
is_contract: boolean; is_contract: boolean;
is_verified: boolean; is_verified: boolean;
name: string | null; name: string | null;
private_tags: Array<AddressTag> | null;
public_tags: Array<AddressTag> | null;
token: TokenInfo | null; token: TokenInfo | null;
watchlist_address_id: number | null; watchlist_address_id: number | null;
watchlist_names: Array<WatchlistName> | null;
} }
export interface AddressCounters { export interface AddressCounters {
...@@ -48,6 +45,7 @@ export interface AddressTokenBalance { ...@@ -48,6 +45,7 @@ export interface AddressTokenBalance {
token: TokenInfo; token: TokenInfo;
token_id: string | null; token_id: string | null;
value: string; value: string;
token_instance?: TokenInstance;
} }
export interface AddressTokensResponse { export interface AddressTokensResponse {
......
...@@ -9,13 +9,16 @@ export interface WatchlistName { ...@@ -9,13 +9,16 @@ export interface WatchlistName {
display_name: string; 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; hash: string;
implementation_name: string | null; implementation_name: string | null;
name: string | null; name: string | null;
is_contract: boolean; is_contract: boolean;
is_verified: boolean | null; 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 { ...@@ -13,20 +13,20 @@ export interface SearchResultAddressOrContract {
type: 'address' | 'contract'; type: 'address' | 'contract';
name: string | null; name: string | null;
address: string; address: string;
url: string; url?: string; // not used by the frontend, we build the url ourselves
} }
export interface SearchResultBlock { export interface SearchResultBlock {
type: 'block'; type: 'block';
block_number: number; block_number: number | string;
block_hash: string; block_hash: string;
url: string; url?: string; // not used by the frontend, we build the url ourselves
} }
export interface SearchResultTx { export interface SearchResultTx {
type: 'transaction'; type: 'transaction';
tx_hash: string; tx_hash: string;
url: string; url?: string; // not used by the frontend, we build the url ourselves
} }
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx; export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx;
......
import type { TokenInfoApplication } from './account';
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155'; export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155';
...@@ -11,6 +12,7 @@ export interface TokenInfo<T extends TokenType = TokenType> { ...@@ -11,6 +12,7 @@ export interface TokenInfo<T extends TokenType = TokenType> {
holders: string | null; holders: string | null;
exchange_rate: string | null; exchange_rate: string | null;
total_supply: string | null; total_supply: string | null;
icon_url: string | null;
} }
export interface TokenCounters { export interface TokenCounters {
...@@ -57,3 +59,5 @@ export interface TokenInventoryResponse { ...@@ -57,3 +59,5 @@ export interface TokenInventoryResponse {
export type TokenInventoryPagination = { export type TokenInventoryPagination = {
unique_token: number; 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 { ...@@ -15,6 +15,8 @@ export interface NetworkExplorer {
paths: { paths: {
tx?: string; tx?: string;
address?: string; address?: string;
token?: string;
block?: string;
}; };
} }
......
...@@ -10,6 +10,7 @@ declare module "nextjs-routes" { ...@@ -10,6 +10,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/account/custom_abi"> | StaticRoute<"/account/custom_abi">
| StaticRoute<"/account/public_tags_request"> | StaticRoute<"/account/public_tags_request">
| StaticRoute<"/account/tag_address"> | StaticRoute<"/account/tag_address">
| StaticRoute<"/account/verified_addresses">
| StaticRoute<"/account/watchlist"> | StaticRoute<"/account/watchlist">
| StaticRoute<"/accounts"> | StaticRoute<"/accounts">
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }> | DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
...@@ -25,7 +26,6 @@ declare module "nextjs-routes" { ...@@ -25,7 +26,6 @@ declare module "nextjs-routes" {
| DynamicRoute<"/block/[height]", { "height": string }> | DynamicRoute<"/block/[height]", { "height": string }>
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| StaticRoute<"/csv-export"> | StaticRoute<"/csv-export">
| StaticRoute<"/graph">
| StaticRoute<"/graphiql"> | StaticRoute<"/graphiql">
| StaticRoute<"/"> | StaticRoute<"/">
| StaticRoute<"/l2-deposits"> | 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 React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import type { RoutedSubTab } from 'ui/shared/Tabs/types'; import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import { ContractContextProvider } from 'ui/address/contract/context'; import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props { interface Props {
tabs: Array<RoutedSubTab>; tabs: Array<RoutedSubTab>;
addressHash?: string; 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 = { const TAB_LIST_PROPS = {
columnGap: 3, columnGap: 3,
}; };
const AddressContract = ({ addressHash, tabs }: Props) => { const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal'); const fallback = React.useCallback(() => {
const web3ModalTheme = useColorModeValue('light', 'dark'); const noProviderTabs = tabs.filter(({ id }) => id === 'contact_code');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>; return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
} }, [ tabs ]);
return ( return (
<WagmiConfig client={ wagmiClient }> <Web3ModalProvider fallback={ fallback }>
<ContractContextProvider addressHash={ addressHash }> <ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/> <RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider> </ContractContextProvider>
<Web3Modal </Web3ModalProvider>
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
); );
}; };
......
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 type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -6,7 +6,6 @@ import React from 'react'; ...@@ -6,7 +6,6 @@ import React from 'react';
import type { Address as TAddress } from 'types/api/address'; import type { Address as TAddress } from 'types/api/address';
import appConfig from 'configs/app/config';
import blockIcon from 'icons/block.svg'; import blockIcon from 'icons/block.svg';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
...@@ -18,7 +17,6 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -18,7 +17,6 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import AddressBalance from './details/AddressBalance'; import AddressBalance from './details/AddressBalance';
...@@ -83,21 +81,11 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => { ...@@ -83,21 +81,11 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
return <AddressDetailsSkeleton/>; return <AddressDetailsSkeleton/>;
} }
const explorers = appConfig.network.explorers.filter(({ paths }) => paths.address);
const data = addressQuery.isError ? errorData : addressQuery.data; const data = addressQuery.isError ? errorData : addressQuery.data;
return ( return (
<Box> <Box>
<AddressHeadingInfo address={ data } token={ data.token } isLinkDisabled/> <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 <Grid
mt={ 8 } mt={ 8 }
columnGap={ 8 } columnGap={ 8 }
......
...@@ -194,11 +194,17 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD ...@@ -194,11 +194,17 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
</> </>
) : null; ) : null;
const tokenData = React.useMemo(() => ({
address: tokenFilter || '',
name: '',
icon_url: '',
}), [ tokenFilter ]);
const tokenFilterComponent = tokenFilter && ( const tokenFilterComponent = tokenFilter && (
<Flex alignItems="center" flexWrap="wrap" mb={{ base: isActionBarHidden ? 3 : 6, lg: 0 }} mr={ 4 }> <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> <Text whiteSpace="nowrap" mr={ 2 } py={ 1 }>Filtered by token</Text>
<Flex alignItems="center" py={ 1 }> <Flex alignItems="center" py={ 1 }>
<TokenLogo hash={ tokenFilter } boxSize={ 6 } mr={ 2 }/> <TokenLogo data={ tokenData } boxSize={ 6 } mr={ 2 }/>
{ isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter } { isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter }
<Tooltip label="Reset filter"> <Tooltip label="Reset filter">
<Flex> <Flex>
......
...@@ -222,7 +222,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => { ...@@ -222,7 +222,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
<RawDataSnippet <RawDataSnippet
data={ data.creation_bytecode } data={ data.creation_bytecode }
title="Contract creation code" 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 ? ( beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }> <Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified. Contracts that self destruct in their constructors have no contract code published and cannot be verified.
......
...@@ -63,6 +63,12 @@ const AddressBalance = ({ data }: Props) => { ...@@ -63,6 +63,12 @@ const AddressBalance = ({ data }: Props) => {
handler: handleNewCoinBalanceMessage, handler: handleNewCoinBalanceMessage,
}); });
const tokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return ( return (
<DetailsInfoItem <DetailsInfoItem
title="Balance" title="Balance"
...@@ -71,8 +77,7 @@ const AddressBalance = ({ data }: Props) => { ...@@ -71,8 +77,7 @@ const AddressBalance = ({ data }: Props) => {
alignItems="flex-start" alignItems="flex-start"
> >
<TokenLogo <TokenLogo
hash={ appConfig.network.currency.address } data={ tokenData }
name={ appConfig.network.currency.name }
boxSize={ 5 } boxSize={ 5 }
mr={ 2 } mr={ 2 }
fontSize="sm" fontSize="sm"
......
...@@ -3,14 +3,14 @@ import { useQueryClient } from '@tanstack/react-query'; ...@@ -3,14 +3,14 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { UserInfo } from 'types/api/account';
import starFilledIcon from 'icons/star_filled.svg'; import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg'; import starOutlineIcon from 'icons/star_outline.svg';
import type { ResourceError } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources'; import { resourceKey } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing'; 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 WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal'; import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
...@@ -25,18 +25,33 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => { ...@@ -25,18 +25,33 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const toast = useToast();
const redirectIfNotAuth = useRedirectIfNotAuth();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]); const profileState = queryClient.getQueryState<unknown, ResourceError<{ message: string }>>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
if (!isAuth) { if (profileState?.error?.status === 403) {
window.location.assign(loginUrl); 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; return;
} }
watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen(); watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, watchListId, isAuth, loginUrl ]); }, [ profileState, redirectIfNotAuth, watchListId, deleteModalProps, addModalProps, toast ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => { const handleAddOrDeleteSuccess = React.useCallback(async() => {
const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } }); const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } });
......
...@@ -65,7 +65,7 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -65,7 +65,7 @@ const TokenSelectItem = ({ data }: Props) => {
href={ url } href={ url }
> >
<Flex alignItems="center" w="100%"> <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> <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> } { data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
</Flex> </Flex>
......
...@@ -24,7 +24,7 @@ const ERC20TokensListItem = ({ token, value }: Props) => { ...@@ -24,7 +24,7 @@ const ERC20TokensListItem = ({ token, value }: Props) => {
return ( return (
<ListItemMobile rowGap={ 2 }> <ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%"> <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 }/> <AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex> </Flex>
<Flex alignItems="center" pl={ 8 }> <Flex alignItems="center" pl={ 8 }>
......
...@@ -27,7 +27,7 @@ const ERC20TokensTableItem = ({ ...@@ -27,7 +27,7 @@ const ERC20TokensTableItem = ({
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Flex alignItems="center"> <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 }/> <AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex> </Flex>
</Td> </Td>
......
import { Flex, HStack, Text } from '@chakra-ui/react'; import { Flex, HStack, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressTokenBalance } from 'types/api/address';
...@@ -12,14 +13,17 @@ import TokenLogo from 'ui/shared/TokenLogo'; ...@@ -12,14 +13,17 @@ import TokenLogo from 'ui/shared/TokenLogo';
type Props = AddressTokenBalance; type Props = AddressTokenBalance;
const ERC721TokensListItem = ({ token, value }: Props) => { 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(' '); const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
return ( return (
<ListItemMobile rowGap={ 2 }> <ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%"> <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 }/> <AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex> </Flex>
<Flex alignItems="center" pl={ 8 }> <Flex alignItems="center" pl={ 8 }>
<AddressLink hash={ token.address } type="address" truncation="constant"/> <AddressLink hash={ token.address } type="address" truncation="constant"/>
......
import { Tr, Td, Flex } from '@chakra-ui/react'; import { Tr, Td, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; import type { AddressTokenBalance } from 'types/api/address';
...@@ -14,15 +15,17 @@ const ERC721TokensTableItem = ({ ...@@ -14,15 +15,17 @@ const ERC721TokensTableItem = ({
token, token,
value, value,
}: Props) => { }: Props) => {
const router = useRouter();
const hash = router.query.hash?.toString() || '';
const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' '); const tokenString = [ token.name, token.symbol && `(${ token.symbol })` ].filter(Boolean).join(' ');
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Flex alignItems="center"> <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 }/> <AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex> </Flex>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
......
...@@ -4,13 +4,13 @@ import React from 'react'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { AddressTokenBalance } from 'types/api/address'; 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 TokenLogo from 'ui/shared/TokenLogo';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = AddressTokenBalance; 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 } }); const tokenLink = route({ pathname: '/token/[hash]', query: { hash: token.address } });
return ( return (
...@@ -26,11 +26,10 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => { ...@@ -26,11 +26,10 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
lineHeight="20px" lineHeight="20px"
> >
<LinkOverlay href={ tokenLink }> <LinkOverlay href={ tokenLink }>
<NftImage <NftMedia
mb="18px" mb="18px"
url={ null } imageUrl={ tokenInstance?.image_url || null }
fallbackPadding="30px" animationUrl={ tokenInstance?.animation_url || null }
cursor="pointer"
/> />
</LinkOverlay> </LinkOverlay>
{ tokenId && ( { tokenId && (
...@@ -50,7 +49,7 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => { ...@@ -50,7 +49,7 @@ const NFTItem = ({ token, token_id: tokenId }: Props) => {
) } ) }
{ token.name && ( { token.name && (
<Flex alignItems="center"> <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 }> <TruncatedTextTooltip label={ token.name }>
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text> <Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text>
</TruncatedTextTooltip> </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 React from 'react';
import * as statsMock from 'mocks/stats/index';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
...@@ -13,11 +12,29 @@ export const test = base.extend<socketServer.SocketServerFixture>({ ...@@ -13,11 +12,29 @@ export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket, createSocket: socketServer.createSocket,
}); });
test('default view +@mobile +@dark-mode', async({ mount, page }) => { test.describe('mobile', () => {
await page.route(buildApiUrl('homepage_stats'), (route) => route.fulfill({ 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, status: 200,
body: JSON.stringify(statsMock.base), 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({ await page.route(buildApiUrl('homepage_txs'), (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify([ body: JSON.stringify([
...@@ -47,10 +64,6 @@ test.describe('socket', () => { ...@@ -47,10 +64,6 @@ test.describe('socket', () => {
}; };
test('new item', async({ mount, page, createSocket }) => { 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({ await page.route(buildApiUrl('homepage_txs'), (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify([ body: JSON.stringify([
......
...@@ -11,7 +11,7 @@ const LatestTxsItemSkeleton = () => { ...@@ -11,7 +11,7 @@ const LatestTxsItemSkeleton = () => {
return ( return (
<Box <Box
width="100%" width="100%"
minW="700px" minW={{ base: 'unset', lg: '700px' }}
borderTop="1px solid" borderTop="1px solid"
borderColor="divider" borderColor="divider"
py={ 4 } py={ 4 }
......
...@@ -5,7 +5,7 @@ import type { ResourcePayload } from 'lib/api/resources'; ...@@ -5,7 +5,7 @@ import type { ResourcePayload } from 'lib/api/resources';
export type ChartsResources = 'homepage_chart_txs' | 'homepage_chart_market'; 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> { export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId; id: ChainIndicatorId;
......
...@@ -27,11 +27,17 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = { ...@@ -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'> = { const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
id: 'coin_price', id: 'coin_price',
title: `${ appConfig.network.currency.symbol } price`, title: `${ appConfig.network.currency.symbol } price`,
value: (stats) => '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), 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.`, hint: `${ appConfig.network.currency.symbol } token daily price in USD.`,
api: { api: {
resourceName: 'homepage_chart_market', resourceName: 'homepage_chart_market',
...@@ -46,7 +52,7 @@ const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = { ...@@ -46,7 +52,7 @@ const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
}; };
const marketPriceIndicator: TChainIndicator<'homepage_chart_market'> = { const marketPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
id: 'market_cup', id: 'market_cap',
title: 'Market cap', title: 'Market cap',
value: (stats) => '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 0, notation: 'compact' }), value: (stats) => '$' + Number(stats.market_cap).toLocaleString(undefined, { maximumFractionDigits: 0, notation: 'compact' }),
icon: <Icon as={ globeIcon } boxSize={ 6 } bgColor="#6A5DCC" borderRadius="base" color="white"/>, icon: <Icon as={ globeIcon } boxSize={ 6 } bgColor="#6A5DCC" borderRadius="base" color="white"/>,
......
...@@ -51,7 +51,7 @@ const Accounts = () => { ...@@ -51,7 +51,7 @@ const Accounts = () => {
return ( return (
<Page> <Page>
<PageTitle text="Top accounts" withTextAd/> <PageTitle title="Top accounts" withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } 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 { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -10,6 +10,7 @@ import iconSuccess from 'icons/status/success.svg'; ...@@ -10,6 +10,7 @@ import iconSuccess from 'icons/status/success.svg';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext'; import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressCoinBalance from 'ui/address/AddressCoinBalance';
...@@ -22,6 +23,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; ...@@ -22,6 +23,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import TextAd from 'ui/shared/ad/TextAd'; 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 Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs'; import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
...@@ -37,11 +40,9 @@ const TOKEN_TABS = Object.values(tokenTabsByType); ...@@ -37,11 +40,9 @@ const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext(); const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
const tabsScrollRef = React.useRef<HTMLDivElement>(null); const tabsScrollRef = React.useRef<HTMLDivElement>(null);
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
...@@ -50,17 +51,6 @@ const AddressPageContent = () => { ...@@ -50,17 +51,6 @@ const AddressPageContent = () => {
queryOptions: { enabled: Boolean(hash) }, 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 contractTabs = useContractTabs(addressQuery.data);
const tabs: Array<RoutedTab> = React.useMemo(() => { const tabs: Array<RoutedTab> = React.useMemo(() => {
...@@ -99,10 +89,36 @@ const AddressPageContent = () => { ...@@ -99,10 +89,36 @@ const AddressPageContent = () => {
].filter(Boolean); ].filter(Boolean);
}, [ addressQuery.data, contractTabs, hash ]); }, [ 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 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 ( return (
<Page> <Page>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> } { addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
...@@ -110,10 +126,9 @@ const AddressPageContent = () => { ...@@ -110,10 +126,9 @@ const AddressPageContent = () => {
<Skeleton h={ 10 } w="260px" mb={ 6 }/> <Skeleton h={ 10 } w="260px" mb={ 6 }/>
) : ( ) : (
<PageTitle <PageTitle
text={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` } title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
additionalsRight={ tagsNode } backLink={ backLink }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } contentAfter={ tags }
backLinkLabel="Back to top accounts list"
/> />
) } ) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/> <AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
......
...@@ -13,7 +13,6 @@ import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable'; ...@@ -13,7 +13,6 @@ import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal'; import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription'; import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
...@@ -29,7 +28,7 @@ const ApiKeysPage: React.FC = () => { ...@@ -29,7 +28,7 @@ const ApiKeysPage: React.FC = () => {
const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>(); const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = 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) => { const onEditClick = useCallback((data: ApiKey) => {
setApiKeyModalData(data); setApiKeyModalData(data);
...@@ -76,6 +75,9 @@ const ApiKeysPage: React.FC = () => { ...@@ -76,6 +75,9 @@ const ApiKeysPage: React.FC = () => {
} }
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -130,12 +132,10 @@ const ApiKeysPage: React.FC = () => { ...@@ -130,12 +132,10 @@ const ApiKeysPage: React.FC = () => {
})(); })();
return ( return (
<Page> <>
<Box h="100%"> <PageTitle title="API keys"/>
<PageTitle text="API keys"/>
{ content } { content }
</Box> </>
</Page>
); );
}; };
......
...@@ -13,6 +13,7 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -13,6 +13,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails'; import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals'; import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination'; import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
...@@ -82,8 +83,19 @@ const BlockPageContent = () => { ...@@ -82,8 +83,19 @@ const BlockPageContent = () => {
pagination = blockWithdrawalsQuery.pagination; pagination = blockWithdrawalsQuery.pagination;
} }
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks'); const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to blocks list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return ( return (
<> <>
{ blockQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> } { blockQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
...@@ -91,9 +103,9 @@ const BlockPageContent = () => { ...@@ -91,9 +103,9 @@ const BlockPageContent = () => {
<Skeleton h={ 10 } w="300px" mb={ 6 }/> <Skeleton h={ 10 } w="300px" mb={ 6 }/>
) : ( ) : (
<PageTitle <PageTitle
text={ `Block #${ blockQuery.data?.height }` } title={ `Block #${ blockQuery.data?.height }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLink={ backLink }
backLinkLabel="Back to blocks list" contentAfter={ <NetworkExplorers type="block" pathParam={ height } ml={{ base: 'initial', lg: 'auto' }}/> }
/> />
) } ) }
{ blockQuery.isLoading ? <SkeletonTabs/> : ( { blockQuery.isLoading ? <SkeletonTabs/> : (
......
...@@ -42,7 +42,7 @@ const BlocksPageContent = () => { ...@@ -42,7 +42,7 @@ const BlocksPageContent = () => {
return ( return (
<Page> <Page>
<PageTitle text="Blocks" withTextAd/> <PageTitle title="Blocks" withTextAd/>
<RoutedTabs <RoutedTabs
tabs={ tabs } tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS } tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
...@@ -20,7 +20,6 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -20,7 +20,6 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => { const ContractVerification = () => {
const appProps = useAppContext(); const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter(); const router = useRouter();
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
...@@ -83,12 +82,24 @@ const ContractVerification = () => { ...@@ -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 ( return (
<Page> <Page>
<PageTitle <PageTitle
text="New smart contract verification" title="New smart contract verification"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLink={ backLink }
backLinkLabel="Back to contract"
/> />
{ hash && ( { hash && (
<Address mb={ 12 }> <Address mb={ 12 }>
......
...@@ -50,7 +50,6 @@ const CsvExport = () => { ...@@ -50,7 +50,6 @@ const CsvExport = () => {
const addressHash = router.query.address?.toString() || ''; const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || ''; const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', { const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
...@@ -59,6 +58,19 @@ const CsvExport = () => { ...@@ -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) { if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } }); throw Error('Not found', { cause: { status: 404 } });
} }
...@@ -78,9 +90,8 @@ const CsvExport = () => { ...@@ -78,9 +90,8 @@ const CsvExport = () => {
return ( return (
<Page> <Page>
<PageTitle <PageTitle
text="Export data to CSV file" title="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLink={ backLink }
backLinkLabel="Back to address"
/> />
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap"> <Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span> <span>Export { EXPORT_TYPES[exportType].text } for address </span>
......
...@@ -12,7 +12,6 @@ import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable'; ...@@ -12,7 +12,6 @@ import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal'; import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription'; import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
...@@ -26,7 +25,7 @@ const CustomAbiPage: React.FC = () => { ...@@ -26,7 +25,7 @@ const CustomAbiPage: React.FC = () => {
const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>(); const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>();
const [ deleteModalData, setDeleteModalData ] = 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) => { const onEditClick = useCallback((data: CustomAbi) => {
setCustomAbiModalData(data); setCustomAbiModalData(data);
...@@ -72,6 +71,9 @@ const CustomAbiPage: React.FC = () => { ...@@ -72,6 +71,9 @@ const CustomAbiPage: React.FC = () => {
} }
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -113,12 +115,10 @@ const CustomAbiPage: React.FC = () => { ...@@ -113,12 +115,10 @@ const CustomAbiPage: React.FC = () => {
})(); })();
return ( return (
<Page> <>
<Box h="100%"> <PageTitle title="Custom ABI"/>
<PageTitle text="Custom ABI"/>
{ content } { content }
</Box> </>
</Page>
); );
}; };
......
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'; ...@@ -5,6 +5,7 @@ import * as blockMock from 'mocks/blocks/block';
import * as dailyTxsMock from 'mocks/stats/daily_txs'; import * as dailyTxsMock from 'mocks/stats/daily_txs';
import * as statsMock from 'mocks/stats/index'; import * as statsMock from 'mocks/stats/index';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder'; import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder';
...@@ -47,6 +48,35 @@ test('default view -@default +@desktop-xl +@dark-mode', async({ mount, page }) = ...@@ -47,6 +48,35 @@ test('default view -@default +@desktop-xl +@dark-mode', async({ mount, page }) =
await expect(component.locator('main')).toHaveScreenshot(); 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 // had to separate mobile test, otherwise all the tests fell on CI
test.describe('mobile', () => { test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
......
...@@ -17,11 +17,11 @@ const Home = () => { ...@@ -17,11 +17,11 @@ const Home = () => {
<Page isHomePage> <Page isHomePage>
<Box <Box
w="100%" w="100%"
backgroundImage={ appConfig.homepage.plate.gradient } background={ appConfig.homepage.plate.background }
backgroundColor="blue.400"
borderRadius="24px" borderRadius="24px"
padding={{ base: '24px', lg: '48px' }} padding={{ base: '24px', lg: '48px' }}
minW={{ base: 'unset', lg: '900px' }} minW={{ base: 'unset', lg: '900px' }}
data-label="hero plate"
> >
<Flex mb={{ base: 6, lg: 8 }} justifyContent="space-between"> <Flex mb={{ base: 6, lg: 8 }} justifyContent="space-between">
<Heading <Heading
......
...@@ -68,7 +68,7 @@ const L2Deposits = () => { ...@@ -68,7 +68,7 @@ const L2Deposits = () => {
return ( return (
<Page> <Page>
<PageTitle text={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/> <PageTitle title={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } isLoading={ isLoading }
......
...@@ -70,7 +70,7 @@ const L2OutputRoots = () => { ...@@ -70,7 +70,7 @@ const L2OutputRoots = () => {
return ( return (
<Page> <Page>
<PageTitle text="Output roots" withTextAd/> <PageTitle title="Output roots" withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } isLoading={ isLoading }
......
...@@ -71,7 +71,7 @@ const L2TxnBatches = () => { ...@@ -71,7 +71,7 @@ const L2TxnBatches = () => {
return ( return (
<Page> <Page>
<PageTitle text={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/> <PageTitle title={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } isLoading={ isLoading }
......
...@@ -68,7 +68,7 @@ const L2Withdrawals = () => { ...@@ -68,7 +68,7 @@ const L2Withdrawals = () => {
return ( return (
<Page> <Page>
<PageTitle text={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/> <PageTitle title={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } isLoading={ isLoading }
......
...@@ -61,7 +61,7 @@ const Login = () => { ...@@ -61,7 +61,7 @@ const Login = () => {
return ( return (
<Page> <Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px"> <VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text="Login page 😂"/> <PageTitle title="Login page 😂"/>
{ isFormVisible && ( { isFormVisible && (
<> <>
<Alert status="error" flexDirection="column" alignItems="flex-start"> <Alert status="error" flexDirection="column" alignItems="flex-start">
......
...@@ -5,12 +5,11 @@ import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; ...@@ -5,12 +5,11 @@ import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import UserAvatar from 'ui/shared/UserAvatar'; import UserAvatar from 'ui/shared/UserAvatar';
const MyProfile = () => { const MyProfile = () => {
const { data, isLoading, isError, isFetched } = useFetchProfileInfo(); const { data, isLoading, isError, error, isFetched } = useFetchProfileInfo();
useRedirectForInvalidAuthToken(); useRedirectForInvalidAuthToken();
const content = (() => { const content = (() => {
...@@ -19,6 +18,9 @@ const MyProfile = () => { ...@@ -19,6 +18,9 @@ const MyProfile = () => {
} }
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -54,10 +56,10 @@ const MyProfile = () => { ...@@ -54,10 +56,10 @@ const MyProfile = () => {
})(); })();
return ( return (
<Page> <>
<PageTitle text="My profile"/> <PageTitle title="My profile"/>
{ content } { content }
</Page> </>
); );
}; };
......
...@@ -5,7 +5,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -5,7 +5,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags'; import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags';
import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags'; import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
...@@ -18,10 +17,10 @@ const PrivateTags = () => { ...@@ -18,10 +17,10 @@ const PrivateTags = () => {
useRedirectForInvalidAuthToken(); useRedirectForInvalidAuthToken();
return ( return (
<Page> <>
<PageTitle text="Private tags"/> <PageTitle title="Private tags"/>
<RoutedTabs tabs={ TABS }/> <RoutedTabs tabs={ TABS }/>
</Page> </>
); );
}; };
......
import { Link, Text, Icon } from '@chakra-ui/react'; import { useRouter } from 'next/router';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll'; import { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account'; 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 useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString';
import PublicTagsData from 'ui/publicTags/PublicTagsData'; import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm'; import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
type TScreen = 'data' | 'form'; type TScreen = 'data' | 'form';
...@@ -23,13 +21,21 @@ const toastDescriptions = { ...@@ -23,13 +21,21 @@ const toastDescriptions = {
} as Record<TToastAction, string>; } as Record<TToastAction, string>;
const PublicTagsComponent: React.FC = () => { const PublicTagsComponent: React.FC = () => {
const [ screen, setScreen ] = useState<TScreen>('data'); const router = useRouter();
const [ formData, setFormData ] = useState<PublicTag>(); 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 toast = useToast();
const isMobile = useIsMobile();
useRedirectForInvalidAuthToken(); 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) => { const showToast = useCallback((action: TToastAction) => {
toast({ toast({
position: 'top-right', position: 'top-right',
...@@ -77,17 +83,20 @@ const PublicTagsComponent: React.FC = () => { ...@@ -77,17 +83,20 @@ const PublicTagsComponent: React.FC = () => {
header = formData ? 'Request to edit a public tag/label' : 'Request a public tag/label'; header = formData ? 'Request to edit a public tag/label' : 'Request a public tag/label';
} }
const backLink = {
label: 'Public tags',
onClick: onGoBack,
};
return ( return (
<Page> <>
{ screen === 'form' && ( <PageTitle
<Link display="inline-flex" alignItems="center" mb={ 6 } onClick={ onGoBack }> title={ header }
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)"/> backLink={ screen === 'form' ? backLink : undefined }
{ isMobile && <Text variant="inherit" fontSize="sm" ml={ 2 }>Public tags</Text> } display={{ base: 'block', lg: 'inline-flex' }}
</Link> />
) }
<PageTitle text={ header } display={{ base: 'block', lg: 'inline-flex' }} ml={{ base: 0, lg: 3 }}/>
{ content } { content }
</Page> </>
); );
}; };
......
...@@ -152,7 +152,7 @@ const SearchResultsPageContent = () => { ...@@ -152,7 +152,7 @@ const SearchResultsPageContent = () => {
<Page renderHeader={ renderHeader }> <Page renderHeader={ renderHeader }>
{ isLoading || redirectCheckQuery.isLoading ? { isLoading || redirectCheckQuery.isLoading ?
<Skeleton h={ 10 } mb={ 6 } w="100%" maxW="222px"/> : <Skeleton h={ 10 } mb={ 6 } w="100%" maxW="222px"/> :
<PageTitle text="Search results"/> <PageTitle title="Search results"/>
} }
{ bar } { bar }
{ content } { content }
......
...@@ -17,14 +17,25 @@ const Sol2Uml = () => { ...@@ -17,14 +17,25 @@ const Sol2Uml = () => {
const appProps = useAppContext(); const appProps = useAppContext();
const addressHash = router.query.address?.toString() || ''; const addressHash = router.query.address?.toString() || '';
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address'); const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return ( return (
<Page> <Page>
<PageTitle <PageTitle
text="Solidity UML diagram" title="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLink={ backLink }
backLinkLabel="Back to address"
/> />
<Flex mb={ 10 }> <Flex mb={ 10 }>
<span>For contract</span> <span>For contract</span>
......
...@@ -26,7 +26,7 @@ const Stats = () => { ...@@ -26,7 +26,7 @@ const Stats = () => {
return ( return (
<Page> <Page>
<PageTitle text={ `${ appConfig.network.name } stats` }/> <PageTitle title={ `${ appConfig.network.name } stats` }/>
<Box mb={{ base: 6, sm: 8 }}> <Box mb={{ base: 6, sm: 8 }}>
<NumberWidgetsList/> <NumberWidgetsList/>
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react'; import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address'; import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo'; import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import * as socketServer from 'playwright/fixtures/socketServer'; import * as socketServer from 'playwright/fixtures/socketServer';
...@@ -16,7 +17,7 @@ const TOKEN_TRANSFERS_API_URL = buildApiUrl('token_transfers', { hash: '1' }); ...@@ -16,7 +17,7 @@ const TOKEN_TRANSFERS_API_URL = buildApiUrl('token_transfers', { hash: '1' });
const ADDRESS_API_URL = buildApiUrl('address', { hash: '1' }); const ADDRESS_API_URL = buildApiUrl('address', { hash: '1' });
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: 1, tab: 'token_transfers' }, query: { hash: '1', tab: 'token_transfers' },
isReady: true, isReady: true,
}, },
}; };
...@@ -70,6 +71,30 @@ test('base view', async({ mount, page, createSocket }) => { ...@@ -70,6 +71,30 @@ test('base view', async({ mount, page, createSocket }) => {
await expect(component).toHaveScreenshot(); 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.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport }); test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page, createSocket }) => { test('base view', async({ mount, page, createSocket }) => {
...@@ -88,4 +113,26 @@ test.describe('mobile', () => { ...@@ -88,4 +113,26 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot(); 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'; ...@@ -7,12 +7,14 @@ import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import iconSuccess from 'icons/status/success.svg'; import iconSuccess from 'icons/status/success.svg';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext'; import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs'; import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages'; import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import trimTokenSymbol from 'lib/token/trimTokenSymbol'; import trimTokenSymbol from 'lib/token/trimTokenSymbol';
...@@ -20,7 +22,8 @@ import * as addressStubs from 'stubs/address'; ...@@ -20,7 +22,8 @@ import * as addressStubs from 'stubs/address';
import * as tokenStubs from 'stubs/token'; import * as tokenStubs from 'stubs/token';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
import TextAd from 'ui/shared/ad/TextAd'; 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 PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination'; import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination'; import Pagination from 'ui/shared/Pagination';
...@@ -32,6 +35,7 @@ import TokenDetails from 'ui/token/TokenDetails'; ...@@ -32,6 +35,7 @@ import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders'; import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory'; import TokenInventory from 'ui/token/TokenInventory';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer'; import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory'; export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
...@@ -43,11 +47,9 @@ const TokenPageContent = () => { ...@@ -43,11 +47,9 @@ const TokenPageContent = () => {
const appProps = useAppContext(); const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = router.query.hash?.toString(); const hashString = getQueryParamString(router.query.hash);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -147,6 +149,12 @@ const TokenPageContent = () => { ...@@ -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 contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
...@@ -207,20 +215,52 @@ const TokenPageContent = () => { ...@@ -207,20 +215,52 @@ const TokenPageContent = () => {
}; };
}, [ isMobile ]); }, [ 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 ( return (
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle <PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
isLoading={ tokenQuery.isPlaceholderData } isLoading={ tokenQuery.isPlaceholderData }
text={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` } backLink={ backLink }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } beforeTitle={ (
backLinkLabel="Back to tokens list" <TokenLogo data={ tokenQuery.data } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData } display="inline-block" mr={ 2 }/>
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData }/>
) } ) }
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 }/> <TokenContractInfo tokenQuery={ tokenQuery } contractQuery={ contractQuery }/>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery } isVerifiedInfoEnabled={ isVerifiedInfoEnabled }/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box> <Box ref={ scrollRef }></Box>
......
...@@ -7,7 +7,7 @@ import TokensList from 'ui/tokens/Tokens'; ...@@ -7,7 +7,7 @@ import TokensList from 'ui/tokens/Tokens';
const Tokens = () => { const Tokens = () => {
return ( return (
<Page> <Page>
<PageTitle text="Tokens" withTextAd/> <PageTitle title="Tokens" withTextAd/>
<TokensList/> <TokensList/>
</Page> </Page>
); );
......
import { Flex, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -6,10 +5,11 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -6,10 +5,11 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext'; import { useAppContext } from 'lib/appContext';
import networkExplorers from 'lib/networks/networkExplorers'; import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import TextAd from 'ui/shared/ad/TextAd'; 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 Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
...@@ -32,47 +32,45 @@ const TABS: Array<RoutedTab> = [ ...@@ -32,47 +32,45 @@ const TABS: Array<RoutedTab> = [
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
const isMobile = useIsMobile();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const { data } = useApiQuery('tx', { const { data, isPlaceholderData } = useApiQuery('tx', {
pathParams: { hash }, pathParams: { hash },
queryOptions: { enabled: Boolean(hash) }, queryOptions: { enabled: Boolean(hash) },
}); });
const explorersLinks = networkExplorers const tags = (
.filter((explorer) => explorer.paths.tx) <EntityTags
.map((explorer) => { isLoading={ isPlaceholderData }
const url = new URL(explorer.paths.tx + '/' + hash, explorer.baseUrl); tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] }
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>Open in { explorer.title }</LinkExternal>; contentAfter={
}); <NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 'initial', lg: 'auto' }} hideText={ isMobile && Boolean(data?.tx_tag) }/>
}
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 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 ( return (
<Page> <Page>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle <PageTitle
text="Transaction details" title="Transaction details"
additionalsRight={ additionals } backLink={ backLink }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } contentAfter={ tags }
backLinkLabel="Back to transactions list"
/> />
<RoutedTabs tabs={ TABS }/> <RoutedTabs tabs={ TABS }/>
</Page> </Page>
......
...@@ -73,7 +73,7 @@ const Transactions = () => { ...@@ -73,7 +73,7 @@ const Transactions = () => {
return ( return (
<Page> <Page>
<Box h="100%"> <Box h="100%">
<PageTitle text="Transactions" withTextAd/> <PageTitle title="Transactions" withTextAd/>
<RoutedTabs <RoutedTabs
tabs={ tabs } tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS } 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 = () => { ...@@ -117,7 +117,7 @@ const VerifiedContracts = () => {
return ( return (
<Box> <Box>
<PageTitle text="Verified contracts" withTextAd/> <PageTitle title="Verified contracts" withTextAd/>
<VerifiedContractsCounters/> <VerifiedContractsCounters/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
......
...@@ -5,13 +5,13 @@ import React, { useCallback, useState } from 'react'; ...@@ -5,13 +5,13 @@ import React, { useCallback, useState } from 'react';
import type { WatchlistAddress, WatchlistTokensResponse } from 'types/api/account'; import type { WatchlistAddress, WatchlistTokensResponse } from 'types/api/account';
import type { TWatchlist, TWatchlistItem } from 'types/client/account'; import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import type { ResourceError } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources'; import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import AccountPageDescription from 'ui/shared/AccountPageDescription'; import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
...@@ -22,12 +22,11 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable'; ...@@ -22,12 +22,11 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => { const WatchList: React.FC = () => {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, TWatchlist>([ resourceKey('watchlist') ], async() => { const { data, isLoading, isError, error } = useQuery<unknown, ResourceError, TWatchlist>([ resourceKey('watchlist') ], async() => {
try {
const watchlistAddresses = await apiFetch<'watchlist', Array<WatchlistAddress>>('watchlist'); const watchlistAddresses = await apiFetch<'watchlist', Array<WatchlistAddress>>('watchlist');
if (!Array.isArray(watchlistAddresses)) { if (!Array.isArray(watchlistAddresses)) {
throw Error(); return;
} }
const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => { const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => {
...@@ -44,9 +43,6 @@ const WatchList: React.FC = () => { ...@@ -44,9 +43,6 @@ const WatchList: React.FC = () => {
})); }));
return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] })); return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] }));
} catch (error) {
return error;
}
}); });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -96,7 +92,14 @@ const WatchList: React.FC = () => { ...@@ -96,7 +92,14 @@ const WatchList: React.FC = () => {
</AccountPageDescription> </AccountPageDescription>
); );
let content; if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>;
}
const content = (() => {
if (isLoading && !data) { if (isLoading && !data) {
const loader = isMobile ? <SkeletonListAccount showFooterSlot/> : ( const loader = isMobile ? <SkeletonListAccount showFooterSlot/> : (
<> <>
...@@ -105,15 +108,14 @@ const WatchList: React.FC = () => { ...@@ -105,15 +108,14 @@ const WatchList: React.FC = () => {
</> </>
); );
content = ( return (
<> <>
{ description } { description }
{ loader } { loader }
</> </>
); );
} else if (isError) { }
content = <DataFetchAlert/>;
} else {
const list = isMobile ? ( const list = isMobile ? (
<Box> <Box>
{ data.map((item) => ( { data.map((item) => (
...@@ -133,7 +135,7 @@ const WatchList: React.FC = () => { ...@@ -133,7 +135,7 @@ const WatchList: React.FC = () => {
/> />
); );
content = ( return (
<> <>
{ description } { description }
{ Boolean(data?.length) && list } { Boolean(data?.length) && list }
...@@ -162,15 +164,13 @@ const WatchList: React.FC = () => { ...@@ -162,15 +164,13 @@ const WatchList: React.FC = () => {
) } ) }
</> </>
); );
} })();
return ( return (
<Page> <>
<Box h="100%"> <PageTitle title="Watch list"/>
<PageTitle text="Watch list"/>
{ content } { content }
</Box> </>
</Page>
); );
}; };
......
...@@ -70,7 +70,7 @@ const Withdrawals = () => { ...@@ -70,7 +70,7 @@ const Withdrawals = () => {
return ( return (
<Page> <Page>
<PageTitle text="Withdrawals" withTextAd/> <PageTitle title="Withdrawals" withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
isLoading={ isLoading } isLoading={ isLoading }
......
...@@ -3,7 +3,7 @@ import { ...@@ -3,7 +3,7 @@ import {
Button, Button,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
...@@ -11,7 +11,6 @@ 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 { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources'; import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
...@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput'; ...@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput';
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
data?: AddressTag; data?: Partial<AddressTag>;
onClose: () => void; onClose: () => void;
onSuccess: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void; setAlertVisible: (isAlertVisible: boolean) => void;
} }
...@@ -31,7 +31,7 @@ type Inputs = { ...@@ -31,7 +31,7 @@ type Inputs = {
tag: string; tag: string;
} }
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
...@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const formBackgroundColor = useColorModeValue('white', 'gray.900'); const formBackgroundColor = useColorModeValue('white', 'gray.900');
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => { const { mutate } = useMutation((formData: Inputs) => {
const body = { const body = {
name: formData?.tag, name: formData?.tag,
...@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
setAlertVisible(true); setAlertVisible(true);
} }
}, },
onSuccess: () => { onSuccess: async() => {
queryClient.refetchQueries([ resourceKey('private_tags_address') ]).then(() => { await onSuccess();
onClose(); onClose();
setPending(false); setPending(false);
});
}, },
}); });
......
...@@ -9,18 +9,19 @@ import AddressForm from './AddressForm'; ...@@ -9,18 +9,19 @@ import AddressForm from './AddressForm';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data?: AddressTag; onSuccess: () => Promise<void>;
data?: Partial<AddressTag>;
} }
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const AddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data }) => {
const title = data ? 'Edit address tag' : 'New address tag'; const title = data?.id ? '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 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 [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => { const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>; return <AddressForm data={ data } onClose={ onClose } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]); }, [ data, onClose, onSuccess ]);
return ( return (
<FormModal<AddressTag> <FormModal<AddressTag>
isOpen={ isOpen } isOpen={ isOpen }
......
...@@ -15,7 +15,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable'; ...@@ -15,7 +15,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal'; import DeletePrivateTagModal from './DeletePrivateTagModal';
const PrivateAddressTags = () => { const PrivateAddressTags = () => {
const { data: addressTagsData, isError, isPlaceholderData } = useApiQuery('private_tags_address', { const { data: addressTagsData, isError, error, isPlaceholderData, refetch } = useApiQuery('private_tags_address', {
queryOptions: { queryOptions: {
refetchOnMount: false, refetchOnMount: false,
placeholderData: Array(3).fill(PRIVATE_TAG_ADDRESS), placeholderData: Array(3).fill(PRIVATE_TAG_ADDRESS),
...@@ -34,6 +34,10 @@ const PrivateAddressTags = () => { ...@@ -34,6 +34,10 @@ const PrivateAddressTags = () => {
addressModalProps.onOpen(); addressModalProps.onOpen();
}, [ addressModalProps ]); }, [ addressModalProps ]);
const onAddOrEditSuccess = useCallback(async() => {
await refetch();
}, [ refetch ]);
const onAddressModalClose = useCallback(() => { const onAddressModalClose = useCallback(() => {
setAddressModalData(undefined); setAddressModalData(undefined);
addressModalProps.onClose(); addressModalProps.onClose();
...@@ -50,6 +54,9 @@ const PrivateAddressTags = () => { ...@@ -50,6 +54,9 @@ const PrivateAddressTags = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -91,7 +98,7 @@ const PrivateAddressTags = () => { ...@@ -91,7 +98,7 @@ const PrivateAddressTags = () => {
</Button> </Button>
</Skeleton> </Skeleton>
</Box> </Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/> <AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData } onSuccess={ onAddOrEditSuccess }/>
{ deleteModalData && ( { deleteModalData && (
<DeletePrivateTagModal <DeletePrivateTagModal
{ ...deleteModalProps } { ...deleteModalProps }
......
...@@ -16,7 +16,7 @@ import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem ...@@ -16,7 +16,7 @@ import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem
import TransactionTagTable from './TransactionTagTable/TransactionTagTable'; import TransactionTagTable from './TransactionTagTable/TransactionTagTable';
const PrivateTransactionTags = () => { 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 transactionModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
...@@ -69,6 +69,9 @@ const PrivateTransactionTags = () => { ...@@ -69,6 +69,9 @@ const PrivateTransactionTags = () => {
} }
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
......
...@@ -24,7 +24,7 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { ...@@ -24,7 +24,7 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>(); const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { data, isLoading, isError } = useApiQuery('public_tags'); const { data, isLoading, isError, error } = useApiQuery('public_tags');
const onDeleteModalClose = useCallback(() => { const onDeleteModalClose = useCallback(() => {
setDeleteModalData(undefined); setDeleteModalData(undefined);
...@@ -70,6 +70,9 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { ...@@ -70,6 +70,9 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
} }
if (isError) { if (isError) {
if (error.status === 403) {
throw new Error('Unverified email error', { cause: error });
}
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
......
...@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput'; ...@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput';
type Props = { type Props = {
changeToDataScreen: (success?: boolean) => void; changeToDataScreen: (success?: boolean) => void;
data?: PublicTag; data?: Partial<PublicTag>;
} }
export type Inputs = { export type Inputs = {
...@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
email: data?.email || '', email: data?.email || '',
companyName: data?.company || '', companyName: data?.company || '',
companyUrl: data?.website || '', companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; ') || '', tags: data?.tags?.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.map((address, index: number) => ({ name: `address.${ index }.address`, address })) || addresses: data?.addresses?.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ], [ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment || '', comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report', 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 _debounce from 'lodash/debounce';
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
const CUT_HEIGHT = 144; 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 ref = useRef<HTMLParagraphElement>(null);
const [ needCut, setNeedCut ] = useState(false); const [ needCut, setNeedCut ] = useState(false);
...@@ -20,6 +20,10 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => { ...@@ -20,6 +20,10 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
}, [ needCut ]); }, [ needCut ]);
useEffect(() => { useEffect(() => {
if (!allowCut) {
return;
}
calculateCut(); calculateCut();
const resizeHandler = _debounce(calculateCut, 300); const resizeHandler = _debounce(calculateCut, 300);
window.addEventListener('resize', resizeHandler); window.addEventListener('resize', resizeHandler);
...@@ -40,15 +44,14 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => { ...@@ -40,15 +44,14 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
return ( return (
<Box position="relative" marginBottom={{ base: 6, lg: 8 }}> <Box position="relative" marginBottom={{ base: 6, lg: 8 }}>
<Text <Box
ref={ ref } ref={ ref }
maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' } maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' }
overflow="hidden" overflow="hidden"
style={ needCut && !expanded ? { WebkitLineClamp: '6', WebkitBoxOrient: 'vertical', display: '-webkit-box' } : {} } style={ needCut && !expanded ? { WebkitLineClamp: '6', WebkitBoxOrient: 'vertical', display: '-webkit-box' } : {} }
> >
{ children } { children }
</Text> </Box>
{ needCut && !expanded && ( { needCut && !expanded && (
<Box <Box
position="absolute" 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'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { Address } from 'types/api/address'; import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import config from 'configs/app/config'; import appConfig from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import AddressActionsMenu from 'ui/shared/AddressActions/Menu';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props { interface Props {
...@@ -21,8 +21,6 @@ interface Props { ...@@ -21,8 +21,6 @@ interface Props {
} }
const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props) => { const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props) => {
const isMobile = useIsMobile();
return ( return (
<Flex alignItems="center"> <Flex alignItems="center">
<AddressIcon address={ address } isLoading={ isLoading }/> <AddressIcon address={ address } isLoading={ isLoading }/>
...@@ -31,17 +29,18 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props ...@@ -31,17 +29,18 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props
hash={ address.hash } hash={ address.hash }
ml={ 2 } ml={ 2 }
fontFamily="heading" fontFamily="heading"
fontSize="lg"
fontWeight={ 500 } fontWeight={ 500 }
truncation={ isMobile ? 'constant' : 'none' }
isDisabled={ isLinkDisabled } isDisabled={ isLinkDisabled }
isLoading={ isLoading } isLoading={ isLoading }
/> />
<CopyToClipboard text={ address.hash } isLoading={ isLoading }/> <CopyToClipboard text={ address.hash } isLoading={ isLoading }/>
{ !isLoading && address.is_contract && token && <AddressAddToWallet ml={ 2 } token={ token }/> } { !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 }/> <AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id } ml={ 3 }/>
) } ) }
<AddressQrCode hash={ address.hash } ml={ 2 } isLoading={ isLoading }/> <AddressQrCode hash={ address.hash } ml={ 2 } isLoading={ isLoading }/>
{ appConfig.isAccountSupported && <AddressActionsMenu isLoading={ isLoading }/> }
</Flex> </Flex>
); );
}; };
......
...@@ -9,7 +9,7 @@ import AddressLink from 'ui/shared/address/AddressLink'; ...@@ -9,7 +9,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props { interface Props {
address: AddressParam; address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name'>;
subtitle?: string; subtitle?: string;
isLoading?: boolean; isLoading?: boolean;
} }
...@@ -20,7 +20,7 @@ const AddressSnippet = ({ address, subtitle, isLoading }: Props) => { ...@@ -20,7 +20,7 @@ const AddressSnippet = ({ address, subtitle, isLoading }: Props) => {
<Address> <Address>
<AddressIcon address={ address } isLoading={ isLoading }/> <AddressIcon address={ address } isLoading={ isLoading }/>
<AddressLink type="address" hash={ address.hash } fontWeight="600" ml={ 2 } 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> </Address>
{ subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 } ml={ 8 }>{ subtitle }</Text> } { subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 } ml={ 8 }>{ subtitle }</Text> }
</Box> </Box>
......
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import txIcon from 'icons/transactions.svg'; import txIcon from 'icons/transactions.svg';
const ErrorInvalidTxHash = () => { const AppErrorInvalidTxHash = () => {
const textColor = useColorModeValue('gray.500', 'gray.400'); const textColor = useColorModeValue('gray.500', 'gray.400');
const snippet = { const snippet = {
borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'), borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'),
...@@ -54,4 +54,4 @@ const ErrorInvalidTxHash = () => { ...@@ -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) => { ...@@ -68,7 +68,7 @@ const DataListDisplay = (props: Props) => {
} }
if (!props.items?.length) { if (!props.items?.length) {
return <Text as="span">{ props.emptyText }</Text>; return props.emptyText ? <Text as="span">{ props.emptyText }</Text> : null;
} }
return ( 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 { Tooltip } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import shortenString from 'lib/shortenString';
interface Props { interface Props {
hash: string; hash: string;
isTooltipDisabled?: boolean; isTooltipDisabled?: boolean;
...@@ -13,7 +15,7 @@ const HashStringShorten = ({ hash, isTooltipDisabled }: Props) => { ...@@ -13,7 +15,7 @@ const HashStringShorten = ({ hash, isTooltipDisabled }: Props) => {
return ( return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled }> <Tooltip label={ hash } isDisabled={ isTooltipDisabled }>
{ hash.slice(0, 4) + '...' + hash.slice(-4) } { shortenString(hash) }
</Tooltip> </Tooltip>
); );
}; };
......
...@@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; ...@@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import type { FontFace } from 'use-font-face-observer'; import type { FontFace } from 'use-font-face-observer';
import useFontFaceObserver 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 TAIL_LENGTH = 4;
const HEAD_MIN_LENGTH = 4; const HEAD_MIN_LENGTH = 4;
...@@ -31,6 +31,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled ...@@ -31,6 +31,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled
const isFontFaceLoaded = useFontFaceObserver([ const isFontFaceLoaded = useFontFaceObserver([
{ family: BODY_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] }, { family: BODY_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
{ family: HEADING_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
]); ]);
const calculateString = useCallback(() => { const calculateString = useCallback(() => {
......
...@@ -11,7 +11,7 @@ interface Props { ...@@ -11,7 +11,7 @@ interface Props {
const LinkExternal = ({ href, children, className }: Props) => { const LinkExternal = ({ href, children, className }: Props) => {
return ( 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 } { children }
<Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle"/> <Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle"/>
</Link> </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 { Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import getErrorStatusCode from 'lib/errors/getErrorStatusCode'; import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload'; import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import useAdblockDetect from 'lib/hooks/useAdblockDetect'; import useAdblockDetect from 'lib/hooks/useAdblockDetect';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken'; import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel'; import * as mixpanel from 'lib/mixpanel';
import AppError from 'ui/shared/AppError/AppError'; import AppError from 'ui/shared/AppError/AppError';
import AppErrorBlockConsensus from 'ui/shared/AppError/AppErrorBlockConsensus'; 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 ErrorBoundary from 'ui/shared/ErrorBoundary';
import ErrorInvalidTxHash from 'ui/shared/ErrorInvalidTxHash';
import PageContent from 'ui/shared/Page/PageContent'; import PageContent from 'ui/shared/Page/PageContent';
import Header from 'ui/snippets/header/Header'; import Header from 'ui/snippets/header/Header';
import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop'; import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop';
...@@ -36,7 +37,7 @@ const Page = ({ ...@@ -36,7 +37,7 @@ const Page = ({
mixpanel.useLogPageView(isMixpanelInited); mixpanel.useLogPageView(isMixpanelInited);
const renderErrorScreen = React.useCallback((error?: Error) => { const renderErrorScreen = React.useCallback((error?: Error) => {
const statusCode = getErrorStatusCode(error) || 500; const statusCode = getErrorCauseStatusCode(error) || 500;
const resourceErrorPayload = getResourceErrorPayload(error); const resourceErrorPayload = getResourceErrorPayload(error);
const messageInPayload = resourceErrorPayload && 'message' in resourceErrorPayload && typeof resourceErrorPayload.message === 'string' ? const messageInPayload = resourceErrorPayload && 'message' in resourceErrorPayload && typeof resourceErrorPayload.message === 'string' ?
resourceErrorPayload.message : resourceErrorPayload.message :
...@@ -44,9 +45,17 @@ const Page = ({ ...@@ -44,9 +45,17 @@ const Page = ({
const isInvalidTxHash = error?.message.includes('Invalid tx hash'); const isInvalidTxHash = error?.message.includes('Invalid tx hash');
const isBlockConsensus = messageInPayload?.includes('Block lost consensus'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
const isUnverifiedEmail = statusCode === 403 && messageInPayload?.includes('Unverified email');
if (isInvalidTxHash) { 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) { if (isBlockConsensus) {
......
// import { Icon } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
// import plusIcon from 'icons/plus.svg';
import * as textAdMock from 'mocks/ad/textAd'; import * as textAdMock from 'mocks/ad/textAd';
import TestApp from 'playwright/TestApp'; 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 }) => { test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
...@@ -19,51 +19,38 @@ test.beforeEach(async({ page }) => { ...@@ -19,51 +19,38 @@ test.beforeEach(async({ page }) => {
path: './playwright/image_s.jpg', 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 }) => { test('default view +@mobile', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<PageTitle <DefaultView/>
text="Title"
/>
</TestApp>, </TestApp>,
); );
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with text ad, back link and addons +@mobile +@dark-mode', async({ mount }) => { test('with text ad +@mobile', async({ mount }) => {
// https://github.com/microsoft/playwright/issues/15620
// not possible to pass component as a prop in tests
// const left = <Icon as={ plusIcon }/>;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<PageTitle <WithTextAd/>
text="Title"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
// additionalsLeft={ left }
additionalsRight="Privet"
/>
</TestApp>, </TestApp>,
); );
await expect(component).toHaveScreenshot(); 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( const component = await mount(
<TestApp> <TestApp>
<PageTitle <LongNameAndManyTags/>
text="This title is long, really long"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
additionalsRight="Privet, kak dela?"
/>
</TestApp>, </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 React from 'react';
import eastArrowIcon from 'icons/arrows/east.svg'; import eastArrowIcon from 'icons/arrows/east.svg';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
type BackLinkProp = { label: string; url: string } | { label: string; onClick: () => void };
type Props = { type Props = {
text: string; title: string;
additionalsLeft?: React.ReactNode;
additionalsRight?: React.ReactNode;
withTextAd?: boolean;
className?: string; className?: string;
backLinkLabel?: string; backLink?: BackLinkProp;
backLinkUrl?: string; beforeTitle?: React.ReactNode;
afterTitle?: React.ReactNode;
contentAfter?: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
withTextAd?: boolean;
} }
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLinkUrl, backLinkLabel, className, isLoading }: Props) => { const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => {
const title = ( if (!props) {
<Skeleton isLoaded={ !isLoading }> return null;
<Heading }
as="h1"
size="lg" if (props.isLoading) {
flex="none" return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } isLoaded={ !props.isLoading }/>;
wordBreak="break-word" }
>
{ text } const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>;
</Heading>
</Skeleton> 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 ( return (
<Flex <Flex
columnGap={ 3 }
rowGap={ 3 }
alignItems={{ base: 'start', lg: 'center' }}
flexDirection={{ base: 'column', lg: 'row' }}
mb={ 6 }
justifyContent="space-between"
className={ className } className={ className }
> mb={ 6 }
<Flex flexWrap="wrap" columnGap={ 3 } alignItems="center" width={ withTextAd ? 'unset' : '100%' } flexShrink={ 0 }> flexDir="row"
<Grid flexWrap="wrap"
templateColumns={ [ backLinkUrl && 'auto', additionalsLeft && 'auto', '1fr' ].filter(Boolean).join(' ') } rowGap={ 3 }
columnGap={ 3 } columnGap={ 3 }
alignItems="center"
>
<Box>
{ backLink && <BackLink { ...backLink } isLoading={ isLoading }/> }
{ beforeTitle }
<Skeleton
isLoaded={ !isLoading }
display="inline"
verticalAlign={ isLoading ? 'super' : undefined }
>
<Heading
as="h1"
size="lg"
display="inline"
wordBreak="break-word"
w="100%"
> >
{ 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 } { title }
</Grid> </Heading>
{ additionalsRight } </Skeleton>
</Flex> { afterTitle }
{ withTextAd && <TextAd flexShrink={ 100 }/> } </Box>
{ contentAfter }
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto"/> }
</Flex> </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 React from 'react';
import appConfig from 'configs/app/config'; import type { TokenInfo } from 'types/api/token';
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');
return ( import appConfig from 'configs/app/config';
<Icon import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
interface Props { export interface Props {
hash?: string; data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name'>;
name?: string | null;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
} }
const TokenLogo = ({ hash, name, className, isLoading }: Props) => { const TokenLogo = ({ className, isLoading, data }: Props) => {
if (isLoading) { if (isLoading) {
return <Skeleton className={ className } borderRadius="base"/>; return <Skeleton className={ className } borderRadius="base"/>;
} }
const logoSrc = appConfig.network.assetsPathname && hash ? [ 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/', 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/',
appConfig.network.assetsPathname, appConfig.network.assetsPathname,
'/assets/', '/assets/',
hash, data.address,
'/logo.png', '/logo.png',
].join('') : undefined; ].join('');
}
})();
return ( return (
<Image <Image
borderRadius="base" borderRadius="base"
className={ className } className={ className }
src={ logoSrc } src={ logoSrc }
alt={ `${ name || 'token' } logo` } alt={ `${ data?.name || 'token' } logo` }
fallback={ <EmptyElement className={ className }/> } 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 { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/token';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import TokenSnippet from './TokenSnippet'; 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.use(devices['iPhone 13 Pro']);
test('unnamed', async({ mount }) => { 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( const component = await mount(
<TestApp> <TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" symbol="xDAI"/> <TokenSnippet data={ data }/>
</TestApp>, </TestApp>,
); );
...@@ -20,16 +31,40 @@ test('unnamed', async({ mount }) => { ...@@ -20,16 +31,40 @@ test('unnamed', async({ mount }) => {
}); });
test('named', 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( const component = await mount(
<TestApp> <TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" name="Shavuha token" symbol="SHA"/> <TokenSnippet data={ data }/>
</TestApp>, </TestApp>,
); );
await expect(component).toHaveScreenshot(); 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) => { await page.route(API_URL, (route) => {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
...@@ -39,7 +74,7 @@ test('with logo', async({ mount, page }) => { ...@@ -39,7 +74,7 @@ test('with logo', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29"/> <TokenSnippet data={ data }/>
</TestApp>, </TestApp>,
); );
......
import { Flex, Text, chakra } from '@chakra-ui/react'; import { Flex, Text, chakra } from '@chakra-ui/react';
import React from '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 AddressLink from 'ui/shared/address/AddressLink';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
interface Props { interface Props {
symbol?: string | null; data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name' | 'symbol'>;
hash: string;
name?: string | null;
className?: string; className?: string;
logoSize?: number; logoSize?: number;
isDisabled?: boolean; isDisabled?: boolean;
hideSymbol?: boolean;
} }
const TokenSnippet = ({ symbol, hash, name, className, logoSize = 6, isDisabled }: Props) => { const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol }: Props) => {
return ( return (
<Flex className={ className } alignItems="center" columnGap={ 2 } w="100%"> <Flex className={ className } alignItems="center" columnGap={ 2 } w="100%">
<TokenLogo boxSize={ logoSize } hash={ hash } name={ name }/> <TokenLogo boxSize={ logoSize } data={ data }/>
<AddressLink hash={ hash } alias={ name } type="token" isDisabled={ isDisabled }/> <AddressLink hash={ data?.address || '' } alias={ data?.name || 'Unnamed token' } type="token" isDisabled={ isDisabled }/>
{ symbol && <Text variant="secondary">({ symbol })</Text> } { data?.symbol && !hideSymbol && <Text variant="secondary">({ trimTokenSymbol(data.symbol) })</Text> }
</Flex> </Flex>
); );
}; };
......
...@@ -52,7 +52,7 @@ const TokenTransferListItem = ({ ...@@ -52,7 +52,7 @@ const TokenTransferListItem = ({
<ListItemMobile rowGap={ 3 } isAnimated> <ListItemMobile rowGap={ 3 } isAnimated>
<Flex w="100%" justifyContent="space-between"> <Flex w="100%" justifyContent="space-between">
<Flex flexWrap="wrap" rowGap={ 1 } mr={ showTxInfo && txHash ? 2 : 0 }> <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 flexShrink={ 0 } ml={ 2 } mr={ 2 }>{ token.type }</Tag>
<Tag colorScheme="orange">{ getTokenTransferTypeText(type) }</Tag> <Tag colorScheme="orange">{ getTokenTransferTypeText(type) }</Tag>
</Flex> </Flex>
......
...@@ -45,7 +45,7 @@ const TokenTransferTableItem = ({ ...@@ -45,7 +45,7 @@ const TokenTransferTableItem = ({
) } ) }
<Td> <Td>
<Flex flexDir="column" alignItems="flex-start"> <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 mt={ 1 }>{ token.type }</Tag>
<Tag colorScheme="orange" mt={ 2 }>{ getTokenTransferTypeText(type) }</Tag> <Tag colorScheme="orange" mt={ 2 }>{ getTokenTransferTypeText(type) }</Tag>
</Flex> </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 { ...@@ -8,7 +8,7 @@ interface Props extends TagProps {
isLoading?: boolean; 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.isTruncated && typeof props.children === 'string') {
if (!props.children) { if (!props.children) {
...@@ -18,16 +18,16 @@ const Tag = ({ isLoading, ...props }: Props) => { ...@@ -18,16 +18,16 @@ const Tag = ({ isLoading, ...props }: Props) => {
return ( return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%"> <Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<TruncatedTextTooltip label={ props.children }> <TruncatedTextTooltip label={ props.children }>
<ChakraTag { ...props }/> <ChakraTag { ...props } ref={ ref }/>
</TruncatedTextTooltip> </TruncatedTextTooltip>
</Skeleton> </Skeleton>
); );
} }
return ( return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%"> <Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<ChakraTag { ...props }/> <ChakraTag { ...props } ref={ ref }/>
</Skeleton> </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>> { ...@@ -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 FileInput = <Values extends FieldValues, Names extends Path<Values>>({ children, accept, multiple, field }: Props<Values, Names>) => {
const ref = React.useRef<HTMLInputElement>(null); 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>) => { const onChange = React.useCallback((files: Array<File>) => {
field.onChange([ ...(field.value || []), ...files ]); field.onChange([ ...(field.value || []), ...files ]);
}, [ field ]); }, [ field ]);
......
import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react'; import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
...@@ -35,7 +36,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => { ...@@ -35,7 +36,7 @@ const NftMedia = ({ imageUrl, animationUrl, className, isLoading }: Props) => {
return; 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) fetch(url)
.then((response) => response.json()) .then((response) => response.json())
.then((_data) => { .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 = () => { ...@@ -44,7 +44,7 @@ const Burger = () => {
<DrawerOverlay/> <DrawerOverlay/>
<DrawerContent maxWidth="260px"> <DrawerContent maxWidth="260px">
<DrawerBody p={ 6 } display="flex" flexDirection="column"> <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"> <Flex alignItems="center" justifyContent="space-between">
<NetworkLogo onClick={ handleNetworkLogoClick }/> <NetworkLogo onClick={ handleNetworkLogoClick }/>
{ appConfig.featuredNetworks ? ( { appConfig.featuredNetworks ? (
......
...@@ -3,8 +3,9 @@ import NextLink from 'next/link'; ...@@ -3,8 +3,9 @@ import NextLink from 'next/link';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
import type { NavItem } from 'types/client/navigation-items';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import type { NavItem } from 'lib/hooks/useNavItems';
import { isInternalItem } from 'lib/hooks/useNavItems'; import { isInternalItem } from 'lib/hooks/useNavItems';
import useColors from './useColors'; import useColors from './useColors';
...@@ -20,26 +21,18 @@ type Props = { ...@@ -20,26 +21,18 @@ type Props = {
const NavLink = ({ item, isCollapsed, px, className }: Props) => { const NavLink = ({ item, isCollapsed, px, className }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const colors = useColors(); 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 }); const isXLScreen = useBreakpointValue({ base: false, xl: true });
const href = isInternalLink ? route(item.nextRoute) : item.url;
let href: string| undefined;
const isInternal = isInternalItem(item);
if (isInternal) {
href = !item.isNewUi ? route(item.nextRoute) : undefined;
} else {
href = item.url;
}
const content = ( const content = (
<Link <Link
href={ href } href={ href }
target={ isInternal ? '_self' : '_blank' } target={ isInternalLink ? '_self' : '_blank' }
{ ...styleProps.itemProps } { ...styleProps.itemProps }
w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }} w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }}
display="flex" display="flex"
...@@ -54,10 +47,10 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => { ...@@ -54,10 +47,10 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => {
placement="right" placement="right"
variant="nav" variant="nav"
gutter={ 20 } 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"> <HStack spacing={ 3 } overflow="hidden">
<Icon as={ item.icon } boxSize="30px"/> { item.icon && <Icon as={ item.icon } boxSize="30px"/> }
<Text { ...styleProps.textProps }> <Text { ...styleProps.textProps }>
{ item.text } { item.text }
</Text> </Text>
...@@ -68,9 +61,7 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => { ...@@ -68,9 +61,7 @@ const NavLink = ({ item, isCollapsed, px, className }: Props) => {
return ( return (
<Box as="li" listStyleType="none" w="100%" className={ className }> <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 */ } { isInternalLink ? (
{ /* we have to hard reload page on every transition between urls from different part of the app */ }
{ isInternalItem(item) && item.isNewUi ? (
<NextLink href={ item.nextRoute } passHref legacyBehavior> <NextLink href={ item.nextRoute } passHref legacyBehavior>
{ content } { content }
</NextLink> </NextLink>
......
...@@ -12,8 +12,9 @@ import { ...@@ -12,8 +12,9 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { NavGroupItem } from 'types/client/navigation-items';
import chevronIcon from 'icons/arrows/east-mini.svg'; import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import NavLink from './NavLink'; import NavLink from './NavLink';
import useNavLinkStyleProps from './useNavLinkStyleProps'; import useNavLinkStyleProps from './useNavLinkStyleProps';
......
...@@ -7,8 +7,9 @@ import { ...@@ -7,8 +7,9 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { NavGroupItem } from 'types/client/navigation-items';
import chevronIcon from 'icons/arrows/east-mini.svg'; import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import useNavLinkStyleProps from './useNavLinkStyleProps'; import useNavLinkStyleProps from './useNavLinkStyleProps';
......
...@@ -61,7 +61,7 @@ const NavigationDesktop = () => { ...@@ -61,7 +61,7 @@ const NavigationDesktop = () => {
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }} width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
{ ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) } { ...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 <Box
as="header" as="header"
display="flex" display="flex"
......
...@@ -4,5 +4,4 @@ export interface NetworkLink { ...@@ -4,5 +4,4 @@ export interface NetworkLink {
pathname: string; pathname: string;
name: string; name: string;
icon?: FunctionComponent<SVGAttributes<SVGElement>>; icon?: FunctionComponent<SVGAttributes<SVGElement>>;
isNewUi?: boolean;
} }
...@@ -132,3 +132,39 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -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 } }); 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) => { ...@@ -24,7 +24,7 @@ const SearchBar = ({ isHomepage }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const router = useRouter(); const router = useRouter();
const { searchTerm, handleSearchTermChange, query, pathname } = useSearchQuery(); const { searchTerm, handleSearchTermChange, query, pathname, redirectCheckQuery } = useSearchQuery();
const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => { const handleSubmit = React.useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
...@@ -115,7 +115,7 @@ const SearchBar = ({ isHomepage }: Props) => { ...@@ -115,7 +115,7 @@ const SearchBar = ({ isHomepage }: Props) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }> <PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 }> <PopoverBody py={ 6 }>
<SearchBarSuggest query={ query } searchTerm={ searchTerm } onItemClick={ handleItemClick }/> <SearchBarSuggest query={ query } redirectCheckQuery={ redirectCheckQuery } searchTerm={ searchTerm } onItemClick={ handleItemClick }/>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
......
import { Text } from '@chakra-ui/react'; import { Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import _uniqBy from 'lodash/uniqBy';
import React from 'react'; 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 useIsMobile from 'lib/hooks/useIsMobile';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
...@@ -11,34 +12,86 @@ import type { Props as PaginationProps } from 'ui/shared/Pagination'; ...@@ -11,34 +12,86 @@ import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SearchBarSuggestItem from './SearchBarSuggestItem'; 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 { interface Props {
query: UseQueryResult<SearchResult> & { query: UseQueryResult<SearchResult> & {
pagination: PaginationProps; pagination: PaginationProps;
}; };
redirectCheckQuery: UseQueryResult<SearchRedirectResult>;
searchTerm: string; searchTerm: string;
onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void; onItemClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
} }
const SearchBarSuggest = ({ query, searchTerm, onItemClick }: Props) => { const SearchBarSuggest = ({ query, redirectCheckQuery, searchTerm, onItemClick }: Props) => {
const isMobile = useIsMobile(); 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 = (() => { const content = (() => {
if (query.isLoading) { if (query.isLoading && !simpleMatch) {
return <ContentLoader text="We are searching, please wait... "/>; 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>; 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 num = query.data?.next_page_params ? '50+' : items.length;
const resultText = query.data.items.length > 1 || query.pagination.page > 1 ? 'results' : 'result'; const resultText = items.length > 1 || query.pagination.page > 1 ? 'results' : 'result';
return ( return (
<> <>
<Text fontWeight={ 500 } fontSize="sm">Found <Text fontWeight={ 700 } as="span">{ num }</Text> matching { resultText }</Text> <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 }/>) } <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) { ...@@ -24,8 +24,11 @@ export default function useSearchQuery(isSearchPage = false) {
}); });
const redirectCheckQuery = useApiQuery('search_check_redirect', { const redirectCheckQuery = useApiQuery('search_check_redirect', {
queryParams: { q: q.current }, // on search result page we check redirect only once on mount
queryOptions: { enabled: isSearchPage && Boolean(q) }, // 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(() => { 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 BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
...@@ -63,13 +63,11 @@ const TokenTransferListItem = ({ ...@@ -63,13 +63,11 @@ const TokenTransferListItem = ({
</Address> </Address>
</Flex> </Flex>
{ timestamp && ( { timestamp && (
<Text variant="secondary" fontWeight="400" fontSize="sm"> <Skeleton isLoaded={ !isLoading } display="inline-block" fontWeight="400" fontSize="sm" color="text_secondary">
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span> <span>
{ timeAgo } { timeAgo }
</span> </span>
</Skeleton> </Skeleton>
</Text>
) } ) }
</Flex> </Flex>
{ method && <Tag isLoading={ isLoading }>{ method }</Tag> } { 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 = () => { ...@@ -35,8 +35,6 @@ const TokenInstanceContent = () => {
const id = router.query.id?.toString(); const id = router.query.id?.toString();
const tab = router.query.tab?.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 scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', { const tokenInstanceQuery = useApiQuery('token_instance', {
...@@ -64,6 +62,19 @@ const TokenInstanceContent = () => { ...@@ -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> = [ const tabs: Array<RoutedTab> = [
{ {
id: 'token_transfers', id: 'token_transfers',
...@@ -84,7 +95,7 @@ const TokenInstanceContent = () => { ...@@ -84,7 +95,7 @@ const TokenInstanceContent = () => {
return <TokenInstanceSkeleton/>; 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 tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = { const address = {
hash: hash || '', hash: hash || '',
...@@ -134,11 +145,10 @@ const TokenInstanceContent = () => { ...@@ -134,11 +145,10 @@ const TokenInstanceContent = () => {
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle <PageTitle
text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` } title={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined } backLink={ backLink }
backLinkLabel="Back to token page" beforeTitle={ nftShieldIcon }
additionalsLeft={ nftShieldIcon } contentAfter={ tokenTag }
additionalsRight={ tokenTag }
/> />
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/> <AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
......
...@@ -58,7 +58,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => { ...@@ -58,7 +58,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
title="Token" title="Token"
hint="Token name" hint="Token name"
> >
<TokenSnippet hash={ data.token.address } name={ data.token.name }/> <TokenSnippet data={ data.token }/>
</DetailsInfoItem> </DetailsInfoItem>
{ data.is_unique && data.owner && ( { data.is_unique && data.owner && (
<DetailsInfoItem <DetailsInfoItem
......
...@@ -47,7 +47,7 @@ const TokensTableItem = ({ ...@@ -47,7 +47,7 @@ const TokensTableItem = ({
> >
<GridItem display="flex"> <GridItem display="flex">
<Flex overflow="hidden" mr={ 3 } alignItems="center"> <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 }/> <AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
<Tag flexShrink={ 0 } ml={ 3 }>{ type }</Tag> <Tag flexShrink={ 0 } ml={ 3 }>{ type }</Tag>
</Flex> </Flex>
......
...@@ -53,7 +53,7 @@ const TokensTableItem = ({ ...@@ -53,7 +53,7 @@ const TokensTableItem = ({
</Text> </Text>
<Box> <Box>
<Flex alignItems="center"> <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 }/> <AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
</Flex> </Flex>
<Flex alignItems="center" width="136px" justifyContent="space-between" ml={ 8 } mt={ 2 }> <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'; ...@@ -2,22 +2,22 @@ import { Flex, Link, Text, Icon, Box } from '@chakra-ui/react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import React from 'react'; import React from 'react';
import type { TokenInfo } from 'types/api/token';
import nftIcon from 'icons/nft_shield.svg'; import nftIcon from 'icons/nft_shield.svg';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet'; import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
interface Props { interface Props {
token: TokenInfo;
value: string; value: string;
tokenId: 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 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 ( return (
<Flex alignItems="center" columnGap={ 3 } rowGap={ 2 } flexWrap="wrap"> <Flex alignItems="center" columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
...@@ -28,10 +28,10 @@ const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props) ...@@ -28,10 +28,10 @@ const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props)
{ tokenId.length > 8 ? <HashStringShorten hash={ tokenId }/> : tokenId } { tokenId.length > 8 ? <HashStringShorten hash={ tokenId }/> : tokenId }
</Link> </Link>
</Box> </Box>
{ name ? ( { token.name ? (
<TokenSnippet symbol={ symbol } hash={ hash } name={ name } w="auto" logoSize={ 5 } columnGap={ 1 }/> <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> </Flex>
); );
......
...@@ -7,7 +7,6 @@ import type { TxAction, TxActionGeneral } from 'types/api/txAction'; ...@@ -7,7 +7,6 @@ import type { TxAction, TxActionGeneral } from 'types/api/txAction';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import uniswapIcon from 'icons/uniswap.svg'; import uniswapIcon from 'icons/uniswap.svg';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet'; import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
...@@ -39,6 +38,20 @@ const TxDetailsAction = ({ action }: Props) => { ...@@ -39,6 +38,20 @@ const TxDetailsAction = ({ action }: Props) => {
const amount0 = BigNumber(data.amount0).toFormat(); const amount0 = BigNumber(data.amount0).toFormat();
const amount1 = BigNumber(data.amount1).toFormat(); const amount1 = BigNumber(data.amount1).toFormat();
const [ text0, text1 ] = getActionText(type); 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 ( return (
<Flex flexWrap="wrap" columnGap={ 1 } rowGap={ 2 } alignItems="center"> <Flex flexWrap="wrap" columnGap={ 1 } rowGap={ 2 } alignItems="center">
...@@ -46,8 +59,7 @@ const TxDetailsAction = ({ action }: Props) => { ...@@ -46,8 +59,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount0 }</chakra.span> <chakra.span fontWeight={ 600 }>{ amount0 }</chakra.span>
<TokenSnippet <TokenSnippet
name={ data.symbol0 === 'Ether' ? appConfig.network.currency.symbol : data.symbol0 } data={ token0 }
hash={ data.symbol0 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
w="auto" w="auto"
columnGap={ 1 } columnGap={ 1 }
logoSize={ 5 } logoSize={ 5 }
...@@ -58,8 +70,7 @@ const TxDetailsAction = ({ action }: Props) => { ...@@ -58,8 +70,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount1 }</chakra.span> <chakra.span fontWeight={ 600 }>{ amount1 }</chakra.span>
<TokenSnippet <TokenSnippet
name={ data.symbol1 === 'Ether' ? appConfig.network.currency.symbol : data.symbol1 } data={ token1 }
hash={ data.symbol1 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
w="auto" w="auto"
columnGap={ 1 } columnGap={ 1 }
logoSize={ 5 } logoSize={ 5 }
...@@ -76,14 +87,20 @@ const TxDetailsAction = ({ action }: Props) => { ...@@ -76,14 +87,20 @@ const TxDetailsAction = ({ action }: Props) => {
} }
case 'mint_nft' : { case 'mint_nft' : {
const token = {
address: data.address,
name: data.name,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
return ( return (
<div> <div>
<Flex rowGap={ 2 } flexWrap="wrap" alignItems="center" whiteSpace="pre-wrap"> <Flex rowGap={ 2 } flexWrap="wrap" alignItems="center" whiteSpace="pre-wrap">
<chakra.span>Mint of </chakra.span> <chakra.span>Mint of </chakra.span>
<TokenSnippet <TokenSnippet
name={ data.name } data={ token }
hash={ data.address }
symbol={ trimTokenSymbol(data.symbol) }
w="auto" w="auto"
columnGap={ 1 } columnGap={ 1 }
logoSize={ 5 } logoSize={ 5 }
......
...@@ -5,7 +5,6 @@ import type { TokenTransfer as TTokenTransfer, Erc20TotalPayload, Erc721TotalPay ...@@ -5,7 +5,6 @@ import type { TokenTransfer as TTokenTransfer, Erc20TotalPayload, Erc721TotalPay
import rightArrowIcon from 'icons/arrows/east.svg'; import rightArrowIcon from 'icons/arrows/east.svg';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet'; import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
...@@ -27,9 +26,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => { ...@@ -27,9 +26,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
<CurrencyValue value={ total.value } exchangeRate={ data.token.exchange_rate } fontWeight={ 600 } decimals={ total.decimals }/> <CurrencyValue value={ total.value } exchangeRate={ data.token.exchange_rate } fontWeight={ 600 } decimals={ total.decimals }/>
</Text> </Text>
<TokenSnippet <TokenSnippet
symbol={ trimTokenSymbol(data.token.symbol) } data={ data.token }
hash={ data.token.address }
name={ data.token.name }
w="auto" w="auto"
flexGrow="1" flexGrow="1"
columnGap={ 1 } columnGap={ 1 }
...@@ -43,11 +40,9 @@ const TxDetailsTokenTransfer = ({ data }: Props) => { ...@@ -43,11 +40,9 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc721TotalPayload; const total = data.total as Erc721TotalPayload;
return ( return (
<NftTokenTransferSnippet <NftTokenTransferSnippet
name={ data.token.name } token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
value="1" value="1"
hash={ data.token.address }
symbol={ trimTokenSymbol(data.token.symbol) }
/> />
); );
} }
...@@ -56,12 +51,10 @@ const TxDetailsTokenTransfer = ({ data }: Props) => { ...@@ -56,12 +51,10 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc1155TotalPayload; const total = data.total as Erc1155TotalPayload;
return ( return (
<NftTokenTransferSnippet <NftTokenTransferSnippet
name={ data.token.name }
key={ total.token_id } key={ total.token_id }
token={ data.token }
tokenId={ total.token_id } tokenId={ total.token_id }
value={ total.value } 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'; ...@@ -14,14 +14,19 @@ import TokenLogo from 'ui/shared/TokenLogo';
const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => { const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const infoItemsPaddingLeft = { base: 1, lg: 8 }; const infoItemsPaddingLeft = { base: 1, lg: 8 };
const nativeTokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return ( return (
<VStack spacing={ 2 } align="stretch" fontWeight={ 500 }> <VStack spacing={ 2 } align="stretch" fontWeight={ 500 }>
<AddressSnippet address={ item.address }/> <AddressSnippet address={ item.address }/>
<Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }> <Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }>
{ appConfig.network.currency.address && ( { appConfig.network.currency.address && (
<TokenLogo <TokenLogo
hash={ appConfig.network.currency.address } data={ nativeTokenData }
name={ appConfig.network.name }
boxSize={ 4 } boxSize={ 4 }
borderRadius="sm" borderRadius="sm"
mr={ 2 } 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