Commit 6b444293 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into long-skeleton

parents af2c8685 0063a3fa
NEXT_PUBLIC_SENTRY_DSN=xxx
SENTRY_CSP_REPORT_URI=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
\ No newline at end of file
......@@ -251,7 +251,7 @@
"presentation": {
"reveal": "always",
"panel": "shared",
"close": true,
"close": false,
"revealProblems": "onProblem",
"focus": true,
},
......
......@@ -46,7 +46,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_NETWORK_SHORT_NAME | `string` *(optional)* | Used for SEO attributes (page title and description) | `OoG` |
| NEXT_PUBLIC_NETWORK_TYPE | `string` *(optional)* | Network type (used for matching pre-defined assets, e.g network logo and icon, which are stored in the project). See all possible values here | `xdai_mainnet` |
| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org/](https://chainlist.org/) for the reference | `99` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | Chain server RPC url, see [https://chainlist.org/](https://chainlist.org/) for the reference | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` *(optional)* | Chain server RPC url, see [https://chainlist.org/](https://chainlist.org/) for the reference. If not provided, some functionality of the explorer, related to smart contracts interaction and third-party apps integration, will be unavailable | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | `Ether` |
| NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | `ETH` |
| NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | `18` |
......
......@@ -32,6 +32,7 @@ NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_MARKETPLACE_APP_LIST=[{'author': 'Blockscout','id':'token-approval-tracker','title':'Token Approval Tracker','logo':'https://approval-tracker.vercel.app/icon-192.png','categories':['security','tools'],'shortDescription':'Token Approval Tracker shows all approvals for any ERC20-compliant tokens and NFTs and lets to revoke them or adjust the approved amount.','site':'https://docs.blockscout.com/for-users/blockscout-apps/token-approval-tracker','description':'Token Approval Tracker shows all approvals for any ERC20-compliant tokens and NFTs and lets to revoke them or adjust the approved amount.','url':'https://approval-tracker.vercel.app/'},{'author': 'Revoke','id':'revoke.cash','title':'Revoke.cash','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FVBMGyUFnd6CScjfK7CYQ%252Frevoke_sing.png%3Falt%3Dmedia%26token%3D9ab94986-7ab1-41c8-bf7e-d9ce11d23182','categories':['security','tools'],'shortDescription': 'Revoke.cash comes in as a preventative tool to manage your token allowances and practice proper wallet hygiene. By regularly revoking active allowances you reduce the chances of becoming the victim of allowance exploits.','site': 'https://revoke.cash/about','description': 'Revoke.cash comes in as a preventative tool to manage your token allowances and practice proper wallet hygiene. By regularly revoking active allowances you reduce the chances of becoming the victim of allowance exploits.','url':'https://revoke.cash/'},{'author':'Aave','id': 'aave','title': 'Aave','logo': 'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FrZkUTIUCG7Zx8BW6Em34%252FAave.png%3Falt%3Dmedia%26token%3D249797a4-4c1e-4372-9cd2-3e48e05e5f30','categories':['tools'],'shortDescription':'Aave is a decentralised non-custodial liquidity market protocol where users can participate as suppliers or borrowers. Suppliers provide liquidity to the market to earn a passive income, while borrowers are able to borrow in an overcollateralised (perpetually) or undercollateralised (one-block liquidity) fashion.','site': 'https://docs.aave.com/faq/','description': 'Aave is a decentralised non-custodial liquidity market protocol where users can participate as suppliers or borrowers. Suppliers provide liquidity to the market to earn a passive income, while borrowers are able to borrow in an overcollateralised (perpetually) or undercollateralised (one-block liquidity) fashion.','url': 'https://staging.aave.com/'},{'author':'LooksRare','id':'looksrare','external':true,'title':'LooksRare','logo': 'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FeAI4gy3qPMt68mZOZHAx%252FLooksRare.png%3Falt%3Dmedia%26token%3D44c01439-ae09-40aa-b904-3a9ce5b2e002','categories':['tools'],'shortDescription': 'LooksRare is the web3 NFT Marketplace where traders and collectors have earned over $1.3 Billion in rewards.','site':'https://docs.looksrare.org/about/welcome-to-looksrare','description':'LooksRare is the web3 NFT Marketplace where traders and collectors have earned over $1.3 Billion in rewards.','url': 'https://goerli.looksrare.org/'},{'author':'zkSync Bridge','id':'zksync-bridge','external':true,'title':'zkSync Bridge','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FrtQsaAz9BjGBc35tVAnq%252FzkSync.png%3Falt%3Dmedia%26token%3D5c18171c-8ccf-4a88-8f44-680cbf238115','categories':['security','tools'],'shortDescription':'zkSync 2.0 Goerli Bridge','site':'https://v2-docs.zksync.io/dev/','description':'zkSync 2.0 Goerli Bridge','url':'https://portal.zksync.io/bridge'},{'author':'dYdX','id':'dydx','external':true,'title':'dYdX','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FCrOglR72wpi0UhEscwe4%252Fdxdy.png%3Falt%3Dmedia%26token%3D8811909e-93e3-487c-9614-dffce37223e9','categories': ['security','tools'],'shortDescription':'dYdX is a leading decentralized exchange that currently supports perpetual trading. dYdX runs on smart contracts on the Ethereum blockchain, and allows users to trade with no intermediaries.','site':'https://help.dydx.exchange/en/articles/3047379-introduction-and-overview','description':'dYdX is a leading decentralized exchange that currently supports perpetual trading. dYdX runs on smart contracts on the Ethereum blockchain, and allows users to trade with no intermediaries.','url':'https://trade.stage.dydx.exchange/portfolio/overview'},{'author':'MetalSwap','id':'metalswap','title':'MetalSwap','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252F8xqldTvxb6avrwVVc3rS%252FMetalSwap.png%3Falt%3Dmedia%26token%3D92d2db99-853a-487d-8d8c-8cdeaeaaf014','categories':['security','tools'],'shortDescription':'MetalSwap is a decentralised platform that enables hedging swaps in financial markets with the aim of providing a hedge for commodity traders and an investment opportunity for those who contribute to the shared liquidity of the project.','site':'https://docs.metalswap.finance/','description':'MetalSwap is a decentralised platform that enables hedging swaps in financial markets with the aim of providing a hedge for commodity traders and an investment opportunity for those who contribute to the shared liquidity of the project.','url':'https://demo.metalswap.finance/'},{'author':'FaucetDao','id':'faucetdao','title':'FaucetDao','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252Ffnnt3ZNZhzRwqMM5YYjD%252FPlaceholder.png%3Falt%3Dmedia%26token%3D507571bb-d76f-4d96-a35e-2b278608f7ca','categories':['tools'],'shortDescription':'FaucetDao is a decentralised community fund providing liquidity and support to early-stage well vetted blockchain projects.','site':'https://linktr.ee/faucet_dao','description':'FaucetDao is a decentralised community fund providing liquidity and support to early-stage well vetted blockchain projects.','url':'https://www.faucetdao.shop/swap?chain=goerli'},{'author':'Uniswap','id':'uniswap','title':'Uniswap','logo':'https://www.gitbook.com/cdn-cgi/image/width=32,dpr=2.200000047683716,format=auto/https%3A%2F%2Ffiles.gitbook.com%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Fspaces%252F-Lq1XoWGmy8zggj_u2fM%252Fuploads%252FJc0QAyeaBmFIL97tSmGv%252FUniswap.png%3Falt%3Dmedia%26token%3D5d25d796-c273-4e22-92fa-ff85206bec76','categories':['tools'],'shortDescription':'Uniswap is a cryptocurrency exchange which uses a decentralized network protocol.','site':'https://docs.uniswap.org/','description':'Uniswap is a cryptocurrency exchange which uses a decentralized network protocol.','url':'https://app.uniswap.org/swap'}]
NEXT_PUBLIC_NETWORK_RPC_URL=https://core.poa.network
# api config
NEXT_PUBLIC_API_BASE_PATH=/poa/core
......
<svg viewBox="0 0 51 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.693 5.058c.498-.57 1.172-.891 1.875-.891h15.91c.351 0 .688.16.937.446l9.28 10.657c.249.285.388.672.388 1.076v24.36c0 .807-.279 1.581-.776 2.152-.498.571-1.172.892-1.875.892H13.568c-.703 0-1.377-.32-1.875-.892-.497-.57-.776-1.345-.776-2.153V7.212c0-.808.28-1.582.776-2.154Zm17.235 2.154h-15.36v33.493h23.864V16.977l-8.504-9.765Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.556 5a1.39 1.39 0 0 1 1.389 1.389v8.333h8.333a1.389 1.389 0 0 1 0 2.778h-9.722a1.389 1.389 0 0 1-1.39-1.389V6.39a1.39 1.39 0 0 1 1.39-1.389ZM22.46 25.151a1.326 1.326 0 0 0-1.875 1.875l3.04 3.04-3.04 3.04a1.326 1.326 0 0 0 1.875 1.875l3.04-3.04 3.04 3.04a1.326 1.326 0 0 0 1.875-1.875l-3.04-3.04 3.04-3.04a1.326 1.326 0 0 0-1.875-1.875l-3.04 3.04-3.04-3.04Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" fill-rule="evenodd" d="M14.688 4.75c-5.482 0-9.938 4.456-9.938 9.938 0 5.481 4.456 9.937 9.938 9.937 5.481 0 9.937-4.456 9.937-9.938 0-5.481-4.456-9.937-9.938-9.937Zm3.499 5.781a14.655 14.655 0 0 0-1.813-3.855 8.151 8.151 0 0 1 5.359 3.855h-3.546Zm-7.219 0H7.644a8.16 8.16 0 0 1 5.094-3.787 14.079 14.079 0 0 0-1.77 3.787Zm5.39 0h-3.56a12.705 12.705 0 0 1 1.78-3.382 12.64 12.64 0 0 1 1.78 3.382ZM6.5 14.687c0-.831.131-1.65.369-2.406h3.692a14.62 14.62 0 0 0-.013 4.737l-3.695.027a7.864 7.864 0 0 1-.353-2.358Zm5.83 2.327c-.3-1.57-.3-3.176 0-4.733h4.496c.3 1.555.3 3.147.028 4.705l-4.524.028Zm6.295-.047a14.364 14.364 0 0 0-.029-4.686h3.91c.238.756.369 1.575.369 2.406 0 .783-.116 1.542-.313 2.252l-3.937.028Zm-2.246 5.73a14.004 14.004 0 0 0 1.843-3.98l3.596-.026a8.21 8.21 0 0 1-5.439 4.005Zm-1.802-.472a12.283 12.283 0 0 1-1.8-3.462l3.613-.026a12.946 12.946 0 0 1-1.813 3.488Zm-1.84.406a8.208 8.208 0 0 1-5.128-3.837l3.328-.027c.394 1.347 1 2.657 1.8 3.864Z" clip-rule="evenodd"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#top-accounts_svg__a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.585 2.973a3.412 3.412 0 0 0-3.412 3.412v17.23a3.412 3.412 0 0 0 3.412 3.412h17.23a.95.95 0 0 0 0-1.9H7.585a1.512 1.512 0 1 1 0-3.023h17.23a.95.95 0 0 0 .934-1.126.954.954 0 0 0 .016-.176V4.662c0-.933-.756-1.689-1.688-1.689H7.585ZM6.073 6.385c0-.835.677-1.512 1.512-1.512h16.28v15.33H7.585a3.4 3.4 0 0 0-1.512.353V6.385Zm8.897 1.4a3.013 3.013 0 1 0 0 6.026 3.013 3.013 0 0 0 0-6.026Zm-1.024 3.013a1.024 1.024 0 1 1 2.048 0 1.024 1.024 0 0 1-2.048 0Zm1.025 3.097c-2.316 0-4.3 1.303-5.319 3.243a.446.446 0 0 0 .395.654h1.281a.446.446 0 0 0 .366-.19 3.972 3.972 0 0 1 3.277-1.718c1.33 0 2.52.685 3.268 1.723a.446.446 0 0 0 .362.185h1.292a.446.446 0 0 0 .393-.659c-1.01-1.866-2.983-3.238-5.315-3.238Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="top-accounts_svg__a">
<path fill="#fff" transform="translate(3 3)" d="M0 0h24v24H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#verified_svg__a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4 15a8.4 8.4 0 1 1-16.8 0 8.4 8.4 0 0 1 16.8 0Zm1.6 0c0 5.523-4.477 10-10 10S5 20.523 5 15 9.477 5 15 5s10 4.477 10 10Zm-5.895-3.706a.916.916 0 1 1 1.295 1.295l-6.022 6.022a1.05 1.05 0 0 1-1.485 0l-3.2-3.199a.915.915 0 0 1 1.296-1.295l2.257 2.258a.55.55 0 0 0 .778 0l5.081-5.081Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="verified_svg__a">
<path fill="#fff" transform="translate(5 5)" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
......@@ -16,22 +16,21 @@ const MAIN_DOMAINS = [ `*.${ appConfig.host }`, appConfig.host ];
// eslint-disable-next-line no-restricted-properties
const REPORT_URI = process.env.SENTRY_CSP_REPORT_URI;
function getNetworksExternalAssets() {
function getNetworksExternalAssetsHosts() {
const icons = featuredNetworks
.filter(({ icon }) => typeof icon === 'string')
.map(({ icon }) => new URL(icon as string));
.map(({ icon }) => new URL(icon as string).host);
const logo = appConfig.network.logo ? new URL(appConfig.network.logo) : undefined;
const logo = appConfig.network.logo ? new URL(appConfig.network.logo).host : undefined;
return logo ? icons.concat(logo) : icons;
}
function getMarketplaceAppsOrigins() {
return appConfig.marketplaceAppList.map(({ url }) => url);
}
function getMarketplaceAppsLogosOrigins() {
return appConfig.marketplaceAppList.map(({ logo }) => new URL(logo));
function getMarketplaceAppsHosts() {
return {
frames: appConfig.marketplaceAppList.map(({ url }) => new URL(url).host),
logos: appConfig.marketplaceAppList.map(({ logo }) => new URL(logo).host),
};
}
// we cannot use lodash/uniq in middleware code since it calls new Set() and it'is causing an error in Nextjs
......@@ -46,7 +45,7 @@ function unique(array: Array<string | undefined>) {
}
function makePolicyMap() {
const networkExternalAssets = getNetworksExternalAssets();
const marketplaceAppsHosts = getMarketplaceAppsHosts();
return {
'default-src': [
......@@ -130,10 +129,10 @@ function makePolicyMap() {
'avatars.githubusercontent.com', // github avatars
// network assets
...networkExternalAssets.map((url) => url.host),
...getNetworksExternalAssetsHosts(),
// marketplace apps logos
...getMarketplaceAppsLogosOrigins().map((url) => url.host),
...marketplaceAppsHosts.logos,
// ad
'servedbyadbutler.com',
......@@ -167,7 +166,7 @@ function makePolicyMap() {
],
'frame-src': [
...getMarketplaceAppsOrigins(),
...marketplaceAppsHosts.frames,
// ad
'request-global.czilladx.com',
......
......@@ -14,14 +14,6 @@ export default function useGetCsrfToken() {
const url = buildUrl('csrf');
const apiResponse = await fetch(url, { credentials: 'include' });
const csrfFromHeader = apiResponse.headers.get('x-bs-account-csrf');
// eslint-disable-next-line no-console
console.log('>>> RESPONSE HEADERS <<<');
// eslint-disable-next-line no-console
console.table([ {
'content-length': apiResponse.headers.get('content-length'),
'x-bs-account-csrf': csrfFromHeader,
} ]);
return csrfFromHeader ? { token: csrfFromHeader } : undefined;
}
......
......@@ -7,14 +7,16 @@ import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg';
import appsIcon from 'icons/apps.svg';
import blocksIcon from 'icons/block.svg';
import globeIcon from 'icons/globe-b.svg';
// import gearIcon from 'icons/gear.svg';
import privateTagIcon from 'icons/privattags.svg';
import profileIcon from 'icons/profile.svg';
import publicTagIcon from 'icons/publictags.svg';
import statsIcon from 'icons/stats.svg';
import tokensIcon from 'icons/token.svg';
import topAccountsIcon from 'icons/top-accounts.svg';
import transactionsIcon from 'icons/transactions.svg';
import walletIcon from 'icons/wallet.svg';
// import verifiedIcon from 'icons/verified.svg';
import watchlistIcon from 'icons/watchlist.svg';
import notEmpty from 'lib/notEmpty';
......@@ -26,24 +28,44 @@ export interface NavItem {
isNewUi?: boolean;
}
export interface NavGroupItem extends Omit<NavItem, 'nextRoute'> {
subItems: Array<NavItem>;
}
interface ReturnType {
mainNavItems: Array<NavItem>;
mainNavItems: Array<NavItem | NavGroupItem>;
accountNavItems: Array<NavItem>;
profileItem: NavItem;
}
export function isGroupItem(item: NavItem | NavGroupItem): item is NavGroupItem {
return 'subItems' in item;
}
export default function useNavItems(): ReturnType {
const isMarketplaceFilled = appConfig.marketplaceAppList.length > 0;
const isMarketplaceFilled = appConfig.marketplaceAppList.length > 0 && appConfig.network.rpcUrl;
const router = useRouter();
const pathname = router.pathname;
return React.useMemo(() => {
const mainNavItems = [
const blockchainNavItems: Array<NavItem> = [
{ text: 'Top accounts', nextRoute: { pathname: '/accounts' as const }, icon: topAccountsIcon, isActive: pathname === '/accounts', isNewUi: true },
{ text: 'Blocks', nextRoute: { pathname: '/blocks' as const }, icon: blocksIcon, isActive: pathname.startsWith('/block'), isNewUi: true },
{ text: 'Transactions', nextRoute: { pathname: '/txs' as const }, icon: transactionsIcon, isActive: pathname.startsWith('/tx'), isNewUi: true },
// eslint-disable-next-line max-len
// { text: 'Verified contracts', nextRoute: { pathname: '/verified_contracts' as const }, icon: verifiedIcon, isActive: pathname === '/verified_contracts', isNewUi: false },
];
const mainNavItems = [
{
text: 'Blockchain',
icon: globeIcon,
isActive: blockchainNavItems.some(item => item.isActive),
isNewUi: true,
subItems: blockchainNavItems,
},
{ text: 'Tokens', nextRoute: { pathname: '/tokens' as const }, icon: tokensIcon, isActive: pathname.startsWith('/token'), isNewUi: true },
{ text: 'Accounts', nextRoute: { pathname: '/accounts' as const }, icon: walletIcon, isActive: pathname === '/accounts', isNewUi: true },
isMarketplaceFilled ?
{ text: 'Apps', nextRoute: { pathname: '/apps' as const }, icon: appsIcon, isActive: pathname.startsWith('/app'), isNewUi: true } : null,
{ text: 'Charts & stats', nextRoute: { pathname: '/stats' as const }, icon: statsIcon, isActive: pathname === '/stats', isNewUi: true },
......
......@@ -36,8 +36,10 @@ export function middleware(req: NextRequest) {
/**
* Configure which routes should pass through the Middleware.
* Exclude all `_next` urls.
*/
export const config = {
matcher: [ '/', '/:notunderscore((?!_next).+)' ],
// matcher: [
// '/((?!.*\\.|api\\/|node-api\\/).*)', // exclude all static + api + node-api routes
// ],
};
......@@ -34,6 +34,11 @@ const moduleExports = {
redirects,
headers,
output: 'standalone',
api: {
// disable body parser since we use next.js api only for local development and as a proxy
// otherwise it is impossible to upload large files (over 1Mb)
bodyParser: false,
},
};
module.exports = withRoutes(moduleExports);
......@@ -4,6 +4,7 @@ import { WebSocketServer } from 'ws';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import type { NewBlockSocketResponse } from 'types/api/block';
import type { SmartContractVerificationResponse } from 'types/api/contract';
type ReturnType = () => Promise<WebSocket>;
......@@ -59,6 +60,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_bal
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'transaction', payload: { transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'pending_transaction', payload: { pending_transaction: number }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block', payload: NewBlockSocketResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
socket.send(JSON.stringify([
...channel,
......
*,
*::after,
*::before {
transition-delay: 0s !important;
transition-duration: 0s !important;
animation-delay: -0.0001s !important;
animation-duration: 0s !important;
animation-play-state: paused !important;
}
\ No newline at end of file
import './fonts.css';
import './index.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import _defaultsDeep from 'lodash/defaultsDeep';
import MockDate from 'mockdate';
......
......@@ -42,7 +42,7 @@ export interface TokenInstance {
image_url: string | null;
animation_url: string | null;
external_app_url: string | null;
metadata: unknown;
metadata: Record<string, unknown> | null;
owner: AddressParam;
token: TokenInfo;
}
......
......@@ -20,7 +20,9 @@ interface Props {
addressHash?: string;
}
export const currentChain: Chain = {
const { wagmiClient, ethereumClient } = (() => {
try {
const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
......@@ -40,20 +42,25 @@ export const currentChain: Chain = {
url: appConfig.baseUrl,
},
},
};
};
const chains = [ currentChain ];
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
const ethereumClient = new EthereumClient(wagmiClient, chains);
return { wagmiClient, ethereumClient };
} catch (error) {
return { wagmiClient: undefined, ethereumClient: undefined };
}
})();
const TAB_LIST_PROPS = {
columnGap: 3,
......@@ -61,6 +68,12 @@ const TAB_LIST_PROPS = {
const AddressContract = ({ addressHash, tabs }: Props) => {
const modalZIndex = useToken<string>('zIndices', 'modal');
const web3ModalTheme = useColorModeValue('light', 'dark');
const noProviderTabs = React.useMemo(() => tabs.filter(({ id }) => id === 'contact_code'), [ tabs ]);
if (!wagmiClient || !ethereumClient) {
return <RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>;
}
return (
<WagmiConfig client={ wagmiClient }>
......@@ -71,7 +84,7 @@ const AddressContract = ({ addressHash, tabs }: Props) => {
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ useColorModeValue('light', 'dark') }
themeMode={ web3ModalTheme }
themeBackground="themeColor"
/>
</WagmiConfig>
......
......@@ -11,6 +11,7 @@ import * as countersMock from 'mocks/address/counters';
import * as tokensMock from 'mocks/address/tokens';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder';
import AddressDetails from './AddressDetails';
import MockAddressPage from './testUtils/MockAddressPage';
......@@ -44,6 +45,8 @@ test('contract +@mobile', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
......@@ -82,6 +85,8 @@ test('token', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
......@@ -102,5 +107,7 @@ test('validator +@mobile', async({ mount, page }) => {
{ hooksConfig },
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
......@@ -16,6 +16,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import LinkExternal from 'ui/shared/LinkExternal';
import LinkInternal from 'ui/shared/LinkInternal';
......@@ -193,6 +194,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
</LinkInternal>
</DetailsInfoItem>
) }
<DetailsSponsoredItem/>
</Grid>
</Box>
);
......
......@@ -35,14 +35,24 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderC
setId((id) => id + 1);
}, []);
if (data.length === 0) {
return null;
}
return (
<>
<Flex mb={ 3 }>
<Box fontWeight={ 500 }>Contract information</Box>
<Link onClick={ handleExpandAll } ml="auto">{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all</Link>
<Link onClick={ handleReset } ml={ 3 }>Reset</Link>
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => {
return (
<AccordionItem key={ index } as="section" _first={{ borderTopWidth: '0', '.chakra-accordion__button': { pr: '150px' } }}>
<AccordionItem key={ index } as="section" _first={{ borderTopWidth: '0' }}>
<h2>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left">
<Box as="span" fontFamily="heading" fontWeight={ 500 } fontSize="lg" mr={ 1 }>
<Box as="span" fontWeight={ 500 } mr={ 1 }>
{ index + 1 }. { item.type === 'fallback' || item.type === 'receive' ? item.type : item.name }
</Box>
{ item.type === 'fallback' && (
......@@ -72,13 +82,8 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderC
</AccordionItem>
);
}) }
{ data.length > 0 && (
<Flex columnGap={ 3 } position="absolute" top={ 0 } right={ 0 } py={ 3 } lineHeight="27px">
<Link onClick={ handleExpandAll }>{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all</Link>
<Link onClick={ handleReset }>Reset</Link>
</Flex>
) }
</Accordion>
</>
);
};
......
......@@ -118,7 +118,7 @@ const BlockDetails = () => {
hint="The number of transactions in the block"
>
<LinkInternal href={ route({ pathname: '/block/[height]', query: { height, tab: 'txs' } }) }>
{ data.tx_count } transactions
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
</DetailsInfoItem>
<DetailsInfoItem
......@@ -274,18 +274,18 @@ const BlockDetails = () => {
<DetailsInfoItem
title="Difficulty"
hint={ `Block difficulty for ${ validatorTitle }, used to calibrate block generation time` }
whiteSpace="normal"
wordBreak="break-all"
>
{ BigNumber(data.difficulty).toFormat() }
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ BigNumber(data.difficulty).toFormat() }/>
</Box>
</DetailsInfoItem>
<DetailsInfoItem
title="Total difficulty"
hint="Total difficulty of the chain until this block"
whiteSpace="normal"
wordBreak="break-all"
>
{ BigNumber(data.total_difficulty).toFormat() }
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ BigNumber(data.total_difficulty).toFormat() }/>
</Box>
</DetailsInfoItem>
{ sectionGap }
......
......@@ -3,6 +3,7 @@ import React from 'react';
import type { SmartContractVerificationConfig } from 'types/api/contract';
import * as socketServer from 'playwright/fixtures/socketServer';
import TestApp from 'playwright/TestApp';
import ContractVerificationForm from './ContractVerificationForm';
......@@ -34,6 +35,7 @@ const formConfig: SmartContractVerificationConfig = {
'sourcify',
'multi-part',
'vyper-code',
'vyper-multi-part',
],
vyper_compiler_versions: [
'v0.3.7+commit.6020b8bb',
......@@ -60,8 +62,9 @@ test('flatten source code method +@dark-mode +@mobile', async({ mount, page }) =
{ hooksConfig },
);
await page.getByText(/flattened source code/i).click();
await page.getByText(/optimization enabled/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /flattened source code/i }).click();
await page.getByText(/add contract libraries/i).click();
await page.locator('button[aria-label="add"]').click();
......@@ -76,21 +79,32 @@ test('standard input json method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/via standard/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /standard json input/i }).click();
await expect(component).toHaveScreenshot();
});
test('sourcify method +@dark-mode +@mobile', async({ mount, page }) => {
test.describe('sourcify', () => {
const testWithSocket = test.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
testWithSocket.describe.configure({ mode: 'serial', timeout: 20_000 });
testWithSocket('with multiple contracts +@mobile', async({ mount, page, createSocket }) => {
const component = await mount(
<TestApp>
<TestApp withSocket>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText(/via sourcify/i).click();
await page.getByText(/upload files/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /sourcify/i }).click();
await page.getByText(/drop files/i).click();
await page.locator('input[name="sources"]').setInputFiles([
'./playwright/mocks/file_mock_1.json',
'./playwright/mocks/file_mock_2.json',
......@@ -98,6 +112,28 @@ test('sourcify method +@dark-mode +@mobile', async({ mount, page }) => {
]);
await expect(component).toHaveScreenshot();
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, `addresses:${ hash.toLowerCase() }`);
await page.getByRole('button', { name: /verify/i }).click();
socketServer.sendMessage(socket, channel, 'verification_result', {
status: 'error',
errors: {
// eslint-disable-next-line max-len
files: [ 'Detected 5 contracts (ERC20, IERC20, IERC20Metadata, Context, MockERC20), but can only verify 1 at a time. Please choose a main contract and click Verify again.' ],
},
});
await component.getByLabel(/contract name/i).focus();
await component.getByLabel(/contract name/i).type('e');
const contractNameOption = page.getByRole('button', { name: /MockERC20/i });
await expect(contractNameOption).toBeVisible();
await expect(component).toHaveScreenshot();
});
});
test('multi-part files method', async({ mount, page }) => {
......@@ -108,7 +144,9 @@ test('multi-part files method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/via multi-part files/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('solidity');
await page.getByRole('button', { name: /multi-part files/i }).click();
await expect(component).toHaveScreenshot();
});
......@@ -121,7 +159,24 @@ test('vyper contract method', async({ mount, page }) => {
{ hooksConfig },
);
await page.getByText(/vyper contract/i).click();
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('vyper');
await page.getByRole('button', { name: /contract/i }).click();
await expect(component).toHaveScreenshot();
});
test('vyper multi-part method', async({ mount, page }) => {
const component = await mount(
<TestApp>
<ContractVerificationForm config={ formConfig } hash={ hash }/>
</TestApp>,
{ hooksConfig },
);
await component.getByLabel(/verification method/i).focus();
await component.getByLabel(/verification method/i).type('vyper');
await page.getByRole('button', { name: /multi-part files/i }).click();
await expect(component).toHaveScreenshot();
});
import { Button, chakra } from '@chakra-ui/react';
import { Button, chakra, useUpdateEffect } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
......@@ -20,7 +20,7 @@ import ContractVerificationSourcify from './methods/ContractVerificationSourcify
import ContractVerificationStandardInput from './methods/ContractVerificationStandardInput';
import ContractVerificationVyperContract from './methods/ContractVerificationVyperContract';
import ContractVerificationVyperMultiPartFile from './methods/ContractVerificationVyperMultiPartFile';
import { prepareRequestBody, formatSocketErrors } from './utils';
import { prepareRequestBody, formatSocketErrors, DEFAULT_VALUES } from './utils';
const METHOD_COMPONENTS = {
'flattened-code': <ContractVerificationFlattenSourceCode/>,
......@@ -40,11 +40,9 @@ interface Props {
const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
method: methodFromQuery,
},
defaultValues: methodFromQuery ? DEFAULT_VALUES[methodFromQuery] : undefined,
});
const { control, handleSubmit, watch, formState, setError } = formApi;
const { control, handleSubmit, watch, formState, setError, reset } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const apiFetch = useApiFetch();
......@@ -56,7 +54,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
try {
await apiFetch('contract_verification_via', {
pathParams: { method: data.method, hash },
pathParams: { method: data.method.value, hash: hash.toLowerCase() },
fetchParams: {
method: 'POST',
body,
......@@ -109,7 +107,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
variant: 'subtle',
isClosable: true,
});
}, [ formState.isSubmitting, toast ]);
// callback should not change when form is submitted
// otherwise it will resubscribe to channel, but we don't want that since in that case we might miss verification result message
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ toast ]);
const channel = useSocketChannel({
topic: `addresses:${ hash.toLowerCase() }`,
......@@ -124,7 +125,15 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
});
const method = watch('method');
const content = METHOD_COMPONENTS[method] || null;
const content = METHOD_COMPONENTS[method?.value] || null;
const methodValue = method?.value;
useUpdateEffect(() => {
if (methodValue) {
reset(DEFAULT_VALUES[methodValue]);
}
// !!! should run only when method is changed
}, [ methodValue ]);
return (
<FormProvider { ...formApi }>
......@@ -134,8 +143,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
>
<ContractVerificationFieldMethod
control={ control }
isDisabled={ Boolean(method) }
methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
{ content }
{ Boolean(method) && (
......
......@@ -15,8 +15,8 @@ const ContractVerificationMethod = ({ title, children }: Props) => {
return (
<section ref={ ref }>
<Text variant="secondary" mt={ 12 } mb={ 5 } fontSize="sm">{ title }</Text>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 4 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
<Text fontWeight={ 500 } fontSize="lg" fontFamily="heading" mt={ 12 } mb={ 5 }>{ title }</Text>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(0, 680px) minmax(0, 340px)' }}>
{ children }
</Grid>
</section>
......
......@@ -34,7 +34,6 @@ const ContractVerificationFieldAutodetectArgs = () => {
name="autodetect_constructor_args"
control={ control }
render={ renderControl }
defaultValue={ true }
/>
</ContractVerificationFormRow>
{ !isOn && <ContractVerificationFieldConstructorArgs/> }
......
......@@ -41,7 +41,6 @@ const ContractVerificationFieldCode = ({ isVyper }: Props) => {
control={ control }
render={ renderControl }
rules={{ required: true }}
defaultValue=""
/>
{ isVyper ? null : (
<>
......
import { Code } from '@chakra-ui/react';
import { chakra, Checkbox, Code } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
......@@ -20,26 +20,30 @@ interface Props {
}
const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const [ isNightly, setIsNightly ] = React.useState(false);
const { formState, control, getValues, resetField } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const config = queryClient.getQueryData<SmartContractVerificationConfig>(getResourceKey('contract_verification_config'));
const handleCheckboxChange = React.useCallback(() => {
if (isNightly) {
const field = getValues('compiler');
field?.value.includes('nightly') && resetField('compiler', { defaultValue: null });
}
setIsNightly(prev => !prev);
}, [ getValues, isNightly, resetField ]);
const options = React.useMemo(() => (
(isVyper ? config?.vyper_compiler_versions : config?.solidity_compiler_versions)?.map((option) => ({ label: option, value: option })) || []
), [ config?.solidity_compiler_versions, config?.vyper_compiler_versions, isVyper ]);
const loadOptions = React.useCallback(async(inputValue: string) => {
return options
.filter(({ label }) => {
if (!inputValue) {
return !label.toLowerCase().includes('nightly');
}
return label.toLowerCase().includes(inputValue.toLowerCase());
})
.filter(({ label }) => !inputValue || label.toLowerCase().includes(inputValue.toLowerCase()))
.filter(({ label }) => isNightly ? true : !label.includes('nightly'))
.slice(0, OPTIONS_LIMIT);
}, [ options ]);
}, [ isNightly, options ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'compiler'>}) => {
const error = 'compiler' in formState.errors ? formState.errors.compiler : undefined;
......@@ -61,20 +65,32 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
return (
<ContractVerificationFormRow>
<>
{ !isVyper && (
<Checkbox
size="lg"
mb={ 2 }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
>
Include nightly builds
</Checkbox>
) }
<Controller
name="compiler"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</>
{ isVyper ? null : (
<>
<span>The compiler version is specified in </span>
<chakra.div mt={{ base: 0, lg: 8 }}>
<span >The compiler version is specified in </span>
<Code color="text_secondary">pragma solidity X.X.X</Code>
<span>. Use the compiler version rather than the nightly build. If using the Solidity compiler, run </span>
<Code color="text_secondary">solc —version</Code>
<span> to check.</span>
</>
</chakra.div>
) }
</ContractVerificationFormRow>
);
......
import { useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { Option } from 'ui/shared/FancySelect/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const SOURCIFY_ERROR_REGEXP = /\(([^()]*)\)/;
const ContractVerificationFieldContractIndex = () => {
const [ options, setOptions ] = React.useState<Array<Option>>([]);
const { formState, control, watch } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const sources = watch('sources');
const sourcesError = 'sources' in formState.errors ? formState.errors.sources?.message : undefined;
useUpdateEffect(() => {
if (!sourcesError) {
return;
}
const matchResult = sourcesError.match(SOURCIFY_ERROR_REGEXP);
const parsedMethods = matchResult?.[1].split(',');
if (!Array.isArray(parsedMethods) || parsedMethods.length === 0) {
return;
}
const newOptions = parsedMethods.map((option, index) => ({ label: option, value: String(index + 1) }));
setOptions(newOptions);
}, [ sourcesError ]);
useUpdateEffect(() => {
setOptions([]);
}, [ sources ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'contract_index'>}) => {
const error = 'contract_index' in formState.errors ? formState.errors.contract_index : undefined;
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Contract name"
isDisabled={ formState.isSubmitting }
error={ error }
isRequired
isAsync={ false }
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, options ]);
if (options.length === 0) {
return null;
}
return (
<ContractVerificationFormRow>
<Controller
name="contract_index"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</ContractVerificationFormRow>
);
};
export default React.memo(ContractVerificationFieldContractIndex);
import { Checkbox } from '@chakra-ui/react';
import { Checkbox, useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
......@@ -8,13 +8,21 @@ import ContractVerificationFormRow from '../ContractVerificationFormRow';
import ContractVerificationFieldLibraryItem from './ContractVerificationFieldLibraryItem';
const ContractVerificationFieldLibraries = () => {
const { formState, control } = useFormContext<FormFields>();
const { formState, control, getValues } = useFormContext<FormFields>();
const { fields, append, remove, insert } = useFieldArray({
name: 'libraries',
control,
});
const [ isEnabled, setIsEnabled ] = React.useState(fields.length > 0);
const value = getValues('libraries');
useUpdateEffect(() => {
if (!value || value.length === 0) {
setIsEnabled(false);
}
}, [ value ]);
const handleCheckboxChange = React.useCallback(() => {
if (!isEnabled) {
append({ name: '', address: '' });
......
import {
RadioGroup,
Radio,
Stack,
Text,
Link,
Icon,
chakra,
......@@ -14,16 +10,23 @@ import {
PopoverBody,
useColorModeValue,
DarkMode,
useBoolean,
ListItem,
OrderedList,
Grid,
Box,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationMethod, SmartContractVerificationConfig } from 'types/api/contract';
import type { SmartContractVerificationConfig, SmartContractVerificationMethod } from 'types/api/contract';
import infoIcon from 'icons/info.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import { METHOD_LABELS } from '../utils';
interface Props {
control: Control<FormFields>;
......@@ -32,91 +35,93 @@ interface Props {
}
const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props) => {
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean();
const tooltipBg = useColorModeValue('gray.700', 'gray.900');
const isMobile = useIsMobile();
const options = React.useMemo(() => methods.map((method) => ({
value: method,
label: METHOD_LABELS[method],
})), [ methods ]);
const renderItem = React.useCallback((method: SmartContractVerificationMethod) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'method'>}) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Verification method (compiler type)"
isDisabled={ isDisabled }
isRequired
isAsync={ false }
/>
);
}, [ isDisabled, isMobile, options ]);
const renderPopoverListItem = React.useCallback((method: SmartContractVerificationMethod) => {
switch (method) {
case 'flattened-code':
return 'Via flattened source code';
return <ListItem>Verification through flattened source code.</ListItem>;
case 'multi-part':
return <ListItem>Verification of multi-part Solidity files.</ListItem>;
case 'sourcify':
return <ListItem>Verification through <Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>.</ListItem>;
case 'standard-input':
return (
<>
<span>Via standard </span>
<ListItem>
<span>Verification using </span>
<Link
href={ isDisabled ? undefined : 'https://docs.soliditylang.org/en/latest/using-the-compiler.html#input-description' }
href="https://docs.soliditylang.org/en/latest/using-the-compiler.html#input-description"
target="_blank"
cursor={ isDisabled ? 'not-allowed' : 'pointer' }
>
Input JSON
Standard input JSON
</Link>
</>
<span> file.</span>
</ListItem>
);
case 'sourcify':
case 'vyper-code':
return <ListItem>Verification of Vyper contract.</ListItem>;
case 'vyper-multi-part':
return <ListItem>Verification of multi-part Vyper files.</ListItem>;
}
}, []);
return (
<>
<span>Via sourcify: sources and metadata JSON file</span>
<Popover trigger="hover" isLazy isOpen={ isDisabled ? false : isPopoverOpen } onOpen={ setIsPopoverOpen.on } onClose={ setIsPopoverOpen.off }>
<section>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 4 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
<div>
<Box mb={ 5 }>
<chakra.span fontWeight={ 500 } fontSize="lg" fontFamily="heading">
Currently, Blockscout supports { methods.length } contract verification methods
</chakra.span>
<Popover trigger="hover" isLazy placement={ isMobile ? 'bottom-end' : 'right-start' } offset={ [ -8, 8 ] }>
<PopoverTrigger>
<chakra.span cursor={ isDisabled ? 'not-allowed' : 'pointer' } display="inline-block" verticalAlign="middle" h="24px" ml={ 1 }>
<chakra.span display="inline-block" ml={ 1 } cursor="pointer" verticalAlign="middle" h="22px">
<Icon as={ infoIcon } boxSize={ 5 } color="link" _hover={{ color: 'link_hovered' }}/>
</chakra.span>
</PopoverTrigger>
<Portal>
<PopoverContent bgColor={ tooltipBg }>
<PopoverContent bgColor={ tooltipBg } w={{ base: '300px', lg: '380px' }}>
<PopoverArrow bgColor={ tooltipBg }/>
<PopoverBody color="white">
<DarkMode>
<div>
<span>Verification through </span>
<Link href="https://sourcify.dev/" target="_blank">Sourcify</Link>
</div>
<div>
<span>a) if smart-contract already verified on Sourcify, it will automatically fetch the data from the </span>
<Link href="https://repo.sourcify.dev/" target="_blank">repo</Link>
</div>
<div>
b) otherwise you will be asked to upload source files and JSON metadata file(s).
</div>
<span>Currently, Blockscout supports { methods.length } methods:</span>
<OrderedList>
{ methods.map(renderPopoverListItem) }
</OrderedList>
</DarkMode>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</>
);
case 'multi-part':
return 'Via multi-part files';
case 'vyper-code':
return 'Vyper contract';
case 'vyper-multi-part':
return 'Via multi-part Vyper files';
default:
break;
}
}, [ isDisabled, isPopoverOpen, setIsPopoverOpen.off, setIsPopoverOpen.on, tooltipBg ]);
const renderRadioGroup = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'method'>}) => {
return (
<RadioGroup defaultValue="add" colorScheme="blue" isDisabled={ isDisabled } isFocusable={ !isDisabled } { ...field } >
<Stack spacing={ 4 }>
{ methods.map((method) => {
return <Radio key={ method } value={ method } size="lg">{ renderItem(method) }</Radio>;
}) }
</Stack>
</RadioGroup>
);
}, [ isDisabled, methods, renderItem ]);
return (
<section>
<Text variant="secondary" fontSize="sm" mb={ 5 }>Smart-contract verification method</Text>
</Box>
<Controller
name="method"
control={ control }
render={ renderRadioGroup }
render={ renderControl }
rules={{ required: true }}
/>
</div>
</Grid>
</section>
);
};
......
......@@ -41,7 +41,6 @@ const ContractVerificationFieldName = ({ hint }: Props) => {
control={ control }
render={ renderControl }
rules={{ required: true }}
defaultValue=""
/>
{ hint ? <span>{ hint }</span> : (
<>
......
import { FormControl, Input } from '@chakra-ui/react';
import { Flex, Input } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
......@@ -6,7 +6,6 @@ import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import CheckboxInput from 'ui/shared/CheckboxInput';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -14,56 +13,59 @@ const ContractVerificationFieldOptimization = () => {
const [ isEnabled, setIsEnabled ] = React.useState(true);
const { formState, control } = useFormContext<FormFields>();
const error = 'optimization_runs' in formState.errors ? formState.errors.optimization_runs : undefined;
const handleCheckboxChange = React.useCallback(() => {
setIsEnabled(prev => !prev);
}, []);
const renderCheckboxControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'is_optimization_enabled'>}) => (
<Flex flexShrink={ 0 }>
<CheckboxInput<FormFields, 'is_optimization_enabled'>
text="Optimization enabled"
field={ field }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
/>
</Flex>
), [ formState.isSubmitting, handleCheckboxChange ]);
const renderInputControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'optimization_runs'>}) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
required
isDisabled={ formState.isSubmitting }
autoComplete="off"
type="number"
placeholder="Optimization runs"
size="xs"
minW="100px"
maxW="200px"
flexShrink={ 1 }
isInvalid={ Boolean(error) }
/>
<InputPlaceholder text="Optimization runs"/>
</FormControl>
);
}, [ formState.isSubmitting ]);
}, [ error, formState.isSubmitting ]);
return (
<>
<ContractVerificationFormRow>
<Flex columnGap={ 5 } h={{ base: 'auto', lg: '32px' }}>
<Controller
name="is_optimization_enabled"
control={ control }
render={ renderCheckboxControl }
defaultValue={ true }
/>
</ContractVerificationFormRow>
{ isEnabled && (
<ContractVerificationFormRow>
<Controller
name="optimization_runs"
control={ control }
render={ renderInputControl }
rules={{ required: true }}
defaultValue="200"
/>
</ContractVerificationFormRow>
) }
</>
</Flex>
</ContractVerificationFormRow>
);
};
......
import { Text, Button, Box, chakra, Flex } from '@chakra-ui/react';
import { Text, Button, Box, Flex } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldPathValue, ValidateResult } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
......@@ -6,7 +6,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { Mb } from 'lib/consts';
// import DragAndDropArea from 'ui/shared/forms/DragAndDropArea';
import DragAndDropArea from 'ui/shared/forms/DragAndDropArea';
import FieldError from 'ui/shared/forms/FieldError';
import FileInput from 'ui/shared/forms/FileInput';
import FileSnippet from 'ui/shared/forms/FileSnippet';
......@@ -19,11 +19,10 @@ interface Props {
fileTypes: Array<FileTypes>;
multiple?: boolean;
title: string;
className?: string;
hint: string;
}
const ContractVerificationFieldSources = ({ fileTypes, multiple, title, className, hint }: Props) => {
const ContractVerificationFieldSources = ({ fileTypes, multiple, title, hint }: Props) => {
const { setValue, getValues, control, formState, clearErrors } = useFormContext<FormFields>();
const error = 'sources' in formState.errors ? formState.errors.sources : undefined;
......@@ -42,11 +41,28 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
}, [ getValues, clearErrors, setValue ]);
const renderUploadButton = React.useCallback(() => {
return (
<div>
<Text fontWeight={ 500 } color="text_secondary" mb={ 3 }>{ title }</Text>
<Button size="sm" variant="outline">
Drop file{ multiple ? 's' : '' } or click here
</Button>
</div>
);
}, [ multiple, title ]);
const renderFiles = React.useCallback((files: Array<File>) => {
const errorList = fileError?.message?.split(';');
return (
<Box display="grid" gridTemplateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(0, 1fr) minmax(0, 1fr)' }} columnGap={ 3 } rowGap={ 3 }>
<Box
display="grid"
gridTemplateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(0, 1fr) minmax(0, 1fr)' }}
columnGap={ 3 }
rowGap={ 3 }
w="100%"
>
{ files.map((file, index) => (
<Box key={ file.name + file.lastModified + index }>
<FileSnippet
......@@ -55,42 +71,36 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
onRemove={ handleFileRemove }
index={ index }
isDisabled={ formState.isSubmitting }
error={ errorList?.[index] }
/>
{ errorList?.[index] && <FieldError message={ errorList?.[index] } mt={ 1 } px={ 3 }/> }
</Box>
)) }
</Box>
);
}, [ formState.isSubmitting, handleFileRemove, fileError ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'sources'>}) => (
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'sources'>}) => {
const hasValue = field.value && field.value.length > 0;
return (
<>
<FileInput<FormFields, 'sources'> accept={ fileTypes.join(',') } multiple={ multiple } field={ field }>
{ () => (
{ ({ onChange }) => (
<Flex
flexDir="column"
alignItems="flex-start"
rowGap={ 2 }
w="100%"
display={ field.value && field.value.length > 0 && !multiple ? 'none' : 'block' }
mb={ field.value && field.value.length > 0 ? 2 : 0 }
>
<Button
variant="outline"
size="sm"
// mb={ 2 }
>
Upload file{ multiple ? 's' : '' }
</Button>
{ /* design is not ready */ }
{ /* <DragAndDropArea onDrop={ onChange }/> */ }
<DragAndDropArea onDrop={ onChange } p={{ base: 3, lg: 6 }} isDisabled={ formState.isSubmitting }>
{ hasValue ? renderFiles(field.value) : renderUploadButton() }
</DragAndDropArea>
</Flex>
) }
</FileInput>
{ field.value && field.value.length > 0 && renderFiles(field.value) }
{ commonError?.message && <FieldError message={ commonError.type === 'required' ? 'Field is required' : commonError.message }/> }
</>
), [ fileTypes, commonError, multiple, renderFiles ]);
);
}, [ fileTypes, multiple, commonError, formState.isSubmitting, renderFiles, renderUploadButton ]);
const validateFileType = React.useCallback(async(value: FieldPathValue<FormFields, 'sources'>): Promise<ValidateResult> => {
if (Array.isArray(value)) {
......@@ -114,19 +124,24 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
return true;
}, []);
const validateQuantity = React.useCallback(async(value: FieldPathValue<FormFields, 'sources'>): Promise<ValidateResult> => {
if (!multiple && Array.isArray(value) && value.length > 1) {
return 'You can upload only one file';
}
return true;
}, [ multiple ]);
const rules = React.useMemo(() => ({
required: true,
validate: {
file_type: validateFileType,
file_size: validateFileSize,
quantity: validateQuantity,
},
}), [ validateFileSize, validateFileType ]);
}), [ validateFileSize, validateFileType, validateQuantity ]);
return (
<>
<ContractVerificationFormRow >
<Text fontWeight={ 500 } className={ className } mt={ 4 }>{ title }</Text>
</ContractVerificationFormRow>
<ContractVerificationFormRow>
<Controller
name="sources"
......@@ -136,8 +151,7 @@ const ContractVerificationFieldSources = ({ fileTypes, multiple, title, classNam
/>
{ hint ? <span>{ hint }</span> : null }
</ContractVerificationFormRow>
</>
);
};
export default React.memo(chakra(ContractVerificationFieldSources));
export default React.memo(ContractVerificationFieldSources);
......@@ -12,9 +12,9 @@ import ContractVerificationFieldOptimization from '../fields/ContractVerificatio
const ContractVerificationFlattenSourceCode = () => {
return (
<ContractVerificationMethod title="New Solidity/Yul Smart Contract Verification">
<ContractVerificationFieldIsYul/>
<ContractVerificationMethod title="Contract verification via Solidity (fattened source code)">
<ContractVerificationFieldName/>
<ContractVerificationFieldIsYul/>
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldEvmVersion/>
<ContractVerificationFieldOptimization/>
......
......@@ -11,7 +11,7 @@ const FILE_TYPES = [ '.sol' as const, '.yul' as const ];
const ContractVerificationMultiPartFile = () => {
return (
<ContractVerificationMethod title="New Solidity/Yul Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (multi-part files)">
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldEvmVersion/>
<ContractVerificationFieldOptimization/>
......
import React from 'react';
import ContractVerificationMethod from '../ContractVerificationMethod';
import ContractVerificationFieldContractIndex from '../fields/ContractVerificationFieldContractIndex';
import ContractVerificationFieldSources from '../fields/ContractVerificationFieldSources';
const FILE_TYPES = [ '.json' as const, '.sol' as const ];
const ContractVerificationSourcify = () => {
return (
<ContractVerificationMethod title="New Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (Sourcify)">
<ContractVerificationFieldSources
fileTypes={ FILE_TYPES }
multiple
title="Sources and Metadata JSON" mt={ 0 }
title="Sources and Metadata JSON"
hint="Upload all Solidity contract source files and JSON metadata file(s) created during contract compilation."
/>
<ContractVerificationFieldContractIndex/>
</ContractVerificationMethod>
);
};
......
......@@ -10,7 +10,7 @@ const FILE_TYPES = [ '.json' as const ];
const ContractVerificationStandardInput = () => {
return (
<ContractVerificationMethod title="New Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Solidity (standard JSON input) ">
<ContractVerificationFieldName/>
<ContractVerificationFieldCompiler/>
<ContractVerificationFieldSources
......
......@@ -8,7 +8,7 @@ import ContractVerificationFieldName from '../fields/ContractVerificationFieldNa
const ContractVerificationVyperContract = () => {
return (
<ContractVerificationMethod title="New Vyper Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Vyper (contract)">
<ContractVerificationFieldName hint="Must match the name specified in the code."/>
<ContractVerificationFieldCompiler isVyper/>
<ContractVerificationFieldCode isVyper/>
......
......@@ -9,7 +9,7 @@ const FILE_TYPES = [ '.vy' as const ];
const ContractVerificationVyperMultiPartFile = () => {
return (
<ContractVerificationMethod title="New Vyper Smart Contract Verification">
<ContractVerificationMethod title="Contract verification via Vyper (multi-part files)">
<ContractVerificationFieldCompiler isVyper/>
<ContractVerificationFieldEvmVersion isVyper/>
<ContractVerificationFieldSources
......
import type { SmartContractVerificationMethod } from 'types/api/contract';
import type { Option } from 'ui/shared/FancySelect/types';
export interface ContractLibrary {
name: string;
address: string;
}
interface MethodOption {
label: string;
value: SmartContractVerificationMethod;
}
export interface FormFieldsFlattenSourceCode {
method: 'flattened-code';
method: MethodOption;
is_yul: boolean;
name: string;
compiler: Option;
......@@ -19,7 +26,7 @@ export interface FormFieldsFlattenSourceCode {
}
export interface FormFieldsStandardInput {
method: 'standard-input';
method: MethodOption;
name: string;
compiler: Option;
sources: Array<File>;
......@@ -28,12 +35,13 @@ export interface FormFieldsStandardInput {
}
export interface FormFieldsSourcify {
method: 'sourcify';
method: MethodOption;
sources: Array<File>;
contract_index?: Option;
}
export interface FormFieldsMultiPartFile {
method: 'multi-part';
method: MethodOption;
compiler: Option;
evm_version: Option;
is_optimization_enabled: boolean;
......@@ -43,7 +51,7 @@ export interface FormFieldsMultiPartFile {
}
export interface FormFieldsVyperContract {
method: 'vyper-code';
method: MethodOption;
name: string;
compiler: Option;
code: string;
......@@ -51,7 +59,7 @@ export interface FormFieldsVyperContract {
}
export interface FormFieldsVyperMultiPartFile {
method: 'vyper-multi-part';
method: MethodOption;
compiler: Option;
evm_version: Option;
sources: Array<File>;
......
import type { FieldPath, ErrorOption } from 'react-hook-form';
import type { ContractLibrary, FormFields } from './types';
import type {
ContractLibrary,
FormFields,
FormFieldsFlattenSourceCode,
FormFieldsMultiPartFile,
FormFieldsSourcify,
FormFieldsStandardInput,
FormFieldsVyperContract,
FormFieldsVyperMultiPartFile,
} from './types';
import type { SmartContractVerificationMethod, SmartContractVerificationError } from 'types/api/contract';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
......@@ -14,6 +23,83 @@ export const SUPPORTED_VERIFICATION_METHODS: Array<SmartContractVerificationMeth
'vyper-multi-part',
];
export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
'flattened-code': 'Solidity (Flattened source code)',
'standard-input': 'Solidity (Standard JSON input)',
sourcify: 'Solidity (Sourcify)',
'multi-part': 'Solidity (Multi-part files)',
'vyper-code': 'Vyper (Contract)',
'vyper-multi-part': 'Vyper (Multi-part files)',
};
export const DEFAULT_VALUES = {
'flattened-code': {
method: {
value: 'flattened-code' as const,
label: METHOD_LABELS['flattened-code'],
},
is_yul: false,
name: '',
compiler: null,
evm_version: null,
is_optimization_enabled: true,
optimization_runs: '200',
code: '',
autodetect_constructor_args: true,
constructor_args: '',
libraries: [],
},
'standard-input': {
method: {
value: 'standard-input' as const,
label: METHOD_LABELS['standard-input'],
},
name: '',
compiler: null,
sources: [],
autodetect_constructor_args: true,
constructor_args: '',
},
sourcify: {
method: {
value: 'sourcify' as const,
label: METHOD_LABELS.sourcify,
},
sources: [],
},
'multi-part': {
method: {
value: 'multi-part' as const,
label: METHOD_LABELS['multi-part'],
},
compiler: null,
evm_version: null,
is_optimization_enabled: true,
optimization_runs: 200,
sources: [],
libraries: [],
},
'vyper-code': {
method: {
value: 'vyper-code' as const,
label: METHOD_LABELS['vyper-code'],
},
name: '',
compiler: null,
code: '',
constructor_args: '',
},
'vyper-multi-part': {
method: {
value: 'vyper-multi-part' as const,
label: METHOD_LABELS['vyper-multi-part'],
},
compiler: null,
evm_version: null,
sources: [],
},
};
export function isValidVerificationMethod(method?: string): method is SmartContractVerificationMethod {
return method && SUPPORTED_VERIFICATION_METHODS.includes(method as SmartContractVerificationMethod) ? true : false;
}
......@@ -34,68 +120,79 @@ export function sortVerificationMethods(methodA: SmartContractVerificationMethod
}
export function prepareRequestBody(data: FormFields): FetchParams['body'] {
switch (data.method) {
switch (data.method.value) {
case 'flattened-code': {
const _data = data as FormFieldsFlattenSourceCode;
return {
compiler_version: data.compiler?.value,
source_code: data.code,
is_optimization_enabled: data.is_optimization_enabled,
is_yul_contract: data.is_yul,
optimization_runs: data.optimization_runs,
contract_name: data.name,
libraries: reduceLibrariesArray(data.libraries),
evm_version: data.evm_version?.value,
autodetect_constructor_args: data.autodetect_constructor_args,
constructor_args: data.constructor_args,
compiler_version: _data.compiler?.value,
source_code: _data.code,
is_optimization_enabled: _data.is_optimization_enabled,
is_yul_contract: _data.is_yul,
optimization_runs: _data.optimization_runs,
contract_name: _data.name,
libraries: reduceLibrariesArray(_data.libraries),
evm_version: _data.evm_version?.value,
autodetect_constructor_args: _data.autodetect_constructor_args,
constructor_args: _data.constructor_args,
};
}
case 'standard-input': {
const _data = data as FormFieldsStandardInput;
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('contract_name', data.name);
body.set('autodetect_constructor_args', String(Boolean(data.autodetect_constructor_args)));
body.set('constructor_args', data.constructor_args);
addFilesToFormData(body, data.sources);
body.set('compiler_version', _data.compiler?.value);
body.set('contract_name', _data.name);
body.set('autodetect_constructor_args', String(Boolean(_data.autodetect_constructor_args)));
body.set('constructor_args', _data.constructor_args);
addFilesToFormData(body, _data.sources);
return body;
}
case 'sourcify': {
const _data = data as FormFieldsSourcify;
const body = new FormData();
addFilesToFormData(body, data.sources);
addFilesToFormData(body, _data.sources);
_data.contract_index && body.set('chosen_contract_index', _data.contract_index.value);
return body;
}
case 'multi-part': {
const _data = data as FormFieldsMultiPartFile;
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('evm_version', data.evm_version?.value);
body.set('is_optimization_enabled', String(Boolean(data.is_optimization_enabled)));
data.is_optimization_enabled && body.set('optimization_runs', data.optimization_runs);
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
body.set('is_optimization_enabled', String(Boolean(_data.is_optimization_enabled)));
_data.is_optimization_enabled && body.set('optimization_runs', _data.optimization_runs);
const libraries = reduceLibrariesArray(data.libraries);
const libraries = reduceLibrariesArray(_data.libraries);
libraries && body.set('libraries', JSON.stringify(libraries));
addFilesToFormData(body, data.sources);
addFilesToFormData(body, _data.sources);
return body;
}
case 'vyper-code': {
const _data = data as FormFieldsVyperContract;
return {
compiler_version: data.compiler?.value,
source_code: data.code,
contract_name: data.name,
constructor_args: data.constructor_args,
compiler_version: _data.compiler?.value,
source_code: _data.code,
contract_name: _data.name,
constructor_args: _data.constructor_args,
};
}
case 'vyper-multi-part': {
const _data = data as FormFieldsVyperMultiPartFile;
const body = new FormData();
body.set('compiler_version', data.compiler?.value);
body.set('evm_version', data.evm_version?.value);
addFilesToFormData(body, data.sources);
body.set('compiler_version', _data.compiler?.value);
body.set('evm_version', _data.evm_version?.value);
addFilesToFormData(body, _data.sources);
return body;
}
......
......@@ -20,7 +20,6 @@ import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -51,10 +50,15 @@ const AddressPageContent = () => {
});
const tags = [
addressQuery.data?.is_contract ? { label: 'contract', display_name: 'Contract' } : { label: 'eoa', display_name: 'EOA' },
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
...(addressQuery.data?.private_tags || []),
...(addressQuery.data?.public_tags || []),
...(addressQuery.data?.watchlist_names || []),
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
]
.filter(notEmpty)
.map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const contractTabs = useContractTabs(addressQuery.data);
......@@ -97,7 +101,7 @@ const AddressPageContent = () => {
return (
<Page>
<TextAd mb={ 6 }/>
{ addressQuery.isLoading ? <Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/> : <TextAd mb={ 6 }/> }
{ addressQuery.isLoading ? (
<Skeleton h={ 10 } w="260px" mb={ 6 }/>
) : (
......@@ -109,7 +113,6 @@ const AddressPageContent = () => {
/>
) }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ tabsScrollRef }></Box>
{ addressQuery.isLoading ? <SkeletonTabs/> : content }
......
import { Skeleton, Box, Flex, SkeletonCircle, Icon } from '@chakra-ui/react';
import { Skeleton, Box, Flex, SkeletonCircle, Icon, Tag } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
......@@ -13,7 +13,6 @@ import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import notEmpty from 'lib/notEmpty';
import trimTokenSymbol from 'lib/token/trimTokenSymbol';
import AddressContract from 'ui/address/AddressContract';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -157,10 +156,13 @@ const TokenPageContent = () => {
return (
<Page>
{ tokenQuery.isLoading ? (
<>
<Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/>
<Flex alignItems="center" mb={ 6 }>
<SkeletonCircle w={ 6 } h={ 6 } mr={ 3 }/>
<Skeleton w="500px" h={ 10 }/>
</Flex>
</>
) : (
<>
<TextAd mb={ 6 }/>
......@@ -171,12 +173,12 @@ const TokenPageContent = () => {
additionalsLeft={ (
<TokenLogo hash={ tokenQuery.data?.address } name={ tokenQuery.data?.name } boxSize={ 6 }/>
) }
additionalsRight={ <Tag>{ tokenQuery.data?.type }</Tag> }
/>
</>
) }
<TokenContractInfo tokenQuery={ tokenQuery }/>
<TokenDetails tokenQuery={ tokenQuery }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up whith pagination */ }
<Box ref={ scrollRef }></Box>
......
import { GridItem } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import AdBanner from 'ui/shared/ad/AdBanner';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
const DetailsSponsoredItem = () => {
const isMobile = useIsMobile();
if (isMobile) {
return (
<GridItem mt={ 5 }>
<AdBanner justifyContent="center"/>
</GridItem>
);
}
return (
<DetailsInfoItem
title="Sponsored"
hint="Sponsored banner advertisement"
>
<AdBanner/>
</DetailsInfoItem>
);
};
export default React.memo(DetailsSponsoredItem);
......@@ -26,6 +26,8 @@ type AdData = {
const CoinzillaTextAd = ({ className }: {className?: string}) => {
const [ adData, setAdData ] = React.useState<AdData | null>(null);
const [ isLoading, setIsLoading ] = React.useState(true);
useEffect(() => {
fetch('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242')
.then(res => res.status === 200 ? res.json() : null)
......@@ -34,9 +36,16 @@ const CoinzillaTextAd = ({ className }: {className?: string}) => {
if (data?.ad?.impressionUrl) {
fetch(data.ad.impressionUrl);
}
})
.finally(() => {
setIsLoading(false);
});
}, []);
if (isLoading) {
return <Box className={ className } h={{ base: 12, lg: 6 }}/>;
}
if (!adData) {
return null;
}
......@@ -52,7 +61,7 @@ const CoinzillaTextAd = ({ className }: {className?: string}) => {
mr={ 3 }
display={{ base: 'none', lg: 'inline' }}
>
ADs:
Ads:
</Text>
{ urlObject.hostname === 'nifty.ink' ?
<Text as="span" mr={ 1 }>🎨</Text> :
......
import { Box } from '@chakra-ui/react';
import { chakra, Center, useColorModeValue } from '@chakra-ui/react';
import type { DragEvent } from 'react';
import React from 'react';
import { getAllFileEntries, convertFileEntryToFile } from './utils/files';
interface Props {
children: React.ReactNode;
onDrop: (files: Array<File>) => void;
className?: string;
isDisabled?: boolean;
}
const DragAndDropArea = ({ onDrop }: Props) => {
const DragAndDropArea = ({ onDrop, children, className, isDisabled }: Props) => {
const [ isDragOver, setIsDragOver ] = React.useState(false);
const handleDrop = React.useCallback(async(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (isDisabled) {
return;
}
const fileEntries = await getAllFileEntries(event.dataTransfer.items);
const files = await Promise.all(fileEntries.map(convertFileEntryToFile));
onDrop(files);
setIsDragOver(false);
}, [ onDrop ]);
}, [ isDisabled, onDrop ]);
const handleDragOver = React.useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
......@@ -35,18 +42,39 @@ const DragAndDropArea = ({ onDrop }: Props) => {
setIsDragOver(false);
}, []);
const handleClick = React.useCallback((event: React.MouseEvent) => {
if (isDisabled) {
event.preventDefault();
event.stopPropagation();
}
}, [ isDisabled ]);
const disabledBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const borderColor = isDragOver ? 'link_hovered' : 'link';
return (
<Box
<Center
className={ className }
w="100%"
h="200px"
bgColor="lightpink"
opacity={ isDragOver ? 0.8 : 1 }
minH="120px"
borderWidth="2px"
borderColor={ isDisabled ? disabledBorderColor : borderColor }
_hover={{
borderColor: isDisabled ? disabledBorderColor : 'link_hovered',
}}
borderRadius="base"
borderStyle="dashed"
cursor="pointer"
textAlign="center"
onClick={ handleClick }
onDrop={ handleDrop }
onDragOver={ handleDragOver }
onDragEnter={ handleDragEnter }
onDragLeave={ handleDragLeave }
/>
>
{ children }
</Center>
);
};
export default React.memo(DragAndDropArea);
export default React.memo(chakra(DragAndDropArea));
......@@ -7,7 +7,7 @@ interface Props {
}
const FieldError = ({ message, className }: Props) => {
return <Box className={ className } color="error" fontSize="sm" mt={ 2 } wordBreak="break-all">{ message }</Box>;
return <Box className={ className } color="error" fontSize="sm" mt={ 2 } wordBreak="break-word">{ message }</Box>;
};
export default chakra(FieldError);
import { Box, Flex, Icon, Text, useColorModeValue, IconButton, chakra } from '@chakra-ui/react';
import { Box, Flex, Icon, Text, useColorModeValue, IconButton, chakra, Tooltip } from '@chakra-ui/react';
import React from 'react';
import CrossIcon from 'icons/cross.svg';
import imageIcon from 'icons/files/image.svg';
import jsonIcon from 'icons/files/json.svg';
import placeholderIcon from 'icons/files/placeholder.svg';
import solIcon from 'icons/files/sol.svg';
import yulIcon from 'icons/files/yul.svg';
import infoIcon from 'icons/info.svg';
import { shortenNumberWithLetter } from 'lib/formatters';
const FILE_ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGElement>>> = {
......@@ -29,31 +30,64 @@ interface Props {
index?: number;
onRemove?: (index?: number) => void;
isDisabled?: boolean;
error?: string;
}
const FileSnippet = ({ file, className, index, onRemove, isDisabled }: Props) => {
const handleRemove = React.useCallback(() => {
const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Props) => {
const handleRemove = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
onRemove?.(index);
}, [ index, onRemove ]);
const handleErrorHintIconClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const fileExtension = getFileExtension(file.name);
const fileIcon = FILE_ICONS[fileExtension] || imageIcon;
const fileIcon = FILE_ICONS[fileExtension] || placeholderIcon;
const iconColor = useColorModeValue('gray.600', 'gray.400');
return (
<Flex
p={ 3 }
maxW="300px"
overflow="hidden"
className={ className }
alignItems="center"
textAlign="left"
>
<Icon
as={ fileIcon }
boxSize="74px"
color={ error ? 'error' : iconColor }
mr={ 2 }
borderWidth="2px"
borderRadius="md"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
maxW="300px"
p={ 3 }
/>
<Box maxW="calc(100% - 58px - 24px)">
<Flex alignItems="center">
<Text
fontWeight={ 600 }
overflow="hidden"
className={ className }
textOverflow="ellipsis"
whiteSpace="nowrap"
color={ error ? 'error' : 'initial' }
>
<Icon as={ fileIcon } boxSize="50px" color={ useColorModeValue('gray.600', 'gray.400') } mr={ 2 }/>
<Box width="calc(100% - 58px - 24px)" >
<Text fontWeight={ 600 } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ file.name }</Text>
<Text variant="secondary" mt={ 1 }>{ shortenNumberWithLetter(file.size) }B</Text>
{ file.name }
</Text>
{ Boolean(error) && (
<Tooltip
label={ error }
placement="top"
maxW="320px"
>
<Box cursor="pointer" display="inherit" onClick={ handleErrorHintIconClick } ml={ 1 }>
<Icon as={ infoIcon } boxSize={ 5 } color="error"/>
</Box>
</Tooltip>
) }
<IconButton
aria-label="remove"
icon={ <CrossIcon/> }
......@@ -64,8 +98,12 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled }: Props) =>
ml="auto"
onClick={ handleRemove }
isDisabled={ isDisabled }
alignSelf="flex-start"
/>
</Flex>
<Text variant="secondary" mt={ 1 }>{ shortenNumberWithLetter(file.size) }B</Text>
</Box>
</Flex>
);
};
......
......@@ -8,11 +8,20 @@ import Burger from './Burger';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
const hooksConfig = {
router: {
route: '/blocks',
query: { id: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859' },
pathname: '/blocks',
},
};
test('base view', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Burger/>
</TestApp>,
{ hooksConfig },
);
await component.locator('svg[aria-label="Menu button"]').click();
......@@ -30,6 +39,7 @@ test.describe('dark mode', () => {
<TestApp>
<Burger/>
</TestApp>,
{ hooksConfig },
);
await component.locator('svg[aria-label="Menu button"]').click();
......@@ -53,9 +63,23 @@ test.describe('auth', () => {
<TestApp>
<Burger/>
</TestApp>,
{ hooksConfig },
);
await component.locator('svg[aria-label="Menu button"]').click();
await expect(page).toHaveScreenshot();
});
extendedTest('submenu', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Burger/>
</TestApp>,
{ hooksConfig },
);
await component.locator('svg[aria-label="Menu button"]').click();
await page.locator('div[aria-label="Blockchain link group"]').click();
await expect(page).toHaveScreenshot();
});
});
......@@ -4,9 +4,9 @@ import { route } from 'nextjs-routes';
import React from 'react';
import type { NavItem } from 'lib/hooks/useNavItems';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import useColors from './useColors';
import useNavLinkStyleProps from './useNavLinkStyleProps';
type Props = NavItem & {
isCollapsed?: boolean;
......@@ -15,23 +15,19 @@ type Props = NavItem & {
const NavLink = ({ text, nextRoute, icon, isCollapsed, isActive, px, isNewUi }: Props) => {
const colors = useColors();
const isExpanded = isCollapsed === false;
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive });
const content = (
<Link
{ ...(isNewUi ? {} : { href: route(nextRoute) }) }
w={{ base: '100%', lg: isExpanded ? '180px' : '60px', xl: isCollapsed ? '60px' : '180px' }}
px={ px || { base: 3, lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 } }
py={ 2.5 }
{ ...styleProps.itemProps }
w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }}
display="flex"
color={ isActive ? colors.text.active : colors.text.default }
bgColor={ isActive ? colors.bg.active : colors.bg.default }
_hover={{ color: isActive ? colors.text.active : colors.text.hover }}
borderRadius="base"
whiteSpace="nowrap"
px={ px || { base: 3, lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 } }
aria-label={ `${ text } link` }
{ ...getDefaultTransitionProps({ transitionProperty: 'width, padding' }) }
whiteSpace="nowrap"
>
<Tooltip
label={ text }
......@@ -44,15 +40,7 @@ const NavLink = ({ text, nextRoute, icon, isCollapsed, isActive, px, isNewUi }:
>
<HStack spacing={ 3 } overflow="hidden">
<Icon as={ icon } boxSize="30px"/>
<Text
variant="inherit"
fontSize="sm"
lineHeight="20px"
opacity={{ base: '1', lg: isExpanded ? '1' : '0', xl: isCollapsed ? '0' : '1' }}
transitionProperty="opacity"
transitionDuration="normal"
transitionTimingFunction="ease"
>
<Text { ...styleProps.textProps }>
{ text }
</Text>
</HStack>
......
import {
Icon,
Text,
HStack,
Flex,
Box,
Link,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
VStack,
} from '@chakra-ui/react';
import React from 'react';
import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import NavLink from './NavLink';
import useNavLinkStyleProps from './useNavLinkStyleProps';
type Props = NavGroupItem & {
isCollapsed?: boolean;
}
const NavLinkGroupDesktop = ({ text, subItems, icon, isCollapsed, isActive }: Props) => {
const isExpanded = isCollapsed === false;
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive });
return (
<Box as="li" listStyleType="none" w="100%">
<Popover
trigger="hover"
placement="right-start"
isLazy
>
<PopoverTrigger>
<Link
{ ...styleProps.itemProps }
w={{ lg: isExpanded ? '180px' : '60px', xl: isCollapsed ? '60px' : '180px' }}
pl={{ lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 }}
pr={{ lg: isExpanded ? 0 : '15px', xl: isCollapsed ? '15px' : 0 }}
aria-label={ `${ text } link group` }
display="grid"
gridColumnGap={ 3 }
gridTemplateColumns="auto, 30px"
overflow="hidden"
>
<Flex justifyContent="space-between" width="100%" alignItems="center" pr={ 1 }>
<HStack spacing={ 3 } overflow="hidden">
<Icon as={ icon } boxSize="30px"/>
<Text
{ ...styleProps.textProps }
>
{ text }
</Text>
</HStack>
<Icon
as={ chevronIcon }
transform="rotate(180deg)"
boxSize={ 6 }
opacity={{ lg: isExpanded ? '1' : '0', xl: isCollapsed ? '0' : '1' }}
transitionProperty="opacity"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
</Flex>
</Link>
</PopoverTrigger>
<PopoverContent width="auto" top={{ lg: isExpanded ? '-16px' : 0, xl: isCollapsed ? 0 : '-16px' }}>
<PopoverBody p={ 4 }>
<Text variant="secondary" fontSize="sm" mb={ 2 } display={{ lg: isExpanded ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }}>
{ text }
</Text>
<VStack spacing={ 1 } alignItems="start">
{ subItems.map(item => <NavLink key={ item.text } { ...item } isCollapsed={ false }/>) }
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
);
};
export default NavLinkGroupDesktop;
import {
Icon,
Text,
HStack,
Flex,
Box,
} from '@chakra-ui/react';
import React from 'react';
import chevronIcon from 'icons/arrows/east-mini.svg';
import type { NavGroupItem } from 'lib/hooks/useNavItems';
import useNavLinkStyleProps from './useNavLinkStyleProps';
type Props = NavGroupItem & {
isCollapsed?: boolean;
onClick: () => void;
}
const NavLinkGroup = ({ text, icon, isActive, onClick }: Props) => {
const styleProps = useNavLinkStyleProps({ isActive });
return (
<Box as="li" listStyleType="none" w="100%" onClick={ onClick }>
<Box
{ ...styleProps.itemProps }
w="100%"
px={ 3 }
aria-label={ `${ text } link group` }
>
<Flex justifyContent="space-between" width="100%" alignItems="center" pr={ 1 }>
<HStack spacing={ 3 } overflow="hidden">
<Icon as={ icon } boxSize="30px"/>
<Text
{ ...styleProps.textProps }
>
{ text }
</Text>
</HStack>
<Icon as={ chevronIcon } transform="rotate(180deg)" boxSize={ 6 }/>
</Flex>
</Box>
</Box>
);
};
export default NavLinkGroup;
......@@ -70,6 +70,21 @@ test('with tooltips +@desktop-xl -@default', async({ mount, page }) => {
await expect(component).toHaveScreenshot();
});
test('with submenu +@desktop-xl +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<Flex w="100%" minH="100vh" alignItems="stretch">
<NavigationDesktop/>
<Box bgColor="lightpink" w="100%"/>
</Flex>
</TestApp>,
{ hooksConfig },
);
await page.locator('a[aria-label="Blockchain link group"]').hover();
await expect(component).toHaveScreenshot();
});
test.describe('cookie set to false', () => {
const extendedTest = test.extend({
context: ({ context }, use) => {
......
......@@ -5,13 +5,14 @@ import appConfig from 'configs/app/config';
import chevronIcon from 'icons/arrows/east-mini.svg';
import { useAppContext } from 'lib/appContext';
import * as cookies from 'lib/cookies';
import useNavItems from 'lib/hooks/useNavItems';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import NetworkMenu from 'ui/snippets/networkMenu/NetworkMenu';
import NavFooter from './NavFooter';
import NavLink from './NavLink';
import NavLinkGroupDesktop from './NavLinkGroupDesktop';
const NavigationDesktop = () => {
const appProps = useAppContext();
......@@ -51,7 +52,7 @@ const NavigationDesktop = () => {
display={{ base: 'none', lg: 'flex' }}
position="relative"
flexDirection="column"
alignItems="flex-start"
alignItems="stretch"
borderRight="1px solid"
borderColor="divider"
px={{ lg: isExpanded ? 6 : 4, xl: isCollapsed ? 4 : 6 }}
......@@ -66,7 +67,8 @@ const NavigationDesktop = () => {
alignItems="center"
flexDirection="row"
w="100%"
px={{ lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 }}
pl={{ lg: isExpanded ? 3 : '15px', xl: isCollapsed ? '15px' : 3 }}
pr={{ lg: isExpanded ? 0 : '15px', xl: isCollapsed ? '15px' : 0 }}
h={ 10 }
transitionProperty="padding"
transitionDuration="normal"
......@@ -77,7 +79,13 @@ const NavigationDesktop = () => {
</Box>
<Box as="nav" mt={ 8 }>
<VStack as="ul" spacing="1" alignItems="flex-start">
{ mainNavItems.map((item) => <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>) }
{ mainNavItems.map((item) => {
if (isGroupItem(item)) {
return <NavLinkGroupDesktop key={ item.text } { ...item } isCollapsed={ isCollapsed }/>;
} else {
return <NavLink key={ item.text } { ...item } isCollapsed={ isCollapsed }/>;
}
}) }
</VStack>
</Box>
{ hasAccount && (
......
import { Box, VStack } from '@chakra-ui/react';
import React from 'react';
import { Box, Flex, Text, Icon, VStack, useColorModeValue } from '@chakra-ui/react';
import { animate, motion, useMotionValue } from 'framer-motion';
import React, { useCallback } from 'react';
import appConfig from 'configs/app/config';
import chevronIcon from 'icons/arrows/east-mini.svg';
import * as cookies from 'lib/cookies';
import useNavItems from 'lib/hooks/useNavItems';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
import NavFooter from 'ui/snippets/navigation/NavFooter';
import NavLink from 'ui/snippets/navigation/NavLink';
import NavLinkGroupMobile from './NavLinkGroupMobile';
const NavigationMobile = () => {
const { mainNavItems, accountNavItems } = useNavItems();
const [ openedGroupIndex, setOpenedGroupIndex ] = React.useState(-1);
const mainX = useMotionValue(0);
const subX = useMotionValue(250);
const onGroupItemOpen = (index: number) => () => {
setOpenedGroupIndex(index);
animate(mainX, -250, { ease: 'easeInOut' });
animate(subX, 0, { ease: 'easeInOut' });
};
const onGroupItemClose = useCallback(() => {
animate(mainX, 0, { ease: 'easeInOut' });
animate(subX, 250, { ease: 'easeInOut', onComplete: () => setOpenedGroupIndex(-1) });
}, [ mainX, subX ]);
const isAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN));
const hasAccount = appConfig.isAccountSupported && isAuth;
const iconColor = useColorModeValue('blue.600', 'blue.300');
const openedItem = mainNavItems[openedGroupIndex];
return (
<>
<Box as="nav" mt={ 6 }>
<VStack as="ul" spacing="1" alignItems="flex-start">
{ mainNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) }
<Box position="relative">
<Box
as={ motion.nav }
mt={ 6 }
style={{ x: mainX }}
>
<VStack
w="100%"
as="ul"
spacing="1"
alignItems="flex-start"
>
{ mainNavItems.map((item, index) => {
if (isGroupItem(item)) {
return <NavLinkGroupMobile key={ item.text } { ...item } onClick={ onGroupItemOpen(index) }/>;
} else {
return <NavLink key={ item.text } { ...item }/>;
}
}) }
</VStack>
</Box>
{ isAuth && (
<Box as="nav" mt={ 6 }>
<Box
as={ motion.nav }
mt={ 6 }
style={{ x: mainX }}
>
<VStack as="ul" spacing="1" alignItems="flex-start">
{ accountNavItems.map((item) => <NavLink key={ item.text } { ...item }/>) }
</VStack>
</Box>
) }
{ openedGroupIndex >= 0 && (
<Box
as={ motion.nav }
w="100%"
mt={ 6 }
position="absolute"
top={ 0 }
style={{ x: subX }}
key="sub"
>
<VStack
w="100%"
as="ul"
spacing="1"
alignItems="flex-start"
>
<Flex alignItems="center" px={ 3 } py={ 2.5 } w="100%" h="50px" onClick={ onGroupItemClose }>
<Icon as={ chevronIcon } boxSize={ 6 } mr={ 2 } color={ iconColor }/>
<Text variant="secondary" fontSize="sm">{ mainNavItems[openedGroupIndex].text }</Text>
</Flex>
{ isGroupItem(openedItem) && openedItem.subItems?.map((item) => <NavLink key={ item.text } { ...item }/>) }
</VStack>
</Box>
) }
</Box>
<NavFooter hasAccount={ hasAccount }/>
</>
);
......
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import useColors from './useColors';
type Props = {
isExpanded?: boolean;
isCollapsed?: boolean;
isActive?: boolean;
px?: string | number;
}
export default function useNavLinkProps({ isExpanded, isCollapsed, isActive }: Props) {
const colors = useColors();
return {
itemProps: {
py: 2.5,
display: 'flex',
color: isActive ? colors.text.active : colors.text.default,
bgColor: isActive ? colors.bg.active : colors.bg.default,
_hover: { color: isActive ? colors.text.active : colors.text.hover },
borderRadius: 'base',
...getDefaultTransitionProps({ transitionProperty: 'width, padding' }),
},
textProps: {
variant: 'inherit',
fontSize: 'sm',
lineHeight: '20px',
opacity: { base: '1', lg: isExpanded ? '1' : '0', xl: isCollapsed ? '0' : '1' },
transitionProperty: 'opacity',
transitionDuration: 'normal',
transitionTimingFunction: 'ease',
},
};
}
import { Grid, Link, Skeleton } from '@chakra-ui/react';
import { Box, Flex, Grid, Link, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
......@@ -10,6 +10,8 @@ import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import type { TokenTabs } from 'ui/pages/Token';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
interface Props {
......@@ -108,7 +110,12 @@ const TokenDetails = ({ tokenQuery }: Props) => {
wordBreak="break-word"
whiteSpace="pre-wrap"
>
{ `${ totalValue?.valueStr || 0 } ${ symbol || '' }` }
<Flex w="100%">
<Box whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic hash={ totalValue?.valueStr || '0' }/>
</Box>
<Box flexShrink={ 0 }> { symbol || '' }</Box>
</Flex>
</DetailsInfoItem>
<DetailsInfoItem
title="Holders"
......@@ -135,6 +142,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
{ decimals }
</DetailsInfoItem>
) }
<DetailsSponsoredItem/>
</Grid>
);
};
......
......@@ -8,7 +8,6 @@ import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import AdBanner from 'ui/shared/ad/AdBanner';
import TextAd from 'ui/shared/ad/TextAd';
import AddressHeadingInfo from 'ui/shared/AddressHeadingInfo';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -18,6 +17,7 @@ import TokenLogo from 'ui/shared/TokenLogo';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
import TokenInstanceDetails from './TokenInstanceDetails';
import TokenInstanceMetadata from './TokenInstanceMetadata';
import TokenInstanceSkeleton from './TokenInstanceSkeleton';
export type TokenTabs = 'token_transfers' | 'holders'
......@@ -53,7 +53,7 @@ const TokenInstanceContent = () => {
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } tokenId={ id }/> },
// there is no api for this tab yet
// { id: 'holders', title: 'Holders', component: <span>Holders</span> },
{ id: 'metadata', title: 'Metadata', component: <span>Metadata</span> },
{ id: 'metadata', title: 'Metadata', component: <TokenInstanceMetadata data={ tokenInstanceQuery.data?.metadata }/> },
];
if (tokenInstanceQuery.isError) {
......@@ -88,15 +88,13 @@ const TokenInstanceContent = () => {
<TokenInstanceDetails data={ tokenInstanceQuery.data } scrollRef={ scrollRef }/>
<AdBanner mt={{ base: 6, lg: 8 }} justifyContent="center"/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ scrollRef }></Box>
<RoutedTabs
tabs={ tabs }
tabListProps={ isMobile ? { mt: 8 } : { mt: 3, py: 5, marginBottom: 0 } }
rightSlot={ !isMobile && transfersQuery.isPaginationVisible ? <Pagination { ...transfersQuery.pagination }/> : null }
rightSlot={ !isMobile && transfersQuery.isPaginationVisible && tab !== 'metadata' ? <Pagination { ...transfersQuery.pagination }/> : null }
stickyEnabled={ !isMobile }
/>
</>
......
......@@ -5,6 +5,7 @@ import * as addressMock from 'mocks/address/address';
import * as tokenInstanceMock from 'mocks/tokens/tokenInstance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import insertAdPlaceholder from 'playwright/utils/insertAdPlaceholder';
import TokenInstanceDetails from './TokenInstanceDetails';
......@@ -30,5 +31,7 @@ test('base view +@dark-mode', async({ mount, page }) => {
</TestApp>,
);
await insertAdPlaceholder(page);
await expect(component).toHaveScreenshot();
});
......@@ -8,6 +8,7 @@ import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import NftMedia from 'ui/shared/nft/NftMedia';
import TokenSnippet from 'ui/shared/TokenSnippet/TokenSnippet';
......@@ -65,6 +66,7 @@ const TokenInstanceDetails = ({ data, scrollRef }: Props) => {
</Flex>
</DetailsInfoItem>
<TokenInstanceTransfersCount hash={ data.token.address } id={ data.id } onClick={ handleCounterItemClick }/>
<DetailsSponsoredItem/>
</Grid>
<NftMedia
imageUrl={ data.image_url }
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
interface Props {
data: TokenInstance['metadata'] | undefined;
}
const TokenInstanceMetadata = ({ data }: Props) => {
if (!data) {
return <Box>There is no metadata for this NFT</Box>;
}
return (
<RawDataSnippet
data={ JSON.stringify(data, undefined, 4) }
/>
);
};
export default TokenInstanceMetadata;
......@@ -7,6 +7,7 @@ import SkeletonTabs from 'ui/shared/skeletons/SkeletonTabs';
const TokenInstanceSkeleton = () => {
return (
<Box>
<Skeleton h={{ base: 12, lg: 6 }} mb={ 6 } w="100%" maxW="680px"/>
<Skeleton h={ 10 } maxW="400px" w="100%" mb={ 6 }/>
<Flex align="center">
<SkeletonCircle boxSize={ 6 } flexShrink={ 0 }/>
......
......@@ -12,6 +12,7 @@ import {
chakra,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import { route } from 'nextjs-routes';
import React from 'react';
import { scroller, Element } from 'react-scroll';
......@@ -22,9 +23,7 @@ import errorIcon from 'icons/status/error.svg';
import successIcon from 'icons/status/success.svg';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import AdBanner from 'ui/shared/ad/AdBanner';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
......@@ -32,8 +31,10 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
// import PrevNext from 'ui/shared/PrevNext';
import LinkInternal from 'ui/shared/LinkInternal';
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
import RawInputData from 'ui/shared/RawInputData';
import TextSeparator from 'ui/shared/TextSeparator';
......@@ -49,8 +50,6 @@ import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxDetails = () => {
const { data, isLoading, isError, socketStatus, error } = useFetchTxInfo();
const isMobile = useIsMobile();
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
......@@ -155,7 +154,9 @@ const TxDetails = () => {
title="Block"
hint="Block number containing the transaction"
>
<Text>{ data.block === null ? 'Pending' : data.block }</Text>
{ data.block === null ?
<Text>Pending</Text> :
<LinkInternal href={ route({ pathname: '/block/[height]', query: { height: String(data.block) } }) }>{ data.block }</LinkInternal> }
{ Boolean(data.confirmations) && (
<>
<TextSeparator color="gray.500"/>
......@@ -177,22 +178,7 @@ const TxDetails = () => {
<Text variant="secondary">{ getConfirmationDuration(data.confirmation_duration) }</Text>
</DetailsInfoItem>
) }
{ isMobile ?
(
<GridItem
colSpan={{ base: undefined, lg: 2 }}
>
<AdBanner justifyContent="center"/>
</GridItem>
) :
(
<DetailsInfoItem
title="Sponsored"
hint="Sponsored banner advertisement"
>
<AdBanner/>
</DetailsInfoItem>
) }
<DetailsSponsoredItem/>
{ divider }
......
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