Commit ae65e565 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into tx-contract-created

parents 26702ee2 02bc55b3
...@@ -2,8 +2,8 @@ name: Run E2E tests k8s ...@@ -2,8 +2,8 @@ name: Run E2E tests k8s
on: on:
# push: # push:
# pull_request: pull_request:
workflow_dispatch workflow_dispatch:
env: env:
K8S_LOCAL_PORT: ${{ secrets.K8S_LOCAL_PORT }} K8S_LOCAL_PORT: ${{ secrets.K8S_LOCAL_PORT }}
......
...@@ -3,8 +3,6 @@ name: Deploy review environment ...@@ -3,8 +3,6 @@ name: Deploy review environment
on: on:
pull_request: pull_request:
# push: # push:
# branches-ignore:
# - 'main'
workflow_dispatch: workflow_dispatch:
env: env:
......
/// <reference types="next-react-svg" />
This diff is collapsed.
This diff is collapsed.
...@@ -8,18 +8,6 @@ blockscout: ...@@ -8,18 +8,6 @@ blockscout:
_default: ENC[AES256_GCM,data:+q7GvdSeRQ0aY4grlQrdWUvxT9tzsRoii3W/ECwpVKs13lw84bGV+rbiEJXBYoOAw7SDz1Hr+KMy9SQzHvj12w==,iv:Xjo9+Nca6eDTZtUcDPAPfXYSAoP2gYmDX+JanCyPPkY=,tag:kQxLFsfN5YXft8ihgNvA+Q==,type:str] _default: ENC[AES256_GCM,data:+q7GvdSeRQ0aY4grlQrdWUvxT9tzsRoii3W/ECwpVKs13lw84bGV+rbiEJXBYoOAw7SDz1Hr+KMy9SQzHvj12w==,iv:Xjo9+Nca6eDTZtUcDPAPfXYSAoP2gYmDX+JanCyPPkY=,tag:kQxLFsfN5YXft8ihgNvA+Q==,type:str]
MAILSLURP_EMAIL_ID: MAILSLURP_EMAIL_ID:
_default: ENC[AES256_GCM,data:tGjukx3Q+NaWpvfXPXmY6dphdEdbO531q4NMqsOa4XCudfpt,iv:6S2jTCoJCfhkRHg4aA2UR1U4fMsDhNV44+r7ofnHLTA=,tag:nr+0zaM45+UBUwOpmVD1hg==,type:str] _default: ENC[AES256_GCM,data:tGjukx3Q+NaWpvfXPXmY6dphdEdbO531q4NMqsOa4XCudfpt,iv:6S2jTCoJCfhkRHg4aA2UR1U4fMsDhNV44+r7ofnHLTA=,tag:nr+0zaM45+UBUwOpmVD1hg==,type:str]
ACCOUNT_AUTH0_DOMAIN:
_default: ENC[AES256_GCM,data:C6TAdc/RZ+qFOan+tWnTsfIDcYgxCNjuKOU=,iv:tEcHTumHxA9LreqLSwjcTTCP3igk/VQrJn/OWd+YQ4c=,tag:XOlWi3XHBZyENtLsv3afmw==,type:str]
ACCOUNT_AUTH0_CLIENT_ID:
_default: ENC[AES256_GCM,data:lojxfDVJBi1Sc6KCL28tln6RJ5leg0jmaTbSHbBd1H4=,iv:l1Iq/YsZKkFuorQgKd+KzAIkqbXHv6I4eid+7LDnyOA=,tag:/c7GBciDCruz+dAOfxncww==,type:str]
ACCOUNT_AUTH0_CLIENT_SECRET:
_default: ENC[AES256_GCM,data:Nmajob/xrOfx3sZgGHWw4/VnSgoyehubNdhJSqasxLFInJeZgxCW/QXpu5KG6j1fDdALLOT89Es+m0IO9VrG8A==,iv:RxYWdOo5G6t0FFwHQfhvUTiCOsxRzJJT0Y1nc0F5sDc=,tag:6v6+yHaHgwnw0cDSD1hXwg==,type:str]
ACCOUNT_AUTH0_CALLBACK_URL:
_default: ENC[AES256_GCM,data:v6LJLRwibjc6QgF1t6QNzy/FfL/i2G0mD2X6oxf0o4Q6A8cwovE03Wd5nXWTSyWrN95rJm0IQY7bjU3fXpDw0p/u4vu8dMpfM2bZMMOalXP4pA==,iv:VtNoyCEsesWn0PyWRXzLgbMcUa+voKa1sjsUY26RKnw=,tag:t02c9NVe4aGGo5feOP1OWQ==,type:str]
ACCOUNT_AUTH0_LOGOUT_RETURN_URL:
_default: ENC[AES256_GCM,data:JQylcEDhJUOqXqBCycJ8XLwz6wpa3Uz3p5MhCEVolLnKkYoDFogGuFt1YkKt7EEtbZjqvSjIa0xQhpezX5hvypU9KxRxoVosUQ4=,iv:dbXG4N4t5jQgx68l0r5iLh5FGGg2O/vqbSDpyxzEauI=,tag:upkfr/3h35rZ93oBNJ3Y/w==,type:str]
ACCOUNT_AUTH0_LOGOUT_URL:
_default: ENC[AES256_GCM,data:6vj0qvxktXptFDX4Tq3kGIyhaJN7Adcwfgb3qWEYH78pWtmFprwfKNdPa/4=,iv:Eb3NcZP9oSmh0ZfY8vCWwR5Ciwe7Lr/QJmcNWXiir9E=,tag:uXoFnMfwo/GD9IPuvs/9Wg==,type:str]
ACCOUNT_SENDGRID_API_KEY: ACCOUNT_SENDGRID_API_KEY:
_default: ENC[AES256_GCM,data:bNzimXY1TDUHgLijvgcJ8mw+9RXvCLchxhum77KY6ss5NUdoWuNuQjpC7M819XRPqhBmZkC2qBSX1ZRhcNQCB1OqGkRq,iv:JFmeCxsO2lkyScN8iVb8B/356SbP7KCMFffIPjGtN0o=,tag:A21hzDObJMAUhHQRWB7Z9w==,type:str] _default: ENC[AES256_GCM,data:bNzimXY1TDUHgLijvgcJ8mw+9RXvCLchxhum77KY6ss5NUdoWuNuQjpC7M819XRPqhBmZkC2qBSX1ZRhcNQCB1OqGkRq,iv:JFmeCxsO2lkyScN8iVb8B/356SbP7KCMFffIPjGtN0o=,tag:A21hzDObJMAUhHQRWB7Z9w==,type:str]
ACCOUNT_SENDGRID_SENDER: ACCOUNT_SENDGRID_SENDER:
...@@ -39,9 +27,21 @@ blockscout: ...@@ -39,9 +27,21 @@ blockscout:
DATABASE_URL: DATABASE_URL:
_default: ENC[AES256_GCM,data:r5JQJH+pq4zwo9ceFP4I8inttRFFxKKaw9+l9hDqeDMcEIPljAfJxvSF3noVlyfNtqO0ru+3GaTumDamdb9Hw2Y=,iv:OwCvrIgXpxVMI5QQfT7ZDGcsXBdzIcuv0wUSxtwkYrM=,tag:o0GFp3F+jTxw8Hg4Ce/IpQ==,type:str] _default: ENC[AES256_GCM,data:r5JQJH+pq4zwo9ceFP4I8inttRFFxKKaw9+l9hDqeDMcEIPljAfJxvSF3noVlyfNtqO0ru+3GaTumDamdb9Hw2Y=,iv:OwCvrIgXpxVMI5QQfT7ZDGcsXBdzIcuv0wUSxtwkYrM=,tag:o0GFp3F+jTxw8Hg4Ce/IpQ==,type:str]
ACCOUNT_DATABASE_URL: ACCOUNT_DATABASE_URL:
_default: ENC[AES256_GCM,data:48q2fFA3s1HGSm7YB5QV8B0eiI2PZHPgOsUS84R8x+8GecrFTenifbcHD6AivFNKovLuClUy2aFuEtEvJDOfelY=,iv:5ZLl62Yj/AXwyg+pdBLUv3EyYXSTOiSCqGYOv3rBx94=,tag:FUiy0haST4mdrqe2VlvrLg==,type:str] _default: ENC[AES256_GCM,data:HF5y8ezV5TiLqeh98WDp4rXQeUfSBETyWVHOyNZNy5pt4MdiKTdFBLauOJpD6YHWynMFsd8IJLRNLrBn4qGe3RfwprR6v3WN9Q==,iv:B9AJXO7EJexsPgDHb5s5tzpadVYoZ79fyaL8NOYXSEw=,tag:lyNKQ13Q+pn+3DrAXIzIKQ==,type:str]
ACCOUNT_REDIS_URL: ACCOUNT_REDIS_URL:
_default: ENC[AES256_GCM,data:MtAI4U/4mLdIW0d4ap5+0wTufO7hvOD/ETPnqpGJ7tGFXmirK2w/M5UdQd8M/EsH/4zk3rlM4WDGRVwMzCTiugG1FxEdIcKOA6127jXgSUrE,iv:zgrAoj7437b0TJTstU0S7UVHGt4fKnmP3wlc2qbURa0=,tag:vT3NL8PLLxB9z/dmkE/jaw==,type:str] _default: ENC[AES256_GCM,data:MtAI4U/4mLdIW0d4ap5+0wTufO7hvOD/ETPnqpGJ7tGFXmirK2w/M5UdQd8M/EsH/4zk3rlM4WDGRVwMzCTiugG1FxEdIcKOA6127jXgSUrE,iv:zgrAoj7437b0TJTstU0S7UVHGt4fKnmP3wlc2qbURa0=,tag:vT3NL8PLLxB9z/dmkE/jaw==,type:str]
ACCOUNT_AUTH0_DOMAIN:
_default: ENC[AES256_GCM,data:zRZL/kIX2R4AbanIjN7s/qFu5od1onFhYBg=,iv:4OSMUjvxUHevuXSNAnTj/DTFoZA5mC4UIerGiIgDwQc=,tag:J5I4CC9AIVXDmhkXG4X5wQ==,type:str]
ACCOUNT_AUTH0_CLIENT_ID:
_default: ENC[AES256_GCM,data:xasZDDg1IuGbKSTm9Opjh43RcqDSzE3dMdmynP0gejo=,iv:TTRqPOkwMxAQIpmc7DklBoPdZzn3FsAprxwXtHY/KSk=,tag:ZI7zQ5zyjjYOqjzy2DyY7Q==,type:str]
ACCOUNT_AUTH0_CLIENT_SECRET:
_default: ENC[AES256_GCM,data:iYnptgxESZs216/m0ArCciXfirPTxVjt5urKTATyYv3uadokC54ofzMZagG/ZVvNY48+Uxh4YGzwySyLOOM0SA==,iv:UQoZf3tesEjAJ7E5YruoZmVclsYfS5EmSo4z90wfHfE=,tag:er+hARrkCrjoQugqvRr4ow==,type:str]
ACCOUNT_AUTH0_CALLBACK_URL:
_default: ENC[AES256_GCM,data:GhyNTVlRvDpfW6swKExjVy65I3S+q6ITJWH8xp5TXuudp7hAtI4ujkYmXaa7D1BCaineTLhQ8UgHXFAgodOlrsGP1g41LIh6T2SGhrXTGGLbFw==,iv:ZflinC/h35jkrVAx0x7Z7U9mRridl85f7f6nfoc0Xts=,tag:40zL2KM9uPg/lRYv5JMxqw==,type:str]
ACCOUNT_AUTH0_LOGOUT_RETURN_URL:
_default: ENC[AES256_GCM,data:mTY6sjNKZ+VEvH47eyyoUHt//beWvuxyreu+1WrmMvkdkwR6jgXnSXh+1pTuiG8e3it96Egxraz0hjZxlkY9kSmE91/dfoqKTns=,iv:op8OuOXXeBmmUnVkCL14j4HrfjHHoN3n2XZqLdg03Mw=,tag:bbFQYi0Aa1sUdfoUjuQ/+w==,type:str]
ACCOUNT_AUTH0_LOGOUT_URL:
_default: ENC[AES256_GCM,data:IZFfi6pn+hy7g0wnEtP9TYHH1fNiC2gqgRHVdgm4C9smPerEvS0pq9dBwVY=,iv:BxbSInFQ6GE2loTv+IzdYr25PlyzdWZI1wdT6r+uvBg=,tag:psujCU372k59OGmlRdH9Fg==,type:str]
scVerifier: scVerifier:
environment: environment:
SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__S3__ACCESS_KEY: SMART_CONTRACT_VERIFIER__SOLIDITY__FETCHER__S3__ACCESS_KEY:
...@@ -57,6 +57,12 @@ postgres: ...@@ -57,6 +57,12 @@ postgres:
POSTGRES_DB: POSTGRES_DB:
_default: ENC[AES256_GCM,data:QRCvQVzKOfOApg==,iv:eOdxyjmv6Zj5xINGXO0dD/GcDgD1J8e4OzLMyOneoDs=,tag:EBnJ7OT3u7vcBIbviRKaHg==,type:str] _default: ENC[AES256_GCM,data:QRCvQVzKOfOApg==,iv:eOdxyjmv6Zj5xINGXO0dD/GcDgD1J8e4OzLMyOneoDs=,tag:EBnJ7OT3u7vcBIbviRKaHg==,type:str]
geth: geth:
jwt:
token: ENC[AES256_GCM,data:+E6k/3vTav9yqAbOStmC5Rwx9mV80l8iAqQYGWJNZFTUfqXQKNavaVJW7AfzixZC4zaj8c0dbdsj1bv9rlJljG0q,iv:zW9spad1AdbkAjYQYAAflzsNhaH+LAIkjHpvIPHVGbs=,tag:oqvgkqVroMxSzwmna9ra5w==,type:str]
client:
environment:
JWT_TOKEN:
_default: ENC[AES256_GCM,data:T46y6KmBcEslaodBMRTNZya51uQiK4I3olCvdxCw3qZlgoOxRUvSgKLLoRNyD/d6w6PwgJA4oU7N5hmNGQ+I26Vc,iv:F+evxDb/sn6raub1LjZPGk2VFoQBVuBvkpgJ2YCJjw8=,tag:K0QV4weIEcdy1AXcpHSxQQ==,type:str]
files: files:
list: list:
genesis.json: ENC[AES256_GCM,data:nSFncQhigaTtb7YSeXiUvua2LTOGlluVDywxQR5NnhvIBZCZniIxHsEhXKF9rBYceOMfBPKY5MTLIyxYisZeaJHL71A/uXQerDEKkECpzhwUSTdNEA9gUEqmpUjf7ChAWjybWw6NCM2jVhG3bnZwTwmuzeCSek85q4J503j9HVEkGXAU52A2+yoov41c+xoCEOj7W6Ka/f7yJYkyppoeS+4AIxBFcJ5hI0pIf1VqDcSrJf68JO1pPzcmp9eK3CK2CIpD8uxV6CseBFEqlbllmgAVwSUJ4i8XhYEO2hMyULm/LkW52U31/tq89yctSkp5TB62sZEHyDxXcq6jFSqd4840o9VedyX4YuEVtLBlXdzerc4Q2tHORQv5u2BLW7ktA/5vj8d9XnblQBDKrqxtUEgvSj9T/Xm4zyKjIhJBAAwUd5rF4j8dNbUGpsBm3G/Lx1+DqrS8cyxXgumtmfsklEVTWBra2dwt/tlTpF+utOwT7KtqXo2dVNvL6tUWrWGQqCt+fgeRKw8NuHWgU9Nm6baQ0yhWIM3XaCEuuJqHLdelYgQiKVEaTvlw7my7XwQsunJCQ98Q2eO61v7WFsg1m5s3Wyts8R2EnMWrnDlGro//eUHFlxDiIQGXmWgnz597lrLTXiuZKNI4pvPsAggBQxIeUcYzICy5uLRPWAiDpfshYt/fRQhIxmrVaJCufJrq4IcUdJYKjAcpnGHqZMB6quZiUPCPYg5Lvz7w1zoIVLn4ANWVuh7J1husNH13SwrDUXEm+k/WYCkDZm5B/K9IzkihY8nNXbvxHEXUClpb943Xw8hpvZZj0BfP/ye7ywUzHGlo3OmFAdE7Fbjc9eELxgYirONoaHy9GkjWPjinTVVlvitakqzx4dGJ+U2adSB01WAj63rn4ZidaC/eUpcPe7E2OeqmpkLc/kLM9g3gEe/MCiqsXXUYtm0Idpbrf+rRTAotoVOi3fKr1inlJY6n+9UzKAxNkTIkiGUQ1E9g0s3Kn4Z83apuy5+Us8gA9xNfWNGcOO105ZUYKyow4Xb5ggV8t+UTXPOZQp97Qz/6q0VC2kY9bx991SrJLoaK+ChLQ0/t/LcLjZxKRPDU2YGOfjrPDO3iscDkZgX/8IvH56jxUXAYmNrxOgbb9encYIrMDYrsKTaj5kXETp2M3sTdHwk6d78PAteYCvgQb44gu5JtknIq2LLXb/2Ue45O4HnrB+AsICPb+oGAxIqFkHvWuOIRZLqap8wbazszBlqsrX8ABegjvReXiG0rX1VeQIXNcG3/tKPHNKrDlFJykaiC1w==,iv:pyrxyy1bbv1UiGZ2y1jer9UqOwVkZGb4GwawJoCGJuM=,tag:aVt5dASuZglsUs9Yydzjaw==,type:str] genesis.json: ENC[AES256_GCM,data:nSFncQhigaTtb7YSeXiUvua2LTOGlluVDywxQR5NnhvIBZCZniIxHsEhXKF9rBYceOMfBPKY5MTLIyxYisZeaJHL71A/uXQerDEKkECpzhwUSTdNEA9gUEqmpUjf7ChAWjybWw6NCM2jVhG3bnZwTwmuzeCSek85q4J503j9HVEkGXAU52A2+yoov41c+xoCEOj7W6Ka/f7yJYkyppoeS+4AIxBFcJ5hI0pIf1VqDcSrJf68JO1pPzcmp9eK3CK2CIpD8uxV6CseBFEqlbllmgAVwSUJ4i8XhYEO2hMyULm/LkW52U31/tq89yctSkp5TB62sZEHyDxXcq6jFSqd4840o9VedyX4YuEVtLBlXdzerc4Q2tHORQv5u2BLW7ktA/5vj8d9XnblQBDKrqxtUEgvSj9T/Xm4zyKjIhJBAAwUd5rF4j8dNbUGpsBm3G/Lx1+DqrS8cyxXgumtmfsklEVTWBra2dwt/tlTpF+utOwT7KtqXo2dVNvL6tUWrWGQqCt+fgeRKw8NuHWgU9Nm6baQ0yhWIM3XaCEuuJqHLdelYgQiKVEaTvlw7my7XwQsunJCQ98Q2eO61v7WFsg1m5s3Wyts8R2EnMWrnDlGro//eUHFlxDiIQGXmWgnz597lrLTXiuZKNI4pvPsAggBQxIeUcYzICy5uLRPWAiDpfshYt/fRQhIxmrVaJCufJrq4IcUdJYKjAcpnGHqZMB6quZiUPCPYg5Lvz7w1zoIVLn4ANWVuh7J1husNH13SwrDUXEm+k/WYCkDZm5B/K9IzkihY8nNXbvxHEXUClpb943Xw8hpvZZj0BfP/ye7ywUzHGlo3OmFAdE7Fbjc9eELxgYirONoaHy9GkjWPjinTVVlvitakqzx4dGJ+U2adSB01WAj63rn4ZidaC/eUpcPe7E2OeqmpkLc/kLM9g3gEe/MCiqsXXUYtm0Idpbrf+rRTAotoVOi3fKr1inlJY6n+9UzKAxNkTIkiGUQ1E9g0s3Kn4Z83apuy5+Us8gA9xNfWNGcOO105ZUYKyow4Xb5ggV8t+UTXPOZQp97Qz/6q0VC2kY9bx991SrJLoaK+ChLQ0/t/LcLjZxKRPDU2YGOfjrPDO3iscDkZgX/8IvH56jxUXAYmNrxOgbb9encYIrMDYrsKTaj5kXETp2M3sTdHwk6d78PAteYCvgQb44gu5JtknIq2LLXb/2Ue45O4HnrB+AsICPb+oGAxIqFkHvWuOIRZLqap8wbazszBlqsrX8ABegjvReXiG0rX1VeQIXNcG3/tKPHNKrDlFJykaiC1w==,iv:pyrxyy1bbv1UiGZ2y1jer9UqOwVkZGb4GwawJoCGJuM=,tag:aVt5dASuZglsUs9Yydzjaw==,type:str]
...@@ -65,7 +71,7 @@ geth: ...@@ -65,7 +71,7 @@ geth:
frontend: frontend:
environment: environment:
NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS: NEXT_PUBLIC_NETWORK_TOKEN_ADDRESS:
_default: ENC[AES256_GCM,data:1SAbzZhCs/vzdftIX0WVLtImH27NJ6SwENee4uTu2p+ZyUso3nQCLUUm,iv:apyLxt2dQ5RN33ra1Q1sAy2cyplG9FSryksQru2ghlA=,tag:PVcCNt0bz1TfQewUebV5LA==,type:str] _default: ENC[AES256_GCM,data:D/pLeRn7C40/rc7nNDCuK5dTuADPrHZl+J8hGC9xhwNRKZnkHySxkSzV,iv:z98n49jakK1mvlJpJ75BXj5gGyDHpBcAoCbaM0FmEA8=,tag:pLTa5kHmSi+bCN+8jeMhGQ==,type:str]
NEXT_PUBLIC_SENTRY_DSN: NEXT_PUBLIC_SENTRY_DSN:
_default: ENC[AES256_GCM,data:n/H2AH2n9ovn265iFbbrqeOOWS3s7FXgDv5FXJ2Dz133GuhTaqIU5psWjTraZ/Vh+dVM8M9zBLFfUanCWOz7cerq/QUoSVZjKvIvcyp6F072DGU=,iv:Co/pSR+U0vfkmWR+LDpxcQKDJl0WNbaEZihpzrRJcbc=,tag:HUiCX8WGJXWCDB59Y6iIbw==,type:str] _default: ENC[AES256_GCM,data:n/H2AH2n9ovn265iFbbrqeOOWS3s7FXgDv5FXJ2Dz133GuhTaqIU5psWjTraZ/Vh+dVM8M9zBLFfUanCWOz7cerq/QUoSVZjKvIvcyp6F072DGU=,iv:Co/pSR+U0vfkmWR+LDpxcQKDJl0WNbaEZihpzrRJcbc=,tag:HUiCX8WGJXWCDB59Y6iIbw==,type:str]
SENTRY_CSP_REPORT_URI: SENTRY_CSP_REPORT_URI:
...@@ -78,8 +84,8 @@ sops: ...@@ -78,8 +84,8 @@ sops:
azure_kv: [] azure_kv: []
hc_vault: [] hc_vault: []
age: [] age: []
lastmodified: "2022-11-02T07:59:42Z" lastmodified: "2022-11-14T10:04:29Z"
mac: ENC[AES256_GCM,data:OrV/dUWOtL23UFQLeIsKsGluTmse42d/4sgFMDs3UXdACsZu8twMt29Y/WaPHyq8Tpn5iYzhBLU6SCUmHxEhBNVzKBd5uCUbav1faS/zW6fSd9bEP7rmbUjaJGHliBkG3T4VCSZn53jR/OMNbSynIxZ0kRpVHr+RTcalaH7dLQ8=,iv:J3gXjFgFyZoPqL+VEnjkuKzA9UIIyK3UsvPWacBZKsY=,tag:/zY1jVjIm5BsDNJlfsg5uw==,type:str] mac: ENC[AES256_GCM,data:LitRw7GOBq9mHXXiWmfmcpw0Tbn8KESGZjFjDAlYWIlYZWVzxIeApXWNin/HoFrwIwnmpn2bpyjG1yWu3FR/3CLkkekUSsZ7k4P7kBlspQyUNSqFxWfDM+2qwpJ7qUNfQ05oGJkceLTsYec5kH2Xe39fs8RQife5uoord1FkW7A=,iv:M4nSUT77FUCCTm72yteJCZNJivYCs0LLr2irRGgrq3c=,tag:HaMxRKNEgNmB3sIw93yCMw==,type:str]
pgp: pgp:
- created_at: "2022-09-14T13:42:28Z" - created_at: "2022-09-14T13:42:28Z"
enc: | enc: |
......
This diff is collapsed.
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { pick, omit } from 'lodash'; import { pick, omit } from 'lodash';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
...@@ -17,11 +18,15 @@ interface ResponseWithPagination { ...@@ -17,11 +18,15 @@ interface ResponseWithPagination {
next_page_params: PaginationParams | null; next_page_params: PaginationParams | null;
} }
export default function useQueryWithPages<Response extends ResponseWithPagination>( interface Params<Response> {
apiPath: string, apiPath: string;
queryName: QueryKeys, queryName: QueryKeys;
filters?: TTxsFilters | BlockFilters, queryIds?: Array<string>;
) { filters?: TTxsFilters | BlockFilters;
options?: Omit<UseQueryOptions<unknown, unknown, Response>, 'queryKey' | 'queryFn'>;
}
export default function useQueryWithPages<Response extends ResponseWithPagination>({ queryName, filters, options, apiPath, queryIds }: Params<Response>) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const [ page, setPage ] = React.useState(1); const [ page, setPage ] = React.useState(1);
...@@ -30,7 +35,7 @@ export default function useQueryWithPages<Response extends ResponseWithPaginatio ...@@ -30,7 +35,7 @@ export default function useQueryWithPages<Response extends ResponseWithPaginatio
const fetch = useFetch(); const fetch = useFetch();
const queryResult = useQuery<unknown, unknown, Response>( const queryResult = useQuery<unknown, unknown, Response>(
[ queryName, { page, filters } ], [ queryName, ...(queryIds || []), { page, filters } ],
async() => { async() => {
const params: Array<string> = []; const params: Array<string> = [];
...@@ -44,7 +49,7 @@ export default function useQueryWithPages<Response extends ResponseWithPaginatio ...@@ -44,7 +49,7 @@ export default function useQueryWithPages<Response extends ResponseWithPaginatio
return fetch(`${ apiPath }${ params.length ? '?' + params.join('&') : '' }`); return fetch(`${ apiPath }${ params.length ? '?' + params.join('&') : '' }`);
}, },
{ staleTime: Infinity }, { staleTime: Infinity, ...options },
); );
const { data } = queryResult; const { data } = queryResult;
......
...@@ -32,5 +32,5 @@ export default function link( ...@@ -32,5 +32,5 @@ export default function link(
url.searchParams.append(key, value); url.searchParams.append(key, value);
}); });
return url.pathname; return url.toString();
} }
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import type { PageParams } from './types';
import Block from 'ui/pages/Block';
import getSeo from './getSeo';
type Props = {
pageParams: PageParams;
}
const BlockNextPage: NextPage<Props> = ({ pageParams }: Props) => {
const { title, description } = getSeo(pageParams);
return (
<>
<Head>
<title>{ title }</title>
<meta name="description" content={ description }/>
</Head>
<Block/>
</>
);
};
export default BlockNextPage;
export type PageParams = unknown
...@@ -3,13 +3,15 @@ import type { GetServerSideProps, GetServerSidePropsResult } from 'next'; ...@@ -3,13 +3,15 @@ import type { GetServerSideProps, GetServerSidePropsResult } from 'next';
export type Props = { export type Props = {
cookies: string; cookies: string;
referrer: string; referrer: string;
id?: string;
} }
export const getServerSideProps: GetServerSideProps = async({ req }): Promise<GetServerSidePropsResult<Props>> => { export const getServerSideProps: GetServerSideProps = async({ req, query }): Promise<GetServerSidePropsResult<Props>> => {
return { return {
props: { props: {
cookies: req.headers.cookie || '', cookies: req.headers.cookie || '',
referrer: req.headers.referer || '', referrer: req.headers.referer || '',
id: query.id?.toString() || '',
}, },
}; };
}; };
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import type { PageParams } from './types';
import Transaction from 'ui/pages/Transaction';
import getSeo from './getSeo';
type Props = {
pageParams: PageParams;
}
const TransactionNextPage: NextPage<Props> = ({ pageParams }: Props) => {
const { title, description } = getSeo(pageParams);
return (
<>
<Head>
<title>{ title }</title>
<meta name="description" content={ description }/>
</Head>
<Transaction/>
</>
);
};
export default TransactionNextPage;
...@@ -4,9 +4,12 @@ import type { NewBlockSocketResponse } from 'types/api/block'; ...@@ -4,9 +4,12 @@ import type { NewBlockSocketResponse } from 'types/api/block';
export type SocketMessageParams = SocketMessage.NewBlock | export type SocketMessageParams = SocketMessage.NewBlock |
SocketMessage.BlocksIndexStatus | SocketMessage.BlocksIndexStatus |
SocketMessage.TxStatusUpdate; SocketMessage.TxStatusUpdate |
SocketMessage.NewTx |
SocketMessage.NewPendingTx |
SocketMessage.Unknown;
interface SocketMessageParamsGeneric<Event extends string, Payload extends object> { interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> {
channel: Channel | undefined; channel: Channel | undefined;
event: Event; event: Event;
handler: (payload: Payload) => void; handler: (payload: Payload) => void;
...@@ -17,4 +20,7 @@ export namespace SocketMessage { ...@@ -17,4 +20,7 @@ export namespace SocketMessage {
export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>; export type NewBlock = SocketMessageParamsGeneric<'new_block', NewBlockSocketResponse>;
export type BlocksIndexStatus = SocketMessageParamsGeneric<'index_status', {finished: boolean; ratio: string}>; export type BlocksIndexStatus = SocketMessageParamsGeneric<'index_status', {finished: boolean; ratio: string}>;
export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>; export type TxStatusUpdate = SocketMessageParamsGeneric<'collated', NewBlockSocketResponse>;
export type NewTx = SocketMessageParamsGeneric<'transaction', { transaction: number }>;
export type NewPendingTx = SocketMessageParamsGeneric<'pending_transaction', { pending_transaction: number }>;
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
} }
...@@ -6,7 +6,7 @@ import notEmpty from 'lib/notEmpty'; ...@@ -6,7 +6,7 @@ import notEmpty from 'lib/notEmpty';
import { useSocket } from './context'; import { useSocket } from './context';
interface Params { interface Params {
topic: string; topic: string | undefined;
params?: object; params?: object;
isDisabled: boolean; isDisabled: boolean;
onJoin?: (channel: Channel, message: unknown) => void; onJoin?: (channel: Channel, message: unknown) => void;
...@@ -47,7 +47,7 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on ...@@ -47,7 +47,7 @@ export default function useSocketChannel({ topic, params, isDisabled, onJoin, on
}, [ channel, isDisabled ]); }, [ channel, isDisabled ]);
useEffect(() => { useEffect(() => {
if (socket === null || isDisabled) { if (socket === null || isDisabled || !topic) {
return; return;
} }
......
...@@ -7,7 +7,7 @@ export default function useSocketMessage({ channel, event, handler }: SocketMess ...@@ -7,7 +7,7 @@ export default function useSocketMessage({ channel, event, handler }: SocketMess
handlerRef.current = handler; handlerRef.current = handler;
useEffect(() => { useEffect(() => {
if (channel === undefined) { if (channel === undefined || event === undefined) {
return; return;
} }
......
const withReactSvg = require('next-react-svg');
const path = require('path'); const path = require('path');
const headers = require('./configs/nextjs/headers'); const headers = require('./configs/nextjs/headers');
...@@ -15,6 +14,12 @@ const moduleExports = { ...@@ -15,6 +14,12 @@ const moduleExports = {
__SENTRY_TRACING__: false, __SENTRY_TRACING__: false,
}), }),
); );
config.module.rules.push(
{
test: /\.svg$/,
use: [ '@svgr/webpack' ],
},
);
return config; return config;
}, },
...@@ -28,4 +33,4 @@ const moduleExports = { ...@@ -28,4 +33,4 @@ const moduleExports = {
output: 'standalone', output: 'standalone',
}; };
module.exports = withReactSvg(moduleExports); module.exports = moduleExports;
import type { NextApiRequest } from 'next';
import getSearchParams from 'lib/api/getSearchParams';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
const searchParamsStr = getSearchParams(req);
return `/v2/transactions/${ req.query.id }/token-transfers${ searchParamsStr ? '?' + searchParamsStr : '' }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react'; import React from 'react';
import type { PageParams } from 'lib/next/tx/types'; import type { PageParams } from 'lib/next/block/types';
import BlockNextPage from 'lib/next/block/BlockNextPage'; import getSeo from 'lib/next/block/getSeo';
import Block from 'ui/pages/Block';
type Props = { const BlockPage: NextPage<PageParams> = ({ id }: PageParams) => {
pageParams: PageParams; const { title, description } = getSeo({ id });
}
const BlockPage: NextPage<Props> = ({ pageParams }: Props) => {
return ( return (
<BlockNextPage pageParams={ pageParams }/> <>
<Head>
<title>{ title }</title>
<meta name="description" content={ description }/>
</Head>
<Block/>
</>
); );
}; };
......
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react'; import React from 'react';
import BlocksNextPage from 'lib/next/blocks/BlocksNextPage'; import getSeo from 'lib/next/blocks/getSeo';
import Blocks from 'ui/pages/Blocks';
const BlockPage: NextPage = () => { const BlockPage: NextPage = () => {
const { title } = getSeo();
return ( return (
<BlocksNextPage/> <>
<Head>
<title>{ title }</title>
</Head>
<Blocks/>
</>
); );
}; };
......
...@@ -2,20 +2,17 @@ import type { NextPage } from 'next'; ...@@ -2,20 +2,17 @@ import type { NextPage } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import Blocks from 'ui/pages/Blocks'; import Graph from 'ui/pages/Graph';
import getSeo from './getSeo'; const GraphPage: NextPage = () => {
const BlocksNextPage: NextPage = () => {
const { title } = getSeo();
return ( return (
<> <>
<Head> <Head><title>Graph Page</title></Head>
<title>{ title }</title> <Graph/>
</Head>
<Blocks/>
</> </>
); );
}; };
export default BlocksNextPage; export default GraphPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
import type { NextPage } from 'next'; import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react'; import React from 'react';
import type { PageParams } from 'lib/next/tx/types'; import type { PageParams } from 'lib/next/tx/types';
import TransactionNextPage from 'lib/next/tx/TransactionNextPage'; import getSeo from 'lib/next/tx/getSeo';
import Transaction from 'ui/pages/Transaction';
type Props = { const TransactionPage: NextPage<PageParams> = ({ id }: PageParams) => {
pageParams: PageParams; const { title, description } = getSeo({ id });
}
const TransactionPage: NextPage<Props> = ({ pageParams }: Props) => {
return ( return (
<TransactionNextPage pageParams={ pageParams }/> <>
<Head>
<title>{ title }</title>
<meta name="description" content={ description }/>
</Head>
<Transaction/>
</>
); );
}; };
......
...@@ -9,6 +9,10 @@ const global = (props: StyleFunctionProps) => ({ ...@@ -9,6 +9,10 @@ const global = (props: StyleFunctionProps) => ({
...getDefaultTransitionProps(), ...getDefaultTransitionProps(),
'-webkit-tap-highlight-color': 'transparent', '-webkit-tap-highlight-color': 'transparent',
}, },
'svg *::selection': {
color: 'none',
background: 'none',
},
form: { form: {
w: '100%', w: '100%',
}, },
......
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
import type { PaginationParams } from './pagination';
import type { TokenInfoGeneric } from './tokenInfo'; import type { TokenInfoGeneric } from './tokenInfo';
export type Erc20TotalPayload = { export type Erc20TotalPayload = {
...@@ -37,3 +38,8 @@ interface TokenTransferBase { ...@@ -37,3 +38,8 @@ interface TokenTransferBase {
from: AddressParam; from: AddressParam;
to: AddressParam; to: AddressParam;
} }
export interface TokenTransferResponse {
items: Array<TokenTransfer>;
next_page_params: PaginationParams | null;
}
...@@ -6,6 +6,7 @@ export enum QueryKeys { ...@@ -6,6 +6,7 @@ export enum QueryKeys {
txInternals = 'tx-internals', txInternals = 'tx-internals',
txLog = 'tx-log', txLog = 'tx-log',
txRawTrace = 'tx-raw-trace', txRawTrace = 'tx-raw-trace',
txTokenTransfers = 'tx-token-transfers',
blockTxs = 'block-transactions', blockTxs = 'block-transactions',
block = 'block', block = 'block',
blocks = 'blocks', blocks = 'blocks',
......
...@@ -2,6 +2,7 @@ import { Grid, GridItem, Text, Icon, Link, Box, Tooltip, Alert } from '@chakra-u ...@@ -2,6 +2,7 @@ import { Grid, GridItem, Text, Icon, Link, Box, Tooltip, Alert } from '@chakra-u
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import NextLink from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { scroller, Element } from 'react-scroll'; import { scroller, Element } from 'react-scroll';
...@@ -109,9 +110,11 @@ const BlockDetails = () => { ...@@ -109,9 +110,11 @@ const BlockDetails = () => {
title="Transactions" title="Transactions"
hint="The number of transactions in the block." hint="The number of transactions in the block."
> >
<Link href={ link('block', { id: router.query.id }, { tab: 'txs' }) }> <NextLink href={ link('block', { id: router.query.id }, { tab: 'txs' }) } passHref>
{ data.tx_count } transactions <Link>
</Link> { data.tx_count } transactions
</Link>
</NextLink>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title={ appConfig.network.verificationType === 'validation' ? 'Validated by' : 'Mined by' } title={ appConfig.network.verificationType === 'validation' ? 'Validated by' : 'Mined by' }
......
...@@ -25,7 +25,11 @@ const BlocksContent = ({ type }: Props) => { ...@@ -25,7 +25,11 @@ const BlocksContent = ({ type }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [ socketAlert, setSocketAlert ] = React.useState(''); const [ socketAlert, setSocketAlert ] = React.useState('');
const { data, isLoading, isError, pagination } = useQueryWithPages<BlocksResponse>('/node-api/blocks', QueryKeys.blocks, { type }); const { data, isLoading, isError, pagination } = useQueryWithPages<BlocksResponse>({
apiPath: '/node-api/blocks',
queryName: QueryKeys.blocks,
filters: { type },
});
const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => { const handleNewBlockMessage: SocketMessage.NewBlock['handler'] = React.useCallback((payload) => {
queryClient.setQueryData([ QueryKeys.blocks, { page: pagination.page, filters: { type } } ], (prevData: BlocksResponse | undefined) => { queryClient.setQueryData([ QueryKeys.blocks, { page: pagination.page, filters: { type } } ], (prevData: BlocksResponse | undefined) => {
......
import { useToken, Button, Box } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
import ethTokenTransferData from 'data/charts_eth_token_transfer.json';
import ethTxsData from 'data/charts_eth_txs.json';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
import ChartGridLine from 'ui/shared/chart/ChartGridLine';
import ChartLegend from 'ui/shared/chart/ChartLegend';
import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartSelectionX from 'ui/shared/chart/ChartSelectionX';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
// import useBrushX from 'ui/shared/chart/useBrushX';
import useChartLegend from 'ui/shared/chart/useChartLegend';
import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const CHART_OFFSET = {
y: 26, // legend height
};
const EthereumChart = () => {
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN, CHART_OFFSET);
const [ range, setRange ] = React.useState<[number, number]>([ 0, Infinity ]);
const data: TimeChartData = [
{
name: 'Daily Transactions',
color: useToken('colors', 'blue.500'),
items: ethTxsData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
},
{
name: 'ERC-20 Token Transfers',
color: useToken('colors', 'green.500'),
items: ethTokenTransferData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
},
];
const { selectedLines, handleLegendItemClick } = useChartLegend(data.length);
const filteredData = data.filter((_, index) => selectedLines.includes(index));
const { yTickFormat, xScale, yScale } = useTimeChartController({
data: filteredData.length === 0 ? data : filteredData,
width: innerWidth,
height: innerHeight,
});
const handleRangeSelect = React.useCallback((nextRange: [number, number]) => {
setRange([ range[0] + nextRange[0], range[0] + nextRange[1] ]);
}, [ range ]);
const handleZoomReset = React.useCallback(() => {
setRange([ 0, Infinity ]);
}, [ ]);
// uncomment if we need brush the chart
// const brushLimits = React.useMemo(() => (
// [ [ 0, innerHeight ], [ innerWidth, height ] ] as [[number, number], [number, number]]
// ), [ height, innerHeight, innerWidth ]);
// useBrushX({ anchor: ref.current, limits: brushLimits, setRange });
return (
<Box display="inline-block" position="relative" width="100%" height="100%">
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ width ? 1 : 0 }>
{ /* BASE GRID LINE */ }
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 1 }
size={ innerWidth }
disableAnimation
/>
{ /* GIRD LINES */ }
<ChartGridLine
type="vertical"
scale={ xScale }
ticks={ 5 }
size={ innerHeight }
transform={ `translate(0, ${ innerHeight })` }
disableAnimation
/>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 5 }
size={ innerWidth }
disableAnimation
/>
{ /* GRAPH */ }
{ filteredData.map((d) => (
<ChartLine
key={ d.name }
data={ d.items }
xScale={ xScale }
yScale={ yScale }
stroke={ d.color }
animation="left"
/>
)) }
{ filteredData.map((d) => (
<ChartArea
key={ d.name }
data={ d.items }
color={ d.color }
xScale={ xScale }
yScale={ yScale }
/>
)) }
{ /* AXISES */ }
<ChartAxis
type="left"
scale={ yScale }
ticks={ 5 }
tickFormat={ yTickFormat }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ 5 }
anchorEl={ overlayRef.current }
disableAnimation
/>
{ filteredData.length > 0 && (
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
margin={ CHART_MARGIN }
xScale={ xScale }
yScale={ yScale }
data={ filteredData }
/>
) }
{ filteredData.length > 0 && (
<ChartSelectionX
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ xScale }
data={ filteredData }
onSelect={ handleRangeSelect }
/>
) }
</ChartOverlay>
</g>
</svg>
{ (range[0] !== 0 || range[1] !== Infinity) && (
<Button
size="sm"
variant="outline"
position="absolute"
top={ `${ CHART_MARGIN?.top || 0 }px` }
right={ `${ CHART_MARGIN?.right || 0 }px` }
onClick={ handleZoomReset }
>
Reset zoom
</Button>
) }
<ChartLegend data={ data } selectedIndexes={ selectedLines } onClick={ handleLegendItemClick }/>
</Box>
);
};
export default React.memo(EthereumChart);
import { useToken } from '@chakra-ui/react';
import React from 'react';
import ethTxsData from 'data/charts_eth_txs.json';
import ChartLine from 'ui/shared/chart/ChartLine';
import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import { BlueLinearGradient } from 'ui/shared/chart/utils/gradients';
const CHART_MARGIN = { bottom: 0, left: 0, right: 0, top: 0 };
const DATA = ethTxsData.slice(-30).map((d) => ({ ...d, date: new Date(d.date) }));
const SplineChartExample = () => {
const ref = React.useRef<SVGSVGElement>(null);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
const color = useToken('colors', 'blue.500');
const { xScale, yScale } = useTimeChartController({
data: [ { items: DATA, name: 'spline', color } ],
width: innerWidth,
height: innerHeight,
});
return (
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref }>
<defs>
<BlueLinearGradient.defs/>
</defs>
<ChartLine
data={ DATA }
xScale={ xScale }
yScale={ yScale }
stroke={ `url(#${ BlueLinearGradient.id })` }
animation="left"
strokeWidth={ 3 }
/>
</svg>
);
};
export default SplineChartExample;
import { Box, Heading } from '@chakra-ui/react';
import React from 'react';
import EthereumChart from 'ui/charts/EthereumChart';
import SplineChartExample from 'ui/charts/SplineChartExample';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const Graph = () => {
return (
<Page>
<PageTitle text="Charts"/>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 }>Ethereum Daily Transactions & ERC-20 Token Transfer Chart</Heading>
<Box w="100%" h="400px">
<EthereumChart/>
</Box>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 } mt="80px">Ethereum Daily Transactions For Last Month</Heading>
<Box w="240px" h="150px">
<SplineChartExample/>
</Box>
</Page>
);
};
export default Graph;
...@@ -19,10 +19,12 @@ import TxDetails from 'ui/tx/TxDetails'; ...@@ -19,10 +19,12 @@ import TxDetails from 'ui/tx/TxDetails';
import TxInternals from 'ui/tx/TxInternals'; import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs'; import TxLogs from 'ui/tx/TxLogs';
import TxRawTrace from 'ui/tx/TxRawTrace'; import TxRawTrace from 'ui/tx/TxRawTrace';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
// import TxState from 'ui/tx/TxState'; // import TxState from 'ui/tx/TxState';
const TABS: Array<RoutedTab> = [ const TABS: Array<RoutedTab> = [
{ id: 'index', title: 'Details', component: <TxDetails/> }, { id: 'index', title: 'Details', component: <TxDetails/> },
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> },
{ id: 'internal', title: 'Internal txn', component: <TxInternals/> }, { id: 'internal', title: 'Internal txn', component: <TxInternals/> },
{ id: 'logs', title: 'Logs', component: <TxLogs/> }, { id: 'logs', title: 'Logs', component: <TxLogs/> },
// will be implemented later, api is not ready // will be implemented later, api is not ready
......
...@@ -2,18 +2,34 @@ import { ...@@ -2,18 +2,34 @@ import {
Icon, Icon,
Center, Center,
useColorModeValue, useColorModeValue,
chakra,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import infoIcon from 'icons/info.svg'; import infoIcon from 'icons/info.svg';
const TxAdditionalInfoButton = ({ isOpen, onClick }: {isOpen?: boolean; onClick?: () => void}, ref: React.ForwardedRef<HTMLDivElement>) => { interface Props {
isOpen?: boolean;
className?: string;
onClick?: () => void;
}
const AdditionalInfoButton = ({ isOpen, onClick, className }: Props, ref: React.ForwardedRef<HTMLDivElement>) => {
const infoBgColor = useColorModeValue('blue.50', 'gray.600'); const infoBgColor = useColorModeValue('blue.50', 'gray.600');
const infoColor = useColorModeValue('blue.600', 'blue.300'); const infoColor = useColorModeValue('blue.600', 'blue.300');
return ( return (
<Center ref={ ref } background={ isOpen ? infoBgColor : 'unset' } borderRadius="8px" w="24px" h="24px" onClick={ onClick } cursor="pointer"> <Center
className={ className }
ref={ ref }
background={ isOpen ? infoBgColor : 'unset' }
borderRadius="8px"
w="24px"
h="24px"
onClick={ onClick }
cursor="pointer"
>
<Icon <Icon
as={ infoIcon } as={ infoIcon }
boxSize={ 5 } boxSize={ 5 }
...@@ -24,4 +40,4 @@ const TxAdditionalInfoButton = ({ isOpen, onClick }: {isOpen?: boolean; onClick? ...@@ -24,4 +40,4 @@ const TxAdditionalInfoButton = ({ isOpen, onClick }: {isOpen?: boolean; onClick?
); );
}; };
export default React.forwardRef(TxAdditionalInfoButton); export default chakra(React.forwardRef(AdditionalInfoButton));
...@@ -4,8 +4,8 @@ import React from 'react'; ...@@ -4,8 +4,8 @@ import React from 'react';
import infoIcon from 'icons/info.svg'; import infoIcon from 'icons/info.svg';
interface Props extends HTMLChakraProps<'div'> { interface Props extends Omit<HTMLChakraProps<'div'>, 'title'> {
title: string; title: React.ReactNode;
hint: string; hint: string;
children: React.ReactNode; children: React.ReactNode;
} }
......
import { Tag, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
baseAddress: string;
addressFrom: string;
className?: string;
}
const InOutTag = ({ baseAddress, addressFrom, className }: Props) => {
const isOut = addressFrom === baseAddress;
const colorScheme = isOut ? 'orange' : 'green';
return <Tag className={ className } colorScheme={ colorScheme }>{ isOut ? 'OUT' : 'IN' }</Tag>;
};
export default React.memo(chakra(InOutTag));
...@@ -38,7 +38,7 @@ const Page = ({ ...@@ -38,7 +38,7 @@ const Page = ({
) : children; ) : children;
return ( return (
<SocketProvider url={ `${ appConfig.api.socket }${ appConfig.api.basePath }/socket/v2/websocket?vsn=2.0.0` }> <SocketProvider url={ `${ appConfig.api.socket }${ appConfig.api.basePath }/socket/v2` }>
<ScrollDirectionContext.Provider value={ directionContext }> <ScrollDirectionContext.Provider value={ directionContext }>
<Flex w="100%" minH="100vh" alignItems="stretch"> <Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/> <NavigationDesktop/>
......
import { Image, chakra } from '@chakra-ui/react'; import { Image, Center, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
const EmptyElement = () => null; const EmptyElement = ({ className, letter }: { className?: string; letter: string }) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
return (
<Center
className={ className }
fontWeight={ 600 }
bgColor={ bgColor }
color={ color }
borderRadius="base"
>
{ letter.toUpperCase() }
</Center>
);
};
interface Props { interface Props {
hash: string; hash: string;
...@@ -12,15 +27,22 @@ interface Props { ...@@ -12,15 +27,22 @@ interface Props {
} }
const TokenLogo = ({ hash, name, className }: Props) => { const TokenLogo = ({ hash, name, className }: Props) => {
const logoSrc = appConfig.network.assetsPathname ? ` const logoSrc = appConfig.network.assetsPathname ? [
https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/',
${ appConfig.network.assetsPathname } appConfig.network.assetsPathname,
/assets/ '/assets/',
${ hash } hash,
/logo.png '/logo.png',
` : undefined; ].join('') : undefined;
return <Image className={ className } src={ logoSrc } alt={ `${ name || 'token' } logo` } fallback={ <EmptyElement/> }/>; return (
<Image
className={ className }
src={ logoSrc }
alt={ `${ name || 'token' } logo` }
fallback={ <EmptyElement className={ className } letter={ name?.slice(0, 1) || 'U' }/> }
/>
);
}; };
export default React.memo(chakra(TokenLogo)); export default React.memo(chakra(TokenLogo));
import { Center, Link, Text, chakra } from '@chakra-ui/react'; import { Flex, Text, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import link from 'lib/link/link'; import AddressLink from 'ui/shared/address/AddressLink';
import TokenLogo from 'ui/shared/TokenLogo'; import TokenLogo from 'ui/shared/TokenLogo';
interface Props { interface Props {
...@@ -12,16 +12,12 @@ interface Props { ...@@ -12,16 +12,12 @@ interface Props {
} }
const TokenSnippet = ({ symbol, hash, name, className }: Props) => { const TokenSnippet = ({ symbol, hash, name, className }: Props) => {
const url = link('token_index', { hash });
return ( return (
<Center className={ className } columnGap={ 1 }> <Flex className={ className } alignItems="center" columnGap={ 1 } w="100%">
<TokenLogo boxSize={ 5 } hash={ hash } name={ name }/> <TokenLogo boxSize={ 5 } hash={ hash } name={ name }/>
<Link href={ url } target="_blank"> <AddressLink hash={ hash } alias={ name } type="token"/>
{ name }
</Link>
{ symbol && <Text variant="secondary">({ symbol })</Text> } { symbol && <Text variant="secondary">({ symbol })</Text> }
</Center> </Flex>
); );
}; };
......
import { Hide, Show, Text, Flex, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransferResponse } from 'types/api/tokenTransfer';
import type { QueryKeys } from 'types/client/queries';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import HashStringShorten from 'ui/shared/HashStringShorten';
import Pagination from 'ui/shared/Pagination';
import SkeletonTable from 'ui/shared/SkeletonTable';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferSkeletonMobile from 'ui/shared/TokenTransfer/TokenTransferSkeletonMobile';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
interface Props {
isLoading?: boolean;
isDisabled?: boolean;
path: string;
queryName: QueryKeys;
queryIds?: Array<string>;
baseAddress?: string;
showTxInfo?: boolean;
txHash?: string;
}
const TokenTransfer = ({ isLoading: isLoadingProp, isDisabled, queryName, queryIds, path, baseAddress, showTxInfo = true, txHash }: Props) => {
const { isError, isLoading, data, pagination } = useQueryWithPages<TokenTransferResponse>({
apiPath: path,
queryName,
queryIds,
options: { enabled: !isDisabled },
});
const isPaginatorHidden = pagination.page === 1 && !pagination.hasNextPage;
const content = (() => {
if (isLoading || isLoadingProp) {
return (
<>
<Hide below="lg">
{ txHash !== undefined && <Skeleton mb={ 6 } h={ 6 } w="340px"/> }
<SkeletonTable columns={ showTxInfo ?
[ '44px', '185px', '160px', '25%', '25%', '25%', '25%' ] :
[ '185px', '25%', '25%', '25%', '25%' ] }
/>
</Hide>
<Show below="lg">
<TokenTransferSkeletonMobile showTxInfo={ showTxInfo } txHash={ txHash }/>
</Show>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (!data.items?.length) {
return <Text as="span">There are no token transfers</Text>;
}
const items = data.items.reduce(flattenTotal, []);
return (
<>
<Hide below="lg">
<TokenTransferTable data={ items } baseAddress={ baseAddress } showTxInfo={ showTxInfo } top={ isPaginatorHidden ? 0 : 80 }/>
</Hide>
<Show below="lg">
<TokenTransferList data={ items } baseAddress={ baseAddress } showTxInfo={ showTxInfo }/>
</Show>
</>
);
})();
return (
<>
{ txHash && (data?.items.length || 0 > 0) && (
<Flex mb={ isPaginatorHidden ? 6 : 0 } w="100%">
<Text as="span" fontWeight={ 600 } whiteSpace="pre">Token transfers for by txn hash: </Text>
<HashStringShorten hash={ txHash }/>
</Flex>
) }
{ isPaginatorHidden ? null : (
<ActionBar>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
{ content }
</>
);
};
export default React.memo(TokenTransfer);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import TokenTransferListItem from 'ui/shared/TokenTransfer/TokenTransferListItem';
interface Props {
data: Array<TokenTransfer>;
baseAddress?: string;
showTxInfo?: boolean;
}
const TokenTransferList = ({ data, baseAddress, showTxInfo }: Props) => {
return (
<Box>
{ data.map((item, index) => <TokenTransferListItem key={ index } { ...item } baseAddress={ baseAddress } showTxInfo={ showTxInfo }/>) }
</Box>
);
};
export default TokenTransferList;
import { Text, Flex, Tag, Icon } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import InOutTag from 'ui/shared/InOutTag';
import TokenSnippet from 'ui/shared/TokenSnippet';
import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferNft from 'ui/shared/TokenTransfer/TokenTransferNft';
type Props = TokenTransfer & {
baseAddress?: string;
showTxInfo?: boolean;
}
const TokenTransferListItem = ({ token, total, tx_hash: txHash, from, to, baseAddress, showTxInfo, type }: Props) => {
const value = (() => {
if (!('value' in total)) {
return null;
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const addressWidth = `calc((100% - ${ baseAddress ? '50px' : '0px' }) / 2)`;
return (
<AccountListItemMobile rowGap={ 3 }>
<Flex w="100%" flexWrap="wrap" rowGap={ 1 } position="relative">
<TokenSnippet hash={ token.address } w="auto" maxW="calc(100% - 140px)" name={ token.name || 'Unnamed token' }/>
<Tag flexShrink={ 0 } ml={ 2 } mr={ 2 }>{ token.type }</Tag>
<Tag colorScheme="orange">{ getTokenTransferTypeText(type) }</Tag>
{ showTxInfo && <AdditionalInfoButton position="absolute" top={ 0 } right={ 0 }/> }
</Flex>
{ 'token_id' in total && <TokenTransferNft hash={ token.address } id={ total.token_id }/> }
{ showTxInfo && (
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Txn hash</Text>
<Address display="inline-flex" maxW="100%">
<AddressLink type="transaction" hash={ txHash }/>
</Address>
</Flex>
) }
<Flex w="100%" columnGap={ 3 }>
<Address width={ addressWidth }>
<AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash }/>
</Address>
{ baseAddress ?
<InOutTag baseAddress={ baseAddress } addressFrom={ from.hash } w="50px" textAlign="center"/> :
<Icon as={ eastArrowIcon } boxSize={ 6 } color="gray.500"/>
}
<Address width={ addressWidth }>
<AddressIcon hash={ to.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ to.hash }/>
</Address>
</Flex>
{ value && (
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Value</Text>
<Text variant="secondary">{ value }</Text>
</Flex>
) }
</AccountListItemMobile>
);
};
export default React.memo(TokenTransferListItem);
import { Box, Icon, Link } from '@chakra-ui/react';
import React from 'react';
import nftPlaceholder from 'icons/nft_shield.svg';
import link from 'lib/link/link';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
hash: string;
id: string;
}
const TokenTransferNft = ({ hash, id }: Props) => {
return (
<Link href={ link('token_instance_item', { hash, id }) } overflow="hidden" whiteSpace="nowrap" display="flex" alignItems="center" w="100%">
<Icon as={ nftPlaceholder } boxSize="30px" mr={ 1 } color="inherit"/>
<Box maxW="calc(100% - 34px)">
<HashStringShortenDynamic hash={ id } fontWeight={ 500 }/>
</Box>
</Link>
);
};
export default React.memo(TokenTransferNft);
import { Skeleton, SkeletonCircle, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const TokenTransferSkeletonMobile = ({ showTxInfo, txHash }: { showTxInfo?: boolean; txHash?: string }) => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<>
{ txHash !== undefined && <Skeleton mb={ 6 } h={ 6 } w="100%"/> }
<Box>
{ Array.from(Array(2)).map((item, index) => (
<Flex
key={ index }
rowGap={ 3 }
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
_last={{
borderBottomWidth: '1px',
}}
>
<Flex h={ 6 }>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton w="100px" mr={ 2 }/>
<Skeleton w="50px"/>
{ showTxInfo && <Skeleton w="24px" ml="auto"/> }
</Flex>
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="70px"/>
<Skeleton w="24px"/>
<Skeleton w="90px"/>
</Flex>
{ showTxInfo && (
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="70px" flexShrink={ 0 }/>
<Skeleton w="100%"/>
</Flex>
) }
<Flex h={ 6 }>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
<Skeleton w="50px" mr={ 3 }/>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
</Flex>
<Flex h={ 6 } columnGap={ 2 }>
<Skeleton w="45px"/>
<Skeleton w="90px"/>
</Flex>
</Flex>
)) }
</Box>
</>
);
};
export default TokenTransferSkeletonMobile;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokenTransferTableItem from 'ui/shared/TokenTransfer/TokenTransferTableItem';
interface Props {
data: Array<TokenTransfer>;
baseAddress?: string;
showTxInfo?: boolean;
top: number;
}
const TokenTransferTable = ({ data, baseAddress, showTxInfo, top }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
{ showTxInfo && <Th width="44px"></Th> }
<Th width="185px">Token</Th>
<Th width="160px">Token ID</Th>
{ showTxInfo && <Th width="25%">Txn hash</Th> }
<Th width="25%">From</Th>
{ baseAddress && <Th width="50px" px={ 0 }/> }
<Th width="25%">To</Th>
<Th width="25%" isNumeric>Value</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item, index) => (
<TokenTransferTableItem key={ index } { ...item } baseAddress={ baseAddress } showTxInfo={ showTxInfo }/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(TokenTransferTable);
import { Tr, Td, Tag, Flex } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import InOutTag from 'ui/shared/InOutTag';
import TokenSnippet from 'ui/shared/TokenSnippet';
import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferNft from 'ui/shared/TokenTransfer/TokenTransferNft';
type Props = TokenTransfer & {
baseAddress?: string;
showTxInfo?: boolean;
}
const TxInternalTableItem = ({ token, total, tx_hash: txHash, from, to, baseAddress, showTxInfo, type }: Props) => {
const value = (() => {
if (!('value' in total)) {
return '-';
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
return (
<Tr alignItems="top">
{ showTxInfo && (
<Td>
<AdditionalInfoButton/>
</Td>
) }
<Td>
<Flex flexDir="column" alignItems="flex-start">
<TokenSnippet hash={ token.address } name={ token.name || 'Unnamed token' } lineHeight="30px"/>
<Tag mt={ 1 }>{ token.type }</Tag>
<Tag colorScheme="orange" mt={ 2 }>{ getTokenTransferTypeText(type) }</Tag>
</Flex>
</Td>
<Td lineHeight="30px">
{ 'token_id' in total ? <TokenTransferNft hash={ token.address } id={ total.token_id }/> : '-' }
</Td>
{ showTxInfo && (
<Td>
<Address display="inline-flex" maxW="100%" fontWeight={ 600 } lineHeight="30px">
<AddressLink type="transaction" hash={ txHash }/>
</Address>
</Td>
) }
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
</Address>
</Td>
{ baseAddress && (
<Td px={ 0 }>
<InOutTag baseAddress={ baseAddress } addressFrom={ from.hash } w="50px" textAlign="center" mt="3px"/>
</Td>
) }
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon hash={ to.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ to.hash } alias={ to.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td isNumeric verticalAlign="top" lineHeight="30px">
{ value }
</Td>
</Tr>
);
};
export default React.memo(TxInternalTableItem);
import type { TokenTransfer } from 'types/api/tokenTransfer';
export const flattenTotal = (result: Array<TokenTransfer>, item: TokenTransfer): Array<TokenTransfer> => {
if (Array.isArray(item.total)) {
item.total.forEach((total) => {
result.push({ ...item, total });
});
} else {
result.push(item);
}
return result;
};
export const getTokenTransferTypeText = (type: TokenTransfer['type']) => {
switch (type) {
case 'token_minting':
return 'Token minting';
case 'token_burning':
return 'Token burning';
case 'token_spawning':
return 'Token creating';
case 'token_transfer':
return 'Token transfer';
}
};
...@@ -7,7 +7,7 @@ import HashStringShorten from 'ui/shared/HashStringShorten'; ...@@ -7,7 +7,7 @@ import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props { interface Props {
type?: 'address' | 'transaction' | 'token' | 'block'; type?: 'address' | 'transaction' | 'token' | 'block' | 'token_instance_item';
alias?: string | null; alias?: string | null;
className?: string; className?: string;
hash: string; hash: string;
...@@ -23,6 +23,8 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id, ...@@ -23,6 +23,8 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id,
url = link('tx', { id: id || hash }); url = link('tx', { id: id || hash });
} else if (type === 'token') { } else if (type === 'token') {
url = link('token_index', { hash: id || hash }); url = link('token_index', { hash: id || hash });
} else if (type === 'token_instance_item') {
url = link('token_instance_item', { hash, id });
} else if (type === 'block') { } else if (type === 'block') {
url = link('block', { id: id || hash }); url = link('block', { id: id || hash });
} else { } else {
...@@ -39,11 +41,11 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id, ...@@ -39,11 +41,11 @@ const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id,
} }
switch (truncation) { switch (truncation) {
case 'constant': case 'constant':
return <HashStringShorten hash={ hash }/>; return <HashStringShorten hash={ id || hash }/>;
case 'dynamic': case 'dynamic':
return <HashStringShortenDynamic hash={ hash } fontWeight={ fontWeight }/>; return <HashStringShortenDynamic hash={ id || hash } fontWeight={ fontWeight }/>;
case 'none': case 'none':
return <span>{ hash }</span>; return <span>{ id || hash }</span>;
} }
})(); })();
......
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
interface Props extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
color?: string;
data: Array<TimeChartItem>;
disableAnimation?: boolean;
}
const ChartArea = ({ xScale, yScale, color, data, disableAnimation, ...props }: Props) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (disableAnimation) {
d3.select(ref.current).attr('opacity', 1);
return;
}
d3.select(ref.current).transition()
.duration(750)
.ease(d3.easeBackIn)
.attr('opacity', 1);
}, [ disableAnimation ]);
const d = React.useMemo(() => {
const area = d3.area<TimeChartItem>()
.x(({ date }) => xScale(date))
.y1(({ value }) => yScale(value))
.y0(() => yScale(yScale.domain()[0]))
.curve(d3.curveNatural);
return area(data) || undefined;
}, [ xScale, yScale, data ]);
return (
<>
<path ref={ ref } d={ d } fill={ color ? `url(#gradient-${ color })` : 'none' } opacity={ 0 } { ...props }/>
{ color && (
<defs>
<linearGradient id={ `gradient-${ color }` } x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stopColor={ color } stopOpacity={ 0.8 }/>
<stop offset="100%" stopColor={ color } stopOpacity={ 0.02 }/>
</linearGradient>
</defs>
) }
</>
);
};
export default React.memo(ChartArea);
import { useColorModeValue, useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'left' | 'bottom';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean;
ticks: number;
tickFormat?: (domainValue: d3.AxisDomain, index: number) => string;
anchorEl?: SVGRectElement | null;
}
const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const textColorToken = useColorModeValue('blackAlpha.500', 'whiteAlpha.500');
const textColor = useToken('colors', textColorToken);
React.useEffect(() => {
if (!ref.current) {
return;
}
const axisGenerator = type === 'left' ? d3.axisLeft : d3.axisBottom;
const axis = tickFormat ?
axisGenerator(scale).ticks(ticks).tickFormat(tickFormat) :
axisGenerator(scale).ticks(ticks);
const axisGroup = d3.select(ref.current);
if (disableAnimation) {
axisGroup.call(axis);
} else {
axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
}
axisGroup.select('.domain').remove();
axisGroup.selectAll('line').remove();
axisGroup.selectAll('text')
.attr('opacity', 1)
.attr('color', textColor)
.attr('font-size', '0.75rem');
}, [ scale, ticks, tickFormat, disableAnimation, type, textColor ]);
React.useEffect(() => {
if (!anchorEl) {
return;
}
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mouseout.axisX', () => {
d3.select(ref.current)
.selectAll('text')
.style('font-weight', 'normal');
})
.on('mousemove.axisX', (event) => {
const [ x ] = d3.pointer(event, anchorEl);
const xDate = scale.invert(x);
const textElements = d3.select(ref.current).selectAll('text');
const data = textElements.data();
const index = d3.bisector((d) => d).left(data, xDate);
textElements
.style('font-weight', (d, i) => i === index - 1 ? 'bold' : 'normal');
});
return () => {
anchorD3.on('mouseout.axisX mousemove.axisX', null);
};
}, [ anchorEl, scale ]);
return <g ref={ ref } { ...props }/>;
};
export default React.memo(ChartAxis);
import { useColorModeValue, useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'vertical' | 'horizontal';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean;
size: number;
ticks: number;
}
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const strokeColorToken = useColorModeValue('blackAlpha.300', 'whiteAlpha.300');
const strokeColor = useToken('colors', strokeColorToken);
React.useEffect(() => {
if (!ref.current) {
return;
}
const axisGenerator = type === 'vertical' ? d3.axisBottom : d3.axisLeft;
const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
const gridGroup = d3.select(ref.current);
if (disableAnimation) {
gridGroup.call(axis);
} else {
gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
}
gridGroup.select('.domain').remove();
gridGroup.selectAll('text').remove();
gridGroup.selectAll('line').attr('stroke', strokeColor);
}, [ scale, ticks, size, disableAnimation, type, strokeColor ]);
return <g ref={ ref } { ...props }/>;
};
export default React.memo(ChartGridLine);
import { Box, Circle, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
interface Props {
data: TimeChartData;
selectedIndexes: Array<number>;
onClick: (index: number) => void;
}
const ChartLegend = ({ data, selectedIndexes, onClick }: Props) => {
const handleItemClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const itemIndex = (event.currentTarget as HTMLDivElement).getAttribute('data-index');
onClick(Number(itemIndex));
}, [ onClick ]);
return (
<Box display="flex" columnGap={ 3 } mt={ 2 }>
{ data.map(({ name, color }, index) => {
const isSelected = selectedIndexes.includes(index);
return (
<Box
key={ name }
data-index={ index }
display="flex"
columnGap={ 1 }
alignItems="center"
onClick={ handleItemClick }
cursor="pointer"
>
<Circle
size={ 2 }
bgColor={ isSelected ? color : 'transparent' }
borderWidth={ 2 }
borderColor={ color }
/>
<Text fontSize="xs">
{ name }
</Text>
</Box>
);
}) }
</Box>
);
};
export default React.memo(ChartLegend);
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
interface Props extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
data: Array<TimeChartItem>;
animation: 'left' | 'fadeIn' | 'none';
}
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
const ref = React.useRef<SVGPathElement>(null);
// Define different types of animation that we can use
const animateLeft = React.useCallback(() => {
const totalLength = ref.current?.getTotalLength() || 0;
d3.select(ref.current)
.attr('opacity', 1)
.attr('stroke-dasharray', `${ totalLength },${ totalLength }`)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('stroke-dashoffset', 0);
}, []);
const animateFadeIn = React.useCallback(() => {
d3.select(ref.current)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('opacity', 1);
}, []);
const noneAnimation = React.useCallback(() => {
d3.select(ref.current).attr('opacity', 1);
}, []);
React.useEffect(() => {
const ANIMATIONS = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
const animationFn = ANIMATIONS[animation];
window.setTimeout(animationFn, 100);
}, [ animateLeft, animateFadeIn, noneAnimation, animation ]);
// Recalculate line length if scale has changed
React.useEffect(() => {
if (animation === 'left') {
const totalLength = ref.current?.getTotalLength();
d3.select(ref.current).attr(
'stroke-dasharray',
`${ totalLength },${ totalLength }`,
);
}
}, [ xScale, yScale, animation ]);
const line = d3.line<TimeChartItem>()
.x((d) => xScale(d.date))
.y((d) => yScale(d.value))
.curve(d3.curveNatural);
return (
<path
ref={ ref }
d={ line(data) || undefined }
strokeWidth={ 1 }
fill="none"
opacity={ 0 }
{ ...props }
/>
);
};
export default React.memo(ChartLine);
import React from 'react';
interface Props {
width: number;
height: number;
children: React.ReactNode;
}
const ChartOverlay = ({ width, height, children }: Props, ref: React.ForwardedRef<SVGRectElement>) => {
return (
<g className="ChartOverlay">
{ children }
<rect ref={ ref } width={ width } height={ height } opacity={ 0 }/>
</g>
);
};
export default React.forwardRef(ChartOverlay);
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
const SELECTION_THRESHOLD = 1;
interface Props {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
data: TimeChartData;
onSelect: (range: [number, number]) => void;
}
const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) => {
const borderColor = useToken('colors', 'blue.200');
const ref = React.useRef(null);
const isPressed = React.useRef(false);
const startX = React.useRef<number>();
const endX = React.useRef<number>();
const startIndex = React.useRef<number>(0);
const getIndexByX = React.useCallback((x: number) => {
const xDate = scale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
return bisectDate(data[0].items, xDate, 1);
}, [ data, scale ]);
const drawSelection = React.useCallback((x0: number, x1: number) => {
const diffX = x1 - x0;
d3.select(ref.current)
.attr('opacity', 1);
d3.select(ref.current)
.select('.ChartSelectionX__line_left')
.attr('x1', x0)
.attr('x2', x0);
d3.select(ref.current)
.select('.ChartSelectionX__line_right')
.attr('x1', x1)
.attr('x2', x1);
d3.select(ref.current)
.select('.ChartSelectionX__rect')
.attr('x', diffX > 0 ? x0 : diffX + x0)
.attr('width', Math.abs(diffX));
}, []);
const handelMouseUp = React.useCallback(() => {
isPressed.current = false;
startX.current = undefined;
d3.select(ref.current).attr('opacity', 0);
if (!endX.current) {
return;
}
const index = getIndexByX(endX.current);
if (Math.abs(index - startIndex.current) > SELECTION_THRESHOLD) {
onSelect([ Math.min(index, startIndex.current), Math.max(index, startIndex.current) ]);
}
}, [ getIndexByX, onSelect ]);
React.useEffect(() => {
if (!anchorEl) {
return;
}
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mousedown.selectionX', (event: MouseEvent) => {
const [ x ] = d3.pointer(event, anchorEl);
isPressed.current = true;
startX.current = x;
const index = getIndexByX(x);
startIndex.current = index;
})
.on('mouseup.selectionX', handelMouseUp)
.on('mousemove.selectionX', (event: MouseEvent) => {
if (isPressed.current) {
const [ x ] = d3.pointer(event, anchorEl);
startX.current && drawSelection(startX.current, x);
endX.current = x;
}
});
d3.select('body').on('mouseup.selectionX', function(event) {
const isOutside = startX.current !== undefined && event.target !== anchorD3.node();
if (isOutside) {
handelMouseUp();
}
});
return () => {
anchorD3.on('mousedown.selectionX mouseup.selectionX mousemove.selectionX', null);
d3.select('body').on('mouseup.selectionX', null);
};
}, [ anchorEl, drawSelection, getIndexByX, handelMouseUp ]);
return (
<g className="ChartSelectionX" ref={ ref } opacity={ 0 }>
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(66, 153, 225, 0.1)"/>
<line className="ChartSelectionX__line ChartSelectionX__line_left" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
<line className="ChartSelectionX__line ChartSelectionX__line_right" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
</g>
);
};
export default React.memo(ChartSelectionX);
import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem, ChartMargin, TimeChartData } from 'ui/shared/chart/types';
interface Props {
width?: number;
height?: number;
margin?: ChartMargin;
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null;
}
const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, anchorEl, ...props }: Props) => {
const margin = React.useMemo(() => ({
top: 0, bottom: 0, left: 0, right: 0,
..._margin,
}), [ _margin ]);
const lineColor = useToken('colors', 'red.500');
const textColor = useToken('colors', useColorModeValue('white', 'black'));
const bgColor = useToken('colors', useColorModeValue('gray.900', 'gray.400'));
const ref = React.useRef(null);
const isPressed = React.useRef(false);
const drawLine = React.useCallback(
(x: number) => {
d3.select(ref.current)
.select('.ChartTooltip__line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', 0)
.attr('y2', height || 0);
},
[ ref, height ],
);
const drawContent = React.useCallback(
(x: number) => {
const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
tooltipContent.attr('transform', (cur, i, nodes) => {
const OFFSET = 8;
const node = nodes[i] as SVGGElement | null;
const nodeWidth = node?.getBoundingClientRect()?.width || 0;
const translateX = nodeWidth + x + OFFSET > (width || 0) ? x - nodeWidth - OFFSET : x + OFFSET;
return `translate(${ translateX }, ${ margin.top + 30 })`;
});
tooltipContent
.select('.ChartTooltip__contentTitle')
.text(d3.timeFormat('%b %d, %Y')(xScale.invert(x)));
},
[ xScale, margin, width ],
);
const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => {
d3.selectAll('.ChartTooltip__value')
.filter((td, tIndex) => tIndex === i)
.text(d.value.toLocaleString());
}, []);
const drawCircles = React.useCallback((event: MouseEvent) => {
const [ x ] = d3.pointer(event, anchorEl);
const xDate = xScale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
let baseXPos = 0;
d3.select(ref.current)
.selectAll('.ChartTooltip__linePoint')
.attr('transform', (cur, i) => {
const index = bisectDate(data[i].items, xDate, 1);
const d0 = data[i].items[index - 1];
const d1 = data[i].items[index];
const d = xDate.getTime() - d0?.date.getTime() > d1?.date.getTime() - xDate.getTime() ? d1 : d0;
if (d.date === undefined && d.value === undefined) {
// move point out of container
return 'translate(-100,-100)';
}
const xPos = xScale(d.date);
if (i === 0) {
baseXPos = xPos;
}
const yPos = yScale(d.value);
updateDisplayedValue(d, i);
return `translate(${ xPos }, ${ yPos })`;
});
return baseXPos;
}, [ anchorEl, data, updateDisplayedValue, xScale, yScale ]);
const followPoints = React.useCallback((event: MouseEvent) => {
const baseXPos = drawCircles(event);
drawLine(baseXPos);
drawContent(baseXPos);
}, [ drawCircles, drawLine, drawContent ]);
React.useEffect(() => {
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mousedown.tooltip', () => {
isPressed.current = true;
d3.select(ref.current).attr('opacity', 0);
})
.on('mouseup.tooltip', () => {
isPressed.current = false;
})
.on('mouseout.tooltip', () => {
d3.select(ref.current).attr('opacity', 0);
})
.on('mouseover.tooltip', () => {
d3.select(ref.current).attr('opacity', 1);
})
.on('mousemove.tooltip', (event: MouseEvent) => {
if (!isPressed.current) {
d3.select(ref.current).attr('opacity', 1);
d3.select(ref.current)
.selectAll('.ChartTooltip__linePoint')
.attr('opacity', 1);
followPoints(event);
}
});
d3.select('body').on('mouseup.tooltip', function(event) {
const isOutside = event.target !== anchorD3.node();
if (isOutside) {
isPressed.current = false;
}
});
return () => {
anchorD3.on('mousedown.tooltip mouseup.tooltip mouseout.tooltip mouseover.tooltip mousemove.tooltip', null);
d3.select('body').on('mouseup.tooltip', null);
};
}, [ anchorEl, followPoints ]);
return (
<g ref={ ref } opacity={ 0 } { ...props }>
<line className="ChartTooltip__line" stroke={ lineColor }/>
<g className="ChartTooltip__content">
<rect className="ChartTooltip__contentBg" rx={ 8 } ry={ 8 } fill={ bgColor } width={ 125 } height={ data.length * 22 + 34 }/>
<text
className="ChartTooltip__contentTitle"
transform="translate(8,20)"
fontSize="12px"
fontWeight="bold"
fill={ textColor }
pointerEvents="none"
/>
<g>
{ data.map(({ name, color }, index) => (
<g key={ name } className="ChartTooltip__contentLine" transform={ `translate(12,${ 40 + index * 20 })` }>
<circle r={ 4 } fill={ color }/>
<text
transform="translate(10,4)"
className="ChartTooltip__value"
fontSize="12px"
fill={ textColor }
pointerEvents="none"
/>
</g>
)) }
</g>
</g>
{ data.map(({ name, color }) => (
<circle key={ name } className="ChartTooltip__linePoint" r={ 4 } opacity={ 0 } fill={ color } stroke="#FFF" strokeWidth={ 1 }/>
)) }
</g>
);
};
export default React.memo(ChartTooltip);
export interface TimeChartItem {
date: Date;
value: number;
}
export interface ChartMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface ChartOffset {
x?: number;
y?: number;
}
export interface TimeChartDataItem {
items: Array<TimeChartItem>;
name: string;
color?: string;
}
export type TimeChartData = Array<TimeChartDataItem>;
import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props {
limits: [[number, number], [number, number]];
anchor: SVGSVGElement | null;
setRange: (range: [number, number]) => void;
}
export default function useBrushX({ limits, anchor, setRange }: Props) {
const brushRef = React.useRef<d3.BrushBehavior<unknown>>();
const brushSelectionBg = useToken('colors', useColorModeValue('blackAlpha.400', 'whiteAlpha.500'));
React.useEffect(() => {
if (!anchor || brushRef.current || limits[1][0] === 0) {
return;
}
const svgEl = d3.select(anchor).select('g');
brushRef.current = d3.brushX()
.extent(limits);
brushRef.current.on('end', (event) => {
setRange(event.selection);
});
const gBrush = svgEl?.append('g')
.attr('class', 'ChartBrush')
.call(brushRef.current);
gBrush.select('.selection')
.attr('stroke', 'none')
.attr('fill', brushSelectionBg);
}, [ anchor, brushSelectionBg, limits, setRange ]);
}
import _range from 'lodash/range';
import React from 'react';
export default function useChartLegend(dataLength: number) {
const [ selectedLines, setSelectedLines ] = React.useState<Array<number>>(_range(dataLength));
const handleLegendItemClick = React.useCallback((index: number) => {
const nextSelectedLines = selectedLines.includes(index) ? selectedLines.filter((item) => item !== index) : [ ...selectedLines, index ];
setSelectedLines(nextSelectedLines);
}, [ selectedLines ]);
return {
selectedLines,
handleLegendItemClick,
};
}
import _debounce from 'lodash/debounce';
import React from 'react';
import type { ChartMargin, ChartOffset } from 'ui/shared/chart/types';
export default function useChartSize(svgEl: SVGSVGElement | null, margin?: ChartMargin, offsets?: ChartOffset) {
const [ rect, setRect ] = React.useState<{ width: number; height: number}>({ width: 0, height: 0 });
const calculateRect = React.useCallback(() => {
const rect = svgEl?.getBoundingClientRect();
return { width: rect?.width || 0, height: rect?.height || 0 };
}, [ svgEl ]);
React.useEffect(() => {
setRect(calculateRect());
}, [ calculateRect ]);
React.useEffect(() => {
let timeoutId: number;
const resizeHandler = _debounce(() => {
setRect({ width: 0, height: 0 });
timeoutId = window.setTimeout(() => {
setRect(calculateRect());
}, 0);
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
window.clearTimeout(timeoutId);
};
}, [ calculateRect ]);
return React.useMemo(() => {
return {
width: Math.max(rect.width - (offsets?.x || 0), 0),
height: Math.max(rect.height - (offsets?.y || 0), 0),
innerWidth: Math.max(rect.width - (offsets?.x || 0) - (margin?.left || 0) - (margin?.right || 0), 0),
innerHeight: Math.max(rect.height - (offsets?.y || 0) - (margin?.bottom || 0) - (margin?.top || 0), 0),
};
}, [ margin?.bottom, margin?.left, margin?.right, margin?.top, offsets?.x, offsets?.y, rect.height, rect.width ]);
}
import * as d3 from 'd3';
import { useMemo } from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
interface Props {
data: TimeChartData;
width: number;
height: number;
}
export default function useTimeChartController({ data, width, height }: Props) {
const xMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xScale = useMemo(
() => d3.scaleTime().domain([ xMin, xMax ]).range([ 0, width ]),
[ xMin, xMax, width ],
);
const yMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) || 0,
[ data ],
);
const yMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) || 0,
[ data ],
);
const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.3;
return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]);
}, [ height, yMin, yMax ]);
const yScaleForAxis = useMemo(
() => d3.scaleBand().domain([ String(yMin), String(yMax) ]).range([ height, 0 ]),
[ height, yMin, yMax ],
);
const xTickFormat = (d: d3.AxisDomain) => d.toLocaleString();
const yTickFormat = (d: d3.AxisDomain) => d.toLocaleString();
return {
xTickFormat,
yTickFormat,
xScale,
yScale,
yScaleForAxis,
};
}
import React from 'react';
export const BlueLinearGradient = {
id: 'blue-linear-gradient',
defs: () => (
<linearGradient id="blue-linear-gradient">
<stop offset="0%" stopColor="#4299E1"/>
<stop offset="100%" stopColor="#00B5D8"/>
</linearGradient>
),
};
export const RainbowGradient = {
id: 'rainbow-gradient',
defs: () => (
<linearGradient id="rainbow-gradient">
<stop offset="0%" stopColor="rgba(255, 0, 0, 1)"/>
<stop offset="10%" stopColor="rgba(255, 154, 0, 1)"/>
<stop offset="20%" stopColor="rgba(208, 222, 33, 1)"/>
<stop offset="30%" stopColor="rgba(79, 220, 74, 1)"/>
<stop offset="40%" stopColor="rgba(63, 218, 216, 1)"/>
<stop offset="50%" stopColor="rgba(47, 201, 226, 1)"/>
<stop offset="60%" stopColor="rgba(28, 127, 238, 1)"/>
<stop offset="70%" stopColor="rgba(95, 21, 242, 1)"/>
<stop offset="80%" stopColor="rgba(186, 12, 248, 1)"/>
<stop offset="90%" stopColor="rgba(251, 7, 217, 1)"/>
<stop offset="100%" stopColor="rgba(255, 0, 0, 1)"/>
</linearGradient>
),
};
...@@ -6,23 +6,34 @@ import type { PreDefinedNetwork } from 'types/networks'; ...@@ -6,23 +6,34 @@ import type { PreDefinedNetwork } from 'types/networks';
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import blockscoutLogo from 'icons/logo.svg'; import blockscoutLogo from 'icons/logo.svg';
import artisLogo from 'icons/networks/logos/artis.svg';
import astarLogo from 'icons/networks/logos/astar.svg';
import etcLogo from 'icons/networks/logos/etc.svg';
import ethLogo from 'icons/networks/logos/eth.svg';
import gnosisLogo from 'icons/networks/logos/gnosis.svg';
import luksoLogo from 'icons/networks/logos/lukso.svg';
import poaLogo from 'icons/networks/logos/poa.svg';
import rskLogo from 'icons/networks/logos/rsk.svg';
import shibuyaLogo from 'icons/networks/logos/shibuya.svg';
import shidenLogo from 'icons/networks/logos/shiden.svg';
import sokolLogo from 'icons/networks/logos/sokol.svg';
import link from 'lib/link/link'; import link from 'lib/link/link';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps'; import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
// predefined network logos // predefined network logos
const LOGOS: Partial<Record<PreDefinedNetwork, React.FunctionComponent<React.SVGAttributes<SVGElement>>>> = { const LOGOS: Partial<Record<PreDefinedNetwork, React.FunctionComponent<React.SVGAttributes<SVGElement>>>> = {
xdai_mainnet: require('icons/networks/logos/gnosis.svg'), xdai_mainnet: gnosisLogo,
eth_mainnet: require('icons/networks/logos/eth.svg'), eth_mainnet: ethLogo,
etc_mainnet: require('icons/networks/logos/etc.svg'), etc_mainnet: etcLogo,
poa_core: require('icons/networks/logos/poa.svg'), poa_core: poaLogo,
rsk_mainnet: require('icons/networks/logos/rsk.svg'), rsk_mainnet: rskLogo,
xdai_testnet: require('icons/networks/logos/gnosis.svg'), xdai_testnet: gnosisLogo,
poa_sokol: require('icons/networks/logos/sokol.svg'), poa_sokol: sokolLogo,
artis_sigma1: require('icons/networks/logos/artis.svg'), artis_sigma1: artisLogo,
lukso_l14: require('icons/networks/logos/lukso.svg'), lukso_l14: luksoLogo,
astar: require('icons/networks/logos/astar.svg'), astar: astarLogo,
shiden: require('icons/networks/logos/shiden.svg'), shiden: shidenLogo,
shibuya: require('icons/networks/logos/shibuya.svg'), shibuya: shibuyaLogo,
}; };
interface Props { interface Props {
......
import { Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransfer as TTokenTransfer } from 'types/api/tokenTransfer';
import TokenTransfer from 'ui/tx/TokenTransfer';
interface Props {
items: Array<TTokenTransfer>;
}
function getItemsNum(items: Array<TTokenTransfer>) {
const nonErc1155items = items.filter((item) => item.token.type !== 'ERC-1155').length;
const erc1155items = items
.filter((item) => item.token.type === 'ERC-1155')
.map((item) => {
if (Array.isArray(item.total)) {
return item.total.length;
}
return 1;
})
.reduce((sum, item) => sum + item, 0);
return nonErc1155items + erc1155items;
}
const TokenTransferList = ({ items }: Props) => {
const itemsNum = getItemsNum(items);
const hasScroll = itemsNum > 5;
const gradientStartColor = useColorModeValue('whiteAlpha.600', 'blackAlpha.600');
const gradientEndColor = useColorModeValue('whiteAlpha.900', 'blackAlpha.900');
return (
<Flex
flexDirection="column"
alignItems="flex-start"
rowGap={ 5 }
w="100%"
_after={ hasScroll ? {
position: 'absolute',
content: '""',
bottom: 0,
left: 0,
right: '20px',
height: '48px',
bgGradient: `linear(to-b, ${ gradientStartColor } 37.5%, ${ gradientEndColor } 77.5%)`,
} : undefined }
maxH={ hasScroll ? '200px' : 'auto' }
overflowY={ hasScroll ? 'scroll' : 'auto' }
pr={ hasScroll ? 5 : 0 }
pb={ hasScroll ? 10 : 0 }
>
{ items.map((item, index) => <TokenTransfer key={ index } { ...item }/>) }
</Flex>
);
};
export default React.memo(TokenTransferList);
...@@ -25,19 +25,12 @@ import TextSeparator from 'ui/shared/TextSeparator'; ...@@ -25,19 +25,12 @@ import TextSeparator from 'ui/shared/TextSeparator';
import TxStatus from 'ui/shared/TxStatus'; import TxStatus from 'ui/shared/TxStatus';
import Utilization from 'ui/shared/Utilization'; import Utilization from 'ui/shared/Utilization';
import TxDetailsSkeleton from 'ui/tx/details/TxDetailsSkeleton'; import TxDetailsSkeleton from 'ui/tx/details/TxDetailsSkeleton';
import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxRevertReason from 'ui/tx/details/TxRevertReason'; import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TokenTransferList from 'ui/tx/TokenTransferList';
import TxDecodedInputData from 'ui/tx/TxDecodedInputData'; import TxDecodedInputData from 'ui/tx/TxDecodedInputData';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TOKEN_TRANSFERS = [
{ title: 'Tokens Transferred', hint: 'List of tokens transferred in the transaction.', type: 'token_transfer' },
{ title: 'Tokens Minted', hint: 'List of tokens minted in the transaction.', type: 'token_minting' },
{ title: 'Tokens Burnt', hint: 'List of tokens burnt in the transaction.', type: 'token_burning' },
{ title: 'Tokens Created', hint: 'List of tokens created in the transaction.', type: 'token_spawning' },
];
const TxDetails = () => { const TxDetails = () => {
const { data, isLoading, isError, socketStatus } = useFetchTxInfo(); const { data, isLoading, isError, socketStatus } = useFetchTxInfo();
...@@ -191,22 +184,7 @@ const TxDetails = () => { ...@@ -191,22 +184,7 @@ const TxDetails = () => {
</Flex> </Flex>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
{ TOKEN_TRANSFERS.map(({ title, hint, type }) => { { data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash }/> }
const items = data.token_transfers?.filter((token) => token.type === type) || [];
if (items.length === 0) {
return null;
}
return (
<DetailsInfoItem
key={ type }
title={ title }
hint={ hint }
position="relative"
>
<TokenTransferList items={ items }/>
</DetailsInfoItem>
);
}) }
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 3, lg: 8 }}/> <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 3, lg: 8 }}/>
<DetailsInfoItem <DetailsInfoItem
......
import { Box, Flex, Text, Show, Hide } from '@chakra-ui/react'; import { Box, Text, Show, Hide } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { InternalTransactionsResponse, TxInternalsType, InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse, InternalTransaction } from 'types/api/internalTransaction';
import { QueryKeys } from 'types/client/queries'; import { QueryKeys } from 'types/client/queries';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
...@@ -12,8 +12,8 @@ import useIsMobile from 'lib/hooks/useIsMobile'; ...@@ -12,8 +12,8 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult'; import EmptySearchResult from 'ui/apps/EmptySearchResult';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import FilterInput from 'ui/shared/FilterInput'; // import FilterInput from 'ui/shared/FilterInput';
import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter'; // import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter';
import TxInternalsList from 'ui/tx/internals/TxInternalsList'; import TxInternalsList from 'ui/tx/internals/TxInternalsList';
import TxInternalsSkeletonDesktop from 'ui/tx/internals/TxInternalsSkeletonDesktop'; import TxInternalsSkeletonDesktop from 'ui/tx/internals/TxInternalsSkeletonDesktop';
import TxInternalsSkeletonMobile from 'ui/tx/internals/TxInternalsSkeletonMobile'; import TxInternalsSkeletonMobile from 'ui/tx/internals/TxInternalsSkeletonMobile';
...@@ -62,19 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT ...@@ -62,19 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT
} }
}; };
const searchFn = (searchTerm: string) => (item: InternalTransaction): boolean => { // const searchFn = (searchTerm: string) => (item: InternalTransaction): boolean => {
const formattedSearchTerm = searchTerm.toLowerCase(); // const formattedSearchTerm = searchTerm.toLowerCase();
return item.type.toLowerCase().includes(formattedSearchTerm) || // return item.type.toLowerCase().includes(formattedSearchTerm) ||
item.from.hash.toLowerCase().includes(formattedSearchTerm) || // item.from.hash.toLowerCase().includes(formattedSearchTerm) ||
item.to.hash.toLowerCase().includes(formattedSearchTerm); // item.to.hash.toLowerCase().includes(formattedSearchTerm);
}; // };
const TxInternals = () => { const TxInternals = () => {
const router = useRouter(); const router = useRouter();
const fetch = useFetch(); const fetch = useFetch();
const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]); // filters are not implemented yet in api
const [ searchTerm, setSearchTerm ] = React.useState<string>(''); // const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
// const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const [ sort, setSort ] = React.useState<Sort>(); const [ sort, setSort ] = React.useState<Sort>();
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isLoading, isError } = useQuery<unknown, unknown, InternalTransactionsResponse>( const { data, isLoading, isError } = useQuery<unknown, unknown, InternalTransactionsResponse>(
...@@ -87,9 +88,9 @@ const TxInternals = () => { ...@@ -87,9 +88,9 @@ const TxInternals = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleFilterChange = React.useCallback((nextValue: Array<TxInternalsType>) => { // const handleFilterChange = React.useCallback((nextValue: Array<TxInternalsType>) => {
setFilters(nextValue); // setFilters(nextValue);
}, []); // }, []);
const handleSortToggle = React.useCallback((field: SortField) => { const handleSortToggle = React.useCallback((field: SortField) => {
return () => { return () => {
...@@ -120,8 +121,9 @@ const TxInternals = () => { ...@@ -120,8 +121,9 @@ const TxInternals = () => {
const content = (() => { const content = (() => {
const filteredData = data.items const filteredData = data.items
.filter(({ type }) => filters.length > 0 ? filters.includes(type) : true) .slice()
.filter(searchFn(searchTerm)) // .filter(({ type }) => filters.length > 0 ? filters.includes(type) : true)
// .filter(searchFn(searchTerm))
.sort(sortFn(sort)); .sort(sortFn(sort));
if (filteredData.length === 0) { if (filteredData.length === 0) {
...@@ -135,10 +137,10 @@ const TxInternals = () => { ...@@ -135,10 +137,10 @@ const TxInternals = () => {
return ( return (
<Box> <Box>
<Flex mb={ 6 }> { /* <Flex mb={ 6 }>
<TxInternalsFilter onFilterChange={ handleFilterChange } defaultFilters={ filters } appliedFiltersNum={ filters.length }/> <TxInternalsFilter onFilterChange={ handleFilterChange } defaultFilters={ filters } appliedFiltersNum={ filters.length }/>
<FilterInput onChange={ setSearchTerm } maxW="360px" ml={ 3 } size="xs" placeholder="Search by addresses, hash, method..."/> <FilterInput onChange={ setSearchTerm } maxW="360px" ml={ 3 } size="xs" placeholder="Search by addresses, hash, method..."/>
</Flex> </Flex> */ }
{ content } { content }
</Box> </Box>
); );
......
import React from 'react';
import { QueryKeys } from 'types/client/queries';
import { SECOND } from 'lib/consts';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TokenTransfer from 'ui/shared/TokenTransfer/TokenTransfer';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxTokenTransfer = () => {
const { isError, isLoading, data, socketStatus } = useFetchTxInfo({ updateDelay: 5 * SECOND });
if (!isLoading && !isError && !data.status) {
return socketStatus ? <TxSocketAlert status={ socketStatus }/> : <TxPendingAlert/>;
}
if (isError) {
return <DataFetchAlert/>;
}
const path = `/node-api/transactions/${ data?.hash }/token-transfers`;
return (
<TokenTransfer
isLoading={ isLoading }
isDisabled={ !data?.status || !data?.hash }
path={ path }
queryName={ QueryKeys.txTokenTransfers }
queryIds={ data?.hash ? [ data.hash ] : undefined }
showTxInfo={ false }
txHash={ data?.hash || '' }
/>
);
};
export default TxTokenTransfer;
...@@ -12,21 +12,20 @@ import NftTokenTransferSnippet from 'ui/tx/NftTokenTransferSnippet'; ...@@ -12,21 +12,20 @@ import NftTokenTransferSnippet from 'ui/tx/NftTokenTransferSnippet';
type Props = TTokenTransfer; type Props = TTokenTransfer;
const TokenTransfer = ({ token, total, to, from }: Props) => { const TxDetailsTokenTransfer = ({ token, total, to, from }: Props) => {
const isColumnLayout = token.type === 'ERC-1155' && Array.isArray(total); const isColumnLayout = token.type === 'ERC-1155' && Array.isArray(total);
const tokenSnippet = <TokenSnippet symbol={ token.symbol } hash={ token.address } name={ token.name } ml={ 3 }/>;
const content = (() => { const content = (() => {
switch (token.type) { switch (token.type) {
case 'ERC-20': { case 'ERC-20': {
const payload = total as Erc20TotalPayload; const payload = total as Erc20TotalPayload;
return ( return (
<Flex> <Flex flexWrap="wrap" columnGap={ 3 } rowGap={ 2 }>
<Text fontWeight={ 500 } as="span">For:{ space } <Text fontWeight={ 500 } as="span">For:{ space }
<CurrencyValue value={ payload.value } exchangeRate={ token.exchange_rate } fontWeight={ 600 }/> <CurrencyValue value={ payload.value } exchangeRate={ token.exchange_rate } fontWeight={ 600 }/>
</Text> </Text>
{ tokenSnippet } <TokenSnippet symbol={ token.symbol } hash={ token.address } name={ token.name } w="auto" flexGrow="1"/>
</Flex> </Flex>
); );
} }
...@@ -79,4 +78,4 @@ const TokenTransfer = ({ token, total, to, from }: Props) => { ...@@ -79,4 +78,4 @@ const TokenTransfer = ({ token, total, to, from }: Props) => {
); );
}; };
export default React.memo(TokenTransfer); export default React.memo(TxDetailsTokenTransfer);
import { Icon, Link, GridItem, Show, Flex } from '@chakra-ui/react';
import NextLink from 'next/link';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import tokenIcon from 'icons/token.svg';
import link from 'lib/link/link';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TxDetailsTokenTransfer from './TxDetailsTokenTransfer';
interface Props {
data: Array<TokenTransfer>;
txHash: string;
}
const TOKEN_TRANSFERS_TYPES = [
{ title: 'Tokens transferred', hint: 'List of tokens transferred in the transaction.', type: 'token_transfer' },
{ title: 'Tokens minted', hint: 'List of tokens minted in the transaction.', type: 'token_minting' },
{ title: 'Tokens burnt', hint: 'List of tokens burnt in the transaction.', type: 'token_burning' },
{ title: 'Tokens created', hint: 'List of tokens created in the transaction.', type: 'token_spawning' },
];
const VISIBLE_ITEMS_NUM = 3;
const TxDetailsTokenTransfers = ({ data, txHash }: Props) => {
const viewAllUrl = link('tx', { id: txHash }, { tab: 'token_transfers' });
const formattedData = data.reduce(flattenTotal, []);
const transferGroups = TOKEN_TRANSFERS_TYPES.map((group) => ({
...group,
items: formattedData?.filter((token) => token.type === group.type) || [],
}));
const showViewAllLink = transferGroups.some(({ items }) => items.length > VISIBLE_ITEMS_NUM);
return (
<>
{ transferGroups.map(({ title, hint, type, items }) => {
if (items.length === 0) {
return null;
}
return (
<DetailsInfoItem
key={ type }
title={ title }
hint={ hint }
position="relative"
>
<Flex
flexDirection="column"
alignItems="flex-start"
rowGap={ 5 }
w="100%"
>
{ items.slice(0, VISIBLE_ITEMS_NUM).map((item, index) => <TxDetailsTokenTransfer key={ index } { ...item }/>) }
</Flex>
</DetailsInfoItem>
);
}) }
{ showViewAllLink && (
<>
<Show above="lg"><GridItem></GridItem></Show>
<GridItem fontSize="sm" alignItems="center" display="inline-flex" pl={{ base: '28px', lg: 0 }}>
<Icon as={ tokenIcon } boxSize={ 6 }/>
<NextLink href={ viewAllUrl } passHref>
<Link>View all</Link>
</NextLink>
</GridItem>
</>
) }
</>
);
};
export default React.memo(TxDetailsTokenTransfers);
...@@ -28,16 +28,16 @@ const TxInternalTableItem = ({ type, from, to, value, success, error, gas_limit: ...@@ -28,16 +28,16 @@ const TxInternalTableItem = ({ type, from, to, value, success, error, gas_limit:
<TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/> <TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/>
</Flex> </Flex>
</Td> </Td>
<Td> <Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%"> <Address display="inline-flex" maxW="100%">
<AddressIcon hash={ from.hash }/> <AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/> <AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
</Address> </Address>
</Td> </Td>
<Td px={ 0 }> <Td px={ 0 } verticalAlign="middle">
<Icon as={ rightArrowIcon } boxSize={ 6 } color="gray.500"/> <Icon as={ rightArrowIcon } boxSize={ 6 } color="gray.500"/>
</Td> </Td>
<Td> <Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%"> <Address display="inline-flex" maxW="100%">
<AddressIcon hash={ to.hash }/> <AddressIcon hash={ to.hash }/>
<AddressLink hash={ to.hash } alias={ to.name } fontWeight="500" ml={ 2 }/> <AddressLink hash={ to.hash } alias={ to.name } fontWeight="500" ml={ 2 }/>
......
...@@ -62,7 +62,11 @@ const TxsContent = ({ ...@@ -62,7 +62,11 @@ const TxsContent = ({
isLoading, isLoading,
isError, isError,
pagination, pagination,
} = useQueryWithPages<TransactionsResponse>(apiPath, queryName, stateFilter && { filter: stateFilter }); } = useQueryWithPages<TransactionsResponse>({
apiPath,
queryName,
filters: stateFilter ? { filter: stateFilter } : undefined,
});
// } = useQueryWithPages({ ...filters, filter: stateFilter, apiPath }); // } = useQueryWithPages({ ...filters, filter: stateFilter, apiPath });
const content = (() => { const content = (() => {
......
...@@ -20,12 +20,12 @@ import transactionIcon from 'icons/transactions.svg'; ...@@ -20,12 +20,12 @@ import transactionIcon from 'icons/transactions.svg';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import link from 'lib/link/link'; import link from 'lib/link/link';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Address from 'ui/shared/address/Address'; import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import TxStatus from 'ui/shared/TxStatus'; import TxStatus from 'ui/shared/TxStatus';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxAdditionalInfoButton from 'ui/txs/TxAdditionalInfoButton';
import TxType from 'ui/txs/TxType'; import TxType from 'ui/txs/TxType';
const TxsListItem = ({ tx }: {tx: Transaction}) => { const TxsListItem = ({ tx }: {tx: Transaction}) => {
...@@ -43,7 +43,7 @@ const TxsListItem = ({ tx }: {tx: Transaction}) => { ...@@ -43,7 +43,7 @@ const TxsListItem = ({ tx }: {tx: Transaction}) => {
{ tx.tx_types.map(item => <TxType key={ item } type={ item }/>) } { tx.tx_types.map(item => <TxType key={ item } type={ item }/>) }
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/> <TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined }/>
</HStack> </HStack>
<TxAdditionalInfoButton onClick={ onOpen }/> <AdditionalInfoButton onClick={ onOpen }/>
</Flex> </Flex>
<Flex justifyContent="space-between" lineHeight="24px" mt={ 3 }> <Flex justifyContent="space-between" lineHeight="24px" mt={ 3 }>
<Flex> <Flex>
......
import { Alert, Spinner, Text, Link, chakra } from '@chakra-ui/react';
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react';
import { ROUTES } from 'lib/link/routes';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
interface InjectedProps {
content: React.ReactNode;
}
interface Props {
children: (props: InjectedProps) => JSX.Element;
className?: string;
}
function getSocketParams(router: NextRouter) {
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'validated' && !router.query.block_number) {
return { topic: 'transactions:new_transaction' as const, event: 'transaction' as const };
}
if (router.pathname === ROUTES.txs.pattern && router.query.tab === 'pending' && !router.query.block_number) {
return { topic: 'transactions:new_pending_transaction' as const, event: 'pending_transaction' as const };
}
return {};
}
function assertIsNewTxResponse(response: unknown): response is { transaction: number } {
return typeof response === 'object' && response !== null && 'transaction' in response;
}
function assertIsNewPendingTxResponse(response: unknown): response is { pending_transaction: number } {
return typeof response === 'object' && response !== null && 'pending_transaction' in response;
}
const TxsNewItemNotice = ({ children, className }: Props) => {
const router = useRouter();
const [ num, setNum ] = React.useState(0);
const [ socketAlert, setSocketAlert ] = React.useState('');
const { topic, event } = getSocketParams(router);
const handleClick = React.useCallback(() => {
window.location.reload();
}, []);
const handleNewTxMessage = React.useCallback((response: { transaction: number } | { pending_transaction: number } | unknown) => {
if (assertIsNewTxResponse(response)) {
setNum((prev) => prev + response.transaction);
}
if (assertIsNewPendingTxResponse(response)) {
setNum((prev) => prev + response.pending_transaction);
}
}, []);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please click here to load new transactions.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new transactions. Please click here to refresh the page.');
}, []);
const channel = useSocketChannel({
topic,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: !topic,
});
useSocketMessage({
channel,
event,
handler: handleNewTxMessage,
});
if (!topic && !event) {
return null;
}
const content = (() => {
if (socketAlert) {
return (
<Alert
className={ className }
status="warning"
p={ 4 }
borderRadius={ 0 }
onClick={ handleClick }
cursor="pointer"
>
{ socketAlert }
</Alert>
);
}
if (!num) {
return null;
}
return (
<Alert className={ className } status="warning" p={ 4 } fontWeight={ 400 }>
<Spinner size="sm" mr={ 3 }/>
<Text as="span" whiteSpace="pre">+ { num } new transaction{ num > 1 ? 's' : '' }. </Text>
<Link onClick={ handleClick }>Show in list</Link>
</Alert>
);
})();
return children({ content });
};
export default chakra(TxsNewItemNotice);
...@@ -14,7 +14,7 @@ const TxsTab = ({ tab }: Props) => { ...@@ -14,7 +14,7 @@ const TxsTab = ({ tab }: Props) => {
queryName={ QueryKeys.transactions } queryName={ QueryKeys.transactions }
showDescription={ tab === 'validated' } showDescription={ tab === 'validated' }
stateFilter={ tab } stateFilter={ tab }
apiPath="/api/transactions" apiPath="/node-api/transactions"
/> />
); );
}; };
......
import { Link, Table, Tbody, Tr, Th, Icon } from '@chakra-ui/react'; import { Link, Table, Tbody, Tr, Th, Td, Icon } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
...@@ -8,6 +8,7 @@ import appConfig from 'configs/app/config'; ...@@ -8,6 +8,7 @@ import appConfig from 'configs/app/config';
import rightArrowIcon from 'icons/arrows/east.svg'; import rightArrowIcon from 'icons/arrows/east.svg';
import TheadSticky from 'ui/shared/TheadSticky'; import TheadSticky from 'ui/shared/TheadSticky';
import TxsNewItemNotice from './TxsNewItemNotice';
import TxsTableItem from './TxsTableItem'; import TxsTableItem from './TxsTableItem';
type Props = { type Props = {
...@@ -46,6 +47,9 @@ const TxsTable = ({ txs, sort, sorting }: Props) => { ...@@ -46,6 +47,9 @@ const TxsTable = ({ txs, sort, sorting }: Props) => {
</Tr> </Tr>
</TheadSticky> </TheadSticky>
<Tbody> <Tbody>
<TxsNewItemNotice borderRadius={ 0 }>
{ ({ content }) => <Tr><Td colSpan={ 10 } p={ 0 }>{ content }</Td></Tr> }
</TxsNewItemNotice>
{ txs.map((item) => ( { txs.map((item) => (
<TxsTableItem <TxsTableItem
key={ item.hash } key={ item.hash }
......
...@@ -23,6 +23,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -23,6 +23,7 @@ import type { Transaction } from 'types/api/transaction';
import rightArrowIcon from 'icons/arrows/east.svg'; import rightArrowIcon from 'icons/arrows/east.svg';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import link from 'lib/link/link'; import link from 'lib/link/link';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Address from 'ui/shared/address/Address'; import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
...@@ -30,7 +31,6 @@ import CurrencyValue from 'ui/shared/CurrencyValue'; ...@@ -30,7 +31,6 @@ import CurrencyValue from 'ui/shared/CurrencyValue';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
import TxStatus from 'ui/shared/TxStatus'; import TxStatus from 'ui/shared/TxStatus';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxAdditionalInfoButton from 'ui/txs/TxAdditionalInfoButton';
import TxType from './TxType'; import TxType from './TxType';
...@@ -63,7 +63,7 @@ const TxsTableItem = ({ tx }: {tx: Transaction}) => { ...@@ -63,7 +63,7 @@ const TxsTableItem = ({ tx }: {tx: Transaction}) => {
{ ({ isOpen }) => ( { ({ isOpen }) => (
<> <>
<PopoverTrigger> <PopoverTrigger>
<TxAdditionalInfoButton isOpen={ isOpen }/> <AdditionalInfoButton isOpen={ isOpen }/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent border="1px solid" borderColor={ infoBorderColor }> <PopoverContent border="1px solid" borderColor={ infoBorderColor }>
<PopoverBody> <PopoverBody>
......
...@@ -7,6 +7,7 @@ import type { Sort } from 'types/client/txs-sort'; ...@@ -7,6 +7,7 @@ import type { Sort } from 'types/client/txs-sort';
import sortTxs from 'lib/tx/sortTxs'; import sortTxs from 'lib/tx/sortTxs';
import TxsListItem from './TxsListItem'; import TxsListItem from './TxsListItem';
import TxsNewItemNotice from './TxsNewItemNotice';
import TxsTable from './TxsTable'; import TxsTable from './TxsTable';
type Props = { type Props = {
...@@ -28,7 +29,14 @@ const TxsWithSort = ({ ...@@ -28,7 +29,14 @@ const TxsWithSort = ({
return ( return (
<> <>
<Show below="lg" ssr={ false }><Box>{ sortedTxs.map(tx => <TxsListItem tx={ tx } key={ tx.hash }/>) }</Box></Show> <Show below="lg" ssr={ false }>
<Box>
<TxsNewItemNotice>
{ ({ content }) => <Box>{ content }</Box> }
</TxsNewItemNotice>
{ sortedTxs.map(tx => <TxsListItem tx={ tx } key={ tx.hash }/>) }
</Box>
</Show>
<Hide below="lg" ssr={ false }><TxsTable txs={ sortedTxs } sort={ sort } sorting={ sorting }/></Hide> <Hide below="lg" ssr={ false }><TxsTable txs={ sortedTxs } sort={ sort } sorting={ sorting }/></Hide>
</> </>
); );
......
This diff is collapsed.
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