Commit d5863de0 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Account v2 (#2262)

* simple profile button and auth modal layout

* connect email and code screens to API

* add screens to modal for wallet authentication

* migrate to pin input

* user profile menu

* refactor otp field

* fix passing set-cookie from api response

* add wallet info into profile menu

* add mobile menu

* show connected wallet address in button

* my profile page re-design

* custom behaviour of connect button on dapp page

* style pin input

* add logout

* handle case when account is disabled

* handle case when wc is disabled

* remove old components

* refactoring

* workflow to link wallet or email to account

* link wallet from profile

* show better OTP code errors

* add email alert on watchlist and verified addresses pages

* deprecate env and remove old code

* remove code for unverified email page

* add auth guard to address action items

* move useRedirectForInvalidAuthToken hook

* add mixpanel events

* refetch csrf token after login and fix connect wallet from contract page

* Add NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY env

* migrate to reCAPTCHA v3

* resend code and change email from profile page

* better wallet sign-in message error

* fix demo envs

* update some screenshots

* profile button design fixes

* fix behaviour "connect wallet" button on contract page

* fix linking email and wallet to existing account

* bug fixes

* restore the login page

* update screenshots

* tests for auth modal and user profile

* add name field to profile page and write some tests

* [skip ci] clean up and more tests

* update texts

* change text once more

* fix verified email checkmark behaviour

* pass api error to the toast while signing in with wallet

* [skip ci] disable email field on profile page

* bug fixes

* update screenshot

* Blockscout account V2

Fixes #2029

* fix texts and button paddings

* Form fields refactoring (#2320)

* text and address fields for watchlist form

* checkbox field component

* refactor private tags form

* refactor api keys and custom abi

* refactor verifiy address and token info forms (pt. 1)

* refactor token info forms (pt. 2)

* refactor token info forms (pt. 3)

* refactor public tags form

* refactor contract verification form

* refactor contract audit form

* refactor auth, profile and csv export forms

* renaming and moving

* more refactoring and test fixes

---------
Co-authored-by: default avataraagaev <alik@agaev.me>
parent 8c4a4ad8
NEXT_PUBLIC_SENTRY_DSN=https://sentry.io
SENTRY_CSP_REPORT_URI=https://sentry.io
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
......
......@@ -7,7 +7,7 @@ const RESTRICTED_MODULES = {
{ name: 'playwright/TestApp', message: 'Please use render() fixture from test() function of playwright/lib module' },
{
name: '@chakra-ui/react',
importNames: [ 'Popover', 'Menu', 'useToast' ],
importNames: [ 'Popover', 'Menu', 'PinInput', 'useToast' ],
message: 'Please use corresponding component or hook from ui/shared/chakra component instead',
},
{
......
import type { Feature } from './types';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import app from '../app';
import services from '../services';
import { getEnvValue } from '../utils';
const authUrl = stripTrailingSlash(getEnvValue('NEXT_PUBLIC_AUTH_URL') || app.baseUrl);
const logoutUrl = (() => {
try {
const envUrl = getEnvValue('NEXT_PUBLIC_LOGOUT_URL');
const auth0ClientId = getEnvValue('NEXT_PUBLIC_AUTH0_CLIENT_ID');
const returnUrl = authUrl + '/auth/logout';
if (!envUrl || !auth0ClientId) {
throw Error();
}
const url = new URL(envUrl);
url.searchParams.set('client_id', auth0ClientId);
url.searchParams.set('returnTo', returnUrl);
return url.toString();
} catch (error) {
return;
}
})();
const title = 'My account';
const config: Feature<{ authUrl: string; logoutUrl: string }> = (() => {
if (
getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' &&
authUrl &&
logoutUrl
) {
const config: Feature<{ isEnabled: true; recaptchaSiteKey: string }> = (() => {
if (getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' && services.reCaptchaV3.siteKey) {
return Object.freeze({
title,
isEnabled: true,
authUrl,
logoutUrl,
recaptchaSiteKey: services.reCaptchaV3.siteKey,
});
}
......
......@@ -5,12 +5,12 @@ import services from '../services';
const title = 'Export data to CSV file';
const config: Feature<{ reCaptcha: { siteKey: string }}> = (() => {
if (services.reCaptcha.siteKey) {
if (services.reCaptchaV3.siteKey) {
return Object.freeze({
title,
isEnabled: true,
reCaptcha: {
siteKey: services.reCaptcha.siteKey,
siteKey: services.reCaptchaV3.siteKey,
},
});
}
......
......@@ -9,7 +9,7 @@ const apiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const title = 'Public tag submission';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (services.reCaptcha.siteKey && addressMetadata.isEnabled && apiHost) {
if (services.reCaptchaV3.siteKey && addressMetadata.isEnabled && apiHost) {
return Object.freeze({
title,
isEnabled: true,
......
import { getEnvValue } from './utils';
export default Object.freeze({
reCaptcha: {
siteKey: getEnvValue('NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY'),
reCaptchaV3: {
siteKey: getEnvValue('NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY'),
},
});
......@@ -49,4 +49,4 @@ NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
NEXT_PUBLIC_STATS_API_HOST=https://localhost:3004
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://localhost:3006
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
......@@ -52,5 +52,5 @@ NEXT_PUBLIC_CONTRACT_INFO_API_HOST=http://localhost:3005
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
......@@ -148,4 +148,15 @@ function printDeprecationWarning(envsMap: Record<string, string>) {
console.warn('The NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR and NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND variables are now deprecated and will be removed in the next release. Please migrate to the NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG variable.');
console.log('❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗\n');
}
if (
envsMap.NEXT_PUBLIC_AUTH0_CLIENT_ID ||
envsMap.NEXT_PUBLIC_AUTH_URL ||
envsMap.NEXT_PUBLIC_LOGOUT_URL
) {
console.log('❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗');
// eslint-disable-next-line max-len
console.warn('The NEXT_PUBLIC_AUTH0_CLIENT_ID, NEXT_PUBLIC_AUTH_URL and NEXT_PUBLIC_LOGOUT_URL variables are now deprecated and will be removed in the next release.');
console.log('❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗❗\n');
}
}
......@@ -839,7 +839,7 @@ const schema = yup
// 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: yup.string(),
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: yup.string(),
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: yup.string(),
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: yup.string(),
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: yup.string(),
......
......@@ -3,7 +3,7 @@ NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
......
......@@ -77,7 +77,7 @@ frontend:
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY
......@@ -83,8 +83,8 @@ frontend:
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY
......@@ -10,4 +10,5 @@
| NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER | `boolean` | Set to false if network doesn't have gas tracker | - | `true` | `false` | - | v1.25.0 | Replaced by NEXT_PUBLIC_GAS_TRACKER_ENABLED |
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` | v1.12.0 | v1.29.0 | Replaced by NEXT_PUBLIC_NETWORK_SECONDARY_COIN_SYMBOL |
| NEXT_PUBLIC_SWAP_BUTTON_URL | `string` | Application ID in the marketplace or website URL | - | - | `uniswap` | v1.24.0 | v1.31.0 | Replaced by NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS |
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` | v1.0.x+ | v1.35.0 | Replaces by NEXT_PUBLIC_HOMEPAGE_STATS
\ No newline at end of file
| NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME | `boolean` | Set to false if average block time is useless for the network | - | `true` | `false` | v1.0.x+ | v1.35.0 | Replaced by NEXT_PUBLIC_HOMEPAGE_STATS |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Google reCAPTCHA v2 site key | - | - | `<your-secret>` | v1.36.0 | Replaced by NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY |
......@@ -342,9 +342,10 @@ Settings for meta tags, OG tags and SEO
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED | `boolean` | Set to true if network has account feature | Required | - | `true` | v1.0.x+ |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` | Client id for [Auth0](https://auth0.com/) provider | Required | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_AUTH_URL | `string` | Account auth base url; it is used for building login URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/auth0`) and logout return URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/logout`); if not provided the base app URL will be used instead | Required | - | `https://blockscout.com` | v1.0.x+ |
| NEXT_PUBLIC_LOGOUT_URL | `string` | Account logout url. Required if account is supported for the app instance. | Required | - | `https://blockscoutcom.us.auth0.com/v2/logout` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `boolean` | See [below](ENVS.md#google-recaptcha) | Required | - | `<your-secret>` | v1.36.0+ |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` | **DEPRECATED** Client id for [Auth0](https://auth0.com/) provider | - | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_AUTH_URL | `string` | **DEPRECATED** Account auth base url; it is used for building login URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/auth0`) and logout return URL (`${ NEXT_PUBLIC_AUTH_URL }/auth/logout`); if not provided the base app URL will be used instead | - | - | `https://blockscout.com` | v1.0.x+ |
| NEXT_PUBLIC_LOGOUT_URL | `string` | **DEPRECATED** Account logout url. Required if account is supported for the app instance. | - | - | `https://blockscoutcom.us.auth0.com/v2/logout` | v1.0.x+ |
&nbsp;
......@@ -442,7 +443,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | See [below](ENVS.md#google-recaptcha) | true | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `string` | See [below](ENVS.md#google-recaptcha) | true | - | `<your-secret>` | v1.36.0+ |
&nbsp;
......@@ -801,4 +802,4 @@ For obtaining the variables values please refer to [reCAPTCHA documentation](htt
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY | `string` | Site key | - | - | `<your-secret>` | v1.0.x+ |
| NEXT_PUBLIC_RE_CAPTCHA_V3_APP_SITE_KEY | `string` | Google reCAPTCHA v3 site key | - | - | `<your-secret>` | v1.36.0+ |
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.3 3.7a1.7 1.7 0 0 1 3.4 0v.903a1 1 0 1 0 2 0V3.7a3.7 3.7 0 0 0-7.4 0v6.272a1 1 0 0 0 2 0V3.7Zm5.4 6.302a1 1 0 0 0-2 0V16.3a1.7 1.7 0 0 1-3.4 0v-.914a1 1 0 1 0-2 0v.914a3.7 3.7 0 1 0 7.4 0v-6.298ZM3.692 8.3C2.76 8.3 2 9.059 2 10c0 .94.76 1.7 1.693 1.7H10a1 1 0 1 1 0 2H3.693A3.696 3.696 0 0 1 0 10C0 7.96 1.65 6.3 3.693 6.3h.902a1 1 0 0 1 0 2h-.902ZM10 6.3a1 1 0 0 0 0 2h6.294C17.238 8.3 18 9.064 18 10c0 .937-.761 1.7-1.705 1.7h-.865a1 1 0 1 0 0 2h.865A3.702 3.702 0 0 0 20 10c0-2.046-1.66-3.7-3.705-3.7H10Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 177 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.961 7.644a2 2 0 0 0-2.251-2.812l-76.313 17.5a2 2 0 0 0-.727 3.569l17.58 12.744v17.636a2.001 2.001 0 0 0 3.321 1.502l10.716-9.426 21.384 9.744a2 2 0 0 0 2.634-.957l23.656-49.5ZM108.308 35.92 93.583 25.247l62.923-14.43-48.198 25.104Zm.942 1.764v14.173l8.367-7.36a.385.385 0 0 1 .022-.018l.016-.014a.998.998 0 0 1 .214-.242l37.608-30.616-46.227 24.077Zm31.293 15.962-19.927-9.08 39.719-32.335-19.792 41.415ZM93.278 65.729a1.5 1.5 0 0 0 1.93 2.296 93.435 93.435 0 0 0 2.449-2.13 57.65 57.65 0 0 0 .819-.753l.044-.042.012-.011.004-.004.001-.001L97.5 64l1.038 1.083a1.5 1.5 0 0 0-2.075-2.167l-.002.002-.008.008-.037.035a19.011 19.011 0 0 1-.154.145 90.663 90.663 0 0 1-2.984 2.623Zm-5.037 7.714a1.5 1.5 0 0 0-1.751-2.436 105.47 105.47 0 0 1-7.163 4.73 1.5 1.5 0 1 0 1.547 2.57c2.69-1.618 5.17-3.284 7.367-4.864Zm-15.172 9.06a1.5 1.5 0 0 0-1.28-2.714c-2.556 1.205-5.207 2.277-7.906 3.13a1.5 1.5 0 0 0 .905 2.86c2.847-.9 5.624-2.024 8.28-3.276Zm-17.046 5.22a1.5 1.5 0 0 0-.358-2.98A34.938 34.938 0 0 1 51.5 85c-1.392 0-2.728-.09-4.012-.26a1.5 1.5 0 1 0-.394 2.973A33.44 33.44 0 0 0 51.5 88c1.51 0 3.02-.097 4.523-.278Zm-17.422-2.388a1.5 1.5 0 1 0 1.212-2.745c-2.517-1.11-4.783-2.55-6.816-4.194a1.5 1.5 0 1 0-1.887 2.332c2.216 1.792 4.705 3.377 7.491 4.607ZM24.974 74.523a1.5 1.5 0 1 0 2.338-1.881C25.51 70.403 24 68.076 22.756 65.86a1.5 1.5 0 0 0-2.616 1.47c1.311 2.333 2.911 4.803 4.834 7.193Zm-8.515-15a1.5 1.5 0 0 0 2.796-1.086 49.986 49.986 0 0 1-1-2.813 32.043 32.043 0 0 1-.29-.956l-.013-.045-.002-.01a1.5 1.5 0 0 0-2.899.775l1.45-.388-1.45.388.001.003.002.005.004.018.018.061.064.227c.057.196.143.478.258.837.23.718.579 1.741 1.06 2.984Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.605 21a2.26 2.26 0 0 1-1.614-.665l-6.314-6.318a2.266 2.266 0 0 1 0-3.23l8.45-8.457C10.888 1.57 12.265 1 13.31 1h5.412C19.956 1 21 2.045 21 3.28v5.416c0 .403-.085.856-.233 1.304H19.18c.208-.443.348-.944.348-1.352V3.233a.851.851 0 0 0-.854-.855h-5.365v.047c-.665 0-1.71.428-2.184.903l-8.451 8.456a.832.832 0 0 0 0 1.188l6.314 6.318c.332.332.902.332 1.187 0l1.818-1.82v2.09l-.773.775A2.26 2.26 0 0 1 9.605 21Z" fill="currentColor"/>
<path d="m7.991 20.335-.177.177.177-.177Zm-6.314-6.318.176-.177-.176.177Zm0-3.23.176.176-.176-.177Zm8.45-8.457-.176-.177.177.177ZM20.768 10v.25h.181l.057-.172-.238-.078Zm-1.587 0-.226-.106-.168.356h.394V10Zm-5.871-7.622v-.25h-.25v.25h.25Zm0 .047v.25h.25v-.25h-.25Zm-2.184.903-.177-.177.177.177Zm-8.451 8.456.176.177-.176-.177Zm0 1.188-.177.176.177-.176Zm6.314 6.318-.177.177.177-.177Zm1.187 0-.177-.177-.007.007-.006.007.19.163Zm1.818-1.82h.25v-.604l-.426.428.176.176Zm0 2.09.177.177.073-.073v-.103h-.25Zm-.773.775.176.177-.176-.177Zm-3.406.177a2.51 2.51 0 0 0 1.791.738v-.5a2.01 2.01 0 0 1-1.437-.592l-.354.354ZM1.5 14.193l6.314 6.319.354-.354-6.315-6.318-.353.353Zm0-3.583c-1 1-1 2.583 0 3.583l.353-.353a2.016 2.016 0 0 1 0-2.877L1.5 10.61Zm8.45-8.457L1.5 10.61l.353.353 8.451-8.456-.353-.354ZM13.31.75c-.564 0-1.202.153-1.794.4-.592.246-1.156.595-1.564 1.003l.353.354c.352-.352.856-.668 1.403-.896.548-.229 1.12-.361 1.602-.361v-.5Zm5.412 0H13.31v.5h5.412v-.5Zm2.529 2.53c0-1.373-1.156-2.53-2.529-2.53v.5c1.096 0 2.029.933 2.029 2.03h.5Zm0 5.416V3.28h-.5v5.416h.5Zm-.245 1.382c.154-.466.245-.946.245-1.382h-.5c0 .37-.078.797-.22 1.226l.475.156Zm-1.825.172h1.587v-.5H19.18v.5Zm.098-1.602c0 .36-.126.823-.324 1.246l.452.213c.218-.464.372-1.002.372-1.459h-.5Zm0-5.415v5.415h.5V3.233h-.5Zm-.604-.605c.336 0 .604.268.604.605h.5c0-.613-.491-1.105-1.104-1.105v.5Zm-5.365 0h5.365v-.5h-5.365v.5Zm.25-.203v-.047h-.5v.047h.5Zm-2.257 1.08c.205-.206.552-.416.939-.576.386-.159.78-.254 1.068-.254v-.5c-.377 0-.839.119-1.259.292-.42.173-.833.414-1.102.684l.354.354ZM2.85 11.96l8.452-8.456-.354-.354-8.451 8.456.353.354Zm0 .834a.582.582 0 0 1 0-.834l-.353-.354c-.43.43-.43 1.111 0 1.541l.353-.353Zm6.315 6.318L2.85 12.795l-.353.353 6.314 6.319.354-.354Zm.82.014c-.178.208-.577.23-.82-.014l-.354.354c.422.422 1.162.443 1.554-.015l-.38-.325Zm1.832-1.833-1.819 1.82.354.352 1.818-1.819-.353-.353Zm.426 2.267v-2.09h-.5v2.09h.5Zm-.847.95.774-.774-.353-.353-.774.774.353.354Zm-1.79.739a2.51 2.51 0 0 0 1.79-.738l-.353-.354a2.01 2.01 0 0 1-1.438.592v.5ZM20.988 20v-5c0-.55-.45-1-1-1h-5.996c-.55 0-1 .45-1 1v5c0 .55.45 1 1 1h5.996c.55 0 1-.45 1-1Zm-2.998-2.5c0 .55-.45 1-1 1s-1-.45-1-1 .45-1 1-1 1 .45 1 1Z" fill="currentColor"/>
<path d="M19.489 16v-2.5c0-1.4-1.1-2.5-2.499-2.5s-2.498 1.1-2.498 2.5V16" stroke="currentColor" stroke-opacity=".8" stroke-miterlimit="10"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.916 9.556c-.111-.111-.111-.223-.222-.334L16.356 5.89a1.077 1.077 0 0 0-1.558 0 1.073 1.073 0 0 0 0 1.556l1.446 1.444h-5.118c-.667 0-1.112.444-1.112 1.111s.445 1.111 1.112 1.111h5.118l-1.446 1.445a1.073 1.073 0 0 0 0 1.555c.223.222.556.334.779.334.223 0 .556-.112.779-.334l3.338-3.333c.111-.111.222-.222.222-.333a1.225 1.225 0 0 0 0-.89Z" fill="currentColor"/>
<path d="M13.908 16.778c-1.224.666-2.559 1-3.894 1-4.34 0-7.789-3.445-7.789-7.778s3.45-7.778 7.789-7.778c1.335 0 2.67.334 3.894 1 .556.334 1.224.111 1.558-.444.334-.556.111-1.222-.445-1.556C13.463.444 11.794 0 10.014 0A9.965 9.965 0 0 0 0 10c0 5.556 4.45 10 10.014 10a9.94 9.94 0 0 0 5.007-1.333c.556-.334.667-1 .445-1.556-.334-.444-1.002-.667-1.558-.333Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4 11a8.4 8.4 0 1 1-16.8 0 8.4 8.4 0 0 1 16.8 0Zm1.6 0c0 5.523-4.477 10-10 10S1 16.523 1 11 5.477 1 11 1s10 4.477 10 10Zm-5.895-3.706A.916.916 0 1 1 16.4 8.589l-6.022 6.022a1.05 1.05 0 0 1-1.485 0l-3.2-3.199a.915.915 0 0 1 1.295-1.295l2.258 2.258a.55.55 0 0 0 .778 0l5.081-5.081Z" fill="currentColor"/>
<path d="m16.4 7.293-.141.142.141-.142Zm-1.295 0 .142.142-.142-.141ZM16.4 8.59l-.141-.142.141.142Zm-6.022 6.022.141.141-.141-.141Zm-4.684-3.199.141-.141-.141.141Zm0-1.295-.142-.141.142.141Zm1.294 0-.141.142.141-.142Zm2.258 2.258-.14.142.14-.142Zm.778 0-.141-.141.141.141ZM11 19.6a8.6 8.6 0 0 0 8.6-8.6h-.4a8.2 8.2 0 0 1-8.2 8.2v.4ZM2.4 11a8.6 8.6 0 0 0 8.6 8.6v-.4A8.2 8.2 0 0 1 2.8 11h-.4ZM11 2.4A8.6 8.6 0 0 0 2.4 11h.4A8.2 8.2 0 0 1 11 2.8v-.4Zm8.6 8.6A8.6 8.6 0 0 0 11 2.4v.4a8.2 8.2 0 0 1 8.2 8.2h.4ZM11 21.2c5.633 0 10.2-4.567 10.2-10.2h-.4c0 5.412-4.388 9.8-9.8 9.8v.4ZM.8 11c0 5.633 4.567 10.2 10.2 10.2v-.4c-5.412 0-9.8-4.388-9.8-9.8H.8ZM11 .8C5.367.8.8 5.367.8 11h.4c0-5.412 4.388-9.8 9.8-9.8V.8ZM21.2 11C21.2 5.367 16.633.8 11 .8v.4c5.412 0 9.8 4.388 9.8 9.8h.4Zm-4.659-3.848a1.116 1.116 0 0 0-1.577 0l.283.283a.716.716 0 0 1 1.012 0l.282-.283Zm0 1.578a1.116 1.116 0 0 0 0-1.578l-.282.283c.28.28.28.733 0 1.012l.283.283Zm-6.022 6.022 6.023-6.022-.283-.283-6.023 6.023.283.282Zm-1.767 0a1.25 1.25 0 0 0 1.767 0l-.283-.282a.85.85 0 0 1-1.202 0l-.282.282Zm-3.2-3.199 3.2 3.2.282-.283-3.199-3.2-.283.283Zm0-1.577a1.115 1.115 0 0 0 0 1.577l.283-.282a.715.715 0 0 1 0-1.012l-.283-.283Zm1.578 0a1.115 1.115 0 0 0-1.578 0l.283.283a.715.715 0 0 1 1.012 0l.283-.283Zm2.258 2.258L7.13 9.976l-.283.283 2.258 2.258.283-.283Zm.495 0a.35.35 0 0 1-.495 0l-.283.283a.75.75 0 0 0 1.06 0l-.282-.283Zm5.081-5.082-5.081 5.082.283.283 5.08-5.082-.282-.283Z" fill="currentColor"/>
</svg>
......@@ -168,9 +168,6 @@ export const RESOURCES = {
user_info: {
path: '/api/account/v2/user/info',
},
email_resend: {
path: '/api/account/v2/email/resend',
},
custom_abi: {
path: '/api/account/v2/user/custom_abis{/:id}',
pathParams: [ 'id' as const ],
......@@ -228,6 +225,26 @@ export const RESOURCES = {
needAuth: true,
},
// AUTH
auth_send_otp: {
path: '/api/account/v2/send_otp',
},
auth_confirm_otp: {
path: '/api/account/v2/confirm_otp',
},
auth_siwe_message: {
path: '/api/account/v2/siwe_message',
},
auth_siwe_verify: {
path: '/api/account/v2/authenticate_via_wallet',
},
auth_link_email: {
path: '/api/account/v2/email/link',
},
auth_link_address: {
path: '/api/account/v2/address/link',
},
// STATS MICROSERVICE API
stats_counters: {
path: '/api/v1/counters',
......
......@@ -10,7 +10,7 @@ type TMarketplaceContext = {
setIsAutoConnectDisabled: (isAutoConnectDisabled: boolean) => void;
}
const MarketplaceContext = createContext<TMarketplaceContext>({
export const MarketplaceContext = createContext<TMarketplaceContext>({
isAutoConnectDisabled: false,
setIsAutoConnectDisabled: () => {},
});
......
......@@ -5,8 +5,6 @@ import isBrowser from './isBrowser';
export enum NAMES {
NAV_BAR_COLLAPSED='nav_bar_collapsed',
API_TOKEN='_explorer_key',
INVALID_SESSION='invalid_session',
CONFIRM_EMAIL_PAGE_VIEWED='confirm_email_page_viewed',
TXS_SORT='txs_sort',
COLOR_MODE='chakra-ui-color-mode',
COLOR_MODE_HEX='chakra-ui-color-mode-hex',
......@@ -28,12 +26,16 @@ export function get(name?: NAMES | undefined | null, serverCookie?: string) {
}
}
export function set(name: string, value: string, attributes: Cookies.CookieAttributes = {}) {
export function set(name: NAMES, value: string, attributes: Cookies.CookieAttributes = {}) {
attributes.path = '/';
return Cookies.set(name, value, attributes);
}
export function remove(name: NAMES, attributes: Cookies.CookieAttributes = {}) {
return Cookies.remove(name, attributes);
}
export function getFromCookieString(cookieString: string, name?: NAMES | undefined | null) {
return cookieString.split(`${ name }=`)[1]?.split(';')[0];
}
import getErrorObj from './getErrorObj';
export default function getErrorMessage(error: unknown): string | undefined {
const errorObj = getErrorObj(error);
return errorObj && 'message' in errorObj && typeof errorObj.message === 'string' ? errorObj.message : undefined;
}
......@@ -10,7 +10,7 @@ import useFetch from 'lib/hooks/useFetch';
export default function useGetCsrfToken() {
const nodeApiFetch = useFetch();
useQuery({
return useQuery({
queryKey: getResourceKey('csrf'),
queryFn: async() => {
if (!isNeedProxy()) {
......
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import useLoginUrl from 'lib/hooks/useLoginUrl';
export default function useIsAccountActionAllowed() {
const queryClient = useQueryClient();
const profileData = queryClient.getQueryData<UserInfo>([ resourceKey('user_info') ]);
const isAuth = Boolean(profileData);
const loginUrl = useLoginUrl();
return React.useCallback(() => {
if (!loginUrl) {
return false;
}
if (!isAuth) {
window.location.assign(loginUrl);
return false;
}
return true;
}, [ isAuth, loginUrl ]);
}
import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import config from 'configs/app';
const feature = config.features.account;
export default function useLoginUrl() {
const router = useRouter();
return feature.isEnabled ?
feature.authUrl + route({ pathname: '/auth/auth0', query: { path: router.asPath } }) :
undefined;
}
......@@ -5,12 +5,10 @@ import type { NavItemInternal, NavItem, NavGroupItem } from 'types/client/naviga
import config from 'configs/app';
import { rightLineArrow } from 'lib/html-entities';
import UserAvatar from 'ui/shared/UserAvatar';
interface ReturnType {
mainNavItems: Array<NavItem | NavGroupItem>;
accountNavItems: Array<NavItem>;
profileItem: NavItem;
}
export function isGroupItem(item: NavItem | NavGroupItem): item is NavGroupItem {
......@@ -312,13 +310,6 @@ export default function useNavItems(): ReturnType {
},
].filter(Boolean);
const profileItem = {
text: 'My profile',
nextRoute: { pathname: '/auth/profile' as const },
iconComponent: UserAvatar,
isActive: pathname === '/auth/profile',
};
return { mainNavItems, accountNavItems, profileItem };
return { mainNavItems, accountNavItems };
}, [ pathname ]);
}
......@@ -65,8 +65,6 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/api/healthz': 'Regular page',
'/api/config': 'Regular page',
'/api/sprite': 'Regular page',
'/auth/auth0': 'Regular page',
'/auth/unverified-email': 'Regular page',
};
export default function getPageOgType(pathname: Route['pathname']) {
......
......@@ -69,8 +69,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/healthz': DEFAULT_TEMPLATE,
'/api/config': DEFAULT_TEMPLATE,
'/api/sprite': DEFAULT_TEMPLATE,
'/auth/auth0': DEFAULT_TEMPLATE,
'/auth/unverified-email': DEFAULT_TEMPLATE,
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
......
......@@ -65,8 +65,6 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/healthz': '%network_name% node API health check',
'/api/config': '%network_name% node API app config',
'/api/sprite': '%network_name% node API SVG sprite content',
'/auth/auth0': '%network_name% authentication',
'/auth/unverified-email': '%network_name% unverified email',
};
const TEMPLATE_MAP_ENHANCED: Partial<Record<Route['pathname'], string>> = {
......
......@@ -63,8 +63,6 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/api/healthz': 'Node API: Health check',
'/api/config': 'Node API: App config',
'/api/sprite': 'Node API: SVG sprite content',
'/auth/auth0': 'Auth',
'/auth/unverified-email': 'Unverified email',
};
export default function getPageType(pathname: Route['pathname']) {
......
......@@ -7,6 +7,8 @@ export enum EventTypes {
LOCAL_SEARCH = 'Local search',
ADD_TO_WALLET = 'Add to wallet',
ACCOUNT_ACCESS = 'Account access',
LOGIN = 'Login',
ACCOUNT_LINK_INFO = 'Account link info',
PRIVATE_TAG = 'Private tag',
VERIFY_ADDRESS = 'Verify address',
VERIFY_TOKEN = 'Verify token',
......@@ -54,7 +56,27 @@ Type extends EventTypes.ADD_TO_WALLET ? (
}
) :
Type extends EventTypes.ACCOUNT_ACCESS ? {
'Action': 'Auth0 init' | 'Verification email resent' | 'Logged out';
'Action': 'Dropdown open' | 'Logged out';
} :
Type extends EventTypes.LOGIN ? (
{
'Action': 'Started';
'Source': string;
} | {
'Action': 'Wallet' | 'Email';
'Source': 'Options selector';
} | {
'Action': 'OTP sent';
'Source': 'Email';
} | {
'Action': 'Success';
'Source': 'Email' | 'Wallet';
}
) :
Type extends EventTypes.ACCOUNT_LINK_INFO ? {
'Source': 'Profile' | 'Login modal' | 'Profile dropdown';
'Status': 'Started' | 'OTP sent' | 'Finished';
'Type': 'Email' | 'Wallet';
} :
Type extends EventTypes.PRIVATE_TAG ? {
'Action': 'Form opened' | 'Submit';
......@@ -75,7 +97,7 @@ Type extends EventTypes.VERIFY_TOKEN ? {
'Action': 'Form opened' | 'Submit';
} :
Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts' | 'Swap button';
'Source': 'Header' | 'Login' | 'Profile' | 'Profile dropdown' | 'Smart contracts' | 'Swap button';
'Status': 'Started' | 'Connected';
} :
Type extends EventTypes.WALLET_ACTION ? (
......
export const validator = (value: string | undefined) => {
if (!value) {
return true;
}
try {
new URL(value);
return true;
} catch (error) {
return 'Incorrect URL';
}
};
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useAccount from './useAccount';
export default function useAccountWithDomain(isEnabled: boolean) {
const { address } = useAccount();
const isQueryEnabled = config.features.nameService.isEnabled && Boolean(address) && Boolean(isEnabled);
const domainQuery = useApiQuery('address_domain', {
pathParams: {
chainId: config.chain.id,
address,
},
queryOptions: {
enabled: isQueryEnabled,
refetchOnMount: false,
},
});
return React.useMemo(() => {
return {
address: isEnabled ? address : undefined,
domain: domainQuery.data?.domain?.name,
isLoading: isQueryEnabled && domainQuery.isLoading,
};
}, [ address, domainQuery.data?.domain?.name, domainQuery.isLoading, isEnabled, isQueryEnabled ]);
}
......@@ -8,11 +8,11 @@ interface Params {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
}
export default function useWallet({ source }: Params) {
const { open } = useWeb3Modal();
export default function useWeb3Wallet({ source }: Params) {
const { open: openModal } = useWeb3Modal();
const { open: isOpen } = useWeb3ModalState();
const { disconnect } = useDisconnect();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const [ isOpening, setIsOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const isConnectionStarted = React.useRef(false);
......@@ -21,12 +21,12 @@ export default function useWallet({ source }: Params) {
}, []);
const handleConnect = React.useCallback(async() => {
setIsModalOpening(true);
await open();
setIsModalOpening(false);
setIsOpening(true);
await openModal();
setIsOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
isConnectionStarted.current = true;
}, [ open, source ]);
}, [ openModal, source ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
if (!isReconnected && isConnectionStarted.current) {
......@@ -46,15 +46,14 @@ export default function useWallet({ source }: Params) {
const { address, isDisconnected } = useAccount();
const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined;
const isConnected = isClientLoaded && !isDisconnected && address !== undefined;
return {
openModal: open,
isWalletConnected,
address: address || '',
return React.useMemo(() => ({
connect: handleConnect,
disconnect: handleDisconnect,
isModalOpening,
isModalOpen: isOpen,
};
isOpen: isOpening || isOpen,
isConnected,
address,
openModal,
}), [ handleConnect, handleDisconnect, isOpen, isOpening, isConnected, address, openModal ]);
}
export const base = {
import type { UserInfo } from 'types/api/account';
export const base: UserInfo = {
avatar: 'https://avatars.githubusercontent.com/u/22130104',
email: 'tom@ohhhh.me',
name: 'tom goriunov',
nickname: 'tom2drum',
address_hash: null,
};
export const withoutEmail = {
export const withoutEmail: UserInfo = {
avatar: 'https://avatars.githubusercontent.com/u/22130104',
email: null,
name: 'tom goriunov',
nickname: 'tom2drum',
address_hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
};
......@@ -3,7 +3,7 @@ import type CspDev from 'csp-dev';
import config from 'configs/app';
export function googleReCaptcha(): CspDev.DirectiveDescriptor {
if (!config.services.reCaptcha.siteKey) {
if (!config.services.reCaptchaV3.siteKey) {
return {};
}
......
......@@ -6,9 +6,9 @@ import type { RollupType } from 'types/client/rollup';
import type { Route } from 'nextjs-routes';
import config from 'configs/app';
import isNeedProxy from 'lib/api/isNeedProxy';
const rollupFeature = config.features.rollup;
const adBannerFeature = config.features.adsBanner;
import isNeedProxy from 'lib/api/isNeedProxy';
import type * as metadata from 'lib/metadata';
export interface Props<Pathname extends Route['pathname'] = never> {
......
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { DAY } from 'lib/consts';
import * as cookies from 'lib/cookies';
export function account(req: NextRequest) {
......@@ -25,37 +22,7 @@ export function account(req: NextRequest) {
const isProfileRoute = req.nextUrl.pathname.includes('/auth/profile');
if ((isAccountRoute || isProfileRoute)) {
const authUrl = feature.authUrl + route({ pathname: '/auth/auth0', query: { path: req.nextUrl.pathname } });
return NextResponse.redirect(authUrl);
}
}
// if user hasn't confirmed email yet
if (req.cookies.get(cookies.NAMES.INVALID_SESSION)) {
// if user has both cookies, make redirect to logout
if (apiTokenCookie) {
// yes, we could have checked that the current URL is not the logout URL, but we hadn't
// logout URL is always external URL in auth0.com sub-domain
// at least we hope so
const res = NextResponse.redirect(feature.logoutUrl);
res.cookies.delete(cookies.NAMES.CONFIRM_EMAIL_PAGE_VIEWED); // reset cookie to show email verification page again
return res;
}
// if user hasn't seen email verification page, make redirect to it
if (!req.cookies.get(cookies.NAMES.CONFIRM_EMAIL_PAGE_VIEWED)) {
if (!req.nextUrl.pathname.includes('/auth/unverified-email')) {
const url = config.app.baseUrl + route({ pathname: '/auth/unverified-email' });
const res = NextResponse.redirect(url);
res.cookies.set({
name: cookies.NAMES.CONFIRM_EMAIL_PAGE_VIEWED,
value: 'true',
expires: Date.now() + 7 * DAY,
});
return res;
}
return NextResponse.redirect(config.app.baseUrl);
}
}
}
......@@ -28,9 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
| StaticRoute<"/auth/auth0">
| StaticRoute<"/auth/profile">
| StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/batches/[number]", { "number": string }>
| StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }>
......
......@@ -33,6 +33,7 @@ export default function fetchFactory(
message: 'API fetch via Next.js proxy',
url,
// headers,
// init,
});
const body = (() => {
......
......@@ -22,8 +22,13 @@ const handler = async(nextReq: NextApiRequest, nextRes: NextApiResponse) => {
);
// proxy some headers from API
nextRes.setHeader('x-request-id', apiRes.headers.get('x-request-id') || '');
nextRes.setHeader('set-cookie', apiRes.headers.get('set-cookie') || '');
const requestId = apiRes.headers.get('x-request-id');
requestId && nextRes.setHeader('x-request-id', requestId);
const setCookie = apiRes.headers.raw()['set-cookie'];
setCookie?.forEach((value) => {
nextRes.appendHeader('set-cookie', value);
});
nextRes.setHeader('content-type', apiRes.headers.get('content-type') || '');
nextRes.status(apiRes.status).send(apiRes.body);
......
import type { NextPage } from 'next';
const Page: NextPage = () => {
return null;
};
export default Page;
export async function getServerSideProps() {
return {
notFound: true,
};
}
import type { NextPage } from 'next';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
import UnverifiedEmail from 'ui/pages/UnverifiedEmail';
const Page: NextPage = () => {
return (
<PageNextJs pathname="/auth/unverified-email">
<UnverifiedEmail/>
</PageNextJs>
);
};
export default Page;
export { account as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -10,6 +10,7 @@ import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { MarketplaceContext } from 'lib/contexts/marketplace';
import { SocketProvider } from 'lib/socket/context';
import currentChain from 'lib/web3/currentChain';
import theme from 'theme/theme';
......@@ -23,6 +24,10 @@ export type Props = {
appContext?: {
pageProps: PageProps;
};
marketplaceContext?: {
isAutoConnectDisabled: boolean;
setIsAutoConnectDisabled: (isAutoConnectDisabled: boolean) => void;
};
}
const defaultAppContext = {
......@@ -35,6 +40,11 @@ const defaultAppContext = {
},
};
const defaultMarketplaceContext = {
isAutoConnectDisabled: false,
setIsAutoConnectDisabled: () => {},
};
const wagmiConfig = createConfig({
chains: [ currentChain ],
connectors: [
......@@ -49,7 +59,7 @@ const wagmiConfig = createConfig({
},
});
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const TestApp = ({ children, withSocket, appContext = defaultAppContext, marketplaceContext = defaultMarketplaceContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -64,11 +74,13 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<QueryClientProvider client={ queryClient }>
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
</GrowthBookProvider>
<MarketplaceContext.Provider value={ marketplaceContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
</GrowthBookProvider>
</MarketplaceContext.Provider>
</AppContextProvider>
</SocketProvider>
</QueryClientProvider>
......
......@@ -3,6 +3,7 @@
export type IconName =
| "ABI_slim"
| "ABI"
| "API_slim"
| "API"
| "apps_list"
| "apps_slim"
......@@ -48,7 +49,6 @@
| "donate"
| "dots"
| "edit"
| "email-sent"
| "email"
| "empty_search_result"
| "ENS_slim"
......@@ -104,6 +104,7 @@
| "output_roots"
| "payment_link"
| "plus"
| "private_tags_slim"
| "privattags"
| "profile"
| "publictags_slim"
......@@ -120,6 +121,7 @@
| "score/score-ok"
| "search"
| "share"
| "sign_out"
| "social/canny"
| "social/coingecko"
| "social/coinmarketcap"
......@@ -165,6 +167,7 @@
| "validator"
| "verification-steps/finalized"
| "verification-steps/unfinalized"
| "verified_slim"
| "verified"
| "wallet"
| "wallets/coinbase"
......
import { defineStyle, defineStyleConfig } from '@chakra-ui/styled-system';
import getOutlinedFieldStyles from '../utils/getOutlinedFieldStyles';
const baseStyle = defineStyle({
textAlign: 'center',
bgColor: 'dialog_bg',
});
const sizes = {
md: defineStyle({
fontSize: 'md',
w: 10,
h: 10,
borderRadius: 'md',
}),
};
const variants = {
outline: defineStyle(
(props) => getOutlinedFieldStyles(props),
),
};
const PinInput = defineStyleConfig({
baseStyle,
sizes,
variants,
defaultProps: {
size: 'md',
},
});
export default PinInput;
......@@ -10,6 +10,7 @@ import Input from './Input';
import Link from './Link';
import Menu from './Menu';
import Modal from './Modal';
import PinInput from './PinInput';
import Popover from './Popover';
import Radio from './Radio';
import Select from './Select';
......@@ -36,6 +37,7 @@ const components = {
Link,
Menu,
Modal,
PinInput,
Popover,
Radio,
Select,
......
......@@ -3,6 +3,7 @@ import { mode } from '@chakra-ui/theme-tools';
import scrollbar from './foundations/scrollbar';
import addressEntity from './globals/address-entity';
import recaptcha from './globals/recaptcha';
import getDefaultTransitionProps from './utils/getDefaultTransitionProps';
const global = (props: StyleFunctionProps) => ({
......@@ -25,6 +26,7 @@ const global = (props: StyleFunctionProps) => ({
},
...scrollbar(props),
...addressEntity(props),
...recaptcha(),
});
export default global;
const styles = () => {
return {
'.grecaptcha-badge': {
zIndex: 'toast',
},
};
};
export default styles;
......@@ -71,6 +71,7 @@ export interface UserInfo {
name?: string;
nickname?: string;
email: string | null;
address_hash: string | null;
avatar?: string;
}
......
......@@ -17,3 +17,6 @@ export type PickByType<T, X> = Record<
{[K in keyof T]: T[K] extends X ? K : never}[keyof T],
X
>;
// Make some properties of an object optional
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
import { Button, VStack } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { SmartContractSecurityAuditSubmission } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import dayjs from 'lib/date/dayjs';
import useToast from 'lib/hooks/useToast';
import AuditComment from './fields/AuditComment';
import AuditCompanyName from './fields/AuditCompanyName';
import AuditProjectName from './fields/AuditProjectName';
import AuditProjectUrl from './fields/AuditProjectUrl';
import AuditReportDate from './fields/AuditReportDate';
import AuditReportUrl from './fields/AuditReportUrl';
import AuditSubmitterEmail from './fields/AuditSubmitterEmail';
import AuditSubmitterIsOwner from './fields/AuditSubmitterIsOwner';
import AuditSubmitterName from './fields/AuditSubmitterName';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
interface Props {
address?: string;
......@@ -46,10 +41,11 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const { handleSubmit, formState, control, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
mode: 'onTouched',
defaultValues: { is_project_owner: false },
});
const { handleSubmit, formState, setError } = formApi;
const onFormSubmit: SubmitHandler<Inputs> = React.useCallback(async(data) => {
try {
......@@ -94,30 +90,46 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => {
}, [ apiFetch, address, toast, setError, onSuccess ]);
return (
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<VStack gap={ 5 }>
<AuditSubmitterName control={ control }/>
<AuditSubmitterEmail control={ control }/>
<AuditSubmitterIsOwner control={ control }/>
<AuditProjectName control={ control }/>
<AuditProjectUrl control={ control }/>
<AuditCompanyName control={ control }/>
<AuditReportUrl control={ control }/>
<AuditReportDate control={ control }/>
<AuditComment control={ control }/>
</VStack>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ !formState.isDirty }
>
<FormProvider { ...formApi }>
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<VStack gap={ 5 } alignItems="flex-start">
<FormFieldText<Inputs> name="submitter_name" isRequired placeholder="Submitter name"/>
<FormFieldEmail<Inputs> name="submitter_email" isRequired placeholder="Submitter email"/>
<FormFieldCheckbox<Inputs, 'is_project_owner'>
name="is_project_owner"
label="I'm the contract owner"
/>
<FormFieldText<Inputs> name="project_name" isRequired placeholder="Project name"/>
<FormFieldUrl<Inputs> name="project_url" isRequired placeholder="Project URL"/>
<FormFieldText<Inputs> name="audit_company_name" isRequired placeholder="Audit company name"/>
<FormFieldUrl<Inputs> name="audit_report_url" isRequired placeholder="Audit report URL"/>
<FormFieldText<Inputs>
name="audit_publish_date"
type="date"
max={ dayjs().format('YYYY-MM-DD') }
isRequired
placeholder="Audit publish date"
/>
<FormFieldText<Inputs>
name="comment"
placeholder="Comment"
maxH="160px"
rules={{ maxLength: 300 }}
asComponent="Textarea"
/>
</VStack>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ !formState.isDirty }
>
Send request
</Button>
</form>
</Button>
</form>
</FormProvider>
);
};
......
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditComment = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'comment'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name }>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Comment" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="comment"
control={ control }
render={ renderControl }
rules={{ maxLength: 300 }}
/>
);
};
export default React.memo(AuditComment);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditCompanyName = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'audit_company_name'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
/>
<InputPlaceholder text="Audit company name" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="audit_company_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AuditCompanyName);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditProjectName = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'project_name'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
/>
<InputPlaceholder text="Project name" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="project_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AuditProjectName);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { validator as validateUrl } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditProjectUrl = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'project_url'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
/>
<InputPlaceholder text="Project URL" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="project_url"
control={ control }
render={ renderControl }
rules={{ required: true, validate: { url: validateUrl } }}
/>
);
};
export default React.memo(AuditProjectUrl);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import dayjs from 'lib/date/dayjs';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditCompanyName = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'audit_publish_date'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
type="date"
max={ dayjs().format('YYYY-MM-DD') }
/>
<InputPlaceholder text="Audit publish date" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="audit_publish_date"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AuditCompanyName);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { validator as validateUrl } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditReportUrl = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'audit_report_url'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
autoComplete="off"
/>
<InputPlaceholder text="Audit report URL" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="audit_report_url"
control={ control }
render={ renderControl }
rules={{ required: true, validate: { url: validateUrl } }}
/>
);
};
export default React.memo(AuditReportUrl);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditSubmitterEmail = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'submitter_email'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
/>
<InputPlaceholder text="Submitter email" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="submitter_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(AuditSubmitterEmail);
import { FormControl } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import CheckboxInput from 'ui/shared/CheckboxInput';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditSubmitterIsOwner = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'is_project_owner'>['render'] = React.useCallback(({ field }) => {
return (
<FormControl id={ field.name }>
<CheckboxInput<Inputs, 'is_project_owner'>
text="I'm the contract owner"
field={ field }
/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="is_project_owner"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(AuditSubmitterIsOwner);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import type { Inputs } from '../ContractSubmitAuditForm';
interface Props {
control: Control<Inputs>;
}
const AuditSubmitterName = ({ control }: Props) => {
const renderControl: ControllerProps<Inputs, 'submitter_name'>['render'] = React.useCallback(({ field, fieldState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
/>
<InputPlaceholder text="Submitter name" error={ fieldState.error }/>
</FormControl>
);
}, [ ]);
return (
<Controller
name="submitter_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AuditSubmitterName);
......@@ -3,31 +3,31 @@ import React from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import useWeb3Wallet from 'lib/web3/useWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import useWallet from 'ui/snippets/walletMenu/useWallet';
interface Props {
isLoading?: boolean;
}
const ContractConnectWallet = ({ isLoading }: Props) => {
const { isModalOpening, isModalOpen, connect, disconnect, address, isWalletConnected } = useWallet({ source: 'Smart contracts' });
const web3Wallet = useWeb3Wallet({ source: 'Smart contracts' });
const isMobile = useIsMobile();
const content = (() => {
if (!isWalletConnected) {
if (!web3Wallet.isConnected) {
return (
<>
<span>Disconnected</span>
<Button
ml={ 3 }
onClick={ connect }
onClick={ web3Wallet.connect }
size="sm"
variant="outline"
isLoading={ isModalOpening || isModalOpen }
isLoading={ web3Wallet.isOpen }
loadingText="Connect wallet"
>
Connect wallet
Connect wallet
</Button>
</>
);
......@@ -38,20 +38,20 @@ const ContractConnectWallet = ({ isLoading }: Props) => {
<Flex alignItems="center">
<span>Connected to </span>
<AddressEntity
address={{ hash: address }}
address={{ hash: web3Wallet.address || '' }}
truncation={ isMobile ? 'constant' : 'dynamic' }
fontWeight={ 600 }
ml={ 2 }
/>
</Flex>
<Button onClick={ disconnect } size="sm" variant="outline">Disconnect</Button>
<Button onClick={ web3Wallet.disconnect } size="sm" variant="outline">Disconnect</Button>
</Flex>
);
})();
return (
<Skeleton isLoaded={ !isLoading } mb={ 6 }>
<Alert status={ address ? 'success' : 'warning' }>
<Alert status={ web3Wallet.address ? 'success' : 'warning' }>
{ content }
</Alert>
</Skeleton>
......
......@@ -5,10 +5,10 @@ import React from 'react';
import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsAccountActionAllowed from 'lib/hooks/useIsAccountActionAllowed';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
......@@ -23,16 +23,12 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
const deleteModalProps = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
const isAccountActionAllowed = useIsAccountActionAllowed();
const onFocusCapture = usePreventFocusAfterModalClosing();
const handleClick = React.useCallback(() => {
if (!isAccountActionAllowed()) {
return;
}
const handleAddToFavorite = React.useCallback(() => {
watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen();
!watchListId && mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Add to watchlist' });
}, [ isAccountActionAllowed, watchListId, deleteModalProps, addModalProps ]);
}, [ watchListId, deleteModalProps, addModalProps ]);
const handleAddOrDeleteSuccess = React.useCallback(async() => {
const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } });
......@@ -50,7 +46,7 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
const formData = React.useMemo(() => {
if (typeof watchListId !== 'number') {
return;
return { address_hash: hash };
}
return {
......@@ -65,21 +61,25 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
return (
<>
<Tooltip label={ `${ watchListId ? 'Remove address from Watch list' : 'Add address to Watch list' }` }>
<IconButton
isActive={ Boolean(watchListId) }
className={ className }
aria-label="edit"
variant="outline"
size="sm"
pl="6px"
pr="6px"
flexShrink={ 0 }
onClick={ handleClick }
icon={ <IconSvg name={ watchListId ? 'star_filled' : 'star_outline' } boxSize={ 5 }/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
<AuthGuard onAuthSuccess={ handleAddToFavorite }>
{ ({ onClick }) => (
<Tooltip label={ `${ watchListId ? 'Remove address from Watch list' : 'Add address to Watch list' }` }>
<IconButton
isActive={ Boolean(watchListId) }
className={ className }
aria-label="edit"
variant="outline"
size="sm"
pl="6px"
pr="6px"
flexShrink={ 0 }
onClick={ onClick }
icon={ <IconSvg name={ watchListId ? 'star_filled' : 'star_outline' } boxSize={ 5 }/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
) }
</AuthGuard>
<WatchlistAddModal
{ ...addModalProps }
isAdd
......@@ -87,7 +87,7 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
onSuccess={ handleAddOrDeleteSuccess }
data={ formData }
/>
{ formData && (
{ formData.id && (
<DeleteAddressModal
{ ...deleteModalProps }
onClose={ handleDeleteModalClose }
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormFirstStepFields, RootFields } from '../types';
import { ADDRESS_REGEXP, ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldAddress = ({ formState, control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
const error = 'address' in formState.errors ? formState.errors.address : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" bgColor="dialog_bg" mt={ 8 }>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
isDisabled={ formState.isSubmitting }
autoComplete="off"
bgColor="dialog_bg"
/>
<InputPlaceholder text="Smart contract address (0x...)" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldAddress);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldMessage = ({ formState, control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'message'>}) => {
const error = 'message' in formState.errors ? formState.errors.message : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" bgColor="dialog_bg">
<Textarea
{ ...field }
required
isInvalid={ Boolean(error) }
isReadOnly
autoComplete="off"
maxH={{ base: '140px', lg: '80px' }}
bgColor="dialog_bg"
/>
<InputPlaceholder text="Message to sign" error={ error }/>
</FormControl>
);
}, [ formState.errors ]);
return (
<Controller
defaultValue="some value"
name="message"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(AddressVerificationFieldMessage);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FormState } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { AddressVerificationFormSecondStepFields, RootFields } from '../types';
import { SIGNATURE_REGEXP } from 'lib/validations/signature';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
interface Props {
formState: FormState<Fields>;
control: Control<Fields>;
}
const AddressVerificationFieldSignature = ({ formState, control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'signature'>}) => {
const error = 'signature' in formState.errors ? formState.errors.signature : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size="md" bgColor="dialog_bg">
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
bgColor="dialog_bg"
/>
<InputPlaceholder text="Signature hash" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
return (
<Controller
name="signature"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: SIGNATURE_REGEXP }}
/>
);
};
export default React.memo(AddressVerificationFieldSignature);
import { Alert, Box, Button, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type {
AddressVerificationResponseError,
......@@ -16,10 +16,10 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import LinkInternal from 'ui/shared/links/LinkInternal';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldAddress from '../fields/AddressVerificationFieldAddress';
type Fields = RootFields & AddressVerificationFormFirstStepFields;
interface Props {
......@@ -34,7 +34,7 @@ const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) =
address: defaultAddress,
},
});
const { handleSubmit, formState, control, setError, clearErrors, watch } = formApi;
const { handleSubmit, formState, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
const address = watch('address');
......@@ -100,17 +100,25 @@ const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) =
})();
return (
<form noValidate onSubmit={ onSubmit }>
<Box>Enter the contract address you are verifying ownership for.</Box>
{ rootError && <Alert status="warning" mt={ 3 }>{ rootError }</Alert> }
<AddressVerificationFieldAddress formState={ formState } control={ control }/>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
<Button size="lg" type="submit" isLoading={ formState.isSubmitting } loadingText="Continue" flexShrink={ 0 }>
<FormProvider { ...formApi }>
<form noValidate onSubmit={ onSubmit }>
<Box>Enter the contract address you are verifying ownership for.</Box>
{ rootError && <Alert status="warning" mt={ 3 }>{ rootError }</Alert> }
<FormFieldAddress<Fields>
name="address"
isRequired
bgColor="dialog_bg"
placeholder="Smart contract address (0x...)"
mt={ 8 }
/>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
<Button size="lg" type="submit" isLoading={ formState.isSubmitting } loadingText="Continue" flexShrink={ 0 }>
Continue
</Button>
<AdminSupportText/>
</Flex>
</form>
</Button>
<AdminSupportText/>
</Flex>
</form>
</FormProvider>
);
};
......
......@@ -2,7 +2,7 @@ import { Alert, Box, Button, chakra, Flex, Link, Radio, RadioGroup } from '@chak
import { useWeb3Modal } from '@web3modal/wagmi/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { useSignMessage, useAccount } from 'wagmi';
import type {
......@@ -19,11 +19,10 @@ import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import shortenString from 'lib/shortenString';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import { SIGNATURE_REGEXP } from 'ui/shared/forms/validators/signature';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import AddressVerificationFieldMessage from '../fields/AddressVerificationFieldMessage';
import AddressVerificationFieldSignature from '../fields/AddressVerificationFieldSignature';
type Fields = RootFields & AddressVerificationFormSecondStepFields;
type SignMethod = 'wallet' | 'manual';
......@@ -45,7 +44,7 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre
message: signingMessage,
},
});
const { handleSubmit, formState, control, setValue, getValues, setError, clearErrors, watch } = formApi;
const { handleSubmit, formState, setValue, getValues, setError, clearErrors, watch } = formApi;
const apiFetch = useApiFetch();
......@@ -184,51 +183,69 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre
})();
return (
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
<span>Please select the address to sign and copy the message and sign it using the Blockscout message provider of your choice. </span>
<Link href="https://docs.blockscout.com/for-users/my-account/verified-addresses/copy-and-sign-message" target="_blank">
<FormProvider { ...formApi }>
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
<span>Please select the address to sign and copy the message and sign it using the Blockscout message provider of your choice. </span>
<Link href="https://docs.blockscout.com/for-users/my-account/verified-addresses/copy-and-sign-message" target="_blank">
Additional instructions
</Link>
<span>. If you do not see your address here but are sure that you are the owner of the contract, kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
{ (contractOwner || contractCreator) && (
<Flex flexDir="column" rowGap={ 4 } mb={ 4 }>
{ contractCreator && (
<Box>
<chakra.span fontWeight={ 600 }>Contract creator: </chakra.span>
<chakra.span>{ contractCreator }</chakra.span>
</Box>
</Link>
<span>. If you do not see your address here but are sure that you are the owner of the contract, kindly </span>
{ contactUsLink }
<span> for further assistance.</span>
</Box>
{ (contractOwner || contractCreator) && (
<Flex flexDir="column" rowGap={ 4 } mb={ 4 }>
{ contractCreator && (
<Box>
<chakra.span fontWeight={ 600 }>Contract creator: </chakra.span>
<chakra.span>{ contractCreator }</chakra.span>
</Box>
) }
{ contractOwner && (
<Box>
<chakra.span fontWeight={ 600 }>Contract owner: </chakra.span>
<chakra.span>{ contractOwner }</chakra.span>
</Box>
) }
</Flex>
) }
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<FormFieldText<Fields>
name="message"
placeholder="Message to sign"
isRequired
asComponent="Textarea"
isReadOnly
maxH={{ base: '140px', lg: '80px' }}
bgColor="dialog_bg"
/>
</div>
{ !noWeb3Provider && (
<RadioGroup onChange={ handleSignMethodChange } value={ signMethod } display="flex" flexDir="column" rowGap={ 4 }>
<Radio value="wallet">Sign via Web3 wallet</Radio>
<Radio value="manual">Sign manually</Radio>
</RadioGroup>
) }
{ contractOwner && (
<Box>
<chakra.span fontWeight={ 600 }>Contract owner: </chakra.span>
<chakra.span>{ contractOwner }</chakra.span>
</Box>
{ signMethod === 'manual' && (
<FormFieldText<Fields>
name="signature"
placeholder="Signature hash"
isRequired
rules={{ pattern: SIGNATURE_REGEXP }}
bgColor="dialog_bg"
/>
) }
</Flex>
) }
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<AddressVerificationFieldMessage formState={ formState } control={ control }/>
</div>
{ !noWeb3Provider && (
<RadioGroup onChange={ handleSignMethodChange } value={ signMethod } display="flex" flexDir="column" rowGap={ 4 }>
<Radio value="wallet">Sign via Web3 wallet</Radio>
<Radio value="manual">Sign manually</Radio>
</RadioGroup>
) }
{ signMethod === 'manual' && <AddressVerificationFieldSignature formState={ formState } control={ control }/> }
</Flex>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
{ button }
<AdminSupportText/>
</Flex>
</form>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }} mt={ 8 } columnGap={ 5 } rowGap={ 2 } flexDir={{ base: 'column', lg: 'row' }}>
{ button }
<AdminSupportText/>
</Flex>
</form>
</FormProvider>
);
};
......
import {
Box,
Button,
FormControl,
FormLabel,
Input,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { ApiKey, ApiKeys, ApiKeyErrors } from 'types/api/account';
......@@ -16,7 +13,7 @@ import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
type Props = {
data?: ApiKey;
......@@ -32,7 +29,7 @@ type Inputs = {
const NAME_MAX_LENGTH = 255;
const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
mode: 'onTouched',
defaultValues: {
token: data?.api_key || '',
......@@ -81,80 +78,54 @@ const ApiKeyForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
onError: (error: ResourceErrorAccount<ApiKeyErrors>) => {
const errorMap = error.payload?.errors;
if (errorMap?.name) {
setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
formApi.setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
} else if (errorMap?.identity_id) {
setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
formApi.setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((data) => {
const onSubmit: SubmitHandler<Inputs> = useCallback(async(data) => {
setAlertVisible(false);
mutation.mutate(data);
await mutation.mutateAsync(data);
}, [ mutation, setAlertVisible ]);
const renderTokenInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'token'>}) => {
return (
<FormControl variant="floating" id="address">
<Input
{ ...field }
bgColor="dialog_bg"
isReadOnly
/>
<FormLabel>Auto-generated API key token</FormLabel>
</FormControl>
);
}, []);
const renderNameInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'name'>}) => {
return (
<FormControl variant="floating" id="name" isRequired bgColor="dialog_bg">
<Input
{ ...field }
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
bgColor="dialog_bg"
/>
<InputPlaceholder text="Application name for API key (e.g Web3 project)" error={ errors.name }/>
</FormControl>
);
}, [ errors ]);
return (
<form noValidate onSubmit={ handleSubmit(onSubmit) }>
{ data && (
<Box marginBottom={ 5 }>
<Controller
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
{ data && (
<FormFieldText<Inputs>
name="token"
control={ control }
render={ renderTokenInput }
placeholder="Auto-generated API key token"
isReadOnly
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
) }
<Box marginBottom={ 8 }>
<Controller
) }
<FormFieldText<Inputs>
name="name"
control={ control }
placeholder="Application name for API key (e.g Web3 project)"
isRequired
rules={{
maxLength: NAME_MAX_LENGTH,
required: true,
}}
render={ renderNameInput }
bgColor="dialog_bg"
mb={ 8 }
/>
</Box>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !isDirty }
isLoading={ mutation.isPending }
>
{ data ? 'Save' : 'Generate API key' }
</Button>
</Box>
</form>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !formApi.formState.isDirty }
isLoading={ mutation.isPending }
>
{ data ? 'Save' : 'Generate API key' }
</Button>
</Box>
</form>
</FormProvider>
);
};
......
......@@ -43,7 +43,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
mode: 'onBlur',
defaultValues: getDefaultValues(methodFromQuery, config, hash, null),
});
const { control, handleSubmit, watch, formState, setError, reset, getFieldState } = formApi;
const { handleSubmit, watch, formState, setError, reset, getFieldState } = formApi;
const submitPromiseResolver = React.useRef<(value: unknown) => void>();
const methodNameRef = React.useRef<string>();
......@@ -145,7 +145,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
topic: `addresses:${ address?.toLowerCase() }`,
onSocketClose: handleSocketError,
onSocketError: handleSocketError,
isDisabled: Boolean(address && addressState.error),
isDisabled: !address || Boolean(address && addressState.error),
});
useSocketMessage({
channel,
......@@ -191,11 +191,7 @@ const ContractVerificationForm = ({ method: methodFromQuery, config, hash }: Pro
<Grid as="section" columnGap="30px" rowGap={{ base: 2, lg: 5 }} templateColumns={{ base: '1fr', lg: 'minmax(auto, 680px) minmax(0, 340px)' }}>
{ !hash && <ContractVerificationFieldAddress/> }
<ContractVerificationFieldLicenseType/>
<ContractVerificationFieldMethod
control={ control }
methods={ config.verification_options }
isDisabled={ formState.isSubmitting }
/>
<ContractVerificationFieldMethod methods={ config.verification_options }/>
</Grid>
{ content }
{ Boolean(method) && method.value !== 'solidity-hardhat' && method.value !== 'solidity-foundry' && (
......
import { FormControl, Input, chakra } from '@chakra-ui/react';
import { 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 FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -15,26 +12,6 @@ interface Props {
}
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>
......@@ -43,11 +20,12 @@ const ContractVerificationFieldAddress = ({ isReadOnly }: Props) => {
</chakra.span>
</ContractVerificationFormRow>
<ContractVerificationFormRow>
<Controller
<FormFieldAddress<FormFields>
name="address"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
isRequired
placeholder="Smart contract / Address (0x...)"
isReadOnly={ isReadOnly }
size={{ base: 'md', lg: 'lg' }}
/>
</ContractVerificationFormRow>
</>
......
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import CheckboxInput from 'ui/shared/CheckboxInput';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
import ContractVerificationFieldConstructorArgs from './ContractVerificationFieldConstructorArgs';
const ContractVerificationFieldAutodetectArgs = () => {
const [ isOn, setIsOn ] = React.useState(true);
const { formState, control, resetField } = useFormContext<FormFields>();
const { resetField } = useFormContext<FormFields>();
const handleCheckboxChange = React.useCallback(() => {
!isOn && resetField('constructor_args');
setIsOn(prev => !prev);
}, [ isOn, resetField ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'autodetect_constructor_args'>}) => (
<CheckboxInput<FormFields, 'autodetect_constructor_args'>
text="Try to fetch constructor arguments automatically"
field={ field }
isDisabled={ formState.isSubmitting }
onChange={ handleCheckboxChange }
/>
), [ formState.isSubmitting, handleCheckboxChange ]);
return (
<>
<ContractVerificationFormRow>
<Controller
<FormFieldCheckbox<FormFields, 'autodetect_constructor_args'>
name="autodetect_constructor_args"
control={ control }
render={ renderControl }
label="Try to fetch constructor arguments automatically"
onChange={ handleCheckboxChange }
/>
</ContractVerificationFormRow>
{ !isOn && <ContractVerificationFieldConstructorArgs/> }
......
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import FieldError from 'ui/shared/forms/FieldError';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -15,32 +11,14 @@ interface Props {
}
const ContractVerificationFieldCode = ({ isVyper }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'code'>}) => {
const error = 'code' in formState.errors ? formState.errors.code : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
required
/>
<InputPlaceholder text="Contract code"/>
{ error?.message && <FieldError message={ error?.message }/> }
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
return (
<ContractVerificationFormRow>
<Controller
<FormFieldText<FormFields>
name="code"
control={ control }
render={ renderControl }
rules={{ required: true }}
isRequired
placeholder="Contract code"
size={{ base: 'md', lg: 'lg' }}
asComponent="Textarea"
/>
{ isVyper ? null : (
<span>If your code utilizes a library or inherits dependencies, we recommend using other verification methods instead.</span>
......
import { chakra, Checkbox, Code } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationConfig } from 'types/client/contract';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import IconSvg from 'ui/shared/IconSvg';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -22,8 +20,7 @@ interface Props {
const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
const [ isNightly, setIsNightly ] = React.useState(false);
const { formState, control, getValues, resetField } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const { formState, getValues, resetField } = useFormContext<FormFields>();
const queryClient = useQueryClient();
const config = queryClient.getQueryData<SmartContractVerificationConfig>(getResourceKey('contract_verification_config'));
......@@ -46,25 +43,6 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
.slice(0, OPTIONS_LIMIT);
}, [ isNightly, options ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'compiler'>}) => {
const error = 'compiler' in formState.errors ? formState.errors.compiler : undefined;
return (
<FancySelect
{ ...field }
loadOptions={ loadOptions }
defaultOptions
size={ isMobile ? 'md' : 'lg' }
placeholder="Compiler (enter version or use the dropdown)"
placeholderIcon={ <IconSvg name="search"/> }
isDisabled={ formState.isSubmitting }
error={ error }
isRequired
isAsync
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, loadOptions ]);
return (
<ContractVerificationFormRow>
<>
......@@ -78,11 +56,14 @@ const ContractVerificationFieldCompiler = ({ isVyper }: Props) => {
Include nightly builds
</Checkbox>
) }
<Controller
<FormFieldFancySelect<FormFields, 'compiler'>
name="compiler"
control={ control }
render={ renderControl }
rules={{ required: true }}
placeholder="Compiler (enter version or use the dropdown)"
loadOptions={ loadOptions }
defaultOptions
placeholderIcon={ <IconSvg name="search"/> }
isRequired
isAsync
/>
</>
{ isVyper ? null : (
......
import { FormControl, Link, Textarea } from '@chakra-ui/react';
import { Link } 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 FieldError from 'ui/shared/forms/FieldError';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const ContractVerificationFieldConstructorArgs = () => {
const { formState, control } = useFormContext<FormFields>();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'constructor_args'>}) => {
const error = 'constructor_args' in formState.errors ? formState.errors.constructor_args : undefined;
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Textarea
{ ...field }
maxLength={ 255 }
isDisabled={ formState.isSubmitting }
isInvalid={ Boolean(error) }
required
/>
<InputPlaceholder text="ABI-encoded Constructor Arguments"/>
{ error?.message && <FieldError message={ error?.message }/> }
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
return (
<ContractVerificationFormRow>
<Controller
<FormFieldText<FormFields>
name="constructor_args"
control={ control }
render={ renderControl }
rules={{ required: true }}
isRequired
rules={{ maxLength: 255 }}
placeholder="ABI-encoded Constructor Arguments"
size={{ base: 'md', lg: 'lg' }}
asComponent="Textarea"
/>
<>
<span>Add arguments in </span>
......
import { useUpdateEffect } from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import type { Option } from 'ui/shared/FancySelect/types';
import type { Option } from 'ui/shared/forms/inputs/select/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -15,8 +13,7 @@ const SOURCIFY_ERROR_REGEXP = /\(([^()]*)\)/;
const ContractVerificationFieldContractIndex = () => {
const [ options, setOptions ] = React.useState<Array<Option>>([]);
const { formState, control, watch } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const { formState, watch } = useFormContext<FormFields>();
const sources = watch('sources');
const sourcesError = 'sources' in formState.errors ? formState.errors.sources?.message : undefined;
......@@ -40,34 +37,18 @@ const ContractVerificationFieldContractIndex = () => {
setOptions([]);
}, [ sources ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'contract_index'>}) => {
const error = 'contract_index' in formState.errors ? formState.errors.contract_index : undefined;
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Contract name"
isDisabled={ formState.isSubmitting }
error={ error }
isRequired
isAsync={ false }
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, options ]);
if (options.length === 0) {
return null;
}
return (
<ContractVerificationFormRow>
<Controller
<FormFieldFancySelect<FormFields, 'contract_index'>
name="contract_index"
control={ control }
render={ renderControl }
rules={{ required: true }}
placeholder="Contract name"
options={ options }
isRequired
isAsync={ false }
/>
</ContractVerificationFormRow>
);
......
import { Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationConfig } from 'types/client/contract';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -18,8 +15,6 @@ interface Props {
}
const ContractVerificationFieldEvmVersion = ({ isVyper }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const config = queryClient.getQueryData<SmartContractVerificationConfig>(getResourceKey('contract_verification_config'));
......@@ -27,29 +22,13 @@ const ContractVerificationFieldEvmVersion = ({ isVyper }: Props) => {
(isVyper ? config?.vyper_evm_versions : config?.solidity_evm_versions)?.map((option) => ({ label: option, value: option })) || []
), [ config?.solidity_evm_versions, config?.vyper_evm_versions, isVyper ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'evm_version'>}) => {
const error = 'evm_version' in formState.errors ? formState.errors.evm_version : undefined;
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="EVM Version"
isDisabled={ formState.isSubmitting }
error={ error }
isRequired
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, options ]);
return (
<ContractVerificationFormRow>
<Controller
<FormFieldFancySelect<FormFields, 'evm_version'>
name="evm_version"
control={ control }
render={ renderControl }
rules={{ required: true }}
placeholder="EVM Version"
options={ options }
isRequired
/>
<>
<span>The EVM version the contract is written for. If the bytecode does not match the version, we try to verify using the latest EVM version. </span>
......
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import CheckboxInput from 'ui/shared/CheckboxInput';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const ContractVerificationFieldIsYul = () => {
const { formState, control } = useFormContext<FormFields>();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'is_yul'>}) => (
<CheckboxInput<FormFields, 'is_yul'> text="Is Yul contract" field={ field } isDisabled={ formState.isSubmitting }/>
), [ formState.isSubmitting ]);
return (
<ContractVerificationFormRow>
<Controller
<FormFieldCheckbox<FormFields, 'is_yul'>
name="is_yul"
control={ control }
render={ renderControl }
label="Is Yul contract"
/>
</ContractVerificationFormRow>
);
......
......@@ -56,11 +56,9 @@ const ContractVerificationFieldLibraries = () => {
<ContractVerificationFieldLibraryItem
key={ field.id }
index={ index }
control={ control }
fieldsLength={ fields.length }
onAddFieldClick={ handleAddFieldClick }
onRemoveFieldClick={ handleRemoveFieldClick }
error={ 'libraries' in formState.errors ? formState.errors.libraries?.[index] : undefined }
isDisabled={ formState.isSubmitting }
/>
)) }
......
import { Flex, FormControl, IconButton, Input, Text } from '@chakra-ui/react';
import { Flex, IconButton, Text } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const LIMIT = 10;
interface Props {
control: Control<FormFields>;
index: number;
fieldsLength: number;
error?: {
name?: FieldError;
address?: FieldError;
};
onAddFieldClick: (index: number) => void;
onRemoveFieldClick: (index: number) => void;
isDisabled?: boolean;
}
const ContractVerificationFieldLibraryItem = ({ control, index, fieldsLength, onAddFieldClick, onRemoveFieldClick, error, isDisabled }: Props) => {
const ContractVerificationFieldLibraryItem = ({ index, fieldsLength, onAddFieldClick, onRemoveFieldClick, isDisabled }: Props) => {
const ref = React.useRef<HTMLDivElement>(null);
const renderNameControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, `libraries.${ number }.name`>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error?.name) }
isDisabled={ isDisabled }
maxLength={ 255 }
autoComplete="off"
/>
<InputPlaceholder text="Library name (.sol file)" error={ error?.name }/>
</FormControl>
);
}, [ error?.name, isDisabled ]);
const renderAddressControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, `libraries.${ number }.address`>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(error?.address) }
isDisabled={ isDisabled }
required
autoComplete="off"
/>
<InputPlaceholder text="Library address (0x...)" error={ error?.address }/>
</FormControl>
);
}, [ error?.address, isDisabled ]);
const handleAddButtonClick = React.useCallback(() => {
onAddFieldClick(index);
}, [ index, onAddFieldClick ]);
......@@ -104,11 +66,12 @@ const ContractVerificationFieldLibraryItem = ({ control, index, fieldsLength, on
</Flex>
</ContractVerificationFormRow>
<ContractVerificationFormRow>
<Controller
<FormFieldText<FormFields, `libraries.${ number }.name`>
name={ `libraries.${ index }.name` }
control={ control }
render={ renderNameControl }
rules={{ required: true }}
isRequired
rules={{ maxLength: 255 }}
placeholder="Library name (.sol file)"
size={{ base: 'md', lg: 'lg' }}
/>
{ index === 0 ? (
<>
......@@ -117,11 +80,11 @@ const ContractVerificationFieldLibraryItem = ({ control, index, fieldsLength, on
) : null }
</ContractVerificationFormRow>
<ContractVerificationFormRow>
<Controller
<FormFieldAddress<FormFields, `libraries.${ number }.address`>
name={ `libraries.${ index }.address` }
control={ control }
render={ renderAddressControl }
rules={{ required: true, pattern: ADDRESS_REGEXP }}
isRequired
placeholder="Library address (0x...)"
size={{ base: 'md', lg: 'lg' }}
/>
{ index === 0 ? (
<>
......
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
const options = CONTRACT_LICENSES.map(({ label, title, type }) => ({ label: `${ title } (${ label })`, value: type }));
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const ContractVerificationFieldLicenseType = () => {
const { formState, control } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'license_type'>}) => {
const error = 'license_type' in formState.errors ? formState.errors.license_type : undefined;
const options = CONTRACT_LICENSES.map(({ label, title, type }) => ({ label: `${ title } (${ label })`, value: type }));
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Contract license"
isDisabled={ formState.isSubmitting }
error={ error }
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile ]);
const ContractVerificationFieldLicenseType = () => {
return (
<ContractVerificationFormRow>
<Controller
<FormFieldFancySelect<FormFields, 'license_type'>
name="license_type"
control={ control }
render={ renderControl }
placeholder="Contract license"
options={ options }
/>
<span>
For best practices, all contract source code holders, publishers and authors are encouraged to also
specify the accompanying license for their verified contract source code provided.
For best practices, all contract source code holders, publishers and authors are encouraged to also
specify the accompanying license for their verified contract source code provided.
</span>
</ContractVerificationFormRow>
);
......
......@@ -13,26 +13,22 @@ import {
Box,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, Control } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationMethod, SmartContractVerificationConfig } from 'types/client/contract';
import useIsMobile from 'lib/hooks/useIsMobile';
import Popover from 'ui/shared/chakra/Popover';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import IconSvg from 'ui/shared/IconSvg';
import { METHOD_LABELS } from '../utils';
interface Props {
control: Control<FormFields>;
isDisabled?: boolean;
methods: SmartContractVerificationConfig['verification_options'];
}
const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props) => {
const ContractVerificationFieldMethod = ({ methods }: Props) => {
const tooltipBg = useColorModeValue('gray.700', 'gray.900');
const isMobile = useIsMobile();
......@@ -41,21 +37,6 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
label: METHOD_LABELS[method],
})), [ methods ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'method'>}) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Verification method (compiler type)"
isDisabled={ isDisabled }
isRequired
isAsync={ false }
isReadOnly={ options.length === 1 }
/>
);
}, [ isDisabled, isMobile, options ]);
const renderPopoverListItem = React.useCallback((method: SmartContractVerificationMethod) => {
switch (method) {
case 'flattened-code':
......@@ -128,11 +109,13 @@ const ContractVerificationFieldMethod = ({ control, isDisabled, methods }: Props
</Portal>
</Popover>
</Box>
<Controller
<FormFieldFancySelect<FormFields, 'method'>
name="method"
control={ control }
render={ renderControl }
rules={{ required: true }}
placeholder="Verification method (compiler type)"
options={ options }
isRequired
isAsync={ false }
isReadOnly={ options.length === 1 }
/>
</>
);
......
import { chakra, Code, FormControl, Input } from '@chakra-ui/react';
import { chakra, Code } 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 InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
interface Props {
hint?: string;
isReadOnly?: boolean;
}
const ContractVerificationFieldName = ({ hint, isReadOnly }: Props) => {
const { formState, control } = useFormContext<FormFields>();
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'name'>}) => {
const error = 'name' in formState.errors ? formState.errors.name : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
maxLength={ 255 }
isDisabled={ formState.isSubmitting || isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Contract name" error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting, isReadOnly ]);
const ContractVerificationFieldName = ({ hint }: Props) => {
return (
<ContractVerificationFormRow>
<Controller
<FormFieldText<FormFields>
name="name"
control={ control }
render={ renderControl }
rules={{ required: true }}
isRequired
placeholder="Contract name"
size={{ base: 'md', lg: 'lg' }}
rules={{ maxLength: 255 }}
/>
{ hint ? <span>{ hint }</span> : (
<>
......
import { Flex, Input } from '@chakra-ui/react';
import { Flex } 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 CheckboxInput from 'ui/shared/CheckboxInput';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
const ContractVerificationFieldOptimization = () => {
const [ isEnabled, setIsEnabled ] = React.useState(true);
const { formState, control } = useFormContext<FormFields>();
const error = 'optimization_runs' in formState.errors ? formState.errors.optimization_runs : undefined;
const handleCheckboxChange = React.useCallback(() => {
setIsEnabled(prev => !prev);
}, []);
const renderCheckboxControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'is_optimization_enabled'>}) => (
<Flex flexShrink={ 0 }>
<CheckboxInput<FormFields, 'is_optimization_enabled'>
text="Optimization enabled"
field={ field }
onChange={ handleCheckboxChange }
isDisabled={ formState.isSubmitting }
/>
</Flex>
), [ formState.isSubmitting, handleCheckboxChange ]);
const renderInputControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'optimization_runs'>}) => {
return (
<Input
{ ...field }
required
isDisabled={ formState.isSubmitting }
autoComplete="off"
type="number"
placeholder="Optimization runs"
size="xs"
minW="100px"
maxW="200px"
flexShrink={ 1 }
isInvalid={ Boolean(error) }
/>
);
}, [ error, formState.isSubmitting ]);
return (
<ContractVerificationFormRow>
<Flex columnGap={ 5 } h={{ base: 'auto', lg: '32px' }}>
<Controller
<FormFieldCheckbox<FormFields, 'is_optimization_enabled'>
name="is_optimization_enabled"
control={ control }
render={ renderCheckboxControl }
label="Optimization enabled"
onChange={ handleCheckboxChange }
flexShrink={ 0 }
/>
{ isEnabled && (
<Controller
<FormFieldText<FormFields, 'optimization_runs'>
name="optimization_runs"
control={ control }
render={ renderInputControl }
rules={{ required: true }}
isRequired
placeholder="Optimization runs"
type="number"
size="xs"
minW="100px"
maxW="200px"
flexShrink={ 1 }
/>
) }
</Flex>
......
......@@ -6,10 +6,10 @@ import { Controller, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { Mb } from 'lib/consts';
import DragAndDropArea from 'ui/shared/forms/DragAndDropArea';
import FieldError from 'ui/shared/forms/FieldError';
import FileInput from 'ui/shared/forms/FileInput';
import FileSnippet from 'ui/shared/forms/FileSnippet';
import FieldError from 'ui/shared/forms/components/FieldError';
import DragAndDropArea from 'ui/shared/forms/inputs/file/DragAndDropArea';
import FileInput from 'ui/shared/forms/inputs/file/FileInput';
import FileSnippet from 'ui/shared/forms/inputs/file/FileSnippet';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......
import { Box, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import type { FormFields } from '../types';
import type { SmartContractVerificationConfig } from 'types/client/contract';
import { getResourceKey } from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import IconSvg from 'ui/shared/IconSvg';
import ContractVerificationFormRow from '../ContractVerificationFormRow';
......@@ -17,8 +14,6 @@ import ContractVerificationFormRow from '../ContractVerificationFormRow';
const OPTIONS_LIMIT = 50;
const ContractVerificationFieldZkCompiler = () => {
const { formState, control } = useFormContext<FormFields>();
const isMobile = useIsMobile();
const queryClient = useQueryClient();
const config = queryClient.getQueryData<SmartContractVerificationConfig>(getResourceKey('contract_verification_config'));
......@@ -32,33 +27,17 @@ const ContractVerificationFieldZkCompiler = () => {
.slice(0, OPTIONS_LIMIT);
}, [ options ]);
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'zk_compiler'>}) => {
const error = 'zk_compiler' in formState.errors ? formState.errors.zk_compiler : undefined;
return (
<FancySelect
{ ...field }
loadOptions={ loadOptions }
defaultOptions
size={ isMobile ? 'md' : 'lg' }
return (
<ContractVerificationFormRow>
<FormFieldFancySelect<FormFields, 'zk_compiler'>
name="zk_compiler"
placeholder="ZK compiler (enter version or use the dropdown)"
placeholderIcon={ <IconSvg name="search"/> }
isDisabled={ formState.isSubmitting }
error={ error }
loadOptions={ loadOptions }
defaultOptions
isRequired
isAsync
/>
);
}, [ formState.errors, formState.isSubmitting, isMobile, loadOptions ]);
return (
<ContractVerificationFormRow>
<Controller
name="zk_compiler"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
<Box>
<Link isExternal href="https://docs.zksync.io/zk-stack/components/compiler/specification#glossary">zksolc</Link>
<span> compiler version.</span>
......
import type { SmartContractLicenseType } from 'types/api/contract';
import type { SmartContractVerificationMethod } from 'types/client/contract';
import type { Option } from 'ui/shared/FancySelect/types';
import type { Option } from 'ui/shared/forms/inputs/select/types';
export interface ContractLibrary {
name: string;
......
......@@ -165,7 +165,7 @@ export function getDefaultValues(
const method = singleMethod || methodParam;
if (!method) {
return;
return { address: hash || '' };
}
const defaultValues: FormFields = { ...DEFAULT_VALUES[method], address: hash || '', license_type: licenseType };
......
import { Alert, Button, chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { FormFields } from './types';
import type { CsvExportParams } from 'types/client/address';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import type { ResourceName } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
......@@ -43,7 +45,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
to_period: exportType !== 'holders' ? data.to : null,
filter_type: filterType,
filter_value: filterValue,
recaptcha_response: data.reCaptcha,
recaptcha_v3_response: data.reCaptcha,
});
const response = await fetch(url, {
......@@ -76,37 +78,41 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
const disabledFeatureMessage = (
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
);
if (!config.services.reCaptchaV3.siteKey) {
return (
<Alert status="error">
CSV export is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
);
}
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<FormFieldReCaptcha disabledFeatureMessage={ disabledFeatureMessage }/>
</Flex>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ !formState.isValid }
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ handleSubmit(onFormSubmit) }
>
Download
</Button>
</chakra.form>
</FormProvider>
<Flex columnGap={ 5 } rowGap={ 3 } flexDir={{ base: 'column', lg: 'row' }} alignItems={{ base: 'flex-start', lg: 'center' }} flexWrap="wrap">
{ exportType !== 'holders' && <CsvExportFormField name="from" formApi={ formApi }/> }
{ exportType !== 'holders' && <CsvExportFormField name="to" formApi={ formApi }/> }
<FormFieldReCaptcha/>
</Flex>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ Boolean(formState.errors.from || formState.errors.to) }
>
Download
</Button>
</chakra.form>
</FormProvider>
</GoogleReCaptchaProvider>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import _capitalize from 'lodash/capitalize';
import React from 'react';
import type { ControllerRenderProps, UseFormReturn } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { UseFormReturn } from 'react-hook-form';
import type { FormFields } from './types';
import dayjs from 'lib/date/dayjs';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
interface Props {
formApi: UseFormReturn<FormFields>;
......@@ -15,26 +13,7 @@ interface Props {
}
const CsvExportFormField = ({ formApi, name }: Props) => {
const { formState, control, getValues, trigger } = formApi;
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<FormFields, 'from' | 'to'>}) => {
const error = field.name in formState.errors ? formState.errors[field.name] : undefined;
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }} maxW={{ base: 'auto', lg: '220px' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(error) }
type="date"
isDisabled={ formState.isSubmitting }
autoComplete="off"
max={ dayjs().format('YYYY-MM-DD') }
/>
<InputPlaceholder text={ _capitalize(field.name) } error={ error }/>
</FormControl>
);
}, [ formState.errors, formState.isSubmitting ]);
const { formState, getValues, trigger } = formApi;
const validate = React.useCallback((newValue: string) => {
if (name === 'from') {
......@@ -57,11 +36,15 @@ const CsvExportFormField = ({ formApi, name }: Props) => {
}, [ formState.errors.from, formState.errors.to, getValues, name, trigger ]);
return (
<Controller
<FormFieldText<FormFields, typeof name>
name={ name }
control={ control }
render={ renderControl }
rules={{ required: true, validate }}
type="date"
max={ dayjs().format('YYYY-MM-DD') }
placeholder={ _capitalize(name) }
isRequired
rules={{ validate }}
size={{ base: 'md', lg: 'lg' }}
maxW={{ base: 'auto', lg: '220px' }}
/>
);
};
......
import {
Box,
Button,
FormControl,
Input,
Textarea,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, SubmitHandler } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { CustomAbi, CustomAbis, CustomAbiErrors } from 'types/api/account';
......@@ -16,9 +13,8 @@ import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
type Props = {
data?: CustomAbi;
......@@ -35,7 +31,7 @@ type Inputs = {
const NAME_MAX_LENGTH = 255;
const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
const { control, formState: { errors, isDirty }, handleSubmit, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
defaultValues: {
contract_address_hash: data?.contract_address_hash || '',
name: data?.name || '',
......@@ -85,102 +81,64 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
onError: (error: ResourceErrorAccount<CustomAbiErrors>) => {
const errorMap = error.payload?.errors;
if (errorMap?.address_hash || errorMap?.name || errorMap?.abi) {
errorMap?.address_hash && setError('contract_address_hash', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
errorMap?.abi && setError('abi', { type: 'custom', message: getErrorMessage(errorMap, 'abi') });
errorMap?.address_hash && formApi.setError('contract_address_hash', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && formApi.setError('name', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
errorMap?.abi && formApi.setError('abi', { type: 'custom', message: getErrorMessage(errorMap, 'abi') });
} else if (errorMap?.identity_id) {
setError('contract_address_hash', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
formApi.setError('contract_address_hash', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = useCallback((formData) => {
const onSubmit: SubmitHandler<Inputs> = useCallback(async(formData) => {
setAlertVisible(false);
mutation.mutate({ ...formData, id: data?.id ? String(data.id) : undefined });
await mutation.mutateAsync({ ...formData, id: data?.id ? String(data.id) : undefined });
}, [ mutation, data, setAlertVisible ]);
const renderContractAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'contract_address_hash'>}) => {
return (
<AddressInput<Inputs, 'contract_address_hash'>
field={ field }
error={ errors.contract_address_hash }
bgColor="dialog_bg"
placeholder="Smart contract address (0x...)"
/>
);
}, [ errors ]);
const renderNameInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'name'>}) => {
return (
<FormControl variant="floating" id="name" isRequired bgColor="dialog_bg">
<Input
{ ...field }
isInvalid={ Boolean(errors.name) }
maxLength={ NAME_MAX_LENGTH }
bgColor="dialog_bg"
/>
<InputPlaceholder text="Project name" error={ errors.name }/>
</FormControl>
);
}, [ errors ]);
const renderAbiInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'abi'>}) => {
return (
<FormControl variant="floating" id="abi" isRequired bgColor="dialog_bg">
<Textarea
{ ...field }
size="lg"
minH="300px"
isInvalid={ Boolean(errors.abi) }
bgColor="dialog_bg"
/>
<InputPlaceholder text="Custom ABI [{...}] (JSON format)" error={ errors.abi }/>
</FormControl>
);
}, [ errors ]);
return (
<form noValidate onSubmit={ handleSubmit(onSubmit) }>
<Box>
<Controller
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
<FormFieldAddress<Inputs>
name="contract_address_hash"
control={ control }
render={ renderContractAddressInput }
rules={{
pattern: ADDRESS_REGEXP,
required: true,
}}
placeholder="Smart contract address (0x...)"
isRequired
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginTop={ 5 }>
<Controller
<FormFieldText<Inputs>
name="name"
control={ control }
render={ renderNameInput }
rules={{ required: true }}
placeholder="Project name"
isRequired
rules={{
maxLength: NAME_MAX_LENGTH,
}}
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginTop={ 5 }>
<Controller
<FormFieldText<Inputs>
name="abi"
control={ control }
render={ renderAbiInput }
rules={{ required: true }}
/>
</Box>
<Box marginTop={ 8 }>
<Button
placeholder="Custom ABI [{...}] (JSON format)"
isRequired
asComponent="Textarea"
bgColor="dialog_bg"
size="lg"
type="submit"
isDisabled={ !isDirty }
isLoading={ mutation.isPending }
>
{ data ? 'Save' : 'Create custom ABI' }
</Button>
</Box>
</form>
minH="300px"
mb={ 8 }
/>
<Box>
<Button
size="lg"
type="submit"
isDisabled={ !formApi.formState.isDirty }
isLoading={ mutation.isPending }
>
{ data ? 'Save' : 'Create custom ABI' }
</Button>
</Box>
</form>
</FormProvider>
);
};
......
......@@ -12,7 +12,7 @@ const authTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
authTest('customization +@dark-mode', async({ render, page, mockEnvs, mockApiResponse, mockAssetResponse }) => {
authTest('customization +@dark-mode', async({ render, page, mockEnvs, mockApiResponse }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.png';
await mockEnvs([
......@@ -28,7 +28,6 @@ authTest('customization +@dark-mode', async({ render, page, mockEnvs, mockApiRes
});
await mockApiResponse('user_info', profileMock.base);
await mockAssetResponse(profileMock.base.avatar, './playwright/mocks/image_s.jpg');
const component = await render(<HeroBanner/>);
......
......@@ -3,9 +3,9 @@ import React from 'react';
import config from 'configs/app';
import AdBanner from 'ui/shared/ad/AdBanner';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
const BACKGROUND_DEFAULT = 'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)';
const TEXT_COLOR_DEFAULT = 'white';
......@@ -67,9 +67,11 @@ const HeroBanner = () => {
}
</Heading>
{ config.UI.navigation.layout === 'vertical' && (
<Box display={{ base: 'none', lg: 'flex' }}>
{ config.features.account.isEnabled && <ProfileMenuDesktop isHomePage/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop isHomePage/> }
<Box display={{ base: 'none', lg: 'block' }}>
{
(config.features.account.isEnabled && <UserProfileDesktop buttonVariant="hero"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonVariant="hero"/>)
}
</Box>
) }
</Flex>
......
......@@ -5,9 +5,9 @@ import { route } from 'nextjs-routes';
import useApiQuery from 'lib/api/useApiQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { TX } from 'stubs/tx';
import LinkInternal from 'ui/shared/links/LinkInternal';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
import LatestTxsItem from './LatestTxsItem';
import LatestTxsItemMobile from './LatestTxsItemMobile';
......
......@@ -2,11 +2,11 @@ import { Heading } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useHasAccount from 'lib/hooks/useHasAccount';
import LatestOptimisticDeposits from 'ui/home/latestDeposits/LatestOptimisticDeposits';
import LatestTxs from 'ui/home/LatestTxs';
import LatestWatchlistTxs from 'ui/home/LatestWatchlistTxs';
import TabsWithScroll from 'ui/shared/Tabs/TabsWithScroll';
import useAuth from 'ui/snippets/auth/useIsAuth';
import LatestArbitrumDeposits from './latestDeposits/LatestArbitrumDeposits';
......@@ -17,15 +17,15 @@ const TAB_LIST_PROPS = {
};
const TransactionsHome = () => {
const hasAccount = useHasAccount();
if ((rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'arbitrum')) || hasAccount) {
const isAuth = useAuth();
if ((rollupFeature.isEnabled && (rollupFeature.type === 'optimistic' || rollupFeature.type === 'arbitrum')) || isAuth) {
const tabs = [
{ id: 'txn', title: 'Latest txn', component: <LatestTxs/> },
rollupFeature.isEnabled && rollupFeature.type === 'optimistic' &&
{ id: 'deposits', title: 'Deposits (L1→L2 txn)', component: <LatestOptimisticDeposits/> },
rollupFeature.isEnabled && rollupFeature.type === 'arbitrum' &&
{ id: 'deposits', title: 'Deposits (L1→L2 txn)', component: <LatestArbitrumDeposits/> },
hasAccount && { id: 'watchlist', title: 'Watch list', component: <LatestWatchlistTxs/> },
isAuth && { id: 'watchlist', title: 'Watch list', component: <LatestWatchlistTxs/> },
].filter(Boolean);
return (
<>
......
import { chakra, Flex, Tooltip, Skeleton } from '@chakra-ui/react';
import { chakra, Flex, Tooltip, Skeleton, Box } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceAppOverview, MarketplaceAppSecurityReport, ContractListTypes } from 'types/client/marketplace';
......@@ -12,8 +12,8 @@ import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
import LinkInternal from 'ui/shared/links/LinkInternal';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal';
......@@ -98,10 +98,12 @@ const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props)
source="App page"
/>
{ !isMobile && (
<Flex flex="1" justifyContent="flex-end">
{ config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop size="sm"/> }
</Flex>
<Box ml="auto">
{
(config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonSize="sm"/>)
}
</Box>
) }
</Flex>
{ contractListType && (
......
......@@ -3,11 +3,11 @@ import { useEffect, useRef } from 'react';
import removeQueryParam from 'lib/router/removeQueryParam';
import updateQueryParam from 'lib/router/updateQueryParam';
import useWallet from 'ui/snippets/walletMenu/useWallet';
import useWeb3Wallet from 'lib/web3/useWallet';
export default function useAutoConnectWallet() {
const router = useRouter();
const { isWalletConnected, isModalOpen, connect } = useWallet({ source: 'Swap button' });
const web3Wallet = useWeb3Wallet({ source: 'Swap button' });
const isConnectionStarted = useRef(false);
useEffect(() => {
......@@ -17,11 +17,11 @@ export default function useAutoConnectWallet() {
let timer: ReturnType<typeof setTimeout>;
if (!isWalletConnected && !isModalOpen) {
if (!web3Wallet.isConnected && !web3Wallet.isOpen) {
if (!isConnectionStarted.current) {
timer = setTimeout(() => {
if (!isWalletConnected) {
connect();
if (!web3Wallet.isConnected) {
web3Wallet.connect();
isConnectionStarted.current = true;
}
}, 500);
......@@ -29,11 +29,11 @@ export default function useAutoConnectWallet() {
isConnectionStarted.current = false;
updateQueryParam(router, 'action', 'tooltip');
}
} else if (isWalletConnected) {
} else if (web3Wallet.isConnected) {
isConnectionStarted.current = false;
removeQueryParam(router, 'action');
}
return () => clearTimeout(timer);
}, [ isWalletConnected, isModalOpen, connect, router ]);
}, [ router, web3Wallet ]);
}
import { Button, chakra, Heading, useDisclosure } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { FormFields } from './types';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import AuthModal from 'ui/snippets/auth/AuthModal';
import MyProfileFieldsEmail from './fields/MyProfileFieldsEmail';
const MIXPANEL_CONFIG = {
account_link_info: {
source: 'Profile' as const,
},
};
interface Props {
profileQuery: UseQueryResult<UserInfo, unknown>;
}
const MyProfileEmail = ({ profileQuery }: Props) => {
const authModal = useDisclosure();
const apiFetch = useApiFetch();
const toast = useToast();
const formApi = useForm<FormFields>({
mode: 'onBlur',
defaultValues: {
email: profileQuery.data?.email || '',
name: profileQuery.data?.name || profileQuery.data?.nickname || '',
},
});
const onFormSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => {
try {
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_v3_response: formData.reCaptcha,
},
},
});
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: 'Profile',
Status: 'OTP sent',
Type: 'Email',
});
authModal.onOpen();
} catch (error) {
const apiError = getErrorObjPayload<{ message: string }>(error);
toast({
status: 'error',
title: 'Error',
description: apiError?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ apiFetch, authModal, toast ]);
const hasDirtyFields = Object.keys(formApi.formState.dirtyFields).length > 0;
return (
<section>
<Heading as="h2" size="sm" mb={ 3 }>Notifications</Heading>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<FormFieldText<FormFields> name="name" placeholder="Name" isReadOnly mb={ 3 }/>
<MyProfileFieldsEmail
isReadOnly={ !config.services.reCaptchaV3.siteKey || Boolean(profileQuery.data?.email) }
defaultValue={ profileQuery.data?.email || undefined }
/>
{ config.services.reCaptchaV3.siteKey && !profileQuery.data?.email && (
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<FormFieldReCaptcha/>
</GoogleReCaptchaProvider>
) }
{ config.services.reCaptchaV3.siteKey && !profileQuery.data?.email && (
<Button
mt={ 6 }
size="sm"
variant="outline"
type="submit"
isDisabled={ formApi.formState.isSubmitting || !hasDirtyFields }
isLoading={ formApi.formState.isSubmitting }
loadingText="Save changes"
>
Save changes
</Button>
) }
</chakra.form>
</FormProvider>
{ authModal.isOpen && (
<AuthModal
initialScreen={{ type: 'otp_code', isAuth: true, email: formApi.getValues('email') }}
onClose={ authModal.onClose }
mixpanelConfig={ MIXPANEL_CONFIG }
/>
) }
</section>
);
};
export default React.memo(MyProfileEmail);
import { Box, Button, Heading, Text, useColorModeValue } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
interface Props {
profileQuery: UseQueryResult<UserInfo, unknown>;
onAddWallet: () => void;
}
const MyProfileWallet = ({ profileQuery, onAddWallet }: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<section>
<Heading as="h2" size="sm" mb={ 3 }>My linked wallet</Heading>
<Text mb={ 3 } >
This wallet address is used for login and participation in the merit program
</Text>
{ profileQuery.data?.address_hash ? (
<Box px={ 3 } py="18px" bgColor={ bgColor } borderRadius="base">
<AddressEntity
address={{ hash: profileQuery.data.address_hash }}
fontWeight="500"
/>
</Box>
) : <Button size="sm" onClick={ onAddWallet }>Link wallet</Button> }
</section>
);
};
export default React.memo(MyProfileWallet);
import { FormControl, Input, InputGroup, InputRightElement, Text } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import FormInputPlaceholder from 'ui/shared/forms/inputs/FormInputPlaceholder';
import { EMAIL_REGEXP } from 'ui/shared/forms/validators/email';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
isReadOnly?: boolean;
defaultValue: string | undefined;
}
const MyProfileFieldsEmail = ({ isReadOnly, defaultValue }: Props) => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'email'>({
control,
name: 'email',
rules: { required: true, pattern: EMAIL_REGEXP },
});
const isDisabled = formState.isSubmitting;
const isVerified = defaultValue && field.value === defaultValue;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size="md">
<InputGroup>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<FormInputPlaceholder text="Email" error={ fieldState.error }/>
{ isVerified && (
<InputRightElement h="100%">
<IconSvg name="certified" boxSize={ 5 } color="green.500"/>
</InputRightElement>
) }
</InputGroup>
<Text variant="secondary" mt={ 1 } fontSize="sm">Email for watch list notifications and private tags</Text>
</FormControl>
);
};
export default React.memo(MyProfileFieldsEmail);
export interface FormFields {
email: string;
name: string;
reCaptcha: string;
}
......@@ -4,7 +4,6 @@ import React, { useCallback, useState } from 'react';
import type { ApiKey } from 'types/api/account';
import useApiQuery from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { space } from 'lib/html-entities';
import { API_KEY } from 'stubs/account';
import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal';
......@@ -14,6 +13,7 @@ import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import PageTitle from 'ui/shared/Page/PageTitle';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const DATA_LIMIT = 3;
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import { test, expect } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import CsvExport from './CsvExport';
test('base view +@mobile +@dark-mode', async({ render, page, mockApiResponse }) => {
test('base view +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'transactions', filterType: 'address', filterValue: 'from' },
......@@ -17,15 +15,12 @@ test('base view +@mobile +@dark-mode', async({ render, page, mockApiResponse })
await mockApiResponse('address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('config_csv_export', { limit: 42123 });
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
const component = await render(<CsvExport/>, { hooksConfig });
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: pwConfig.maskColor,
});
await expect(component).toHaveScreenshot();
});
test('token holders', async({ render, page, mockApiResponse }) => {
test('token holders', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { address: addressMock.hash, type: 'holders' },
......@@ -34,10 +29,7 @@ test('token holders', async({ render, page, mockApiResponse }) => {
await mockApiResponse('address', addressMock.token, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('token', tokenMock.tokenInfo, { pathParams: { hash: addressMock.hash } });
const component = await render(<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}><CsvExport/></Box>, { hooksConfig });
const component = await render(<CsvExport/>, { hooksConfig });
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: pwConfig.maskColor,
});
await expect(component).toHaveScreenshot();
});
......@@ -4,7 +4,6 @@ import React, { useCallback, useState } from 'react';
import type { CustomAbi } from 'types/api/account';
import useApiQuery from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { CUSTOM_ABI } from 'stubs/account';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import CustomAbiListItem from 'ui/customAbi/CustomAbiTable/CustomAbiListItem';
......@@ -13,6 +12,7 @@ import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import PageTitle from 'ui/shared/Page/PageTitle';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const CustomAbiPage: React.FC = () => {
const customAbiModalProps = useDisclosure();
......
......@@ -11,7 +11,6 @@ import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useToast from 'lib/hooks/useToast';
import PageTitle from 'ui/shared/Page/PageTitle';
{ /* will be deleted when we fix login in preview CI stands */ }
const Login = () => {
const toast = useToast();
const [ num, setNum ] = useGradualIncrement(0);
......@@ -68,12 +67,12 @@ const Login = () => {
<>
<Alert status="error" flexDirection="column" alignItems="flex-start">
<AlertTitle fontSize="md">
!!! Temporary solution for authentication on localhost !!!
!!! Temporary solution for authentication on localhost !!!
</AlertTitle>
<AlertDescription mt={ 3 }>
To Sign in go to production instance first, sign in there, copy obtained API token from cookie
To Sign in go to production instance first, sign in there, copy obtained API token from cookie
<Code ml={ 1 }>{ cookies.NAMES.API_TOKEN }</Code> and paste it in the form below. After submitting the form you should be successfully
authenticated in current environment
authenticated in current environment
</AlertDescription>
</Alert>
<Textarea value={ token } onChange={ handleTokenChange } placeholder="API token"/>
......
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test as base, expect } from 'playwright/lib';
import MyProfile from './MyProfile';
const test = base.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
test('without address', async({ render, mockApiResponse }) => {
await mockApiResponse('user_info', profileMock.base);
const component = await render(<MyProfile/>);
await expect(component).toHaveScreenshot();
});
test('without email', async({ render, mockApiResponse }) => {
await mockApiResponse('user_info', profileMock.withoutEmail);
const component = await render(<MyProfile/>);
await expect(component).toHaveScreenshot();
});
import { VStack, FormControl, FormLabel, Input } from '@chakra-ui/react';
import { Flex, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import type { Screen } from 'ui/snippets/auth/types';
import config from 'configs/app';
import MyProfileEmail from 'ui/myProfile/MyProfileEmail';
import MyProfileWallet from 'ui/myProfile/MyProfileWallet';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import PageTitle from 'ui/shared/Page/PageTitle';
import UserAvatar from 'ui/shared/UserAvatar';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const MIXPANEL_CONFIG = {
wallet_connect: {
source: 'Profile' as const,
},
account_link_info: {
source: 'Profile' as const,
},
};
const MyProfile = () => {
const { data, isPending, isError } = useFetchProfileInfo();
const [ authInitialScreen, setAuthInitialScreen ] = React.useState<Screen>();
const authModal = useDisclosure();
const profileQuery = useProfileQuery();
useRedirectForInvalidAuthToken();
const handleAddWalletClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
authModal.onOpen();
}, [ authModal ]);
const content = (() => {
if (isPending) {
if (profileQuery.isPending) {
return <ContentLoader/>;
}
if (isError) {
if (profileQuery.isError) {
return <DataFetchAlert/>;
}
return (
<VStack maxW="412px" mt={ 8 } gap={ 5 } alignItems="stretch">
<UserAvatar size={ 64 }/>
<FormControl variant="floating" id="name" isRequired size="lg">
<Input
required
readOnly
value={ data.name || '' }
/>
<FormLabel>Name</FormLabel>
</FormControl>
<FormControl variant="floating" id="nickname" isRequired size="lg">
<Input
required
readOnly
value={ data.nickname || '' }
/>
<FormLabel>Nickname</FormLabel>
</FormControl>
<FormControl variant="floating" id="email" isRequired size="lg">
<Input
required
readOnly
value={ data.email || '' }
/>
<FormLabel>Email</FormLabel>
</FormControl>
</VStack>
<>
<AccountPageDescription>
You can add your email to receive watchlist notifications.
Additionally, you can manage your wallet address and email, which can be used for logging into your Blockscout account.
</AccountPageDescription>
<Flex maxW="480px" mt={ 8 } flexDir="column" rowGap={ 12 }>
<MyProfileEmail profileQuery={ profileQuery }/>
{ config.features.blockchainInteraction.isEnabled &&
<MyProfileWallet profileQuery={ profileQuery } onAddWallet={ handleAddWalletClick }/> }
</Flex>
{ authModal.isOpen && authInitialScreen &&
<AuthModal initialScreen={ authInitialScreen } onClose={ authModal.onClose } mixpanelConfig={ MIXPANEL_CONFIG }/> }
</>
);
})();
......
......@@ -9,7 +9,6 @@ import useApiQuery from 'lib/api/useApiQuery';
import useDebounce from 'lib/hooks/useDebounce';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import { ENS_DOMAIN } from 'stubs/ENS';
import { generateListStub } from 'stubs/utils';
import NameDomainsActionBar from 'ui/nameDomains/NameDomainsActionBar';
......@@ -18,6 +17,7 @@ import NameDomainsTable from 'ui/nameDomains/NameDomainsTable';
import type { Sort, SortField } from 'ui/nameDomains/utils';
import { SORT_OPTIONS, getNextSortValue } from 'ui/nameDomains/utils';
import DataListDisplay from 'ui/shared/DataListDisplay';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
......
......@@ -2,11 +2,11 @@ import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags';
import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
const TABS: Array<RoutedTab> = [
{ id: 'address', title: 'Address', component: <PrivateAddressTags/> },
......
......@@ -3,12 +3,12 @@ import React from 'react';
import type { FormSubmitResult } from 'ui/publicTags/submit/types';
import useApiQuery from 'lib/api/useApiQuery';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import PublicTagsSubmitForm from 'ui/publicTags/submit/PublicTagsSubmitForm';
import PublicTagsSubmitResult from 'ui/publicTags/submit/PublicTagsSubmitResult';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import PageTitle from 'ui/shared/Page/PageTitle';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
type Screen = 'form' | 'result' | 'initializing' | 'error';
......@@ -17,8 +17,8 @@ const PublicTagsSubmit = () => {
const [ screen, setScreen ] = React.useState<Screen>('initializing');
const [ submitResult, setSubmitResult ] = React.useState<FormSubmitResult>();
const userQuery = useFetchProfileInfo();
const configQuery = useApiQuery('address_metadata_tag_types', { queryOptions: { enabled: !userQuery.isLoading } });
const profileQuery = useProfileQuery();
const configQuery = useApiQuery('address_metadata_tag_types', { queryOptions: { enabled: !profileQuery.isLoading } });
React.useEffect(() => {
if (!configQuery.isPending) {
......@@ -38,7 +38,7 @@ const PublicTagsSubmit = () => {
case 'error':
return <DataFetchAlert/>;
case 'form':
return <PublicTagsSubmitForm config={ configQuery.data } onSubmitResult={ handleFormSubmitResult } userInfo={ userQuery.data }/>;
return <PublicTagsSubmitForm config={ configQuery.data } onSubmitResult={ handleFormSubmitResult } userInfo={ profileQuery.data }/>;
case 'result':
return <PublicTagsSubmitResult data={ submitResult }/>;
default:
......
......@@ -5,7 +5,6 @@ import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useHasAccount from 'lib/hooks/useHasAccount';
import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
......@@ -16,6 +15,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import TxsStats from 'ui/txs/TxsStats';
import TxsWatchlist from 'ui/txs/TxsWatchlist';
import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting';
......@@ -88,7 +88,7 @@ const Transactions = () => {
const { num, socketAlert } = useNewTxsSocket();
const hasAccount = useHasAccount();
const isAuth = useIsAuth();
const tabs: Array<RoutedTab> = [
{
......@@ -129,7 +129,7 @@ const Transactions = () => {
/>
),
},
hasAccount ? {
isAuth ? {
id: 'watchlist',
title: 'Watch list',
component: <TxsWatchlist query={ txsWatchlistQuery }/>,
......
import { Box, Text, Button, Heading, chakra } from '@chakra-ui/react';
import React from 'react';
import useApiFetch from 'lib/api/useApiFetch';
import dayjs from 'lib/date/dayjs';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
email?: string; // TODO: obtain email from API
}
const UnverifiedEmail = ({ email }: Props) => {
const apiFetch = useApiFetch();
const [ isLoading, setIsLoading ] = React.useState(false);
const toast = useToast();
const handleButtonClick = React.useCallback(async() => {
const toastId = 'resend-email-error';
setIsLoading(true);
mixpanel.logEvent(
mixpanel.EventTypes.ACCOUNT_ACCESS,
{ Action: 'Verification email resent' },
);
try {
await apiFetch('email_resend');
toast({
id: toastId,
position: 'top-right',
title: 'Success',
description: 'Email successfully resent.',
status: 'success',
variant: 'subtle',
isClosable: true,
});
} catch (error) {
const statusCode = getErrorObjStatusCode(error);
const message = (() => {
if (statusCode !== 429) {
return;
}
const payload = getErrorObjPayload<{ seconds_before_next_resend: number }>(error);
if (!payload) {
return;
}
if (!payload.seconds_before_next_resend) {
return;
}
const timeUntilNextResend = dayjs().add(payload.seconds_before_next_resend, 'seconds').fromNow();
return `Email resend is available ${ timeUntilNextResend }.`;
})();
!toast.isActive(toastId) && toast({
id: toastId,
position: 'top-right',
title: 'Error',
description: message || 'Something went wrong. Try again later.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
setIsLoading(false);
}, [ apiFetch, toast ]);
return (
<Box>
<IconSvg name="email-sent" width="180px" height="auto" mt="52px"/>
<Heading mt={ 6 } size="2xl">Verify your email address</Heading>
<Text variant="secondary" mt={ 3 }>
<span>Please confirm your email address to use the My Account feature. A confirmation email was sent to </span>
<span>{ email || 'your email address' }</span>
<span> on signup. { `Didn't receive?` }</span>
</Text>
<Button
mt={ 8 }
size="lg"
variant="outline"
isLoading={ isLoading }
loadingText="Resending..."
onClick={ handleButtonClick }
>
Resend verification email
</Button>
</Box>
);
};
export default chakra(UnverifiedEmail);
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Link, Alert } from '@chakra-ui/react';
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Link } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -7,8 +7,6 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { PAGE_TYPE_DICT } from 'lib/mixpanel/getPageType';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TOKEN_INFO_APPLICATION, VERIFIED_ADDRESS } from 'stubs/account';
......@@ -17,7 +15,10 @@ import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import AdminSupportText from 'ui/shared/texts/AdminSupportText';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
import TokenInfoForm from 'ui/tokenInfo/TokenInfoForm';
import VerifiedAddressesEmailAlert from 'ui/verifiedAddresses/VerifiedAddressesEmailAlert';
import VerifiedAddressesListItem from 'ui/verifiedAddresses/VerifiedAddressesListItem';
import VerifiedAddressesTable from 'ui/verifiedAddresses/VerifiedAddressesTable';
......@@ -38,20 +39,20 @@ const VerifiedAddresses = () => {
const modalProps = useDisclosure();
const queryClient = useQueryClient();
const userInfoQuery = useFetchProfileInfo();
const profileQuery = useProfileQuery();
const addressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: config.chain.id },
queryOptions: {
placeholderData: { verifiedAddresses: Array(3).fill(VERIFIED_ADDRESS) },
enabled: Boolean(userInfoQuery.data?.email),
enabled: Boolean(profileQuery.data?.email),
},
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: config.chain.id, id: undefined },
queryOptions: {
placeholderData: { submissions: Array(3).fill(TOKEN_INFO_APPLICATION) },
enabled: Boolean(userInfoQuery.data?.email),
enabled: Boolean(profileQuery.data?.email),
select: (data) => {
return {
...data,
......@@ -62,7 +63,7 @@ const VerifiedAddresses = () => {
});
const isLoading = addressesQuery.isPlaceholderData || applicationsQuery.isPlaceholderData;
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const userWithoutEmail = profileQuery.data && !profileQuery.data.email;
const handleGoBack = React.useCallback(() => {
setSelectedAddress(undefined);
......@@ -193,11 +194,7 @@ const VerifiedAddresses = () => {
return (
<>
<PageTitle title="My verified addresses"/>
{ userWithoutEmail && (
<Alert status="warning" mb={ 6 }>
You need a valid email address to verify addresses. Please logout of MyAccount then login using your email to proceed.
</Alert>
) }
{ userWithoutEmail && <VerifiedAddressesEmailAlert/> }
<AccountPageDescription allowCut={ false }>
<span>
Verify ownership of a smart contract address to easily update information in Blockscout.
......@@ -222,7 +219,7 @@ const VerifiedAddresses = () => {
<AdminSupportText mt={ 5 }/>
</AccountPageDescription>
<DataListDisplay
isError={ userInfoQuery.isError || addressesQuery.isError || applicationsQuery.isError }
isError={ profileQuery.isError || addressesQuery.isError || applicationsQuery.isError }
items={ addressesQuery.data?.verifiedAddresses }
content={ content }
emptyText=""
......
......@@ -6,7 +6,6 @@ import type { WatchlistAddress, WatchlistResponse } from 'types/api/account';
import { resourceKey } from 'lib/api/resources';
import { getResourceKey } from 'lib/api/useApiQuery';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { WATCH_LIST_ITEM_WITH_TOKEN_INFO } from 'stubs/account';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
......@@ -14,6 +13,8 @@ import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
import AddressModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
import WatchListItem from 'ui/watchlist/WatchlistTable/WatchListItem';
......@@ -28,6 +29,7 @@ const WatchList: React.FC = () => {
},
});
const queryClient = useQueryClient();
const profileQuery = useProfileQuery();
const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
......@@ -93,6 +95,7 @@ const WatchList: React.FC = () => {
isLoading={ isPlaceholderData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
hasEmail={ Boolean(profileQuery.data?.email) }
/>
)) }
</Box>
......@@ -103,6 +106,7 @@ const WatchList: React.FC = () => {
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
hasEmail={ Boolean(profileQuery.data?.email) }
/>
</Box>
</>
......
......@@ -3,18 +3,17 @@ import {
Button,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import React, { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { AddressTag, AddressTagErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import TagInput from 'ui/shared/TagInput';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
const TAG_MAX_LENGTH = 35;
......@@ -33,7 +32,7 @@ type Inputs = {
const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const apiFetch = useApiFetch();
const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
mode: 'onTouched',
defaultValues: {
address: data?.address_hash || '',
......@@ -41,7 +40,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisibl
},
});
const { mutate } = useMutation({
const { mutateAsync } = useMutation({
mutationFn: (formData: Inputs) => {
const body = {
name: formData?.tag,
......@@ -62,10 +61,10 @@ const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisibl
setPending(false);
const errorMap = error.payload?.errors;
if (errorMap?.address_hash || errorMap?.name) {
errorMap?.address_hash && setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
errorMap?.address_hash && formApi.setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && formApi.setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
} else if (errorMap?.identity_id) {
setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
formApi.setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
} else {
setAlertVisible(true);
}
......@@ -77,55 +76,43 @@ const AddressForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisibl
},
});
const onSubmit: SubmitHandler<Inputs> = (formData) => {
const onSubmit: SubmitHandler<Inputs> = async(formData) => {
setAlertVisible(false);
setPending(true);
mutate(formData);
await mutateAsync(formData);
};
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return <AddressInput<Inputs, 'address'> field={ field } error={ errors.address } bgColor="dialog_bg"/>;
}, [ errors ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag } bgColor="dialog_bg"/>;
}, [ errors ]);
return (
<form noValidate onSubmit={ handleSubmit(onSubmit) }>
<Box marginBottom={ 5 }>
<Controller
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
<FormFieldAddress<Inputs>
name="address"
control={ control }
rules={{
pattern: ADDRESS_REGEXP,
required: true,
}}
render={ renderAddressInput }
isRequired
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginBottom={ 8 }>
<Controller
<FormFieldText<Inputs>
name="tag"
control={ control }
placeholder="Private tag (max 35 characters)"
isRequired
rules={{
maxLength: TAG_MAX_LENGTH,
required: true,
}}
render={ renderTagInput }
bgColor="dialog_bg"
mb={ 8 }
/>
</Box>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !isDirty }
isLoading={ pending }
>
{ data ? 'Save changes' : 'Add tag' }
</Button>
</Box>
</form>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !formApi.formState.isDirty }
isLoading={ pending }
>
{ data ? 'Save changes' : 'Add tag' }
</Button>
</Box>
</form>
</FormProvider>
);
};
......
......@@ -3,9 +3,9 @@ import {
Button,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import React, { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { TransactionTag, TransactionTagErrors } from 'types/api/account';
......@@ -13,9 +13,8 @@ import type { ResourceErrorAccount } from 'lib/api/resources';
import { resourceKey } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { TRANSACTION_HASH_REGEXP } from 'lib/validations/transaction';
import TagInput from 'ui/shared/TagInput';
import TransactionInput from 'ui/shared/TransactionInput';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import { TRANSACTION_HASH_LENGTH, TRANSACTION_HASH_REGEXP } from 'ui/shared/forms/validators/transaction';
const TAG_MAX_LENGTH = 35;
......@@ -34,7 +33,7 @@ type Inputs = {
const TransactionForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVisible }) => {
const [ pending, setPending ] = useState(false);
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
mode: 'onTouched',
defaultValues: {
transaction: data?.transaction_hash || '',
......@@ -45,7 +44,7 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVi
const queryClient = useQueryClient();
const apiFetch = useApiFetch();
const { mutate } = useMutation({
const { mutateAsync } = useMutation({
mutationFn: (formData: Inputs) => {
const body = {
name: formData?.tag,
......@@ -66,10 +65,10 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVi
setPending(false);
const errorMap = error.payload?.errors;
if (errorMap?.tx_hash || errorMap?.name) {
errorMap?.tx_hash && setError('transaction', { type: 'custom', message: getErrorMessage(errorMap, 'tx_hash') });
errorMap?.name && setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
errorMap?.tx_hash && formApi.setError('transaction', { type: 'custom', message: getErrorMessage(errorMap, 'tx_hash') });
errorMap?.name && formApi.setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
} else if (errorMap?.identity_id) {
setError('transaction', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
formApi.setError('transaction', { type: 'custom', message: getErrorMessage(errorMap, 'identity_id') });
} else {
setAlertVisible(true);
}
......@@ -82,54 +81,47 @@ const TransactionForm: React.FC<Props> = ({ data, onClose, onSuccess, setAlertVi
},
});
const onSubmit: SubmitHandler<Inputs> = formData => {
const onSubmit: SubmitHandler<Inputs> = async(formData) => {
setPending(true);
mutate(formData);
await mutateAsync(formData);
};
const renderTransactionInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'transaction'>}) => {
return <TransactionInput field={ field } error={ errors.transaction } bgColor="dialog_bg"/>;
}, [ errors ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag } bgColor="dialog_bg"/>;
}, [ errors ]);
return (
<form noValidate onSubmit={ handleSubmit(onSubmit) }>
<Box marginBottom={ 5 }>
<Controller
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
<FormFieldText<Inputs>
name="transaction"
control={ control }
placeholder="Transaction hash (0x...)"
isRequired
rules={{
maxLength: TRANSACTION_HASH_LENGTH,
pattern: TRANSACTION_HASH_REGEXP,
required: true,
}}
render={ renderTransactionInput }
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginBottom={ 8 }>
<Controller
<FormFieldText<Inputs>
name="tag"
control={ control }
placeholder="Private tag (max 35 characters)"
isRequired
rules={{
maxLength: TAG_MAX_LENGTH,
required: true,
}}
render={ renderTagInput }
bgColor="dialog_bg"
mb={ 8 }
/>
</Box>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !isDirty }
isLoading={ pending }
>
{ data ? 'Save changes' : 'Add tag' }
</Button>
</Box>
</form>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isDisabled={ !formApi.formState.isDirty }
isLoading={ pending }
>
{ data ? 'Save changes' : 'Add tag' }
</Button>
</Box>
</form>
</FormProvider>
);
};
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import { publicTagTypes as configMock } from 'mocks/metadata/publicTagTypes';
import { base as useInfoMock } from 'mocks/user/profile';
import { expect, test } from 'playwright/lib';
import * as pwConfig from 'playwright/utils/config';
import * as mocks from './mocks';
import PublicTagsSubmitForm from './PublicTagsSubmitForm';
......@@ -13,9 +11,7 @@ const onSubmitResult = () => {};
test('base view +@mobile', async({ render }) => {
const component = await render(
<Box sx={{ '.recaptcha': { w: '304px', h: '78px' } }}>
<PublicTagsSubmitForm config={ configMock } onSubmitResult={ onSubmitResult } userInfo={ useInfoMock }/>
</Box>,
<PublicTagsSubmitForm config={ configMock } onSubmitResult={ onSubmitResult } userInfo={ useInfoMock }/>,
);
await component.getByLabel(/Smart contract \/ Address/i).fill(mocks.address1);
......@@ -30,8 +26,5 @@ test('base view +@mobile', async({ render }) => {
await component.getByLabel(/connection/i).focus();
await component.getByLabel(/connection/i).blur();
await expect(component).toHaveScreenshot({
mask: [ component.locator('.recaptcha') ],
maskColor: pwConfig.maskColor,
});
await expect(component).toHaveScreenshot();
});
import { Button, chakra, Grid, GridItem } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
......@@ -13,15 +14,13 @@ import useApiFetch from 'lib/api/useApiFetch';
import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useIsMobile from 'lib/hooks/useIsMobile';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
import FormFieldReCaptcha from 'ui/shared/forms/fields/FormFieldReCaptcha';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import Hint from 'ui/shared/Hint';
import PublicTagsSubmitFieldAddresses from './fields/PublicTagsSubmitFieldAddresses';
import PublicTagsSubmitFieldCompanyName from './fields/PublicTagsSubmitFieldCompanyName';
import PublicTagsSubmitFieldCompanyWebsite from './fields/PublicTagsSubmitFieldCompanyWebsite';
import PublicTagsSubmitFieldDescription from './fields/PublicTagsSubmitFieldDescription';
import PublicTagsSubmitFieldRequesterEmail from './fields/PublicTagsSubmitFieldRequesterEmail';
import PublicTagsSubmitFieldRequesterName from './fields/PublicTagsSubmitFieldRequesterName';
import PublicTagsSubmitFieldTags from './fields/PublicTagsSubmitFieldTags';
import { convertFormDataToRequestsBody, getFormDefaultValues } from './utils';
......@@ -77,56 +76,78 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
onSubmitResult(result);
}, [ apiFetch, onSubmitResult ]);
if (!appConfig.services.reCaptchaV3.siteKey) {
return null;
}
const fieldProps = {
size: { base: 'md', lg: 'lg' },
};
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<Grid
columnGap={ 3 }
rowGap={ 3 }
templateColumns={{ base: '1fr', lg: '1fr 1fr minmax(0, 200px)', xl: '1fr 1fr minmax(0, 250px)' }}
<GoogleReCaptchaProvider reCaptchaKey={ appConfig.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<PublicTagsSubmitFieldRequesterName/>
<PublicTagsSubmitFieldRequesterEmail/>
{ !isMobile && <div/> }
<PublicTagsSubmitFieldCompanyName/>
<PublicTagsSubmitFieldCompanyWebsite/>
{ !isMobile && <div/> }
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4" mt={{ base: 3, lg: 5 }}>
Public tags/labels
<Hint label="Submit a public tag proposal for our moderation team to review" ml={ 1 } color="link"/>
</GridItem>
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<PublicTagsSubmitFieldDescription/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 3 }}>
<FormFieldReCaptcha/>
</GridItem>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 3 }
isDisabled={ !formApi.formState.isValid }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
<Grid
columnGap={ 3 }
rowGap={ 3 }
templateColumns={{ base: '1fr', lg: '1fr 1fr minmax(0, 200px)', xl: '1fr 1fr minmax(0, 250px)' }}
>
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<FormFieldText<FormFields> name="requesterName" isRequired placeholder="Your name" { ...fieldProps }/>
<FormFieldEmail<FormFields> name="requesterEmail" isRequired { ...fieldProps }/>
{ !isMobile && <div/> }
<FormFieldText<FormFields> name="companyName" placeholder="Company name" { ...fieldProps }/>
<FormFieldUrl<FormFields> name="companyWebsite" placeholder="Company website" { ...fieldProps }/>
{ !isMobile && <div/> }
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4" mt={{ base: 3, lg: 5 }}>
Public tags/labels
<Hint label="Submit a public tag proposal for our moderation team to review" ml={ 1 } color="link"/>
</GridItem>
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldText<FormFields>
name="description"
isRequired
placeholder={
isMobile ?
'Confirm the connection between addresses and tags.' :
'Provide a comment to confirm the connection between addresses and tags.'
}
maxH="160px"
rules={{ maxLength: 80 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 3 }}>
<FormFieldReCaptcha/>
</GridItem>
<Button
variant="solid"
size="lg"
type="submit"
mt={ 3 }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
>
Send request
</Button>
</Grid>
</chakra.form>
</FormProvider>
</Button>
</Grid>
</chakra.form>
</FormProvider>
</GoogleReCaptchaProvider>
);
};
......
import { FormControl, GridItem, IconButton, Input } from '@chakra-ui/react';
import { GridItem, IconButton } from '@chakra-ui/react';
import React from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const LIMIT = 20;
const PublicTagsSubmitFieldAddresses = () => {
const { control, formState, register } = useFormContext<FormFields>();
const { control, formState } = useFormContext<FormFields>();
const { fields, insert, remove } = useFieldArray<FormFields, 'addresses'>({
name: 'addresses',
control,
......@@ -36,20 +35,15 @@ const PublicTagsSubmitFieldAddresses = () => {
return (
<>
{ fields.map((field, index) => {
const error = formState.errors?.addresses?.[ index ]?.hash;
return (
<React.Fragment key={ field.id }>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`addresses.${ index }.hash`, { required: true, pattern: ADDRESS_REGEXP }) }
isInvalid={ Boolean(error) }
isDisabled={ formState.isSubmitting }
autoComplete="off"
/>
<InputPlaceholder text="Smart contract / Address (0x...)" error={ error }/>
</FormControl>
<FormFieldAddress<FormFields>
name={ `addresses.${ index }.hash` }
isRequired
placeholder="Smart contract / Address (0x...)"
size={{ base: 'md', lg: 'lg' }}
/>
</GridItem>
<GridItem display="flex" alignItems="center" columnGap={ 5 } justifyContent={{ base: 'flex-end', lg: 'flex-start' }}>
{ fields.length < LIMIT && index === fields.length - 1 && (
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldCompanyName = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'companyName'>({ control, name: 'companyName' });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Company name" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldCompanyName);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { validator as urlValidator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldCompanyWebsite = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'companyWebsite'>({ control, name: 'companyWebsite', rules: { validate: urlValidator } });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Company website" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldCompanyWebsite);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import useIsMobile from 'lib/hooks/useIsMobile';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const MAX_LENGTH = 80;
const PublicTagsSubmitFieldDescription = () => {
const isMobile = useIsMobile();
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'description'>({
control,
name: 'description',
rules: { maxLength: MAX_LENGTH, required: true },
});
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
maxH="160px"
maxLength={ MAX_LENGTH }
/>
<InputPlaceholder
text={ isMobile ? 'Confirm the connection between addresses and tags.' : 'Provide a comment to confirm the connection between addresses and tags.' }
error={ fieldState.error }
/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldDescription);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldRequesterEmail = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'requesterEmail'>({
control,
name: 'requesterEmail',
rules: { required: true, pattern: EMAIL_REGEXP },
});
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Email" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldRequesterEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const PublicTagsSubmitFieldRequesterName = () => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, 'requesterName'>({ control, name: 'requesterName', rules: { required: true } });
const isDisabled = formState.isSubmitting;
return (
<FormControl variant="floating" isDisabled={ isDisabled } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Your name" error={ fieldState.error }/>
</FormControl>
);
};
export default React.memo(PublicTagsSubmitFieldRequesterName);
import { chakra, Flex, FormControl, Grid, GridItem, IconButton, Input, Textarea, useColorModeValue } from '@chakra-ui/react';
import { chakra, Flex, Grid, GridItem, IconButton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { type FieldError, type FieldErrorsImpl, type Merge, type UseFormRegister } from 'react-hook-form';
import { type FieldError, type FieldErrorsImpl, type Merge } from 'react-hook-form';
import type { FormFields, FormFieldTag } from '../types';
import type { PublicTagType } from 'types/api/addressMetadata';
import useIsMobile from 'lib/hooks/useIsMobile';
import { validator as colorValidator } from 'lib/validations/color';
import { validator as urlValidator } from 'lib/validations/url';
import EntityTag from 'ui/shared/EntityTags/EntityTag';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import { validator as colorValidator } from 'ui/shared/forms/validators/color';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import PublicTagsSubmitFieldTagColor from './PublicTagsSubmitFieldTagColor';
import PublicTagsSubmitFieldTagType from './PublicTagsSubmitFieldTagType';
......@@ -19,14 +19,13 @@ interface Props {
index: number;
field: FormFieldTag;
tagTypes: Array<PublicTagType> | undefined;
register: UseFormRegister<FormFields>;
errors: Merge<FieldError, FieldErrorsImpl<FormFieldTag>> | undefined;
isDisabled: boolean;
onAddClick?: (index: number) => void;
onRemoveClick?: (index: number) => void;
}
const PublicTagsSubmitFieldTag = ({ index, isDisabled, register, errors, onAddClick, onRemoveClick, tagTypes, field }: Props) => {
const PublicTagsSubmitFieldTag = ({ index, isDisabled, errors, onAddClick, onRemoveClick, tagTypes, field }: Props) => {
const isMobile = useIsMobile();
const bgColorDefault = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const bgColorError = useColorModeValue('red.50', 'red.900');
......@@ -39,6 +38,10 @@ const PublicTagsSubmitFieldTag = ({ index, isDisabled, register, errors, onAddCl
onRemoveClick?.(index);
}, [ index, onRemoveClick ]);
const fieldProps = {
size: { base: 'md', lg: 'lg' },
};
return (
<>
<GridItem colSpan={{ base: 1, lg: 2 }} p="10px" borderRadius="base" bgColor={ errors ? bgColorError : bgColorDefault }>
......@@ -48,62 +51,45 @@ const PublicTagsSubmitFieldTag = ({ index, isDisabled, register, errors, onAddCl
templateColumns={{ base: '1fr', lg: 'repeat(4, 1fr)' }}
>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`tags.${ index }.name`, { required: true, maxLength: 35 }) }
isInvalid={ Boolean(errors?.name) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Tag (max 35 characters)" error={ errors?.name }/>
</FormControl>
<FormFieldText<FormFields>
name={ `tags.${ index }.name` }
placeholder="Tag (max 35 characters)"
isRequired
rules={{ maxLength: 35 }}
{ ...fieldProps }
/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<PublicTagsSubmitFieldTagType index={ index } tagTypes={ tagTypes } isDisabled={ isDisabled }/>
<PublicTagsSubmitFieldTagType index={ index } tagTypes={ tagTypes }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormControl variant="floating" size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...register(`tags.${ index }.url`, { validate: urlValidator }) }
isInvalid={ Boolean(errors?.url) }
isDisabled={ isDisabled }
autoComplete="off"
/>
<InputPlaceholder text="Label URL" error={ errors?.url }/>
</FormControl>
<FormFieldUrl<FormFields>
name={ `tags.${ index }.url` }
placeholder="Label URL"
{ ...fieldProps }
/>
</GridItem>
<PublicTagsSubmitFieldTagColor
fieldType="bgColor"
fieldName={ `tags.${ index }.bgColor` }
placeholder="Background (Hex)"
index={ index }
register={ register }
error={ errors?.bgColor }
isDisabled={ isDisabled }
/>
<PublicTagsSubmitFieldTagColor
fieldType="textColor"
fieldName={ `tags.${ index }.textColor` }
placeholder="Text (Hex)"
index={ index }
register={ register }
error={ errors?.textColor }
isDisabled={ isDisabled }
/>
<GridItem colSpan={{ base: 1, lg: 4 }}>
<FormControl variant="floating" size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...register(`tags.${ index }.tooltipDescription`, { maxLength: 80 }) }
isInvalid={ Boolean(errors?.tooltipDescription) }
isDisabled={ isDisabled }
autoComplete="off"
maxH="160px"
/>
<InputPlaceholder
text="Label description (max 80 characters)"
error={ errors?.tooltipDescription }
/>
</FormControl>
<FormFieldText<FormFields>
name={ `tags.${ index }.tooltipDescription` }
placeholder="Label description (max 80 characters)"
maxH="160px"
rules={{ maxLength: 80 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
</Grid>
</GridItem>
......
import { Circle, FormControl, Input, InputGroup, InputRightElement, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import { useFormContext, type FieldError, type UseFormRegister } from 'react-hook-form';
import { useFormContext, type FieldError } from 'react-hook-form';
import type { FormFields } from '../types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { validator as colorValidator } from 'lib/validations/color';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormInputPlaceholder from 'ui/shared/forms/inputs/FormInputPlaceholder';
import { validator as colorValidator } from 'ui/shared/forms/validators/color';
type ColorFieldTypes = 'bgColor' | 'textColor';
interface Props<Type extends ColorFieldTypes> {
fieldType: Type;
fieldName: `tags.${ number }.${ Type }`;
index: number;
isDisabled: boolean;
register: UseFormRegister<FormFields>;
error: FieldError | undefined;
placeholder: string;
}
const PublicTagsSubmitFieldTagColor = <Type extends ColorFieldTypes>({ isDisabled, error, fieldName, placeholder, fieldType }: Props<Type>) => {
const { register } = useFormContext<FormFields>();
const PublicTagsSubmitFieldTagColor = <Type extends ColorFieldTypes>({ error, fieldName, placeholder, fieldType }: Props<Type>) => {
const { register, formState } = useFormContext<FormFields>();
const isDisabled = formState.isSubmitting;
const circleBgColorDefault = {
bgColor: useColorModeValue('gray.100', 'gray.700'),
......@@ -57,7 +56,7 @@ const PublicTagsSubmitFieldTagColor = <Type extends ColorFieldTypes>({ isDisable
autoComplete="off"
maxLength={ 7 }
/>
<InputPlaceholder text={ placeholder } error={ error }/>
<FormInputPlaceholder text={ placeholder } error={ error }/>
<InputRightElement w="30px" h="auto" right={ 4 } top="50%" transform="translateY(-50%)" zIndex={ 10 }>
<Circle
size="30px"
......
import { chakra, Flex, FormControl } from '@chakra-ui/react';
import { chakra, Flex } from '@chakra-ui/react';
import type { GroupBase, SelectComponentsConfig, SingleValueProps } from 'chakra-react-select';
import { chakraComponents } from 'chakra-react-select';
import _capitalize from 'lodash/capitalize';
import React from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import type { FormFields } from '../types';
import type { PublicTagType } from 'types/api/addressMetadata';
import type { Option } from 'ui/shared/FancySelect/types';
import type { Option } from 'ui/shared/forms/inputs/select/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
index: number;
tagTypes: Array<PublicTagType> | undefined;
isDisabled: boolean;
}
const PublicTagsSubmitFieldTagType = ({ index, tagTypes, isDisabled }: Props) => {
const isMobile = useIsMobile();
const { control, watch } = useFormContext<FormFields>();
const PublicTagsSubmitFieldTagType = ({ index, tagTypes }: Props) => {
const { watch } = useFormContext<FormFields>();
const typeOptions = React.useMemo(() => tagTypes?.map((type) => ({
value: type.type,
label: _capitalize(type.type),
})), [ tagTypes ]);
})) ?? [], [ tagTypes ]);
const fieldValue = watch(`tags.${ index }.type`).value;
......@@ -63,30 +59,15 @@ const PublicTagsSubmitFieldTagType = ({ index, tagTypes, isDisabled }: Props) =>
return { SingleValue };
}, [ fieldValue ]);
const renderControl = React.useCallback(({ field }: { field: ControllerRenderProps<FormFields, `tags.${ number }.type`> }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<FancySelect
{ ...field }
options={ typeOptions }
size={ isMobile ? 'md' : 'lg' }
placeholder="Tag type"
isDisabled={ isDisabled }
isRequired
isAsync={ false }
isSearchable={ false }
components={ selectComponents }
/>
</FormControl>
);
}, [ isDisabled, isMobile, selectComponents, typeOptions ]);
return (
<Controller
<FormFieldFancySelect<FormFields, `tags.${ number }.type`>
name={ `tags.${ index }.type` }
control={ control }
render={ renderControl }
rules={{ required: true }}
placeholder="Tag type"
options={ typeOptions }
isRequired
isAsync={ false }
isSearchable={ false }
components={ selectComponents }
/>
);
};
......
......@@ -13,7 +13,7 @@ interface Props {
}
const PublicTagsSubmitFieldTags = ({ tagTypes }: Props) => {
const { control, formState, register, watch } = useFormContext<FormFields>();
const { control, formState, watch } = useFormContext<FormFields>();
const { fields, insert, remove } = useFieldArray<FormFields, 'tags'>({
name: 'tags',
control,
......@@ -47,7 +47,6 @@ const PublicTagsSubmitFieldTags = ({ tagTypes }: Props) => {
field={ watch(`tags.${ index }`) }
index={ index }
tagTypes={ tagTypes }
register={ register }
errors={ errors }
isDisabled={ isDisabled }
onAddClick={ fields.length < LIMIT && index === fields.length - 1 ? handleAddFieldClick : undefined }
......
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { Option } from 'ui/shared/forms/inputs/select/types';
export interface FormFields {
requesterName: string;
......@@ -13,10 +14,7 @@ export interface FormFields {
export interface FormFieldTag {
name: string;
type: {
label: string;
value: AddressMetadataTagType;
};
type: Option<AddressMetadataTagType>;
url: string | undefined;
bgColor: string | undefined;
textColor: string | undefined;
......
......@@ -10,7 +10,6 @@ import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
......@@ -19,6 +18,7 @@ import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
......
......@@ -10,7 +10,6 @@ import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
......@@ -19,6 +18,7 @@ import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal';
......
......@@ -5,12 +5,11 @@ import React from 'react';
import type { ItemProps } from './types';
import config from 'configs/app';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useIsAccountActionAllowed from 'lib/hooks/useIsAccountActionAllowed';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import Menu from 'ui/shared/chakra/Menu';
import IconSvg from 'ui/shared/IconSvg';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import MetadataUpdateMenuItem from './items/MetadataUpdateMenuItem';
import PrivateTagMenuItem from './items/PrivateTagMenuItem';
......@@ -30,9 +29,8 @@ const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Pr
const isTokenPage = router.pathname === '/token/[hash]';
const isTokenInstancePage = router.pathname === '/token/[hash]/instance/[id]';
const isTxPage = router.pathname === '/tx/[hash]';
const isAccountActionAllowed = useIsAccountActionAllowed();
const userInfoQuery = useFetchProfileInfo();
const profileQuery = useProfileQuery();
const handleButtonClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Address actions (more button)' });
......@@ -42,7 +40,7 @@ const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Pr
return null;
}
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const userWithoutEmail = profileQuery.data && !profileQuery.data.email;
const items = [
{
......@@ -74,7 +72,7 @@ const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Pr
if (items.length === 1) {
return (
<Box className={ className }>
{ items[0].render({ type: 'button', hash, onBeforeClick: isAccountActionAllowed }) }
{ items[0].render({ type: 'button', hash }) }
</Box>
);
}
......@@ -95,7 +93,7 @@ const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Pr
<MenuList minWidth="180px" zIndex="popover">
{ items.map(({ render }, index) => (
<React.Fragment key={ index }>
{ render({ type: 'menu_item', hash, onBeforeClick: isAccountActionAllowed }) }
{ render({ type: 'menu_item', hash }) }
</React.Fragment>
)) }
</MenuList>
......
......@@ -12,6 +12,7 @@ import getPageType from 'lib/mixpanel/getPageType';
import AddressModal from 'ui/privateTags/AddressModal/AddressModal';
import TransactionModal from 'ui/privateTags/TransactionModal/TransactionModal';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem';
......@@ -20,7 +21,7 @@ interface Props extends ItemProps {
entityType?: 'address' | 'tx';
}
const PrivateTagMenuItem = ({ className, hash, onBeforeClick, entityType = 'address', type }: Props) => {
const PrivateTagMenuItem = ({ className, hash, entityType = 'address', type }: Props) => {
const modal = useDisclosure();
const queryClient = useQueryClient();
const router = useRouter();
......@@ -28,14 +29,6 @@ const PrivateTagMenuItem = ({ className, hash, onBeforeClick, entityType = 'addr
const queryKey = getResourceKey(entityType === 'tx' ? 'tx' : 'address', { pathParams: { hash } });
const queryData = queryClient.getQueryData<Address | Transaction>(queryKey);
const handleClick = React.useCallback(() => {
if (!onBeforeClick()) {
return;
}
modal.onOpen();
}, [ modal, onBeforeClick ]);
const handleAddPrivateTag = React.useCallback(async() => {
await queryClient.refetchQueries({ queryKey });
modal.onClose();
......@@ -62,14 +55,24 @@ const PrivateTagMenuItem = ({ className, hash, onBeforeClick, entityType = 'addr
const element = (() => {
switch (type) {
case 'button': {
return <ButtonItem label="Add private tag" icon="privattags" onClick={ handleClick } className={ className }/>;
return (
<AuthGuard onAuthSuccess={ modal.onOpen }>
{ ({ onClick }) => (
<ButtonItem label="Add private tag" icon="privattags" onClick={ onClick } className={ className }/>
) }
</AuthGuard>
);
}
case 'menu_item': {
return (
<MenuItem className={ className } onClick={ handleClick }>
<IconSvg name="privattags" boxSize={ 6 } mr={ 2 }/>
<span>Add private tag</span>
</MenuItem>
<AuthGuard onAuthSuccess={ modal.onOpen }>
{ ({ onClick }) => (
<MenuItem className={ className } onClick={ onClick }>
<IconSvg name="privattags" boxSize={ 6 } mr={ 2 }/>
<span>Add private tag</span>
</MenuItem>
) }
</AuthGuard>
);
}
}
......
......@@ -8,34 +8,26 @@ import IconSvg from 'ui/shared/IconSvg';
import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem';
const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps) => {
const PublicTagMenuItem = ({ className, hash, type }: ItemProps) => {
const router = useRouter();
const handleClick = React.useCallback(() => {
if (!onBeforeClick()) {
return;
}
router.push({ pathname: '/public-tags/submit', query: { addresses: [ hash ] } });
}, [ hash, onBeforeClick, router ]);
}, [ hash, router ]);
const element = (() => {
switch (type) {
case 'button': {
return <ButtonItem label="Add public tag" icon="publictags" onClick={ handleClick } className={ className }/>;
}
case 'menu_item': {
return (
<MenuItem className={ className } onClick={ handleClick }>
<IconSvg name="publictags" boxSize={ 6 } mr={ 2 }/>
<span>Add public tag</span>
</MenuItem>
);
}
switch (type) {
case 'button': {
return <ButtonItem label="Add public tag" icon="publictags" onClick={ handleClick } className={ className }/>;
}
})();
return element;
case 'menu_item': {
return (
<MenuItem className={ className } onClick={ handleClick }>
<IconSvg name="publictags" boxSize={ 6 } mr={ 2 }/>
<span>Add public tag</span>
</MenuItem>
);
}
}
};
export default React.memo(PublicTagMenuItem);
......@@ -6,18 +6,19 @@ import type { ItemProps } from '../types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useHasAccount from 'lib/hooks/useHasAccount';
import { PAGE_TYPE_DICT } from 'lib/mixpanel/getPageType';
import AddressVerificationModal from 'ui/addressVerification/AddressVerificationModal';
import IconSvg from 'ui/shared/IconSvg';
import AuthGuard from 'ui/snippets/auth/AuthGuard';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import ButtonItem from '../parts/ButtonItem';
import MenuItem from '../parts/MenuItem';
const TokenInfoMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps) => {
const TokenInfoMenuItem = ({ className, hash, type }: ItemProps) => {
const router = useRouter();
const modal = useDisclosure();
const isAuth = useHasAccount();
const isAuth = useIsAuth();
const verifiedAddressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: config.chain.id },
......@@ -38,14 +39,6 @@ const TokenInfoMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps)
},
});
const handleAddAddressClick = React.useCallback(() => {
if (!onBeforeClick({ pathname: '/account/verified-addresses' })) {
return;
}
modal.onOpen();
}, [ modal, onBeforeClick ]);
const handleAddApplicationClick = React.useCallback(async() => {
router.push({ pathname: '/account/verified-addresses', query: { address: hash } });
}, [ hash, router ]);
......@@ -72,18 +65,28 @@ const TokenInfoMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps)
return hasApplication || tokenInfoQuery.data?.tokenAddress ? 'Update token info' : 'Add token info';
})();
const onClick = isVerifiedAddress ? handleAddApplicationClick : handleAddAddressClick;
const onAuthSuccess = isVerifiedAddress ? handleAddApplicationClick : modal.onOpen;
switch (type) {
case 'button': {
return <ButtonItem label={ label } icon={ icon } onClick={ onClick } className={ className }/>;
return (
<AuthGuard onAuthSuccess={ onAuthSuccess }>
{ ({ onClick }) => (
<ButtonItem label={ label } icon={ icon } onClick={ onClick } className={ className }/>
) }
</AuthGuard>
);
}
case 'menu_item': {
return (
<MenuItem className={ className } onClick={ onClick }>
{ icon }
<chakra.span ml={ 2 }>{ label }</chakra.span>
</MenuItem>
<AuthGuard onAuthSuccess={ onAuthSuccess }>
{ ({ onClick }) => (
<MenuItem className={ className } onClick={ onClick }>
{ icon }
<chakra.span ml={ 2 }>{ label }</chakra.span>
</MenuItem>
) }
</AuthGuard>
);
}
}
......
export type ItemType = 'button' | 'menu_item';
import type { Route } from 'nextjs-routes';
export interface ItemProps {
className?: string;
type: ItemType;
hash: string;
onBeforeClick: (route?: Route) => boolean;
}
import type { InputProps } from '@chakra-ui/react';
import {
Input,
FormControl,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form';
import { ADDRESS_LENGTH } from 'lib/validations/address';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
size?: InputProps['size'];
placeholder?: string;
bgColor?: string;
error?: FieldError;
}
export default function AddressInput<Inputs extends FieldValues, Name extends Path<Inputs>>(
{
error,
field,
size,
placeholder = 'Address (0x...)',
bgColor,
}: Props<Inputs, Name>) {
return (
<FormControl variant="floating" id="address" isRequired size={ size } bgColor={ bgColor }>
<Input
{ ...field }
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
bgColor={ bgColor }
/>
<InputPlaceholder text={ placeholder } error={ error }/>
</FormControl>
);
}
......@@ -41,14 +41,11 @@ test('block lost consensus', async({ render }) => {
await expect(component).toHaveScreenshot();
});
test('too many requests +@mobile', async({ render, page }) => {
test('too many requests +@mobile', async({ render }) => {
const error = {
message: 'Too many requests',
cause: { status: 429 },
} as Error;
const component = await render(<AppError error={ error }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator('.recaptcha') ],
maskColor: pwConfig.maskColor,
});
await expect(component).toHaveScreenshot();
});
import { Box, Text } from '@chakra-ui/react';
import { Button, Text } from '@chakra-ui/react';
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import { GoogleReCaptcha, GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
......@@ -13,58 +13,55 @@ import AppErrorTitle from '../AppErrorTitle';
const AppErrorTooManyRequests = () => {
const toast = useToast();
const fetch = useFetch();
const [ token, setToken ] = React.useState<string | undefined>(undefined);
const handleReCaptchaChange = React.useCallback(async(token: string | null) => {
const handleReCaptchaChange = React.useCallback(async(token: string) => {
setToken(token);
}, [ ]);
if (token) {
try {
const url = buildUrl('api_v2_key');
const handleSubmit = React.useCallback(async() => {
try {
const url = buildUrl('api_v2_key');
await fetch(url, {
method: 'POST',
body: { recaptcha_response: token },
credentials: 'include',
}, {
resource: 'api_v2_key',
});
await fetch(url, {
method: 'POST',
body: { recaptcha_v3_response: token },
credentials: 'include',
}, {
resource: 'api_v2_key',
});
window.location.reload();
window.location.reload();
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: 'Unable to get client key.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
} catch (error) {
toast({
position: 'top-right',
title: 'Error',
description: 'Unable to get client key.',
status: 'error',
variant: 'subtle',
isClosable: true,
});
}
}, [ toast, fetch ]);
}, [ token, toast, fetch ]);
if (!config.services.reCaptchaV3.siteKey) {
throw new Error('reCAPTCHA V3 site key is not set');
}
return (
<Box
sx={{
'.recaptcha': {
mt: 8,
h: '78px', // otherwise content will jump after reCaptcha is loaded
},
}}
>
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<AppErrorIcon statusCode={ 429 }/>
<AppErrorTitle title="Too many requests"/>
<Text variant="secondary" mt={ 3 }>
You have exceeded the request rate for a given time period. Please reduce the number of requests and try again soon.
</Text>
{ config.services.reCaptcha.siteKey && (
<ReCaptcha
className="recaptcha"
sitekey={ config.services.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
/>
) }
</Box>
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha
/>
<Button onClick={ handleSubmit } mt={ 8 }>Try again</Button>
</GoogleReCaptchaProvider>
);
};
......
import {
Checkbox,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
text: string;
onChange?: () => void;
isDisabled?: boolean;
}
export default function CheckboxInput<Inputs extends FieldValues, Name extends Path<Inputs>>(
{
field,
text,
onChange,
isDisabled,
}: Props<Inputs, Name>) {
const handleChange: typeof field.onChange = React.useCallback((...args) => {
field.onChange(...args);
onChange?.();
}, [ field, onChange ]);
return (
<Checkbox
isChecked={ field.value }
onChange={ handleChange }
ref={ field.ref }
colorScheme="blue"
size="lg"
isDisabled={ isDisabled }
>
{ text }
</Checkbox>
);
}
export interface Option {
label: string;
value: string;
}
import {
Input,
FormControl,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path } from 'react-hook-form';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
const TAG_MAX_LENGTH = 35;
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
error?: FieldError;
bgColor?: string;
}
function TagInput<Inputs extends FieldValues, Name extends Path<Inputs>>({ field, error, bgColor }: Props<Inputs, Name>) {
return (
<FormControl variant="floating" id="tag" isRequired bgColor={ bgColor }>
<Input
{ ...field }
isInvalid={ Boolean(error) }
maxLength={ TAG_MAX_LENGTH }
bgColor={ bgColor }
/>
<InputPlaceholder text="Private tag (max 35 characters)" error={ error }/>
</FormControl>
);
}
export default TagInput;
import {
Input,
FormControl,
} from '@chakra-ui/react';
import React from 'react';
import type { ControllerRenderProps, FieldError, FieldValues } from 'react-hook-form';
import { TRANSACTION_HASH_LENGTH } from 'lib/validations/transaction';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
type Props<Field> = {
field: Field;
error?: FieldError;
bgColor?: string;
}
function TransactionInput<Field extends Partial<ControllerRenderProps<FieldValues, 'transaction'>>>({ field, error, bgColor }: Props<Field>) {
return (
<FormControl variant="floating" id="transaction" isRequired bgColor={ bgColor }>
<Input
{ ...field }
isInvalid={ Boolean(error) }
maxLength={ TRANSACTION_HASH_LENGTH }
bgColor={ bgColor }
/>
<InputPlaceholder text="Transaction hash (0x...)" error={ error }/>
</FormControl>
);
}
export default TransactionInput;
import { SkeletonCircle, Image } from '@chakra-ui/react';
import React from 'react';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
size: number;
fallbackIconSize?: number;
}
const UserAvatar = ({ size, fallbackIconSize = 20 }: Props) => {
const appProps = useAppContext();
const hasAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN, appProps.cookies));
const [ isImageLoadError, setImageLoadError ] = React.useState(false);
const { data, isFetched } = useFetchProfileInfo();
const sizeString = `${ size }px`;
const handleImageLoadError = React.useCallback(() => {
setImageLoadError(true);
}, []);
if (hasAuth && !isFetched) {
return <SkeletonCircle h={ sizeString } w={ sizeString }/>;
}
return (
<Image
flexShrink={ 0 }
src={ data?.avatar }
alt={ `Profile picture of ${ data?.name || data?.nickname || '' }` }
boxSize={ `${ size }px` }
borderRadius="full"
overflow="hidden"
fallback={ isImageLoadError || !data?.avatar ? <IconSvg name="profile" boxSize={ `${ fallbackIconSize }px` }/> : undefined }
onError={ handleImageLoadError }
/>
);
};
export default React.memo(UserAvatar);
......@@ -24,7 +24,7 @@ const init = () => {
'--w3m-font-family': `${ BODY_TYPEFACE }, sans-serif`,
'--w3m-accent': colors.blue[600],
'--w3m-border-radius-master': '2px',
'--w3m-z-index': zIndices.modal,
'--w3m-z-index': zIndices.popover,
},
featuredWalletIds: [],
allowUnsupportedChain: true,
......
import type { PinInputProps, StyleProps } from '@chakra-ui/react';
// eslint-disable-next-line no-restricted-imports
import { PinInput as PinInputBase } from '@chakra-ui/react';
import React from 'react';
const PinInput = (props: PinInputProps & { bgColor?: StyleProps['bgColor'] }) => {
return <PinInputBase { ...props }/>;
};
export default React.memo(PinInput);
import type { ColorMode } from '@chakra-ui/react';
import { Image, Skeleton, chakra, DarkMode } from '@chakra-ui/react';
import React from 'react';
interface Props {
src: string | undefined;
onLoad?: () => void;
onError?: () => void;
isInvalid: boolean;
className?: string;
fallback: React.ReactElement;
colorMode?: ColorMode;
}
const ImageUrlPreview = ({
src,
isInvalid,
onError,
onLoad,
className,
fallback: fallbackProp,
colorMode,
}: Props) => {
const skeleton = <Skeleton className={ className } w="100%" h="100%"/>;
const fallback = (() => {
if (src && !isInvalid) {
return colorMode === 'dark' ? <DarkMode>{ skeleton }</DarkMode> : skeleton;
}
return fallbackProp;
})();
return (
<Image
className={ className }
src={ src }
alt="Image preview"
w="auto"
h="100%"
fallback={ fallback }
onError={ onError }
onLoad={ onLoad }
/>
);
};
export default chakra(React.memo(ImageUrlPreview));
import type { ChakraProps } from '@chakra-ui/react';
import React from 'react';
import type { FieldValues, Path } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
import type { PartialBy } from 'types/utils';
import { addressValidator } from '../validators/address';
import FormFieldText from './FormFieldText';
const FormFieldAddress = <FormFields extends FieldValues>(
props: PartialBy<FormFieldPropsBase<FormFields>, 'placeholder'>,
) => {
const rules = React.useMemo(
() => ({
...props.rules,
validate: {
...props.rules?.validate,
address: addressValidator,
},
}),
[ props.rules ],
);
return (
<FormFieldText
{ ...props }
placeholder={ props.placeholder || 'Address (0x...)' }
rules={ rules }
/>
);
};
export type WrappedComponent = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>(props: PartialBy<FormFieldPropsBase<FormFields, Name>, 'placeholder'> & ChakraProps) => JSX.Element;
export default React.memo(FormFieldAddress) as WrappedComponent;
import type { ChakraProps } from '@chakra-ui/react';
import { chakra, Checkbox } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext, type FieldValues, type Path } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
interface Props<
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
> extends Omit<FormFieldPropsBase<FormFields, Name>, 'size' | 'bgColor' | 'placeholder'> {
label: string;
}
const FormFieldCheckbox = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>({
name,
label,
rules,
onChange,
isReadOnly,
className,
}: Props<FormFields, Name>) => {
const { control } = useFormContext<FormFields>();
const { field, formState } = useController<FormFields, typeof name>({
control,
name,
rules,
});
const isDisabled = formState.isSubmitting;
const handleChange: typeof field.onChange = React.useCallback((...args) => {
field.onChange(...args);
onChange?.();
}, [ field, onChange ]);
return (
<Checkbox
ref={ field.ref }
isChecked={ field.value }
className={ className }
onChange={ handleChange }
colorScheme="blue"
size="lg"
isDisabled={ isDisabled }
isReadOnly={ isReadOnly }
>
{ label }
</Checkbox>
);
};
const WrappedFormFieldCheckbox = chakra(FormFieldCheckbox);
export type WrappedComponent = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>(props: Props<FormFields, Name> & ChakraProps) => JSX.Element;
export default React.memo(WrappedFormFieldCheckbox) as WrappedComponent;
import type { ChakraProps } from '@chakra-ui/react';
import React from 'react';
import type { FieldValues, Path } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
import type { PartialBy } from 'types/utils';
import { EMAIL_REGEXP } from '../validators/email';
import FormFieldText from './FormFieldText';
const FormFieldEmail = <FormFields extends FieldValues>(
props: PartialBy<FormFieldPropsBase<FormFields>, 'placeholder'>,
) => {
const rules = React.useMemo(
() => ({
...props.rules,
pattern: EMAIL_REGEXP,
}),
[ props.rules ],
);
return (
<FormFieldText
{ ...props }
placeholder={ props.placeholder || 'Email' }
rules={ rules }
/>
);
};
export type WrappedComponent = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>(props: PartialBy<FormFieldPropsBase<FormFields, Name>, 'placeholder'> & ChakraProps) => JSX.Element;
export default React.memo(FormFieldEmail) as WrappedComponent;
import React from 'react';
import type { Path, FieldValues } from 'react-hook-form';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
// import type { Option } from 'ui/shared/forms/inputs/select/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import type { Props as FancySelectProps } from 'ui/shared/forms/inputs/select/FancySelect';
import FancySelect from 'ui/shared/forms/inputs/select/FancySelect';
// FIXME: Try to get this to work to add more constraints to the props type
// this type only works for plain objects, not for nested objects or arrays (e.g. ui/publicTags/submit/types.ts:FormFields)
// type SelectField<O> = { [K in keyof O]: NonNullable<O[K]> extends Option ? K : never }[keyof O];
type Props<
FormFields extends FieldValues,
Name extends Path<FormFields>,
> = Omit<FormFieldPropsBase<FormFields, Name>, 'bgColor' | 'size'> & Partial<FancySelectProps> & {
size?: 'md' | 'lg';
}
const FormFieldFancySelect = <
FormFields extends FieldValues,
Name extends Path<FormFields>,
>(props: Props<FormFields, Name>) => {
const isMobile = useIsMobile();
const defaultSize = isMobile ? 'md' : 'lg';
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, typeof props.name>({
control,
name: props.name,
rules: { ...props.rules, required: props.isRequired },
});
const isDisabled = formState.isSubmitting;
return (
<FancySelect
{ ...field }
{ ...props }
size={ props.size || defaultSize }
error={ fieldState.error }
isDisabled={ isDisabled }
/>
);
};
export default React.memo(FormFieldFancySelect) as typeof FormFieldFancySelect;
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import { GoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useFormContext } from 'react-hook-form';
import config from 'configs/app';
const FormFieldReCaptcha = () => {
interface Props {
disabledFeatureMessage?: JSX.Element;
}
const FormFieldReCaptcha = ({ disabledFeatureMessage }: Props) => {
const { register, unregister, trigger, clearErrors, setValue, resetField, setError, formState } = useFormContext();
const ref = React.useRef<ReCaptcha>(null);
const { register, unregister, clearErrors, setValue, formState } = useFormContext();
React.useEffect(() => {
register('reCaptcha', { required: true, shouldUnregister: true });
......@@ -22,35 +15,15 @@ const FormFieldReCaptcha = ({ disabledFeatureMessage }: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
ref.current?.reset();
trigger('reCaptcha');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ formState.submitCount ]);
const handleReCaptchaChange = React.useCallback((token: string | null) => {
if (token) {
clearErrors('reCaptcha');
setValue('reCaptcha', token, { shouldValidate: true });
}
const handleReCaptchaChange = React.useCallback((token: string) => {
clearErrors('reCaptcha');
setValue('reCaptcha', token, { shouldValidate: true });
}, [ clearErrors, setValue ]);
const handleReCaptchaExpire = React.useCallback(() => {
resetField('reCaptcha');
setError('reCaptcha', { type: 'required' });
}, [ resetField, setError ]);
if (!config.services.reCaptcha.siteKey) {
return disabledFeatureMessage ?? null;
}
return (
<ReCaptcha
className="recaptcha"
ref={ ref }
sitekey={ config.services.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
onExpired={ handleReCaptchaExpire }
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha={ formState.submitCount ?? -1 }
/>
);
};
......
import type { ChakraProps } from '@chakra-ui/react';
import { FormControl, Input, InputGroup, InputRightElement, Textarea, chakra, shouldForwardProp } from '@chakra-ui/react';
import React from 'react';
import type { FieldValues, Path } from 'react-hook-form';
import { useController, useFormContext } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
import FormInputPlaceholder from '../inputs/FormInputPlaceholder';
interface Props<
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
> extends FormFieldPropsBase<FormFields, Name> {
asComponent?: 'Input' | 'Textarea';
}
const FormFieldText = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>({
name,
placeholder,
isReadOnly,
isRequired,
rules,
onBlur,
type = 'text',
rightElement,
asComponent,
max,
className,
size = 'md',
bgColor,
minH,
maxH,
}: Props<FormFields, Name>) => {
const { control } = useFormContext<FormFields>();
const { field, fieldState, formState } = useController<FormFields, typeof name>({
control,
name,
rules: { ...rules, required: isRequired },
});
const isDisabled = formState.isSubmitting;
const handleBlur = React.useCallback(() => {
field.onBlur();
onBlur?.();
}, [ field, onBlur ]);
const Component = asComponent === 'Textarea' ? Textarea : Input;
const input = (
<Component
{ ...field }
onBlur={ handleBlur }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ isDisabled }
isReadOnly={ isReadOnly }
autoComplete="off"
type={ type }
placeholder=" "
max={ max }
size={ size }
bgColor={ bgColor }
minH={ minH }
maxH={ maxH }
/>
);
const inputPlaceholder = size !== 'xs' && <FormInputPlaceholder text={ placeholder } error={ fieldState.error }/>;
return (
<FormControl
className={ className }
variant="floating"
isDisabled={ isDisabled }
isRequired={ isRequired }
size={ size }
bgColor={ bgColor }
>
{ rightElement ? (
<InputGroup>
{ input }
{ inputPlaceholder }
<InputRightElement h="100%"> { rightElement({ field }) } </InputRightElement>
</InputGroup>
) : (
<>
{ input }
{ inputPlaceholder }
</>
) }
</FormControl>
);
};
const WrappedFormFieldText = chakra(FormFieldText, {
shouldForwardProp: (prop) => {
const isChakraProp = !shouldForwardProp(prop);
if (isChakraProp && ![ 'bgColor', 'size', 'minH', 'maxH' ].includes(prop)) {
return false;
}
return true;
},
});
export type WrappedComponent = <
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
>(props: Props<FormFields, Name> & ChakraProps) => JSX.Element;
export default React.memo(WrappedFormFieldText) as WrappedComponent;
import React from 'react';
import type { FieldValues } from 'react-hook-form';
import type { FormFieldPropsBase } from './types';
import { urlValidator } from '../validators/url';
import FormFieldText, { type WrappedComponent } from './FormFieldText';
const FormFieldUrl = <FormFields extends FieldValues>(
props: FormFieldPropsBase<FormFields>,
) => {
const rules = React.useMemo(
() => ({
...props.rules,
validate: {
...props.rules?.validate,
url: urlValidator,
},
}),
[ props.rules ],
);
return <FormFieldText { ...props } rules={ rules }/>;
};
export default React.memo(FormFieldUrl) as WrappedComponent;
import type { FormControlProps } from '@chakra-ui/react';
import type { ControllerRenderProps, FieldValues, Path, RegisterOptions } from 'react-hook-form';
export interface FormFieldPropsBase<
FormFields extends FieldValues,
Name extends Path<FormFields> = Path<FormFields>,
> {
name: Name;
placeholder: string;
isReadOnly?: boolean;
isRequired?: boolean;
rules?: Omit<RegisterOptions<FormFields, Name>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>;
onBlur?: () => void;
onChange?: () => void;
type?: HTMLInputElement['type'];
rightElement?: ({ field }: { field: ControllerRenderProps<FormFields, Name> }) => React.ReactNode;
max?: HTMLInputElement['max'];
// styles
size?: FormControlProps['size'];
bgColor?: FormControlProps['bgColor'];
maxH?: FormControlProps['maxH'];
minH?: FormControlProps['minH'];
className?: string;
}
......@@ -9,7 +9,7 @@ interface Props {
isFancy?: boolean;
}
const InputPlaceholder = ({ text, icon, error, isFancy }: Props) => {
const FormInputPlaceholder = ({ text, icon, error, isFancy }: Props) => {
let errorMessage = error?.message;
if (!errorMessage && error?.type === 'pattern') {
......@@ -21,13 +21,17 @@ const InputPlaceholder = ({ text, icon, error, isFancy }: Props) => {
alignItems="center"
{ ...(isFancy ? { 'data-fancy': true } : {}) }
variant="floating"
bgColor="deeppink"
>
{ icon }
<chakra.span>{ text }</chakra.span>
{ errorMessage && <chakra.span order={ 3 } whiteSpace="pre"> - { errorMessage }</chakra.span> }
{ errorMessage && (
<chakra.span order={ 3 } whiteSpace="pre">
{ ' ' }
- { errorMessage }
</chakra.span>
) }
</FormLabel>
);
};
export default React.memo(InputPlaceholder);
export default React.memo(FormInputPlaceholder);
......@@ -2,7 +2,7 @@ import { chakra, Center, useColorModeValue } from '@chakra-ui/react';
import type { DragEvent } from 'react';
import React from 'react';
import { getAllFileEntries, convertFileEntryToFile } from './utils/files';
import { getAllFileEntries, convertFileEntryToFile } from './utils';
interface Props {
children: React.ReactNode;
......
......@@ -6,8 +6,8 @@ import type { FieldError, FieldErrorsImpl, Merge } from 'react-hook-form';
import type { Option } from './types';
import { getChakraStyles } from 'ui/shared/FancySelect/utils';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormInputPlaceholder from 'ui/shared/forms/inputs/FormInputPlaceholder';
import { getChakraStyles } from 'ui/shared/forms/inputs/select/utils';
interface CommonProps {
error?: Merge<FieldError, FieldErrorsImpl<Option>> | undefined;
......@@ -24,7 +24,7 @@ interface AsyncSelectProps extends AsyncProps<Option, boolean, GroupBase<Option>
onChange: (newValue: SingleValue<Option> | MultiValue<Option>) => void;
}
type Props = RegularSelectProps | AsyncSelectProps;
export type Props = RegularSelectProps | AsyncSelectProps;
const FancySelect = (props: Props, ref: React.LegacyRef<HTMLDivElement>) => {
const menuZIndex = useToken('zIndices', 'dropdown');
......@@ -58,7 +58,7 @@ const FancySelect = (props: Props, ref: React.LegacyRef<HTMLDivElement>) => {
isInvalid={ Boolean(props.error) }
useBasicStyles
/>
<InputPlaceholder
<FormInputPlaceholder
text={ typeof props.placeholder === 'string' ? props.placeholder : '' }
icon={ props.placeholderIcon }
error={ props.error }
......
export interface Option<T extends string = string> {
label: string;
value: T;
}
import React from 'react';
import type { FieldValues, Path } from 'react-hook-form';
import { useFormContext, useWatch } from 'react-hook-form';
import { urlValidator } from '../validators/url';
interface Params<
FormFields extends FieldValues,
Name extends Path<FormFields>
> {
name: Name;
isRequired?: boolean;
}
interface ReturnType {
input: {
rules: {
required?: boolean;
validate: {
preview: () => string | true;
};
};
isRequired?: boolean;
onBlur: () => void;
};
preview: {
src: string | undefined;
isInvalid: boolean;
onLoad: () => void;
onError: () => void;
};
}
export default function useFieldWithImagePreview<
FormFields extends FieldValues,
Name extends Path<FormFields>
>({
name,
isRequired,
}: Params<FormFields, Name>): ReturnType {
const { trigger, formState, control } = useFormContext<FormFields>();
const imageLoadError = React.useRef(false);
const fieldValue = useWatch({ name, control, exact: true });
const fieldError = formState.errors[name];
const [ value, setValue ] = React.useState<string | undefined>(fieldValue);
const validator = React.useCallback(() => {
return imageLoadError.current ? 'Unable to load image' : true;
}, []);
const onLoad = React.useCallback(() => {
imageLoadError.current = false;
trigger(name);
}, [ name, trigger ]);
const onError = React.useCallback(() => {
imageLoadError.current = true;
trigger(name);
}, [ name, trigger ]);
const onBlur = React.useCallback(() => {
if (!isRequired && !fieldValue) {
imageLoadError.current = false;
trigger(name);
setValue(undefined);
return;
}
const isValidUrl = urlValidator(fieldValue);
isValidUrl === true && setValue(fieldValue);
}, [ fieldValue, isRequired, name, trigger ]);
return React.useMemo(() => {
return {
input: {
isRequired,
rules: {
required: isRequired,
validate: {
preview: validator,
},
},
onBlur,
},
preview: {
src: fieldError?.type === 'url' ? undefined : value,
isInvalid: fieldError?.type === 'preview',
onLoad,
onError,
},
};
}, [ fieldError?.type, isRequired, onBlur, onError, onLoad, validator, value ]);
}
// maybe it depends on the network??
export const ADDRESS_REGEXP = /^0x[a-fA-F\d]{40}$/;
export const ADDRESS_LENGTH = 42;
export function addressValidator(value: string | undefined) {
if (!value) {
return true;
}
return ADDRESS_REGEXP.test(value) ? true : 'Incorrect address format';
}
export function urlValidator(value: string | undefined) {
if (!value) {
return true;
}
try {
new URL(value);
return true;
} catch (error) {
return 'Incorrect URL';
}
}
export const DOMAIN_REGEXP =
/(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d][a-z\d-]{0,61}[a-z\d]/gi;
export function domainValidator(value: string | undefined) {
if (!value) {
return true;
}
const domain = (() => {
try {
const url = new URL(`https://${ value }`);
return url.hostname;
} catch (error) {
return;
}
})();
return domain === value.toLowerCase() || 'Incorrect domain';
}
import { useDisclosure } from '@chakra-ui/react';
import React from 'react';
import AuthModal from './AuthModal';
import useIsAuth from './useIsAuth';
interface InjectedProps {
onClick: () => void;
}
interface Props {
children: (props: InjectedProps) => React.ReactNode;
onAuthSuccess: () => void;
}
const AuthGuard = ({ children, onAuthSuccess }: Props) => {
const authModal = useDisclosure();
const isAuth = useIsAuth();
const handleClick = React.useCallback(() => {
isAuth ? onAuthSuccess() : authModal.onOpen();
}, [ authModal, isAuth, onAuthSuccess ]);
const handleModalClose = React.useCallback((isSuccess?: boolean) => {
if (isSuccess) {
onAuthSuccess();
}
authModal.onClose();
}, [ authModal, onAuthSuccess ]);
return (
<>
{ children({ onClick: handleClick }) }
{ authModal.isOpen && <AuthModal onClose={ handleModalClose } initialScreen={{ type: 'select_method' }}/> }
</>
);
};
export default React.memo(AuthGuard);
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test, expect } from 'playwright/lib';
import AuthModalStory from './AuthModal.pwstory';
test('email login', async({ render, page, mockApiResponse }) => {
await render(<AuthModalStory flow="email_login"/>);
await expect(page.getByText('Status: Not authenticated')).toBeVisible();
await page.getByText('Log in').click();
await expect(page).toHaveScreenshot();
// fill email
await page.getByText('Continue with email').click();
await page.getByLabel(/email/i).getByPlaceholder(' ').fill('john.doe@example.com');
await expect(page).toHaveScreenshot();
// send otp code
await mockApiResponse('auth_send_otp', {} as never);
await page.getByText('Send a code').click();
// fill otp code
await page.getByLabel(/enter your pin code/i).nth(0).fill('123456');
await expect(page).toHaveScreenshot();
// submit otp code
await mockApiResponse('auth_confirm_otp', profileMock.base as never);
await page.getByText('Submit').click();
await expect(page).toHaveScreenshot();
await page.getByLabel('Close').click();
await expect(page.getByText('Status: Authenticated')).toBeVisible();
});
const linkEmailTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
linkEmailTest('link email to account', async({ render, page, mockApiResponse }) => {
await mockApiResponse('user_info', profileMock.base);
await render(<AuthModalStory flow="email_link"/>);
await expect(page.getByText('Status: Authenticated')).toBeVisible();
// fill email
await page.getByText('Link email').click();
await page.getByLabel(/email/i).getByPlaceholder(' ').fill('john.doe@example.com');
await expect(page).toHaveScreenshot();
// send and fill otp code
await mockApiResponse('auth_send_otp', {} as never);
await page.getByText('Send a code').click();
await page.getByLabel(/enter your pin code/i).nth(0).fill('123456');
await mockApiResponse('auth_link_email', profileMock.base as never);
await page.getByText('Submit').click();
await expect(page).toHaveScreenshot();
});
import { Box, Button, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import AuthModal from './AuthModal';
import useIsAuth from './useIsAuth';
interface Props {
flow: 'email_login' | 'email_link';
}
const AuthModalStory = ({ flow }: Props) => {
const authModal = useDisclosure();
const isAuth = useIsAuth();
const initialScreen = flow === 'email_login' ? { type: 'select_method' as const } : { type: 'email' as const, isAuth: true };
const handleClose = React.useCallback(() => {
authModal.onClose();
}, [ authModal ]);
return (
<>
<Button onClick={ authModal.onOpen }>{ flow === 'email_login' ? 'Log in' : 'Link email' }</Button>
{ authModal.isOpen && <AuthModal initialScreen={ initialScreen } onClose={ handleClose }/> }
<Box>Status: { isAuth ? 'Authenticated' : 'Not authenticated' }</Box>
</>
);
};
export default AuthModalStory;
import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { Screen, ScreenSuccess } from './types';
import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import * as mixpanel from 'lib/mixpanel';
import IconSvg from 'ui/shared/IconSvg';
import AuthModalScreenConnectWallet from './screens/AuthModalScreenConnectWallet';
import AuthModalScreenEmail from './screens/AuthModalScreenEmail';
import AuthModalScreenOtpCode from './screens/AuthModalScreenOtpCode';
import AuthModalScreenSelectMethod from './screens/AuthModalScreenSelectMethod';
import AuthModalScreenSuccessEmail from './screens/AuthModalScreenSuccessEmail';
import AuthModalScreenSuccessWallet from './screens/AuthModalScreenSuccessWallet';
const feature = config.features.account;
interface Props {
initialScreen: Screen;
onClose: (isSuccess?: boolean) => void;
mixpanelConfig?: {
'wallet_connect'?: {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
};
'account_link_info': {
source: mixpanel.EventPayload<mixpanel.EventTypes.ACCOUNT_LINK_INFO>['Source'];
};
};
}
const AuthModal = ({ initialScreen, onClose, mixpanelConfig }: Props) => {
const [ steps, setSteps ] = React.useState<Array<Screen>>([ initialScreen ]);
const [ isSuccess, setIsSuccess ] = React.useState(false);
const router = useRouter();
const csrfQuery = useGetCsrfToken();
const queryClient = useQueryClient();
React.useEffect(() => {
if ('isAuth' in initialScreen && initialScreen.isAuth) {
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Status: 'Started',
Type: initialScreen.type === 'connect_wallet' ? 'Wallet' : 'Email',
Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown',
});
} else {
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'Started',
Source: mixpanel.getPageType(router.pathname),
});
}
}, [ initialScreen, mixpanelConfig, router.pathname ]);
const onNextStep = React.useCallback((screen: Screen) => {
setSteps((prev) => [ ...prev, screen ]);
}, []);
const onPrevStep = React.useCallback(() => {
setSteps((prev) => prev.length > 1 ? prev.slice(0, -1) : prev);
}, []);
const onReset = React.useCallback((isAuth?: boolean) => {
isAuth ? onClose() : setSteps([ initialScreen ]);
}, [ initialScreen, onClose ]);
const onAuthSuccess = React.useCallback(async(screen: ScreenSuccess) => {
setIsSuccess(true);
if ('isAuth' in initialScreen && initialScreen.isAuth) {
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Status: 'Finished',
Type: screen.type === 'success_wallet' ? 'Wallet' : 'Email',
Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown',
});
} else {
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'Success',
Source: screen.type === 'success_wallet' ? 'Wallet' : 'Email',
});
}
queryClient.setQueryData(getResourceKey('user_info'), () => screen.profile);
await csrfQuery.refetch();
onNextStep(screen);
}, [ initialScreen, mixpanelConfig?.account_link_info.source, onNextStep, csrfQuery, queryClient ]);
const onModalClose = React.useCallback(() => {
onClose(isSuccess);
}, [ isSuccess, onClose ]);
const header = (() => {
const currentStep = steps[steps.length - 1];
switch (currentStep.type) {
case 'select_method':
return 'Select a way to login';
case 'connect_wallet':
return currentStep.isAuth ? 'Add wallet' : 'Continue with wallet';
case 'email':
return currentStep.isAuth ? 'Add email' : 'Continue with email';
case 'otp_code':
return 'Confirmation code';
case 'success_email':
case 'success_wallet':
return 'Congrats!';
}
})();
const content = (() => {
const currentStep = steps[steps.length - 1];
switch (currentStep.type) {
case 'select_method':
return <AuthModalScreenSelectMethod onSelectMethod={ onNextStep }/>;
case 'connect_wallet':
return (
<AuthModalScreenConnectWallet
onSuccess={ onAuthSuccess }
onError={ onReset }
isAuth={ currentStep.isAuth }
source={ mixpanelConfig?.wallet_connect?.source }
/>
);
case 'email':
return (
<AuthModalScreenEmail
onSubmit={ onNextStep }
isAuth={ currentStep.isAuth }
mixpanelConfig={ mixpanelConfig }
/>
);
case 'otp_code':
return <AuthModalScreenOtpCode email={ currentStep.email } onSuccess={ onAuthSuccess } isAuth={ currentStep.isAuth }/>;
case 'success_email':
return (
<AuthModalScreenSuccessEmail
email={ currentStep.email }
onConnectWallet={ onNextStep }
isAuth={ currentStep.isAuth }
profile={ currentStep.profile }
/>
);
case 'success_wallet':
return (
<AuthModalScreenSuccessWallet
address={ currentStep.address }
onAddEmail={ onNextStep }
isAuth={ currentStep.isAuth }
profile={ currentStep.profile }
/>
);
}
})();
if (!feature.isEnabled) {
return null;
}
return (
<Modal isOpen onClose={ onModalClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent p={ 6 } maxW={{ lg: '400px' }}>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 2 } display="flex" alignItems="center" columnGap={ 2 }>
{ steps.length > 1 && !steps[steps.length - 1].type.startsWith('success') && (
<IconSvg
name="arrows/east"
boxSize={ 6 }
transform="rotate(180deg)"
color="gray.400"
flexShrink={ 0 }
onClick={ onPrevStep }
cursor="pointer"
/>
) }
{ header }
</ModalHeader>
<ModalCloseButton top={ 6 } right={ 6 } color="gray.400"/>
<ModalBody mb={ 0 }>
<GoogleReCaptchaProvider reCaptchaKey={ feature.recaptchaSiteKey }>
{ content }
</GoogleReCaptchaProvider>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default React.memo(AuthModal);
import { HStack, PinInputField, Text } from '@chakra-ui/react';
import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { OtpCodeFormFields } from '../types';
import PinInput from 'ui/shared/chakra/PinInput';
const CODE_LENGTH = 6;
interface Props {
isDisabled?: boolean;
}
const AuthModalFieldOtpCode = ({ isDisabled: isDisabledProp }: Props) => {
const { control } = useFormContext<OtpCodeFormFields>();
const { field, fieldState, formState } = useController<OtpCodeFormFields, 'code'>({
control,
name: 'code',
rules: { required: true, minLength: CODE_LENGTH, maxLength: CODE_LENGTH },
});
const isDisabled = isDisabledProp || formState.isSubmitting;
return (
<>
<HStack>
<PinInput otp placeholder="" { ...field } isDisabled={ isDisabled } isInvalid={ Boolean(fieldState.error) } bgColor="dialog_bg">
{ Array.from({ length: CODE_LENGTH }).map((_, index) => (
<PinInputField key={ index } borderRadius="base" borderWidth="2px" bgColor="dialog_bg"/>
)) }
</PinInput>
</HStack>
{ fieldState.error?.message && <Text color="error" fontSize="xs" mt={ 1 }>{ fieldState.error.message }</Text> }
</>
);
};
export default React.memo(AuthModalFieldOtpCode);
import { Center, Spinner } from '@chakra-ui/react';
import React from 'react';
import type { ScreenSuccess } from '../types';
import type { UserInfo } from 'types/api/account';
import type * as mixpanel from 'lib/mixpanel';
import useSignInWithWallet from '../useSignInWithWallet';
interface Props {
onSuccess: (screen: ScreenSuccess) => void;
onError: (isAuth?: boolean) => void;
isAuth?: boolean;
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
}
const AuthModalScreenConnectWallet = ({ onSuccess, onError, isAuth, source }: Props) => {
const isStartedRef = React.useRef(false);
const handleSignInSuccess = React.useCallback(({ address, profile }: { address: string; profile: UserInfo }) => {
onSuccess({ type: 'success_wallet', address, isAuth, profile });
}, [ onSuccess, isAuth ]);
const handleSignInError = React.useCallback(() => {
onError(isAuth);
}, [ onError, isAuth ]);
const { start } = useSignInWithWallet({ onSuccess: handleSignInSuccess, onError: handleSignInError, source, isAuth });
React.useEffect(() => {
if (!isStartedRef.current) {
isStartedRef.current = true;
start();
}
}, [ start ]);
return <Center h="100px"><Spinner/></Center>;
};
export default React.memo(AuthModalScreenConnectWallet);
import { chakra, Button, Text } from '@chakra-ui/react';
import React from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { EmailFormFields, Screen } from '../types';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import * as mixpanel from 'lib/mixpanel';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
interface Props {
onSubmit: (screen: Screen) => void;
isAuth?: boolean;
mixpanelConfig?: {
account_link_info: {
source: mixpanel.EventPayload<mixpanel.EventTypes.ACCOUNT_LINK_INFO>['Source'];
};
};
}
const AuthModalScreenEmail = ({ onSubmit, isAuth, mixpanelConfig }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const { executeRecaptcha } = useGoogleReCaptcha();
const formApi = useForm<EmailFormFields>({
mode: 'onBlur',
defaultValues: {
email: '',
},
});
const onFormSubmit: SubmitHandler<EmailFormFields> = React.useCallback(async(formData) => {
try {
const token = await executeRecaptcha?.();
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: {
email: formData.email,
recaptcha_v3_response: token,
},
},
});
if (isAuth) {
mixpanelConfig?.account_link_info.source !== 'Profile' && mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_LINK_INFO, {
Source: mixpanelConfig?.account_link_info.source ?? 'Profile dropdown',
Status: 'OTP sent',
Type: 'Email',
});
} else {
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'OTP sent',
Source: 'Email',
});
}
onSubmit({ type: 'otp_code', email: formData.email, isAuth });
} catch (error) {
toast({
status: 'error',
title: 'Error',
description: getErrorObjPayload<{ message: string }>(error)?.message || getErrorMessage(error) || 'Something went wrong',
});
}
}, [ executeRecaptcha, apiFetch, isAuth, onSubmit, mixpanelConfig?.account_link_info.source, toast ]);
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<Text>Account email, used for transaction notifications from your watchlist.</Text>
<FormFieldEmail<EmailFormFields>
name="email"
isRequired
placeholder="Email"
bgColor="dialog_bg"
mt={ 6 }
/>
<Button
mt={ 6 }
type="submit"
isDisabled={ formApi.formState.isSubmitting }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send a code"
>
Send a code
</Button>
</chakra.form>
</FormProvider>
);
};
export default React.memo(AuthModalScreenEmail);
import { chakra, Box, Text, Button } from '@chakra-ui/react';
import React from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { OtpCodeFormFields, ScreenSuccess } from '../types';
import type { UserInfo } from 'types/api/account';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import IconSvg from 'ui/shared/IconSvg';
import AuthModalFieldOtpCode from '../fields/AuthModalFieldOtpCode';
interface Props {
email: string;
onSuccess: (screen: ScreenSuccess) => void;
isAuth?: boolean;
}
const AuthModalScreenOtpCode = ({ email, onSuccess, isAuth }: Props) => {
const apiFetch = useApiFetch();
const toast = useToast();
const { executeRecaptcha } = useGoogleReCaptcha();
const [ isCodeSending, setIsCodeSending ] = React.useState(false);
const formApi = useForm<OtpCodeFormFields>({
mode: 'onBlur',
defaultValues: {
code: '',
},
});
const onFormSubmit: SubmitHandler<OtpCodeFormFields> = React.useCallback((formData) => {
const resource = isAuth ? 'auth_link_email' : 'auth_confirm_otp';
return apiFetch<typeof resource, UserInfo, unknown>(resource, {
fetchParams: {
method: 'POST',
body: {
otp: formData.code,
email,
},
},
})
.then((response) => {
if (!('name' in response)) {
throw Error('Something went wrong');
}
onSuccess({ type: 'success_email', email, isAuth, profile: response });
})
.catch((error) => {
const apiError = getErrorObjPayload<{ message: string }>(error);
if (apiError?.message) {
formApi.setError('code', { message: apiError.message });
return;
}
toast({
status: 'error',
title: 'Error',
description: getErrorMessage(error) || 'Something went wrong',
});
});
}, [ apiFetch, email, onSuccess, isAuth, toast, formApi ]);
const handleResendCodeClick = React.useCallback(async() => {
try {
formApi.clearErrors('code');
setIsCodeSending(true);
const token = await executeRecaptcha?.();
await apiFetch('auth_send_otp', {
fetchParams: {
method: 'POST',
body: { email, recaptcha_v3_response: token },
},
});
toast({
status: 'success',
title: 'Success',
description: 'Code has been sent to your email',
});
} catch (error) {
const apiError = getErrorObjPayload<{ message: string }>(error);
toast({
status: 'error',
title: 'Error',
description: apiError?.message || getErrorMessage(error) || 'Something went wrong',
});
} finally {
setIsCodeSending(false);
}
}, [ apiFetch, email, executeRecaptcha, formApi, toast ]);
return (
<FormProvider { ...formApi }>
<chakra.form
noValidate
onSubmit={ formApi.handleSubmit(onFormSubmit) }
>
<Text mb={ 6 }>
Please check{ ' ' }
<chakra.span fontWeight="700">{ email }</chakra.span>{ ' ' }
and enter your code below.
</Text>
<AuthModalFieldOtpCode isDisabled={ isCodeSending }/>
<Button
variant="link"
display="flex"
alignItems="center"
columnGap={ 2 }
mt={ 3 }
fontWeight="400"
w="fit-content"
isDisabled={ isCodeSending }
onClick={ handleResendCodeClick }
>
<IconSvg name="repeat" boxSize={ 5 }/>
<Box fontSize="sm">Resend code</Box>
</Button>
<Button
mt={ 6 }
type="submit"
isLoading={ formApi.formState.isSubmitting }
isDisabled={ formApi.formState.isSubmitting || isCodeSending }
loadingText="Submit"
onClick={ formApi.handleSubmit(onFormSubmit) }
>
Submit
</Button>
</chakra.form>
</FormProvider>
);
};
export default React.memo(AuthModalScreenOtpCode);
import { Button, VStack } from '@chakra-ui/react';
import React from 'react';
import type { Screen } from '../types';
import * as mixpanel from 'lib/mixpanel';
interface Props {
onSelectMethod: (screen: Screen) => void;
}
const AuthModalScreenSelectMethod = ({ onSelectMethod }: Props) => {
const handleEmailClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'Email',
Source: 'Options selector',
});
onSelectMethod({ type: 'email' });
}, [ onSelectMethod ]);
const handleConnectWalletClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.LOGIN, {
Action: 'Wallet',
Source: 'Options selector',
});
onSelectMethod({ type: 'connect_wallet' });
}, [ onSelectMethod ]);
return (
<VStack spacing={ 3 } mt={ 4 } align="stretch">
<Button variant="outline" onClick={ handleConnectWalletClick }>Continue with Web3 wallet</Button>
<Button variant="outline" onClick={ handleEmailClick }>Continue with email</Button>
</VStack>
);
};
export default React.memo(AuthModalScreenSelectMethod);
import { chakra, Box, Text, Button } from '@chakra-ui/react';
import React from 'react';
import type { Screen } from '../types';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
interface Props {
email: string;
onConnectWallet: (screen: Screen) => void;
isAuth?: boolean;
profile: UserInfo | undefined;
}
const AuthModalScreenSuccessEmail = ({ email, onConnectWallet, isAuth, profile }: Props) => {
const handleConnectWalletClick = React.useCallback(() => {
onConnectWallet({ type: 'connect_wallet', isAuth: true });
}, [ onConnectWallet ]);
if (isAuth) {
return (
<Text>
Your account was linked to{ ' ' }
<chakra.span fontWeight="700">{ email }</chakra.span>{ ' ' }
email. Use for the next login.
</Text>
);
}
return (
<Box>
<Text>
<chakra.span fontWeight="700">{ email }</chakra.span>{ ' ' }
email has been successfully used to log in to your Blockscout account.
</Text>
{ !profile?.address_hash && config.features.blockchainInteraction.isEnabled && (
<>
<Text mt={ 6 }>Add your web3 wallet to safely interact with smart contracts and dapps inside Blockscout.</Text>
<Button mt={ 6 } onClick={ handleConnectWalletClick }>Connect wallet</Button>
</>
) }
</Box>
);
};
export default React.memo(AuthModalScreenSuccessEmail);
import { chakra, Box, Text, Button } from '@chakra-ui/react';
import React from 'react';
import type { Screen } from '../types';
import type { UserInfo } from 'types/api/account';
import shortenString from 'lib/shortenString';
interface Props {
address: string;
onAddEmail: (screen: Screen) => void;
isAuth?: boolean;
profile: UserInfo | undefined;
}
const AuthModalScreenSuccessWallet = ({ address, onAddEmail, isAuth, profile }: Props) => {
const handleAddEmailClick = React.useCallback(() => {
onAddEmail({ type: 'email', isAuth: true });
}, [ onAddEmail ]);
if (isAuth) {
return (
<Text>
Your account was linked to{ ' ' }
<chakra.span fontWeight="700">{ shortenString(address) }</chakra.span>{ ' ' }
wallet. Use for the next login.
</Text>
);
}
return (
<Box>
<Text>
Wallet{ ' ' }
<chakra.span fontWeight="700">{ shortenString(address) }</chakra.span>{ ' ' }
has been successfully used to log in to your Blockscout account.
</Text>
{ !profile?.email && (
<>
<Text mt={ 6 }>Add your email to receive notifications about addresses in your watch list.</Text>
<Button mt={ 6 } onClick={ handleAddEmailClick }>Add email</Button>
</>
) }
</Box>
);
};
export default React.memo(AuthModalScreenSuccessWallet);
import type { UserInfo } from 'types/api/account';
export type ScreenSuccess = {
type: 'success_email';
email: string;
profile: UserInfo;
isAuth?: boolean;
} | {
type: 'success_wallet';
address: string;
profile: UserInfo;
isAuth?: boolean;
}
export type Screen = {
type: 'select_method';
} | {
type: 'connect_wallet';
isAuth?: boolean;
} | {
type: 'email';
isAuth?: boolean;
} | {
type: 'otp_code';
email: string;
isAuth?: boolean;
} | ScreenSuccess;
export interface EmailFormFields {
email: string;
}
export interface OtpCodeFormFields {
code: string;
}
......@@ -2,14 +2,17 @@ import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
export default function useHasAccount() {
import useProfileQuery from './useProfileQuery';
export default function useAuth() {
const appProps = useAppContext();
const profileQuery = useProfileQuery();
if (!config.features.account.isEnabled) {
return false;
}
const cookiesString = appProps.cookies;
const hasAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN, cookiesString));
const hasAuth = Boolean(cookies.get(cookies.NAMES.API_TOKEN, cookiesString) || profileQuery.data);
return hasAuth;
}
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { Route } from 'nextjs-routes';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import * as mixpanel from 'lib/mixpanel';
const PROTECTED_ROUTES: Array<Route['pathname']> = [
'/account/api-key',
'/account/custom-abi',
'/account/tag-address',
'/account/verified-addresses',
'/account/watchlist',
'/auth/profile',
];
export default function useLogout() {
const router = useRouter();
const queryClient = useQueryClient();
return React.useCallback(async() => {
cookies.remove(cookies.NAMES.API_TOKEN);
queryClient.resetQueries({
queryKey: getResourceKey('user_info'),
exact: true,
});
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Logged out' }, { send_immediately: true });
if (
PROTECTED_ROUTES.includes(router.pathname) ||
(router.pathname === '/txs' && router.query.tab === 'watchlist')
) {
router.push({ pathname: '/' }, undefined, { shallow: true });
}
}, [ queryClient, router ]);
}
import useApiQuery from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
export default function useFetchProfileInfo() {
export default function useProfileQuery() {
return useApiQuery('user_info', {
queryOptions: {
refetchOnMount: false,
......
import * as Sentry from '@sentry/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import { resourceKey } from 'lib/api/resources';
import type { ResourceError } from 'lib/api/resources';
import * as cookies from 'lib/cookies';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
export default function useRedirectForInvalidAuthToken() {
const queryClient = useQueryClient();
const state = queryClient.getQueryState<unknown, ResourceError>([ resourceKey('user_info') ]);
const errorStatus = state?.error?.status;
const loginUrl = useLoginUrl();
const profileQuery = useProfileQuery();
const errorStatus = profileQuery.error?.status;
React.useEffect(() => {
if (errorStatus === 401) {
const apiToken = cookies.get(cookies.NAMES.API_TOKEN);
if (apiToken && loginUrl) {
if (apiToken) {
Sentry.captureException(new Error('Invalid API token'), { tags: { source: 'invalid_api_token' } });
window.location.assign(loginUrl);
cookies.remove(cookies.NAMES.API_TOKEN);
window.location.assign('/');
}
}
}, [ errorStatus, loginUrl ]);
}, [ errorStatus ]);
}
import React from 'react';
import { useSignMessage } from 'wagmi';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/errors/getErrorMessage';
import getErrorObj from 'lib/errors/getErrorObj';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import useToast from 'lib/hooks/useToast';
import type * as mixpanel from 'lib/mixpanel';
import useWeb3Wallet from 'lib/web3/useWallet';
interface Props {
onSuccess?: ({ address, profile }: { address: string; profile: UserInfo }) => void;
onError?: () => void;
source?: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
isAuth?: boolean;
}
function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth }: Props) {
const [ isPending, setIsPending ] = React.useState(false);
const isConnectingWalletRef = React.useRef(false);
const apiFetch = useApiFetch();
const toast = useToast();
const web3Wallet = useWeb3Wallet({ source });
const { signMessageAsync } = useSignMessage();
const proceedToAuth = React.useCallback(async(address: string) => {
try {
const siweMessage = await apiFetch('auth_siwe_message', { queryParams: { address } }) as { siwe_message: string };
const signature = await signMessageAsync({ message: siweMessage.siwe_message });
const resource = isAuth ? 'auth_link_address' : 'auth_siwe_verify';
const response = await apiFetch<typeof resource, UserInfo, unknown>(resource, {
fetchParams: {
method: 'POST',
body: { message: siweMessage.siwe_message, signature },
},
});
if (!('name' in response)) {
throw Error('Something went wrong');
}
onSuccess?.({ address, profile: response });
} catch (error) {
const errorObj = getErrorObj(error);
const apiErrorMessage = getErrorObjPayload<{ message: string }>(error)?.message;
const shortMessage = errorObj && 'shortMessage' in errorObj && typeof errorObj.shortMessage === 'string' ? errorObj.shortMessage : undefined;
onError?.();
toast({
status: 'error',
title: 'Error',
description: apiErrorMessage || shortMessage || getErrorMessage(error) || 'Something went wrong',
});
} finally {
setIsPending(false);
}
}, [ apiFetch, isAuth, onError, onSuccess, signMessageAsync, toast ]);
const start = React.useCallback(() => {
setIsPending(true);
if (web3Wallet.address) {
proceedToAuth(web3Wallet.address);
} else {
isConnectingWalletRef.current = true;
web3Wallet.openModal();
}
}, [ proceedToAuth, web3Wallet ]);
React.useEffect(() => {
if (web3Wallet.address && isConnectingWalletRef.current) {
isConnectingWalletRef.current = false;
proceedToAuth(web3Wallet.address);
}
}, [ proceedToAuth, web3Wallet.address ]);
return React.useMemo(() => ({ start, isPending }), [ start, isPending ]);
}
function useSignInWithWalletFallback() {
return React.useMemo(() => ({ start: () => {}, isPending: false }), [ ]);
}
export default config.features.blockchainInteraction.isEnabled ? useSignInWithWallet : useSignInWithWalletFallback;
......@@ -3,9 +3,9 @@ import React from 'react';
import config from 'configs/app';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import SearchBar from 'ui/snippets/searchBar/SearchBar';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import Burger from './Burger';
......@@ -37,9 +37,11 @@ const HeaderDesktop = ({ renderSearchBar, isMarketplaceAppPage }: Props) => {
{ searchBar }
</Box>
{ config.UI.navigation.layout === 'vertical' && (
<Box display="flex">
{ config.features.account.isEnabled && <ProfileMenuDesktop/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop/> }
<Box display="flex" flexShrink={ 0 }>
{
(config.features.account.isEnabled && <UserProfileDesktop/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop/>)
}
</Box>
) }
</HStack>
......
......@@ -5,9 +5,9 @@ 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 WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile';
import UserProfileMobile from 'ui/snippets/user/profile/UserProfileMobile';
import UserWalletMobile from 'ui/snippets/user/wallet/UserWalletMobile';
import Burger from './Burger';
......@@ -48,8 +48,11 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => {
<Burger/>
<NetworkLogo ml={ 2 } mr="auto"/>
<Flex columnGap={ 2 }>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuMobile/> }
{
(config.features.account.isEnabled && <UserProfileMobile/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletMobile/>) ||
<Box boxSize={ 10 }/>
}
</Flex>
</Flex>
{ !hideSearchBar && searchBar }
......
......@@ -12,7 +12,7 @@ const testWithAuth = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
testWithAuth('base view +@dark-mode', async({ render, mockApiResponse, mockAssetResponse, mockEnvs, page }) => {
testWithAuth('base view +@dark-mode', async({ render, mockApiResponse, mockEnvs, page }) => {
const hooksConfig = {
router: {
route: '/blocks',
......@@ -21,7 +21,6 @@ testWithAuth('base view +@dark-mode', async({ render, mockApiResponse, mockAsset
};
await mockApiResponse('user_info', profileMock.base);
await mockAssetResponse(profileMock.base.avatar, './playwright/mocks/image_s.jpg');
await mockEnvs([
...ENVS_MAP.userOps,
...ENVS_MAP.nameService,
......
......@@ -5,8 +5,8 @@ import config from 'configs/app';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/snippets/profileMenu/ProfileMenuDesktop';
import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import UserProfileDesktop from 'ui/snippets/user/profile/UserProfileDesktop';
import UserWalletDesktop from 'ui/snippets/user/wallet/UserWalletDesktop';
import TestnetBadge from '../TestnetBadge';
import NavLink from './NavLink';
......@@ -38,8 +38,10 @@ const NavigationDesktop = () => {
}) }
</Flex>
</chakra.nav>
{ config.features.account.isEnabled && <ProfileMenuDesktop buttonBoxSize="32px"/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuDesktop size="sm"/> }
{
(config.features.account.isEnabled && <UserProfileDesktop buttonSize="sm"/>) ||
(config.features.blockchainInteraction.isEnabled && <UserWalletDesktop buttonSize="sm"/>)
}
</Flex>
</Box>
);
......
......@@ -2,9 +2,9 @@ import { Box, Flex, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import { animate, motion, useMotionValue } from 'framer-motion';
import React, { useCallback } from 'react';
import useHasAccount from 'lib/hooks/useHasAccount';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
import IconSvg from 'ui/shared/IconSvg';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import NavLink from '../vertical/NavLink';
import NavLinkGroup from './NavLinkGroup';
......@@ -35,7 +35,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
animate(subX, DRAWER_WIDTH, { ease: 'easeInOut', onComplete: () => setOpenedGroupIndex(-1) });
}, [ mainX, subX ]);
const hasAccount = useHasAccount();
const isAuth = useIsAuth();
const iconColor = useColorModeValue('blue.600', 'blue.300');
......@@ -73,7 +73,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
}) }
</VStack>
</Box>
{ hasAccount && (
{ isAuth && (
<Box
as="nav"
mt={ 3 }
......
......@@ -4,10 +4,10 @@ import React from 'react';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import * as cookies from 'lib/cookies';
import useHasAccount from 'lib/hooks/useHasAccount';
import useNavItems, { isGroupItem } from 'lib/hooks/useNavItems';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import IconSvg from 'ui/shared/IconSvg';
import useIsAuth from 'ui/snippets/auth/useIsAuth';
import NetworkLogo from 'ui/snippets/networkMenu/NetworkLogo';
import NetworkMenu from 'ui/snippets/networkMenu/NetworkMenu';
......@@ -30,7 +30,7 @@ const NavigationDesktop = () => {
const { mainNavItems, accountNavItems } = useNavItems();
const hasAccount = useHasAccount();
const isAuth = useIsAuth();
const [ isCollapsed, setCollapsedState ] = React.useState<boolean | undefined>(isNavBarCollapsed);
......@@ -97,7 +97,7 @@ const NavigationDesktop = () => {
}) }
</VStack>
</Box>
{ hasAccount && (
{ isAuth && (
<Box as="nav" borderTopWidth="1px" borderColor="divider" w="100%" mt={ 3 } pt={ 3 }>
<VStack as="ul" spacing="1" alignItems="flex-start">
{ accountNavItems.map((item) => <NavLink key={ item.text } item={ item } isCollapsed={ isCollapsed }/>) }
......
import { Box, Button, VStack, chakra } from '@chakra-ui/react';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import config from 'configs/app';
import useNavItems from 'lib/hooks/useNavItems';
import * as mixpanel from 'lib/mixpanel/index';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import NavLink from 'ui/snippets/navigation/vertical/NavLink';
const feature = config.features.account;
type Props = {
data?: UserInfo;
onNavLinkClick?: () => void;
};
const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => {
const { accountNavItems, profileItem } = useNavItems();
const handleSingOutClick = React.useCallback(() => {
mixpanel.logEvent(
mixpanel.EventTypes.ACCOUNT_ACCESS,
{ Action: 'Logged out' },
{ send_immediately: true },
);
}, []);
if (!feature.isEnabled) {
return null;
}
const userName = data?.email || data?.nickname || data?.name;
return (
<Box>
{ userName && (
<Box
fontSize="sm"
fontWeight={ 500 }
mb={ 1 }
>
<span>Signed in as </span>
<chakra.span color="text_secondary">{ userName }</chakra.span>
</Box>
) }
<NavLink item={ profileItem } disableActiveState={ true } px="0px" isCollapsed={ false } onClick={ onNavLinkClick }/>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden">
{ accountNavItems.map((item) => (
<NavLink
key={ item.text }
item={ item }
disableActiveState={ true }
isCollapsed={ false }
px="0px"
onClick={ onNavLinkClick }
/>
)) }
</VStack>
</Box>
<Box mt={ 2 } pt={ 3 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<Button size="sm" width="full" variant="outline" as="a" href={ feature.logoutUrl } onClick={ handleSingOutClick }>
Sign Out
</Button>
</Box>
</Box>
);
};
export default ProfileMenuContent;
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import config from 'configs/app';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test, expect } from 'playwright/lib';
import ProfileMenuDesktop from './ProfileMenuDesktop';
test('no auth', async({ render, page }) => {
const hooksConfig = {
router: {
asPath: '/',
pathname: '/',
},
};
const component = await render(<ProfileMenuDesktop/>, { hooksConfig });
await component.locator('a').click();
expect(page.url()).toBe(`${ config.app.baseUrl }/auth/auth0?path=%2F`);
});
const authTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
authTest('auth +@dark-mode', async({ render, page, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('user_info', profileMock.base);
await mockAssetResponse(profileMock.base.avatar, './playwright/mocks/image_s.jpg');
const component = await render(<ProfileMenuDesktop/>);
await component.getByAltText(/Profile picture/i).click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 600 } });
});
import type { IconButtonProps } from '@chakra-ui/react';
import { PopoverContent, PopoverBody, PopoverTrigger, IconButton, Tooltip, Box, chakra } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import * as mixpanel from 'lib/mixpanel/index';
import Popover from 'ui/shared/chakra/Popover';
import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent';
type Props = {
isHomePage?: boolean;
className?: string;
fallbackIconSize?: number;
buttonBoxSize?: string;
};
const ProfileMenuDesktop = ({ isHomePage, className, fallbackIconSize, buttonBoxSize }: Props) => {
const { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl();
const [ hasMenu, setHasMenu ] = React.useState(false);
React.useEffect(() => {
if (!isPending) {
setHasMenu(Boolean(data));
}
}, [ data, error?.status, isPending ]);
const handleSignInClick = React.useCallback(() => {
mixpanel.logEvent(
mixpanel.EventTypes.ACCOUNT_ACCESS,
{ Action: 'Auth0 init' },
{ send_immediately: true },
);
}, []);
const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) {
return {};
}
return {
as: 'a',
href: loginUrl,
onClick: handleSignInClick,
};
})();
return (
<Popover openDelay={ 300 } placement="bottom-end" gutter={ 10 } isLazy>
<Tooltip
label={ <span>Sign in to My Account to add tags,<br/>create watchlists, access API keys and more</span> }
textAlign="center"
padding={ 2 }
isDisabled={ hasMenu }
openDelay={ 500 }
>
<Box>
<PopoverTrigger>
<IconButton
className={ className }
aria-label="profile menu"
icon={ <UserAvatar size={ 20 } fallbackIconSize={ fallbackIconSize }/> }
variant={ isHomePage ? 'hero' : 'header' }
data-selected={ hasMenu }
boxSize={ buttonBoxSize ?? '40px' }
flexShrink={ 0 }
{ ...iconButtonProps }
/>
</PopoverTrigger>
</Box>
</Tooltip>
{ hasMenu && (
<PopoverContent maxW="400px" minW="220px" w="min-content">
<PopoverBody padding="24px 16px 16px 16px">
<ProfileMenuContent data={ data }/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
);
};
export default chakra(ProfileMenuDesktop);
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import config from 'configs/app';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test, expect, devices } from 'playwright/lib';
import ProfileMenuMobile from './ProfileMenuMobile';
test('no auth', async({ render, page }) => {
const hooksConfig = {
router: {
asPath: '/',
pathname: '/',
},
};
const component = await render(<ProfileMenuMobile/>, { hooksConfig });
await component.locator('a').click();
expect(page.url()).toBe(`${ config.app.baseUrl }/auth/auth0?path=%2F`);
});
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
const authTest = test.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
authTest.describe('auth', () => {
authTest('base view', async({ render, page, mockApiResponse, mockAssetResponse }) => {
await mockApiResponse('user_info', profileMock.base);
await mockAssetResponse(profileMock.base.avatar, './playwright/mocks/image_s.jpg');
const component = await render(<ProfileMenuMobile/>);
await component.getByAltText(/Profile picture/i).click();
await expect(page).toHaveScreenshot();
});
});
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconButton } from '@chakra-ui/react';
import type { IconButtonProps } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useLoginUrl from 'lib/hooks/useLoginUrl';
import * as mixpanel from 'lib/mixpanel/index';
import UserAvatar from 'ui/shared/UserAvatar';
import ProfileMenuContent from 'ui/snippets/profileMenu/ProfileMenuContent';
const ProfileMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { data, error, isPending } = useFetchProfileInfo();
const loginUrl = useLoginUrl();
const [ hasMenu, setHasMenu ] = React.useState(false);
const handleSignInClick = React.useCallback(() => {
mixpanel.logEvent(
mixpanel.EventTypes.ACCOUNT_ACCESS,
{ Action: 'Auth0 init' },
{ send_immediately: true },
);
}, []);
React.useEffect(() => {
if (!isPending) {
setHasMenu(Boolean(data));
}
}, [ data, error?.status, isPending ]);
const iconButtonProps: Partial<IconButtonProps> = (() => {
if (hasMenu || !loginUrl) {
return {};
}
return {
as: 'a',
href: loginUrl,
onClick: handleSignInClick,
};
})();
return (
<>
<IconButton
aria-label="profile menu"
icon={ <UserAvatar size={ 20 }/> }
variant="header"
data-selected={ hasMenu }
boxSize="40px"
flexShrink={ 0 }
onClick={ hasMenu ? onOpen : undefined }
{ ...iconButtonProps }
/>
{ hasMenu && (
<Drawer
isOpen={ isOpen }
placement="right"
onClose={ onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="300px">
<DrawerBody p={ 6 }>
<ProfileMenuContent data={ data } onNavLinkClick={ onClose }/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
</>
);
};
export default ProfileMenuMobile;
......@@ -5,9 +5,9 @@ import type { SearchResultAddressOrContract } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import highlightText from 'lib/highlightText';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { ADDRESS_REGEXP } from 'ui/shared/forms/validators/address';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
......
Component in `/profile` directory are used when Account and WalletConnect features are enabled.
Component in `/wallet` directory are used when only WalletConnect features is enabled.
\ No newline at end of file
import { Box, Flex, chakra, useColorModeValue } from '@chakra-ui/react';
import { Box, Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
address: string;
isAutoConnectDisabled?: boolean;
className?: string;
};
const WalletIdenticon = ({ address, isAutoConnectDisabled, className }: Props) => {
const isMobile = useIsMobile();
const UserIdenticon = ({ address, isAutoConnectDisabled }: Props) => {
const borderColor = useColorModeValue('orange.100', 'orange.900');
return (
<Box className={ className } position="relative">
<Box position="relative">
<AddressIdenticon size={ 20 } hash={ address }/>
{ isAutoConnectDisabled && (
<Flex
alignItems="center"
justifyContent="center"
<Center
boxSize="14px"
position="absolute"
bottom={ isMobile ? '-3px' : '-1px' }
right={ isMobile ? '-4px' : '-5px' }
bottom="-1px"
right="-3px"
backgroundColor="rgba(16, 17, 18, 0.80)"
borderRadius="full"
border="1px solid"
......@@ -34,12 +29,12 @@ const WalletIdenticon = ({ address, isAutoConnectDisabled, className }: Props) =
<IconSvg
name="integration/partial"
color="white"
boxSize="8px"
boxSize={ 2 }
/>
</Flex>
</Center>
) }
</Box>
);
};
export default chakra(WalletIdenticon);
export default React.memo(UserIdenticon);
import { Text, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
const UserWalletAutoConnectAlert = () => {
const bgColor = useColorModeValue('orange.100', 'orange.900');
return (
<Flex
borderRadius="base"
p={ 3 }
mb={ 3 }
alignItems="center"
bgColor={ bgColor }
>
<IconSvg
name="integration/partial"
color="text"
boxSize={ 5 }
flexShrink={ 0 }
mr={ 2 }
/>
<Text fontSize="xs" lineHeight="16px">
Connect your wallet in the app below
</Text>
</Flex>
);
};
export default React.memo(UserWalletAutoConnectAlert);
import type { ButtonProps } from '@chakra-ui/react';
import { Button, Skeleton, Tooltip, Box, HStack } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import shortenString from 'lib/shortenString';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import IconSvg from 'ui/shared/IconSvg';
import UserIdenticon from '../UserIdenticon';
import { getUserHandle } from './utils';
interface Props {
profileQuery: UseQueryResult<UserInfo, unknown>;
size?: ButtonProps['size'];
variant?: ButtonProps['variant'];
onClick: () => void;
isPending?: boolean;
}
const UserProfileButton = ({ profileQuery, size, variant, onClick, isPending }: Props, ref: React.ForwardedRef<HTMLDivElement>) => {
const [ isFetched, setIsFetched ] = React.useState(false);
const isMobile = useIsMobile();
const { data, isLoading } = profileQuery;
const web3AccountWithDomain = useWeb3AccountWithDomain(true);
const { isAutoConnectDisabled } = useMarketplaceContext();
React.useEffect(() => {
if (!isLoading) {
setIsFetched(true);
}
}, [ isLoading ]);
const content = (() => {
if (web3AccountWithDomain.address) {
return (
<HStack gap={ 2 }>
<UserIdenticon address={ web3AccountWithDomain.address } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<Box display={{ base: 'none', md: 'block' }}>
{ web3AccountWithDomain.domain || shortenString(web3AccountWithDomain.address) }
</Box>
</HStack>
);
}
if (!data) {
return 'Log in';
}
return (
<HStack gap={ 2 }>
<IconSvg name="profile" boxSize={ 5 }/>
<Box display={{ base: 'none', md: 'block' }}>{ data.email ? getUserHandle(data.email) : 'My profile' }</Box>
</HStack>
);
})();
return (
<Tooltip
label={ <span>Sign in to My Account to add tags,<br/>create watchlists, access API keys and more</span> }
textAlign="center"
padding={ 2 }
isDisabled={ isMobile || isFetched || Boolean(data) }
openDelay={ 500 }
>
<Skeleton isLoaded={ isFetched } borderRadius="base" ref={ ref } w="fit-content">
<Button
size={ size }
variant={ variant }
onClick={ onClick }
data-selected={ Boolean(data) || Boolean(web3AccountWithDomain.address) }
data-warning={ isAutoConnectDisabled }
fontSize="sm"
lineHeight={ 5 }
px={ data || web3AccountWithDomain.address ? 2.5 : 4 }
fontWeight={ data || web3AccountWithDomain.address ? 700 : 600 }
isLoading={ isPending }
loadingText={ isMobile ? undefined : 'Connecting' }
>
{ content }
</Button>
</Skeleton>
</Tooltip>
);
};
export default React.memo(React.forwardRef(UserProfileButton));
import { Box, Button, Divider, Flex, Link, VStack, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { NavLink } from './types';
import type { UserInfo } from 'types/api/account';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import shortenString from 'lib/shortenString';
import Hint from 'ui/shared/Hint';
import TruncatedValue from 'ui/shared/TruncatedValue';
import useLogout from 'ui/snippets/auth/useLogout';
import UserWalletAutoConnectAlert from '../UserWalletAutoConnectAlert';
import UserProfileContentNavLink from './UserProfileContentNavLink';
import UserProfileContentWallet from './UserProfileContentWallet';
const navLinks: Array<NavLink> = [
{
text: 'My profile',
href: route({ pathname: '/auth/profile' }),
icon: 'profile' as const,
},
{
text: 'Watch list',
href: route({ pathname: '/account/watchlist' }),
icon: 'star_outline' as const,
},
{
text: 'Private tags',
href: route({ pathname: '/account/tag-address' }),
icon: 'private_tags_slim' as const,
},
{
text: 'API keys',
href: route({ pathname: '/account/api-key' }),
icon: 'API_slim' as const,
},
{
text: 'Custom ABI',
href: route({ pathname: '/account/custom-abi' }),
icon: 'ABI_slim' as const,
},
config.features.addressVerification.isEnabled && {
text: 'Verified addrs',
href: route({ pathname: '/account/verified-addresses' }),
icon: 'verified_slim' as const,
},
].filter(Boolean);
interface Props {
data: UserInfo | undefined;
onClose: () => void;
onLogin: () => void;
onAddEmail: () => void;
onAddAddress: () => void;
}
const UserProfileContent = ({ data, onClose, onLogin, onAddEmail, onAddAddress }: Props) => {
const { isAutoConnectDisabled } = useMarketplaceContext();
const logout = useLogout();
const accountTextColor = useColorModeValue('gray.500', 'gray.300');
if (!data) {
return (
<Box>
{ isAutoConnectDisabled && <UserWalletAutoConnectAlert/> }
{ config.features.blockchainInteraction.isEnabled && <UserProfileContentWallet onClose={ onClose }/> }
<Button mt={ 3 } onClick={ onLogin } size="sm" w="100%">Log in</Button>
</Box>
);
}
return (
<Box>
{ isAutoConnectDisabled && <UserWalletAutoConnectAlert/> }
<Box fontSize="xs" lineHeight={ 6 } fontWeight="500" px={ 1 } mb="2px">Account</Box>
<Box
fontSize="xs"
lineHeight={ 4 }
fontWeight="500"
borderColor="divider"
borderWidth="1px"
borderRadius="base"
color={ accountTextColor }
>
{ config.features.blockchainInteraction.isEnabled && (
<Flex p={ 2 } borderColor="divider" borderBottomWidth="1px">
<Box>Address</Box>
<Hint
label="This wallet address is linked to your Blockscout account. It can be used to login and is used for merit program participation"
boxSize={ 4 }
ml={ 1 }
mr="auto"
/>
{ data?.address_hash ?
<Box>{ shortenString(data?.address_hash) }</Box> :
<Link onClick={ onAddAddress } color="icon_info" _hover={{ color: 'link_hovered', textDecoration: 'none' }}>Add address</Link>
}
</Flex>
) }
<Flex p={ 2 } columnGap={ 4 }>
<Box mr="auto">Email</Box>
{ data?.email ?
<TruncatedValue value={ data.email }/> :
<Link onClick={ onAddEmail } color="icon_info" _hover={{ color: 'link_hovered', textDecoration: 'none' }}>Add email</Link>
}
</Flex>
</Box>
{ config.features.blockchainInteraction.isEnabled && <UserProfileContentWallet onClose={ onClose } mt={ 3 }/> }
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden" mt={ 4 }>
{ navLinks.map((item) => (
<UserProfileContentNavLink
key={ item.text }
{ ...item }
onClick={ onClose }
/>
)) }
</VStack>
<Divider my={ 1 }/>
<UserProfileContentNavLink
text="Sign out"
icon="sign_out"
onClick={ logout }
/>
</Box>
);
};
export default React.memo(UserProfileContent);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { NavLink } from './types';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
const UserProfileContentNavLink = ({ href, icon, text, onClick }: NavLink) => {
return (
<LinkInternal
href={ href }
display="flex"
alignItems="center"
columnGap={ 3 }
py="14px"
color="inherit"
_hover={{ textDecoration: 'none', color: 'link_hovered' }}
onClick={ onClick }
>
<IconSvg name={ icon } boxSize={ 5 } flexShrink={ 0 }/>
<Box fontSize="14px" fontWeight="500" lineHeight={ 5 }>{ text }</Box>
</LinkInternal>
);
};
export default React.memo(UserProfileContentNavLink);
import { chakra, Box, Button, Flex, IconButton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import delay from 'lib/delay';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
onClose?: () => void;
className?: string;
}
const UserProfileContentWallet = ({ onClose, className }: Props) => {
const walletSnippetBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const web3Wallet = useWeb3Wallet({ source: 'Profile dropdown' });
const web3AccountWithDomain = useWeb3AccountWithDomain(true);
const handleConnectWalletClick = React.useCallback(async() => {
web3Wallet.openModal();
await delay(300);
onClose?.();
}, [ web3Wallet, onClose ]);
const handleOpenWalletClick = React.useCallback(async() => {
web3Wallet.openModal();
await delay(300);
onClose?.();
}, [ web3Wallet, onClose ]);
const content = (() => {
if (web3Wallet.isConnected && web3AccountWithDomain.address) {
return (
<Flex alignItems="center" columnGap={ 2 } bgColor={ walletSnippetBgColor } px={ 2 } py="10px" borderRadius="base">
<AddressEntity
address={{ hash: web3AccountWithDomain.address, ens_domain_name: web3AccountWithDomain.domain }}
isLoading={ web3AccountWithDomain.isLoading }
isTooltipDisabled
truncation="dynamic"
fontSize="sm"
fontWeight={ 700 }
/>
<IconButton
aria-label="Open wallet"
icon={ <IconSvg name="gear_slim" boxSize={ 5 }/> }
variant="simple"
color="icon_info"
boxSize={ 5 }
onClick={ handleOpenWalletClick }
isLoading={ web3Wallet.isOpen }
flexShrink={ 0 }
ml="auto"
/>
</Flex>
);
}
return (
<Button
size="sm"
onClick={ handleConnectWalletClick }
isLoading={ web3Wallet.isOpen }
loadingText="Connect Wallet"
w="100%"
>
Connect
</Button>
);
})();
return (
<Box className={ className }>
<Flex px={ 1 } mb="2px" fontSize="xs" alignItems="center" lineHeight={ 6 } fontWeight="500">
<span>Connected wallet</span>
<Hint
label={
web3Wallet.isConnected ?
'This wallet is currently connected to Blockscout and used for interacting with apps and smart contracts' :
'This wallet is used for interacting with apps and smart contracts'
}
boxSize={ 4 }
ml={ 1 }
/>
</Flex>
{ content }
</Box>
);
};
export default React.memo(chakra(UserProfileContentWallet));
import type { BrowserContext } from '@playwright/test';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import { contextWithAuth } from 'playwright/fixtures/auth';
import { test as base, expect } from 'playwright/lib';
import UserProfileDesktop from './UserProfileDesktop';
const test = base.extend<{ context: BrowserContext }>({
context: contextWithAuth,
});
test('without address', async({ render, page, mockApiResponse }) => {
await mockApiResponse('user_info', profileMock.base);
await render(<UserProfileDesktop/>, undefined, { marketplaceContext: { isAutoConnectDisabled: true, setIsAutoConnectDisabled: () => {} } });
await page.getByText(/tom/i).click();
await expect(page).toHaveScreenshot({
clip: { x: 0, y: 0, width: 300, height: 700 },
});
});
test('without email', async({ render, page, mockApiResponse }) => {
await mockApiResponse('user_info', profileMock.withoutEmail);
await render(<UserProfileDesktop/>);
await page.getByText(/my profile/i).click();
await expect(page).toHaveScreenshot({
clip: { x: 0, y: 0, width: 300, height: 600 },
});
});
import { PopoverBody, PopoverContent, PopoverTrigger, useDisclosure, type ButtonProps } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { Screen } from 'ui/snippets/auth/types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel';
import useAccount from 'lib/web3/useAccount';
import Popover from 'ui/shared/chakra/Popover';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import UserProfileButton from './UserProfileButton';
import UserProfileContent from './UserProfileContent';
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const initialScreen = {
type: config.features.blockchainInteraction.isEnabled ? 'select_method' as const : 'email' as const,
};
const UserProfileDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const [ authInitialScreen, setAuthInitialScreen ] = React.useState<Screen>(initialScreen);
const router = useRouter();
const authModal = useDisclosure();
const profileMenu = useDisclosure();
const profileQuery = useProfileQuery();
const { address: web3Address } = useAccount();
const handleProfileButtonClick = React.useCallback(() => {
if (profileQuery.data || web3Address) {
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Dropdown open' });
profileMenu.onOpen();
return;
}
if (router.pathname === '/apps/[id]' && config.features.blockchainInteraction.isEnabled) {
setAuthInitialScreen({ type: 'connect_wallet' });
}
authModal.onOpen();
}, [ profileQuery.data, router.pathname, authModal, profileMenu, web3Address ]);
const handleAddEmailClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'email', isAuth: true });
authModal.onOpen();
}, [ authModal ]);
const handleAddAddressClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
authModal.onOpen();
}, [ authModal ]);
const handleAuthModalClose = React.useCallback(() => {
setAuthInitialScreen(initialScreen);
authModal.onClose();
}, [ authModal ]);
return (
<>
<Popover openDelay={ 300 } placement="bottom-end" isLazy isOpen={ profileMenu.isOpen } onClose={ profileMenu.onClose }>
<PopoverTrigger>
<UserProfileButton
profileQuery={ profileQuery }
size={ buttonSize }
variant={ buttonVariant }
onClick={ handleProfileButtonClick }
/>
</PopoverTrigger>
{ (profileQuery.data || web3Address) && (
<PopoverContent w="280px">
<PopoverBody>
<UserProfileContent
data={ profileQuery.data }
onClose={ profileMenu.onClose }
onLogin={ authModal.onOpen }
onAddEmail={ handleAddEmailClick }
onAddAddress={ handleAddAddressClick }
/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
{ authModal.isOpen && (
<AuthModal
onClose={ handleAuthModalClose }
initialScreen={ authInitialScreen }
/>
) }
</>
);
};
export default React.memo(UserProfileDesktop);
import { Drawer, DrawerBody, DrawerContent, DrawerOverlay, useDisclosure } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { Screen } from 'ui/snippets/auth/types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel';
import useAccount from 'lib/web3/useAccount';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import UserProfileButton from './UserProfileButton';
import UserProfileContent from './UserProfileContent';
const initialScreen = {
type: config.features.blockchainInteraction.isEnabled ? 'select_method' as const : 'email' as const,
};
const UserProfileMobile = () => {
const [ authInitialScreen, setAuthInitialScreen ] = React.useState<Screen>(initialScreen);
const router = useRouter();
const authModal = useDisclosure();
const profileMenu = useDisclosure();
const profileQuery = useProfileQuery();
const { address: web3Address } = useAccount();
const handleProfileButtonClick = React.useCallback(() => {
if (profileQuery.data || web3Address) {
mixpanel.logEvent(mixpanel.EventTypes.ACCOUNT_ACCESS, { Action: 'Dropdown open' });
profileMenu.onOpen();
return;
}
if (router.pathname === '/apps/[id]' && config.features.blockchainInteraction.isEnabled) {
setAuthInitialScreen({ type: 'connect_wallet' });
}
authModal.onOpen();
}, [ profileQuery.data, web3Address, router.pathname, authModal, profileMenu ]);
const handleAddEmailClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'email', isAuth: true });
authModal.onOpen();
}, [ authModal ]);
const handleAddAddressClick = React.useCallback(() => {
setAuthInitialScreen({ type: 'connect_wallet', isAuth: true });
authModal.onOpen();
}, [ authModal ]);
const handleAuthModalClose = React.useCallback(() => {
setAuthInitialScreen(initialScreen);
authModal.onClose();
}, [ authModal ]);
return (
<>
<UserProfileButton
profileQuery={ profileQuery }
variant="header"
onClick={ handleProfileButtonClick }
/>
{ (profileQuery.data || web3Address) && (
<Drawer
isOpen={ profileMenu.isOpen }
placement="right"
onClose={ profileMenu.onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="300px">
<DrawerBody p={ 6 }>
<UserProfileContent
data={ profileQuery.data }
onClose={ profileMenu.onClose }
onLogin={ authModal.onOpen }
onAddEmail={ handleAddEmailClick }
onAddAddress={ handleAddAddressClick }
/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
{ authModal.isOpen && (
<AuthModal
onClose={ handleAuthModalClose }
initialScreen={ authInitialScreen }
/>
) }
</>
);
};
export default React.memo(UserProfileMobile);
import type { IconName } from 'public/icons/name';
export interface NavLink {
text: string;
href?: string;
onClick?: () => void;
icon: IconName;
}
export function getUserHandle(email: string) {
return email.split('@')[0];
}
import type { ButtonProps } from '@chakra-ui/react';
import { Button, Box, HStack, Tooltip } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import shortenString from 'lib/shortenString';
import UserIdenticon from '../UserIdenticon';
interface Props {
size?: ButtonProps['size'];
variant?: ButtonProps['variant'];
onClick?: () => void;
isPending?: boolean;
isAutoConnectDisabled?: boolean;
address?: string;
domain?: string;
}
const UserWalletButton = ({ size, variant, onClick, isPending, isAutoConnectDisabled, address, domain }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const isMobile = useIsMobile();
const content = (() => {
if (!address) {
return 'Connect';
}
const text = domain || shortenString(address);
return (
<HStack gap={ 2 }>
<UserIdenticon address={ address } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<Box display={{ base: 'none', md: 'block' }}>{ text }</Box>
</HStack>
);
})();
return (
<Tooltip
label={ <span>Connect your wallet<br/>to Blockscout for<br/>full-featured access</span> }
textAlign="center"
padding={ 2 }
isDisabled={ isMobile || Boolean(address) }
openDelay={ 500 }
>
<Button
ref={ ref }
size={ size }
variant={ variant }
onClick={ onClick }
data-selected={ Boolean(address) }
data-warning={ isAutoConnectDisabled }
fontSize="sm"
lineHeight={ 5 }
px={ address ? 2.5 : 4 }
fontWeight={ address ? 700 : 600 }
isLoading={ isPending }
loadingText={ isMobile ? undefined : 'Connecting' }
>
{ content }
</Button>
</Tooltip>
);
};
export default React.memo(React.forwardRef(UserWalletButton));
import { PopoverBody, PopoverContent, PopoverTrigger, useDisclosure, type ButtonProps } from '@chakra-ui/react';
import React from 'react';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import Popover from 'ui/shared/chakra/Popover';
import UserWalletButton from './UserWalletButton';
import UserWalletMenuContent from './UserWalletMenuContent';
interface Props {
buttonSize?: ButtonProps['size'];
buttonVariant?: ButtonProps['variant'];
}
const UserWalletDesktop = ({ buttonSize, buttonVariant = 'header' }: Props) => {
const walletMenu = useDisclosure();
const web3Wallet = useWeb3Wallet({ source: 'Header' });
const web3AccountWithDomain = useWeb3AccountWithDomain(web3Wallet.isConnected);
const { isAutoConnectDisabled } = useMarketplaceContext();
const isPending =
(web3Wallet.isConnected && web3AccountWithDomain.isLoading) ||
(!web3Wallet.isConnected && web3Wallet.isOpen);
const handleOpenWalletClick = React.useCallback(() => {
web3Wallet.openModal();
walletMenu.onClose();
}, [ web3Wallet, walletMenu ]);
const handleDisconnectClick = React.useCallback(() => {
web3Wallet.disconnect();
walletMenu.onClose();
}, [ web3Wallet, walletMenu ]);
return (
<Popover openDelay={ 300 } placement="bottom-end" isLazy isOpen={ walletMenu.isOpen } onClose={ walletMenu.onClose }>
<PopoverTrigger>
<UserWalletButton
size={ buttonSize }
variant={ buttonVariant }
onClick={ web3Wallet.isConnected ? walletMenu.onOpen : web3Wallet.openModal }
address={ web3AccountWithDomain.address }
domain={ web3AccountWithDomain.domain }
isPending={ isPending }
isAutoConnectDisabled={ isAutoConnectDisabled }
/>
</PopoverTrigger>
{ web3AccountWithDomain.address && (
<PopoverContent w="235px">
<PopoverBody>
<UserWalletMenuContent
address={ web3AccountWithDomain.address }
domain={ web3AccountWithDomain.domain }
isAutoConnectDisabled={ isAutoConnectDisabled }
onOpenWallet={ handleOpenWalletClick }
onDisconnect={ handleDisconnectClick }
/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
);
};
export default React.memo(UserWalletDesktop);
import { Box, Button, Flex, IconButton, Text } from '@chakra-ui/react';
import React from 'react';
import delay from 'lib/delay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import IconSvg from 'ui/shared/IconSvg';
import UserWalletAutoConnectAlert from '../UserWalletAutoConnectAlert';
interface Props {
address: string;
domain?: string;
isAutoConnectDisabled?: boolean;
onDisconnect: () => void;
onOpenWallet: () => void;
}
const UserWalletMenuContent = ({ isAutoConnectDisabled, address, domain, onDisconnect, onOpenWallet }: Props) => {
const handleOpenWalletClick = React.useCallback(async() => {
await delay(100);
onOpenWallet();
}, [ onOpenWallet ]);
return (
<Box>
{ isAutoConnectDisabled && <UserWalletAutoConnectAlert/> }
<Text fontSize="sm" fontWeight={ 600 } mb={ 1 }>My wallet</Text>
<Text fontSize="sm" mb={ 5 } fontWeight={ 400 } color="text_secondary">
Your wallet is used to interact with apps and contracts in the explorer.
</Text>
<Flex alignItems="center" columnGap={ 2 }>
<AddressEntity
address={{ hash: address, ens_domain_name: domain }}
isTooltipDisabled
truncation="dynamic"
fontSize="sm"
fontWeight={ 700 }
/>
<IconButton
aria-label="Open wallet"
icon={ <IconSvg name="gear_slim" boxSize={ 5 }/> }
variant="simple"
color="icon_info"
boxSize={ 5 }
onClick={ handleOpenWalletClick }
flexShrink={ 0 }
/>
</Flex>
<Button size="sm" width="full" variant="outline" onClick={ onDisconnect } mt={ 6 }>
Disconnect
</Button>
</Box>
);
};
export default React.memo(UserWalletMenuContent);
import { Drawer, DrawerBody, DrawerContent, DrawerOverlay, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useWeb3AccountWithDomain from 'lib/web3/useAccountWithDomain';
import useWeb3Wallet from 'lib/web3/useWallet';
import UserWalletButton from './UserWalletButton';
import UserWalletMenuContent from './UserWalletMenuContent';
const UserWalletMobile = () => {
const walletMenu = useDisclosure();
const web3Wallet = useWeb3Wallet({ source: 'Header' });
const web3AccountWithDomain = useWeb3AccountWithDomain(web3Wallet.isConnected);
const { isAutoConnectDisabled } = useMarketplaceContext();
const isPending =
(web3Wallet.isConnected && web3AccountWithDomain.isLoading) ||
(!web3Wallet.isConnected && web3Wallet.isOpen);
const handleOpenWalletClick = React.useCallback(() => {
web3Wallet.openModal();
walletMenu.onClose();
}, [ web3Wallet, walletMenu ]);
const handleDisconnectClick = React.useCallback(() => {
web3Wallet.disconnect();
walletMenu.onClose();
}, [ web3Wallet, walletMenu ]);
return (
<>
<UserWalletButton
variant="header"
onClick={ web3Wallet.isConnected ? walletMenu.onOpen : web3Wallet.openModal }
address={ web3AccountWithDomain.address }
domain={ web3AccountWithDomain.domain }
isPending={ isPending }
/>
{ web3AccountWithDomain.address && (
<Drawer
isOpen={ walletMenu.isOpen }
placement="right"
onClose={ walletMenu.onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="300px">
<DrawerBody p={ 6 }>
<UserWalletMenuContent
address={ web3AccountWithDomain.address }
domain={ web3AccountWithDomain.domain }
isAutoConnectDisabled={ isAutoConnectDisabled }
onOpenWallet={ handleOpenWalletClick }
onDisconnect={ handleDisconnectClick }
/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
</>
);
};
export default React.memo(UserWalletMobile);
import { Box, Button, Text, Flex, IconButton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import * as mixpanel from 'lib/mixpanel/index';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
address?: string;
ensDomainName?: string | null;
disconnect?: () => void;
isAutoConnectDisabled?: boolean;
openWeb3Modal: () => void;
closeWalletMenu: () => void;
};
const WalletMenuContent = ({ address, ensDomainName, disconnect, isAutoConnectDisabled, openWeb3Modal, closeWalletMenu }: Props) => {
const bgColor = useColorModeValue('orange.100', 'orange.900');
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const onAddressClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' });
}, []);
const handleOpenWeb3Modal = React.useCallback(async() => {
setIsModalOpening(true);
await openWeb3Modal();
setTimeout(closeWalletMenu, 300);
}, [ openWeb3Modal, closeWalletMenu ]);
return (
<Box>
{ isAutoConnectDisabled && (
<Flex
borderRadius="base"
p={ 3 }
mb={ 3 }
alignItems="center"
backgroundColor={ bgColor }
>
<IconSvg
name="integration/partial"
color="text"
boxSize={ 5 }
flexShrink={ 0 }
mr={ 2 }
/>
<Text fontSize="xs" lineHeight="16px">
Connect your wallet in the app below
</Text>
</Flex>
) }
<Text
fontSize="sm"
fontWeight={ 600 }
mb={ 1 }
{ ...getDefaultTransitionProps() }
>
My wallet
</Text>
<Text
fontSize="sm"
mb={ 5 }
fontWeight={ 400 }
color="text_secondary"
{ ...getDefaultTransitionProps() }
>
Your wallet is used to interact with apps and contracts in the explorer.
</Text>
{ address && (
<Flex alignItems="center" mb={ 6 }>
<AddressEntity
address={{ hash: address, ens_domain_name: ensDomainName }}
noTooltip
truncation="dynamic"
fontSize="sm"
fontWeight={ 700 }
color="text"
onClick={ onAddressClick }
flex={ 1 }
/>
<IconButton
aria-label="open wallet"
icon={ <IconSvg name="gear_slim" boxSize={ 5 }/> }
variant="simple"
h="20px"
w="20px"
ml={ 1 }
onClick={ handleOpenWeb3Modal }
isLoading={ isModalOpening }
/>
</Flex>
) }
<Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect
</Button>
</Box>
);
};
export default WalletMenuContent;
import React from 'react';
import type * as bens from '@blockscout/bens-types';
import config from 'configs/app';
import * as addressMock from 'mocks/address/address';
import * as domainMock from 'mocks/ens/domain';
import { test, expect } from 'playwright/lib';
import { WalletMenuDesktop } from './WalletMenuDesktop';
const props = {
isWalletConnected: false,
address: '',
connect: () => {},
disconnect: () => {},
isModalOpening: false,
isModalOpen: false,
openModal: () => {},
};
test.use({ viewport: { width: 1440, height: 750 } }); // xl
test('wallet is not connected +@dark-mode', async({ page, render }) => {
await render(<WalletMenuDesktop { ...props }/>);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 50 } });
});
test('wallet is not connected (home page) +@dark-mode', async({ page, render }) => {
await render(<WalletMenuDesktop { ...props } isHomePage/>);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 50 } });
});
test('wallet is loading', async({ page, render }) => {
await render(<WalletMenuDesktop { ...props } isModalOpen/>);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 50 } });
});
test('wallet connected +@dark-mode', async({ page, render, mockApiResponse }) => {
await mockApiResponse(
'address_domain',
{ domain: undefined, resolved_domains_count: 0 } as bens.GetAddressResponse,
{ pathParams: { address: addressMock.hash, chainId: config.chain.id } },
);
const component = await render(<WalletMenuDesktop { ...props } isWalletConnected address={ addressMock.hash }/>);
await component.locator('button').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 300 } });
});
test('wallet connected (home page) +@dark-mode', async({ page, render }) => {
const component = await render(<WalletMenuDesktop { ...props } isHomePage isWalletConnected address={ addressMock.hash }/>);
await component.locator('button').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 300 } });
});
test('wallet with ENS connected', async({ page, render, mockApiResponse }) => {
await mockApiResponse(
'address_domain',
{ domain: domainMock.ensDomainB, resolved_domains_count: 1 } as bens.GetAddressResponse,
{ pathParams: { address: addressMock.hash, chainId: config.chain.id } },
);
const component = await render(<WalletMenuDesktop { ...props } isWalletConnected address={ addressMock.hash }/>);
await component.locator('button').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 300 } });
});
import { PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useBoolean, chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import Popover from 'ui/shared/chakra/Popover';
import HashStringShorten from 'ui/shared/HashStringShorten';
import IconSvg from 'ui/shared/IconSvg';
import useWallet from 'ui/snippets/walletMenu/useWallet';
import WalletMenuContent from 'ui/snippets/walletMenu/WalletMenuContent';
import WalletIdenticon from './WalletIdenticon';
import WalletTooltip from './WalletTooltip';
type Props = {
isHomePage?: boolean;
className?: string;
size?: 'sm' | 'md';
};
type ComponentProps = Props & {
isWalletConnected: boolean;
address: string;
connect: () => void;
disconnect: () => void;
isModalOpening: boolean;
isModalOpen: boolean;
openModal: () => void;
};
export const WalletMenuDesktop = ({
isHomePage, className, size = 'md', isWalletConnected, address, connect,
disconnect, isModalOpening, isModalOpen, openModal,
}: ComponentProps) => {
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const isMobile = useIsMobile();
const { isAutoConnectDisabled } = useMarketplaceContext();
const addressDomainQuery = useApiQuery('address_domain', {
pathParams: {
chainId: config.chain.id,
address,
},
queryOptions: {
enabled: config.features.nameService.isEnabled,
},
});
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
setIsPopoverOpen.toggle();
}, [ setIsPopoverOpen ]);
return (
<Popover
openDelay={ 300 }
placement="bottom-end"
isLazy
isOpen={ isPopoverOpen }
onClose={ setIsPopoverOpen.off }
>
<Box ml={ 2 }>
<PopoverTrigger>
<WalletTooltip
isDisabled={ isMobile === undefined || isMobile || isModalOpening || isModalOpen }
isWalletConnected={ isWalletConnected }
isAutoConnectDisabled={ isAutoConnectDisabled }
>
<Button
className={ className }
variant={ isHomePage ? 'hero' : 'header' }
data-selected={ isWalletConnected }
data-warning={ isAutoConnectDisabled }
flexShrink={ 0 }
isLoading={
((isModalOpening || isModalOpen) && !isWalletConnected) ||
(addressDomainQuery.isLoading && isWalletConnected)
}
loadingText="Connect wallet"
onClick={ isWalletConnected ? openPopover : connect }
fontSize="sm"
size={ size }
px={{ lg: isHomePage ? 2 : 4, xl: 4 }}
>
{ isWalletConnected ? (
<>
<WalletIdenticon address={ address } isAutoConnectDisabled={ isAutoConnectDisabled } mr={ 2 }/>
{ addressDomainQuery.data?.domain?.name ? (
<chakra.span>{ addressDomainQuery.data.domain?.name }</chakra.span>
) : (
<HashStringShorten hash={ address } isTooltipDisabled/>
) }
</>
) : (
<>
<IconSvg display={{ base: isHomePage ? 'inline' : 'none', xl: 'none' }} name="wallet" boxSize={ 6 } p={ 0.5 }/>
<chakra.span display={{ base: isHomePage ? 'none' : 'inline', xl: 'inline' }}>Connect wallet</chakra.span>
</>
) }
</Button>
</WalletTooltip>
</PopoverTrigger>
</Box>
{ isWalletConnected && (
<PopoverContent w="235px">
<PopoverBody padding="24px 16px 16px 16px">
<WalletMenuContent
address={ address }
ensDomainName={ addressDomainQuery.data?.domain?.name }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ setIsPopoverOpen.off }
/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
);
};
// separated the useWallet hook from the main component because it's hard to mock it in tests
const WalletMenuDesktopWrapper = ({ isHomePage, className, size = 'md' }: Props) => {
const {
isWalletConnected, address, connect, disconnect,
isModalOpening, isModalOpen, openModal,
} = useWallet({ source: 'Header' });
return (
<WalletMenuDesktop
isHomePage={ isHomePage }
className={ className }
size={ size }
isWalletConnected={ isWalletConnected }
address={ address }
connect={ connect }
disconnect={ disconnect }
isModalOpening={ isModalOpening }
isModalOpen={ isModalOpen }
openModal={ openModal }
/>
);
};
export default chakra(WalletMenuDesktopWrapper);
import React from 'react';
import type * as bens from '@blockscout/bens-types';
import config from 'configs/app';
import * as addressMock from 'mocks/address/address';
import * as domainMock from 'mocks/ens/domain';
import { test, expect, devices } from 'playwright/lib';
import { WalletMenuMobile } from './WalletMenuMobile';
const props = {
isWalletConnected: false,
address: '',
connect: () => {},
disconnect: () => {},
isModalOpening: false,
isModalOpen: false,
openModal: () => {},
};
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('wallet is not connected +@dark-mode', async({ page, render }) => {
await render(<WalletMenuMobile { ...props }/>);
await expect(page).toHaveScreenshot();
});
test('wallet is loading', async({ page, render }) => {
await render(<WalletMenuMobile { ...props } isModalOpen/>);
await expect(page).toHaveScreenshot();
});
test('wallet connected +@dark-mode', async({ page, render, mockApiResponse }) => {
await mockApiResponse(
'address_domain',
{ domain: undefined, resolved_domains_count: 0 } as bens.GetAddressResponse,
{ pathParams: { address: addressMock.hash, chainId: config.chain.id } },
);
const component = await render(<WalletMenuMobile { ...props } isWalletConnected address={ addressMock.hash }/>);
await component.locator('button').click();
await expect(page).toHaveScreenshot();
});
test('wallet with ENS connected', async({ page, render, mockApiResponse }) => {
await mockApiResponse(
'address_domain',
{ domain: domainMock.ensDomainB, resolved_domains_count: 1 } as bens.GetAddressResponse,
{ pathParams: { address: addressMock.hash, chainId: config.chain.id } },
);
const component = await render(<WalletMenuMobile { ...props } isWalletConnected address={ addressMock.hash }/>);
await component.locator('button').click();
await expect(page).toHaveScreenshot();
});
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconButton } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useMarketplaceContext } from 'lib/contexts/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import useWallet from 'ui/snippets/walletMenu/useWallet';
import WalletMenuContent from 'ui/snippets/walletMenu/WalletMenuContent';
import WalletIdenticon from './WalletIdenticon';
import WalletTooltip from './WalletTooltip';
type ComponentProps = {
isWalletConnected: boolean;
address: string;
connect: () => void;
disconnect: () => void;
isModalOpening: boolean;
isModalOpen: boolean;
openModal: () => void;
};
export const WalletMenuMobile = (
{ isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen, openModal }: ComponentProps,
) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const isMobile = useIsMobile();
const { isAutoConnectDisabled } = useMarketplaceContext();
const addressDomainQuery = useApiQuery('address_domain', {
pathParams: {
chainId: config.chain.id,
address,
},
queryOptions: {
enabled: config.features.nameService.isEnabled,
},
});
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
onOpen();
}, [ onOpen ]);
return (
<>
<WalletTooltip
isDisabled={ isMobile === undefined || !isMobile }
isMobile
isWalletConnected={ isWalletConnected }
isAutoConnectDisabled={ isAutoConnectDisabled }
>
<IconButton
aria-label="wallet menu"
icon={ isWalletConnected ?
<WalletIdenticon address={ address } isAutoConnectDisabled={ isAutoConnectDisabled }/> :
<IconSvg name="wallet" boxSize={ 6 } p={ 0.5 }/>
}
variant="header"
data-selected={ isWalletConnected }
data-warning={ isAutoConnectDisabled }
boxSize="40px"
flexShrink={ 0 }
onClick={ isWalletConnected ? openPopover : connect }
isLoading={
((isModalOpening || isModalOpen) && !isWalletConnected) ||
(addressDomainQuery.isLoading && isWalletConnected)
}
/>
</WalletTooltip>
{ isWalletConnected && (
<Drawer
isOpen={ isOpen }
placement="right"
onClose={ onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerBody p={ 6 }>
<WalletMenuContent
address={ address }
ensDomainName={ addressDomainQuery.data?.domain?.name }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ onClose }
/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
</>
);
};
const WalletMenuMobileWrapper = () => {
const {
isWalletConnected, address, connect, disconnect,
isModalOpening, isModalOpen, openModal,
} = useWallet({ source: 'Header' });
return (
<WalletMenuMobile
isWalletConnected={ isWalletConnected }
address={ address }
connect={ connect }
disconnect={ disconnect }
isModalOpening={ isModalOpening }
isModalOpen={ isModalOpen }
openModal={ openModal }
/>
);
};
export default WalletMenuMobileWrapper;
import { Tooltip, useBoolean, useOutsideClick, Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { SECOND } from 'lib/consts';
import removeQueryParam from 'lib/router/removeQueryParam';
type Props = {
children: React.ReactNode;
isDisabled?: boolean;
isMobile?: boolean;
isWalletConnected?: boolean;
isAutoConnectDisabled?: boolean;
};
const LOCAL_STORAGE_KEY = 'wallet-connect-tooltip-shown';
const WalletTooltip = ({ children, isDisabled, isMobile, isWalletConnected, isAutoConnectDisabled }: Props, ref: React.ForwardedRef<HTMLDivElement>) => {
const router = useRouter();
const [ isTooltipShown, setIsTooltipShown ] = useBoolean(false);
const innerRef = React.useRef(null);
useOutsideClick({ ref: innerRef, handler: setIsTooltipShown.off });
const label = React.useMemo(() => {
if (isWalletConnected) {
if (isAutoConnectDisabled) {
return <span>Your wallet is not<br/>connected to this app.<br/>Connect your wallet<br/>in the app directly</span>;
}
return null;
}
return <span>Connect your wallet<br/>to Blockscout for<br/>full-featured access</span>;
}, [ isWalletConnected, isAutoConnectDisabled ]);
const isAppPage = router.pathname === '/apps/[id]';
React.useEffect(() => {
const wasShown = window.localStorage.getItem(LOCAL_STORAGE_KEY);
const isMarketplacePage = router.pathname === '/apps';
const isTooltipShowAction = router.query.action === 'tooltip';
const isConnectWalletAction = router.query.action === 'connect';
const needToShow = (isAppPage && !isConnectWalletAction) || isTooltipShowAction || (!wasShown && isMarketplacePage);
let timer1: ReturnType<typeof setTimeout>;
let timer2: ReturnType<typeof setTimeout>;
if (!isDisabled && needToShow) {
timer1 = setTimeout(() => {
setIsTooltipShown.on();
timer2 = setTimeout(() => setIsTooltipShown.off(), 3 * SECOND);
if (!wasShown && isMarketplacePage) {
window.localStorage.setItem(LOCAL_STORAGE_KEY, 'true');
}
if (isTooltipShowAction) {
removeQueryParam(router, 'action');
}
}, isTooltipShowAction ? 0 : SECOND);
}
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [ setIsTooltipShown, isDisabled, router, isAppPage ]);
return (
<Box ref={ ref }>
<Tooltip
label={ label }
textAlign="center"
padding={ 2 }
isDisabled={ isDisabled || !label || (isWalletConnected && !isAppPage) }
openDelay={ 500 }
isOpen={ isTooltipShown || (isMobile ? false : undefined) }
onClose={ setIsTooltipShown.off }
display={ isMobile ? { base: 'flex', lg: 'none' } : { base: 'none', lg: 'flex' } }
ref={ innerRef }
>
{ children }
</Tooltip>
</Box>
);
};
export default React.forwardRef(WalletTooltip);
import { Button, Grid, GridItem } from '@chakra-ui/react';
import { Button, Grid, GridItem, Text } from '@chakra-ui/react';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import type { Fields } from './types';
import type { TokenInfoApplication } from 'types/api/account';
......@@ -15,22 +15,15 @@ import useUpdateEffect from 'lib/hooks/useUpdateEffect';
import * as mixpanel from 'lib/mixpanel/index';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldEmail from 'ui/shared/forms/fields/FormFieldEmail';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import TokenInfoFieldAddress from './fields/TokenInfoFieldAddress';
import TokenInfoFieldComment from './fields/TokenInfoFieldComment';
import TokenInfoFieldDocs from './fields/TokenInfoFieldDocs';
import TokenInfoFieldIconUrl from './fields/TokenInfoFieldIconUrl';
import TokenInfoFieldPriceTicker from './fields/TokenInfoFieldPriceTicker';
import TokenInfoFieldProjectDescription from './fields/TokenInfoFieldProjectDescription';
import TokenInfoFieldProjectEmail from './fields/TokenInfoFieldProjectEmail';
import TokenInfoFieldProjectName from './fields/TokenInfoFieldProjectName';
import TokenInfoFieldProjectSector from './fields/TokenInfoFieldProjectSector';
import TokenInfoFieldProjectWebsite from './fields/TokenInfoFieldProjectWebsite';
import TokenInfoFieldRequesterEmail from './fields/TokenInfoFieldRequesterEmail';
import TokenInfoFieldRequesterName from './fields/TokenInfoFieldRequesterName';
import TokenInfoFieldSocialLink from './fields/TokenInfoFieldSocialLink';
import TokenInfoFieldSupport from './fields/TokenInfoFieldSupport';
import TokenInfoFieldTokenName from './fields/TokenInfoFieldTokenName';
import TokenInfoFormSectionHeader from './TokenInfoFormSectionHeader';
import TokenInfoFormStatusText from './TokenInfoFormStatusText';
import { getFormDefaultValues, prepareRequestBody } from './utils';
......@@ -58,7 +51,7 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) =>
mode: 'onBlur',
defaultValues: getFormDefaultValues(address, tokenName, application),
});
const { handleSubmit, formState, control, trigger } = formApi;
const { handleSubmit, formState } = formApi;
React.useEffect(() => {
if (!application?.id && !openEventSent.current) {
......@@ -116,66 +109,90 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) =>
return <ContentLoader/>;
}
const fieldProps = { control, isReadOnly: application?.status === 'IN_PROCESS' };
const fieldProps = {
size: { base: 'md', lg: 'lg' },
isReadOnly: application?.status === 'IN_PROCESS',
};
return (
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<TokenInfoFormStatusText application={ application }/>
<Grid mt={ 8 } gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }} columnGap={ 5 } rowGap={ 5 }>
<TokenInfoFieldTokenName { ...fieldProps }/>
<TokenInfoFieldAddress { ...fieldProps }/>
<TokenInfoFieldRequesterName { ...fieldProps }/>
<TokenInfoFieldRequesterEmail { ...fieldProps }/>
<TokenInfoFormSectionHeader>Project info</TokenInfoFormSectionHeader>
<TokenInfoFieldProjectName { ...fieldProps }/>
<TokenInfoFieldProjectSector { ...fieldProps } config={ configQuery.data.projectSectors }/>
<TokenInfoFieldProjectEmail { ...fieldProps }/>
<TokenInfoFieldProjectWebsite { ...fieldProps }/>
<TokenInfoFieldDocs { ...fieldProps }/>
<TokenInfoFieldSupport { ...fieldProps }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldIconUrl { ...fieldProps } trigger={ trigger }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldProjectDescription { ...fieldProps }/>
</GridItem>
<TokenInfoFormSectionHeader>Links</TokenInfoFormSectionHeader>
<TokenInfoFieldSocialLink { ...fieldProps } name="github"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="twitter"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="telegram"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="opensea"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="linkedin"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="facebook"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="discord"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="medium"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="slack"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="reddit"/>
<TokenInfoFormSectionHeader>Price data</TokenInfoFormSectionHeader>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_market_cap" label="CoinMarketCap URL"/>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_coin_gecko" label="CoinGecko URL"/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_defi_llama" label="DefiLlama URL "/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldComment { ...fieldProps }/>
</GridItem>
</Grid>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ application?.status === 'IN_PROCESS' }
>
Send request
</Button>
</form>
<FormProvider { ...formApi }>
<form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }>
<TokenInfoFormStatusText application={ application }/>
<Grid mt={ 8 } gridTemplateColumns={{ base: '1fr', lg: '1fr 1fr' }} columnGap={ 5 } rowGap={ 5 }>
<FormFieldText<Fields> name="token_name" isRequired placeholder="Token name" { ...fieldProps } isReadOnly/>
<FormFieldAddress<Fields> name="address" isRequired placeholder="Token contract address" { ...fieldProps } isReadOnly/>
<FormFieldText<Fields> name="requester_name" isRequired placeholder="Requester name" { ...fieldProps }/>
<FormFieldEmail<Fields> name="requester_email" isRequired placeholder="Requester email" { ...fieldProps }/>
<TokenInfoFormSectionHeader>Project info</TokenInfoFormSectionHeader>
<FormFieldText<Fields> name="project_name" placeholder="Project name" { ...fieldProps }/>
<TokenInfoFieldProjectSector { ...fieldProps } config={ configQuery.data.projectSectors }/>
<FormFieldEmail<Fields> name="project_email" isRequired placeholder="Official project email address" { ...fieldProps }/>
<FormFieldUrl<Fields> name="project_website" isRequired placeholder="Official project website" { ...fieldProps }/>
<FormFieldUrl<Fields> name="docs" placeholder="Docs" { ...fieldProps }/>
<TokenInfoFieldSupport { ...fieldProps }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldIconUrl { ...fieldProps }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldText<Fields>
name="project_description"
isRequired
placeholder="Project description"
maxH="160px"
rules={{ maxLength: 300 }}
asComponent="Textarea"
{ ...fieldProps }
/>
<Text variant="secondary" fontSize="sm" mt={ 1 }>
Introduce or summarize the project’s operation/goals in a maximum of 300 characters.
The description should be written in a neutral point of view and must exclude unsubstantiated claims unless proven otherwise.
</Text>
</GridItem>
<TokenInfoFormSectionHeader>Links</TokenInfoFormSectionHeader>
<TokenInfoFieldSocialLink { ...fieldProps } name="github"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="twitter"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="telegram"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="opensea"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="linkedin"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="facebook"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="discord"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="medium"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="slack"/>
<TokenInfoFieldSocialLink { ...fieldProps } name="reddit"/>
<TokenInfoFormSectionHeader>Price data</TokenInfoFormSectionHeader>
<FormFieldUrl<Fields> name="ticker_coin_market_cap" placeholder="CoinMarketCap URL" { ...fieldProps }/>
<FormFieldUrl<Fields> name="ticker_coin_gecko" placeholder="CoinGecko URL" { ...fieldProps }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldUrl<Fields> name="ticker_defi_llama" placeholder="DefiLlama URL" { ...fieldProps }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<FormFieldText<Fields>
name="comment"
placeholder="Comment"
maxH="160px"
rules={{ maxLength: 300 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
</Grid>
<Button
type="submit"
size="lg"
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Send request"
isDisabled={ application?.status === 'IN_PROCESS' }
>
Send request
</Button>
</form>
</FormProvider>
);
};
......
import { Center, Image, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
interface Props {
url: string | undefined;
onLoad?: () => void;
onError?: () => void;
isInvalid: boolean;
children: React.ReactElement;
}
const TokenInfoIconPreview = ({ url, onError, onLoad, isInvalid }: Props) => {
const TokenInfoIconPreview = ({ url, isInvalid, children }: Props) => {
const borderColor = useColorModeValue('gray.100', 'gray.700');
const borderColorFilled = useColorModeValue('gray.300', 'gray.600');
const borderColorError = useColorModeValue('red.400', 'red.300');
const borderColorActive = isInvalid ? borderColorError : borderColorFilled;
const borderColorActive = isInvalid ? 'error' : borderColorFilled;
return (
<Center
......@@ -24,16 +20,7 @@ const TokenInfoIconPreview = ({ url, onError, onLoad, isInvalid }: Props) => {
borderColor={ url ? borderColorActive : borderColor }
borderRadius="base"
>
<Image
borderRadius="base"
src={ url }
alt="Token logo preview"
boxSize={{ base: 10, lg: 12 }}
objectFit="cover"
fallback={ url && !isInvalid ? <Skeleton boxSize={{ base: 10, lg: 12 }}/> : <TokenLogoPlaceholder boxSize={{ base: 10, lg: 12 }}/> }
onError={ onError }
onLoad={ onLoad }
/>
{ children }
</Center>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldAddress = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'address'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isReadOnly
/>
<InputPlaceholder text="Token contract address"/>
</FormControl>
);
}, []);
return (
<Controller
name="address"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldAddress);
import { FormControl, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldComment = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'comment'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Textarea
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Comment" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="comment"
control={ control }
render={ renderControl }
rules={{ maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldComment);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldDocs = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'docs'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Docs" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="docs"
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldDocs);
import { FormControl, Flex, Input } from '@chakra-ui/react';
import type { FormControlProps } from '@chakra-ui/react';
import { Flex } from '@chakra-ui/react';
import React from 'react';
import type { Control, UseFormTrigger } from 'react-hook-form';
import { useController } from 'react-hook-form';
import type { Fields } from '../types';
import { times } from 'lib/html-entities';
import { validator as validateUrl } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import ImageUrlPreview from 'ui/shared/forms/components/ImageUrlPreview';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import useFieldWithImagePreview from 'ui/shared/forms/utils/useFieldWithImagePreview';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
import TokenInfoIconPreview from '../TokenInfoIconPreview';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
trigger: UseFormTrigger<Fields>;
size?: FormControlProps['size'];
}
const TokenInfoFieldIconUrl = ({ control, isReadOnly, trigger }: Props) => {
const TokenInfoFieldIconUrl = ({ isReadOnly, size }: Props) => {
const validatePreview = React.useCallback(() => {
return imageLoadError.current ? 'Unable to load image' : true;
}, [ ]);
const { field, formState, fieldState } = useController({
name: 'icon_url',
control,
rules: {
required: true,
validate: { url: validateUrl, preview: validatePreview },
},
});
const [ valueForPreview, setValueForPreview ] = React.useState<string>(field.value);
const imageLoadError = React.useRef(false);
const handleImageLoadSuccess = React.useCallback(() => {
imageLoadError.current = false;
trigger('icon_url');
}, [ trigger ]);
const handleImageLoadError = React.useCallback(() => {
imageLoadError.current = true;
trigger('icon_url');
}, [ trigger ]);
const handleBlur = React.useCallback(() => {
field.onBlur();
const isValidUrl = validateUrl(field.value);
isValidUrl === true && setValueForPreview(field.value);
}, [ field ]);
const previewUtils = useFieldWithImagePreview({ name: 'icon_url', isRequired: true });
return (
<Flex columnGap={ 5 }>
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
onBlur={ handleBlur }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` } error={ fieldState.error }/>
</FormControl>
<TokenInfoIconPreview
url={ fieldState.error?.type === 'url' ? undefined : valueForPreview }
onLoad={ handleImageLoadSuccess }
onError={ !isReadOnly ? handleImageLoadError : undefined }
isInvalid={ fieldState.error?.type === 'preview' }
<FormFieldUrl<Fields>
name="icon_url"
placeholder={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` }
isReadOnly={ isReadOnly }
size={ size }
{ ...previewUtils.input }
/>
<TokenInfoIconPreview url={ previewUtils.preview.src } isInvalid={ previewUtils.preview.isInvalid }>
<ImageUrlPreview
{ ...previewUtils.preview }
fallback={ <TokenLogoPlaceholder boxSize={{ base: 10, lg: 12 }}/> }
boxSize={{ base: 10, lg: 12 }}
borderRadius="base"
/>
</TokenInfoIconPreview>
</Flex>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields, TickerUrlFields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
name: keyof TickerUrlFields;
label: string;
}
const TokenInfoFieldPriceTicker = ({ control, isReadOnly, name, label }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ label } error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly, label ]);
return (
<Controller
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldPriceTicker);
import { FormControl, Text, Textarea } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectDescription = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_description'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Textarea
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
maxH="160px"
maxLength={ 300 }
/>
<InputPlaceholder text="Project description" error={ fieldState.error }/>
<Text variant="secondary" fontSize="sm" mt={ 1 }>
Introduce or summarize the project’s operation/goals in a maximum of 300 characters.
The description should be written in a neutral point of view and must exclude unsubstantiated claims unless proven otherwise.
</Text>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_description"
control={ control }
render={ renderControl }
rules={{ required: true, maxLength: 300 }}
/>
);
};
export default React.memo(TokenInfoFieldProjectDescription);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Official project email address" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldProjectEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Project name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldProjectName);
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import type { TokenInfoApplicationConfig } from 'types/api/account';
import useIsMobile from 'lib/hooks/useIsMobile';
import FancySelect from 'ui/shared/FancySelect/FancySelect';
import FormFieldFancySelect from 'ui/shared/forms/fields/FormFieldFancySelect';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
config: TokenInfoApplicationConfig['projectSectors'];
}
const TokenInfoFieldProjectSector = ({ control, isReadOnly, config }: Props) => {
const isMobile = useIsMobile();
const TokenInfoFieldProjectSector = ({ isReadOnly, config }: Props) => {
const options = React.useMemo(() => {
return config.map((option) => ({ label: option, value: option }));
}, [ config ]);
const renderControl: ControllerProps<Fields, 'project_sector'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FancySelect
{ ...field }
options={ options }
size={ isMobile ? 'md' : 'lg' }
placeholder="Project industry"
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
error={ fieldState.error }
/>
);
}, [ isReadOnly, options, isMobile ]);
return (
<Controller
<FormFieldFancySelect<Fields, 'project_sector'>
name="project_sector"
control={ control }
render={ renderControl }
placeholder="Project industry"
options={ options }
isReadOnly={ isReadOnly }
/>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldProjectWebsite = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'project_website'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} isRequired>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
required
/>
<InputPlaceholder text="Official project website" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="project_website"
control={ control }
render={ renderControl }
rules={{ required: true, validate: validator }}
/>
);
};
export default React.memo(TokenInfoFieldProjectWebsite);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { EMAIL_REGEXP } from 'lib/validations/email';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterEmail = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_email'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_email"
control={ control }
render={ renderControl }
rules={{ required: true, pattern: EMAIL_REGEXP }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterEmail);
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
}
const TokenInfoFieldRequesterName = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'requester_name'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Requester name" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
return (
<Controller
name="requester_name"
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
export default React.memo(TokenInfoFieldRequesterName);
import { FormControl, Input, InputRightElement, InputGroup } from '@chakra-ui/react';
import type { FormControlProps } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { ControllerRenderProps } from 'react-hook-form';
import type { Fields, SocialLinkFields } from '../types';
import { validator } from 'lib/validations/url';
import FormFieldUrl from 'ui/shared/forms/fields/FormFieldUrl';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Item {
icon: IconName;
......@@ -29,38 +27,24 @@ const SETTINGS: Record<keyof SocialLinkFields, Item> = {
};
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
size?: FormControlProps['size'];
name: keyof SocialLinkFields;
}
const TokenInfoFieldSocialLink = ({ control, isReadOnly, name }: Props) => {
const renderControl: ControllerProps<Fields, typeof name>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }} sx={{ '.chakra-input__group input': { pr: '60px' } }}>
<InputGroup>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text={ SETTINGS[name].label } error={ fieldState.error }/>
<InputRightElement h="100%">
<IconSvg name={ SETTINGS[name].icon } boxSize={ 6 } color={ field.value ? SETTINGS[name].color : '#718096' }/>
</InputRightElement>
</InputGroup>
</FormControl>
);
}, [ isReadOnly, name ]);
const TokenInfoFieldSocialLink = ({ isReadOnly, size, name }: Props) => {
const rightElement = React.useCallback(({ field }: { field: ControllerRenderProps<Fields, keyof SocialLinkFields> }) => {
return <IconSvg name={ SETTINGS[name].icon } boxSize={ 6 } color={ field.value ? SETTINGS[name].color : '#718096' }/>;
}, [ name ]);
return (
<Controller
<FormFieldUrl<Fields, keyof SocialLinkFields>
name={ name }
control={ control }
render={ renderControl }
rules={{ validate: validator }}
placeholder={ SETTINGS[name].label }
rightElement={ rightElement }
isReadOnly={ isReadOnly }
size={ size }
/>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import type { InputProps } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import { validator as emailValidator } from 'lib/validations/email';
import { validator as urlValidator } from 'lib/validations/url';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import { validator as emailValidator } from 'ui/shared/forms/validators/email';
import { urlValidator } from 'ui/shared/forms/validators/url';
interface Props {
control: Control<Fields>;
isReadOnly?: boolean;
size?: InputProps['size'];
}
const TokenInfoFieldSupport = ({ control, isReadOnly }: Props) => {
const renderControl: ControllerProps<Fields, 'support'>['render'] = React.useCallback(({ field, fieldState, formState }) => {
return (
<FormControl variant="floating" id={ field.name } size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
isInvalid={ Boolean(fieldState.error) }
isDisabled={ formState.isSubmitting }
isReadOnly={ isReadOnly }
autoComplete="off"
/>
<InputPlaceholder text="Support URL or email" error={ fieldState.error }/>
</FormControl>
);
}, [ isReadOnly ]);
const TokenInfoFieldSupport = (props: Props) => {
const validate = React.useCallback((newValue: string | undefined) => {
if (typeof newValue !== 'string') {
return true;
}
const urlValidationResult = urlValidator(newValue);
const emailValidationResult = emailValidator(newValue || '');
const emailValidationResult = emailValidator(newValue);
if (urlValidationResult === true || emailValidationResult === true) {
return true;
......@@ -43,11 +29,11 @@ const TokenInfoFieldSupport = ({ control, isReadOnly }: Props) => {
}, []);
return (
<Controller
<FormFieldText<Fields, 'support'>
name="support"
control={ control }
render={ renderControl }
placeholder="Support URL or email"
rules={{ validate }}
{ ...props }
/>
);
};
......
import { FormControl, Input } from '@chakra-ui/react';
import React from 'react';
import type { Control, ControllerRenderProps } from 'react-hook-form';
import { Controller } from 'react-hook-form';
import type { Fields } from '../types';
import InputPlaceholder from 'ui/shared/InputPlaceholder';
interface Props {
control: Control<Fields>;
}
const TokenInfoFieldTokenName = ({ control }: Props) => {
const renderControl = React.useCallback(({ field }: {field: ControllerRenderProps<Fields, 'token_name'>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired size={{ base: 'md', lg: 'lg' }}>
<Input
{ ...field }
required
isReadOnly
/>
<InputPlaceholder text="Token name"/>
</FormControl>
);
}, []);
return (
<Controller
name="token_name"
control={ control }
render={ renderControl }
/>
);
};
export default React.memo(TokenInfoFieldTokenName);
import type { Option } from 'ui/shared/FancySelect/types';
import type { Option } from 'ui/shared/forms/inputs/select/types';
export interface Fields extends SocialLinkFields, TickerUrlFields {
address: string;
......
import type { ToastId } from '@chakra-ui/react';
import { chakra, Alert, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Spinner } from '@chakra-ui/react';
import { chakra, Alert, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay, Spinner, Center } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import ReCaptcha from 'react-google-recaptcha';
import { GoogleReCaptcha, GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
import type { SocketMessage } from 'lib/socket/types';
import type { TokenInstance } from 'types/api/token';
......@@ -47,7 +47,7 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
pathParams: { hash, id },
fetchParams: {
method: 'PATCH',
body: { recaptcha_response: reCaptchaToken },
body: { recaptcha_v3_response: reCaptchaToken },
},
})
.then(() => {
......@@ -150,33 +150,43 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (status !== 'MODAL_OPENED') {
return null;
}
return (
<Modal isOpen={ status === 'MODAL_OPENED' } onClose={ handleModalClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 4 }>Solve captcha to refresh metadata</ModalHeader>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 4 }>Sending request</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 } minH="78px">
{ config.services.reCaptcha.siteKey ? (
<ReCaptcha
className="recaptcha"
sitekey={ config.services.reCaptcha.siteKey }
onChange={ handleReCaptchaChange }
/>
{ config.services.reCaptchaV3.siteKey ? (
<>
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<Center h="80px">
<Spinner size="lg"/>
</Center>
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha
/>
</GoogleReCaptchaProvider>
{ /* ONLY FOR TEST PURPOSES */ }
<chakra.form noValidate onSubmit={ handleFormSubmit } display="none">
<chakra.input
name="recaptcha_token"
placeholder="reCaptcha token"
/>
<chakra.button type="submit">Submit</chakra.button>
</chakra.form>
</>
) : (
<Alert status="error">
Metadata refresh is not available at the moment since reCaptcha is not configured for this application.
Please contact the service maintainer to make necessary changes in the service configuration.
</Alert>
) }
{ /* ONLY FOR TEST PURPOSES */ }
<chakra.form noValidate onSubmit={ handleFormSubmit } display="none">
<chakra.input
name="recaptcha_token"
placeholder="reCaptcha token"
/>
<chakra.button type="submit">Submit</chakra.button>
</chakra.form>
</ModalBody>
</ModalContent>
</Modal>
......
import React from 'react';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import useRedirectForInvalidAuthToken from 'ui/snippets/auth/useRedirectForInvalidAuthToken';
import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting';
type Props = {
......
import { Alert, Button, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import AuthModal from 'ui/snippets/auth/AuthModal';
const VerifiedAddressesEmailAlert = () => {
const authModal = useDisclosure();
return (
<>
<Alert
status="warning"
mb={ 6 }
display="flex"
flexDirection={{ base: 'column', md: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
columnGap={ 2 }
rowGap={ 2 }
>
You need a valid email address to verify contracts. Please add your email to your account.
<Button variant="outline" size="sm" onClick={ authModal.onOpen }>Add email</Button>
</Alert>
{ authModal.isOpen && <AuthModal initialScreen={{ type: 'email', isAuth: true }} onClose={ authModal.onClose }/> }
</>
);
};
export default React.memo(VerifiedAddressesEmailAlert);
import {
Alert,
Box,
Button,
Text,
useDisclosure,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { SubmitHandler, ControllerRenderProps } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import React, { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { WatchlistAddress, WatchlistErrors } from 'types/api/account';
import type { ResourceErrorAccount } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import getErrorMessage from 'lib/getErrorMessage';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import AddressInput from 'ui/shared/AddressInput';
import CheckboxInput from 'ui/shared/CheckboxInput';
import TagInput from 'ui/shared/TagInput';
import FormFieldAddress from 'ui/shared/forms/fields/FormFieldAddress';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import FormFieldText from 'ui/shared/forms/fields/FormFieldText';
import AuthModal from 'ui/snippets/auth/AuthModal';
import useProfileQuery from 'ui/snippets/auth/useProfileQuery';
import AddressFormNotifications from './AddressFormNotifications';
......@@ -32,7 +35,7 @@ type Props = {
isAdd: boolean;
}
type Inputs = {
export type Inputs = {
address: string;
tag: string;
notification: boolean;
......@@ -56,31 +59,25 @@ type Inputs = {
};
}
type Checkboxes = 'notification' |
'notification_settings.native.outcoming' |
'notification_settings.native.incoming' |
'notification_settings.ERC-20.outcoming' |
'notification_settings.ERC-20.incoming' |
'notification_settings.ERC-721.outcoming' |
'notification_settings.ERC-721.incoming' |
'notification_settings.ERC-404.outcoming' |
'notification_settings.ERC-404.incoming';
const AddressForm: React.FC<Props> = ({ data, onSuccess, setAlertVisible, isAdd }) => {
const [ pending, setPending ] = useState(false);
const profileQuery = useProfileQuery();
const userWithoutEmail = profileQuery.data && !profileQuery.data.email;
const authModal = useDisclosure();
let notificationsDefault = {} as Inputs['notification_settings'];
if (!data?.notification_settings) {
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: true, outcoming: true });
NOTIFICATIONS.forEach(n => notificationsDefault[n] = { incoming: !userWithoutEmail, outcoming: !userWithoutEmail });
} else {
notificationsDefault = data.notification_settings;
}
const { control, handleSubmit, formState: { errors, isDirty }, setError } = useForm<Inputs>({
const formApi = useForm<Inputs>({
defaultValues: {
address: data?.address_hash || '',
tag: data?.name || '',
notification: data?.notification_methods ? data.notification_methods.email : true,
notification: data?.notification_methods ? data.notification_methods.email : !userWithoutEmail,
notification_settings: notificationsDefault,
},
mode: 'onTouched',
......@@ -110,7 +107,7 @@ const AddressForm: React.FC<Props> = ({ data, onSuccess, setAlertVisible, isAdd
}
}
const { mutate } = useMutation({
const { mutateAsync } = useMutation({
mutationFn: updateWatchlist,
onSuccess: async() => {
await onSuccess();
......@@ -120,88 +117,85 @@ const AddressForm: React.FC<Props> = ({ data, onSuccess, setAlertVisible, isAdd
setPending(false);
const errorMap = error.payload?.errors;
if (errorMap?.address_hash || errorMap?.name) {
errorMap?.address_hash && setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
errorMap?.address_hash && formApi.setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'address_hash') });
errorMap?.name && formApi.setError('tag', { type: 'custom', message: getErrorMessage(errorMap, 'name') });
} else if (errorMap?.watchlist_id) {
setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'watchlist_id') });
formApi.setError('address', { type: 'custom', message: getErrorMessage(errorMap, 'watchlist_id') });
} else {
setAlertVisible(true);
}
},
});
const onSubmit: SubmitHandler<Inputs> = (formData) => {
const onSubmit: SubmitHandler<Inputs> = async(formData) => {
setAlertVisible(false);
setPending(true);
mutate(formData);
await mutateAsync(formData);
};
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'address'>}) => {
return (
<AddressInput<Inputs, 'address'>
field={ field }
bgColor="dialog_bg"
error={ errors.address }
/>
);
}, [ errors ]);
const renderTagInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'tag'>}) => {
return <TagInput<Inputs, 'tag'> field={ field } error={ errors.tag } bgColor="dialog_bg"/>;
}, [ errors ]);
// eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
<CheckboxInput<Inputs, Checkboxes> text={ text } field={ field }/>
), []);
return (
<form noValidate onSubmit={ handleSubmit(onSubmit) }>
<Box marginBottom={ 5 }>
<Controller
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
<FormFieldAddress<Inputs>
name="address"
control={ control }
rules={{
pattern: ADDRESS_REGEXP,
required: true,
}}
render={ renderAddressInput }
isRequired
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginBottom={ 8 }>
<Controller
<FormFieldText<Inputs>
name="tag"
control={ control }
placeholder="Private tag (max 35 characters)"
isRequired
rules={{
maxLength: TAG_MAX_LENGTH,
required: true,
}}
render={ renderTagInput }
bgColor="dialog_bg"
mb={ 8 }
/>
</Box>
<Text variant="secondary" fontSize="sm" marginBottom={ 5 }>
Please select what types of notifications you will receive
</Text>
<Box marginBottom={ 8 }>
<AddressFormNotifications control={ control }/>
</Box>
<Text variant="secondary" fontSize="sm" marginBottom={{ base: '10px', lg: 5 }}>Notification methods</Text>
<Controller
name={ 'notification' as Checkboxes }
control={ control }
render={ renderCheckbox('Email notifications') }
/>
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isLoading={ pending }
isDisabled={ !isDirty }
>
{ !isAdd ? 'Save changes' : 'Add address' }
</Button>
</Box>
</form>
{ userWithoutEmail ? (
<>
<Alert
status="info"
colorScheme="gray"
display="flex"
flexDirection={{ base: 'column', md: 'row' }}
alignItems={{ base: 'flex-start', lg: 'center' }}
columnGap={ 2 }
rowGap={ 2 }
w="fit-content"
>
To receive notifications you need to add an email to your profile.
<Button variant="outline" size="sm" onClick={ authModal.onOpen }>Add email</Button>
</Alert>
{ authModal.isOpen && <AuthModal initialScreen={{ type: 'email', isAuth: true }} onClose={ authModal.onClose }/> }
</>
) : (
<>
<Text variant="secondary" fontSize="sm" marginBottom={ 5 }>
Please select what types of notifications you will receive
</Text>
<Box marginBottom={ 8 }>
<AddressFormNotifications/>
</Box>
<Text variant="secondary" fontSize="sm" marginBottom={{ base: '10px', lg: 5 }}>Notification methods</Text>
<FormFieldCheckbox<Inputs, 'notification'>
name="notification"
label="Email notifications"
/>
</>
) }
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isLoading={ pending }
isDisabled={ !formApi.formState.isDirty }
>
{ !isAdd ? 'Save changes' : 'Add address' }
</Button>
</Box>
</form>
</FormProvider>
);
};
......
import { Grid, GridItem } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { Controller } from 'react-hook-form';
import type { Path, ControllerRenderProps, FieldValues, Control } from 'react-hook-form';
import React from 'react';
import config from 'configs/app';
import CheckboxInput from 'ui/shared/CheckboxInput';
import FormFieldCheckbox from 'ui/shared/forms/fields/FormFieldCheckbox';
import type { Inputs as FormFields } from './AddressForm';
const tokenStandardName = config.chain.tokenStandard;
......@@ -15,21 +15,12 @@ const NOTIFICATIONS_NAMES = [
`${ tokenStandardName }-721, ${ tokenStandardName }-1155 (NFT)`,
`${ tokenStandardName }-404` ];
type Props<Inputs extends FieldValues> = {
control: Control<Inputs>;
}
export default function AddressFormNotifications<Inputs extends FieldValues, Checkboxes extends Path<Inputs>>({ control }: Props<Inputs>) {
// eslint-disable-next-line react/display-name
const renderCheckbox = useCallback((text: string) => ({ field }: {field: ControllerRenderProps<Inputs, Checkboxes>}) => (
<CheckboxInput<Inputs, Checkboxes> text={ text } field={ field }/>
), []);
export default function AddressFormNotifications() {
return (
<Grid templateColumns={{ base: 'repeat(2, max-content)', lg: 'repeat(3, max-content)' }} gap={{ base: '10px 24px', lg: '20px 24px' }}>
{ NOTIFICATIONS.map((notification: string, index: number) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes;
{ NOTIFICATIONS.map((notification, index: number) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as const;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as const;
return (
<React.Fragment key={ notification }>
<GridItem
......@@ -42,19 +33,15 @@ export default function AddressFormNotifications<Inputs extends FieldValues, Che
{ NOTIFICATIONS_NAMES[index] }
</GridItem>
<GridItem>
<Controller
<FormFieldCheckbox<FormFields, typeof incomingFieldName>
name={ incomingFieldName }
control={ control }
render={ renderCheckbox('Incoming') }
label="Incoming"
/>
</GridItem>
<GridItem>
<Controller
<FormFieldCheckbox<FormFields, typeof outgoingFieldName>
name={ outgoingFieldName }
control={ control }
render={ renderCheckbox('Outgoing') }
label="Outgoing"
/>
</GridItem>
</React.Fragment>
......
......@@ -17,9 +17,10 @@ interface Props {
isLoading?: boolean;
onEditClick: (data: WatchlistAddress) => void;
onDeleteClick: (data: WatchlistAddress) => void;
hasEmail: boolean;
}
const WatchListItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) => {
const WatchListItem = ({ item, isLoading, onEditClick, onDeleteClick, hasEmail }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const [ switchDisabled, setSwitchDisabled ] = useState(false);
const onItemEditClick = useCallback(() => {
......@@ -49,7 +50,7 @@ const WatchListItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) =
const showNotificationToast = useCallback((isOn: boolean) => {
notificationToast({
position: 'top-right',
description: isOn ? 'Email notification is ON' : 'Email notification is OFF',
description: !isOn ? 'Email notification is ON' : 'Email notification is OFF',
colorScheme: 'green',
status: 'success',
variant: 'subtle',
......@@ -103,7 +104,7 @@ const WatchListItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) =
isChecked={ notificationEnabled }
onChange={ onSwitch }
aria-label="Email notification"
isDisabled={ switchDisabled }
isDisabled={ !hasEmail || switchDisabled }
/>
</Skeleton>
</HStack>
......
......@@ -21,9 +21,10 @@ interface Props {
isLoading?: boolean;
onEditClick: (data: WatchlistAddress) => void;
onDeleteClick: (data: WatchlistAddress) => void;
hasEmail: boolean;
}
const WatchlistTableItem = ({ item, isLoading, onEditClick, onDeleteClick }: Props) => {
const WatchlistTableItem = ({ item, isLoading, onEditClick, onDeleteClick, hasEmail }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const [ switchDisabled, setSwitchDisabled ] = useState(false);
const onItemEditClick = useCallback(() => {
......@@ -53,7 +54,7 @@ const WatchlistTableItem = ({ item, isLoading, onEditClick, onDeleteClick }: Pro
const showNotificationToast = useCallback((isOn: boolean) => {
notificationToast({
position: 'top-right',
description: isOn ? 'Email notification is ON' : 'Email notification is OFF',
description: !isOn ? 'Email notification is ON' : 'Email notification is OFF',
colorScheme: 'green',
status: 'success',
variant: 'subtle',
......@@ -101,7 +102,7 @@ const WatchlistTableItem = ({ item, isLoading, onEditClick, onDeleteClick }: Pro
size="md"
isChecked={ notificationEnabled }
onChange={ onSwitch }
isDisabled={ switchDisabled }
isDisabled={ !hasEmail || switchDisabled }
aria-label="Email notification"
/>
</Skeleton>
......
......@@ -18,9 +18,10 @@ interface Props {
onEditClick: (data: WatchlistAddress) => void;
onDeleteClick: (data: WatchlistAddress) => void;
top: number;
hasEmail: boolean;
}
const WatchlistTable = ({ data, isLoading, onDeleteClick, onEditClick, top }: Props) => {
const WatchlistTable = ({ data, isLoading, onDeleteClick, onEditClick, top, hasEmail }: Props) => {
return (
<Table minWidth="600px">
<TheadSticky top={ top }>
......@@ -39,6 +40,7 @@ const WatchlistTable = ({ data, isLoading, onDeleteClick, onEditClick, top }: Pr
isLoading={ isLoading }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
hasEmail={ hasEmail }
/>
)) }
</Tbody>
......
......@@ -13621,7 +13621,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.5.0, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
......@@ -13820,14 +13820,6 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-async-script@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.2.0.tgz#ab9412a26f0b83f5e2e00de1d2befc9400834b21"
integrity sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==
dependencies:
hoist-non-react-statics "^3.3.0"
prop-types "^15.5.0"
react-clientside-effect@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"
......@@ -13883,18 +13875,17 @@ react-focus-lock@^2.5.2, react-focus-lock@^2.9.4:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-google-recaptcha@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz#44aaab834495d922b9d93d7d7a7fb2326315b4ab"
integrity sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==
react-google-recaptcha-v3@1.10.1:
version "1.10.1"
resolved "https://registry.yarnpkg.com/react-google-recaptcha-v3/-/react-google-recaptcha-v3-1.10.1.tgz#5b125bc0dec123206431860e8800e188fc735aff"
integrity sha512-K3AYzSE0SasTn+XvV2tq+6YaxM+zQypk9rbCgG4OVUt7Rh4ze9basIKefoBz9sC0CNslJj9N1uwTTgRMJQbQJQ==
dependencies:
prop-types "^15.5.0"
react-async-script "^1.2.0"
hoist-non-react-statics "^3.3.2"
react-hook-form@^7.33.1:
version "7.37.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.37.0.tgz#4d1738f092d3d8a3ade34ee892d97350b1032b19"
integrity sha512-6NFTxsnw+EXSpNNvLr5nFMjPdYKRryQcelTHg7zwBB6vAzfPIcZq4AExP4heVlwdzntepQgwiOQW4z7Mr99Lsg==
react-hook-form@7.52.1:
version "7.52.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.52.1.tgz#ec2c96437b977f8b89ae2d541a70736c66284852"
integrity sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==
react-identicons@^1.2.5:
version "1.2.5"
......
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