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

Merge branch 'main' into marketplace-improvements

parents fd130d55 84ed9b9b
...@@ -10,7 +10,7 @@ ...@@ -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). 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 ```sh
docker run -p 3000:3000 --env-file <path-to-your-env-file> ghcr.io/blockscout/frontend:latest 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 ...@@ -33,7 +33,7 @@ We are using following technology stack in the project
- [Yarn](https://yarnpkg.com/) as package manager - [Yarn](https://yarnpkg.com/) as package manager
- [ReactJS](https://reactjs.org/) as UI library - [ReactJS](https://reactjs.org/) as UI library
- [Next.js](https://nextjs.org/) as application framework - [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 - [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 - [Jest](https://jestjs.io/) as JavaScript testing framework
- [Playwright](https://playwright.dev/) as a tool for components visual testing - [Playwright](https://playwright.dev/) as a tool for components visual testing
...@@ -60,6 +60,7 @@ B. Pre-defined configuration: ...@@ -60,6 +60,7 @@ B. Pre-defined configuration:
3. Start your local dev server using the `yarn dev:<config_name>` command. 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`). 4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`).
&nbsp; &nbsp;
## Adding new dependencies ## Adding new dependencies
...@@ -160,7 +161,7 @@ We have 3 pre-configured projects. You can run your test with the desired projec ...@@ -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 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 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 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. 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. *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 ...@@ -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. 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. And you may find the Dev Container setup useful too.
\ No newline at end of file
...@@ -7,4 +7,4 @@ For running app container from freshly built image do ...@@ -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> 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 ...@@ -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` | | 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` | | 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` | | 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` | | invertIconInDarkMode | `boolean` | Pass `true` if icon colors should be inverted in dark mode | - | - | `true` |
&nbsp; &nbsp;
...@@ -188,6 +188,7 @@ Settings for meta tags and OG tags ...@@ -188,6 +188,7 @@ Settings for meta tags and OG tags
| `burnt_fees` | Burnt fees | | `burnt_fees` | Burnt fees |
| `total_reward` | Total block reward | | `total_reward` | Total block reward |
| `nonce` | Block nonce | | `nonce` | Block nonce |
| `miner` | Address of block's miner or validator |
&nbsp; &nbsp;
...@@ -197,7 +198,7 @@ Settings for meta tags and OG tags ...@@ -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_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_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 ##### Address views list
| Id | Description | | Id | Description |
...@@ -272,7 +273,7 @@ Settings for meta tags and OG tags ...@@ -272,7 +273,7 @@ Settings for meta tags and OG tags
## App features ## 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 ### My account
...@@ -287,7 +288,7 @@ Settings for meta tags and OG tags ...@@ -287,7 +288,7 @@ Settings for meta tags and OG tags
### Address verification in "My account" ### 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 | | 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 ...@@ -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'` | | 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'` | | 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'` | | 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'` | | 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'` | | 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'` | | 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 ...@@ -512,13 +513,13 @@ This feature allows users to view tokens that have been bridged from other EVM c
### Safe{Core} address tags ### 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; &nbsp;
### SUAVE chain ### 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 | | 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"> <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 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"/> <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>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/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>
<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 { ...@@ -9,6 +9,7 @@ export enum NAMES {
CONFIRM_EMAIL_PAGE_VIEWED='confirm_email_page_viewed', CONFIRM_EMAIL_PAGE_VIEWED='confirm_email_page_viewed',
TXS_SORT='txs_sort', TXS_SORT='txs_sort',
COLOR_MODE='chakra-ui-color-mode', COLOR_MODE='chakra-ui-color-mode',
COLOR_MODE_HEX='chakra-ui-color-mode-hex',
INDEXING_ALERT='indexing_alert', INDEXING_ALERT='indexing_alert',
ADBLOCK_DETECTED='adblock_detected', ADBLOCK_DETECTED='adblock_detected',
MIXPANEL_DEBUG='_mixpanel_debug', MIXPANEL_DEBUG='_mixpanel_debug',
......
...@@ -24,6 +24,7 @@ import topAccountsIcon from 'icons/top-accounts.svg'; ...@@ -24,6 +24,7 @@ import topAccountsIcon from 'icons/top-accounts.svg';
import transactionsIcon from 'icons/transactions.svg'; import transactionsIcon from 'icons/transactions.svg';
import txnBatchIcon from 'icons/txn_batches.svg'; import txnBatchIcon from 'icons/txn_batches.svg';
import verifiedIcon from 'icons/verified.svg'; import verifiedIcon from 'icons/verified.svg';
import verifyContractIcon from 'icons/verify-contract.svg';
import watchlistIcon from 'icons/watchlist.svg'; import watchlistIcon from 'icons/watchlist.svg';
import { rightLineArrow } from 'lib/html-entities'; import { rightLineArrow } from 'lib/html-entities';
import UserAvatar from 'ui/shared/UserAvatar'; import UserAvatar from 'ui/shared/UserAvatar';
...@@ -176,11 +177,19 @@ export default function useNavItems(): ReturnType { ...@@ -176,11 +177,19 @@ export default function useNavItems(): ReturnType {
isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive), isActive: apiNavItems.some(item => isInternalItem(item) && item.isActive),
subItems: apiNavItems, subItems: apiNavItems,
}, },
config.UI.sidebar.otherLinks.length > 0 ? { {
text: 'Other', text: 'Other',
icon: gearIcon, icon: gearIcon,
subItems: config.UI.sidebar.otherLinks, subItems: [
} : null, {
text: 'Verify contract',
nextRoute: { pathname: '/contract-verification' as const },
icon: verifyContractIcon,
isActive: pathname.startsWith('/contract-verification'),
},
...config.UI.sidebar.otherLinks,
],
},
].filter(Boolean); ].filter(Boolean);
const accountNavItems: ReturnType['accountNavItems'] = [ const accountNavItems: ReturnType['accountNavItems'] = [
......
// https://unicode-table.com // https://symbl.cc/en/
export const asymp = String.fromCharCode(8776); // ~ export const asymp = String.fromCharCode(8776); // ≈
export const tilde = String.fromCharCode(126); // ~
export const hellip = String.fromCharCode(8230); // … export const hellip = String.fromCharCode(8230); // …
export const nbsp = String.fromCharCode(160); // no-break Space export const nbsp = String.fromCharCode(160); // no-break Space
export const thinsp = String.fromCharCode(8201); // thin Space export const thinsp = String.fromCharCode(8201); // thin Space
......
...@@ -12,6 +12,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -12,6 +12,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/accounts': 'Root page', '/accounts': 'Root page',
'/address/[hash]': 'Regular page', '/address/[hash]': 'Regular page',
'/verified-contracts': 'Root page', '/verified-contracts': 'Root page',
'/contract-verification': 'Root page',
'/address/[hash]/contract-verification': 'Regular page', '/address/[hash]/contract-verification': 'Regular page',
'/tokens': 'Root page', '/tokens': 'Root page',
'/token/[hash]': 'Regular page', '/token/[hash]': 'Regular page',
......
...@@ -15,6 +15,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -15,6 +15,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/accounts': DEFAULT_TEMPLATE, '/accounts': DEFAULT_TEMPLATE,
'/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%', '/address/[hash]': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/verified-contracts': DEFAULT_TEMPLATE, '/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%', '/address/[hash]/contract-verification': 'View the account balance, transactions, and other data for %hash% on the %network_title%',
'/tokens': DEFAULT_TEMPLATE, '/tokens': DEFAULT_TEMPLATE,
'/token/[hash]': '%hash%, balances and analytics on the %network_title%', '/token/[hash]': '%hash%, balances and analytics on the %network_title%',
......
...@@ -10,6 +10,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -10,6 +10,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/accounts': 'top accounts', '/accounts': 'top accounts',
'/address/[hash]': 'address details for %hash%', '/address/[hash]': 'address details for %hash%',
'/verified-contracts': 'verified contracts', '/verified-contracts': 'verified contracts',
'/contract-verification': 'verify contract',
'/address/[hash]/contract-verification': 'contract verification for %hash%', '/address/[hash]/contract-verification': 'contract verification for %hash%',
'/tokens': 'tokens', '/tokens': 'tokens',
'/token/[hash]': '%symbol% token details', '/token/[hash]': '%symbol% token details',
......
...@@ -10,7 +10,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -10,7 +10,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/accounts': 'Top accounts', '/accounts': 'Top accounts',
'/address/[hash]': 'Address details', '/address/[hash]': 'Address details',
'/verified-contracts': 'Verified contracts', '/verified-contracts': 'Verified contracts',
'/address/[hash]/contract-verification': 'Contract verification', '/contract-verification': 'Contract verification',
'/address/[hash]/contract-verification': 'Contract verification for address',
'/tokens': 'Tokens', '/tokens': 'Tokens',
'/token/[hash]': 'Token details', '/token/[hash]': 'Token details',
'/token/[hash]/instance/[id]': 'Token Instance', '/token/[hash]/instance/[id]': 'Token Instance',
......
...@@ -10,9 +10,9 @@ const sortTxs = (sorting?: Sort) => (tx1: Transaction, tx2: Transaction) => { ...@@ -10,9 +10,9 @@ const sortTxs = (sorting?: Sort) => (tx1: Transaction, tx2: Transaction) => {
case 'val-asc': case 'val-asc':
return compareBns(tx2.value, tx1.value); return compareBns(tx2.value, tx1.value);
case 'fee-desc': case 'fee-desc':
return compareBns(tx1.fee.value, tx2.fee.value); return compareBns(tx1.fee.value || 0, tx2.fee.value || 0);
case 'fee-asc': case 'fee-asc':
return compareBns(tx2.fee.value, tx1.fee.value); return compareBns(tx2.fee.value || 0, tx1.fee.value || 0);
default: default:
return 0; return 0;
} }
......
...@@ -28,6 +28,7 @@ declare module "nextjs-routes" { ...@@ -28,6 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/auth/unverified-email"> | StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }> | DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| StaticRoute<"/contract-verification">
| StaticRoute<"/csv-export"> | StaticRoute<"/csv-export">
| StaticRoute<"/graphiql"> | StaticRoute<"/graphiql">
| StaticRoute<"/"> | StaticRoute<"/">
......
...@@ -4,12 +4,12 @@ import React from 'react'; ...@@ -4,12 +4,12 @@ import React from 'react';
import type { Props } from 'nextjs/getServerSideProps'; import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs'; import PageNextJs from 'nextjs/PageNextJs';
import ContractVerification from 'ui/pages/ContractVerification'; import ContractVerificationForAddress from 'ui/pages/ContractVerificationForAddress';
const Page: NextPage<Props> = (props: Props) => { const Page: NextPage<Props> = (props: Props) => {
return ( return (
<PageNextJs pathname="/address/[hash]/contract-verification" query={ props }> <PageNextJs pathname="/address/[hash]/contract-verification" query={ props }>
<ContractVerification/> <ContractVerificationForAddress/>
</PageNextJs> </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 { export interface Fee {
type: string; type: string;
value: string; value: string | null;
} }
...@@ -3,7 +3,7 @@ export type HomeStats = { ...@@ -3,7 +3,7 @@ export type HomeStats = {
total_addresses: string; total_addresses: string;
total_transactions: string; total_transactions: string;
average_block_time: number; average_block_time: number;
coin_price: string; coin_price: string | null;
total_gas_used: string; total_gas_used: string;
transactions_today: string; transactions_today: string;
gas_used_today: string; gas_used_today: string;
......
...@@ -4,6 +4,7 @@ export const BLOCK_FIELDS_IDS = [ ...@@ -4,6 +4,7 @@ export const BLOCK_FIELDS_IDS = [
'burnt_fees', 'burnt_fees',
'total_reward', 'total_reward',
'nonce', 'nonce',
'miner',
] as const; ] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>; export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
...@@ -202,19 +202,21 @@ const BlockDetails = ({ query }: Props) => { ...@@ -202,19 +202,21 @@ const BlockDetails = ({ query }: Props) => {
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
<DetailsInfoItem { !config.UI.views.block.hiddenFields?.miner && (
title={ verificationTitle } <DetailsInfoItem
hint="A block producer who successfully included the block onto the blockchain" title={ verificationTitle }
columnGap={ 1 } hint="A block producer who successfully included the block onto the blockchain"
isLoading={ isPlaceholderData } columnGap={ 1 }
>
<AddressEntity
address={ data.miner }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
/> >
{ /* api doesn't return the block processing time yet */ } <AddressEntity
{ /* <Text>{ dayjs.duration(block.minedIn, 'second').humanize(true) }</Text> */ } address={ data.miner }
</DetailsInfoItem> 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 && ( { !isRollup && !totalReward.isEqualTo(ZERO) && !config.UI.views.block.hiddenFields?.total_reward && (
<DetailsInfoItem <DetailsInfoItem
title="Block reward" title="Block reward"
......
...@@ -57,13 +57,15 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -57,13 +57,15 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<span>{ data.size.toLocaleString() } bytes</span> <span>{ data.size.toLocaleString() } bytes</span>
</Skeleton> </Skeleton>
</Flex> </Flex>
<Flex columnGap={ 2 } w="100%"> { !config.UI.views.block.hiddenFields?.miner && (
<Text fontWeight={ 500 }>{ capitalize(getNetworkValidatorTitle()) }</Text> <Flex columnGap={ 2 } w="100%">
<AddressEntity <Text fontWeight={ 500 }>{ capitalize(getNetworkValidatorTitle()) }</Text>
address={ data.miner } <AddressEntity
isLoading={ isLoading } address={ data.miner }
/> isLoading={ isLoading }
</Flex> />
</Flex>
) }
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Txn</Text> <Text fontWeight={ 500 }>Txn</Text>
{ data.tx_count > 0 ? ( { data.tx_count > 0 ? (
......
...@@ -42,7 +42,8 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum ...@@ -42,7 +42,8 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
<Tr> <Tr>
<Th width="125px">Block</Th> <Th width="125px">Block</Th>
<Th width="120px">Size, bytes</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="64px" isNumeric>Txn</Th>
<Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th> <Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && { !isRollup && !config.UI.views.block.hiddenFields?.total_reward &&
......
...@@ -66,12 +66,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -66,12 +66,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
{ data.size.toLocaleString() } { data.size.toLocaleString() }
</Skeleton> </Skeleton>
</Td> </Td>
<Td fontSize="sm"> { !config.UI.views.block.hiddenFields?.miner && (
<AddressEntity <Td fontSize="sm">
address={ data.miner } <AddressEntity
isLoading={ isLoading } address={ data.miner }
/> isLoading={ isLoading }
</Td> />
</Td>
) }
<Td isNumeric fontSize="sm"> <Td isNumeric fontSize="sm">
{ data.tx_count > 0 ? ( { data.tx_count > 0 ? (
<Skeleton isLoaded={ !isLoading } display="inline-block"> <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 React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types'; import type { FormFields } from './types';
import type { SocketMessage } from 'lib/socket/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 { route } from 'nextjs-routes';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import delay from 'lib/delay'; import delay from 'lib/delay';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import ContractVerificationFieldAddress from './fields/ContractVerificationFieldAddress';
import ContractVerificationFieldMethod from './fields/ContractVerificationFieldMethod'; import ContractVerificationFieldMethod from './fields/ContractVerificationFieldMethod';
import ContractVerificationFlattenSourceCode from './methods/ContractVerificationFlattenSourceCode'; import ContractVerificationFlattenSourceCode from './methods/ContractVerificationFlattenSourceCode';
import ContractVerificationMultiPartFile from './methods/ContractVerificationMultiPartFile'; import ContractVerificationMultiPartFile from './methods/ContractVerificationMultiPartFile';
...@@ -29,15 +31,15 @@ import { prepareRequestBody, formatSocketErrors, getDefaultValues, METHOD_LABELS ...@@ -29,15 +31,15 @@ import { prepareRequestBody, formatSocketErrors, getDefaultValues, METHOD_LABELS
interface Props { interface Props {
method?: SmartContractVerificationMethod; method?: SmartContractVerificationMethod;
config: SmartContractVerificationConfig; config: SmartContractVerificationConfig;
hash: string; hash?: string;
} }
const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => { const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Props) => {
const formApi = useForm<FormFields>({ const formApi = useForm<FormFields>({
mode: 'onBlur', 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 submitPromiseResolver = React.useRef<(value: unknown) => void>();
const methodNameRef = React.useRef<string>(); const methodNameRef = React.useRef<string>();
...@@ -47,9 +49,28 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -47,9 +49,28 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => { const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(data) => {
const body = prepareRequestBody(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 { try {
await apiFetch('contract_verification_via', { await apiFetch('contract_verification_via', {
pathParams: { method: data.method.value, hash: hash.toLowerCase() }, pathParams: { method: data.method.value, hash: data.address.toLowerCase() },
fetchParams: { fetchParams: {
method: 'POST', method: 'POST',
body, body,
...@@ -62,7 +83,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -62,7 +83,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
return new Promise((resolve) => { return new Promise((resolve) => {
submitPromiseResolver.current = 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) => { const handleNewSocketMessage: SocketMessage.ContractVerification['handler'] = React.useCallback(async(payload) => {
if (payload.status === 'error') { if (payload.status === 'error') {
...@@ -88,8 +112,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -88,8 +112,8 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
{ send_immediately: true }, { send_immediately: true },
); );
window.location.assign(route({ pathname: '/address/[hash]', query: { hash, tab: 'contract' } })); window.location.assign(route({ pathname: '/address/[hash]', query: { hash: address, tab: 'contract' } }));
}, [ hash, setError, toast ]); }, [ setError, toast, address ]);
const handleSocketError = React.useCallback(() => { const handleSocketError = React.useCallback(() => {
if (!formState.isSubmitting) { if (!formState.isSubmitting) {
...@@ -114,10 +138,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -114,10 +138,10 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
}, [ toast ]); }, [ toast ]);
const channel = useSocketChannel({ const channel = useSocketChannel({
topic: `addresses:${ hash.toLowerCase() }`, topic: `addresses:${ address?.toLowerCase() }`,
onSocketClose: handleSocketError, onSocketClose: handleSocketError,
onSocketError: handleSocketError, onSocketError: handleSocketError,
isDisabled: false, isDisabled: Boolean(address && addressState.error),
}); });
useSocketMessage({ useSocketMessage({
channel, channel,
...@@ -142,7 +166,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -142,7 +166,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
useUpdateEffect(() => { useUpdateEffect(() => {
if (methodValue) { if (methodValue) {
reset(getDefaultValues(methodValue, config)); reset(getDefaultValues(methodValue, config, address || hash));
const methodName = METHOD_LABELS[methodValue]; const methodName = METHOD_LABELS[methodValue];
mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_VERIFICATION, { Status: 'Method selected', Method: methodName }); mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_VERIFICATION, { Status: 'Method selected', Method: methodName });
...@@ -157,11 +181,14 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro ...@@ -157,11 +181,14 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
noValidate noValidate
onSubmit={ handleSubmit(onFormSubmit) } onSubmit={ handleSubmit(onFormSubmit) }
> >
<ContractVerificationFieldMethod <Grid as="section" columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
control={ control } { !hash && <ContractVerificationFieldAddress/> }
methods={ config.verification_options } <ContractVerificationFieldMethod
isDisabled={ formState.isSubmitting } control={ control }
/> methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
</Grid>
{ content } { content }
{ Boolean(method) && ( { Boolean(method) && (
<Button <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 { ...@@ -12,8 +12,6 @@ import {
DarkMode, DarkMode,
ListItem, ListItem,
OrderedList, OrderedList,
Grid,
Box,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form'; import type { ControllerRenderProps, Control } from 'react-hook-form';
...@@ -99,43 +97,40 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props ...@@ -99,43 +97,40 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
}, []); }, []);
return ( return (
<section> <>
<Grid columnGap="30px" rowGap={{ base: 2, lg: 4 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}> <div>
<div> <chakra.span fontWeight={ 500 } fontSize="lg" fontFamily="heading">
<Box mb={ 5 }> Currently, Blockscout supports { methods.length } contract verification methods
<chakra.span fontWeight={ 500 } fontSize="lg" fontFamily="heading"> </chakra.span>
Currently, Blockscout supports { methods.length } contract verification methods <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> </chakra.span>
<Popover trigger="hover" isLazy placement={ isMobile ? 'bottom-end' : 'right-start' } offset={ [ -8, 8 ] }> </PopoverTrigger>
<PopoverTrigger> <Portal>
<chakra.span display="inline-block" ml={ 1 } cursor="pointer" verticalAlign="middle" h="22px"> <PopoverContent bgColor={ tooltipBg } w={{ base: '300px', lg: '380px' }}>
<Icon as={ infoIcon } boxSize={ 5 } color="link" _hover={{ color: 'link_hovered' }}/> <PopoverArrow bgColor={ tooltipBg }/>
</chakra.span> <PopoverBody color="white">
</PopoverTrigger> <DarkMode>
<Portal> <span>Currently, Blockscout supports { methods.length } methods:</span>
<PopoverContent bgColor={ tooltipBg } w={{ base: '300px', lg: '380px' }}> <OrderedList>
<PopoverArrow bgColor={ tooltipBg }/> { methods.map(renderPopoverListItem) }
<PopoverBody color="white"> </OrderedList>
<DarkMode> </DarkMode>
<span>Currently, Blockscout supports { methods.length } methods:</span> </PopoverBody>
<OrderedList> </PopoverContent>
{ methods.map(renderPopoverListItem) } </Portal>
</OrderedList> </Popover>
</DarkMode> </div>
</PopoverBody> <div/>
</PopoverContent> <Controller
</Portal> name="method"
</Popover> control={ control }
</Box> render={ renderControl }
<Controller rules={{ required: true }}
name="method" />
control={ control } </>
render={ renderControl }
rules={{ required: true }}
/>
</div>
</Grid>
</section>
); );
}; };
......
...@@ -12,6 +12,7 @@ interface MethodOption { ...@@ -12,6 +12,7 @@ interface MethodOption {
} }
export interface FormFieldsFlattenSourceCode { export interface FormFieldsFlattenSourceCode {
address: string;
method: MethodOption; method: MethodOption;
is_yul: boolean; is_yul: boolean;
name: string | undefined; name: string | undefined;
...@@ -26,6 +27,7 @@ export interface FormFieldsFlattenSourceCode { ...@@ -26,6 +27,7 @@ export interface FormFieldsFlattenSourceCode {
} }
export interface FormFieldsStandardInput { export interface FormFieldsStandardInput {
address: string;
method: MethodOption; method: MethodOption;
name: string; name: string;
compiler: Option | null; compiler: Option | null;
...@@ -35,12 +37,14 @@ export interface FormFieldsStandardInput { ...@@ -35,12 +37,14 @@ export interface FormFieldsStandardInput {
} }
export interface FormFieldsSourcify { export interface FormFieldsSourcify {
address: string;
method: MethodOption; method: MethodOption;
sources: Array<File>; sources: Array<File>;
contract_index?: Option; contract_index?: Option;
} }
export interface FormFieldsMultiPartFile { export interface FormFieldsMultiPartFile {
address: string;
method: MethodOption; method: MethodOption;
compiler: Option | null; compiler: Option | null;
evm_version: Option | null; evm_version: Option | null;
...@@ -51,6 +55,7 @@ export interface FormFieldsMultiPartFile { ...@@ -51,6 +55,7 @@ export interface FormFieldsMultiPartFile {
} }
export interface FormFieldsVyperContract { export interface FormFieldsVyperContract {
address: string;
method: MethodOption; method: MethodOption;
name: string; name: string;
evm_version: Option | null; evm_version: Option | null;
...@@ -60,6 +65,7 @@ export interface FormFieldsVyperContract { ...@@ -60,6 +65,7 @@ export interface FormFieldsVyperContract {
} }
export interface FormFieldsVyperMultiPartFile { export interface FormFieldsVyperMultiPartFile {
address: string;
method: MethodOption; method: MethodOption;
compiler: Option | null; compiler: Option | null;
evm_version: Option | null; evm_version: Option | null;
...@@ -68,6 +74,7 @@ export interface FormFieldsVyperMultiPartFile { ...@@ -68,6 +74,7 @@ export interface FormFieldsVyperMultiPartFile {
} }
export interface FormFieldsVyperStandardInput { export interface FormFieldsVyperStandardInput {
address: string;
method: MethodOption; method: MethodOption;
compiler: Option | null; compiler: Option | null;
sources: Array<File>; 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> = { ...@@ -37,6 +37,7 @@ export const METHOD_LABELS: Record<SmartContractVerificationMethod, string> = {
export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> = { export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> = {
'flattened-code': { 'flattened-code': {
address: '',
method: { method: {
value: 'flattened-code' as const, value: 'flattened-code' as const,
label: METHOD_LABELS['flattened-code'], label: METHOD_LABELS['flattened-code'],
...@@ -53,6 +54,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -53,6 +54,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
libraries: [], libraries: [],
}, },
'standard-input': { 'standard-input': {
address: '',
method: { method: {
value: 'standard-input' as const, value: 'standard-input' as const,
label: METHOD_LABELS['standard-input'], label: METHOD_LABELS['standard-input'],
...@@ -64,6 +66,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -64,6 +66,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
constructor_args: '', constructor_args: '',
}, },
sourcify: { sourcify: {
address: '',
method: { method: {
value: 'sourcify' as const, value: 'sourcify' as const,
label: METHOD_LABELS.sourcify, label: METHOD_LABELS.sourcify,
...@@ -71,6 +74,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -71,6 +74,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
sources: [], sources: [],
}, },
'multi-part': { 'multi-part': {
address: '',
method: { method: {
value: 'multi-part' as const, value: 'multi-part' as const,
label: METHOD_LABELS['multi-part'], label: METHOD_LABELS['multi-part'],
...@@ -83,6 +87,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -83,6 +87,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
libraries: [], libraries: [],
}, },
'vyper-code': { 'vyper-code': {
address: '',
method: { method: {
value: 'vyper-code' as const, value: 'vyper-code' as const,
label: METHOD_LABELS['vyper-code'], label: METHOD_LABELS['vyper-code'],
...@@ -94,6 +99,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -94,6 +99,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
constructor_args: '', constructor_args: '',
}, },
'vyper-multi-part': { 'vyper-multi-part': {
address: '',
method: { method: {
value: 'vyper-multi-part' as const, value: 'vyper-multi-part' as const,
label: METHOD_LABELS['vyper-multi-part'], label: METHOD_LABELS['vyper-multi-part'],
...@@ -103,6 +109,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -103,6 +109,7 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
sources: [], sources: [],
}, },
'vyper-standard-input': { 'vyper-standard-input': {
address: '',
method: { method: {
value: 'vyper-standard-input' as const, value: 'vyper-standard-input' as const,
label: METHOD_LABELS['vyper-standard-input'], label: METHOD_LABELS['vyper-standard-input'],
...@@ -112,8 +119,8 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields> ...@@ -112,8 +119,8 @@ export const DEFAULT_VALUES: Record<SmartContractVerificationMethod, FormFields>
}, },
}; };
export function getDefaultValues(method: SmartContractVerificationMethod, config: SmartContractVerificationConfig) { export function getDefaultValues(method: SmartContractVerificationMethod, config: SmartContractVerificationConfig, hash?: string) {
const defaultValues = DEFAULT_VALUES[method]; const defaultValues = { ...DEFAULT_VALUES[method], address: hash };
if ('evm_version' in defaultValues) { if ('evm_version' in defaultValues) {
if (method === 'flattened-code' || method === 'multi-part') { if (method === 'flattened-code' || method === 'multi-part') {
......
...@@ -66,7 +66,7 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => { ...@@ -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> <Skeleton isLoaded={ !isLoading } textTransform="capitalize">{ getNetworkValidatorTitle() }</Skeleton>
<AddressEntity <AddressEntity
......
...@@ -118,7 +118,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -118,7 +118,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
{ tx.stability_fee ? ( { tx.stability_fee ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/> <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> </Skeleton>
) } ) }
......
...@@ -104,7 +104,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -104,7 +104,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
{ tx.stability_fee ? ( { tx.stability_fee ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/> <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> </Skeleton>
) } ) }
......
...@@ -116,20 +116,22 @@ const BlockPageContent = () => { ...@@ -116,20 +116,22 @@ const BlockPageContent = () => {
const title = blockQuery.data?.type === 'reorg' ? `Reorged block #${ blockQuery.data?.height }` : `Block #${ blockQuery.data?.height }`; const title = blockQuery.data?.type === 'reorg' ? `Reorged block #${ blockQuery.data?.height }` : `Block #${ blockQuery.data?.height }`;
const titleSecondRow = ( const titleSecondRow = (
<> <>
<Skeleton { !config.UI.views.block.hiddenFields?.miner && (
isLoaded={ !blockQuery.isPlaceholderData } <Skeleton
fontFamily="heading" isLoaded={ !blockQuery.isPlaceholderData }
display="flex" fontFamily="heading"
minW={ 0 } display="flex"
columnGap={ 2 } minW={ 0 }
fontWeight={ 500 } columnGap={ 2 }
> fontWeight={ 500 }
<chakra.span flexShrink={ 0 }> >
{ config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by' } <chakra.span flexShrink={ 0 }>
</chakra.span> { config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by' }
<AddressEntity address={ blockQuery.data?.miner }/> </chakra.span>
</Skeleton> <AddressEntity address={ blockQuery.data?.miner }/>
<NetworkExplorers type="block" pathParam={ heightOrHash } ml={{ base: 3, lg: 'auto' }}/> </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 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 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 ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
const ContractVerification = () => { const ContractVerification = () => {
const appProps = useAppContext(); const configQuery = useFormConfigQuery(true);
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 content = (() => { const content = (() => {
if (configQuery.isError || !hash || contractQuery.isError) { if (configQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
if (configQuery.isPending || contractQuery.isPending || isVerifiedContract) { if (configQuery.isPending) {
return <ContentLoader/>; return <ContentLoader/>;
} }
return ( return (
<ContractVerificationForm <ContractVerificationForm config={ configQuery.data }/>
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 ( return (
<> <>
<PageTitle <PageTitle title="Verify & publish contract"/>
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 } { 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 React from 'react';
import config from 'configs/app'; import config from 'configs/app';
...@@ -31,16 +31,14 @@ const Home = () => { ...@@ -31,16 +31,14 @@ const Home = () => {
fontWeight={ 600 } fontWeight={ 600 }
color={ config.UI.homepage.plate.textColor } color={ config.UI.homepage.plate.textColor }
> >
Welcome to { config.chain.name } explorer { config.chain.name } explorer
</Heading> </Heading>
<Box display={{ base: 'none', lg: 'flex' }}> <Box display={{ base: 'none', lg: 'flex' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> } { config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> } { config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> }
</Box> </Box>
</Flex> </Flex>
<LightMode> <SearchBar isHomepage/>
<SearchBar isHomepage/>
</LightMode>
</Box> </Box>
<Stats/> <Stats/>
<ChainIndicators/> <ChainIndicators/>
......
...@@ -8,8 +8,6 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; ...@@ -8,8 +8,6 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app'; import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl'; 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'; import SearchResults from './SearchResults';
...@@ -47,17 +45,12 @@ test.describe('search by name ', () => { ...@@ -47,17 +45,12 @@ test.describe('search by name ', () => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
}); });
...@@ -78,17 +71,12 @@ test('search by address hash +@mobile', async({ mount, page }) => { ...@@ -78,17 +71,12 @@ test('search by address hash +@mobile', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
test('search by block number +@mobile', async({ mount, page }) => { test('search by block number +@mobile', async({ mount, page }) => {
...@@ -109,17 +97,12 @@ 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( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
test('search by block hash +@mobile', async({ mount, page }) => { test('search by block hash +@mobile', async({ mount, page }) => {
...@@ -139,17 +122,12 @@ 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( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
test('search by tx hash +@mobile', async({ mount, page }) => { test('search by tx hash +@mobile', async({ mount, page }) => {
...@@ -169,17 +147,12 @@ 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( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
test.describe('with apps', () => { test.describe('with apps', () => {
...@@ -228,16 +201,11 @@ test.describe('with apps', () => { ...@@ -228,16 +201,11 @@ test.describe('with apps', () => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<LayoutMainColumn> <SearchResults/>
<SearchResults/>
</LayoutMainColumn>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
await expect(component).toHaveScreenshot({ await expect(component.locator('main')).toHaveScreenshot();
mask: [ page.locator('header'), page.locator('form') ],
maskColor: configs.maskColor,
});
}); });
}); });
...@@ -15,8 +15,9 @@ import * as Layout from 'ui/shared/layout/components'; ...@@ -15,8 +15,9 @@ import * as Layout from 'ui/shared/layout/components';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import Thead from 'ui/shared/TheadSticky'; import Thead from 'ui/shared/TheadSticky';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert'; 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'; import useSearchQuery from 'ui/snippets/searchBar/useSearchQuery';
const SearchResultsPageContent = () => { const SearchResultsPageContent = () => {
...@@ -181,13 +182,20 @@ const SearchResultsPageContent = () => { ...@@ -181,13 +182,20 @@ const SearchResultsPageContent = () => {
return ( return (
<> <>
<HeaderAlert/> <HeaderMobile renderSearchBar={ renderSearchBar }/>
<Header renderSearchBar={ renderSearchBar }/> <Layout.MainArea>
<AppErrorBoundary> <Layout.SideBar/>
<Layout.Content> <Layout.MainColumn>
{ pageContent } <HeaderAlert/>
</Layout.Content> <HeaderDesktop renderSearchBar={ renderSearchBar }/>
</AppErrorBoundary> <AppErrorBoundary>
<Layout.Content>
{ pageContent }
</Layout.Content>
</AppErrorBoundary>
</Layout.MainColumn>
</Layout.MainArea>
<Layout.Footer/>
</> </>
); );
}; };
......
...@@ -3,19 +3,22 @@ import React from 'react'; ...@@ -3,19 +3,22 @@ import React from 'react';
import type { Props } from './types'; import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert'; 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'; import * as Layout from './components';
const LayoutDefault = ({ children }: Props) => { const LayoutDefault = ({ children }: Props) => {
return ( return (
<Layout.Container> <Layout.Container>
<Layout.TopRow/>
<HeaderMobile/>
<Layout.MainArea> <Layout.MainArea>
<Layout.SideBar/> <Layout.SideBar/>
<Layout.MainColumn> <Layout.MainColumn>
<HeaderAlert/> <HeaderAlert/>
<Header/> <HeaderDesktop/>
<AppErrorBoundary> <AppErrorBoundary>
<Layout.Content> <Layout.Content>
{ children } { children }
......
...@@ -3,19 +3,22 @@ import React from 'react'; ...@@ -3,19 +3,22 @@ import React from 'react';
import type { Props } from './types'; import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert'; 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'; import * as Layout from './components';
const LayoutError = ({ children }: Props) => { const LayoutError = ({ children }: Props) => {
return ( return (
<Layout.Container> <Layout.Container>
<Layout.TopRow/>
<HeaderMobile/>
<Layout.MainArea> <Layout.MainArea>
<Layout.SideBar/> <Layout.SideBar/>
<Layout.MainColumn> <Layout.MainColumn>
<HeaderAlert/> <HeaderAlert/>
<Header/> <HeaderDesktop/>
<AppErrorBoundary> <AppErrorBoundary>
<main> <main>
{ children } { children }
......
...@@ -3,21 +3,22 @@ import React from 'react'; ...@@ -3,21 +3,22 @@ import React from 'react';
import type { Props } from './types'; import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import Header from 'ui/snippets/header/Header';
import HeaderAlert from 'ui/snippets/header/HeaderAlert'; import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderMobile from 'ui/snippets/header/HeaderMobile';
import * as Layout from './components'; import * as Layout from './components';
const LayoutHome = ({ children }: Props) => { const LayoutHome = ({ children }: Props) => {
return ( return (
<Layout.Container> <Layout.Container>
<Layout.TopRow/>
<HeaderMobile isHomePage/>
<Layout.MainArea> <Layout.MainArea>
<Layout.SideBar/> <Layout.SideBar/>
<Layout.MainColumn <Layout.MainColumn
paddingTop={{ base: '88px', lg: 9 }} paddingTop={{ base: 6, lg: 9 }}
> >
<HeaderAlert/> <HeaderAlert/>
<Header isHomePage/>
<AppErrorBoundary> <AppErrorBoundary>
{ children } { children }
</AppErrorBoundary> </AppErrorBoundary>
......
...@@ -8,13 +8,8 @@ const LayoutSearchResults = ({ children }: Props) => { ...@@ -8,13 +8,8 @@ const LayoutSearchResults = ({ children }: Props) => {
return ( return (
<Layout.Container> <Layout.Container>
<Layout.MainArea> <Layout.TopRow/>
<Layout.SideBar/> { children }
<Layout.MainColumn>
{ children }
</Layout.MainColumn>
</Layout.MainArea>
<Layout.Footer/>
</Layout.Container> </Layout.Container>
); );
}; };
......
...@@ -14,7 +14,7 @@ const MainColumn = ({ children, className }: Props) => { ...@@ -14,7 +14,7 @@ const MainColumn = ({ children, className }: Props) => {
flexGrow={ 1 } flexGrow={ 1 }
w={{ base: '100%', lg: 'auto' }} w={{ base: '100%', lg: 'auto' }}
paddingX={{ base: 4, lg: 12 }} 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 } paddingBottom={ 10 }
> >
{ children } { children }
......
import Footer from 'ui/snippets/footer/Footer'; import Footer from 'ui/snippets/footer/Footer';
import TopRow from 'ui/snippets/topBar/TopBar';
import Container from './Container'; import Container from './Container';
import Content from './Content'; import Content from './Content';
...@@ -13,9 +14,11 @@ export { ...@@ -13,9 +14,11 @@ export {
SideBar, SideBar,
MainColumn, MainColumn,
Footer, Footer,
TopRow,
}; };
// Container // Container
// TopRow
// MainArea // MainArea
// SideBar // SideBar
// MainColumn // MainColumn
......
import type { GridProps } from '@chakra-ui/react';
import { Box, Grid, Flex, Text, Link, VStack, Skeleton } from '@chakra-ui/react'; import { Box, Grid, Flex, Text, Link, VStack, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
...@@ -18,7 +19,6 @@ import useFetch from 'lib/hooks/useFetch'; ...@@ -18,7 +19,6 @@ import useFetch from 'lib/hooks/useFetch';
import useIssueUrl from 'lib/hooks/useIssueUrl'; import useIssueUrl from 'lib/hooks/useIssueUrl';
import NetworkAddToWallet from 'ui/shared/NetworkAddToWallet'; import NetworkAddToWallet from 'ui/shared/NetworkAddToWallet';
import ColorModeToggler from '../header/ColorModeToggler';
import FooterLinkItem from './FooterLinkItem'; import FooterLinkItem from './FooterLinkItem';
import IntTxsIndexingStatus from './IntTxsIndexingStatus'; import IntTxsIndexingStatus from './IntTxsIndexingStatus';
import getApiVersionUrl from './utils/getApiVersionUrl'; import getApiVersionUrl from './utils/getApiVersionUrl';
...@@ -96,41 +96,43 @@ const Footer = () => { ...@@ -96,41 +96,43 @@ const Footer = () => {
const fetch = useFetch(); 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' ], queryKey: [ 'footer-links' ],
queryFn: async() => fetch(config.UI.footer.links || '', undefined, { resource: 'footer-links' }), queryFn: async() => fetch(config.UI.footer.links || '', undefined, { resource: 'footer-links' }),
enabled: Boolean(config.UI.footer.links), enabled: Boolean(config.UI.footer.links),
staleTime: Infinity, 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 ( const renderNetworkInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
<Flex return (
direction={{ base: 'column', lg: 'row' }} <Flex
px={{ base: 4, lg: 12 }} gridArea={ gridArea }
py={{ base: 4, lg: 9 }} flexWrap="wrap"
borderTop="1px solid" columnGap={ 8 }
borderColor="divider" rowGap={ 6 }
as="footer" mb={{ base: 5, lg: 10 }}
columnGap={{ lg: '32px', xl: '100px' }} _empty={{ display: 'none' }}
> >
<Box flexGrow="1" mb={{ base: 8, lg: 0 }} minW="195px"> { !config.UI.indexingAlert.intTxs.isHidden && <IntTxsIndexingStatus/> }
<Flex flexWrap="wrap" columnGap={ 8 } rowGap={ 6 }> <NetworkAddToWallet/>
<ColorModeToggler/> </Flex>
{ !config.UI.indexingAlert.intTxs.isHidden && <IntTxsIndexingStatus/> } );
<NetworkAddToWallet/> }, []);
</Flex>
<Box mt={{ base: 5, lg: '44px' }}> const renderProjectInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
<Link fontSize="xs" href="https://www.blockscout.com">blockscout.com</Link> return (
</Box> <Box gridArea={ gridArea }>
<Text mt={ 3 } maxW={{ base: 'unset', lg: '470px' }} fontSize="xs"> <Link fontSize="xs" href="https://www.blockscout.com">blockscout.com</Link>
Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks. <Text mt={ 3 } fontSize="xs">
Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks.
</Text> </Text>
<VStack spacing={ 1 } mt={ 6 } alignItems="start"> <VStack spacing={ 1 } mt={ 6 } alignItems="start">
{ apiVersionUrl && ( { apiVersionUrl && (
<Text fontSize="xs"> <Text fontSize="xs">
Backend: <Link href={ apiVersionUrl } target="_blank">{ backendVersionData?.backend_version }</Link> Backend: <Link href={ apiVersionUrl } target="_blank">{ backendVersionData?.backend_version }</Link>
</Text> </Text>
) } ) }
{ frontendLink && ( { frontendLink && (
...@@ -140,64 +142,93 @@ const Footer = () => { ...@@ -140,64 +142,93 @@ const Footer = () => {
) } ) }
</VStack> </VStack>
</Box> </Box>
<Grid );
gap={{ base: 6, lg: config.UI.footer.links && colNum === MAX_LINKS_COLUMNS + 1 ? 2 : 8, xl: 12 }} }, [ apiVersionUrl, backendVersionData?.backend_version, frontendLink ]);
gridTemplateColumns={ config.UI.footer.links ?
{ 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)', base: 'repeat(auto-fill, 160px)',
lg: `repeat(${ colNum }, 135px)`, lg: `repeat(${ colNum }, 135px)`,
xl: `repeat(${ colNum }, 160px)`, 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> { BLOCKSCOUT_LINKS.map(link => <FooterLinkItem { ...link } key={ link.text }/>) }
{ 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>
))
) }
</Grid> </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'; import React from 'react';
type Props = { type Props = {
...@@ -6,9 +6,14 @@ type Props = { ...@@ -6,9 +6,14 @@ type Props = {
iconSize?: string; iconSize?: string;
text: string; text: string;
url: 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 ( return (
<Link href={ url } display="flex" alignItems="center" h="30px" variant="secondary" target="_blank" fontSize="xs"> <Link href={ url } display="flex" alignItems="center" h="30px" variant="secondary" target="_blank" fontSize="xs">
{ icon && ( { 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 ...@@ -25,7 +25,8 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleScroll = React.useCallback(() => { const handleScroll = React.useCallback(() => {
if (window.pageYOffset !== 0) { const TOP_BAR_HEIGHT = 36;
if (window.pageYOffset >= TOP_BAR_HEIGHT) {
setIsSticky(true); setIsSticky(true);
} else { } else {
setIsSticky(false); setIsSticky(false);
...@@ -68,12 +69,12 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid ...@@ -68,12 +69,12 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
onBlur={ onBlur } onBlur={ onBlur }
onFocus={ onFocus } onFocus={ onFocus }
w="100%" w="100%"
backgroundColor={ isHomepage ? 'white' : bgColor } backgroundColor={ bgColor }
borderRadius={{ base: isHomepage ? 'base' : 'none', lg: 'base' }} 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 }} top={{ base: isHomepage ? 0 : 55, lg: 0 }}
left="0" left="0"
zIndex={{ base: isHomepage ? 'auto' : 'sticky1', lg: 'auto' }} zIndex={{ base: isHomepage ? 'auto' : '-1', lg: 'auto' }}
paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }} paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }}
paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }} paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }}
paddingBottom={{ base: isHomepage ? 0 : 4, 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