Commit 1d4c80b4 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #764 from blockscout/token/verified-info

token page: display verified info
parents 4ea22c48 d27e6a4b
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.978 6.156c.56.455.77.42 1.82.35l9.912-.595c.21 0 .035-.21-.035-.245l-1.646-1.19c-.315-.245-.736-.525-1.54-.455l-9.598.7c-.35.035-.42.21-.28.35l1.367 1.085Zm.595 2.31v10.428c0 .56.28.77.91.735L18.375 19c.631-.035.701-.42.701-.875V7.765c0-.454-.175-.7-.56-.664l-11.383.664c-.42.036-.56.246-.56.7Zm10.753.559c.07.315 0 .63-.316.666l-.525.104v7.699c-.456.245-.876.385-1.226.385-.56 0-.701-.175-1.121-.7l-3.433-5.389v5.214l1.086.245s0 .63-.876.63l-2.416.14c-.07-.14 0-.49.245-.56l.63-.174V10.39l-.875-.07c-.07-.315.105-.77.595-.805l2.592-.175 3.573 5.46V9.97l-.911-.104c-.07-.386.21-.666.56-.7l2.418-.141ZM4.086 3.776l9.982-.735c1.226-.105 1.541-.035 2.312.525l3.186 2.24c.526.385.701.49.701.91v12.283c0 .77-.28 1.225-1.26 1.295l-11.593.7c-.736.035-1.087-.07-1.472-.56l-2.347-3.045c-.42-.56-.595-.98-.595-1.47V5c0-.63.28-1.155 1.085-1.225Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 5H3v13.58h18V5ZM4.256 7.163v9.25L8.8 11.766 4.256 7.163Zm14.622 10.16H5.12l4.56-4.662.879.89c.782.788 2.156.78 2.925.006l.865-.87 4.528 4.637Zm.866-.91V7.257l-4.51 4.539 4.51 4.617ZM18.97 6.256H5.125l6.327 6.411c.29.292.864.286 1.142.005l6.375-6.416Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#link_svg__a)"> <path fill-rule="evenodd" clip-rule="evenodd" d="M16.17 3a5.073 5.073 0 0 0-3.568 1.424l-.01.01-1.401 1.393a1 1 0 0 0 1.41 1.419l1.396-1.388a3.073 3.073 0 0 1 4.346 4.344l-2.437 2.438a3.072 3.072 0 0 1-4.635-.332 1 1 0 1 0-1.601 1.198 5.074 5.074 0 0 0 7.65.548l2.444-2.444.012-.012A5.073 5.073 0 0 0 16.17 3Zm-5.34 5.657a5.073 5.073 0 0 0-3.95 1.474l-2.444 2.444-.012.012a5.073 5.073 0 0 0 7.174 7.174l.012-.013 1.393-1.393a1 1 0 1 0-1.414-1.414l-1.387 1.387a3.073 3.073 0 0 1-4.345-4.346l2.437-2.437a3.072 3.072 0 0 1 4.635.332 1 1 0 0 0 1.601-1.198 5.074 5.074 0 0 0-3.7-2.022Z" fill="currentColor"/>
<path d="M5.547 6.073c1.303-1.303 3.469-1.303 4.772 0 1.171 1.172 1.345 3.04.382 4.387l-.026.038a.75.75 0 0 1-1.221-.872l.026-.038a1.89 1.89 0 0 0-2.874-2.435l-2.63 2.632c-.738.717-.738 1.934 0 2.672a1.887 1.887 0 0 0 2.433.202l.038-.047a.772.772 0 0 1 1.045.194.75.75 0 0 1-.173 1.048l-.038.026a3.389 3.389 0 0 1-4.366-5.154l2.632-2.653Zm6.914 5.833A3.388 3.388 0 0 1 7.307 7.54l.026-.038c.22-.335.689-.415 1.045-.173.338.22.417.689.176 1.045l-.026.038c-.537.731-.452 1.781.202 2.435a1.893 1.893 0 0 0 2.671 0l2.63-2.632c.738-.738.738-1.955 0-2.672a1.887 1.887 0 0 0-2.433-.202l-.037.026c-.338.242-.806.143-1.046-.174a.749.749 0 0 1 .174-1.046l.037-.026a3.389 3.389 0 0 1 4.367 5.153l-2.632 2.632Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="link_svg__a">
<path fill="#fff" transform="translate(1.504 3)" d="M0 0h15v12H0z"/>
</clipPath>
</defs>
</svg> </svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2a9.996 9.996 0 0 1 10.014 10.026c0 5.483-4.523 10-10.092 9.974-5.385-.052-9.908-4.49-9.908-10 0-5.535 4.444-10.026 9.987-10ZM4.594 17.222c0 .104.052.156.105.235.392.548.836 1.07 1.359 1.514.235.21.47.418.732.6.732.47 1.49.914 2.327 1.202.68.235 1.385.418 2.091.496.55.052 1.124.078 1.673 0 .314-.052.654-.052.968-.183.052.026-.026-.958 0-.958.653-.105.392-.896 1.02-1.157 2.039-.887 2.797-1.828 3.79-3.812.89-1.75 1.726-2.767 1.438-4.7-.261-1.853-1.882-2.428-3.137-3.785-1.673-1.906-1.908-2.455-4.444-2.611-1.125-.079-1.15.81-2.223 1.175-1.751.574-3.059.391-4.235 1.827C4.724 8.71 4.776 9.911 4.697 12c-.026.522-.182 1.332-.104 1.854.157 1.175-1.02 1.801-.418 2.82.157.156.261.365.418.548Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.253 7.04c-.575-.105-1.15-.21-1.752-.183-.653.026-1.228.235-1.83.47-.705.26-1.359.626-1.882 1.174-.575.601-.889 1.332-1.02 2.168-.104.704-.13 1.41-.209 2.114-.13 1.515-.366 3.03-.993 4.413-.183-.157-.288-.392-.392-.575a8.801 8.801 0 0 1-1.15-3.315c-.079-.523-.131-1.019-.105-1.54.078-2.09.732-3.97 2.065-5.614C6.135 4.716 7.6 3.723 9.351 3.149a9.54 9.54 0 0 1 3.32-.444c2.51.157 4.654 1.175 6.353 3.029 1.255 1.384 2.014 3.002 2.301 4.83.288 1.932 0 3.786-.889 5.535-.993 1.985-2.536 3.42-4.575 4.308a7.7 7.7 0 0 1-1.909.575c-.052 0-.078.078-.104.026-.157-.992.078-1.932.47-2.846.497-1.097 1.203-2.037 1.987-2.95.654-.758 1.412-1.463 2.144-2.168.418-.417.785-.835.994-1.383.366-.888.157-1.62-.628-2.194-.601-.443-1.307-.678-2.013-.887a26.36 26.36 0 0 1-2.013-.731c-.13-.444-.444-.731-.863-.888-.549-.13-1.098-.078-1.673.078Zm8.55 5.56h.052c.104-.365.104-.757.104-1.148 0-.444-.052-.888-.13-1.332a7.48 7.48 0 0 0-1.962-3.76c-.627-.652-1.333-1.2-2.039-1.775-.627-.522-1.307-.914-2.091-1.123-.758-.182-1.49-.287-2.275-.156-.052 0-.104 0-.104.052s.052.052.104.052c1.15.209 2.249.575 3.32.992 1.046.418 1.935 1.019 2.589 1.958.444.653.863 1.332 1.202 2.063.445.992.785 2.01.994 3.055.105.418.183.757.235 1.123Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.894 7.927c.993 0 1.778.81 1.778 1.775 0 .992-.81 1.776-1.804 1.802-.994 0-1.778-.81-1.778-1.802s.81-1.775 1.804-1.775Zm1.255 1.801c0-.705-.55-1.253-1.255-1.253-.706 0-1.255.548-1.255 1.253 0 .68.55 1.254 1.229 1.254h.026c.68 0 1.229-.549 1.255-1.254Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.358 13.41c.68.418 1.438.47 2.196.444a8.302 8.302 0 0 0 2.98-.705c.367-.157.733-.314 1.072-.522.027-.027.053-.053.105-.027-.026.079-.105.105-.183.157-1.02.679-2.144 1.097-3.346 1.332-.68.13-1.386.13-2.04-.105-.34-.13-.653-.34-.784-.574Zm-.104-6.37c.549-.158 1.124-.21 1.673 0 .418.156.732.443.862.887-.784-.418-1.647-.679-2.535-.888Z" fill="transparent"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.358 13.41c.68.417 1.438.469 2.196.443a8.304 8.304 0 0 0 2.98-.705c.367-.157.733-.313 1.073-.522.026-.026.052-.052.104-.026-.026.078-.104.104-.183.156-1.02.68-2.144 1.097-3.346 1.332-.68.13-1.386.13-2.04-.104-.34-.131-.653-.34-.784-.575ZM17.3 11.87a.402.402 0 0 1-.393-.392c0-.21.157-.392.366-.392.21 0 .392.157.418.365-.026.236-.183.418-.392.418Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.815 12.523a.056.056 0 0 1-.052.052c0-.052.026-.078.052-.052Zm.078-.052-.078.052-.026-.026c.052-.026.078-.026.104-.026Z" fill="transparent"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.59 14.774c.245-.016.482-.099.684-.238h.004c.197-.125.369-.288.511-.484.138-.662.211-1.349.211-2.052 0-5.523-4.477-10-10-10S2 6.477 2 12c0 1.55.352 3.017.981 4.326l5.643-9.058c.733-1.118 1.486-1.178 1.989-1.03.76.22 1.161.96 1.191 2.195v5.356l2.712-4.388.02-.034c.457-.73 1.59-2.544 3.1-2.102.907.264 1.449 1.239 1.449 2.608v2.852c0 .93.278 1.613.793 1.905.22.11.465.16.711.144Zm-.018 2.38a3.683 3.683 0 0 1-1.808-.442c-1.28-.717-2.009-2.17-2.009-3.987v-2.48c-.08.116-.17.25-.264.408l-3.013 4.877c-1.014 1.647-1.982 1.774-2.614 1.59-.633-.184-1.386-.813-1.386-2.758v-4.047L4.392 18.49A9.978 9.978 0 0 0 12 22a9.995 9.995 0 0 0 8.572-4.847Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m8.074 22 .128-3.79a13.266 13.266 0 0 1-5.491 1.181v.805a1.803 1.803 0 0 0 1.803 1.805h3.56Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.631 2h2.797a9.95 9.95 0 0 1 4.552 1.094 13.202 13.202 0 0 0-1.871 2.688.873.873 0 0 0-.633.03.942.942 0 0 0-1.806 0 .922.922 0 0 0-1.09.287.913.913 0 0 0-.126.207c-.24-.328-.586-.533-.96-.754-.309-.183-.637-.377-.937-.661a.266.266 0 0 0-.406.02.534.534 0 0 0-.058.54c.323.762.72 1.305 1.075 1.787.114.156.224.305.325.454a.667.667 0 0 0 .488.316 3.043 3.043 0 0 0-.417 1.13c-.458.124-.805.676-.805 1.341 0 .59.276 1.096.66 1.286l-.217 6.446a13.266 13.266 0 0 1-5.491 1.18V6.922A4.924 4.924 0 0 0 7.63 2Zm-.514 6.574a.457.457 0 0 0-.11-.345.446.446 0 0 0-.149-.11L5.591 7.55a.304.304 0 0 0-.304.524l1.127.814a.444.444 0 0 0 .522-.003.456.456 0 0 0 .125-.136.456.456 0 0 0 .056-.175Zm-.11 7.395a.446.446 0 0 0 .092-.16h-.002a.456.456 0 0 0-.038-.359.445.445 0 0 0-.646-.138l-1.128.812a.304.304 0 0 0 .305.525l1.267-.57a.446.446 0 0 0 .15-.11ZM4.33 12.372l.484.166a.544.544 0 1 0 0-1.032l-.484.166a.37.37 0 0 0 0 .7Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.98 3.094A9.993 9.993 0 0 1 20.422 12a9.997 9.997 0 0 1-5.394 8.877l-.304-9.017v-.096a.403.403 0 0 0 .065-.038c.352-.211.595-.692.595-1.247 0-.066-.003-.132-.01-.198-.061-.575-.382-1.032-.795-1.144a2.988 2.988 0 0 0-.409-1.13.666.666 0 0 0 .496-.316c.101-.15.211-.3.326-.458.35-.481.745-1.022 1.066-1.768l.018-.042a.365.365 0 0 0-.044-.38.373.373 0 0 0-.566-.035c-.294.28-.607.457-.9.623-.34.194-.652.37-.874.674a.97.97 0 0 0-.583-.523c.51-.97 1.138-1.873 1.871-2.688Zm3.348 9.442.486-.166a.37.37 0 0 0 0-.7l-.486-.166a.545.545 0 1 0 0 1.032Zm-.708 4.122a.304.304 0 0 0 .084-.536l-1.127-.813a.443.443 0 0 0-.645.139.443.443 0 0 0 .202.628l1.268.57c.069.032.146.036.218.012ZM15.985 8.23a.448.448 0 0 0-.11.345.444.444 0 0 0 .182.313.447.447 0 0 0 .521.002l1.128-.814a.304.304 0 0 0-.304-.524l-1.268.57a.445.445 0 0 0-.15.108Z" fill="currentColor"/>
<path d="M6.19 5.48A4.924 4.924 0 0 0 7.63 2H4.514A1.803 1.803 0 0 0 2.71 3.803v3.119c1.305 0 2.556-.52 3.479-1.443ZM12.476 10.704l-.507.466a.314.314 0 0 0-.09.152c-.033.134-.099.397-.152.647a.152.152 0 0 1-.152.115.152.152 0 0 1-.152-.115l-.162-.647a.304.304 0 0 0-.09-.152l-.506-.466a.151.151 0 0 1-.033-.175.153.153 0 0 1 .156-.086l.76.103h.042l.06-.007.7-.096a.153.153 0 0 1 .153.085.152.152 0 0 1-.035.176m.76-.17c.005-.328.099-.648.271-.927a.608.608 0 0 0 .05-.503l-.036.032a.888.888 0 0 1-1.086.009c0 .313-.286.572-.66.618a.988.988 0 0 1-.118 0c-.427 0-.776-.28-.776-.626a.888.888 0 0 1-1.086-.009.65.65 0 0 1-.114-.12.596.596 0 0 0-.034.628c.147.277.224.585.223.898 0 .327-.087.648-.253.93a.609.609 0 0 0 .056.689c.437.525 1.135.961 1.803 1.02h.165c.773 0 1.38-.482 1.813-1.114a.599.599 0 0 0 .026-.626 1.892 1.892 0 0 1-.228-.897" fill="currentColor"/>
</svg>
...@@ -44,6 +44,7 @@ import type { ...@@ -44,6 +44,7 @@ import type {
TokenInventoryResponse, TokenInventoryResponse,
TokenInstance, TokenInstance,
TokenInstanceTransfersCount, TokenInstanceTransfersCount,
TokenVerifiedInfo,
} from 'types/api/token'; } from 'types/api/token';
import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens'; import type { TokensResponse, TokensFilters, TokenInstanceTransferResponse } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer'; import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
...@@ -322,6 +323,12 @@ export const RESOURCES = { ...@@ -322,6 +323,12 @@ export const RESOURCES = {
path: '/api/v2/tokens/:hash', path: '/api/v2/tokens/:hash',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
}, },
token_verified_info: {
path: '/api/v1/chains/:chainId/token-infos/:hash',
pathParams: [ 'chainId' as const, 'hash' as const ],
endpoint: appConfig.contractInfoApi.endpoint,
basePath: appConfig.contractInfoApi.basePath,
},
token_counters: { token_counters: {
path: '/api/v2/tokens/:hash/counters', path: '/api/v2/tokens/:hash/counters',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
...@@ -559,6 +566,7 @@ Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart : ...@@ -559,6 +566,7 @@ Q extends 'address_coin_balance_chart' ? AddressCoinBalanceHistoryChart :
Q extends 'address_logs' ? LogsResponseAddress : Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse : Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'token' ? TokenInfo : Q extends 'token' ? TokenInfo :
Q extends 'token_verified_info' ? TokenVerifiedInfo :
Q extends 'token_counters' ? TokenCounters : Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse : Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders : Q extends 'token_holders' ? TokenHolders :
......
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import useLoginUrl from 'lib/hooks/useLoginUrl';
export default function useRedirectIfNotAuth() {
const queryClient = useQueryClient();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
return React.useCallback(() => {
if (!isAuth) {
window.location.assign(loginUrl);
return true;
}
return false;
}, [ isAuth, loginUrl ]);
}
import type { TokenInfoApplication } from './account';
import type { AddressParam } from './addressParams'; import type { AddressParam } from './addressParams';
export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155'; export type TokenType = 'ERC-20' | 'ERC-721' | 'ERC-1155';
...@@ -57,3 +58,5 @@ export interface TokenInventoryResponse { ...@@ -57,3 +58,5 @@ export interface TokenInventoryResponse {
export type TokenInventoryPagination = { export type TokenInventoryPagination = {
unique_token: number; unique_token: number;
} }
export type TokenVerifiedInfo = Omit<TokenInfoApplication, 'id' | 'status'>;
...@@ -3,14 +3,11 @@ import { useQueryClient } from '@tanstack/react-query'; ...@@ -3,14 +3,11 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { UserInfo } from 'types/api/account';
import starFilledIcon from 'icons/star_filled.svg'; import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg'; import starOutlineIcon from 'icons/star_outline.svg';
import { resourceKey } from 'lib/api/resources';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing'; import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal'; import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal'; import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
...@@ -25,20 +22,15 @@ const AddressFavoriteButton = ({ className, hash, isAdded }: Props) => { ...@@ -25,20 +22,15 @@ const AddressFavoriteButton = ({ className, hash, isAdded }: Props) => {
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const redirectIfNotAuth = useRedirectIfNotAuth();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
const watchListQuery = useApiQuery('watchlist', { queryOptions: { enabled: isAdded } }); const watchListQuery = useApiQuery('watchlist', { queryOptions: { enabled: isAdded } });
const handleClick = React.useCallback(() => { const handleClick = React.useCallback(() => {
if (!isAuth) { if (redirectIfNotAuth()) {
window.location.assign(loginUrl);
return; return;
} }
isAdded ? deleteModalProps.onOpen() : addModalProps.onOpen(); isAdded ? deleteModalProps.onOpen() : addModalProps.onOpen();
}, [ addModalProps, deleteModalProps, isAdded, isAuth, loginUrl ]); }, [ addModalProps, deleteModalProps, isAdded, redirectIfNotAuth ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => { const handleAddOrDeleteSuccess = React.useCallback(async() => {
const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } }); const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } });
......
...@@ -18,9 +18,11 @@ interface Props { ...@@ -18,9 +18,11 @@ interface Props {
onClose: () => void; onClose: () => void;
onSubmit: (address: VerifiedAddress) => void; onSubmit: (address: VerifiedAddress) => void;
onAddTokenInfoClick: (address: string) => void; onAddTokenInfoClick: (address: string) => void;
onShowListClick: () => void;
defaultAddress?: string;
} }
const AddressVerificationModal = ({ isOpen, onClose, onSubmit, onAddTokenInfoClick }: Props) => { const AddressVerificationModal = ({ defaultAddress, isOpen, onClose, onSubmit, onAddTokenInfoClick, onShowListClick }: Props) => {
const [ stepIndex, setStepIndex ] = React.useState(0); const [ stepIndex, setStepIndex ] = React.useState(0);
const [ data, setData ] = React.useState<StateData>({ address: '', signingMessage: '' }); const [ data, setData ] = React.useState<StateData>({ address: '', signingMessage: '' });
...@@ -53,7 +55,7 @@ const AddressVerificationModal = ({ isOpen, onClose, onSubmit, onAddTokenInfoCli ...@@ -53,7 +55,7 @@ const AddressVerificationModal = ({ isOpen, onClose, onSubmit, onAddTokenInfoCli
const steps = [ const steps = [
{ {
title: 'Verify new address ownership', title: 'Verify new address ownership',
content: <AddressVerificationStepAddress onContinue={ handleGoToSecondStep }/>, content: <AddressVerificationStepAddress onContinue={ handleGoToSecondStep } defaultAddress={ defaultAddress }/>,
}, },
{ {
title: 'Copy and sign message', title: 'Copy and sign message',
...@@ -63,7 +65,7 @@ const AddressVerificationModal = ({ isOpen, onClose, onSubmit, onAddTokenInfoCli ...@@ -63,7 +65,7 @@ const AddressVerificationModal = ({ isOpen, onClose, onSubmit, onAddTokenInfoCli
title: 'Congrats! Address is verified.', title: 'Congrats! Address is verified.',
content: ( content: (
<AddressVerificationStepSuccess <AddressVerificationStepSuccess
onShowListClick={ handleClose } onShowListClick={ onShowListClick }
onAddTokenInfoClick={ handleAddTokenInfoClick } onAddTokenInfoClick={ handleAddTokenInfoClick }
isToken={ data.isToken } isToken={ data.isToken }
address={ data.address } address={ data.address }
......
...@@ -21,12 +21,16 @@ import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldA ...@@ -21,12 +21,16 @@ import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldA
type Fields = RootFields & AddressVerificationFormFirstStepFields; type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props { interface Props {
defaultAddress?: string;
onContinue: (data: AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess) => void; onContinue: (data: AddressVerificationFormFirstStepFields & AddressCheckStatusSuccess) => void;
} }
const AddressVerificationStepAddress = ({ onContinue }: Props) => { const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) => {
const formApi = useForm<Fields>({ const formApi = useForm<Fields>({
mode: 'onBlur', mode: 'onBlur',
defaultValues: {
address: defaultAddress,
},
}); });
const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi; const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
......
import { Link, Text, Icon } from '@chakra-ui/react'; import { Link, Text, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll'; import { animateScroll } from 'react-scroll';
...@@ -8,6 +9,7 @@ import eastArrowIcon from 'icons/arrows/east.svg'; ...@@ -8,6 +9,7 @@ import eastArrowIcon from 'icons/arrows/east.svg';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import getQueryParamString from 'lib/router/getQueryParamString';
import PublicTagsData from 'ui/publicTags/PublicTagsData'; import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm'; import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
...@@ -23,13 +25,22 @@ const toastDescriptions = { ...@@ -23,13 +25,22 @@ const toastDescriptions = {
} as Record<TToastAction, string>; } as Record<TToastAction, string>;
const PublicTagsComponent: React.FC = () => { const PublicTagsComponent: React.FC = () => {
const [ screen, setScreen ] = useState<TScreen>('data'); const router = useRouter();
const [ formData, setFormData ] = useState<PublicTag>(); const addressHash = getQueryParamString(router.query.address);
const [ screen, setScreen ] = useState<TScreen>(addressHash ? 'form' : 'data');
const [ formData, setFormData ] = useState<Partial<PublicTag> | undefined>(addressHash ? { addresses: [ addressHash ] } : undefined);
const toast = useToast(); const toast = useToast();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
useRedirectForInvalidAuthToken(); useRedirectForInvalidAuthToken();
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/public_tags_request' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const showToast = useCallback((action: TToastAction) => { const showToast = useCallback((action: TToastAction) => {
toast({ toast({
position: 'top-right', position: 'top-right',
......
...@@ -7,6 +7,7 @@ import type { SocketMessage } from 'lib/socket/types'; ...@@ -7,6 +7,7 @@ import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types'; import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import appConfig from 'configs/app/config';
import iconSuccess from 'icons/status/success.svg'; import iconSuccess from 'icons/status/success.svg';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext'; import { useAppContext } from 'lib/appContext';
...@@ -132,6 +133,12 @@ const TokenPageContent = () => { ...@@ -132,6 +133,12 @@ const TokenPageContent = () => {
queryOptions: { enabled: Boolean(router.query.hash) }, queryOptions: { enabled: Boolean(router.query.hash) },
}); });
const isVerifiedInfoEnabled = Boolean(appConfig.contractInfoApi.endpoint);
const verifiedInfoQuery = useApiQuery('token_verified_info', {
pathParams: { hash: hashString, chainId: appConfig.network.id },
queryOptions: { enabled: Boolean(tokenQuery.data) && isVerifiedInfoEnabled },
});
const contractTabs = useContractTabs(contractQuery.data); const contractTabs = useContractTabs(contractQuery.data);
const tabs: Array<RoutedTab> = [ const tabs: Array<RoutedTab> = [
...@@ -205,6 +212,16 @@ const TokenPageContent = () => { ...@@ -205,6 +212,16 @@ const TokenPageContent = () => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
const tags = [
{ label: tokenQuery.data?.type, display_name: tokenQuery.data?.type },
...(contractQuery.data?.private_tags || []),
...(contractQuery.data?.public_tags || []),
...(contractQuery.data?.watchlist_names || []),
]
.filter(Boolean)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const tagsNode = tags.length > 0 ? <Flex columnGap={ 2 }>{ tags }</Flex> : null;
return ( return (
<Page> <Page>
{ tokenQuery.isLoading ? ( { tokenQuery.isLoading ? (
...@@ -224,12 +241,13 @@ const TokenPageContent = () => { ...@@ -224,12 +241,13 @@ const TokenPageContent = () => {
additionalsLeft={ ( additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 }/> <TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 }/>
) } ) }
additionalsRight={ <Tag>{ tokenQuery.data?.type }</Tag> } additionalsRight={ tagsNode }
afterTitle={ verifiedInfoQuery.data ? <Icon as={ iconSuccess } color="green.500" boxSize={ 4 } verticalAlign="top"/> : null }
/> />
</> </>
) } ) }
<TokenContractInfo tokenQuery={ tokenQuery }/> <TokenContractInfo tokenQuery={ tokenQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/> <TokenDetails tokenQuery={ tokenQuery } verifiedInfoQuery={ verifiedInfoQuery } isVerifiedInfoEnabled={ isVerifiedInfoEnabled }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box> <Box ref={ scrollRef }></Box>
......
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Box, Link } from '@chakra-ui/react'; import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Box, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, VerifiedAddressResponse } from 'types/api/account'; import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, VerifiedAddressResponse } from 'types/api/account';
...@@ -7,6 +8,7 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri ...@@ -7,6 +8,7 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri
import appConfig from 'configs/app/config'; import appConfig from 'configs/app/config';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal'; import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription'; import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
...@@ -21,7 +23,16 @@ import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable' ...@@ -21,7 +23,16 @@ import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable'
const VerifiedAddresses = () => { const VerifiedAddresses = () => {
useRedirectForInvalidAuthToken(); useRedirectForInvalidAuthToken();
const [ selectedAddress, setSelectedAddress ] = React.useState<string>(); const router = useRouter();
const addressHash = getQueryParamString(router.query.address);
const [ selectedAddress, setSelectedAddress ] = React.useState<string | undefined>(addressHash);
React.useEffect(() => {
addressHash && router.replace({ pathname: '/account/verified_addresses' });
// componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const modalProps = useDisclosure(); const modalProps = useDisclosure();
const addressesQuery = useApiQuery('verified_addresses', { const addressesQuery = useApiQuery('verified_addresses', {
...@@ -191,6 +202,7 @@ const VerifiedAddresses = () => { ...@@ -191,6 +202,7 @@ const VerifiedAddresses = () => {
onClose={ modalProps.onClose } onClose={ modalProps.onClose }
onSubmit={ handleAddressSubmit } onSubmit={ handleAddressSubmit }
onAddTokenInfoClick={ handleItemAdd } onAddTokenInfoClick={ handleItemAdd }
onShowListClick={ modalProps.onClose }
/> />
</Page> </Page>
); );
......
...@@ -3,7 +3,7 @@ import { ...@@ -3,7 +3,7 @@ import {
Button, Button,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form'; import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
...@@ -11,7 +11,6 @@ import { useForm, Controller } from 'react-hook-form'; ...@@ -11,7 +11,6 @@ import { useForm, Controller } from 'react-hook-form';
import type { AddressTag, AddressTagErrors } from 'types/api/account'; import type { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources'; import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
...@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput'; ...@@ -21,8 +20,9 @@ import TagInput from 'ui/shared/TagInput';
const TAG_MAX_LENGTH = 35; const TAG_MAX_LENGTH = 35;
type Props = { type Props = {
data?: AddressTag; data?: Partial<AddressTag>;
onClose: () => void; onClose: () => void;
onSuccess: () => Promise<void>;
setAlertVisible: (isAlertVisible: boolean) => void; setAlertVisible: (isAlertVisible: boolean) => void;
} }
...@@ -31,7 +31,7 @@ type Inputs = { ...@@ -31,7 +31,7 @@ type Inputs = {
tag: string; tag: string;
} }
const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const [ pending, setPending ] = useState(false); const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
...@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -44,8 +44,6 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const formBackgroundColor = useColorModeValue('white', 'gray.900'); const formBackgroundColor = useColorModeValue('white', 'gray.900');
const queryClient = useQueryClient();
const { mutate } = useMutation((formData: Inputs) => { const { mutate } = useMutation((formData: Inputs) => {
const body = { const body = {
name: formData?.tag, name: formData?.tag,
...@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -74,11 +72,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
setAlertVisible(true); setAlertVisible(true);
} }
}, },
onSuccess: () => { onSuccess: async() => {
queryClient.refetchQueries([ resourceKey('private_tags_address') ]).then(() => { await onSuccess();
onClose(); onClose();
setPending(false); setPending(false);
});
}, },
}); });
......
...@@ -9,18 +9,19 @@ import AddressForm from './AddressForm'; ...@@ -9,18 +9,19 @@ import AddressForm from './AddressForm';
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
data?: AddressTag; onSuccess: () => Promise<void>;
data?: Partial<AddressTag>;
} }
const AddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const AddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data }) => {
const title = data ? 'Edit address tag' : 'New address tag'; const title = data?.id ? 'Edit address tag' : 'New address tag';
const text = !data ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : ''; const text = !data?.id ? 'Label any address with a private address tag (up to 35 chars) to customize your explorer experience.' : '';
const [ isAlertVisible, setAlertVisible ] = useState(false); const [ isAlertVisible, setAlertVisible ] = useState(false);
const renderForm = useCallback(() => { const renderForm = useCallback(() => {
return <AddressForm data={ data } onClose={ onClose } setAlertVisible={ setAlertVisible }/>; return <AddressForm data={ data } onClose={ onClose } onSuccess={ onSuccess } setAlertVisible={ setAlertVisible }/>;
}, [ data, onClose ]); }, [ data, onClose, onSuccess ]);
return ( return (
<FormModal<AddressTag> <FormModal<AddressTag>
isOpen={ isOpen } isOpen={ isOpen }
......
...@@ -16,7 +16,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable'; ...@@ -16,7 +16,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal'; import DeletePrivateTagModal from './DeletePrivateTagModal';
const PrivateAddressTags = () => { const PrivateAddressTags = () => {
const { data: addressTagsData, isLoading, isError } = useApiQuery('private_tags_address', { queryOptions: { refetchOnMount: false } }); const { data: addressTagsData, isLoading, isError, refetch } = useApiQuery('private_tags_address', { queryOptions: { refetchOnMount: false } });
const addressModalProps = useDisclosure(); const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
...@@ -30,6 +30,10 @@ const PrivateAddressTags = () => { ...@@ -30,6 +30,10 @@ const PrivateAddressTags = () => {
addressModalProps.onOpen(); addressModalProps.onOpen();
}, [ addressModalProps ]); }, [ addressModalProps ]);
const onAddOrEditSuccess = useCallback(async() => {
await refetch();
}, [ refetch ]);
const onAddressModalClose = useCallback(() => { const onAddressModalClose = useCallback(() => {
setAddressModalData(undefined); setAddressModalData(undefined);
addressModalProps.onClose(); addressModalProps.onClose();
...@@ -103,7 +107,7 @@ const PrivateAddressTags = () => { ...@@ -103,7 +107,7 @@ const PrivateAddressTags = () => {
Add address tag Add address tag
</Button> </Button>
</Box> </Box>
<AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData }/> <AddressModal { ...addressModalProps } onClose={ onAddressModalClose } data={ addressModalData } onSuccess={ onAddOrEditSuccess }/>
{ deleteModalData && ( { deleteModalData && (
<DeletePrivateTagModal <DeletePrivateTagModal
{ ...deleteModalProps } { ...deleteModalProps }
......
...@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput'; ...@@ -28,7 +28,7 @@ import PublicTagsFormInput from './PublicTagsFormInput';
type Props = { type Props = {
changeToDataScreen: (success?: boolean) => void; changeToDataScreen: (success?: boolean) => void;
data?: PublicTag; data?: Partial<PublicTag>;
} }
export type Inputs = { export type Inputs = {
...@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -67,8 +67,8 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
email: data?.email || '', email: data?.email || '',
companyName: data?.company || '', companyName: data?.company || '',
companyUrl: data?.website || '', companyUrl: data?.website || '',
tags: data?.tags.split(';').map((tag) => tag).join('; ') || '', tags: data?.tags?.split(';').map((tag) => tag).join('; ') || '',
addresses: data?.addresses.map((address, index: number) => ({ name: `address.${ index }.address`, address })) || addresses: data?.addresses?.map((address, index: number) => ({ name: `address.${ index }.address`, address })) ||
[ { name: 'address.0.address', address: '' } ], [ { name: 'address.0.address', address: '' } ],
comment: data?.additional_comment || '', comment: data?.additional_comment || '',
action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report', action: data?.is_owner === undefined || data?.is_owner ? 'add' : 'report',
......
import { Button, Menu, MenuButton, MenuList, Icon, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import iconArrow from 'icons/arrows/east-mini.svg';
import getQueryParamString from 'lib/router/getQueryParamString';
import PrivateTagMenuItem from './PrivateTagMenuItem';
import PublicTagMenuItem from './PublicTagMenuItem';
import TokenInfoMenuItem from './TokenInfoMenuItem';
const AddressActions = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const isTokenPage = router.pathname === '/token/[hash]';
return (
<Menu>
<MenuButton
as={ Button }
size="sm"
variant="outline"
ml={ 2 }
>
<Flex alignItems="center">
<span>More</span>
<Icon as={ iconArrow } transform="rotate(-90deg)" boxSize={ 5 } ml={ 1 }/>
</Flex>
</MenuButton>
<MenuList minWidth="180px" zIndex="popover">
{ isTokenPage && appConfig.contractInfoApi.endpoint && appConfig.adminServiceApi.endpoint && <TokenInfoMenuItem py={ 2 } px={ 4 } hash={ hash }/> }
<PublicTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
<PrivateTagMenuItem py={ 2 } px={ 4 } hash={ hash }/>
</MenuList>
</Menu>
);
};
export default React.memo(AddressActions);
import { MenuItem, chakra, useDisclosure } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import PrivateTagModal from 'ui/privateTags/AddressModal/AddressModal';
interface Props {
className?: string;
hash: string;
}
const PrivateTagMenuItem = ({ className, hash }: Props) => {
const modal = useDisclosure();
const queryClient = useQueryClient();
const redirectIfNotAuth = useRedirectIfNotAuth();
const queryKey = getResourceKey('address', { pathParams: { hash } });
const addressData = queryClient.getQueryData<Address>(queryKey);
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddPrivateTag = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey });
modal.onClose();
}, [ queryClient, queryKey, modal ]);
const formData = React.useMemo(() => {
return {
address_hash: hash,
};
}, [ hash ]);
if (addressData?.private_tags?.length) {
return null;
}
return (
<>
<MenuItem className={ className }onClick={ handleClick }>
Add private tag
</MenuItem>
<PrivateTagModal isOpen={ modal.isOpen } onClose={ modal.onClose } onSuccess={ handleAddPrivateTag } data={ formData }/>
</>
);
};
export default React.memo(chakra(PrivateTagMenuItem));
import { MenuItem, chakra } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
interface Props {
className?: string;
hash: string;
}
const PublicTagMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const redirectIfNotAuth = useRedirectIfNotAuth();
const handleClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
router.push({ pathname: '/account/public_tags_request', query: { address: hash } });
}, [ hash, redirectIfNotAuth, router ]);
return (
<MenuItem className={ className }onClick={ handleClick }>
Add public tag
</MenuItem>
);
};
export default React.memo(chakra(PublicTagMenuItem));
import { MenuItem, chakra, useDisclosure } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import appConfig from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
interface Props {
className?: string;
hash: string;
}
const TokenInfoMenuItem = ({ className, hash }: Props) => {
const router = useRouter();
const modal = useDisclosure();
const redirectIfNotAuth = useRedirectIfNotAuth();
const verifiedAddressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: appConfig.network.id },
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: appConfig.network.id, id: undefined },
});
const handleAddAddressClick = React.useCallback(() => {
if (redirectIfNotAuth()) {
return;
}
modal.onOpen();
}, [ modal, redirectIfNotAuth ]);
const handleAddApplicationClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses', query: { address: hash } });
}, [ hash, router ]);
const handleVerifiedAddressSubmit = React.useCallback(async() => {
await verifiedAddressesQuery.refetch();
}, [ verifiedAddressesQuery ]);
const handleShowMyAddressesClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified_addresses' });
}, [ router ]);
const content = (() => {
if (!verifiedAddressesQuery.data?.verifiedAddresses.find(({ contractAddress }) => contractAddress === hash)) {
return (
<MenuItem className={ className } onClick={ handleAddAddressClick }>
Add token info
</MenuItem>
);
}
return (
<MenuItem className={ className } onClick={ handleAddApplicationClick }>
{ applicationsQuery.data?.submissions.some(({ tokenAddress }) => tokenAddress === hash) ? 'Update token info' : 'Add token info' }
</MenuItem>
);
})();
return (
<>
{ content }
<AddressVerificationModal
defaultAddress={ hash }
isOpen={ modal.isOpen }
onClose={ modal.onClose }
onSubmit={ handleVerifiedAddressSubmit }
onAddTokenInfoClick={ handleAddApplicationClick }
onShowListClick={ handleShowMyAddressesClick }
/>
</>
);
};
export default React.memo(chakra(TokenInfoMenuItem));
...@@ -4,12 +4,14 @@ import React from 'react'; ...@@ -4,12 +4,14 @@ import React from 'react';
import type { AddressParam } from 'types/api/addressParams'; import type { AddressParam } from 'types/api/addressParams';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import appConfig from 'configs/app/config';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask'; import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import AddressActionsMenu from 'ui/shared/AddressActions/Menu';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props { interface Props {
...@@ -35,10 +37,11 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled }: Props) => { ...@@ -35,10 +37,11 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled }: Props) => {
/> />
<CopyToClipboard text={ address.hash }/> <CopyToClipboard text={ address.hash }/>
{ address.is_contract && token && <AddressAddToMetaMask ml={ 2 } token={ token }/> } { address.is_contract && token && <AddressAddToMetaMask ml={ 2 } token={ token }/> }
{ !address.is_contract && ( { !address.is_contract && appConfig.isAccountSupported && (
<AddressFavoriteButton hash={ address.hash } isAdded={ Boolean(address.watchlist_names?.length) } ml={ 3 }/> <AddressFavoriteButton hash={ address.hash } isAdded={ Boolean(address.watchlist_names?.length) } ml={ 3 }/>
) } ) }
<AddressQrCode hash={ address.hash } ml={ 2 }/> <AddressQrCode hash={ address.hash } ml={ 2 }/>
{ appConfig.isAccountSupported && <AddressActionsMenu/> }
</Flex> </Flex>
); );
}; };
......
...@@ -14,9 +14,10 @@ type Props = { ...@@ -14,9 +14,10 @@ type Props = {
withTextAd?: boolean; withTextAd?: boolean;
className?: string; className?: string;
backLink?: BackLinkProp; backLink?: BackLinkProp;
afterTitle?: React.ReactNode;
} }
const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLink, className }: Props) => { const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLink, className, afterTitle }: Props) => {
const title = ( const title = (
<Heading <Heading
as="h1" as="h1"
...@@ -25,6 +26,7 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi ...@@ -25,6 +26,7 @@ const PageTitle = ({ text, additionalsLeft, additionalsRight, withTextAd, backLi
wordBreak="break-word" wordBreak="break-word"
> >
{ text } { text }
{ afterTitle }
</Heading> </Heading>
); );
......
...@@ -44,7 +44,12 @@ const TokenContractInfo = ({ tokenQuery }: Props) => { ...@@ -44,7 +44,12 @@ const TokenContractInfo = ({ tokenQuery }: Props) => {
watchlist_names: [], watchlist_names: [],
}; };
return <AddressHeadingInfo address={ address } token={ contractQuery.data?.token }/>; return (
<AddressHeadingInfo
address={ address }
token={ contractQuery.data?.token }
/>
);
}; };
export default React.memo(TokenContractInfo); export default React.memo(TokenContractInfo);
import { Box, Flex, Grid, Link, Skeleton } from '@chakra-ui/react'; import { Box, Flex, Grid, GridItem, Link, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { scroller } from 'react-scroll'; import { scroller } from 'react-scroll';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo, TokenVerifiedInfo } from 'types/api/token';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
...@@ -14,11 +14,15 @@ import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; ...@@ -14,11 +14,15 @@ import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow'; import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
import TokenDetailsVerifiedInfo from './TokenDetails/TokenDetailsVerifiedInfo';
interface Props { interface Props {
tokenQuery: UseQueryResult<TokenInfo>; tokenQuery: UseQueryResult<TokenInfo>;
verifiedInfoQuery: UseQueryResult<TokenVerifiedInfo>;
isVerifiedInfoEnabled: boolean;
} }
const TokenDetails = ({ tokenQuery }: Props) => { const TokenDetails = ({ tokenQuery, verifiedInfoQuery, isVerifiedInfoEnabled }: Props) => {
const router = useRouter(); const router = useRouter();
const tokenCountersQuery = useApiQuery('token_counters', { const tokenCountersQuery = useApiQuery('token_counters', {
...@@ -56,7 +60,7 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -56,7 +60,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
throw Error('Token fetch error', { cause: tokenQuery.error as unknown as Error }); throw Error('Token fetch error', { cause: tokenQuery.error as unknown as Error });
} }
if (tokenQuery.isLoading) { if (tokenQuery.isLoading || (isVerifiedInfoEnabled && verifiedInfoQuery.isLoading)) {
return ( return (
<Grid mt={ 10 } columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px"> <Grid mt={ 10 } columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
<DetailsSkeletonRow w="10%"/> <DetailsSkeletonRow w="10%"/>
...@@ -88,6 +92,14 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -88,6 +92,14 @@ const TokenDetails = ({ tokenQuery }: Props) => {
totalSupplyValue = Number(totalSupply).toLocaleString('en'); totalSupplyValue = Number(totalSupply).toLocaleString('en');
} }
const divider = (
<GridItem
colSpan={{ base: undefined, lg: 2 }}
borderBottom="1px solid"
borderColor="divider"
/>
);
return ( return (
<Grid <Grid
mt={ 8 } mt={ 8 }
...@@ -95,6 +107,12 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -95,6 +107,12 @@ const TokenDetails = ({ tokenQuery }: Props) => {
rowGap={{ base: 1, lg: 3 }} rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} overflow="hidden" templateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }} overflow="hidden"
> >
{ verifiedInfoQuery.data && (
<>
<TokenDetailsVerifiedInfo data={ verifiedInfoQuery.data }/>
{ divider }
</>
) }
{ exchangeRate && ( { exchangeRate && (
<DetailsInfoItem <DetailsInfoItem
title="Price" title="Price"
......
import { Flex, Link, Icon } from '@chakra-ui/react';
import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token';
import iconDocs from 'icons/docs.svg';
import iconEmail from 'icons/email.svg';
import iconLink from 'icons/link.svg';
import iconCoinGecko from 'icons/social/coingecko.svg';
import iconCoinMarketCap from 'icons/social/coinmarketcap.svg';
import iconDefiLlama from 'icons/social/defi_llama.svg';
import iconDiscord from 'icons/social/discord_filled.svg';
import iconFacebook from 'icons/social/facebook_filled.svg';
import iconGithub from 'icons/social/github_filled.svg';
import iconLinkedIn from 'icons/social/linkedin_filled.svg';
import iconMedium from 'icons/social/medium_filled.svg';
import iconOpenSea from 'icons/social/opensea_filled.svg';
import iconReddit from 'icons/social/reddit_filled.svg';
import iconSlack from 'icons/social/slack_filled.svg';
import iconTelegram from 'icons/social/telegram_filled.svg';
import iconTwitter from 'icons/social/twitter_filled.svg';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import LinkExternal from 'ui/shared/LinkExternal';
import TextSeparator from 'ui/shared/TextSeparator';
interface Props {
data: TokenVerifiedInfo;
}
interface TServiceLink {
field: keyof TokenVerifiedInfo;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
hint: string;
}
const SOCIAL_LINKS: Array<TServiceLink> = [
{ field: 'github', icon: iconGithub, hint: 'Github account' },
{ field: 'twitter', icon: iconTwitter, hint: 'Twitter account' },
{ field: 'telegram', icon: iconTelegram, hint: 'Telegram account' },
{ field: 'openSea', icon: iconOpenSea, hint: 'OpenSea page' },
{ field: 'linkedin', icon: iconLinkedIn, hint: 'LinkedIn page' },
{ field: 'facebook', icon: iconFacebook, hint: 'Facebook account' },
{ field: 'discord', icon: iconDiscord, hint: 'Discord account' },
{ field: 'medium', icon: iconMedium, hint: 'Medium account' },
{ field: 'slack', icon: iconSlack, hint: 'Slack account' },
{ field: 'reddit', icon: iconReddit, hint: 'Reddit account' },
];
const PRICE_TICKERS: Array<TServiceLink> = [
{ field: 'coinGeckoTicker', icon: iconCoinGecko, hint: 'Coin Gecko' },
{ field: 'coinMarketCapTicker', icon: iconCoinMarketCap, hint: 'Coin Market Cap' },
{ field: 'defiLlamaTicker', icon: iconDefiLlama, hint: 'Defi Llama' },
];
const ServiceLink = ({ href, hint, icon }: TServiceLink & { href: string | undefined }) => (
<Link
href={ href }
variant="secondary"
boxSize={ 5 }
aria-label={ hint }
title={ hint }
target="_blank"
>
<Icon as={ icon } boxSize={ 5 }/>
</Link>
);
const TokenDetailsVerifiedInfo = ({ data }: Props) => {
const websiteLink = (() => {
try {
const url = new URL(data.projectWebsite);
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconLink } boxSize={ 6 }/>
<LinkExternal href={ data.projectWebsite } fontSize="md">{ url.host }</LinkExternal>
</Flex>
);
} catch (error) {
return null;
}
})();
const docsLink = (() => {
if (!data.docs) {
return null;
}
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconDocs } boxSize={ 6 }/>
<LinkExternal href={ data.docs } fontSize="md">Documentation</LinkExternal>
</Flex>
);
})();
const supportLink = (() => {
if (!data.support) {
return null;
}
const isEmail = data.support.includes('@');
const href = isEmail ? `mailto:${ data.support }` : data.support;
return (
<Flex alignItems="center" columnGap={ 1 } color="text_secondary" _hover={{ color: 'link_hovered' }}>
<Icon as={ iconEmail } boxSize={ 6 }/>
<Link href={ href } target="_blank">
{ data.support }
</Link>
</Flex>
);
})();
const socialLinks = SOCIAL_LINKS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
const priceTickersLinks = PRICE_TICKERS
.map((link) => ({ ...link, href: data[link.field] }))
.filter(({ href }) => href);
return (
<DetailsInfoItem
title="Links"
hint="Links to the project's official website and social media channels."
>
<Flex flexDir="column" rowGap={ 5 }>
<Flex
flexDir={{ base: 'column', lg: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
columnGap={ 6 }
rowGap={ 2 }
>
{ websiteLink }
{ docsLink }
{ supportLink }
</Flex>
{ (socialLinks.length > 0 || priceTickersLinks.length > 0) && (
<Flex
columnGap={ 2 }
rowGap={ 2 }
flexWrap="wrap"
>
{ socialLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
{ priceTickersLinks.length > 0 && (
<>
<TextSeparator color="divider"/>
{ priceTickersLinks.map((link) => <ServiceLink key={ link.field } { ...link }/>) }
</>
) }
</Flex>
) }
</Flex>
</DetailsInfoItem>
);
};
export default React.memo(TokenDetailsVerifiedInfo);
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