Commit 006f22da authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into marketplace-improvements

parents fd130d55 84ed9b9b
......@@ -10,7 +10,7 @@
App is distributed as a docker image. Here you can find information about the [package](https://github.com/blockscout/frontend/pkgs/container/frontend) and its recent [releases](https://github.com/blockscout/frontend/releases).
You can configure your app by passing necessary environment variables when stating the container. See full list of ENVs and their description [here](./docs/ENVS.md).
You can configure your app by passing necessary environment variables when starting the container. See full list of ENVs and their description [here](./docs/ENVS.md).
```sh
docker run -p 3000:3000 --env-file <path-to-your-env-file> ghcr.io/blockscout/frontend:latest
......
......@@ -33,7 +33,7 @@ We are using following technology stack in the project
- [Yarn](https://yarnpkg.com/) as package manager
- [ReactJS](https://reactjs.org/) as UI library
- [Next.js](https://nextjs.org/) as application framework
- [Chakra](https://chakra-ui.com/) as component library; our theme customization could be found in `/theme` folder
- [Chakra](https://chakra-ui.com/) as component library; our theme customization can be found in `/theme` folder
- [TanStack Query](https://tanstack.com/query/v4/docs/react/overview/) for fetching, caching and updating data from the API
- [Jest](https://jestjs.io/) as JavaScript testing framework
- [Playwright](https://playwright.dev/) as a tool for components visual testing
......@@ -60,6 +60,7 @@ B. Pre-defined configuration:
3. Start your local dev server using the `yarn dev:<config_name>` command.
4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`).
&nbsp;
## Adding new dependencies
......@@ -160,7 +161,7 @@ We have 3 pre-configured projects. You can run your test with the desired projec
2. Once you finish your work, remove label "WIP" from PR, if it was added before, and publish PR if it was in the draft state
3. Make sure that all code checks and tests are successfully passed
4. Add description to your Pull Request and link an existing issue(s) that it is fixing
5. Request review from one or all core team members: @tom2drum, @isstuev. Our core team are committed to reviewing patches in a timely manner.
5. Request review from one or all core team members: @tom2drum, @isstuev. Our core team is committed to reviewing patches in a timely manner.
6. After code review is done, we merge pull requests by squashing all commits and editing the commit message if necessary using the GitHub user interface.
*Note*, if you Pull Request contains any changes that are not backwards compatible with the previous versions of the app, please specify them in PR description and add label ["breaking changes"](https://github.com/blockscout/frontend/labels/breaking%20changes) to it.
......@@ -201,4 +202,4 @@ There are some predefined tasks for all commands described above. You can see it
Also there is a Jest test launch configuration for debugging and running current test file in the watch mode.
And you may find the Dev Container setup useful too.
\ No newline at end of file
And you may find the Dev Container setup useful too.
......@@ -7,4 +7,4 @@ For running app container from freshly built image do
docker run -p 3000:3000 --env-file <path-to-your-env-file> <your-image-tag>
```
*Disclaimer* Do no try to generate production build of the app on your local machine (outside the docker). The app will not work as you would expect.
*Disclaimer* Do not try to generate production build of the app on your local machine (outside the docker). The app will not work as you would expect.
......@@ -128,7 +128,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
| url | `string` | Network explorer main page url | Required | - | `https://blockscout.com/xdai/mainnet` |
| group | `Mainnets \| Testnets \| Other` | Indicates in which tab network appears in the menu | Required | - | `Mainnets` |
| icon | `string` | Network icon; if not provided, the common placeholder will be shown; *Note* that icon size should be at least 60px by 60px | - | - | `https://placekitten.com/60/60` |
| isActive | `boolean` | Pass `true` if item should be shonw as active in the menu | - | - | `true` |
| isActive | `boolean` | Pass `true` if item should be shown as active in the menu | - | - | `true` |
| invertIconInDarkMode | `boolean` | Pass `true` if icon colors should be inverted in dark mode | - | - | `true` |
&nbsp;
......@@ -188,6 +188,7 @@ Settings for meta tags and OG tags
| `burnt_fees` | Burnt fees |
| `total_reward` | Total block reward |
| `nonce` | Block nonce |
| `miner` | Address of block's miner or validator |
&nbsp;
......@@ -197,7 +198,7 @@ Settings for meta tags and OG tags
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE | `"github" \| "jazzicon" \| "gradient_avatar" \| "blockie"` | Style of address identicon appearance. Choose between [GitHub](https://github.blog/2013-08-14-identicons/), [Metamask Jazzicon](https://metamask.github.io/jazzicon/), [Gradient Avatar](https://github.com/varld/gradient-avatar) and [Ethereum Blocky](https://mycryptohq.github.io/ethereum-blockies-base64/) | - | `jazzicon` | `gradient_avatar` |
| NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS | `Array<AddressViewId>` | Address views that should not be displayed. See below the list of the possible id values. | - | - | `'["top_accounts"]'` |
| NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED | `boolean` | Set to `true` if SolidityScan reports are supperted | - | - | `true` |
| NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED | `boolean` | Set to `true` if SolidityScan reports are supported | - | - | `true` |
##### Address views list
| Id | Description |
......@@ -272,7 +273,7 @@ Settings for meta tags and OG tags
## App features
*Note* The variables which are marked as required should be passed as described in order to enable the particular feature, but there are not required in the whole app context.
*Note* The variables which are marked as required should be passed as described in order to enable the particular feature, but they are not required in the whole app context.
### My account
......@@ -287,7 +288,7 @@ Settings for meta tags and OG tags
### Address verification in "My account"
*Note* all ENV variables required for [My account](ENVS.md#my-account) feature should be passed along side with the following ones:
*Note* all ENV variables required for [My account](ENVS.md#my-account) feature should be passed alongside the following ones:
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
......@@ -419,7 +420,7 @@ This feature is **always enabled**, but you can configure its behavior by passin
| title | `string` | Displayed title of the app. | Required | `'The App'` |
| logo | `string` | URL to logo file. Should be at least 288x288. | Required | `'https://foo.app/icon.png'` |
| shortDescription | `string` | Displayed only in the app list. | Required | `'Awesome app'` |
| categories | `Array<MarketplaceCategoryId>` | Displayed category. Select one of the following bellow. | Required | `['security', 'tools']` |
| categories | `Array<MarketplaceCategoryId>` | Displayed category. Select one of the following below. | Required | `['security', 'tools']` |
| author | `string` | Displayed author of the app | Required | `'Bob'` |
| url | `string` | URL of the app which will be launched in the iframe. | Required | `'https://foo.app/launch'` |
| description | `string` | Displayed only in the modal dialog with additional info about the app. | Required | `'The best app'` |
......@@ -512,13 +513,13 @@ This feature allows users to view tokens that have been bridged from other EVM c
### Safe{Core} address tags
For the smart contract addresses which are [Safe{Core} accounts](https://safe.global/) public tag "Multisig: Safe" will be displayed in the address page header along side to Safe logo. The Safe service is available only for certain networks, see full list [here](https://docs.safe.global/safe-core-api/available-services). Based on provided value of `NEXT_PUBLIC_NETWORK_ID`, the feature will be enabled or disabled.
For the smart contract addresses which are [Safe{Core} accounts](https://safe.global/) public tag "Multisig: Safe" will be displayed in the address page header alongside to Safe logo. The Safe service is available only for certain networks, see full list [here](https://docs.safe.global/safe-core-api/available-services). Based on provided value of `NEXT_PUBLIC_NETWORK_ID`, the feature will be enabled or disabled.
&nbsp;
### SUAVE chain
For blockchains that implementing SUAVE architecture additional fields will be shown on the transaction page ("Allowed peekers", "Kettle"). Users also will be able to see the list of all transaction for a particular Kettle in the separate view.
For blockchains that implement SUAVE architecture additional fields will be shown on the transaction page ("Allowed peekers", "Kettle"). Users also will be able to see the list of all transactions for a particular Kettle in the separate view.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.557 9.542a.75.75 0 0 1 .441.729 6.798 6.798 0 1 1-7.214-7.186.75.75 0 0 1 .548 1.307l-.149.133a3.786 3.786 0 0 0 5.364 5.344l.172-.173a.75.75 0 0 1 .838-.154ZM14.063 12a5.297 5.297 0 0 1-2.195.476 5.285 5.285 0 0 1-4.822-7.44A5.297 5.297 0 1 0 14.063 12Z" fill="currentColor"/>
<path d="m12.631 5.626.193.386a.75.75 0 0 0 .336.335l.386.194-.386.193a.75.75 0 0 0-.336.335l-.193.386-.193-.386a.75.75 0 0 0-.335-.335l-.386-.193.386-.194a.75.75 0 0 0 .335-.335l.193-.386Z" stroke="currentColor"/>
</svg>
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.557 7.712a.75.75 0 0 1 .442.729A7.983 7.983 0 1 1 7.526 0a.75.75 0 0 1 .548 1.308l-.18.161a4.675 4.675 0 0 0 6.619 6.603l.207-.207a.75.75 0 0 1 .837-.154Zm-1.446 2.504a6.176 6.176 0 0 1-8.373-8.311 6.481 6.481 0 0 0-2.282 10.657 6.482 6.482 0 0 0 10.655-2.346Z" fill="currentColor"/>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.557 9.542a.75.75 0 0 1 .441.729 6.798 6.798 0 1 1-7.214-7.186.75.75 0 0 1 .548 1.307l-.149.133a3.786 3.786 0 0 0 5.364 5.344l.172-.173a.75.75 0 0 1 .838-.154ZM14.063 12a5.297 5.297 0 0 1-2.195.476 5.285 5.285 0 0 1-4.822-7.44A5.297 5.297 0 1 0 14.063 12Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m4.7 14.167-.592.591a.833.833 0 0 0 0 1.175.833.833 0 0 0 1.175 0l.592-.591A.833.833 0 0 0 4.7 14.167ZM4.166 10a.833.833 0 0 0-.833-.833H2.5a.833.833 0 1 0 0 1.666h.833A.833.833 0 0 0 4.166 10ZM10 4.167a.833.833 0 0 0 .833-.834V2.5a.833.833 0 1 0-1.667 0v.833a.833.833 0 0 0 .834.834ZM4.7 5.875a.833.833 0 0 0 1.175 0 .833.833 0 0 0 0-1.175l-.592-.592a.833.833 0 0 0-1.175 1.175l.592.592Zm10 .242a.833.833 0 0 0 .583-.242l.592-.592A.832.832 0 1 0 14.7 4.108l-.534.592a.833.833 0 0 0 0 1.175.833.833 0 0 0 .55.242H14.7Zm2.8 3.05h-.834a.833.833 0 0 0 0 1.666h.834a.833.833 0 1 0 0-1.666ZM10 15.833a.833.833 0 0 0-.834.834v.833a.833.833 0 1 0 1.667 0v-.833a.833.833 0 0 0-.833-.834Zm5.3-1.666a.833.833 0 0 0-1.134 1.133l.592.592a.833.833 0 0 0 1.175 0 .833.833 0 0 0 0-1.175l-.633-.55ZM10 5.417A4.583 4.583 0 1 0 14.583 10 4.592 4.592 0 0 0 10 5.417Zm0 7.5a2.917 2.917 0 1 1 0-5.833 2.917 2.917 0 0 1 0 5.833Z" fill="currentColor"/>
<path d="m4.7 14.167-.592.591a.833.833 0 0 0 0 1.175.833.833 0 0 0 1.175 0l.592-.591A.833.833 0 0 0 4.7 14.167ZM4.167 10a.833.833 0 0 0-.834-.833H2.5a.833.833 0 0 0 0 1.666h.833A.833.833 0 0 0 4.167 10ZM10 4.167a.833.833 0 0 0 .833-.834V2.5a.833.833 0 1 0-1.666 0v.833a.833.833 0 0 0 .833.834ZM4.7 5.875a.833.833 0 0 0 1.175 0 .833.833 0 0 0 0-1.175l-.592-.592a.833.833 0 0 0-1.175 1.175l.592.592Zm10 .242a.833.833 0 0 0 .583-.242l.592-.592a.835.835 0 0 0-.574-1.465.833.833 0 0 0-.601.29l-.533.592a.833.833 0 0 0 0 1.175.833.833 0 0 0 .55.242H14.7Zm2.8 3.05h-.833a.833.833 0 0 0 0 1.666h.833a.833.833 0 0 0 0-1.666ZM10 15.833a.834.834 0 0 0-.833.834v.833a.833.833 0 0 0 1.666 0v-.833a.833.833 0 0 0-.833-.834Zm5.3-1.666a.834.834 0 0 0-1.133 1.133l.591.592a.833.833 0 0 0 1.175 0 .833.833 0 0 0 0-1.175l-.633-.55ZM10 5.417A4.583 4.583 0 1 0 14.583 10 4.592 4.592 0 0 0 10 5.417Zm0 7.5a2.917 2.917 0 1 1 0-5.834 2.917 2.917 0 0 1 0 5.834Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.392 5.45c.252-.288.592-.45.948-.45h8.038a.63.63 0 0 1 .474.225l4.689 5.385a.83.83 0 0 1 .196.544v8.574l-.985 1.055-.355-.38v-8.666h-4.485a.702.702 0 0 1-.702-.702V6.538H7.34v16.924h9.44L18.217 25H7.34c-.356 0-.696-.162-.948-.45A1.661 1.661 0 0 1 6 23.461V6.538c0-.408.141-.799.392-1.087Zm9.222 1.678 2.791 3.205h-2.791V7.128ZM8.85 15.5a.65.65 0 0 1 .65-.65h7.2a.65.65 0 1 1 0 1.3H9.5a.65.65 0 0 1-.65-.65Zm0 2.4a.65.65 0 0 1 .65-.65h7.2a.65.65 0 1 1 0 1.3H9.5a.65.65 0 0 1-.65-.65Z" fill="currentColor"/>
<path d="m17.552 21.357 2.2 2.357 4.4-4.714" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
......@@ -9,6 +9,7 @@ export enum NAMES {
CONFIRM_EMAIL_PAGE_VIEWED='confirm_email_page_viewed',
TXS_SORT='txs_sort',
COLOR_MODE='chakra-ui-color-mode',
COLOR_MODE_HEX='chakra-ui-color-mode-hex',
INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug',
......
......@@ -24,6 +24,7 @@ import topAccountsIcon from 'icons/top-accounts.svg';
import transactionsIcon from 'icons/transactions.svg';
import txnBatchIcon from 'icons/txn_batches.svg';
import verifiedIcon from 'icons/verified.svg';
import verifyContractIcon from 'icons/verify-contract.svg';
import watchlistIcon from 'icons/watchlist.svg';
import { rightLineArrow } from 'lib/html-entities';
import UserAvatar from 'ui/shared/UserAvatar';
......@@ -176,11 +177,19 @@ export default function useNavItems(): ReturnType {
isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive),
subItems: apiNavItems,
},
config.UI.sidebar.otherLinks.length > 0 ? {
{
text: 'Other',
icon: gearIcon,
subItems: config.UI.sidebar.otherLinks,
} : null,
subItems: [
{
text: 'Verify contract',
nextRoute: { pathname: '/contract-verification' as const },
icon: verifyContractIcon,
isActive: pathname.startsWith('/contract-verification'),
},
...config.UI.sidebar.otherLinks,
],
},
].filter(Boolean);
const accountNavItems: ReturnType['accountNavItems'] = [
......
// https://unicode-table.com
export const asymp = String.fromCharCode(8776); // ~
// https://symbl.cc/en/
export const asymp = String.fromCharCode(8776); // ≈
export const tilde = String.fromCharCode(126); // ~
export const hellip = String.fromCharCode(8230); // …
export const nbsp = String.fromCharCode(160); // no-break Space
export const thinsp = String.fromCharCode(8201); // thin Space
......
......@@ -12,6 +12,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/accounts': 'Root page',
'/address/[hash]': 'Regular page',
'/verified-contracts': 'Root page',
'/contract-verification': 'Root page',
'/address/[hash]/contract-verification': 'Regular page',
'/tokens': 'Root page',
'/token/[hash]': 'Regular page',
......
......@@ -15,6 +15,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/accounts': DEFAULT_TEMPLATE,
'/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/verified-contracts': DEFAULT_TEMPLATE,
'/contract-verification': DEFAULT_TEMPLATE,
'/address/[hash]/contract-verification': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/tokens': DEFAULT_TEMPLATE,
'/token/[hash]': '%hash%, balances and analytics on the %network_title%',
......
......@@ -10,6 +10,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/accounts': 'top accounts',
'/address/[hash]': 'address details for %hash%',
'/verified-contracts': 'verified contracts',
'/contract-verification': 'verify contract',
'/address/[hash]/contract-verification': 'contract verification for %hash%',
'/tokens': 'tokens',
'/token/[hash]': '%symbol% token details',
......
......@@ -10,7 +10,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/accounts': 'Top accounts',
'/address/[hash]': 'Address details',
'/verified-contracts': 'Verified contracts',
'/address/[hash]/contract-verification': 'Contract verification',
'/contract-verification': 'Contract verification',
'/address/[hash]/contract-verification': 'Contract verification for address',
'/tokens': 'Tokens',
'/token/[hash]': 'Token details',
'/token/[hash]/instance/[id]': 'Token Instance',
......
......@@ -10,9 +10,9 @@ const sortTxs = (sorting?: Sort) => (tx1: Transaction, tx2: Transaction) => {
case 'val-asc':
return compareBns(tx2.value, tx1.value);
case 'fee-desc':
return compareBns(tx1.fee.value, tx2.fee.value);
return compareBns(tx1.fee.value || 0, tx2.fee.value || 0);
case 'fee-asc':
return compareBns(tx2.fee.value, tx1.fee.value);
return compareBns(tx2.fee.value || 0, tx1.fee.value || 0);
default:
return 0;
}
......
......@@ -28,6 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| StaticRoute<"/blocks">
| StaticRoute<"/contract-verification">
| StaticRoute<"/csv-export">
| StaticRoute<"/graphiql">
| StaticRoute<"/">
......
......@@ -4,12 +4,12 @@ import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import ContractVerification from 'ui/pages/ContractVerification';
import ContractVerificationForAddress from 'ui/pages/ContractVerificationForAddress';
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props }>
<ContractVerification/>
<ContractVerificationForAddress/>
</PageNextJs>
);
};
......
import type { NextPage } from 'next';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
import ContractVerification from 'ui/pages/ContractVerification';
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/contract-verification" query={ props }>
<ContractVerification/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
export interface Fee {
type: string;
value: string;
value: string | null;
}
......@@ -3,7 +3,7 @@ export type HomeStats = {
total_addresses: string;
total_transactions: string;
average_block_time: number;
coin_price: string;
coin_price: string | null;
total_gas_used: string;
transactions_today: string;
gas_used_today: string;
......
......@@ -4,6 +4,7 @@ export const BLOCK_FIELDS_IDS = [
'burnt_fees',
'total_reward',
'nonce',
'miner',
] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
......@@ -202,19 +202,21 @@ const BlockDetails = ({ query }: Props) => {
</Skeleton>
</DetailsInfoItem>
) }
<DetailsInfoItem
title={ verificationTitle }
hint="A block producer who successfully included the block onto the blockchain"
columnGap={ 1 }
isLoading={ isPlaceholderData }
>
<AddressEntity
address={ data.miner }
{ !config.UI.views.block.hiddenFields?.miner && (
<DetailsInfoItem
title={ verificationTitle }
hint="A block producer who successfully included the block onto the blockchain"
columnGap={ 1 }
isLoading={ isPlaceholderData }
/>
{ /* api doesn't return the block processing time yet */ }
{ /* <Text>{ dayjs.duration(block.minedIn, 'second').humanize(true) }</Text> */ }
</DetailsInfoItem>
>
<AddressEntity
address={ data.miner }
isLoading={ isPlaceholderData }
/>
{ /* api doesn't return the block processing time yet */ }
{ /* <Text>{ dayjs.duration(block.minedIn, 'second').humanize(true) }</Text> */ }
</DetailsInfoItem>
) }
{ !isRollup && !totalReward.isEqualTo(ZERO) && !config.UI.views.block.hiddenFields?.total_reward && (
<DetailsInfoItem
title="Block reward"
......
......@@ -57,13 +57,15 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<span>{ data.size.toLocaleString() } bytes</span>
</Skeleton>
</Flex>
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 }>{ capitalize(getNetworkValidatorTitle()) }</Text>
<AddressEntity
address={ data.miner }
isLoading={ isLoading }
/>
</Flex>
{ !config.UI.views.block.hiddenFields?.miner && (
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 }>{ capitalize(getNetworkValidatorTitle()) }</Text>
<AddressEntity
address={ data.miner }
isLoading={ isLoading }
/>
</Flex>
) }
<Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Txn</Text>
{ data.tx_count > 0 ? (
......
......@@ -42,7 +42,8 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
<Tr>
<Th width="125px">Block</Th>
<Th width="120px">Size, bytes</Th>
<Th width={ `${ VALIDATOR_COL_WEIGHT / widthBase * 100 }%` } minW="160px">{ capitalize(getNetworkValidatorTitle()) }</Th>
{ !config.UI.views.block.hiddenFields?.miner &&
<Th width={ `${ VALIDATOR_COL_WEIGHT / widthBase * 100 }%` } minW="160px">{ capitalize(getNetworkValidatorTitle()) }</Th> }
<Th width="64px" isNumeric>Txn</Th>
<Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward &&
......
......@@ -66,12 +66,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
{ data.size.toLocaleString() }
</Skeleton>
</Td>
<Td fontSize="sm">
<AddressEntity
address={ data.miner }
isLoading={ isLoading }
/>
</Td>
{ !config.UI.views.block.hiddenFields?.miner && (
<Td fontSize="sm">
<AddressEntity
address={ data.miner }
isLoading={ isLoading }
/>
</Td>
) }
<Td isNumeric fontSize="sm">
{ data.tx_count > 0 ? (
<Skeleton isLoaded={ !isLoading } display="inline-block">
......
import { Button, chakra, useUpdateEffect } from '@chakra-ui/react';
import { Button, Grid, chakra, useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types';
import type { SocketMessage } from 'lib/socket/types';
import type { SmartContractVerificationMethod, SmartContractVerificationConfig } from 'types/api/contract';
import type { SmartContractVerificationMethod, SmartContractVerificationConfig, SmartContract } from 'types/api/contract';
import { route } from 'nextjs-routes';
import useApiFetch from 'lib/api/useApiFetch';
import delay from 'lib/delay';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel/index';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ContractVerificationFieldAddress from './fields/ContractVerificationFieldAddress';
import ContractVerificationFieldMethod from './fields/ContractVerificationFieldMethod';
import ContractVerificationFlattenSourceCode from './methods/ContractVerificationFlattenSourceCode';
import ContractVerificationMultiPartFile from './methods/ContractVerificationMultiPartFile';
......@@ -29,15 +31,15 @@ import { prepareRequestBody, formatSocketErrors, getDefaultValues, METHOD_LABELS
interface Props {
method?: SmartContractVerificationMethod;
config: SmartContractVerificationConfig;
hash: string;
hash?: string;
}
const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => {
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: methodFromQuery ? getDefaultValues(methodFromQuery, config) : undefined,
defaultValues: methodFromQuery ? getDefaultValues(methodFromQuery, config, hash) : undefined,
});
const { control, handleSubmit, watch, formState, setError, reset } = formApi;
const { control, handleSubmit, watch, formState, setError, reset, getFieldState } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const methodNameRef = React.useRef<string>();
......@@ -47,9 +49,28 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
const body = prepareRequestBody(data);
if (!hash) {
try {
const response = await apiFetch<'contract', SmartContract>('contract', {
pathParams: { hash: data.address.toLowerCase() },
});
const isVerifiedContract = 'is_verified' in response && response?.is_verified && !response.is_partially_verified;
if (isVerifiedContract) {
setError('address', { message: 'Contract has already been verified' });
return Promise.resolve();
}
} catch (error) {
const statusCode = getErrorObjStatusCode(error);
const message = statusCode === 404 ? 'Address is not a smart contract' : 'Something went wrong';
setError('address', { message });
return Promise.resolve();
}
}
try {
await apiFetch('contract_verification_via', {
pathParams: { method: data.method.value, hash: hash.toLowerCase() },
pathParams: { method: data.method.value, hash: data.address.toLowerCase() },
fetchParams: {
method: 'POST',
body,
......@@ -62,7 +83,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
return new Promise((resolve) => {
submitPromiseResolver.current = resolve;
});
}, [ apiFetch, hash ]);
}, [ apiFetch, hash, setError ]);
const address = watch('address');
const addressState = getFieldState('address');
const handleNewSocketMessage: SocketMessage.ContractVerification['handler'] = React.useCallback(async(payload) => {
if (payload.status === 'error') {
......@@ -88,8 +112,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
{ send_immediately: true },
);
window.location.assign(route({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } }));
}, [ hash, setError, toast ]);
window.location.assign(route({ pathname: '/address/[hash]', query: { hash: address, tab: 'contract' } }));
}, [ setError, toast, address ]);
const handleSocketError = React.useCallback(() => {
if (!formState.isSubmitting) {
......@@ -114,10 +138,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
}, [ toast ]);
const channel = useSocketChannel({
topic: `addresses:${ hash.toLowerCase() }`,
topic: `addresses:${ address?.toLowerCase() }`,
onSocketClose: handleSocketError,
onSocketError: handleSocketError,
isDisabled: false,
isDisabled: Boolean(address && addressState.error),
});
useSocketMessage({
channel,
......@@ -142,7 +166,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
useUpdateEffect(() => {
if (methodValue) {
reset(getDefaultValues(methodValue, config));
reset(getDefaultValues(methodValue, config, address || hash));
const methodName = METHOD_LABELS[methodValue];
mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_VERIFICATION, { Status: 'Method selected', Method: methodName });
......@@ -157,11 +181,14 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<ContractVerificationFieldMethod
control={ control }
methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
<Grid as="section" columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
{ !hash && <ContractVerificationFieldAddress/> }
<ContractVerificationFieldMethod
control={ control }
methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
</Grid>
{ content }
{ Boolean(method) && (
<Button
......
import { FormControl, Input, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { ADDRESS_REGEXP, ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
interface Props {
isReadOnly?: boolean;
}
const ContractVerificationFieldAddress = ({ isReadOnly }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'address'>}) => {
const error = 'address' in formState.errors ? formState.errors.address : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract / Address (0x...)" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, isReadOnly ]);
return (
<>
<ContractVerificationFormRow>
<chakra.span fontWeight={ 500 } fontSize="lg" fontFamily="heading">
Contract address to verify
</chakra.span>
</ContractVerificationFormRow>
<ContractVerificationFormRow mb={ 3 }>
<Controller
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
/>
</ContractVerificationFormRow>
</>
);
};
export default React.memo(ContractVerificationFieldAddress);
......@@ -12,8 +12,6 @@ import {
DarkMode,
ListItem,
OrderedList,
Grid,
Box,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
......@@ -99,43 +97,40 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
}, []);
return (
<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
<>
<div>
<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 display="inline-block" ml={ 1 } cursor="pointer" verticalAlign="middle" h="22px">
<Icon as={ infoIcon } boxSize={ 5 } color="link" _hover={{ color: 'link_hovered' }}/>
</chakra.span>
<Popover trigger="hover" isLazy placement={ isMobile ? 'bottom-end' : 'right-start' } offset={ [ -8, 8 ] }>
<PopoverTrigger>
<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 } w={{ base: '300px', lg: '380px' }}>
<PopoverArrow bgColor={ tooltipBg }/>
<PopoverBody color="white">
<DarkMode>
<span>Currently, Blockscout supports { methods.length } methods:</span>
<OrderedList>
{ methods.map(renderPopoverListItem) }
</OrderedList>
</DarkMode>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</Box>
<Controller
name="method"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</div>
</Grid>
</section>
</PopoverTrigger>
<Portal>
<PopoverContent bgColor={ tooltipBg } w={{ base: '300px', lg: '380px' }}>
<PopoverArrow bgColor={ tooltipBg }/>
<PopoverBody color="white">
<DarkMode>
<span>Currently, Blockscout supports { methods.length } methods:</span>
<OrderedList>
{ methods.map(renderPopoverListItem) }
</OrderedList>
</DarkMode>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
</div>
<div/>
<Controller
name="method"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
</>
);
};
......
......@@ -12,6 +12,7 @@ interface MethodOption {
}
export interface FormFieldsFlattenSourceCode {
address: string;
method: MethodOption;
is_yul: boolean;
name: string | undefined;
......@@ -26,6 +27,7 @@ export interface FormFieldsFlattenSourceCode {
}
export interface FormFieldsStandardInput {
address: string;
method: MethodOption;
name: string;
compiler: Option | null;
......@@ -35,12 +37,14 @@ export interface FormFieldsStandardInput {
}
export interface FormFieldsSourcify {
address: string;
method: MethodOption;
sources: Array<File>;
contract_index?: Option;
}
export interface FormFieldsMultiPartFile {
address: string;
method: MethodOption;
compiler: Option | null;
evm_version: Option | null;
......@@ -51,6 +55,7 @@ export interface FormFieldsMultiPartFile {
}
export interface FormFieldsVyperContract {
address: string;
method: MethodOption;
name: string;
evm_version: Option | null;
......@@ -60,6 +65,7 @@ export interface FormFieldsVyperContract {
}
export interface FormFieldsVyperMultiPartFile {
address: string;
method: MethodOption;
compiler: Option | null;
evm_version: Option | null;
......@@ -68,6 +74,7 @@ export interface FormFieldsVyperMultiPartFile {
}
export interface FormFieldsVyperStandardInput {
address: string;
method: MethodOption;
compiler: Option | null;
sources: Array<File>;
......
import useApiQuery from 'lib/api/useApiQuery';
import { isValidVerificationMethod, sortVerificationMethods } from './utils';
export default function useFormConfigQuery(enabled: boolean) {
return useApiQuery('contract_verification_config', {
queryOptions: {
select: (data) => {
return {
...data,
verification_options: data.verification_options.filter(isValidVerificationMethod).sort(sortVerificationMethods),
};
},
enabled,
},
});
}
......@@ -37,6 +37,7 @@ export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> = {
'flattened-code': {
address: '',
method: {
value: 'flattened-code' as const,
label: METHOD_LABELS['flattened-code'],
......@@ -53,6 +54,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
libraries: [],
},
'standard-input': {
address: '',
method: {
value: 'standard-input' as const,
label: METHOD_LABELS['standard-input'],
......@@ -64,6 +66,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
constructor_args: '',
},
sourcify: {
address: '',
method: {
value: 'sourcify' as const,
label: METHOD_LABELS.sourcify,
......@@ -71,6 +74,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
sources: [],
},
'multi-part': {
address: '',
method: {
value: 'multi-part' as const,
label: METHOD_LABELS['multi-part'],
......@@ -83,6 +87,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
libraries: [],
},
'vyper-code': {
address: '',
method: {
value: 'vyper-code' as const,
label: METHOD_LABELS['vyper-code'],
......@@ -94,6 +99,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
constructor_args: '',
},
'vyper-multi-part': {
address: '',
method: {
value: 'vyper-multi-part' as const,
label: METHOD_LABELS['vyper-multi-part'],
......@@ -103,6 +109,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
sources: [],
},
'vyper-standard-input': {
address: '',
method: {
value: 'vyper-standard-input' as const,
label: METHOD_LABELS['vyper-standard-input'],
......@@ -112,8 +119,8 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
},
};
export function getDefaultValues(method: SmartContractVerificationMethod, config: SmartContractVerificationConfig) {
const defaultValues = DEFAULT_VALUES[method];
export function getDefaultValues(method: SmartContractVerificationMethod, config: SmartContractVerificationConfig, hash?: string) {
const defaultValues = { ...DEFAULT_VALUES[method], address: hash };
if ('evm_version' in defaultValues) {
if (method === 'flattened-code' || method === 'multi-part') {
......
......@@ -66,7 +66,7 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => {
</>
) }
{ !config.features.optimisticRollup.isEnabled && (
{ !config.features.optimisticRollup.isEnabled && !config.UI.views.block.hiddenFields?.miner && (
<>
<Skeleton isLoaded={ !isLoading } textTransform="capitalize">{ getNetworkValidatorTitle() }</Skeleton>
<AddressEntity
......
......@@ -118,7 +118,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
{ tx.stability_fee ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/>
) : (
<Text as="span" variant="secondary">{ getValueWithUnit(tx.fee.value).dp(5).toFormat() }</Text>
<Text as="span" variant="secondary">{ tx.fee.value ? getValueWithUnit(tx.fee.value).dp(5).toFormat() : '-' }</Text>
) }
</Skeleton>
) }
......
......@@ -104,7 +104,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
{ tx.stability_fee ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/>
) : (
<Text as="span" variant="secondary">{ getValueWithUnit(tx.fee.value).dp(5).toFormat() }</Text>
<Text as="span" variant="secondary">{ tx.fee.value ? getValueWithUnit(tx.fee.value).dp(5).toFormat() : '-' }</Text>
) }
</Skeleton>
) }
......
......@@ -116,20 +116,22 @@ const BlockPageContent = () => {
const title = blockQuery.data?.type === 'reorg' ? `Reorged block #${ blockQuery.data?.height }` : `Block #${ blockQuery.data?.height }`;
const titleSecondRow = (
<>
<Skeleton
isLoaded={ !blockQuery.isPlaceholderData }
fontFamily="heading"
display="flex"
minW={ 0 }
columnGap={ 2 }
fontWeight={ 500 }
>
<chakra.span flexShrink={ 0 }>
{ config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by' }
</chakra.span>
<AddressEntity address={ blockQuery.data?.miner }/>
</Skeleton>
<NetworkExplorers type="block" pathParam={ heightOrHash } ml={{ base: 3, lg: 'auto' }}/>
{ !config.UI.views.block.hiddenFields?.miner && (
<Skeleton
isLoaded={ !blockQuery.isPlaceholderData }
fontFamily="heading"
display="flex"
minW={ 0 }
columnGap={ 2 }
fontWeight={ 500 }
>
<chakra.span flexShrink={ 0 }>
{ config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by' }
</chakra.span>
<AddressEntity address={ blockQuery.data?.miner }/>
</Skeleton>
) }
<NetworkExplorers type="block" pathParam={ heightOrHash } ml={{ base: config.UI.views.block.hiddenFields?.miner ? 0 : 3, lg: 'auto' }}/>
</>
);
......
import { useRouter } from 'next/router';
import React from 'react';
import type { SmartContractVerificationMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import { isValidVerificationMethod, sortVerificationMethods } from 'ui/contractVerification/utils';
import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => {
const appProps = useAppContext();
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const method = getQueryParamString(router.query.method) as SmartContractVerificationMethod;
const contractQuery = useApiQuery('contract', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
},
});
if (contractQuery.isError && contractQuery.error.status === 404) {
throw Error('Not found', { cause: contractQuery.error as unknown as Error });
}
const configQuery = useApiQuery('contract_verification_config', {
queryOptions: {
select: (data) => {
return {
...data,
verification_options: data.verification_options.filter(isValidVerificationMethod).sort(sortVerificationMethods),
};
},
enabled: Boolean(hash),
},
});
React.useEffect(() => {
if (method && hash) {
router.replace({ pathname: '/address/[hash]/contract-verification', query: { hash } }, undefined, { scroll: false, shallow: true });
}
// onMount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const isVerifiedContract = contractQuery.data?.is_verified && !contractQuery.data.is_partially_verified;
React.useEffect(() => {
if (isVerifiedContract) {
router.push({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } }, undefined, { scroll: false, shallow: true });
}
}, [ hash, isVerifiedContract, router ]);
const configQuery = useFormConfigQuery(true);
const content = (() => {
if (configQuery.isError || !hash || contractQuery.isError) {
if (configQuery.isError) {
return <DataFetchAlert/>;
}
if (configQuery.isPending || contractQuery.isPending || isVerifiedContract) {
if (configQuery.isPending) {
return <ContentLoader/>;
}
return (
<ContractVerificationForm
method={ method && configQuery.data.verification_options.includes(method) ? method : undefined }
config={ configQuery.data }
hash={ hash }
/>
<ContractVerificationForm config={ configQuery.data }/>
);
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to contract',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
<PageTitle
title="New smart contract verification"
backLink={ backLink }
/>
<AddressEntity
address={{ hash, is_contract: true, implementation_name: null }}
noLink
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
mb={ 12 }
/>
<PageTitle title="Verify & publish contract"/>
{ content }
</>
);
......
import { useRouter } from 'next/router';
import React from 'react';
import type { SmartContractVerificationMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerificationForAddress = () => {
const appProps = useAppContext();
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const method = getQueryParamString(router.query.method) as SmartContractVerificationMethod;
const contractQuery = useApiQuery('contract', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
},
});
if (contractQuery.isError && contractQuery.error.status === 404) {
throw Error('Not found', { cause: contractQuery.error as unknown as Error });
}
const configQuery = useFormConfigQuery(Boolean(hash));
React.useEffect(() => {
if (method && hash) {
router.replace({ pathname: '/address/[hash]/contract-verification', query: { hash } }, undefined, { scroll: false, shallow: true });
}
// onMount only
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const isVerifiedContract = contractQuery.data?.is_verified && !contractQuery.data.is_partially_verified;
React.useEffect(() => {
if (isVerifiedContract) {
router.push({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } }, undefined, { scroll: false, shallow: true });
}
}, [ hash, isVerifiedContract, router ]);
const content = (() => {
if (configQuery.isError || !hash || contractQuery.isError) {
return <DataFetchAlert/>;
}
if (configQuery.isPending || contractQuery.isPending || isVerifiedContract) {
return <ContentLoader/>;
}
return (
<ContractVerificationForm
method={ method && configQuery.data.verification_options.includes(method) ? method : undefined }
config={ configQuery.data }
hash={ hash }
/>
);
})();
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/address');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to contract',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
return (
<>
<PageTitle
title="New smart contract verification"
backLink={ backLink }
/>
<AddressEntity
address={{ hash, is_contract: true, implementation_name: null }}
noLink
fontFamily="heading"
fontSize="lg"
fontWeight={ 500 }
mb={ 12 }
/>
{ content }
</>
);
};
export default ContractVerificationForAddress;
import { Box, Heading, Flex, LightMode } from '@chakra-ui/react';
import { Box, Heading, Flex } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
......@@ -31,16 +31,14 @@ const Home = () => {
fontWeight={ 600 }
color={ config.UI.homepage.plate.textColor }
>
Welcome to { config.chain.name } explorer
{ config.chain.name } explorer
</Heading>
<Box display={{ base: 'none', lg: 'flex' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> }
</Box>
</Flex>
<LightMode>
<SearchBar isHomepage/>
</LightMode>
<SearchBar isHomepage/>
</Box>
<Stats/>
<ChainIndicators/>
......
......@@ -8,8 +8,6 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import LayoutMainColumn from 'ui/shared/layout/components/MainColumn';
import SearchResults from './SearchResults';
......@@ -47,17 +45,12 @@ test.describe('search by name ', () => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
});
......@@ -78,17 +71,12 @@ test('search by address hash +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block number +@mobile', async({ mount, page }) => {
......@@ -109,17 +97,12 @@ test('search by block number +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by block hash +@mobile', async({ mount, page }) => {
......@@ -139,17 +122,12 @@ test('search by block hash +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by tx hash +@mobile', async({ mount, page }) => {
......@@ -169,17 +147,12 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
test.describe('with apps', () => {
......@@ -228,16 +201,11 @@ test.describe('with apps', () => {
const component = await mount(
<TestApp>
<LayoutMainColumn>
<SearchResults/>
</LayoutMainColumn>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
await expect(component.locator('main')).toHaveScreenshot();
});
});
......@@ -15,8 +15,9 @@ import * as Layout from 'ui/shared/layout/components';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import Thead from 'ui/shared/TheadSticky';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => {
......@@ -181,13 +182,20 @@ const SearchResultsPageContent = () => {
return (
<>
<HeaderAlert/>
<Header renderSearchBar={ renderSearchBar }/>
<AppErrorBoundary>
<Layout.Content>
{ pageContent }
</Layout.Content>
</AppErrorBoundary>
<HeaderMobile renderSearchBar={ renderSearchBar }/>
<Layout.MainArea>
<Layout.SideBar/>
<Layout.MainColumn>
<HeaderAlert/>
<HeaderDesktop renderSearchBar={ renderSearchBar }/>
<AppErrorBoundary>
<Layout.Content>
{ pageContent }
</Layout.Content>
</AppErrorBoundary>
</Layout.MainColumn>
</Layout.MainArea>
<Layout.Footer/>
</>
);
};
......
......@@ -3,19 +3,22 @@ import React from 'react';
import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import * as Layout from './components';
const LayoutDefault = ({ children }: Props) => {
return (
<Layout.Container>
<Layout.TopRow/>
<HeaderMobile/>
<Layout.MainArea>
<Layout.SideBar/>
<Layout.MainColumn>
<HeaderAlert/>
<Header/>
<HeaderDesktop/>
<AppErrorBoundary>
<Layout.Content>
{ children }
......
......@@ -3,19 +3,22 @@ import React from 'react';
import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import * as Layout from './components';
const LayoutError = ({ children }: Props) => {
return (
<Layout.Container>
<Layout.TopRow/>
<HeaderMobile/>
<Layout.MainArea>
<Layout.SideBar/>
<Layout.MainColumn>
<HeaderAlert/>
<Header/>
<HeaderDesktop/>
<AppErrorBoundary>
<main>
{ children }
......
......@@ -3,21 +3,22 @@ import React from 'react';
import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import * as Layout from './components';
const LayoutHome = ({ children }: Props) => {
return (
<Layout.Container>
<Layout.TopRow/>
<HeaderMobile isHomePage/>
<Layout.MainArea>
<Layout.SideBar/>
<Layout.MainColumn
paddingTop={{ base: '88px', lg: 9 }}
paddingTop={{ base: 6, lg: 9 }}
>
<HeaderAlert/>
<Header isHomePage/>
<AppErrorBoundary>
{ children }
</AppErrorBoundary>
......
......@@ -8,13 +8,8 @@ const LayoutSearchResults = ({ children }: Props) => {
return (
<Layout.Container>
<Layout.MainArea>
<Layout.SideBar/>
<Layout.MainColumn>
{ children }
</Layout.MainColumn>
</Layout.MainArea>
<Layout.Footer/>
<Layout.TopRow/>
{ children }
</Layout.Container>
);
};
......
......@@ -14,7 +14,7 @@ const MainColumn = ({ children, className }: Props) => {
flexGrow={ 1 }
w={{ base: '100%', lg: 'auto' }}
paddingX={{ base: 4, lg: 12 }}
paddingTop={{ base: '138px', lg: 9 }}
paddingTop={{ base: `${ 32 + 60 }px`, lg: 9 }} // 32px is top padding of content area, 60px is search bar height
paddingBottom={ 10 }
>
{ children }
......
import Footer from 'ui/snippets/footer/Footer';
import TopRow from 'ui/snippets/topBar/TopBar';
import Container from './Container';
import Content from './Content';
......@@ -13,9 +14,11 @@ export {
SideBar,
MainColumn,
Footer,
TopRow,
};
// Container
// TopRow
// MainArea
// SideBar
// MainColumn
......
import type { GridProps } from '@chakra-ui/react';
import { Box, Grid, Flex, Text, Link, VStack, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
......@@ -18,7 +19,6 @@ import useFetch from 'lib/hooks/useFetch';
import useIssueUrl from 'lib/hooks/useIssueUrl';
import NetworkAddToWallet from 'ui/shared/NetworkAddToWallet';
import ColorModeToggler from '../header/ColorModeToggler';
import FooterLinkItem from './FooterLinkItem';
import IntTxsIndexingStatus from './IntTxsIndexingStatus';
import getApiVersionUrl from './utils/getApiVersionUrl';
......@@ -96,41 +96,43 @@ const Footer = () => {
const fetch = useFetch();
const { isPending, data: linksData } = useQuery<unknown, ResourceError<unknown>, Array<CustomLinksGroup>>({
const { isPlaceholderData, data: linksData } = useQuery<unknown, ResourceError<unknown>, Array<CustomLinksGroup>>({
queryKey: [ 'footer-links' ],
queryFn: async() => fetch(config.UI.footer.links || '', undefined, { resource: 'footer-links' }),
enabled: Boolean(config.UI.footer.links),
staleTime: Infinity,
placeholderData: [],
});
const colNum = Math.min(linksData?.length || Infinity, MAX_LINKS_COLUMNS) + 1;
const colNum = isPlaceholderData ? 1 : Math.min(linksData?.length || Infinity, MAX_LINKS_COLUMNS) + 1;
return (
<Flex
direction={{ base: 'column', lg: 'row' }}
px={{ base: 4, lg: 12 }}
py={{ base: 4, lg: 9 }}
borderTop="1px solid"
borderColor="divider"
as="footer"
columnGap={{ lg: '32px', xl: '100px' }}
>
<Box flexGrow="1" mb={{ base: 8, lg: 0 }} minW="195px">
<Flex flexWrap="wrap" columnGap={ 8 } rowGap={ 6 }>
<ColorModeToggler/>
{ !config.UI.indexingAlert.intTxs.isHidden && <IntTxsIndexingStatus/> }
<NetworkAddToWallet/>
</Flex>
<Box mt={{ base: 5, lg: '44px' }}>
<Link fontSize="xs" href="https://www.blockscout.com">blockscout.com</Link>
</Box>
<Text mt={ 3 } maxW={{ base: 'unset', lg: '470px' }} fontSize="xs">
Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks.
const renderNetworkInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
return (
<Flex
gridArea={ gridArea }
flexWrap="wrap"
columnGap={ 8 }
rowGap={ 6 }
mb={{ base: 5, lg: 10 }}
_empty={{ display: 'none' }}
>
{ !config.UI.indexingAlert.intTxs.isHidden && <IntTxsIndexingStatus/> }
<NetworkAddToWallet/>
</Flex>
);
}, []);
const renderProjectInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
return (
<Box gridArea={ gridArea }>
<Link fontSize="xs" href="https://www.blockscout.com">blockscout.com</Link>
<Text mt={ 3 } fontSize="xs">
Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks.
</Text>
<VStack spacing={ 1 } mt={ 6 } alignItems="start">
{ apiVersionUrl && (
<Text fontSize="xs">
Backend: <Link href={ apiVersionUrl } target="_blank">{ backendVersionData?.backend_version }</Link>
Backend: <Link href={ apiVersionUrl } target="_blank">{ backendVersionData?.backend_version }</Link>
</Text>
) }
{ frontendLink && (
......@@ -140,64 +142,93 @@ const Footer = () => {
) }
</VStack>
</Box>
<Grid
gap={{ base: 6, lg: config.UI.footer.links && colNum === MAX_LINKS_COLUMNS + 1 ? 2 : 8, xl: 12 }}
gridTemplateColumns={ config.UI.footer.links ?
{
);
}, [ apiVersionUrl, backendVersionData?.backend_version, frontendLink ]);
const containerProps: GridProps = {
as: 'footer',
px: { base: 4, lg: 12 },
py: { base: 4, lg: 9 },
borderTop: '1px solid',
borderColor: 'divider',
gridTemplateColumns: { base: '1fr', lg: 'minmax(auto, 470px) 1fr' },
columnGap: { lg: '32px', xl: '100px' },
};
if (config.UI.footer.links) {
return (
<Grid { ...containerProps }>
<div>
{ renderNetworkInfo() }
{ renderProjectInfo() }
</div>
<Grid
gap={{ base: 6, lg: colNum === MAX_LINKS_COLUMNS + 1 ? 2 : 8, xl: 12 }}
gridTemplateColumns={{
base: 'repeat(auto-fill, 160px)',
lg: `repeat(${ colNum }, 135px)`,
xl: `repeat(${ colNum }, 160px)`,
} :
'auto'
}
}}
justifyContent={{ lg: 'flex-end' }}
mt={{ base: 8, lg: 0 }}
>
{
([
{ title: 'Blockscout', links: BLOCKSCOUT_LINKS },
...(linksData || []),
])
.slice(0, colNum)
.map(linkGroup => (
<Box key={ linkGroup.title }>
<Skeleton fontWeight={ 500 } mb={ 3 } display="inline-block" isLoaded={ !isPlaceholderData }>{ linkGroup.title }</Skeleton>
<VStack spacing={ 1 } alignItems="start">
{ linkGroup.links.map(link => <FooterLinkItem { ...link } key={ link.text } isLoading={ isPlaceholderData }/>) }
</VStack>
</Box>
))
}
</Grid>
</Grid>
);
}
return (
<Grid
{ ...containerProps }
gridTemplateAreas={{
lg: `
"network links-top"
"info links-bottom"
`,
}}
>
{ renderNetworkInfo({ lg: 'network' }) }
{ renderProjectInfo({ lg: 'info' }) }
<Grid
gridArea={{ lg: 'links-bottom' }}
gap={ 1 }
gridTemplateColumns={{
base: 'repeat(auto-fill, 160px)',
lg: 'repeat(3, 160px)',
xl: 'repeat(4, 160px)',
}}
gridTemplateRows={{
base: 'auto',
lg: 'repeat(3, auto)',
xl: 'repeat(2, auto)',
}}
gridAutoFlow={{ base: 'row', lg: 'column' }}
alignContent="start"
justifyContent={{ lg: 'flex-end' }}
mt={{ base: 8, lg: 0 }}
>
<Box>
{ config.UI.footer.links && <Text fontWeight={ 500 } mb={ 3 }>Blockscout</Text> }
<Grid
gap={ 1 }
gridTemplateColumns={
config.UI.footer.links ?
'1fr' :
{
base: 'repeat(auto-fill, 160px)',
lg: 'repeat(3, 160px)',
xl: 'repeat(4, 160px)',
}
}
gridTemplateRows={{
base: 'auto',
lg: config.UI.footer.links ? 'auto' : 'repeat(3, auto)',
xl: config.UI.footer.links ? 'auto' : 'repeat(2, auto)',
}}
gridAutoFlow={{ base: 'row', lg: config.UI.footer.links ? 'row' : 'column' }}
mt={{ base: 0, lg: config.UI.footer.links ? 0 : '100px' }}
>
{ BLOCKSCOUT_LINKS.map(link => <FooterLinkItem { ...link } key={ link.text }/>) }
</Grid>
</Box>
{ config.UI.footer.links && isPending && (
Array.from(Array(3)).map((i, index) => (
<Box key={ index }>
<Skeleton w="100%" h="20px" mb={ 6 }/>
<VStack spacing={ 5 } alignItems="start" mb={ 2 }>
{ Array.from(Array(5)).map((i, index) => <Skeleton w="100%" h="14px" key={ index }/>) }
</VStack>
</Box>
))
) }
{ config.UI.footer.links && linksData && (
linksData.slice(0, MAX_LINKS_COLUMNS).map(linkGroup => (
<Box key={ linkGroup.title }>
<Text fontWeight={ 500 } mb={ 3 }>{ linkGroup.title }</Text>
<VStack spacing={ 1 } alignItems="start">
{ linkGroup.links.map(link => <FooterLinkItem { ...link } key={ link.text }/>) }
</VStack>
</Box>
))
) }
{ BLOCKSCOUT_LINKS.map(link => <FooterLinkItem { ...link } key={ link.text }/>) }
</Grid>
</Flex>
</Grid>
);
};
export default Footer;
export default React.memo(Footer);
import { Center, Icon, Link } from '@chakra-ui/react';
import { Center, Icon, Link, Skeleton } from '@chakra-ui/react';
import React from 'react';
type Props = {
......@@ -6,9 +6,14 @@ type Props = {
iconSize?: string;
text: string;
url: string;
isLoading?: boolean;
}
const FooterLinkItem = ({ icon, iconSize, text, url }: Props) => {
const FooterLinkItem = ({ icon, iconSize, text, url, isLoading }: Props) => {
if (isLoading) {
return <Skeleton my="3px">{ text }</Skeleton>;
}
return (
<Link href={ url } display="flex" alignItems="center" h="30px" variant="secondary" target="_blank" fontSize="xs">
{ icon && (
......
import type { UseCheckboxProps } from '@chakra-ui/checkbox';
import { useCheckbox } from '@chakra-ui/checkbox';
import { useColorMode, useColorModeValue, Icon } from '@chakra-ui/react';
import type {
SystemStyleObject,
ThemingProps,
HTMLChakraProps,
} from '@chakra-ui/system';
import {
chakra,
forwardRef,
omitThemingProps,
} from '@chakra-ui/system';
import { dataAttr, __DEV__ } from '@chakra-ui/utils';
import * as React from 'react';
import moonIcon from 'icons/moon.svg';
import sunIcon from 'icons/sun.svg';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
export interface ColorModeTogglerProps
extends Omit<UseCheckboxProps, 'isIndeterminate'>,
Omit<HTMLChakraProps<'label'>, keyof UseCheckboxProps>,
ThemingProps<'Switch'> {
trackBg?: string;
}
const ColorModeToggler = forwardRef<ColorModeTogglerProps, 'input'>((props, ref) => {
const ownProps = omitThemingProps(props);
const { toggleColorMode, colorMode } = useColorMode();
const {
state,
getInputProps,
getCheckboxProps,
getRootProps,
} = useCheckbox({ ...ownProps, isChecked: colorMode === 'light' });
const trackBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.100');
const thumbBg = 'white';
const transitionProps = getDefaultTransitionProps();
const trackStyles: SystemStyleObject = React.useMemo(() => ({
bgColor: props.trackBg || trackBg,
width: '72px',
height: '32px',
borderRadius: 'full',
display: 'inline-flex',
flexShrink: 0,
justifyContent: 'space-between',
boxSizing: 'content-box',
cursor: 'pointer',
...transitionProps,
transitionDuration: 'ultra-slow',
}), [ props.trackBg, trackBg, transitionProps ]);
const thumbStyles: SystemStyleObject = React.useMemo(() => ({
bg: thumbBg,
boxShadow: 'md',
width: '24px',
height: '24px',
borderRadius: 'md',
position: 'absolute',
transform: state.isChecked ? 'translate(44px, 4px)' : 'translate(4px, 4px)',
...transitionProps,
transitionProperty: 'background-color, transform',
transitionDuration: 'ultra-slow',
}), [ thumbBg, transitionProps, state.isChecked ]);
return (
<chakra.label
{ ...getRootProps({ onChange: toggleColorMode }) }
display="inline-block"
position="relative"
verticalAlign="middle"
lineHeight={ 0 }
>
<chakra.input
{ ...getInputProps({}, ref) }
border="none"
height="1px"
width="1px"
margin="1px"
padding="0"
overflow="hidden"
whiteSpace="nowrap"
position="absolute"
/>
<chakra.div
{ ...getCheckboxProps() }
__css={ trackStyles }
aria-label="Toggle color mode"
>
<Icon
boxSize={ 4 }
margin={ 2 }
zIndex="docked"
as={ moonIcon }
color={ useColorModeValue('blue.300', 'blackAlpha.900') }
{ ...transitionProps }
/>
<chakra.div
data-checked={ dataAttr(state.isChecked) }
data-hover={ dataAttr(state.isHovered) }
__css={ thumbStyles }
/>
<Icon
boxSize={ 5 }
margin={ 1.5 }
zIndex="docked"
as={ sunIcon }
color={ useColorModeValue('blackAlpha.900', 'blue.300') }
{ ...transitionProps }
/>
</chakra.div>
</chakra.label>
);
});
if (__DEV__) {
ColorModeToggler.displayName = 'ColorModeToggler';
}
export default ColorModeToggler;
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import HeaderDesktop from './HeaderDesktop';
test('default view +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<HeaderDesktop/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { HStack, Box } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
type Props = {
renderSearchBar?: () => React.ReactNode;
}
const HeaderDesktop = ({ renderSearchBar }: Props) => {
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
return (
<HStack
as="header"
display={{ base: 'none', lg: 'flex' }}
width="100%"
alignItems="center"
justifyContent="center"
gap={ 12 }
>
<Box width="100%">
{ searchBar }
</Box>
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
</HStack>
);
};
export default React.memo(HeaderDesktop);
import { test, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import HeaderMobile from './HeaderMobile';
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('default view +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
<HeaderMobile/>
</TestApp>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 150 } });
});
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import config from 'configs/app';
import { useScrollDirection } from 'lib/contexts/scrollDirection';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuMobile from 'ui/snippets/profileMenu/ProfileMenuMobile';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import Burger from './Burger';
type Props = {
isHomePage?: boolean;
renderSearchBar?: () => React.ReactNode;
}
const HeaderMobile = ({ isHomePage, renderSearchBar }: Props) => {
const bgColor = useColorModeValue('white', 'black');
const scrollDirection = useScrollDirection();
const { ref, inView } = useInView({ threshold: 1 });
const searchBar = renderSearchBar ? renderSearchBar() : <SearchBar/>;
return (
<Box
ref={ ref }
bgColor={ bgColor }
display={{ base: 'block', lg: 'none' }}
position="sticky"
top="-1px"
left={ 0 }
zIndex="sticky2"
pt="1px"
>
<Flex
as="header"
paddingX={ 4 }
paddingY={ 2 }
bgColor={ bgColor }
width="100%"
alignItems="center"
justifyContent="space-between"
transitionProperty="box-shadow"
transitionDuration="slow"
boxShadow={ !inView && scrollDirection === 'down' ? 'md' : 'none' }
>
<Burger/>
<NetworkLogo/>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
</Flex>
{ !isHomePage && searchBar }
</Box>
);
};
export default React.memo(HeaderMobile);
......@@ -25,7 +25,8 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
const isMobile = useIsMobile();
const handleScroll = React.useCallback(() => {
if (window.pageYOffset !== 0) {
const TOP_BAR_HEIGHT = 36;
if (window.pageYOffset >= TOP_BAR_HEIGHT) {
setIsSticky(true);
} else {
setIsSticky(false);
......@@ -68,12 +69,12 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
onBlur={ onBlur }
onFocus={ onFocus }
w="100%"
backgroundColor={ isHomepage ? 'white' : bgColor }
backgroundColor={ bgColor }
borderRadius={{ base: isHomepage ? 'base' : 'none', lg: 'base' }}
position={{ base: isHomepage ? 'static' : 'fixed', lg: 'static' }}
position={{ base: isHomepage ? 'static' : 'absolute', lg: 'static' }}
top={{ base: isHomepage ? 0 : 55, lg: 0 }}
left="0"
zIndex={{ base: isHomepage ? 'auto' : 'sticky1', lg: 'auto' }}
zIndex={{ base: isHomepage ? 'auto' : '-1', lg: 'auto' }}
paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }}
paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }}
paddingBottom={{ base: isHomepage ? 0 : 4, lg: 0 }}
......
import {
IconButton,
Icon,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useColorMode,
useDisclosure,
Skeleton,
} from '@chakra-ui/react';
import React from 'react';
import * as cookies from 'lib/cookies';
import ColorModeSwitchTheme from './ColorModeSwitchTheme';
import { COLOR_THEMES } from './utils';
const ColorModeSwitch = () => {
const { isOpen, onToggle, onClose } = useDisclosure();
const { setColorMode, colorMode } = useColorMode();
const [ activeHex, setActiveHex ] = React.useState<string>();
const setTheme = React.useCallback((hex: string) => {
const nextTheme = COLOR_THEMES.find((theme) => theme.colors.some((color) => color.hex === hex));
if (!nextTheme) {
return;
}
setColorMode(nextTheme.colorMode);
const varName = nextTheme.colorMode === 'light' ? '--chakra-colors-white' : '--chakra-colors-black';
window.document.documentElement.style.setProperty(varName, hex);
cookies.set(cookies.NAMES.COLOR_MODE_HEX, hex);
}, [ setColorMode ]);
React.useEffect(() => {
const cookieColorMode = cookies.get(cookies.NAMES.COLOR_MODE);
const nextColorMode = (() => {
if (!cookieColorMode) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return colorMode;
})();
const fallbackHex = (COLOR_THEMES.find(theme => theme.colorMode === nextColorMode && theme.colors.length === 1) ?? COLOR_THEMES[0]).colors[0].hex;
const cookieHex = cookies.get(cookies.NAMES.COLOR_MODE_HEX) ?? fallbackHex;
setTheme(cookieHex);
setActiveHex(cookieHex);
// should run only on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ ]);
const handleSelect = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
const hex = event.currentTarget.getAttribute('data-hex');
if (!hex) {
return;
}
setTheme(hex);
setActiveHex(hex);
}, [ setTheme ]);
const activeTheme = COLOR_THEMES.find((theme) => theme.colors.some((color) => color.hex === activeHex));
return (
<Popover placement="bottom-start" isLazy trigger="click" isOpen={ isOpen } onClose={ onClose }>
<PopoverTrigger>
{ activeTheme ? (
<IconButton
variant="simple"
colorScheme="blue"
aria-label="color mode switch"
icon={ <Icon as={ activeTheme.icon } boxSize={ 5 }/> }
boxSize={ 5 }
onClick={ onToggle }
/>
) : <Skeleton boxSize={ 5 } borderRadius="sm"/> }
</PopoverTrigger>
<PopoverContent overflowY="hidden" w="164px" fontSize="sm">
<PopoverBody boxShadow="2xl" p={ 3 }>
{ COLOR_THEMES.map((theme) => <ColorModeSwitchTheme key={ theme.name } { ...theme } onClick={ handleSelect } activeHex={ activeHex }/>) }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default ColorModeSwitch;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment