Commit fbffd21e authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #760 from blockscout/feat/verified-tokens

🐳 Feature: verified tokens
parents de3956e2 e6f5c96b
......@@ -56,6 +56,8 @@ NEXT_PUBLIC_API_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PORT__
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL__
NEXT_PUBLIC_STATS_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_STATS_API_HOST__
NEXT_PUBLIC_VISUALIZE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_VISUALIZE_API_HOST__
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_CONTRACT_INFO_API_HOST__
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=__PLACEHOLDER_FOR_NEXT_PUBLIC_ADMIN_SERVICE_API_HOST__
NEXT_PUBLIC_API_SPEC_URL=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_SPEC_URL__
# external services config
......
name: Checks
on:
workflow_dispatch:
pull_request:
push:
branches:
......@@ -9,6 +10,7 @@ jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps:
- name: Checkout repo
uses: actions/checkout@v3
......@@ -30,6 +32,7 @@ jobs:
type_check:
name: TypeScript
runs-on: ubuntu-latest
if: "!contains(github.event.pull_request.labels.*.name, 'WIP')"
steps:
- name: Checkout repo
uses: actions/checkout@v3
......
{
"typescript.tsdk": "node_modules/typescript/lib"
}
\ No newline at end of file
......@@ -144,6 +144,14 @@ const config = Object.freeze({
endpoint: getEnvValue(process.env.NEXT_PUBLIC_VISUALIZE_API_HOST),
basePath: '',
},
contractInfoApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_CONTRACT_INFO_API_HOST),
basePath: '',
},
adminServiceApi: {
endpoint: getEnvValue(process.env.NEXT_PUBLIC_ADMIN_SERVICE_API_HOST),
basePath: '',
},
homepage: {
charts: parseEnvJson<Array<ChainIndicatorId>>(getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_CHARTS)) || [],
plate: {
......
......@@ -13,5 +13,7 @@ NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout
# api config
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_STATS_API_HOST=https://stats-test.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs-test.k8s-dev.blockscout.com
# ui config
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
# network config
......
......@@ -2,7 +2,7 @@
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address','block':'/ethereum/poa/core/block'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
#NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND='no-repeat bottom 20% right 0px/100% url(https://neon-labs.org/images/index/banner.jpg)'
#NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR=\#DCFE76
......
......@@ -11,7 +11,7 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=https://t.me/poa_network
NEXT_PUBLIC_FOOTER_STAKING_LINK=https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/transaction','address':'/ethereum/poa/core/address'}}]
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs','coin_price','market_cap']
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
......@@ -42,5 +42,7 @@ NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://localhost:3000/marketplace-submit-fo
# api config
NEXT_PUBLIC_API_HOST=https://localhost:3003
NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_API_BASE_PATH=/
......@@ -401,6 +401,10 @@ frontend:
_default: true
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -299,12 +299,12 @@ frontend:
NEXT_PUBLIC_FEATURED_NETWORKS:
_default: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-goerli.json
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
_default: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
# network config
NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum
_default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli
_default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum
NEXT_PUBLIC_NETWORK_LOGO:
......@@ -343,9 +343,13 @@ frontend:
NEXT_PUBLIC_API_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_STATS_API_HOST:
_default: https://stats-test.aws-k8s.blockscout.com/
_default: https://stats-test.aws-k8s.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_APP_HOST:
_default: blockscout.com/eth/goerli
NEXT_PUBLIC_LOGOUT_URL:
......
......@@ -129,6 +129,10 @@ frontend:
_default: true
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer-optimism-goerli.test.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_IS_L2_NETWORK:
_default: "true"
NEXT_PUBLIC_L1_BASE_URL:
......
......@@ -39,6 +39,7 @@ frontend:
- "/csv-export"
- "/verified-contracts"
- "/graphiql"
- "/login"
resources:
limits:
......@@ -67,9 +68,9 @@ frontend:
NEXT_PUBLIC_FOOTER_STAKING_LINK:
_default: https://duneanalytics.com/maxaleks/xdai-staking
NEXT_PUBLIC_NETWORK_NAME:
_default: Ethereum
_default: Göerli
NEXT_PUBLIC_NETWORK_SHORT_NAME:
_default: Goerli
_default: Göerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME:
_default: ethereum
NEXT_PUBLIC_NETWORK_LOGO:
......@@ -97,6 +98,10 @@ frontend:
_default: https://stats-test.aws-k8s.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST:
_default: https://visualizer.aws-k8s.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST:
_default: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST:
_default: https://admin-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_AUTH_URL:
_default: https://blockscout-main.test.aws-k8s.blockscout.com
NEXT_PUBLIC_API_BASE_PATH:
......@@ -115,7 +120,7 @@ frontend:
NEXT_PUBLIC_NETWORK_RPC_URL:
_default: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS:
_default: "[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]"
_default: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
NEXT_PUBLIC_API_SPEC_URL:
_default: https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL:
......
......@@ -104,7 +104,7 @@ For each application, you need to specify the `MarketplaceCategoryId` to which i
| --- | --- | --- | --- | --- | --- |
| title | `string` | Displayed name of the explorer | yes | - | `Anyblock` |
| baseUrl | `string` | Base url of the explorer | yes | - | `https://explorer.anyblock.tools` |
| paths | `Record<'tx' \| 'block' \| 'address', string>` | Map of explorer entities and their paths | yes | - | `{'tx':'/ethereum/poa/core/tx'}` |
| paths | `Record<'tx' \| 'block' \| 'address' \| 'token', string>` | Map of explorer entities and their paths | yes | - | `{'tx':'/ethereum/poa/core/tx'}` |
*Note* The url of an entity will be constructed as `<baseUrl><paths[<entity-type>]><entity-id>`, e.g `https://explorer.anyblock.tools/ethereum/poa/core/tx/<tx-id>`
......@@ -129,6 +129,8 @@ For each application, you need to specify the `MarketplaceCategoryId` to which i
| NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL | `ws \| wss` | Main API websocket protocol | - | `wss` | `ws` |
| NEXT_PUBLIC_STATS_API_HOST | `string` | Stats API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_VISUALIZE_API_HOST | `string` | Visualize API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_CONTRACT_INFO_API_HOST | `string` | Contract Info API endpoint url | - | - | `https://my-host.com` |
| NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | `string` | Admin Service API endpoint url | - | - | `https://my-host.com` |
## External services configuration
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.26 17.5H4.027a.47.47 0 0 1-.47-.469V5.312c0-.26.21-.469.47-.469h2.137V2.969c0-.259.21-.469.468-.469h5.89l.015.003.014.003a.449.449 0 0 1 .14.03.463.463 0 0 1 .156.095l3.452 3.31-.002.002a.466.466 0 0 1 .146.337v7.3c0 .87-.708 1.577-1.577 1.577h-1.029v.766c0 .87-.707 1.577-1.577 1.577Zm2.823-11.69L12.99 3.794v1.379a.64.64 0 0 0 .638.639h1.454Zm-2.755-2.648H6.826v11.333h8.316a.64.64 0 0 0 .639-.64V6.474H13.63c-.87 0-1.302-.432-1.302-1.301v-2.01ZM6.164 5.505H4.22v11.333h8.316c.352 0 .639-.563.639-.915v-.766H6.632a.469.469 0 0 1-.468-.469V5.505ZM8.85 8.5a.25.25 0 1 0 0 .5h4.7a.25.25 0 1 0 0-.5h-4.7Zm-.25 1.95a.25.25 0 0 1 .25-.25h4.7a.25.25 0 1 1 0 .5h-4.7a.25.25 0 0 1-.25-.25ZM8.85 12a.25.25 0 1 0 0 .5h4.7a.25.25 0 1 0 0-.5h-4.7Z" fill="currentColor"/>
<path d="M6.164 4.843v.35h.35v-.35h-.35Zm6.373-2.34-.098.336h.003l.095-.336Zm0 0 .099-.336h-.003l-.095.336Zm.014.003.02-.35-.02.35Zm.123.025.12-.33h-.004l-.115.33Zm.017.005.101-.335-.101.335Zm.025.009.145-.319h-.002l-.143.319Zm.13.086.243-.253-.243.252ZM16.3 5.94l.247.248.253-.253-.258-.247-.242.252Zm-.002.002-.247-.248-.252.252.257.248.242-.252Zm-2.46 9.214v-.35h-.35v.35h.35Zm-.846-11.364.242-.252-.593-.572v.824h.35Zm2.092 2.018v.35h.867l-.624-.602-.243.252ZM6.826 3.162v-.35h-.35v.35h.35Zm5.502 0h.35v-.35h-.35v.35ZM6.826 14.495h-.35v.35h.35v-.35Zm8.955-8.022h.35v-.35h-.35v.35ZM4.22 5.505v-.35h-.35v.35h.35Zm1.944 0h.35v-.35h-.35v.35ZM4.22 16.838h-.35v.35h.35v-.35Zm8.955-1.68h.35v-.35h-.35v.35ZM4.027 17.85h8.233v-.7H4.027v.7Zm-.82-.819a.82.82 0 0 0 .82.819v-.7a.12.12 0 0 1-.12-.119h-.7Zm0-11.719v11.719h.7V5.312h-.7Zm.82-.819a.82.82 0 0 0-.82.819h.7a.12.12 0 0 1 .12-.119v-.7Zm2.137 0H4.027v.7h2.137v-.7Zm-.35-1.524v1.874h.7V2.969h-.7Zm.818-.819a.819.819 0 0 0-.818.819h.7c0-.066.053-.119.118-.119v-.7Zm5.89 0h-5.89v.7h5.89v-.7Zm.114.017s-.015-.005-.033-.008a.389.389 0 0 0-.081-.009v.7a.313.313 0 0 1-.065-.007c-.013-.002-.022-.006-.018-.004l.197-.672Zm-.003 0-.191.673.191-.674Zm-.063-.01a.308.308 0 0 1 .053.007l.013.003-.197.672.027.007a.39.39 0 0 0 .066.01l.038-.7Zm.22.043a.8.8 0 0 0-.218-.044l-.041.7a.1.1 0 0 1 .028.005l.231-.66Zm.002 0 .001.001-.237.659c.015.005.029.01.033.01l.203-.67Zm.067.025c-.032-.014-.061-.022-.067-.024l-.203.67h.004-.002a.208.208 0 0 1-.018-.007l.286-.639Zm.23.153a.812.812 0 0 0-.228-.152l-.29.637c.022.01.03.017.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.09c0-.236-.101-.443-.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 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<g fill="currentColor" clip-path="url(#explorer_svg__a)">
<path d="M14.192 5.808a.624.624 0 0 0-.64-.15L7.927 7.531a.625.625 0 0 0-.395.395l-1.875 5.625a.625.625 0 0 0 .79.791l5.626-1.875a.626.626 0 0 0 .395-.395l1.875-5.625a.625.625 0 0 0-.151-.64Zm-6.954 6.954L8.62 8.619l2.762 2.762-4.143 1.38Z"/>
<path d="M10 18.75A8.75 8.75 0 1 1 18.75 10 8.76 8.76 0 0 1 10 18.75ZM10 2.5a7.5 7.5 0 1 0 7.5 7.5A7.509 7.509 0 0 0 10 2.5Z"/>
</g>
<defs>
<clipPath id="explorer_svg__a">
<path fill="currentColor" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#link_svg__a)">
<path d="M5.547 6.073c1.303-1.303 3.469-1.303 4.772 0 1.171 1.172 1.345 3.04.382 4.387l-.026.038a.75.75 0 0 1-1.221-.872l.026-.038a1.89 1.89 0 0 0-2.874-2.435l-2.63 2.632c-.738.717-.738 1.934 0 2.672a1.887 1.887 0 0 0 2.433.202l.038-.047a.772.772 0 0 1 1.045.194.75.75 0 0 1-.173 1.048l-.038.026a3.389 3.389 0 0 1-4.366-5.154l2.632-2.653Zm6.914 5.833A3.388 3.388 0 0 1 7.307 7.54l.026-.038c.22-.335.689-.415 1.045-.173.338.22.417.689.176 1.045l-.026.038c-.537.731-.452 1.781.202 2.435a1.893 1.893 0 0 0 2.671 0l2.63-2.632c.738-.738.738-1.955 0-2.672a1.887 1.887 0 0 0-2.433-.202l-.037.026c-.338.242-.806.143-1.046-.174a.749.749 0 0 1 .174-1.046l.037-.026a3.389 3.389 0 0 1 4.367 5.153l-2.632 2.632Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="link_svg__a">
<path fill="#fff" transform="translate(1.504 3)" d="M0 0h15v12H0z"/>
</clipPath>
</defs>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.17 3a5.073 5.073 0 0 0-3.568 1.424l-.01.01-1.401 1.393a1 1 0 0 0 1.41 1.419l1.396-1.388a3.073 3.073 0 0 1 4.346 4.344l-2.437 2.438a3.072 3.072 0 0 1-4.635-.332 1 1 0 1 0-1.601 1.198 5.074 5.074 0 0 0 7.65.548l2.444-2.444.012-.012A5.073 5.073 0 0 0 16.17 3Zm-5.34 5.657a5.073 5.073 0 0 0-3.95 1.474l-2.444 2.444-.012.012a5.073 5.073 0 0 0 7.174 7.174l.012-.013 1.393-1.393a1 1 0 1 0-1.414-1.414l-1.387 1.387a3.073 3.073 0 0 1-4.345-4.346l2.437-2.437a3.072 3.072 0 0 1 4.635.332 1 1 0 0 0 1.601-1.198 5.074 5.074 0 0 0-3.7-2.022Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 9.375a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" stroke="currentColor" stroke-width="1.4" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.25 9.938c-1.188.937-2.125 2.25-2.5 3.812h1.438c.937 0 1.625-.875 1.375-1.75a9.626 9.626 0 0 1-.375-2.688C6.25 5.876 7.813 2.939 10 1.875c2.188 1.063 3.75 4 3.75 7.375 0 .938-.125 1.875-.375 2.688-.25.874.438 1.75 1.375 1.75h1.5c-.375-1.563-1.313-2.938-2.5-3.813M10 13.75v5M8.125 15v2.5M11.875 15v2.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.029 1.255 1.384 2.014 3.002 2.301 4.83.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-.575ZM17.3 11.87a.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.57c.069.032.146.036.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.119c1.305 0 2.556-.52 3.479-1.443ZM12.476 10.704l-.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-.17c.005-.328.099-.648.271-.927a.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.27-.472.202-.944.329-1.416.38.533-.32.893-.772 1.081-1.355a4.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.816c-.224.38-.335.794-.335 1.24a2.456 2.456 0 0 0 1.096 2.048 2.44 2.44 0 0 1-1.112-.312v.031c0 .594.187 1.115.56 1.564.373.45.844.732 1.412.849a2.542 2.542 0 0 1-.647.084c-.142 0-.297-.013-.464-.038.157.492.446.897.868 1.214a2.4 2.4 0 0 0 1.431.49 4.816 4.816 0 0 1-3.053 1.051c-.218 0-.416-.01-.594-.03a6.83 6.83 0 0 0 3.777 1.104 7.19 7.19 0 0 0 2.459-.415c.766-.277 1.421-.647 1.964-1.112a7.434 7.434 0 0 0 1.405-1.602 7.138 7.138 0 0 0 .88-1.892 6.983 6.983 0 0 0 .282-2.295Z" fill="currentColor"/>
</svg>
import type { UserInfo, CustomAbis, PublicTags, AddressTags, TransactionTags, ApiKeys, WatchlistAddress } from 'types/api/account';
import type {
UserInfo,
CustomAbis,
PublicTags,
AddressTags,
TransactionTags,
ApiKeys,
WatchlistAddress,
VerifiedAddressResponse,
TokenInfoApplicationConfig,
TokenInfoApplications,
} from 'types/api/account';
import type {
Address,
AddressCounters,
......@@ -36,6 +47,7 @@ import type {
TokenInventoryResponse,
TokenInstance,
TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token';
import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
......@@ -88,6 +100,35 @@ export const RESOURCES = {
pathParams: [ 'id' as const ],
},
// ACCOUNT: ADDRESS VERIFICATION & TOKEN INFO
address_verification: {
path: '/api/v1/chains/:chainId/verified-addresses:type',
pathParams: [ 'chainId' as const, 'type' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
verified_addresses: {
path: '/api/v1/chains/:chainId/verified-addresses',
pathParams: [ 'chainId' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
token_info_applications_config: {
path: '/api/v1/chains/:chainId/token-info-submissions/selectors',
pathParams: [ 'chainId' as const ],
endpoint: appConfig.adminServiceApi.endpoint,
basePath: appConfig.adminServiceApi.basePath,
},
token_info_applications: {
path: '/api/v1/chains/:chainId/token-info-submissions/:id?',
pathParams: [ 'chainId' as const, 'id' as const ],
endpoint: appConfig.adminServiceApi.endpoint,
basePath: appConfig.adminServiceApi.basePath,
},
// STATS
stats_counters: {
path: '/api/v1/counters',
......@@ -309,6 +350,12 @@ export const RESOURCES = {
path: '/api/v2/tokens/:hash',
pathParams: [ 'hash' as const ],
},
token_verified_info: {
path: '/api/v1/chains/:chainId/token-infos/:hash',
pathParams: [ 'chainId' as const, 'hash' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
token_counters: {
path: '/api/v2/tokens/:hash/counters',
pathParams: [ 'hash' as const ],
......@@ -524,6 +571,9 @@ Q extends 'private_tags_address' ? AddressTags :
Q extends 'private_tags_tx' ? TransactionTags :
Q extends 'api_keys' ? ApiKeys :
Q extends 'watchlist' ? Array<WatchlistAddress> :
Q extends 'verified_addresses' ? VerifiedAddressResponse :
Q extends 'token_info_applications_config' ? TokenInfoApplicationConfig :
Q extends 'token_info_applications' ? TokenInfoApplications :
Q extends 'homepage_stats' ? HomeStats :
Q extends 'homepage_chart_txs' ? ChartTransactionResponse :
Q extends 'homepage_chart_market' ? ChartMarketResponse :
......@@ -561,6 +611,7 @@ Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'address_withdrawals' ? AddressWithdrawalsResponse :
Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders :
......
import React from 'react';
import appConfig from 'configs/app/config';
import isNeedProxy from 'lib/api/isNeedProxy';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
......@@ -27,7 +27,7 @@ export default function useApiFetch() {
url,
{
credentials: 'include',
...(resource.endpoint && appConfig.host === 'localhost' ? {
...(resource.endpoint && isNeedProxy() ? {
headers: {
'x-endpoint': resource.endpoint,
},
......
......@@ -33,6 +33,8 @@ export function app(): CspDev.DirectiveDescriptor {
appConfig.api.socket,
appConfig.statsApi.endpoint,
appConfig.visualizeApi.endpoint,
appConfig.contractInfoApi.endpoint,
appConfig.adminServiceApi.endpoint,
// chain RPC server
appConfig.network.rpcUrl,
......
......@@ -211,7 +211,14 @@ export default function useNavItems(): ReturnType {
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',
isNewUi: true,
},
].filter(Boolean);
const profileItem = {
text: 'My profile',
......
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import useLoginUrl from 'lib/hooks/useLoginUrl';
export default function useRedirectIfNotAuth() {
const queryClient = useQueryClient();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
return React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
return true;
}
return false;
}, [ isAuth, loginUrl ]);
}
import appConfig from 'configs/app/config';
import { getServerSideProps as base } from '../getServerSideProps';
export const getServerSideProps: typeof base = async(...args) => {
if (!appConfig.isAccountSupported) {
return {
notFound: true,
};
}
return base(...args);
};
export const getServerSidePropsForVerifiedAddresses: typeof base = async(...args) => {
if (!appConfig.isAccountSupported || !appConfig.adminServiceApi.endpoint || !appConfig.contractInfoApi.endpoint) {
return {
notFound: true,
};
}
return base(...args);
};
export default function shortenString(string: string | null) {
if (!string) {
return '';
}
if (string.length <= 7) {
return string;
}
return string.slice(0, 4) + '...' + string.slice(-4);
}
export const EMAIL_REGEXP = /^[\w.%+-]+@[a-zA-Z\d-]+(?:\.[a-zA-Z\d-]+)+$/;
export const validator = (value: string) => EMAIL_REGEXP.test(value) ? true : 'Invalid email';
export const SIGNATURE_REGEXP = /^0x[a-fA-F\d]{130}$/;
export const validator = (value: string | undefined) => {
if (!value) {
return true;
}
try {
new URL(value);
return true;
} catch (error) {
return 'Incorrect URL';
}
};
import type { TokenInfoApplication, TokenInfoApplications, VerifiedAddress, VerifiedAddressResponse } from 'types/api/account';
import type { AddressValidationResponseSuccess } from 'ui/addressVerification/types';
export const SIGNATURE = '0x96491e0cd1b99c14951552361b7f6ff64f41651b5d1c12501914342c8a6847e21e08726c3505e11ba2af9a40ac0b05c8d113e7fd1f74594224b9c7276ebb3a661b';
export const VERIFIED_ADDRESS: Record<string, VerifiedAddress> = {
NEW_ITEM: {
userId: '1',
chainId: '99',
contractAddress: '0xF822070D07067D1519490dBf49448a7E30EE9ea5',
verifiedDate: '2022-09-01',
metadata: {
tokenName: 'Test Token',
tokenSymbol: 'TT',
},
},
ITEM_1: {
userId: '1',
chainId: '99',
contractAddress: '0xd0e3010d1ecdbd17aae178b2bf36eb413d8a7441',
verifiedDate: '2022-08-01',
metadata: {
tokenName: 'My Token',
tokenSymbol: 'MYT',
},
},
ITEM_2: {
userId: '1',
chainId: '99',
contractAddress: '0xa8FCe579a11E551635b9c9CB915BEcd873C51254',
verifiedDate: '2022-09-23',
metadata: {
tokenName: 'Cat Token',
tokenSymbol: 'CATT',
},
},
};
export const ADDRESS_CHECK_RESPONSE = {
SUCCESS: {
status: 'SUCCESS',
result: {
// eslint-disable-next-line max-len
signingMessage: '[eth-goerli.blockscout.com] [2023-04-18 18:47:40] I, hereby verify that I am the owner/creator of the address [0xf822070d07067d1519490dbf49448a7e30ee9ea5]',
contractCreator: '0xd0e3010d1ecdbd17aae178b2bf36eb413d8a7441',
contractOwner: '0xa8FCe579a11E551635b9c9CB915BEcd873C51254',
},
},
SOURCE_CODE_NOT_VERIFIED_ERROR: {
status: 'SOURCE_CODE_NOT_VERIFIED_ERROR',
},
};
export const ADDRESS_VERIFY_RESPONSE: Record<string, AddressValidationResponseSuccess> = {
SUCCESS: {
status: 'SUCCESS',
result: {
verifiedAddress: VERIFIED_ADDRESS.NEW_ITEM,
},
},
INVALID_SIGNER_ERROR: {
status: 'INVALID_SIGNER_ERROR',
invalidSigner: {
signer: '0xF822070D07067D1519490dBf49448a7E30EE9ea5',
},
},
};
export const VERIFIED_ADDRESS_RESPONSE: Record<string, VerifiedAddressResponse> = {
DEFAULT: {
verifiedAddresses: [
VERIFIED_ADDRESS.ITEM_1,
VERIFIED_ADDRESS.ITEM_2,
],
},
};
export const TOKEN_INFO_APPLICATION_BASE = {
id: '1',
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
status: 'APPROVED',
updatedAt: '2022-11-08 12:47:10.149148Z',
requesterName: 'Tom',
requesterEmail: 'tom@example.com',
projectName: 'My project',
projectWebsite: 'http://example.com',
projectEmail: 'token@example.com',
iconUrl: 'https://placekitten.com/100',
projectDescription: 'description',
projectSector: 'DeFi',
comment: '',
docs: 'https://example.com/docs',
github: 'https://github.com',
telegram: 'https://telegram.com',
linkedin: 'https://linkedin.com',
discord: 'https://discord.com',
slack: 'https://slack.com',
twitter: 'https://twitter.com',
openSea: 'https://opensea.com',
facebook: 'https://facebook.com',
medium: 'https://medium.com',
reddit: 'https://reddit.com',
support: 'support@example.com',
coinMarketCapTicker: 'https://coinmarketcap.com',
coinGeckoTicker: 'https://coingecko.com',
defiLlamaTicker: 'https://defillama.com',
};
export const TOKEN_INFO_APPLICATION: Record<string, TokenInfoApplication> = {
APPROVED: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
id: '1',
status: 'APPROVED',
updatedAt: '2022-11-08 12:47:10.149148Z',
},
IN_PROCESS: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_2.contractAddress,
id: '2',
status: 'IN_PROCESS',
updatedAt: '2022-11-10 08:11:10.149148Z',
},
UPDATED_ITEM: {
...TOKEN_INFO_APPLICATION_BASE,
tokenAddress: VERIFIED_ADDRESS.ITEM_1.contractAddress,
id: '1',
status: 'IN_PROCESS',
updatedAt: '2022-11-11 05:11:10.149148Z',
},
};
export const TOKEN_INFO_APPLICATIONS_RESPONSE: Record<string, TokenInfoApplications> = {
DEFAULT: {
submissions: [
TOKEN_INFO_APPLICATION.APPROVED,
TOKEN_INFO_APPLICATION.IN_PROCESS,
],
},
FOR_UPDATE: {
submissions: [
{
...TOKEN_INFO_APPLICATION.APPROVED,
status: 'UPDATE_REQUIRED',
},
TOKEN_INFO_APPLICATION.IN_PROCESS,
],
},
};
export const TOKEN_INFO_FORM_CONFIG = {
projectSectors: [
'Infra & Dev tooling',
'DeFi',
'Data',
'Bridge',
'NFT',
'Payments',
'Faucet',
'DAO',
'Games',
'Wallet',
],
};
......@@ -9,6 +9,7 @@ export const tokenInfo: TokenInfo = {
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '1235',
icon_url: null,
};
export const tokenCounters: TokenCounters = {
......@@ -25,6 +26,7 @@ export const tokenInfoERC20a: TokenInfo = {
symbol: 'HyFi',
total_supply: '369000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20b: TokenInfo = {
......@@ -36,6 +38,7 @@ export const tokenInfoERC20b: TokenInfo = {
symbol: 'USDC',
total_supply: '900000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20c: TokenInfo = {
......@@ -47,6 +50,7 @@ export const tokenInfoERC20c: TokenInfo = {
symbol: 'ETH',
total_supply: '1000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20d: TokenInfo = {
......@@ -58,6 +62,7 @@ export const tokenInfoERC20d: TokenInfo = {
symbol: 'ZETA',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC20LongSymbol: TokenInfo = {
......@@ -69,6 +74,7 @@ export const tokenInfoERC20LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const tokenInfoERC721a: TokenInfo = {
......@@ -80,6 +86,7 @@ export const tokenInfoERC721a: TokenInfo = {
symbol: 'HYFI_ATHENA',
total_supply: '105',
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721b: TokenInfo = {
......@@ -91,6 +98,7 @@ export const tokenInfoERC721b: TokenInfo = {
symbol: 'WOWG',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721c: TokenInfo = {
......@@ -102,6 +110,7 @@ export const tokenInfoERC721c: TokenInfo = {
symbol: 'PUMA',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC721LongSymbol: TokenInfo = {
......@@ -113,6 +122,7 @@ export const tokenInfoERC721LongSymbol: TokenInfo = {
symbol: 'ipfs://QmUpFUfVKDCWeZQk5pvDFUxnpQP9N6eLSHhNUy49T1JVtY',
total_supply: null,
type: 'ERC-721',
icon_url: null,
};
export const tokenInfoERC1155a: TokenInfo = {
......@@ -124,6 +134,7 @@ export const tokenInfoERC1155a: TokenInfo = {
symbol: 'HYFI_MEMBERSHIP',
total_supply: '482',
type: 'ERC-1155',
icon_url: null,
};
export const tokenInfoERC1155b: TokenInfo = {
......@@ -135,6 +146,7 @@ export const tokenInfoERC1155b: TokenInfo = {
symbol: 'WVC',
total_supply: '4943',
type: 'ERC-1155',
icon_url: null,
};
export const tokenInfoERC1155WithoutName: TokenInfo = {
......@@ -146,4 +158,5 @@ export const tokenInfoERC1155WithoutName: TokenInfo = {
symbol: null,
total_supply: '482',
type: 'ERC-1155',
icon_url: null,
};
......@@ -30,6 +30,7 @@ export const erc20: TokenTransfer = {
symbol: 'ARIA',
type: 'ERC-20',
total_supply: '0',
icon_url: null,
},
total: {
decimals: '18',
......@@ -73,6 +74,7 @@ export const erc721: TokenTransfer = {
symbol: 'AriaSA',
type: 'ERC-721',
total_supply: '0',
icon_url: null,
},
total: {
token_id: '875879856',
......@@ -115,6 +117,7 @@ export const erc1155A: TokenTransfer = {
symbol: 'MY_SYMBOL_IS_VERY_LONG',
type: 'ERC-1155',
total_supply: '0',
icon_url: null,
},
total: {
token_id: '123',
......
......@@ -31,6 +31,7 @@ export const mintToken: TxStateChange = {
symbol: 'nMOONBIRD',
total_supply: '10645',
type: 'ERC-721',
icon_url: null,
},
type: 'token',
};
......@@ -66,6 +67,7 @@ export const receiveMintedToken: TxStateChange = {
symbol: 'nMOONBIRD',
total_supply: '10645',
type: 'ERC-721',
icon_url: null,
},
type: 'token',
};
......
......@@ -17,4 +17,4 @@ const ApiKeysPage: NextPage = () => {
export default ApiKeysPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -17,4 +17,4 @@ const CustomAbiPage: NextPage = () => {
export default CustomAbiPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -17,4 +17,4 @@ const PublicTagsPage: NextPage = () => {
export default PublicTagsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -17,4 +17,4 @@ const AddressTagsPage: NextPage = () => {
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';
......@@ -19,4 +19,4 @@ const WatchListPage: NextPage = () => {
export default WatchListPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -15,7 +15,7 @@ const APIDocsPage: NextPage = () => {
return (
<Page>
<PageTitle text="API Documentation"/>
<PageTitle title="API Documentation"/>
<Head><title>{ `API for the ${ networkTitle }` }</title></Head>
<SwaggerUI/>
</Page>
......
......@@ -9,7 +9,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const MarketplacePage: NextPage = () => {
return (
<Page>
<PageTitle text="Marketplace"/>
<PageTitle title="Marketplace"/>
<Head><title>Blockscout | Marketplace</title></Head>
<Marketplace/>
......
......@@ -15,4 +15,4 @@ const MyProfilePage: NextPage = () => {
export default MyProfilePage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export { getServerSideProps } from 'lib/next/account/getServerSideProps';
......@@ -16,7 +16,7 @@ const GraphiqlPage: NextPage = () => {
return (
<Page>
<Head><title>Graph Page</title></Head>
<PageTitle text="GraphQL playground"/>
<PageTitle title="GraphQL playground"/>
<GraphQL/>
</Page>
);
......
......@@ -7,7 +7,7 @@ interface Env {
}
// keep in mind that all passed variables here should be present in env config files (.env.pw or .env.poa)
export default function contextWithEnvs(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
export default function contextWithEnvsFixture(envs: Array<Env>): Parameters<typeof test.extend>[0]['context'] {
return async({ browser }, use) => {
const context = await createContextWithEnvs(browser, envs);
......
......@@ -14,6 +14,7 @@ export const TOKEN_INFO_ERC_20: TokenInfo<'ERC-20'> = {
symbol: 'STUB',
total_supply: '6000000000000000000',
type: 'ERC-20',
icon_url: null,
};
export const TOKEN_INFO_ERC_721: TokenInfo<'ERC-721'> = {
......
......@@ -50,6 +50,16 @@ const colors = {
'800': 'RGBA(16, 17, 18, 0.80)',
'900': 'RGBA(16, 17, 18, 0.92)',
},
github: '#171923',
telegram: '#2775CA',
linkedin: '#1564BA',
discord: '#9747FF',
slack: '#1BA27A',
twitter: '#63B3ED',
opensea: '#2081E2',
facebook: '#4460A0',
medium: '#231F20',
reddit: '#FF4500',
};
export default colors;
import { theme } from '@chakra-ui/react';
export const BODY_TYPEFACE = 'Inter';
export const HEADING_TYPEFACE = 'Poppins';
const typography = {
fonts: {
heading: `Poppins, ${ theme.fonts.heading }`,
heading: `${ HEADING_TYPEFACE }, ${ theme.fonts.heading }`,
body: `${ BODY_TYPEFACE }, ${ theme.fonts.body }`,
},
textStyles: {
......
......@@ -3,7 +3,6 @@ const zIndices = {
auto: 'auto',
base: 0,
docked: 10,
tooltip: 900,
dropdown: 1000,
sticky: 1100,
sticky1: 1101,
......@@ -12,6 +11,7 @@ const zIndices = {
overlay: 1300,
modal: 1400,
popover: 1500,
tooltip: 1550, // otherwise tooltips will not be visible in modals
skipLink: 1600,
toast: 1700,
};
......
......@@ -160,3 +160,58 @@ export type PublicTagErrors = {
full_name: Array<string>;
tags: Array<string>;
}
export interface VerifiedAddress {
userId: string;
chainId: string;
contractAddress: string;
verifiedDate: string;
metadata: {
tokenName: string | null;
tokenSymbol: string | null;
};
}
export interface VerifiedAddressResponse {
verifiedAddresses: Array<VerifiedAddress>;
}
export interface TokenInfoApplicationConfig {
projectSectors: Array<string>;
}
export interface TokenInfoApplication {
adminComments?: string;
coinGeckoTicker?: string;
coinMarketCapTicker?: string;
comment?: string;
defiLlamaTicker?: string;
discord?: string;
docs?: string;
facebook?: string;
github?: string;
iconUrl: string;
id: string;
linkedin?: string;
medium?: string;
openSea?: string;
projectDescription?: string;
projectEmail: string;
projectName?: string;
projectSector?: string;
projectWebsite: string;
reddit?: string;
requesterEmail: string;
requesterName: string;
slack?: string;
status: 'STATUS_UNKNOWN' | 'IN_PROCESS' | 'APPROVED' | 'REJECTED' | 'UPDATE_REQUIRED';
support?: string;
telegram?: string;
tokenAddress: string;
twitter?: string;
updatedAt: string;
}
export interface TokenInfoApplications {
submissions: Array<TokenInfoApplication>;
}
import type { Transaction } from 'types/api/transaction';
import type { AddressTag, WatchlistName } from './addressParams';
import type { UserTags } from './addressParams';
import type { Block } from './block';
import type { InternalTransaction } from './internalTransaction';
import type { TokenInfo, TokenInstance, TokenType } from './token';
import type { TokenTransfer, TokenTransferPagination } from './tokenTransfer';
export interface Address {
export interface Address extends UserTags {
block_number_balance_updated_at: number | null;
coin_balance: string | null;
creator_address_hash: string | null;
......@@ -30,11 +30,8 @@ export interface Address {
is_contract: boolean;
is_verified: boolean;
name: string | null;
private_tags: Array<AddressTag> | null;
public_tags: Array<AddressTag> | null;
token: TokenInfo | null;
watchlist_address_id: number | null;
watchlist_names: Array<WatchlistName> | null;
}
export interface AddressCounters {
......
......@@ -9,13 +9,16 @@ export interface WatchlistName {
display_name: string;
}
export interface AddressParam {
export interface UserTags {
private_tags: Array<AddressTag> | null;
watchlist_names: Array<WatchlistName> | null;
public_tags: Array<AddressTag> | null;
}
export interface AddressParam extends UserTags {
hash: string;
implementation_name: string | null;
name: string | null;
is_contract: boolean;
is_verified: boolean | null;
private_tags: Array<AddressTag> | null;
watchlist_names: Array<WatchlistName> | null;
public_tags: Array<AddressTag> | null;
}
import type { TokenInfoApplication } from './account';
import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155';
......@@ -11,6 +12,7 @@ export interface TokenInfo<T extends TokenType = TokenType> {
holders: string | null;
exchange_rate: string | null;
total_supply: string | null;
icon_url: string | null;
}
export interface TokenCounters {
......@@ -57,3 +59,5 @@ export interface TokenInventoryResponse {
export type TokenInventoryPagination = {
unique_token: number;
}
export type TokenVerifiedInfo = Omit<TokenInfoApplication, 'id' | 'status'>;
......@@ -15,6 +15,8 @@ export interface NetworkExplorer {
paths: {
tx?: string;
address?: string;
token?: string;
block?: string;
};
}
......
......@@ -10,6 +10,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/account/custom_abi">
| StaticRoute<"/account/public_tags_request">
| StaticRoute<"/account/tag_address">
| StaticRoute<"/account/verified_addresses">
| StaticRoute<"/account/watchlist">
| StaticRoute<"/accounts">
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
addressHash?: string;
}
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contact_code');
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
}
}, [ tabs ]);
return (
<WagmiConfig client={ wagmiClient }>
<Web3ModalProvider fallback={ fallback }>
<ContractContextProvider addressHash={ addressHash }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
</Web3ModalProvider>
);
};
......
import { Box, Flex, Text, Icon, Grid } from '@chakra-ui/react';
import { Box, Text, Icon, Grid } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
......@@ -6,7 +6,6 @@ import React from 'react';
import type { Address as TAddress } from 'types/api/address';
import appConfig from 'configs/app/config';
import blockIcon from 'icons/block.svg';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
......@@ -18,7 +17,6 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal';
import AddressBalance from './details/AddressBalance';
......@@ -83,21 +81,11 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
return <AddressDetailsSkeleton/>;
}
const explorers = appConfig.network.explorers.filter(({ paths }) => paths.address);
const data = addressQuery.isError ? errorData : addressQuery.data;
return (
<Box>
<AddressHeadingInfo address={ data } token={ data.token } isLinkDisabled/>
{ explorers.length > 0 && (
<Flex mt={ 8 } columnGap={ 4 } flexWrap="wrap">
<Text fontSize="sm">Verify with other explorers</Text>
{ explorers.map((explorer) => {
const url = new URL(explorer.paths.address + '/' + addressHash, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>{ explorer.title }</LinkExternal>;
}) }
</Flex>
) }
<Grid
mt={ 8 }
columnGap={ 8 }
......
......@@ -194,11 +194,17 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
</>
) : null;
const tokenData = React.useMemo(() => ({
address: tokenFilter || '',
name: '',
icon_url: '',
}), [ tokenFilter ]);
const tokenFilterComponent = tokenFilter && (
<Flex alignItems="center" flexWrap="wrap" mb={{ base: isActionBarHidden ? 3 : 6, lg: 0 }} mr={ 4 }>
<Text whiteSpace="nowrap" mr={ 2 } py={ 1 }>Filtered by token</Text>
<Flex alignItems="center" py={ 1 }>
<TokenLogo hash={ tokenFilter } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ tokenData } boxSize={ 6 } mr={ 2 }/>
{ isMobile ? <HashStringShorten hash={ tokenFilter }/> : tokenFilter }
<Tooltip label="Reset filter">
<Flex>
......
......@@ -222,7 +222,7 @@ const ContractCode = ({ addressHash, noSocket }: Props) => {
<RawDataSnippet
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ data.is_verified ? null : verificationButton }
rightSlot={ data.is_verified || data.is_self_destructed ? null : verificationButton }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
......
......@@ -63,6 +63,12 @@ const AddressBalance = ({ data }: Props) => {
handler: handleNewCoinBalanceMessage,
});
const tokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return (
<DetailsInfoItem
title="Balance"
......@@ -71,8 +77,7 @@ const AddressBalance = ({ data }: Props) => {
alignItems="flex-start"
>
<TokenLogo
hash={ appConfig.network.currency.address }
name={ appConfig.network.currency.name }
data={ tokenData }
boxSize={ 5 }
mr={ 2 }
fontSize="sm"
......
......@@ -3,14 +3,11 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import { resourceKey } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
......@@ -25,18 +22,14 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
const deleteModalProps = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const redirectIfNotAuth = useRedirectIfNotAuth();
const handleClick = React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
if (redirectIfNotAuth()) {
return;
}
watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, watchListId, isAuth, loginUrl ]);
}, [ addModalProps, deleteModalProps, watchListId, redirectIfNotAuth ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => {
const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } });
......
......@@ -65,7 +65,7 @@ const TokenSelectItem = ({ data }: Props) => {
href={ url }
>
<Flex alignItems="center" w="100%">
<TokenLogo hash={ data.token.address } name={ data.token.name } boxSize={ 6 }/>
<TokenLogo data={ data.token } boxSize={ 6 }/>
<Text fontWeight={ 700 } ml={ 2 }>{ data.token.name || <HashStringShorten hash={ data.token.address }/> }</Text>
{ data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
</Flex>
......
......@@ -24,7 +24,7 @@ const ERC20TokensListItem = ({ token, value }: Props) => {
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
......
......@@ -27,7 +27,7 @@ const ERC20TokensTableItem = ({
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ token.address } type="token" alias={ tokenString }/>
</Flex>
</Td>
......
......@@ -22,7 +22,7 @@ const ERC721TokensListItem = ({ token, value }: Props) => {
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" width="100%">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" pl={ 8 }>
......
......@@ -24,7 +24,7 @@ const ERC721TokensTableItem = ({
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontWeight="700" hash={ hash } tokenHash={ token.address } type="address_token" alias={ tokenString }/>
</Flex>
</Td>
......
......@@ -49,7 +49,7 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance }: Pr
) }
{ token.name && (
<Flex alignItems="center">
<TokenLogo hash={ token.address } name={ token.name } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TokenLogo data={ token } boxSize={ 6 } ml={ 1 } mr={ 1 }/>
<TruncatedTextTooltip label={ token.name }>
<Text variant="secondary" overflow="hidden" whiteSpace="nowrap" textOverflow="ellipsis">{ token.name }</Text>
</TruncatedTextTooltip>
......
import { Icon, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Link } from '@chakra-ui/react';
import React from 'react';
import type { AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess } from './types';
import type { VerifiedAddress } from 'types/api/account';
import eastArrowIcon from 'icons/arrows/east.svg';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import AddressVerificationStepAddress from './steps/AddressVerificationStepAddress';
import AddressVerificationStepSignature from './steps/AddressVerificationStepSignature';
import AddressVerificationStepSuccess from './steps/AddressVerificationStepSuccess';
type StateData = AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess & { isToken?: boolean };
interface Props {
isOpen: boolean;
onClose: () => void;
onSubmit: (address: VerifiedAddress) => void;
onAddTokenInfoClick: (address: string) => void;
onShowListClick: () => void;
defaultAddress?: string;
}
const AddressVerificationModal = ({ defaultAddress, isOpen, onClose, onSubmit, onAddTokenInfoClick, onShowListClick }: Props) => {
const [ stepIndex, setStepIndex ] = React.useState(0);
const [ data, setData ] = React.useState<StateData>({ address: '', signingMessage: '' });
const handleGoToSecondStep = React.useCallback((firstStepResult: typeof data) => {
setData(firstStepResult);
setStepIndex((prev) => prev + 1);
}, []);
const handleGoToThirdStep = React.useCallback((address: VerifiedAddress) => {
onSubmit(address);
setStepIndex((prev) => prev + 1);
setData((prev) => ({ ...prev, isToken: Boolean(address.metadata.tokenName) }));
}, [ onSubmit ]);
const handleGoToPrevStep = React.useCallback(() => {
setStepIndex((prev) => prev - 1);
}, []);
const handleClose = React.useCallback(() => {
onClose();
setStepIndex(0);
setData({ address: '', signingMessage: '' });
}, [ onClose ]);
const handleAddTokenInfoClick = React.useCallback(() => {
onAddTokenInfoClick(data.address);
handleClose();
}, [ handleClose, data.address, onAddTokenInfoClick ]);
const steps = [
{
title: 'Verify new address ownership',
content: <AddressVerificationStepAddress onContinue={ handleGoToSecondStep } defaultAddress={ defaultAddress }/>,
},
{
title: 'Copy and sign message',
content: <AddressVerificationStepSignature { ...data } onContinue={ handleGoToThirdStep }/>,
fallback: <AddressVerificationStepSignature { ...data } onContinue={ handleGoToThirdStep } noWeb3Provider/>,
},
{
title: 'Congrats! Address is verified.',
content: (
<AddressVerificationStepSuccess
onShowListClick={ onShowListClick }
onAddTokenInfoClick={ handleAddTokenInfoClick }
isToken={ data.isToken }
address={ data.address }
/>
),
},
];
const step = steps[stepIndex];
return (
<Modal isOpen={ isOpen } onClose={ handleClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 6 }>
{ stepIndex !== 0 && (
<Link mr={ 3 } onClick={ handleGoToPrevStep }>
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" verticalAlign="middle"/>
</Link>
) }
<span>{ step.title }</span>
</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
<Web3ModalProvider fallback={ step?.fallback || step.content }>
{ step.content }
</Web3ModalProvider>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default React.memo(AddressVerificationModal);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormFirstStepFields, RootFields } from '../types';
import { ADDRESS_REGEXP, ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldAddress = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
const error = 'address' in formState.errors ? formState.errors.address : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor } mt={ 8 }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract address (0x...)" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldAddress);
import { FormControl, Textarea, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldMessage = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'message'>}) => {
const error = 'message' in formState.errors ? formState.errors.message : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Textarea
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled
autoComplete="off"
maxH={{ base: '140px', lg: '80px' }}
/>
<InputPlaceholder text="Message to sign" error={ error } isInModal/>
</FormControl>
);
}, [ formState.errors, backgroundColor ]);
return (
<Controller
defaultValue="some value"
name="message"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AddressVerificationFieldMessage);
import { FormControl, Input, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import { SIGNATURE_REGEXP } from 'lib/validations/signature';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldSignature = ({ formState, control }: Props) => {
const backgroundColor = useColorModeValue('white', 'gray.900');
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'signature'>}) => {
const error = 'signature' in formState.errors ? formState.errors.signature : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" backgroundColor={ backgroundColor }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Signature hash" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, backgroundColor ]);
return (
<Controller
name="signature"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: SIGNATURE_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldSignature);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressVerificationStepAddress from './AddressVerificationStepAddress';
const CHECK_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':prepare' });
test('base view', async({ mount, page }) => {
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SUCCESS),
}));
const props = {
onContinue: () => {},
defaultAddress: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
};
await mount(
<TestApp>
<AddressVerificationStepAddress { ...props }/>
</TestApp>,
);
await expect(page).toHaveScreenshot();
});
test('SOURCE_CODE_NOT_VERIFIED_ERROR view +@mobile', async({ mount, page }) => {
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SOURCE_CODE_NOT_VERIFIED_ERROR),
}));
const props = {
onContinue: () => {},
};
await mount(
<TestApp>
<AddressVerificationStepAddress { ...props }/>
</TestApp>,
);
const addressInput = page.getByLabel(/smart contract address/i);
await addressInput.focus();
await addressInput.type(mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress);
await page.getByRole('button', { name: /continue/i }).click();
await expect(page).toHaveScreenshot();
});
import { Alert, Box, Button, Flex } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type {
AddressVerificationResponseError,
AddressCheckResponseSuccess,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
} from '../types';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import LinkInternal from 'ui/shared/LinkInternal';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldAddress';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
defaultAddress?: string;
onContinue: (data: AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess) => void;
}
const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) => {
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: {
address: defaultAddress,
},
});
const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const address = watch('address');
React.useEffect(() => {
clearErrors('root');
}, [ address, clearErrors ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: data.address,
};
const response = await apiFetch<'address_verification', AddressCheckResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':prepare' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_ERROR' : response.status;
const message = ('payload' in response ? response.payload?.message : undefined) || 'Oops! Something went wrong';
return setError('root', { type, message });
}
onContinue({ ...response.result, address: data.address });
} catch (_error) {
const error = _error as ResourceError<AddressVerificationResponseError>;
setError('root', { type: 'manual', message: error.payload?.message || 'Oops! Something went wrong' });
}
}, [ apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_ADDRESS_ERROR': {
return <span>Specified address either does not exist or is EOA.</span>;
}
case 'IS_OWNER_ERROR': {
return <span>Ownership of this contract address is already verified by this account.</span>;
}
case 'OWNERSHIP_VERIFIED_ERROR': {
return <span>Ownership of this contract address is already verified by another account.</span>;
}
case 'SOURCE_CODE_NOT_VERIFIED_ERROR': {
const href = route({ pathname: '/address/[hash]/contract_verification', query: { hash: address } });
return (
<Box>
<span>The contract source code you entered is not yet verified. Please follow these steps to </span>
<LinkInternal href={ href }>verify the contract</LinkInternal>
<span>.</span>
</Box>
);
}
case undefined: {
return null;
}
default: {
return formState.errors.root?.message;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
<Box>Enter the contract address you are verifying ownership for.</Box>
{ rootError && <Alert status="warning" mt={ 3 }>{ rootError }</Alert> }
<AddressVerificationFieldAddress formState={ formState } control={ control }/>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
<Button size="lg" type="submit" isDisabled={ formState.isSubmitting } flexShrink={ 0 }>
Continue
</Button>
<AdminSupportText/>
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepAddress);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressVerificationStepSignature from './AddressVerificationStepSignature';
const VERIFY_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':verify' });
test('base view', async({ mount, page }) => {
await page.route(VERIFY_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.SUCCESS),
}));
const props = {
onContinue: () => {},
noWeb3Provider: true,
address: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
signingMessage: mocks.ADDRESS_CHECK_RESPONSE.SUCCESS.result.signingMessage,
};
await mount(
<TestApp>
<AddressVerificationStepSignature { ...props }/>
</TestApp>,
);
await expect(page).toHaveScreenshot();
});
test('INVALID_SIGNER_ERROR view +@mobile', async({ mount, page }) => {
await page.route(VERIFY_ADDRESS_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.INVALID_SIGNER_ERROR),
}));
const props = {
onContinue: () => {},
noWeb3Provider: true,
address: mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress,
...mocks.ADDRESS_CHECK_RESPONSE.SUCCESS.result,
};
await mount(
<TestApp>
<AddressVerificationStepSignature { ...props }/>
</TestApp>,
);
const signatureInput = page.getByLabel(/signature hash/i);
await signatureInput.fill(mocks.SIGNATURE);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveScreenshot();
});
import { Alert, Box, Button, chakra, Flex, Link, Radio, RadioGroup } from '@chakra-ui/react';
import { useWeb3Modal } from '@web3modal/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { useSignMessage, useAccount } from 'wagmi';
import type {
AddressVerificationFormSecondStepFields,
AddressCheckStatusSuccess,
AddressVerificationFormFirstStepFields,
RootFields,
AddressVerificationResponseError,
AddressValidationResponseSuccess,
} from '../types';
import type { VerifiedAddress } from 'types/api/account';
import appConfig from 'configs/app/config';
import useApiFetch from 'lib/api/useApiFetch';
import shortenString from 'lib/shortenString';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldMessage from '../fields/AddressVerificationFieldMessage';
import AddressVerificationFieldSignature from '../fields/AddressVerificationFieldSignature';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props extends AddressVerificationFormFirstStepFields, AddressCheckStatusSuccess{
onContinue: (newItem: VerifiedAddress) => void;
noWeb3Provider?: boolean;
}
const AddressVerificationStepSignature = ({ address, signingMessage, contractCreator, contractOwner, onContinue, noWeb3Provider }: Props) => {
const [ signMethod, setSignMethod ] = React.useState<'wallet' | 'manually'>(noWeb3Provider ? 'manually' : 'wallet');
const { open: openWeb3Modal } = useWeb3Modal();
const { isConnected } = useAccount();
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: {
message: signingMessage,
},
});
const { handleSubmit, formState, control, setValue, getValues, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const signature = watch('signature');
React.useEffect(() => {
clearErrors('root');
}, [ clearErrors, signature ]);
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const body = {
contractAddress: address,
message: data.message,
signature: data.signature,
};
const response = await apiFetch<'address_verification', AddressValidationResponseSuccess, AddressVerificationResponseError>('address_verification', {
fetchParams: { method: 'POST', body },
pathParams: { chainId: appConfig.network.id, type: ':verify' },
});
if (response.status !== 'SUCCESS') {
const type = typeof response.status === 'number' ? 'UNKNOWN_STATUS' : response.status;
return setError('root', { type, message: response.status === 'INVALID_SIGNER_ERROR' ? response.invalidSigner.signer : undefined });
}
onContinue(response.result.verifiedAddress);
} catch (error) {
setError('root', { type: 'UNKNOWN_STATUS' });
}
}, [ address, apiFetch, onContinue, setError ]);
const onSubmit = handleSubmit(onFormSubmit);
const { signMessage, isLoading: isSigning } = useSignMessage({
onSuccess: (data) => {
setValue('signature', data);
onSubmit();
},
onError: (error) => {
return setError('root', { type: 'SIGNING_FAIL', message: (error as Error)?.message || 'Oops! Something went wrong' });
},
});
const handleSignMethodChange = React.useCallback((value: typeof signMethod) => {
setSignMethod(value);
clearErrors('root');
}, [ clearErrors ]);
const handleOpenWeb3Modal = React.useCallback(() => {
openWeb3Modal();
}, [ openWeb3Modal ]);
const handleWeb3SignClick = React.useCallback(() => {
if (!isConnected) {
return setError('root', { type: 'manual', message: 'Please connect to your Web3 wallet first' });
}
const message = getValues('message');
signMessage({ message });
}, [ getValues, signMessage, isConnected, setError ]);
const handleManualSignClick = React.useCallback(() => {
onSubmit();
}, [ onSubmit ]);
const button = (() => {
if (signMethod === 'manually') {
return (
<Button
size="lg"
onClick={ handleManualSignClick }
isLoading={ formState.isSubmitting }
loadingText="Verifying"
>
Verify
</Button>
);
}
return (
<Button
size="lg"
onClick={ isConnected ? handleWeb3SignClick : handleOpenWeb3Modal }
isLoading={ formState.isSubmitting || isSigning }
loadingText={ isSigning ? 'Signing' : 'Verifying' }
>
{ isConnected ? 'Sign and verify' : 'Connect wallet' }
</Button>
);
})();
const contactUsLink = <span>contact us <Link href="mailto:help@blockscout.com">help@blockscout.com</Link></span>;
const rootError = (() => {
switch (formState.errors.root?.type) {
case 'INVALID_SIGNATURE_ERROR': {
return <span>The signature could not be processed.</span>;
}
case 'VALIDITY_EXPIRED_ERROR': {
return <span>This verification message has expired. Add the contract address to restart the process.</span>;
}
case 'SIGNING_FAIL': {
return <span>{ formState.errors.root.message }</span>;
}
case 'INVALID_SIGNER_ERROR': {
const signer = shortenString(formState.errors.root.message || '');
const expectedSigners = [ contractCreator, contractOwner ].filter(Boolean).map(shortenString).join(', ');
return (
<Box>
<span>This address </span>
<span>{ signer }</span>
<span> is not a creator/owner of the requested contract and cannot claim ownership. Only </span>
<span>{ expectedSigners }2</span>
<span> can verify ownership of this contract.</span>
</Box>
);
}
case 'UNKNOWN_STATUS': {
return (
<Box>
<span>We are not able to process the verify account ownership for this contract address. Kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
);
}
case undefined: {
return null;
}
}
})();
return (
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
<span>Please select the address to sign and copy the message and sign it using the Blockscout message provider of your choice. </span>
<Link href="https://docs.blockscout.com/for-users/my-account/verified-addresses/copy-and-sign-message" target="_blank">
Additional instructions
</Link>
<span>. If you do not see your address here but are sure that you are the owner of the contract, kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
{ (contractOwner || contractCreator) && (
<Flex flexDir="column" rowGap={ 4 } mb={ 4 }>
{ contractCreator && (
<Box>
<chakra.span fontWeight={ 600 }>Contract creator: </chakra.span>
<chakra.span>{ contractCreator }</chakra.span>
</Box>
) }
{ contractOwner && (
<Box>
<chakra.span fontWeight={ 600 }>Contract owner: </chakra.span>
<chakra.span>{ contractOwner }</chakra.span>
</Box>
) }
</Flex>
) }
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<AddressVerificationFieldMessage formState={ formState } control={ control }/>
</div>
{ !noWeb3Provider && (
<RadioGroup onChange={ handleSignMethodChange } value={ signMethod } display="flex" flexDir="column" rowGap={ 4 }>
<Radio value="wallet">Sign via Web3 wallet</Radio>
<Radio value="manually">Sign manually</Radio>
</RadioGroup>
) }
{ signMethod === 'manually' && <AddressVerificationFieldSignature formState={ formState } control={ control }/> }
</Flex>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
{ button }
<AdminSupportText/>
</Flex>
</form>
);
};
export default React.memo(AddressVerificationStepSignature);
import { Alert, Box, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
interface Props {
onShowListClick: () => void;
onAddTokenInfoClick: () => void;
isToken?: boolean;
address: string;
}
const AddressVerificationStepSuccess = ({ onAddTokenInfoClick, onShowListClick, isToken, address }: Props) => {
return (
<Box>
<Alert status="success" flexWrap="wrap" whiteSpace="pre-wrap" wordBreak="break-word" mb={ 3 } display="inline-block">
<span>The address ownership for </span>
<chakra.span fontWeight={ 700 }>{ address }</chakra.span>
<span> is verified.</span>
</Alert>
<p>You may now submit the “Add token information” request</p>
<Flex alignItems="center" mt={ 8 } columnGap={ 5 } flexWrap="wrap" rowGap={ 5 }>
<Button size="lg" variant={ isToken ? 'outline' : 'solid' } onClick={ onShowListClick }>
View my verified addresses
</Button>
{ isToken && (
<Button size="lg" onClick={ onAddTokenInfoClick }>
Add token information
</Button>
) }
</Flex>
</Box>
);
};
export default React.memo(AddressVerificationStepSuccess);
import type { VerifiedAddress } from 'types/api/account';
export interface AddressVerificationFormFirstStepFields {
address: string;
}
export interface AddressVerificationFormSecondStepFields {
signature: string;
message: string;
}
export interface RootFields {
root: string;
}
export interface AddressCheckStatusSuccess {
contractCreator?: string;
contractOwner?: string;
signingMessage: string;
}
export type AddressCheckResponseSuccess = {
status: 'SUCCESS';
result: AddressCheckStatusSuccess;
} |
{ status: 'IS_OWNER_ERROR' } |
{ status: 'OWNERSHIP_VERIFIED_ERROR' } |
{ status: 'SOURCE_CODE_NOT_VERIFIED_ERROR' } |
{ status: 'INVALID_ADDRESS_ERROR' };
export interface AddressVerificationResponseError {
code: number;
message: string;
}
export type AddressValidationResponseSuccess = {
status: 'SUCCESS';
result: {
verifiedAddress: VerifiedAddress;
};
} |
{
status: 'INVALID_SIGNER_ERROR';
invalidSigner: {
signer: string;
};
} |
{ status: 'VALIDITY_EXPIRED_ERROR' } |
{ status: 'INVALID_SIGNATURE_ERROR' } |
{ status: 'UNKNOWN_STATUS' }
......@@ -27,11 +27,17 @@ const dailyTxsIndicator: TChainIndicator<'homepage_chart_txs'> = {
},
};
const nativeTokenData = {
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
};
const coinPriceIndicator: TChainIndicator<'homepage_chart_market'> = {
id: 'coin_price',
title: `${ appConfig.network.currency.symbol } price`,
value: (stats) => '$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
icon: <TokenLogo hash={ appConfig.network.currency.address || '' } name={ appConfig.network.currency.name } boxSize={ 6 }/>,
icon: <TokenLogo data={ nativeTokenData } boxSize={ 6 }/>,
hint: `${ appConfig.network.currency.symbol } token daily price in USD.`,
api: {
resourceName: 'homepage_chart_market',
......
......@@ -51,7 +51,7 @@ const Accounts = () => {
return (
<Page>
<PageTitle text="Top accounts" withTextAd/>
<PageTitle title="Top accounts" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react';
import { Skeleton, Box, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -10,6 +10,7 @@ import iconSuccess from 'icons/status/success.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
......@@ -22,6 +23,8 @@ import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import TextAd from 'ui/shared/ad/TextAd';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
......@@ -37,11 +40,9 @@ const TOKEN_TABS = Object.values(tokenTabsByType);
const AddressPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
const tabsScrollRef = React.useRef<HTMLDivElement>(null);
const hash = getQueryParamString(router.query.hash);
......@@ -50,17 +51,6 @@ const AddressPageContent = () => {
queryOptions: { enabled: Boolean(hash) },
});
const tags = [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
...(addressQuery.data?.private_tags || []),
...(addressQuery.data?.public_tags || []),
...(addressQuery.data?.watchlist_names || []),
]
.filter(Boolean)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const contractTabs = useContractTabs(addressQuery.data);
const tabs: Array<RoutedTab> = React.useMemo(() => {
......@@ -99,10 +89,36 @@ const AddressPageContent = () => {
].filter(Boolean);
}, [ addressQuery.data, contractTabs, hash ]);
const tagsNode = tags.length > 0 ? <Flex columnGap={ 2 }>{ tags }</Flex> : null;
const tags = (
<EntityTags
data={ addressQuery.data }
isLoading={ addressQuery.isPlaceholderData }
tagsBefore={ [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
] }
contentAfter={
<NetworkExplorers type="address" pathParam={ hash } ml="auto" hideText={ isMobile }/>
}
/>
);
const content = addressQuery.isError ? null : <RoutedTabs tabs={ tabs } tabListProps={{ mt: 8 }}/>;
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/accounts');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to top accounts list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
......@@ -110,10 +126,9 @@ const AddressPageContent = () => {
<Skeleton h={ 10 } w="260px" mb={ 6 }/>
) : (
<PageTitle
text={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
additionalsRight={ tagsNode }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to top accounts list"
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
backLink={ backLink }
contentAfter={ tags }
/>
) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
......
......@@ -132,7 +132,7 @@ const ApiKeysPage: React.FC = () => {
return (
<Page>
<Box h="100%">
<PageTitle text="API keys"/>
<PageTitle title="API keys"/>
{ content }
</Box>
</Page>
......
......@@ -13,6 +13,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import TextAd from 'ui/shared/ad/TextAd';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
......@@ -82,8 +83,19 @@ const BlockPageContent = () => {
pagination = blockWithdrawalsQuery.pagination;
}
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/blocks');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to blocks list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
{ blockQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
......@@ -91,9 +103,9 @@ const BlockPageContent = () => {
<Skeleton h={ 10 } w="300px" mb={ 6 }/>
) : (
<PageTitle
text={ `Block #${ blockQuery.data?.height }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to blocks list"
title={ `Block #${ blockQuery.data?.height }` }
backLink={ backLink }
contentAfter={ <NetworkExplorers type="block" pathParam={ height } ml={{ base: 'initial', lg: 'auto' }}/> }
/>
) }
{ blockQuery.isLoading ? <SkeletonTabs/> : (
......
......@@ -42,7 +42,7 @@ const BlocksPageContent = () => {
return (
<Page>
<PageTitle text="Blocks" withTextAd/>
<PageTitle title="Blocks" withTextAd/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
......@@ -20,7 +20,6 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
......@@ -83,12 +82,24 @@ const ContractVerification = () => {
);
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to contract',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="New smart contract verification"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to contract"
title="New smart contract verification"
backLink={ backLink }
/>
{ hash && (
<Address mb={ 12 }>
......
......@@ -50,7 +50,6 @@ const CsvExport = () => {
const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || '';
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
const addressQuery = useApiQuery('address', {
pathParams: { hash: addressHash },
......@@ -59,6 +58,19 @@ const CsvExport = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
}
......@@ -78,9 +90,8 @@ const CsvExport = () => {
return (
<Page>
<PageTitle
text="Export data to CSV file"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
title="Export data to CSV file"
backLink={ backLink }
/>
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span>
......
......@@ -115,7 +115,7 @@ const CustomAbiPage: React.FC = () => {
return (
<Page>
<Box h="100%">
<PageTitle text="Custom ABI"/>
<PageTitle title="Custom ABI"/>
{ content }
</Box>
</Page>
......
......@@ -9,7 +9,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
const Graph = () => {
return (
<Page>
<PageTitle text="Charts"/>
<PageTitle title="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/>
......
......@@ -68,7 +68,7 @@ const L2Deposits = () => {
return (
<Page>
<PageTitle text={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<PageTitle title={ `Deposits (L1${ nbsp }${ rightLineArrow }${ nbsp }L2)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -70,7 +70,7 @@ const L2OutputRoots = () => {
return (
<Page>
<PageTitle text="Output roots" withTextAd/>
<PageTitle title="Output roots" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -71,7 +71,7 @@ const L2TxnBatches = () => {
return (
<Page>
<PageTitle text={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/>
<PageTitle title={ `Tx batches (L2${ nbsp }blocks)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -68,7 +68,7 @@ const L2Withdrawals = () => {
return (
<Page>
<PageTitle text={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<PageTitle title={ `Withdrawals (L2${ nbsp }${ rightLineArrow }${ nbsp }L1)` } withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -56,7 +56,7 @@ const Login = () => {
return (
<Page>
<VStack gap={ 4 } alignItems="flex-start" maxW="1000px">
<PageTitle text="Login page 😂"/>
<PageTitle title="Login page 😂"/>
{ isFormVisible && (
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
......
......@@ -55,7 +55,7 @@ const MyProfile = () => {
return (
<Page>
<PageTitle text="My profile"/>
<PageTitle title="My profile"/>
{ content }
</Page>
);
......
......@@ -19,7 +19,7 @@ const PrivateTags = () => {
return (
<Page>
<PageTitle text="Private tags"/>
<PageTitle title="Private tags"/>
<RoutedTabs tabs={ TABS }/>
</Page>
);
......
import { Link, Text, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account';
import eastArrowIcon from 'icons/arrows/east.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString';
import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import Page from 'ui/shared/Page/Page';
......@@ -23,13 +22,21 @@ const toastDescriptions = {
} as Record<TToastAction, string>;
const PublicTagsComponent: React.FC = () => {
const [ screen, setScreen ] = useState<TScreen>('data');
const [ formData, setFormData ] = useState<PublicTag>();
const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ screen, setScreen ] = useState<TScreen>(addressHash ? 'form' : 'data');
const [ formData, setFormData ] = useState<Partial<PublicTag> | undefined>(addressHash ? { addresses: [ addressHash ] } : undefined);
const toast = useToast();
const isMobile = useIsMobile();
useRedirectForInvalidAuthToken();
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/public_tags_request' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const showToast = useCallback((action: TToastAction) => {
toast({
position: 'top-right',
......@@ -77,15 +84,18 @@ const PublicTagsComponent: React.FC = () => {
header = formData ? 'Request to edit a public tag/label' : 'Request a public tag/label';
}
const backLink = {
label: 'Public tags',
onClick: onGoBack,
};
return (
<Page>
{ screen === 'form' && (
<Link display="inline-flex" alignItems="center" mb={ 6 } onClick={ onGoBack }>
<Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)"/>
{ isMobile && <Text variant="inherit" fontSize="sm" ml={ 2 }>Public tags</Text> }
</Link>
) }
<PageTitle text={ header } display={{ base: 'block', lg: 'inline-flex' }} ml={{ base: 0, lg: 3 }}/>
<PageTitle
title={ header }
backLink={ screen === 'form' ? backLink : undefined }
display={{ base: 'block', lg: 'inline-flex' }}
/>
{ content }
</Page>
);
......
......@@ -152,7 +152,7 @@ const SearchResultsPageContent = () => {
<Page renderHeader={ renderHeader }>
{ isLoading || redirectCheckQuery.isLoading ?
<Skeleton h={ 10 } mb={ 6 } w="100%" maxW="222px"/> :
<PageTitle text="Search results"/>
<PageTitle title="Search results"/>
}
{ bar }
{ content }
......
......@@ -17,14 +17,25 @@ const Sol2Uml = () => {
const appProps = useAppContext();
const addressHash = router.query.address?.toString() || '';
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to address',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<PageTitle
text="Solidity UML diagram"
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to address"
title="Solidity UML diagram"
backLink={ backLink }
/>
<Flex mb={ 10 }>
<span>For contract</span>
......
......@@ -26,7 +26,7 @@ const Stats = () => {
return (
<Page>
<PageTitle text={ `${ appConfig.network.name } stats` }/>
<PageTitle title={ `${ appConfig.network.name } stats` }/>
<Box mb={{ base: 6, sm: 8 }}>
<NumberWidgetsList/>
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as verifiedAddressesMocks from 'mocks/account/verifiedAddresses';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import * as socketServer from 'playwright/fixtures/socketServer';
......@@ -16,7 +17,7 @@ const TOKEN_TRANSFERS_API_URL = buildApiUrl('token_transfers', { hash: '1' });
const ADDRESS_API_URL = buildApiUrl('address', { hash: '1' });
const hooksConfig = {
router: {
query: { hash: 1, tab: 'token_transfers' },
query: { hash: '1', tab: 'token_transfers' },
isReady: true,
},
};
......@@ -70,6 +71,30 @@ test('base view', async({ mount, page, createSocket }) => {
await expect(component).toHaveScreenshot();
});
test('with verified info', async({ mount, page, createSocket }) => {
const VERIFIED_INFO_URL = buildApiUrl('token_verified_info', { chainId: '1', hash: '1' });
await page.route(VERIFIED_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED),
}));
const component = await mount(
<TestApp withSocket>
<Token/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await insertAdPlaceholder(page);
await page.getByRole('button', { name: /project info/i }).click();
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page, createSocket }) => {
......@@ -88,4 +113,26 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot();
});
test('with verified info', async({ mount, page, createSocket }) => {
const VERIFIED_INFO_URL = buildApiUrl('token_verified_info', { chainId: '1', hash: '1' });
await page.route(VERIFIED_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(verifiedAddressesMocks.TOKEN_INFO_APPLICATION.APPROVED),
}));
const component = await mount(
<TestApp withSocket>
<Token/>
</TestApp>,
{ hooksConfig },
);
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'tokens:1');
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
});
......@@ -7,12 +7,14 @@ import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import appConfig from 'configs/app/config';
import iconSuccess from 'icons/status/success.svg';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
......@@ -20,7 +22,8 @@ import * as addressStubs from 'stubs/address';
import * as tokenStubs from 'stubs/token';
import AddressContract from 'ui/address/AddressContract';
import TextAd from 'ui/shared/ad/TextAd';
import Tag from 'ui/shared/chakra/Tag';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import Pagination from 'ui/shared/Pagination';
......@@ -32,6 +35,7 @@ import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenInventory from 'ui/token/TokenInventory';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenVerifiedInfo from 'ui/token/TokenVerifiedInfo';
export type TokenTabs = 'token_transfers' | 'holders' | 'inventory';
......@@ -43,11 +47,9 @@ const TokenPageContent = () => {
const appProps = useAppContext();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
const scrollRef = React.useRef<HTMLDivElement>(null);
const hashString = router.query.hash?.toString();
const hashString = getQueryParamString(router.query.hash);
const queryClient = useQueryClient();
......@@ -147,6 +149,12 @@ const TokenPageContent = () => {
},
});
const isVerifiedInfoEnabled = Boolean(appConfig.contractInfoApi.endpoint);
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: hashString, chainId: appConfig.network.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && isVerifiedInfoEnabled },
});
const contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [
......@@ -207,20 +215,52 @@ const TokenPageContent = () => {
};
}, [ isMobile ]);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/tokens');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to tokens list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const tags = (
<EntityTags
data={ contractQuery.data }
isLoading={ tokenQuery.isPlaceholderData || contractQuery.isPlaceholderData }
tagsBefore={ [
tokenQuery.data ? { label: tokenQuery.data?.type, display_name: tokenQuery.data?.type } : undefined,
] }
contentAfter={
<NetworkExplorers type="token" pathParam={ hashString } ml="auto" hideText={ isMobile }/>
}
flexGrow={ 1 }
/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
isLoading={ tokenQuery.isPlaceholderData }
text={ `${ tokenQuery.data?.name || 'Unnamed' }${ tokenSymbolText } token` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to tokens list"
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData }/>
backLink={ backLink }
beforeTitle={ (
<TokenLogo data={ tokenQuery.data } boxSize={ 6 } isLoading={ tokenQuery.isPlaceholderData } display="inline-block" mr={ 2 }/>
) }
additionalsRight={ <Tag isLoading={ tokenQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag> }
afterTitle={
verifiedInfoQuery.data?.tokenAddress ?
<Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> :
<Box boxSize={ 4 } display="inline-block"/>
}
contentAfter={ tags }
/>
<TokenContractInfo tokenQuery={ tokenQuery } contractQuery={ contractQuery }/>
<TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery } isVerifiedInfoEnabled={ isVerifiedInfoEnabled }/>
<TokenDetails tokenQuery={ tokenQuery }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
......
......@@ -7,7 +7,7 @@ import TokensList from 'ui/tokens/Tokens';
const Tokens = () => {
return (
<Page>
<PageTitle text="Tokens" withTextAd/>
<PageTitle title="Tokens" withTextAd/>
<TokensList/>
</Page>
);
......
import { Flex, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -6,10 +5,11 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import networkExplorers from 'lib/networks/networkExplorers';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import TextAd from 'ui/shared/ad/TextAd';
import LinkExternal from 'ui/shared/LinkExternal';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
......@@ -32,47 +32,45 @@ const TABS: Array<RoutedTab> = [
const TransactionPageContent = () => {
const router = useRouter();
const appProps = useAppContext();
const isMobile = useIsMobile();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
const hash = getQueryParamString(router.query.hash);
const { data } = useApiQuery('tx', {
const { data, isPlaceholderData } = useApiQuery('tx', {
pathParams: { hash },
queryOptions: { enabled: Boolean(hash) },
});
const explorersLinks = networkExplorers
.filter((explorer) => explorer.paths.tx)
.map((explorer) => {
const url = new URL(explorer.paths.tx + '/' + hash, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>Open in { explorer.title }</LinkExternal>;
});
const additionals = (
<Flex justifyContent="space-between" alignItems="center" flexGrow={ 1 } flexWrap="wrap">
{ data?.tx_tag && <Tag my={ 2 }>{ data.tx_tag }</Tag> }
{ explorersLinks.length > 0 && (
<Flex
alignItems="center"
flexWrap="wrap"
columnGap={ 6 }
rowGap={ 3 }
ml={{ base: 'initial', lg: 'auto' }}
>
{ explorersLinks }
</Flex>
) }
</Flex>
const tags = (
<EntityTags
isLoading={ isPlaceholderData }
tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] }
contentAfter={
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 'initial', lg: 'auto' }} hideText={ isMobile && Boolean(data?.tx_tag) }/>
}
/>
);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/txs');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to transactions list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<Page>
<TextAd mb={ 6 }/>
<PageTitle
text="Transaction details"
additionalsRight={ additionals }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to transactions list"
title="Transaction details"
backLink={ backLink }
contentAfter={ tags }
/>
<RoutedTabs tabs={ TABS }/>
</Page>
......
......@@ -73,7 +73,7 @@ const Transactions = () => {
return (
<Page>
<Box h="100%">
<PageTitle text="Transactions" withTextAd/>
<PageTitle title="Transactions" withTextAd/>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import VerifiedAddresses from './VerifiedAddresses';
const VERIFIED_ADDRESS_URL = buildApiUrl('verified_addresses', { chainId: '1' });
const TOKEN_INFO_APPLICATIONS_URL = buildApiUrl('token_info_applications', { chainId: '1', id: undefined });
test.beforeEach(async({ context }) => {
await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
});
test('base view +@mobile', async({ mount, page }) => {
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
const component = await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('address verification flow', async({ mount, page }) => {
const CHECK_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':prepare' });
const VERIFY_ADDRESS_URL = buildApiUrl('address_verification', { chainId: '1', type: ':verify' });
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
await page.route(CHECK_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.ADDRESS_CHECK_RESPONSE.SUCCESS),
}));
await page.route(VERIFY_ADDRESS_URL, (route) => {
return route.fulfill({
body: JSON.stringify(mocks.ADDRESS_VERIFY_RESPONSE.SUCCESS),
});
});
await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
// open modal
await page.getByRole('button', { name: /add address/i }).click();
// fill first step
const addressInput = page.getByLabel(/smart contract address/i);
await addressInput.fill(mocks.VERIFIED_ADDRESS.NEW_ITEM.contractAddress);
await page.getByRole('button', { name: /continue/i }).click();
// fill second step
const option = page.getByText(/sign manually/i);
option.click();
const signatureInput = page.getByLabel(/signature hash/i);
await signatureInput.fill(mocks.SIGNATURE);
await page.getByRole('button', { name: /verify/i }).click();
// success screen
await page.getByRole('button', { name: /view my verified addresses/i }).click();
await expect(page).toHaveScreenshot();
});
test('application update flow', async({ mount, page }) => {
const TOKEN_INFO_APPLICATION_URL = buildApiUrl('token_info_applications', { chainId: '1', id: mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM.id });
const FORM_CONFIG_URL = buildApiUrl('token_info_applications_config', { chainId: '1' });
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.FOR_UPDATE),
}));
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
// PUT request
await page.route(TOKEN_INFO_APPLICATION_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM),
}));
await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
// open form
await page.locator('tr').filter({ hasText: 'waiting for update' }).locator('button[aria-label="edit"]').click();
// change project name
const addressInput = page.getByLabel(/project name/i);
await addressInput.fill('New name');
await page.getByRole('button', { name: /send request/i }).click();
const locator = page.locator('tr').filter({ hasText: 'in progress' }).filter({ hasText: 'nov 11, 2022' });
await expect(locator).toBeVisible();
});
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Box, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, VerifiedAddressResponse } from 'types/api/account';
import appConfig from 'configs/app/config';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import TokenInfoForm from 'ui/tokenInfo/TokenInfoForm';
import VerifiedAddressesListItem from 'ui/verifiedAddresses/VerifiedAddressesListItem';
import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable';
const VerifiedAddresses = () => {
useRedirectForInvalidAuthToken();
const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ selectedAddress, setSelectedAddress ] = React.useState<string | undefined>(addressHash);
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/verified_addresses' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const modalProps = useDisclosure();
const addressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: undefined },
queryOptions: {
select: (data) => {
return {
...data,
submissions: data.submissions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
};
},
},
});
const queryClient = useQueryClient();
const handleGoBack = React.useCallback(() => {
setSelectedAddress(undefined);
}, []);
const handleItemAdd = React.useCallback((address: string) => {
setSelectedAddress(address);
}, []);
const handleItemEdit = React.useCallback((address: string) => {
setSelectedAddress(address);
}, []);
const handleAddressSubmit = React.useCallback((newItem: VerifiedAddress) => {
queryClient.setQueryData(
getResourceKey('verified_addresses', { pathParams: { chainId: appConfig.network.id } }),
(prevData: VerifiedAddressResponse | undefined) => {
if (!prevData) {
return { verifiedAddresses: [ newItem ] };
}
return {
verifiedAddresses: [ newItem, ...prevData.verifiedAddresses ],
};
});
}, [ queryClient ]);
const handleApplicationSubmit = React.useCallback((newItem: TokenInfoApplication) => {
setSelectedAddress(undefined);
queryClient.setQueryData(
getResourceKey('token_info_applications', { pathParams: { chainId: appConfig.network.id, id: undefined } }),
(prevData: TokenInfoApplications | undefined) => {
if (!prevData) {
return { submissions: [ newItem ] };
}
const isExisting = prevData.submissions.some((item) => item.id.toLowerCase() === newItem.id.toLowerCase());
const submissions = isExisting ?
prevData.submissions.map((item) => item.id.toLowerCase() === newItem.id.toLowerCase() ? newItem : item) :
[ newItem, ...prevData.submissions ];
return { submissions };
});
}, [ queryClient ]);
const addButton = (
<Box marginTop={ 8 }>
<Button size="lg" onClick={ modalProps.onOpen }>
Add address
</Button>
</Box>
);
const skeleton = (
<>
<Box display={{ base: 'block', lg: 'none' }}>
<SkeletonListAccount/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
<Box display={{ base: 'none', lg: 'block' }}>
<SkeletonTable columns={ [ '100%', '180px', '260px', '160px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</Box>
</>
);
const backLink = React.useMemo(() => {
if (!selectedAddress) {
return;
}
return {
label: 'Back to my verified addresses',
onClick: handleGoBack,
};
}, [ handleGoBack, selectedAddress ]);
if (selectedAddress) {
const addressInfo = addressesQuery.data?.verifiedAddresses.find(({ contractAddress }) => contractAddress.toLowerCase() === selectedAddress.toLowerCase());
const tokenName = addressInfo ? `${ addressInfo.metadata.tokenName } (${ addressInfo.metadata.tokenSymbol })` : '';
return (
<>
<PageTitle title="Token info application form" backLink={ backLink }/>
<TokenInfoForm
address={ selectedAddress }
tokenName={ tokenName }
application={ applicationsQuery.data?.submissions.find(({ tokenAddress }) => tokenAddress.toLowerCase() === selectedAddress.toLowerCase()) }
onSubmit={ handleApplicationSubmit }
/>
</>
);
}
const content = addressesQuery.data?.verifiedAddresses ? (
<>
<Show below="lg" key="content-mobile" ssr={ false }>
{ addressesQuery.data.verifiedAddresses.map((item) => (
<VerifiedAddressesListItem
key={ item.contractAddress }
item={ item }
application={ applicationsQuery.data?.submissions?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) }
onAdd={ handleItemAdd }
onEdit={ handleItemEdit }
/>
)) }
</Show>
<Hide below="lg" key="content-desktop" ssr={ false }>
<VerifiedAddressesTable
data={ addressesQuery.data.verifiedAddresses }
applications={ applicationsQuery.data?.submissions }
onItemEdit={ handleItemEdit }
onItemAdd={ handleItemAdd }
/>
</Hide>
</>
) : null;
return (
<>
<PageTitle title="My verified addresses"/>
<AccountPageDescription allowCut={ false }>
<span>
Verify ownership of a smart contract address to easily update information in Blockscout.
You will sign a single message to verify contract ownership.
Once verified, you can update token information, address name tags, and address labels from the
Blockscout console without needing to sign additional messages.
</span>
<chakra.p fontWeight={ 600 } mt={ 5 }>
Before starting, make sure that:
</chakra.p>
<OrderedList ml={ 6 }>
<ListItem>The source code for the smart contract is deployed on “{ appConfig.network.name }”.</ListItem>
<ListItem>
<span>The source code is verified (if not yet verified, you can use </span>
<Link href="https://docs.blockscout.com/for-users/verifying-a-smart-contract" target="_blank">this tool</Link>
<span>).</span>
</ListItem>
</OrderedList>
<chakra.div mt={ 5 }>
Once these steps are complete, click the Add address button below to get started.
</chakra.div>
<AdminSupportText mt={ 5 }/>
</AccountPageDescription>
<DataListDisplay
isLoading={ addressesQuery.isLoading || applicationsQuery.isLoading }
isError={ addressesQuery.isError || applicationsQuery.isError }
items={ addressesQuery.data?.verifiedAddresses }
content={ content }
emptyText=""
skeletonProps={{ customSkeleton: skeleton }}
/>
{ addButton }
<AddressVerificationModal
isOpen={ modalProps.isOpen }
onClose={ modalProps.onClose }
onSubmit={ handleAddressSubmit }
onAddTokenInfoClick={ handleItemAdd }
onShowListClick={ modalProps.onClose }
/>
</>
);
};
export default React.memo(VerifiedAddresses);
......@@ -117,7 +117,7 @@ const VerifiedContracts = () => {
return (
<Box>
<PageTitle text="Verified contracts" withTextAd/>
<PageTitle title="Verified contracts" withTextAd/>
<VerifiedContractsCounters/>
<DataListDisplay
isError={ isError }
......
......@@ -167,7 +167,7 @@ const WatchList: React.FC = () => {
return (
<Page>
<Box h="100%">
<PageTitle text="Watch list"/>
<PageTitle title="Watch list"/>
{ content }
</Box>
</Page>
......
......@@ -70,7 +70,7 @@ const Withdrawals = () => {
return (
<Page>
<PageTitle text="Withdrawals" withTextAd/>
<PageTitle title="Withdrawals" withTextAd/>
<DataListDisplay
isError={ isError }
isLoading={ isLoading }
......
......@@ -3,7 +3,7 @@ import {
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
......@@ -11,7 +11,6 @@ import { useForm, Controller } from 'react-hook-form';
import type { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
......@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput';
const TAG_MAX_LENGTH = 35;
type Props = {
data?: AddressTag;
data?: Partial<AddressTag>;
onClose: () => void;
onSuccess: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void;
}
......@@ -31,7 +31,7 @@ type Inputs = {
tag: string;
}
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const apiFetch = useApiFetch();
const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
......@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => {
const body = {
name: formData?.tag,
......@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
setAlertVisible(true);
}
},
onSuccess: () => {
queryClient.refetchQueries([ resourceKey('private_tags_address') ]).then(() => {
onSuccess: async() => {
await onSuccess();
onClose();
setPending(false);
});
},
});
......
......@@ -9,18 +9,19 @@ import AddressForm from './AddressForm';
type Props = {
isOpen: boolean;
onClose: () => void;
data?: AddressTag;
onSuccess: () => Promise<void>;
data?: Partial<AddressTag>;
}
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const title = data ? 'Edit address tag' : 'New address tag';
const text = !data ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const AddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data }) => {
const title = data?.id ? 'Edit address tag' : 'New address tag';
const text = !data?.id ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]);
return <AddressForm data={ data } onClose={ onClose } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose, onSuccess ]);
return (
<FormModal<AddressTag>
isOpen={ isOpen }
......
......@@ -15,7 +15,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
const PrivateAddressTags = () => {
const { data: addressTagsData, isError, isPlaceholderData } = useApiQuery('private_tags_address', {
const { data: addressTagsData, isError, isPlaceholderData, refetch } = useApiQuery('private_tags_address', {
queryOptions: {
refetchOnMount: false,
placeholderData: Array(3).fill(PRIVATE_TAG_ADDRESS),
......@@ -34,6 +34,10 @@ const PrivateAddressTags = () => {
addressModalProps.onOpen();
}, [ addressModalProps ]);
const onAddOrEditSuccess = useCallback(async() => {
await refetch();
}, [ refetch ]);
const onAddressModalClose = useCallback(() => {
setAddressModalData(undefined);
addressModalProps.onClose();
......@@ -91,7 +95,7 @@ const PrivateAddressTags = () => {
</Button>
</Skeleton>
</Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData } onSuccess={ onAddOrEditSuccess }/>
{ deleteModalData && (
<DeletePrivateTagModal
{ ...deleteModalProps }
......
......@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput';
type Props = {
changeToDataScreen: (success?: boolean) => void;
data?: PublicTag;
data?: Partial<PublicTag>;
}
export type Inputs = {
......@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
email: data?.email || '',
companyName: data?.company || '',
companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
tags: data?.tags?.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses?.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
......
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import React, { useRef, useEffect, useState, useCallback } from 'react';
const CUT_HEIGHT = 144;
const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
const AccountPageDescription = ({ children, allowCut = true }: { children: React.ReactNode; allowCut?: boolean }) => {
const ref = useRef<HTMLParagraphElement>(null);
const [ needCut, setNeedCut ] = useState(false);
......@@ -20,6 +20,10 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
}, [ needCut ]);
useEffect(() => {
if (!allowCut) {
return;
}
calculateCut();
const resizeHandler = _debounce(calculateCut, 300);
window.addEventListener('resize', resizeHandler);
......@@ -40,15 +44,14 @@ const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
return (
<Box position="relative" marginBottom={{ base: 6, lg: 8 }}>
<Text
<Box
ref={ ref }
maxHeight={ needCut && !expanded ? `${ CUT_HEIGHT }px` : 'auto' }
overflow="hidden"
style={ needCut && !expanded ? { WebkitLineClamp: '6', WebkitBoxOrient: 'vertical', display: '-webkit-box' } : {} }
>
{ children }
</Text>
</Box>
{ needCut && !expanded && (
<Box
position="absolute"
......
import { Button, Menu, MenuButton, MenuList, Icon, Flex, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import iconArrow from 'icons/arrows/east-mini.svg';
import getQueryParamString from 'lib/router/getQueryParamString';
import PrivateTagMenuItem from './PrivateTagMenuItem';
import PublicTagMenuItem from './PublicTagMenuItem';
import TokenInfoMenuItem from './TokenInfoMenuItem';
interface Props {
isLoading?: boolean;
}
const AddressActions = ({ isLoading }: Props) => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const isTokenPage = router.pathname === '/token/[hash]';
return (
<Menu>
<Skeleton isLoaded={ !isLoading } ml={ 2 } borderRadius="base">
<MenuButton
as={ Button }
size="sm"
variant="outline"
>
<Flex alignItems="center">
<span>More</span>
<Icon as={ iconArrow } transform="rotate(-90deg)" boxSize={ 5 } ml={ 1 }/>
</Flex>
</MenuButton>
</Skeleton>
<MenuList minWidth="180px" zIndex="popover">
{ isTokenPage && appConfig.contractInfoApi.endpoint && appConfig.adminServiceApi.endpoint && appConfig.isAccountSupported &&
<TokenInfoMenuItem py={ 2 } px={ 4 } hash={ hash }/> }
<PrivateTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
<PublicTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
</MenuList>
</Menu>
);
};
export default React.memo(AddressActions);
import { MenuItem, Icon, chakra, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import iconPrivateTags from 'icons/privattags.svg';
import { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import PrivateTagModal from 'ui/privateTags/AddressModal/AddressModal';
interface Props {
className?: string;
hash: string;
}
const PrivateTagMenuItem = ({ className, hash }: Props) => {
const modal = useDisclosure();
const queryClient = useQueryClient();
const redirectIfNotAuth = useRedirectIfNotAuth();
const queryKey = getResourceKey('address', { pathParams: { hash } });
const addressData = queryClient.getQueryData<Address>(queryKey);
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddPrivateTag = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey });
modal.onClose();
}, [ queryClient, queryKey, modal ]);
const formData = React.useMemo(() => {
return {
address_hash: hash,
};
}, [ hash ]);
if (addressData?.private_tags?.length) {
return null;
}
return (
<>
<MenuItem className={ className } onClick={ handleClick }>
<Icon as={ iconPrivateTags } boxSize={ 6 } mr={ 2 }/>
<span>Add private tag</span>
</MenuItem>
<PrivateTagModal isOpen={ modal.isOpen } onClose={ modal.onClose } onSuccess={ handleAddPrivateTag } data={ formData }/>
</>
);
};
export default React.memo(chakra(PrivateTagMenuItem));
import { MenuItem, Icon, chakra } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import iconPublicTags from 'icons/publictags.svg';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
interface Props {
className?: string;
hash: string;
}
const PublicTagMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const redirectIfNotAuth = useRedirectIfNotAuth();
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
router.push({ pathname: '/account/public_tags_request', query: { address: hash } });
}, [ hash, redirectIfNotAuth, router ]);
return (
<MenuItem className={ className }onClick={ handleClick }>
<Icon as={ iconPublicTags } boxSize={ 6 } mr={ 2 }/>
<span>Add public tag</span>
</MenuItem>
);
};
export default React.memo(chakra(PublicTagMenuItem));
import { MenuItem, Icon, chakra, useDisclosure } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import iconEdit from 'icons/edit.svg';
import useApiQuery from 'lib/api/useApiQuery';
import useHasAccount from 'lib/hooks/useHasAccount';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
interface Props {
className?: string;
hash: string;
}
const TokenInfoMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const modal = useDisclosure();
const redirectIfNotAuth = useRedirectIfNotAuth();
const isAuth = useHasAccount();
const verifiedAddressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
queryOptions: {
enabled: isAuth,
},
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: undefined },
queryOptions: {
enabled: isAuth,
},
});
const tokenInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash, chainId: appConfig.network.id },
queryOptions: {
refetchOnMount: false,
},
});
const handleAddAddressClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddApplicationClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses', query: { address: hash } });
}, [ hash, router ]);
const handleVerifiedAddressSubmit = React.useCallback(async() => {
await verifiedAddressesQuery.refetch();
}, [ verifiedAddressesQuery ]);
const handleShowMyAddressesClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses' });
}, [ router ]);
const icon = <Icon as={ iconEdit } boxSize={ 6 } mr={ 2 } p={ 1 }/>;
const content = (() => {
if (!verifiedAddressesQuery.data?.verifiedAddresses.find(({ contractAddress }) => contractAddress.toLowerCase() === hash.toLowerCase())) {
return (
<MenuItem className={ className } onClick={ handleAddAddressClick }>
{ icon }
<span>{ tokenInfoQuery.data?.tokenAddress ? 'Update token info' : 'Add token info' }</span>
</MenuItem>
);
}
const hasApplication = applicationsQuery.data?.submissions.some(({ tokenAddress }) => tokenAddress.toLowerCase() === hash.toLowerCase());
return (
<MenuItem className={ className } onClick={ handleAddApplicationClick }>
{ icon }
<span>
{
hasApplication || tokenInfoQuery.data?.tokenAddress ?
'Update token info' :
'Add token info'
}
</span>
</MenuItem>
);
})();
return (
<>
{ content }
<AddressVerificationModal
defaultAddress={ hash }
isOpen={ modal.isOpen }
onClose={ modal.onClose }
onSubmit={ handleVerifiedAddressSubmit }
onAddTokenInfoClick={ handleAddApplicationClick }
onShowListClick={ handleShowMyAddressesClick }
/>
</>
);
};
export default React.memo(chakra(TokenInfoMenuItem));
......@@ -4,13 +4,13 @@ import React from 'react';
import type { Address } from 'types/api/address';
import type { TokenInfo } from 'types/api/token';
import config from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile';
import appConfig from 'configs/app/config';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import AddressActionsMenu from 'ui/shared/AddressActions/Menu';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
......@@ -21,8 +21,6 @@ interface Props {
}
const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props) => {
const isMobile = useIsMobile();
return (
<Flex alignItems="center">
<AddressIcon address={ address } isLoading={ isLoading }/>
......@@ -31,17 +29,18 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props
hash={ address.hash }
ml={ 2 }
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
truncation={ isMobile ? 'constant' : 'none' }
isDisabled={ isLinkDisabled }
isLoading={ isLoading }
/>
<CopyToClipboard text={ address.hash } isLoading={ isLoading }/>
{ !isLoading && address.is_contract && token && <AddressAddToWallet ml={ 2 } token={ token }/> }
{ !isLoading && !address.is_contract && config.isAccountSupported && (
{ !isLoading && !address.is_contract && appConfig.isAccountSupported && (
<AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id } ml={ 3 }/>
) }
<AddressQrCode hash={ address.hash } ml={ 2 } isLoading={ isLoading }/>
{ appConfig.isAccountSupported && <AddressActionsMenu isLoading={ isLoading }/> }
</Flex>
);
};
......
......@@ -9,7 +9,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
address: AddressParam;
address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name'>;
subtitle?: string;
isLoading?: boolean;
}
......@@ -20,7 +20,7 @@ const AddressSnippet = ({ address, subtitle, isLoading }: Props) => {
<Address>
<AddressIcon address={ address } isLoading={ isLoading }/>
<AddressLink type="address" hash={ address.hash } fontWeight="600" ml={ 2 } isLoading={ isLoading }/>
<CopyToClipboard text={ address.hash } ml={ 1 } isLoading={ isLoading }/>
<CopyToClipboard text={ address.hash } isLoading={ isLoading }/>
</Address>
{ subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 } ml={ 8 }>{ subtitle }</Text> }
</Box>
......
......@@ -68,7 +68,7 @@ const DataListDisplay = (props: Props) => {
}
if (!props.items?.length) {
return <Text as="span">{ props.emptyText }</Text>;
return props.emptyText ? <Text as="span">{ props.emptyText }</Text> : null;
}
return (
......
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody } from '@chakra-ui/react';
import React from 'react';
import type { UserTags } from 'types/api/addressParams';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
}
interface Props {
className?: string;
data?: UserTags;
isLoading?: boolean;
tagsBefore?: Array<TagData | undefined>;
tagsAfter?: Array<TagData | undefined>;
contentAfter?: React.ReactNode;
}
const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
...(data?.watchlist_names || []),
...tagsAfter,
]
.filter(Boolean);
if (tags.length === 0 && !contentAfter) {
return null;
}
const content = (() => {
if (isMobile && tags.length > 2) {
return (
<>
{
tags
.slice(0, 2)
.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
{ tag.display_name }
</Tag>
))
}
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag onClick={ onToggle }>+{ tags.length - 1 }</Tag>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{
tags
.slice(2)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>)
}
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return tags.map((tag) => (
<Tag key={ tag.label } isLoading={ isLoading } isTruncated maxW={{ base: '115px', lg: 'initial' }}>
{ tag.display_name }
</Tag>
));
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
{ contentAfter }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import { Tooltip } from '@chakra-ui/react';
import React from 'react';
import shortenString from 'lib/shortenString';
interface Props {
hash: string;
isTooltipDisabled?: boolean;
......@@ -13,7 +15,7 @@ const HashStringShorten = ({ hash, isTooltipDisabled }: Props) => {
return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled }>
{ hash.slice(0, 4) + '...' + hash.slice(-4) }
{ shortenString(hash) }
</Tooltip>
);
};
......
......@@ -14,7 +14,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import type { FontFace } from 'use-font-face-observer';
import useFontFaceObserver from 'use-font-face-observer';
import { BODY_TYPEFACE } from 'theme/foundations/typography';
import { BODY_TYPEFACE, HEADING_TYPEFACE } from 'theme/foundations/typography';
const TAIL_LENGTH = 4;
const HEAD_MIN_LENGTH = 4;
......@@ -31,6 +31,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled
const isFontFaceLoaded = useFontFaceObserver([
{ family: BODY_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
{ family: HEADING_TYPEFACE, weight: String(fontWeight) as FontFace['weight'] },
]);
const calculateString = useCallback(() => {
......
......@@ -11,7 +11,7 @@ interface Props {
const LinkExternal = ({ href, children, className }: Props) => {
return (
<Link className={ className } fontSize="sm" display="inline-block" alignItems="center" target="_blank" href={ href }>
<Link className={ className } fontSize="sm" lineHeight={ 5 } display="inline-block" alignItems="center" target="_blank" href={ href }>
{ children }
<Icon as={ arrowIcon } boxSize={ 4 } verticalAlign="middle"/>
</Link>
......
import { Flex, Button, Icon, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { NetworkExplorer as TNetworkExplorer } from 'types/networks';
import appConfig from 'configs/app/config';
import arrowIcon from 'icons/arrows/east-mini.svg';
import explorerIcon from 'icons/explorer.svg';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
className?: string;
type: keyof TNetworkExplorer['paths'];
pathParam: string;
hideText?: boolean;
}
const NetworkExplorers = ({ className, type, pathParam, hideText }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const explorersLinks = appConfig.network.explorers
.filter((explorer) => explorer.paths[type])
.map((explorer) => {
const url = new URL(explorer.paths[type] + '/' + pathParam, explorer.baseUrl);
return <LinkExternal key={ explorer.baseUrl } href={ url.toString() }>{ explorer.title }</LinkExternal>;
});
if (explorersLinks.length === 0) {
return null;
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
className={ className }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Verify in other explorers"
fontWeight={ 500 }
px={ 2 }
h="30px"
>
<Icon as={ explorerIcon } boxSize={ 5 } mr={ hideText ? 0 : 1 }/>
{ !hideText && <span>Explorers</span> }
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">Verify with other explorers</chakra.span>
<Flex
alignItems="center"
flexWrap="wrap"
columnGap={ 6 }
rowGap={ 3 }
mt={ 3 }
>
{ explorersLinks }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(chakra(NetworkExplorers));
// import { Icon } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
// import plusIcon from 'icons/plus.svg';
import * as textAdMock from 'mocks/ad/textAd';
import TestApp from 'playwright/TestApp';
import PageTitle from './PageTitle';
import DefaultView from './specs/DefaultView';
import LongNameAndManyTags from './specs/LongNameAndManyTags';
import WithTextAd from './specs/WithTextAd';
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
......@@ -19,51 +19,38 @@ test.beforeEach(async({ page }) => {
path: './playwright/image_s.jpg',
});
});
await page.route('https://example.com/logo.png', (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_s.jpg',
});
});
});
test('default view +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="Title"
/>
<DefaultView/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with text ad, back link and addons +@mobile +@dark-mode', async({ mount }) => {
// https://github.com/microsoft/playwright/issues/15620
// not possible to pass component as a prop in tests
// const left = <Icon as={ plusIcon }/>;
test('with text ad +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="Title"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
// additionalsLeft={ left }
additionalsRight="Privet"
/>
<WithTextAd/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('long title with text ad, back link and addons +@mobile', async({ mount }) => {
test('with long name and many tags +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<PageTitle
text="This title is long, really long"
withTextAd
backLinkLabel="Back"
backLinkUrl="back"
additionalsRight="Privet, kak dela?"
/>
<LongNameAndManyTags/>
</TestApp>,
);
......
import { Heading, Flex, Grid, Tooltip, Icon, chakra, Skeleton } from '@chakra-ui/react';
import { Heading, Flex, Tooltip, Icon, Link, chakra, Box, Skeleton } from '@chakra-ui/react';
import React from 'react';
import eastArrowIcon from 'icons/arrows/east.svg';
import TextAd from 'ui/shared/ad/TextAd';
import LinkInternal from 'ui/shared/LinkInternal';
type BackLinkProp = { label: string; url: string } | { label: string; onClick: () => void };
type Props = {
text: string;
additionalsLeft?: React.ReactNode;
additionalsRight?: React.ReactNode;
withTextAd?: boolean;
title: string;
className?: string;
backLinkLabel?: string;
backLinkUrl?: string;
backLink?: BackLinkProp;
beforeTitle?: React.ReactNode;
afterTitle?: React.ReactNode;
contentAfter?: React.ReactNode;
isLoading?: boolean;
withTextAd?: boolean;
}
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLinkUrl, backLinkLabel, className, isLoading }: Props) => {
const title = (
<Skeleton isLoaded={ !isLoading }>
<Heading
as="h1"
size="lg"
flex="none"
wordBreak="break-word"
>
{ text }
</Heading>
</Skeleton>
const BackLink = (props: BackLinkProp & { isLoading?: boolean }) => {
if (!props) {
return null;
}
if (props.isLoading) {
return <Skeleton boxSize={ 6 } display="inline-block" borderRadius="base" mr={ 3 } isLoaded={ !props.isLoading }/>;
}
const icon = <Icon as={ eastArrowIcon } boxSize={ 6 } transform="rotate(180deg)" margin="auto"/>;
if ('url' in props) {
return (
<Tooltip label={ props.label }>
<LinkInternal display="inline-flex" href={ props.url } h="40px" mr={ 3 }>
{ icon }
</LinkInternal>
</Tooltip>
);
}
return (
<Tooltip label={ props.label }>
<Link display="inline-flex" onClick={ props.onClick } h="40px" mr={ 3 }>
{ icon }
</Link>
</Tooltip>
);
};
const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoading, afterTitle, beforeTitle }: Props) => {
return (
<Flex
columnGap={ 3 }
rowGap={ 3 }
alignItems={{ base: 'start', lg: 'center' }}
flexDirection={{ base: 'column', lg: 'row' }}
mb={ 6 }
justifyContent="space-between"
className={ className }
>
<Flex flexWrap="wrap" columnGap={ 3 } alignItems="center" width={ withTextAd ? 'unset' : '100%' } flexShrink={ 0 }>
<Grid
templateColumns={ [ backLinkUrl && 'auto', additionalsLeft && 'auto', '1fr' ].filter(Boolean).join(' ') }
mb={ 6 }
flexDir="row"
flexWrap="wrap"
rowGap={ 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 }
</Grid>
{ additionalsRight }
</Flex>
{ withTextAd && <TextAd flexShrink={ 100 }/> }
</Heading>
</Skeleton>
{ afterTitle }
</Box>
{ contentAfter }
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto"/> }
</Flex>
);
};
......
import { Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import iconSuccess from 'icons/status/success.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import TokenLogo from 'ui/shared/TokenLogo';
import PageTitle from '../PageTitle';
const DefaultView = () => {
const isMobile = useIsMobile();
const tokenData: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHAAAAAAAAAAAAA',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: 'https://example.com/logo.png',
};
const backLink = {
label: 'Back to tokens list',
url: 'https://localhost:3000/tokens',
};
const contentAfter = (
<EntityTags
tagsBefore={ [
{ label: 'example', display_name: 'Example label' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto" hideText={ isMobile }/> }
flexGrow={ 1 }
/>
);
return (
<PageTitle
title="Shavukha Token (SHVKH) token"
beforeTitle={ (
<TokenLogo data={ tokenData } boxSize={ 6 } display="inline-block" mr={ 2 }/>
) }
afterTitle={ <Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> }
backLink={ backLink }
contentAfter={ contentAfter }
/>
);
};
export default DefaultView;
/* eslint-disable max-len */
import { Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import iconSuccess from 'icons/status/success.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import EntityTags from 'ui/shared/EntityTags';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import TokenLogo from 'ui/shared/TokenLogo';
import PageTitle from '../PageTitle';
const LongNameAndManyTags = () => {
const isMobile = useIsMobile();
const tokenData: TokenInfo = {
address: '0xa77A39CC9680B10C00af5D4ABFc92e1F07406c64',
decimals: null,
exchange_rate: null,
holders: '294',
icon_url: null,
name: 'Ring ding ding daa baa Baa aramba baa bom baa barooumba Wh-wha-what&#39;s going on-on? Ding, ding This is the Crazy Frog Ding, ding Bem, bem! Ring ding ding ding ding ding Ring ding ding ding bem bem bem Ring ding ding ding ding ding Ring ding ding ding baa b',
symbol: 'BatcoiRing ding ding daa baa Baa aramba baa bom baa barooumba Wh-wha-what&#39;s going on-on? Ding, ding This is the Crazy Frog Ding, ding Bem, bem! Ring ding ding ding ding ding Ring ding ding ding bem bem bem Ring ding ding ding ding ding Ring ding ding ding',
total_supply: '13747',
type: 'ERC-721',
};
const contentAfter = (
<EntityTags
data={{
private_tags: [ privateTag ],
public_tags: [ publicTag ],
watchlist_names: [ watchlistName ],
}}
tagsBefore={ [
{ label: 'example', display_name: 'Example with long name' },
] }
tagsAfter={ [
{ label: 'after_1', display_name: 'Another tag' },
{ label: 'after_2', display_name: 'And yet more' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto" hideText={ isMobile }/> }
flexGrow={ 1 }
/>
);
const tokenSymbolText = ` (${ trimTokenSymbol(tokenData.symbol) })`;
return (
<PageTitle
title={ `${ tokenData?.name }${ tokenSymbolText } token` }
beforeTitle={ (
<TokenLogo data={ tokenData } boxSize={ 6 } display="inline-block" mr={ 2 }/>
) }
afterTitle={ <Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> }
contentAfter={ contentAfter }
/>
);
};
export default LongNameAndManyTags;
import React from 'react';
import Tag from 'ui/shared/chakra/Tag';
import PageTitle from '../PageTitle';
const WithTextAd = () => {
const backLink = {
label: 'Back to Home',
url: 'https://localhost:3000',
};
return (
<PageTitle
title="Block"
backLink={ backLink }
contentAfter={ <Tag key="custom" colorScheme="orange" variant="solid">Awesome</Tag> }
withTextAd
/>
);
};
export default WithTextAd;
import { Image, chakra, useColorModeValue, Icon, Skeleton } from '@chakra-ui/react';
import { Image, chakra, Skeleton } from '@chakra-ui/react';
import React from 'react';
import appConfig from 'configs/app/config';
import tokenPlaceholderIcon from 'icons/token-placeholder.svg';
const EmptyElement = ({ className }: { className?: string }) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
import type { TokenInfo } from 'types/api/token';
return (
<Icon
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
import appConfig from 'configs/app/config';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
hash?: string;
name?: string | null;
export interface Props {
data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name'>;
className?: string;
isLoading?: boolean;
}
const TokenLogo = ({ hash, name, className, isLoading }: Props) => {
const TokenLogo = ({ className, isLoading, data }: Props) => {
if (isLoading) {
return <Skeleton className={ className } borderRadius="base"/>;
}
const logoSrc = appConfig.network.assetsPathname && hash ? [
const logoSrc = (() => {
if (data?.icon_url) {
return data.icon_url;
}
if (appConfig.network.assetsPathname && data?.address) {
return [
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/',
appConfig.network.assetsPathname,
'/assets/',
hash,
data.address,
'/logo.png',
].join('') : undefined;
].join('');
}
})();
return (
<Image
borderRadius="base"
className={ className }
src={ logoSrc }
alt={ `${ name || 'token' } logo` }
fallback={ <EmptyElement className={ className }/> }
alt={ `${ data?.name || 'token' } logo` }
fallback={ <TokenLogoPlaceholder className={ className }/> }
/>
);
};
......
import { chakra, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import tokenPlaceholderIcon from 'icons/token-placeholder.svg';
const TokenLogoPlaceholder = ({ className }: { className?: string }) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
return (
<Icon
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
export default chakra(TokenLogoPlaceholder);
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import TestApp from 'playwright/TestApp';
import TokenSnippet from './TokenSnippet';
const API_URL = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x363574E6C5C71c343d7348093D84320c76d5Dd29/logo.png';
test.use(devices['iPhone 13 Pro']);
test('unnamed', async({ mount }) => {
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'xDAI',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: null,
};
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" symbol="xDAI"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
......@@ -20,16 +31,40 @@ test('unnamed', async({ mount }) => {
});
test('named', async({ mount }) => {
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHA',
name: 'Shavuha token',
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: null,
};
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29" name="Shavuha token" symbol="SHA"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with logo', async({ mount, page }) => {
test('with logo and long symbol', async({ mount, page }) => {
const API_URL = 'https://example.com/logo.png';
const data: TokenInfo = {
address: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
type: 'ERC-20',
symbol: 'SHAAAAAAAAAAAAA',
name: null,
decimals: '18',
holders: '1',
exchange_rate: null,
total_supply: null,
icon_url: API_URL,
};
await page.route(API_URL, (route) => {
return route.fulfill({
status: 200,
......@@ -39,7 +74,7 @@ test('with logo', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TokenSnippet hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29"/>
<TokenSnippet data={ data }/>
</TestApp>,
);
......
import { Flex, Text, chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenLogo from 'ui/shared/TokenLogo';
interface Props {
symbol?: string | null;
hash: string;
name?: string | null;
data?: Pick<TokenInfo, 'address' | 'icon_url' | 'name' | 'symbol'>;
className?: string;
logoSize?: number;
isDisabled?: boolean;
hideSymbol?: boolean;
}
const TokenSnippet = ({ symbol, hash, name, className, logoSize = 6, isDisabled }: Props) => {
const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol }: Props) => {
return (
<Flex className={ className } alignItems="center" columnGap={ 2 } w="100%">
<TokenLogo boxSize={ logoSize } hash={ hash } name={ name }/>
<AddressLink hash={ hash } alias={ name } type="token" isDisabled={ isDisabled }/>
{ symbol && <Text variant="secondary">({ symbol })</Text> }
<TokenLogo boxSize={ logoSize } data={ data }/>
<AddressLink hash={ data?.address || '' } alias={ data?.name || 'Unnamed token' } type="token" isDisabled={ isDisabled }/>
{ data?.symbol && !hideSymbol && <Text variant="secondary">({ trimTokenSymbol(data.symbol) })</Text> }
</Flex>
);
};
......
......@@ -52,7 +52,7 @@ const TokenTransferListItem = ({
<ListItemMobile rowGap={ 3 } isAnimated>
<Flex w="100%" justifyContent="space-between">
<Flex flexWrap="wrap" rowGap={ 1 } mr={ showTxInfo && txHash ? 2 : 0 }>
<TokenSnippet hash={ token.address } w="auto" maxW="calc(100% - 140px)" name={ token.name || 'Unnamed token' }/>
<TokenSnippet data={ token } w="auto" maxW="calc(100% - 140px)" hideSymbol/>
<Tag flexShrink={ 0 } ml={ 2 } mr={ 2 }>{ token.type }</Tag>
<Tag colorScheme="orange">{ getTokenTransferTypeText(type) }</Tag>
</Flex>
......
......@@ -45,7 +45,7 @@ const TokenTransferTableItem = ({
) }
<Td>
<Flex flexDir="column" alignItems="flex-start">
<TokenSnippet hash={ token.address } name={ token.name || 'Unnamed token' } lineHeight="30px"/>
<TokenSnippet data={ token } lineHeight="30px" hideSymbol/>
<Tag mt={ 1 }>{ token.type }</Tag>
<Tag colorScheme="orange" mt={ 2 }>{ getTokenTransferTypeText(type) }</Tag>
</Flex>
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import appConfig from 'configs/app/config';
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
interface Props {
children: React.ReactNode;
fallback?: JSX.Element | (() => JSX.Element);
}
const Web3ModalProvider = ({ children, fallback }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
if (!wagmiClient || !ethereumClient) {
return typeof fallback === 'function' ? fallback() : (fallback || null);
}
return (
<WagmiConfig client={ wagmiClient }>
{ children }
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
);
};
export default Web3ModalProvider;
......@@ -8,7 +8,7 @@ interface Props extends TagProps {
isLoading?: boolean;
}
const Tag = ({ isLoading, ...props }: Props) => {
const Tag = ({ isLoading, ...props }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
if (props.isTruncated && typeof props.children === 'string') {
if (!props.children) {
......@@ -18,16 +18,16 @@ const Tag = ({ isLoading, ...props }: Props) => {
return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<TruncatedTextTooltip label={ props.children }>
<ChakraTag { ...props }/>
<ChakraTag { ...props } ref={ ref }/>
</TruncatedTextTooltip>
</Skeleton>
);
}
return (
<Skeleton isLoaded={ !isLoading } display="inline-block" borderRadius="sm" maxW="100%">
<ChakraTag { ...props }/>
<ChakraTag { ...props } ref={ ref }/>
</Skeleton>
);
};
export default React.memo(Tag);
export default React.memo(React.forwardRef(Tag));
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);
import { Flex, Link, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import iconDocs from 'icons/docs.svg';
import iconEmail from 'icons/email.svg';
import iconLink from 'icons/link.svg';
import iconCoinGecko from 'icons/social/coingecko.svg';
import iconCoinMarketCap from 'icons/social/coinmarketcap.svg';
import iconDefiLlama from 'icons/social/defi_llama.svg';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import LinkExternal from 'ui/shared/LinkExternal';
import TextSeparator from 'ui/shared/TextSeparator';
interface Props {
data: TokenVerifiedInfo;
}
interface TServiceLink {
field: keyof TokenVerifiedInfo;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
hint: string;
}
const SOCIAL_LINKS: Array<TServiceLink> = [
{ field: 'github', icon: iconGithub, hint: 'Github account' },
{ field: 'twitter', icon: iconTwitter, hint: 'Twitter account' },
{ field: 'telegram', icon: iconTelegram, hint: 'Telegram account' },
{ field: 'openSea', icon: iconOpenSea, hint: 'OpenSea page' },
{ field: 'linkedin', icon: iconLinkedIn, hint: 'LinkedIn page' },
{ field: 'facebook', icon: iconFacebook, hint: 'Facebook account' },
{ field: 'discord', icon: iconDiscord, hint: 'Discord account' },
{ field: 'medium', icon: iconMedium, hint: 'Medium account' },
{ field: 'slack', icon: iconSlack, hint: 'Slack account' },
{ field: 'reddit', icon: iconReddit, hint: 'Reddit account' },
];
const PRICE_TICKERS: Array<TServiceLink> = [
{ field: 'coinGeckoTicker', icon: iconCoinGecko, hint: 'Coin Gecko' },
{ field: 'coinMarketCapTicker', icon: iconCoinMarketCap, hint: 'Coin Market Cap' },
{ field: 'defiLlamaTicker', icon: iconDefiLlama, hint: 'Defi Llama' },
];
const ServiceLink = ({ href, hint, icon }: TServiceLink & { href: string | undefined }) => (
<Link
href={ href }
variant="secondary"
boxSize={ 5 }
aria-label={ hint }
title={ hint }
target="_blank"
>
<Icon as={ icon } boxSize={ 5 }/>
</Link>
);
// todo_tom DELETE ME
const TokenDetailsVerifiedInfo = ({ data }: Props) => {
const websiteLink = (() => {
try {
const url = new URL(data.projectWebsite);
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconLink } boxSize={ 6 }/>
<LinkExternal href={ data.projectWebsite } fontSize="md">{ url.host }</LinkExternal>
</Flex>
);
} catch (error) {
return null;
}
})();
const docsLink = (() => {
if (!data.docs) {
return null;
}
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconDocs } boxSize={ 6 }/>
<LinkExternal href={ data.docs } fontSize="md">Documentation</LinkExternal>
</Flex>
);
})();
const supportLink = (() => {
if (!data.support) {
return null;
}
const isEmail = data.support.includes('@');
const href = isEmail ? `mailto:${ data.support }` : data.support;
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconEmail } boxSize={ 6 }/>
<Link href={ href } target="_blank">
{ data.support }
</Link>
</Flex>
);
})();
const socialLinks = SOCIAL_LINKS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
const priceTickersLinks = PRICE_TICKERS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
return (
<DetailsInfoItem
title="Links"
hint="Links to the project's official website and social media channels."
>
<Flex flexDir="column" rowGap={ 5 }>
<Flex
flexDir={{ base: 'column', lg: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
columnGap={ 6 }
rowGap={ 2 }
>
{ websiteLink }
{ docsLink }
{ supportLink }
</Flex>
{ (socialLinks.length > 0 || priceTickersLinks.length > 0) && (
<Flex
columnGap={ 2 }
rowGap={ 2 }
flexWrap="wrap"
>
{ socialLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
{ priceTickersLinks.length > 0 && (
<>
<TextSeparator color="divider"/>
{ priceTickersLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</>
) }
</Flex>
) }
</Flex>
</DetailsInfoItem>
);
};
export default React.memo(TokenDetailsVerifiedInfo);
import {
Popover, PopoverTrigger, PopoverContent, PopoverBody,
Modal, ModalContent, ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile';
import Content from './TokenProjectInfo/Content';
import TriggerButton from './TokenProjectInfo/TriggerButton';
interface Props {
data: TokenVerifiedInfo;
}
const TokenProjectInfo = ({ data }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
if (isMobile) {
return (
<>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
<Content data={ data }/>
</ModalContent>
</Modal>
</>
);
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
<Content data={ data }/>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(TokenProjectInfo);
import { Flex, Text, Box, Grid } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import iconCoinGecko from 'icons/social/coingecko.svg';
import iconCoinMarketCap from 'icons/social/coinmarketcap.svg';
import iconDefiLlama from 'icons/social/defi_llama.svg';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import DocsLink from './DocsLink';
import type { Props as ServiceLinkProps } from './ServiceLink';
import ServiceLink from './ServiceLink';
import SupportLink from './SupportLink';
interface Props {
data: TokenVerifiedInfo;
}
const SOCIAL_LINKS: Array<Omit<ServiceLinkProps, 'href'>> = [
{ field: 'github', icon: iconGithub, title: 'Github' },
{ field: 'twitter', icon: iconTwitter, title: 'Twitter' },
{ field: 'telegram', icon: iconTelegram, title: 'Telegram' },
{ field: 'openSea', icon: iconOpenSea, title: 'OpenSea' },
{ field: 'linkedin', icon: iconLinkedIn, title: 'LinkedIn' },
{ field: 'facebook', icon: iconFacebook, title: 'Facebook' },
{ field: 'discord', icon: iconDiscord, title: 'Discord' },
{ field: 'medium', icon: iconMedium, title: 'Medium' },
{ field: 'slack', icon: iconSlack, title: 'Slack' },
{ field: 'reddit', icon: iconReddit, title: 'Reddit' },
];
const PRICE_TICKERS: Array<Omit<ServiceLinkProps, 'href'>> = [
{ field: 'coinGeckoTicker', icon: iconCoinGecko, title: 'CoinGecko' },
{ field: 'coinMarketCapTicker', icon: iconCoinMarketCap, title: 'CoinMarketCap' },
{ field: 'defiLlamaTicker', icon: iconDefiLlama, title: 'DefiLlama' },
];
const Content = ({ data }: Props) => {
const docs = <DocsLink href={ data.docs }/>;
const support = <SupportLink url={ data.support }/>;
const description = data.projectDescription ? <Text fontSize="sm" mt={ 3 }>{ data.projectDescription }</Text> : null;
const socialLinks = SOCIAL_LINKS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
const priceTickersLinks = PRICE_TICKERS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
return (
<Box fontSize="sm">
{ (description || docs || support) && (
<>
<Text variant="secondary" fontSize="xs">Description and support info</Text>
{ description }
{ (docs || support) && (
<Flex alignItems="center" flexWrap="wrap" columnGap={ 6 } mt={ 3 }>
{ support }
{ docs }
</Flex>
) }
</>
) }
{ socialLinks.length > 0 && (
<>
<Text variant="secondary" fontSize="xs" mt={ 5 }>Links</Text>
<Grid templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} columnGap={ 4 } rowGap={ 3 } mt={ 3 }>
{ socialLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</Grid>
</>
) }
{ priceTickersLinks.length > 0 && (
<>
<Text variant="secondary" fontSize="xs" mt={ 5 }>Crypto markets</Text>
<Grid templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} columnGap={ 4 } rowGap={ 3 } mt={ 3 }>
{ priceTickersLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</Grid>
</>
) }
</Box>
);
};
export default Content;
import { Icon, Link } from '@chakra-ui/react';
import React from 'react';
import iconDocs from 'icons/docs.svg';
interface Props {
href?: string;
}
const DocsLink = ({ href }: Props) => {
if (!href) {
return null;
}
return (
<Link
href={ href }
target="_blank"
display="inline-flex"
alignItems="center"
columnGap={ 1 }
>
<Icon as={ iconDocs } boxSize={ 6 } color="text_secondary"/>
<span>Documentation</span>
</Link>
);
};
export default DocsLink;
import { Link, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
export interface Props {
field: keyof TokenVerifiedInfo;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
title: string;
href?: string;
}
const ServiceLink = ({ href, title, icon }: Props) => {
return (
<Link
href={ href }
aria-label={ title }
title={ title }
target="_blank"
display="inline-flex"
alignItems="center"
>
<Icon as={ icon } boxSize={ 5 } mr={ 2 } color="text_secondary"/>
<span>{ title }</span>
</Link>
);
};
export default ServiceLink;
import { Icon, Link } from '@chakra-ui/react';
import React from 'react';
import iconEmail from 'icons/email.svg';
import iconLink from 'icons/link.svg';
interface Props {
url?: string;
}
const SupportLink = ({ url }: Props) => {
if (!url) {
return null;
}
const isEmail = url.includes('@');
const href = isEmail ? `mailto:${ url }` : url;
const icon = isEmail ? iconEmail : iconLink;
return (
<Link
href={ href }
target="_blank"
display="inline-flex"
alignItems="center"
columnGap={ 1 }
>
<Icon as={ icon } boxSize={ 6 } color="text_secondary"/>
<span>{ url }</span>
</Link>
);
};
export default SupportLink;
import { Button, Icon } from '@chakra-ui/react';
import React from 'react';
import arrowIcon from 'icons/arrows/east-mini.svg';
import rocketIcon from 'icons/rocket.svg';
interface Props {
onClick: () => void;
isOpen: boolean;
}
const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onClick }
aria-label="Show project info"
fontWeight={ 500 }
px={ 2 }
h="30px"
>
<Icon as={ rocketIcon } boxSize={ 4 } mr={ 1 }/>
<span>Project Info</span>
<Icon as={ arrowIcon } transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button>
);
};
export default React.forwardRef(TriggerButton);
import { Text, Flex, Icon, useColorModeValue, Skeleton } from '@chakra-ui/react';
import { Flex, Icon, useColorModeValue, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -63,13 +63,11 @@ const TokenTransferListItem = ({
</Address>
</Flex>
{ timestamp && (
<Text variant="secondary" fontWeight="400" fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">
<Skeleton isLoaded={ !isLoading } display="inline-block" fontWeight="400" fontSize="sm" color="text_secondary">
<span>
{ timeAgo }
</span>
</Skeleton>
</Text>
) }
</Flex>
{ method && <Tag isLoading={ isLoading }>{ method }</Tag> }
......
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TokenVerifiedInfo as TTokenVerifiedInfo } from 'types/api/token';
import LinkExternal from 'ui/shared/LinkExternal';
import TokenProjectInfo from './TokenProjectInfo';
interface Props {
verifiedInfoQuery: UseQueryResult<TTokenVerifiedInfo>;
isVerifiedInfoEnabled: boolean;
}
const TokenVerifiedInfo = ({ verifiedInfoQuery, isVerifiedInfoEnabled }: Props) => {
const { data, isLoading, isError } = verifiedInfoQuery;
const websiteLinkBg = useColorModeValue('gray.100', 'gray.700');
const content = (() => {
if (!isVerifiedInfoEnabled) {
return null;
}
if (isLoading) {
return (
<>
<Skeleton w="130px" h="30px" borderRadius="base"/>
<Skeleton w="130px" h="30px" borderRadius="base"/>
<Skeleton w="120px" h="30px" borderRadius="base"/>
</>
);
}
if (isError) {
return null;
}
const websiteLink = (() => {
try {
const url = new URL(data.projectWebsite);
return (
<LinkExternal href={ data.projectWebsite } px="10px" py="5px" bgColor={ websiteLinkBg } borderRadius="base">{ url.host }</LinkExternal>
);
} catch (error) {
return null;
}
})();
return (
<>
{ websiteLink }
{ Boolean(data.tokenAddress) && <TokenProjectInfo data={ data }/> }
</>
);
})();
return <Flex columnGap={ 3 } rowGap={ 3 } mt={ 5 } flexWrap="wrap" _empty={{ display: 'none' }}>{ content }</Flex>;
};
export default React.memo(TokenVerifiedInfo);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TokenInfoForm from './TokenInfoForm';
const FORM_CONFIG_URL = buildApiUrl('token_info_applications_config', { chainId: '1' });
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
});
});
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
const props = {
address: mocks.VERIFIED_ADDRESS.ITEM_1.contractAddress,
tokenName: 'Test Token (TT)',
application: mocks.TOKEN_INFO_APPLICATION.APPROVED,
onSubmit: () => {},
};
const component = await mount(
<TestApp>
<TokenInfoForm { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('status IN_PROCESS', async({ mount, page }) => {
await page.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
return route.fulfill({
status: 200,
path: './playwright/image_md.jpg',
});
});
await page.route(FORM_CONFIG_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
const props = {
address: mocks.VERIFIED_ADDRESS.ITEM_1.contractAddress,
tokenName: 'Test Token (TT)',
application: mocks.TOKEN_INFO_APPLICATION.IN_PROCESS,
onSubmit: () => {},
};
const component = await mount(
<TestApp>
<TokenInfoForm { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Button, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { Fields } from './types';
import type { TokenInfoApplication } from 'types/api/account';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
import useToast from 'lib/hooks/useToast';
import useUpdateEffect from 'lib/hooks/useUpdateEffect';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TokenInfoFieldAddress from './fields/TokenInfoFieldAddress';
import TokenInfoFieldComment from './fields/TokenInfoFieldComment';
import TokenInfoFieldDocs from './fields/TokenInfoFieldDocs';
import TokenInfoFieldIconUrl from './fields/TokenInfoFieldIconUrl';
import TokenInfoFieldPriceTicker from './fields/TokenInfoFieldPriceTicker';
import TokenInfoFieldProjectDescription from './fields/TokenInfoFieldProjectDescription';
import TokenInfoFieldProjectEmail from './fields/TokenInfoFieldProjectEmail';
import TokenInfoFieldProjectName from './fields/TokenInfoFieldProjectName';
import TokenInfoFieldProjectSector from './fields/TokenInfoFieldProjectSector';
import TokenInfoFieldProjectWebsite from './fields/TokenInfoFieldProjectWebsite';
import TokenInfoFieldRequesterEmail from './fields/TokenInfoFieldRequesterEmail';
import TokenInfoFieldRequesterName from './fields/TokenInfoFieldRequesterName';
import TokenInfoFieldSocialLink from './fields/TokenInfoFieldSocialLink';
import TokenInfoFieldSupport from './fields/TokenInfoFieldSupport';
import TokenInfoFieldTokenName from './fields/TokenInfoFieldTokenName';
import TokenInfoFormSectionHeader from './TokenInfoFormSectionHeader';
import TokenInfoFormStatusText from './TokenInfoFormStatusText';
import { getFormDefaultValues, prepareRequestBody } from './utils';
interface Props {
address: string;
tokenName: string;
application?: TokenInfoApplication;
onSubmit: (application: TokenInfoApplication) => void;
}
const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) => {
const containerRef = React.useRef<HTMLFormElement>(null);
const apiFetch = useApiFetch();
const toast = useToast();
const configQuery = useApiQuery('token_info_applications_config', {
pathParams: { chainId: appConfig.network.id },
});
const formApi = useForm<Fields>({
mode: 'onBlur',
defaultValues: getFormDefaultValues(address, tokenName, application),
});
const { handleSubmit, formState, control, trigger } = formApi;
const onFormSubmit: SubmitHandler<Fields> = React.useCallback(async(data) => {
try {
const submission = prepareRequestBody(data);
const isNewApplication = !application?.id || [ 'REJECTED', 'APPROVED' ].includes(application.status);
const result = await apiFetch<'token_info_applications', TokenInfoApplication, { message: string }>('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: !isNewApplication ? application.id : undefined },
fetchParams: {
method: isNewApplication ? 'POST' : 'PUT',
body: { submission },
},
});
if ('id' in result) {
onSubmit(result);
} else {
throw result;
}
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: (error as ResourceError<{ message: string }>)?.payload?.message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ apiFetch, application?.id, application?.status, onSubmit, toast ]);
useUpdateEffect(() => {
if (formState.submitCount > 0 && !formState.isValid) {
containerRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ formState.isValid, formState.submitCount ]);
if (configQuery.isError) {
return <DataFetchAlert/>;
}
if (configQuery.isLoading) {
return <ContentLoader/>;
}
const fieldProps = { control, isReadOnly: application?.status === 'IN_PROCESS' };
return (
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<TokenInfoFormStatusText application={ application }/>
<Grid mt={ 8 } gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }} columnGap={ 5 } rowGap={ 5 }>
<TokenInfoFieldTokenName { ...fieldProps }/>
<TokenInfoFieldAddress { ...fieldProps }/>
<TokenInfoFieldRequesterName { ...fieldProps }/>
<TokenInfoFieldRequesterEmail { ...fieldProps }/>
<TokenInfoFormSectionHeader>Project info</TokenInfoFormSectionHeader>
<TokenInfoFieldProjectName { ...fieldProps }/>
<TokenInfoFieldProjectSector { ...fieldProps } config={ configQuery.data.projectSectors }/>
<TokenInfoFieldProjectEmail { ...fieldProps }/>
<TokenInfoFieldProjectWebsite { ...fieldProps }/>
<TokenInfoFieldDocs { ...fieldProps }/>
<TokenInfoFieldSupport { ...fieldProps }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldIconUrl { ...fieldProps } trigger={ trigger }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldProjectDescription { ...fieldProps }/>
</GridItem>
<TokenInfoFormSectionHeader>Links</TokenInfoFormSectionHeader>
<TokenInfoFieldSocialLink { ...fieldProps } name="github"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="twitter"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="telegram"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="opensea"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="linkedin"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="facebook"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="discord"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="medium"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="slack"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="reddit"/>
<TokenInfoFormSectionHeader>Price data</TokenInfoFormSectionHeader>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_market_cap" label="CoinMarketCap URL"/>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_gecko" label="CoinGecko URL"/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_defi_llama" label="DefiLlama URL "/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldComment { ...fieldProps }/>
</GridItem>
</Grid>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ application?.status === 'IN_PROCESS' }
>
Send request
</Button>
</form>
);
};
export default React.memo(TokenInfoForm);
import { GridItem } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const TokenInfoFormSectionHeader = ({ children }: Props) => {
return (
<GridItem colSpan={{ base: 1, lg: 2 }} fontFamily="heading" fontSize="lg" fontWeight={ 500 } mt={ 3 }>
{ children }
</GridItem>
);
};
export default TokenInfoFormSectionHeader;
import { Alert } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
interface Props {
application?: TokenInfoApplication;
}
const TokenInfoFormStatusText = ({ application }: Props) => {
if (!application) {
return null;
}
switch (application.status) {
case 'IN_PROCESS': {
return (
<div>
<div>Requests are sent to a moderator for review and approval. This process can take several days.</div>
<Alert status="warning" mt={ 6 }>Request in progress. Once an admin approves your request you can edit token info.</Alert>
</div>
);
}
case 'UPDATE_REQUIRED': {
return (
<div>
{ application.adminComments && <Alert status="warning" mt={ 6 }>{ application.adminComments }</Alert> }
</div>
);
}
case 'REJECTED': {
return (
<div>
{ application.adminComments && <Alert status="warning" mt={ 6 }>{ application.adminComments }</Alert> }
</div>
);
}
default:
return null;
}
};
export default React.memo(TokenInfoFormStatusText);
import { Center, Image, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
url: string | undefined;
onLoad?: () => void;
onError?: () => void;
isInvalid: boolean;
}
const TokenInfoIconPreview = ({ url, onError, onLoad, isInvalid }: Props) => {
const borderColor = useColorModeValue('gray.100', 'gray.700');
const borderColorFilled = useColorModeValue('gray.300', 'gray.600');
const borderColorError = useColorModeValue('red.400', 'red.300');
const borderColorActive = isInvalid ? borderColorError : borderColorFilled;
return (
<Center
boxSize={{ base: '60px', lg: '80px' }}
flexShrink={ 0 }
borderWidth="2px"
borderColor={ url ? borderColorActive : borderColor }
borderRadius="base"
>
<Image
borderRadius="base"
src={ url }
alt="Token logo preview"
boxSize={{ base: 10, lg: 12 }}
objectFit="cover"
fallback={ url && !isInvalid ? <Skeleton boxSize={{ base: 10, lg: 12 }}/> : <TokenLogoPlaceholder boxSize={{ base: 10, lg: 12 }}/> }
onError={ onError }
onLoad={ onLoad }
/>
</Center>
);
};
export default React.memo(TokenInfoIconPreview);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldAddress = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isDisabled
/>
<InputPlaceholder text="Token contract address"/>
</FormControl>
);
}, []);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldAddress);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldComment = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'comment'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Comment" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="comment"
control={ control }
render={ renderControl }
rules={{ maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldComment);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldDocs = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'docs'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Docs" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="docs"
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldDocs);
import { FormControl, Flex, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, UseFormTrigger } from 'react-hook-form';
import { useController } from 'react-hook-form';
import type { Fields } from '../types';
import { times } from 'lib/html-entities';
import { validator as validateUrl } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import TokenInfoIconPreview from '../TokenInfoIconPreview';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
trigger: UseFormTrigger<Fields>;
}
const TokenInfoFieldIconUrl = ({ control, isReadOnly, trigger }: Props) => {
const validatePreview = React.useCallback(() => {
return imageLoadError.current ? 'Unable to load image' : true;
}, [ ]);
const { field, formState, fieldState } = useController({
name: 'icon_url',
control,
rules: {
required: true,
validate: { url: validateUrl, preview: validatePreview },
},
});
const [ valueForPreview, setValueForPreview ] = React.useState<string>(field.value);
const imageLoadError = React.useRef(false);
const handleImageLoadSuccess = React.useCallback(() => {
imageLoadError.current = false;
trigger('icon_url');
}, [ trigger ]);
const handleImageLoadError = React.useCallback(() => {
imageLoadError.current = true;
trigger('icon_url');
}, [ trigger ]);
const handleBlur = React.useCallback(() => {
field.onBlur();
const isValidUrl = validateUrl(field.value);
isValidUrl === true && setValueForPreview(field.value);
}, [ field ]);
return (
<Flex columnGap={ 5 }>
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
onBlur={ handleBlur }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` } error={ fieldState.error }/>
</FormControl>
<TokenInfoIconPreview
url={ fieldState.error?.type === 'url' ? undefined : valueForPreview }
onLoad={ handleImageLoadSuccess }
onError={ !isReadOnly ? handleImageLoadError : undefined }
isInvalid={ fieldState.error?.type === 'preview' }
/>
</Flex>
);
};
export default React.memo(TokenInfoFieldIconUrl);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields, TickerUrlFields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
name: keyof TickerUrlFields;
label: string;
}
const TokenInfoFieldPriceTicker = ({ control, isReadOnly, name, label }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ label } error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly, label ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldPriceTicker);
import { FormControl, Text, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectDescription = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_description'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Textarea
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Project description" error={ fieldState.error }/>
<Text variant="secondary" fontSize="sm" mt={ 1 }>
Introduce or summarize the project’s operation/goals in a maximum of 300 characters.
The description should be written in a neutral point of view and must exclude unsubstantiated claims unless proven otherwise.
</Text>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_description"
control={ control }
render={ renderControl }
rules={{ required: true, maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldProjectDescription);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Official project email address" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldProjectEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Project name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldProjectName);
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import type { TokenInfoApplicationConfig } from 'types/api/account';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
config: TokenInfoApplicationConfig['projectSectors'];
}
const TokenInfoFieldProjectSector = ({ control, isReadOnly, config }: Props) => {
const isMobile = useIsMobile();
const options = React.useMemo(() => {
return config.map((option) => ({ label: option, value: option }));
}, [ config ]);
const renderControl: ControllerProps<Fields, 'project_sector'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Project industry"
isDisabled={ formState.isSubmitting || isReadOnly }
error={ fieldState.error }
/>
);
}, [ isReadOnly, options, isMobile ]);
return (
<Controller
name="project_sector"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldProjectSector);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectWebsite = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_website'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text="Official project website" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_website"
control={ control }
render={ renderControl }
rules={{ required: true, validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldProjectWebsite);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterName);
import { FormControl, Icon, Input, InputRightElement, InputGroup } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields, SocialLinkFields } from '../types';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Item {
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
label: string;
color: string;
}
const SETTINGS: Record<keyof SocialLinkFields, Item> = {
github: { label: 'GitHub', icon: iconGithub, color: 'inherit' },
telegram: { label: 'Telegram', icon: iconTelegram, color: 'telegram' },
linkedin: { label: 'LinkedIn', icon: iconLinkedIn, color: 'linkedin' },
discord: { label: 'Discord', icon: iconDiscord, color: 'discord' },
slack: { label: 'Slack', icon: iconSlack, color: 'slack' },
twitter: { label: 'Twitter', icon: iconTwitter, color: 'twitter' },
opensea: { label: 'OpenSea', icon: iconOpenSea, color: 'opensea' },
facebook: { label: 'Facebook', icon: iconFacebook, color: 'facebook' },
medium: { label: 'Medium', icon: iconMedium, color: 'inherit' },
reddit: { label: 'Reddit', icon: iconReddit, color: 'reddit' },
};
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
name: keyof SocialLinkFields;
}
const TokenInfoFieldSocialLink = ({ control, isReadOnly, name }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} sx={{ '.chakra-input__group input': { pr: '60px' } }}>
<InputGroup>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ SETTINGS[name].label } error={ fieldState.error }/>
<InputRightElement h="100%">
<Icon as={ SETTINGS[name].icon } boxSize={ 6 } color={ field.value ? SETTINGS[name].color : '#718096' }/>
</InputRightElement>
</InputGroup>
</FormControl>
);
}, [ isReadOnly, name ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldSocialLink);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator as emailValidator } from 'lib/validations/email';
import { validator as urlValidator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldSupport = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'support'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Support URL or email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
const validate = React.useCallback((newValue: string | undefined) => {
const urlValidationResult = urlValidator(newValue);
const emailValidationResult = emailValidator(newValue || '');
if (urlValidationResult === true || emailValidationResult === true) {
return true;
}
return 'Invalid format';
}, []);
return (
<Controller
name="support"
control={ control }
render={ renderControl }
rules={{ validate }}
/>
);
};
export default React.memo(TokenInfoFieldSupport);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldTokenName = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'token_name'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isDisabled
/>
<InputPlaceholder text="Token name"/>
</FormControl>
);
}, []);
return (
<Controller
name="token_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldTokenName);
import type { Option } from 'ui/shared/FancySelect/types';
export interface Fields extends SocialLinkFields, TickerUrlFields {
address: string;
token_name: string;
requester_name: string;
requester_email: string;
project_name?: string;
project_sector: Option | null;
project_email: string;
project_website: string;
project_description: string;
docs?: string;
support?: string;
icon_url: string;
comment?: string;
}
export interface TickerUrlFields {
ticker_coin_gecko?: string;
ticker_coin_market_cap?: string;
ticker_defi_llama?: string;
}
export interface SocialLinkFields {
github?: string;
telegram?: string;
linkedin?: string;
discord?: string;
slack?: string;
twitter?: string;
opensea?: string;
facebook?: string;
medium?: string;
reddit?: string;
}
import type { Fields } from './types';
import type { TokenInfoApplication } from 'types/api/account';
export function getFormDefaultValues(address: string, tokenName: string, application: TokenInfoApplication | undefined): Partial<Fields> {
if (!application) {
return { address, token_name: tokenName };
}
return {
address,
token_name: tokenName,
requester_name: application.requesterName,
requester_email: application.requesterEmail,
project_name: application.projectName,
project_sector: application.projectSector ? { value: application.projectSector, label: application.projectSector } : null,
project_email: application.projectEmail,
project_website: application.projectWebsite,
project_description: application.projectDescription || '',
docs: application.docs || '',
support: application.support || '',
icon_url: application.iconUrl,
ticker_coin_gecko: application.coinGeckoTicker || '',
ticker_coin_market_cap: application.coinMarketCapTicker,
ticker_defi_llama: application.defiLlamaTicker,
github: application.github || '',
telegram: application.telegram || '',
linkedin: application.linkedin || '',
discord: application.discord || '',
slack: application.slack || '',
twitter: application.twitter || '',
opensea: application.openSea || '',
facebook: application.facebook || '',
medium: application.medium || '',
reddit: application.reddit || '',
comment: application.comment || '',
};
}
export function prepareRequestBody(data: Fields): Omit<TokenInfoApplication, 'id' | 'status' | 'updatedAt'> {
return {
coinGeckoTicker: data.ticker_coin_gecko,
coinMarketCapTicker: data.ticker_coin_market_cap,
defiLlamaTicker: data.ticker_defi_llama,
discord: data.discord,
docs: data.docs,
facebook: data.facebook,
github: data.github,
iconUrl: data.icon_url,
linkedin: data.linkedin,
medium: data.medium,
openSea: data.opensea,
projectDescription: data.project_description,
projectEmail: data.project_email,
projectName: data.project_name,
projectSector: data.project_sector?.value,
projectWebsite: data.project_website,
reddit: data.reddit,
requesterEmail: data.requester_email,
requesterName: data.requester_name,
slack: data.slack,
support: data.support,
telegram: data.telegram,
tokenAddress: data.address,
twitter: data.twitter,
comment: data.comment,
};
}
......@@ -35,8 +35,6 @@ const TokenInstanceContent = () => {
const id = router.query.id?.toString();
const tab = router.query.tab?.toString();
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
const scrollRef = React.useRef<HTMLDivElement>(null);
const tokenInstanceQuery = useApiQuery('token_instance', {
......@@ -64,6 +62,19 @@ const TokenInstanceContent = () => {
},
});
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes(`/token/${ hash }`) && !appProps.referrer.includes('instance');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to token page',
url: appProps.referrer,
};
}, [ appProps.referrer, hash ]);
const tabs: Array<RoutedTab> = [
{
id: 'token_transfers',
......@@ -84,7 +95,7 @@ const TokenInstanceContent = () => {
return <TokenInstanceSkeleton/>;
}
const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 }/>;
const nftShieldIcon = <Icon as={ nftIcon } boxSize={ 6 } mr={ 2 }/>;
const tokenTag = <Tag>{ tokenInstanceQuery.data.token.type }</Tag>;
const address = {
hash: hash || '',
......@@ -134,11 +145,10 @@ const TokenInstanceContent = () => {
<>
<TextAd mb={ 6 }/>
<PageTitle
text={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLinkUrl={ hasGoBackLink ? appProps.referrer : undefined }
backLinkLabel="Back to token page"
additionalsLeft={ nftShieldIcon }
additionalsRight={ tokenTag }
title={ `${ tokenInstanceQuery.data.token.name || 'Unnamed token' } #${ tokenInstanceQuery.data.id }` }
backLink={ backLink }
beforeTitle={ nftShieldIcon }
contentAfter={ tokenTag }
/>
<AddressHeadingInfo address={ address } token={ tokenInstanceQuery.data.token }/>
......
......@@ -58,7 +58,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
title="Token"
hint="Token name"
>
<TokenSnippet hash={ data.token.address } name={ data.token.name }/>
<TokenSnippet data={ data.token }/>
</DetailsInfoItem>
{ data.is_unique && data.owner && (
<DetailsInfoItem
......
......@@ -47,7 +47,7 @@ const TokensTableItem = ({
>
<GridItem display="flex">
<Flex overflow="hidden" mr={ 3 } alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
<Tag flexShrink={ 0 } ml={ 3 }>{ type }</Tag>
</Flex>
......
......@@ -53,7 +53,7 @@ const TokensTableItem = ({
</Text>
<Box>
<Flex alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<TokenLogo data={ token } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" width="136px" justifyContent="space-between" ml={ 8 } mt={ 2 }>
......
......@@ -2,22 +2,22 @@ import { Flex, Link, Text, Icon, Box } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import nftIcon from 'icons/nft_shield.svg';
import AddressLink from 'ui/shared/address/AddressLink';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
interface Props {
token: TokenInfo;
value: string;
tokenId: string;
hash: string;
name?: string | null;
symbol?: string | null;
}
const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props) => {
const NftTokenTransferSnippet = ({ value, token, tokenId }: Props) => {
const num = value === '1' ? '' : value;
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: hash, id: tokenId } });
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } });
return (
<Flex alignItems="center" columnGap={ 3 } rowGap={ 2 } flexWrap="wrap">
......@@ -28,10 +28,10 @@ const NftTokenTransferSnippet = ({ value, name, hash, symbol, tokenId }: Props)
{ tokenId.length > 8 ? <HashStringShorten hash={ tokenId }/> : tokenId }
</Link>
</Box>
{ name ? (
<TokenSnippet symbol={ symbol } hash={ hash } name={ name } w="auto" logoSize={ 5 } columnGap={ 1 }/>
{ token.name ? (
<TokenSnippet data={ token } w="auto" logoSize={ 5 } columnGap={ 1 }/>
) : (
<AddressLink hash={ hash } truncation="constant" type="token"/>
<AddressLink hash={ token.address } truncation="constant" type="token"/>
) }
</Flex>
);
......
......@@ -7,7 +7,6 @@ import type { TxAction, TxActionGeneral } from 'types/api/txAction';
import appConfig from 'configs/app/config';
import uniswapIcon from 'icons/uniswap.svg';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
......@@ -39,6 +38,20 @@ const TxDetailsAction = ({ action }: Props) => {
const amount0 = BigNumber(data.amount0).toFormat();
const amount1 = BigNumber(data.amount1).toFormat();
const [ text0, text1 ] = getActionText(type);
const token0 = {
address: data.symbol0 === 'Ether' ? appConfig.network.currency.address || '' : data.address0,
name: data.symbol0 === 'Ether' ? appConfig.network.currency.symbol || null : data.symbol0,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
const token1 = {
address: data.symbol1 === 'Ether' ? appConfig.network.currency.address || '' : data.address1,
name: data.symbol1 === 'Ether' ? appConfig.network.currency.symbol || null : data.symbol1,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
return (
<Flex flexWrap="wrap" columnGap={ 1 } rowGap={ 2 } alignItems="center">
......@@ -46,8 +59,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount0 }</chakra.span>
<TokenSnippet
name={ data.symbol0 === 'Ether' ? appConfig.network.currency.symbol : data.symbol0 }
hash={ data.symbol0 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
data={ token0 }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......@@ -58,8 +70,7 @@ const TxDetailsAction = ({ action }: Props) => {
<chakra.span fontWeight={ 600 }>{ amount1 }</chakra.span>
<TokenSnippet
name={ data.symbol1 === 'Ether' ? appConfig.network.currency.symbol : data.symbol1 }
hash={ data.symbol1 === 'Ether' ? appConfig.network.currency.address || '' : data.address1 }
data={ token1 }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......@@ -76,14 +87,20 @@ const TxDetailsAction = ({ action }: Props) => {
}
case 'mint_nft' : {
const token = {
address: data.address,
name: data.name,
type: 'ERC-20',
symbol: null,
icon_url: null,
};
return (
<div>
<Flex rowGap={ 2 } flexWrap="wrap" alignItems="center" whiteSpace="pre-wrap">
<chakra.span>Mint of </chakra.span>
<TokenSnippet
name={ data.name }
hash={ data.address }
symbol={ trimTokenSymbol(data.symbol) }
data={ token }
w="auto"
columnGap={ 1 }
logoSize={ 5 }
......
......@@ -5,7 +5,6 @@ import type { TokenTransfer as TTokenTransfer, Erc20TotalPayload, Erc721TotalPay
import rightArrowIcon from 'icons/arrows/east.svg';
import { space } from 'lib/html-entities';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressLink from 'ui/shared/address/AddressLink';
import CurrencyValue from 'ui/shared/CurrencyValue';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
......@@ -27,9 +26,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
<CurrencyValue value={ total.value } exchangeRate={ data.token.exchange_rate } fontWeight={ 600 } decimals={ total.decimals }/>
</Text>
<TokenSnippet
symbol={ trimTokenSymbol(data.token.symbol) }
hash={ data.token.address }
name={ data.token.name }
data={ data.token }
w="auto"
flexGrow="1"
columnGap={ 1 }
......@@ -43,11 +40,9 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc721TotalPayload;
return (
<NftTokenTransferSnippet
name={ data.token.name }
token={ data.token }
tokenId={ total.token_id }
value="1"
hash={ data.token.address }
symbol={ trimTokenSymbol(data.token.symbol) }
/>
);
}
......@@ -56,12 +51,10 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
const total = data.total as Erc1155TotalPayload;
return (
<NftTokenTransferSnippet
name={ data.token.name }
key={ total.token_id }
token={ data.token }
tokenId={ total.token_id }
value={ total.value }
hash={ data.token.address }
symbol={ trimTokenSymbol(data.token.symbol) }
/>
);
}
......
import { Icon, IconButton, Link, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import editIcon from 'icons/edit.svg';
import dayjs from 'lib/date/dayjs';
import AddressSnippet from 'ui/shared/AddressSnippet';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import VerifiedAddressesStatus from './VerifiedAddressesStatus';
import VerifiedAddressesTokenSnippet from './VerifiedAddressesTokenSnippet';
interface Props {
item: VerifiedAddress;
application: TokenInfoApplication | undefined;
onAdd: (address: string) => void;
onEdit: (address: string) => void;
}
const VerifiedAddressesListItem = ({ item, application, onAdd, onEdit }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
const handleEditClick = React.useCallback(() => {
onEdit(item.contractAddress);
}, [ item, onEdit ]);
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label>Address</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value py="3px">
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</ListItemMobileGrid.Value>
{ item.metadata.tokenName && (
<>
<ListItemMobileGrid.Label>Token Info</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value py={ application ? '3px' : '5px' } display="flex" alignItems="center">
{ application ? (
<>
<VerifiedAddressesTokenSnippet application={ application } name={ item.metadata.tokenName }/>
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="simple"
boxSize={ 5 }
borderRadius="none"
flexShrink={ 0 }
onClick={ handleEditClick }
icon={ <Icon as={ editIcon }/> }
/>
</Tooltip>
</>
) : (
<Link onClick={ handleAddClick }>Add details</Link>
) }
</ListItemMobileGrid.Value>
</>
) }
{ item.metadata.tokenName && application && (
<>
<ListItemMobileGrid.Label>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<VerifiedAddressesStatus status={ application.status }/>
</ListItemMobileGrid.Value>
</>
) }
{ item.metadata.tokenName && application && (
<>
<ListItemMobileGrid.Label>Date</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ dayjs(application.updatedAt).format('MMM DD, YYYY') }
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default React.memo(VerifiedAddressesListItem);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
interface Props {
status?: TokenInfoApplication['status'];
}
const VerifiedAddressesStatus = ({ status }: Props) => {
switch (status) {
case 'IN_PROCESS': {
return <chakra.span fontWeight={ 500 }>In progress</chakra.span>;
}
case 'APPROVED': {
return <chakra.span fontWeight={ 500 } color="green.500">Approved</chakra.span>;
}
case 'UPDATE_REQUIRED': {
return <chakra.span fontWeight={ 500 } color="orange.500">Waiting for update</chakra.span>;
}
case 'REJECTED': {
return <chakra.span fontWeight={ 500 } color="red.500">Rejected</chakra.span>;
}
default:
return null;
}
};
export default VerifiedAddressesStatus;
import { Table, Tbody, Th, Thead, Tr } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import VerifiedAddressesTableItem from './VerifiedAddressesTableItem';
interface Props {
data: Array<VerifiedAddress>;
applications: Array<TokenInfoApplication> | undefined;
onItemAdd: (address: string) => void;
onItemEdit: (address: string) => void;
}
const VerifiedAddressesTable = ({ data, applications, onItemEdit, onItemAdd }: Props) => {
return (
<Table variant="simple">
<Thead>
<Tr>
<Th>Address</Th>
<Th w="168px" pr={ 1 }>Token info</Th>
<Th w="36px" pl="0"></Th>
<Th w="160px">Request status</Th>
<Th w="150px">Date</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<VerifiedAddressesTableItem
key={ item.contractAddress }
item={ item }
application={ applications?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) }
onAdd={ onItemAdd }
onEdit={ onItemEdit }
/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(VerifiedAddressesTable);
import { Td, Tr, Link, Tooltip, IconButton, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication, VerifiedAddress } from 'types/api/account';
import editIcon from 'icons/edit.svg';
import dayjs from 'lib/date/dayjs';
import AddressSnippet from 'ui/shared/AddressSnippet';
import VerifiedAddressesStatus from './VerifiedAddressesStatus';
import VerifiedAddressesTokenSnippet from './VerifiedAddressesTokenSnippet';
interface Props {
item: VerifiedAddress;
application: TokenInfoApplication | undefined;
onAdd: (address: string) => void;
onEdit: (address: string) => void;
}
const VerifiedAddressesTableItem = ({ item, application, onAdd, onEdit }: Props) => {
const handleAddClick = React.useCallback(() => {
onAdd(item.contractAddress);
}, [ item, onAdd ]);
const handleEditClick = React.useCallback(() => {
onEdit(item.contractAddress);
}, [ item, onEdit ]);
const tokenInfo = (() => {
if (!item.metadata.tokenName) {
return <span>Not a token</span>;
}
if (!application) {
return <Link onClick={ handleAddClick }>Add details</Link>;
}
return <VerifiedAddressesTokenSnippet application={ application } name={ item.metadata.tokenName }/>;
})();
return (
<Tr>
<Td>
<AddressSnippet address={{ hash: item.contractAddress, is_contract: true, implementation_name: null }}/>
</Td>
<Td fontSize="sm" verticalAlign="middle" pr={ 1 }>
{ tokenInfo }
</Td>
<Td pl="0">
{ item.metadata.tokenName && application ? (
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="simple"
boxSize={ 5 }
borderRadius="none"
flexShrink={ 0 }
onClick={ handleEditClick }
icon={ <Icon as={ editIcon }/> }
/>
</Tooltip>
) : null }
</Td>
<Td fontSize="sm"><VerifiedAddressesStatus status={ item.metadata.tokenName ? application?.status : undefined }/></Td>
<Td fontSize="sm" color="text_secondary">{ item.metadata.tokenName && application ? dayjs(application.updatedAt).format('MMM DD, YYYY') : null }</Td>
</Tr>
);
};
export default React.memo(VerifiedAddressesTableItem);
import { Image, Flex } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfoApplication } from 'types/api/account';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
application: TokenInfoApplication;
name: string;
}
const VerifiedAddressesTokenSnippet = ({ application, name }: Props) => {
return (
<Flex alignItems="center" columnGap={ 2 } w="100%">
<Image
borderRadius="base"
boxSize={ 6 }
objectFit="cover"
src={ application.iconUrl }
alt="Token logo"
fallback={ <TokenLogoPlaceholder boxSize={ 6 }/> }
/>
<AddressLink
hash={ application.tokenAddress }
alias={ name }
type="token"
isDisabled={ application.status === 'IN_PROCESS' }
fontWeight={ 500 }
/>
</Flex>
);
};
export default React.memo(VerifiedAddressesTokenSnippet);
......@@ -14,14 +14,19 @@ import TokenLogo from 'ui/shared/TokenLogo';
const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const infoItemsPaddingLeft = { base: 1, lg: 8 };
const nativeTokenData = React.useMemo(() => ({
address: appConfig.network.currency.address || '',
name: appConfig.network.currency.name || '',
icon_url: '',
}), [ ]);
return (
<VStack spacing={ 2 } align="stretch" fontWeight={ 500 }>
<AddressSnippet address={ item.address }/>
<Flex fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft } flexWrap="wrap" alignItems="center" rowGap={ 1 }>
{ appConfig.network.currency.address && (
<TokenLogo
hash={ appConfig.network.currency.address }
name={ appConfig.network.name }
data={ nativeTokenData }
boxSize={ 4 }
borderRadius="sm"
mr={ 2 }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment