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 }>
<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,18 +90,33 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => {
}, [ apiFetch, address, toast, setError, onSuccess ]);
return (
<FormProvider { ...formApi }>
<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 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"
......@@ -118,6 +129,7 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => {
Send request
</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,28 +3,28 @@ 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
......@@ -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,6 +61,8 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
return (
<>
<AuthGuard onAuthSuccess={ handleAddToFavorite }>
{ ({ onClick }) => (
<Tooltip label={ `${ watchListId ? 'Remove address from Watch list' : 'Add address to Watch list' }` }>
<IconButton
isActive={ Boolean(watchListId) }
......@@ -75,11 +73,13 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => {
pl="6px"
pr="6px"
flexShrink={ 0 }
onClick={ handleClick }
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,10 +100,17 @@ const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) =
})();
return (
<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> }
<AddressVerificationFieldAddress formState={ formState } control={ control }/>
<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
......@@ -111,6 +118,7 @@ const AddressVerificationStepAddress = ({ defaultAddress, onContinue }: Props) =
<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,6 +183,7 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre
})();
return (
<FormProvider { ...formApi }>
<form noValidate onSubmit={ onSubmit }>
{ rootError && <Alert status="warning" mb={ 6 }>{ rootError }</Alert> }
<Box mb={ 8 }>
......@@ -214,7 +214,15 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre
<Flex rowGap={ 5 } flexDir="column">
<div>
<CopyToClipboard text={ signingMessage } ml="auto" display="block"/>
<AddressVerificationFieldMessage formState={ formState } control={ control }/>
<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 }>
......@@ -222,13 +230,22 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre
<Radio value="manual">Sign manually</Radio>
</RadioGroup>
) }
{ signMethod === 'manual' && <AddressVerificationFieldSignature formState={ formState } control={ control }/> }
{ signMethod === 'manual' && (
<FormFieldText<Fields>
name="signature"
placeholder="Signature hash"
isRequired
rules={{ pattern: SIGNATURE_REGEXP }}
bgColor="dialog_bg"
/>
) }
</Flex>
<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) }>
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
{ data && (
<Box marginBottom={ 5 }>
<Controller
<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 }
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
......
......@@ -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' }
<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,14 +78,17 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
}, [ resource, hash, exportType, filterType, filterValue, fileNameTemplate, toast ]);
const disabledFeatureMessage = (
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 (
<GoogleReCaptchaProvider reCaptchaKey={ config.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
......@@ -92,7 +97,7 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
<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 }/>
<FormFieldReCaptcha/>
</Flex>
<Button
variant="solid"
......@@ -101,12 +106,13 @@ const CsvExportForm = ({ hash, resource, filterType, filterValue, fileNameTempla
mt={ 8 }
isLoading={ formState.isSubmitting }
loadingText="Download"
isDisabled={ !formState.isValid }
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"
<FormProvider { ...formApi }>
<form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }>
<FormFieldAddress<Inputs>
name="contract_address_hash"
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 }
isRequired
bgColor="dialog_bg"
mb={ 5 }
/>
<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
name="contract_address_hash"
control={ control }
render={ renderContractAddressInput }
<FormFieldText<Inputs>
name="name"
placeholder="Project name"
isRequired
rules={{
pattern: ADDRESS_REGEXP,
required: true,
maxLength: NAME_MAX_LENGTH,
}}
bgColor="dialog_bg"
mb={ 5 }
/>
</Box>
<Box marginTop={ 5 }>
<Controller
name="name"
control={ control }
render={ renderNameInput }
rules={{ required: true }}
/>
</Box>
<Box marginTop={ 5 }>
<Controller
<FormFieldText<Inputs>
name="abi"
control={ control }
render={ renderAbiInput }
rules={{ required: true }}
placeholder="Custom ABI [{...}] (JSON format)"
isRequired
asComponent="Textarea"
bgColor="dialog_bg"
size="lg"
minH="300px"
mb={ 8 }
/>
</Box>
<Box marginTop={ 8 }>
<Box>
<Button
size="lg"
type="submit"
isDisabled={ !isDirty }
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);
......
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 }
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 }
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,7 +76,16 @@ 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 (
<GoogleReCaptchaProvider reCaptchaKey={ appConfig.services.reCaptchaV3.siteKey }>
<FormProvider { ...formApi }>
<chakra.form
noValidate
......@@ -91,11 +99,12 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
<GridItem colSpan={{ base: 1, lg: 3 }} as="h2" textStyle="h4">
Company info
</GridItem>
<PublicTagsSubmitFieldRequesterName/>
<PublicTagsSubmitFieldRequesterEmail/>
<FormFieldText<FormFields> name="requesterName" isRequired placeholder="Your name" { ...fieldProps }/>
<FormFieldEmail<FormFields> name="requesterEmail" isRequired { ...fieldProps }/>
{ !isMobile && <div/> }
<PublicTagsSubmitFieldCompanyName/>
<PublicTagsSubmitFieldCompanyWebsite/>
<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 }}>
......@@ -105,7 +114,19 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
<PublicTagsSubmitFieldAddresses/>
<PublicTagsSubmitFieldTags tagTypes={ config?.tagTypes }/>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<PublicTagsSubmitFieldDescription/>
<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 }}>
......@@ -117,7 +138,6 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
size="lg"
type="submit"
mt={ 3 }
isDisabled={ !formApi.formState.isValid }
isLoading={ formApi.formState.isSubmitting }
loadingText="Send request"
w="min-content"
......@@ -127,6 +147,7 @@ const PublicTagsSubmitForm = ({ config, userInfo, onSubmitResult }: Props) => {
</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"
<FormFieldAddress<FormFields>
name={ `addresses.${ index }.hash` }
isRequired
placeholder="Smart contract / Address (0x...)"
size={{ base: 'md', lg: 'lg' }}
/>
<InputPlaceholder text="Smart contract / Address (0x...)" error={ error }/>
</FormControl>
</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"
<FormFieldText<FormFields>
name={ `tags.${ index }.name` }
placeholder="Tag (max 35 characters)"
isRequired
rules={{ maxLength: 35 }}
{ ...fieldProps }
/>
<InputPlaceholder text="Tag (max 35 characters)" error={ errors?.name }/>
</FormControl>
</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"
<FormFieldUrl<FormFields>
name={ `tags.${ index }.url` }
placeholder="Label URL"
{ ...fieldProps }
/>
<InputPlaceholder text="Label URL" error={ errors?.url }/>
</FormControl>
</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"
<FormFieldText<FormFields>
name={ `tags.${ index }.tooltipDescription` }
placeholder="Label description (max 80 characters)"
maxH="160px"
rules={{ maxLength: 80 }}
asComponent="Textarea"
{ ...fieldProps }
/>
<InputPlaceholder
text="Label description (max 80 characters)"
error={ errors?.tooltipDescription }
/>
</FormControl>
</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,31 +59,16 @@ 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' }
<FormFieldFancySelect<FormFields, `tags.${ number }.type`>
name={ `tags.${ index }.type` }
placeholder="Tag type"
isDisabled={ isDisabled }
options={ typeOptions }
isRequired
isAsync={ false }
isSearchable={ false }
components={ selectComponents }
/>
</FormControl>
);
}, [ isDisabled, isMobile, selectComponents, typeOptions ]);
return (
<Controller
name={ `tags.${ index }.type` }
control={ control }
render={ renderControl }
rules={{ required: true }}
/>
);
};
......
......@@ -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 }>
<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,18 +8,13 @@ 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 }/>;
......@@ -33,9 +28,6 @@ const PublicTagMenuItem = ({ className, hash, onBeforeClick, type }: ItemProps)
);
}
}
})();
return element;
};
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 (
<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,16 +13,19 @@ 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) {
const handleSubmit = React.useCallback(async() => {
try {
const url = buildUrl('api_v2_key');
await fetch(url, {
method: 'POST',
body: { recaptcha_response: token },
body: { recaptcha_v3_response: token },
credentials: 'include',
}, {
resource: 'api_v2_key',
......@@ -40,31 +43,25 @@ const AppErrorTooManyRequests = () => {
isClosable: true,
});
}
}, [ token, toast, fetch ]);
if (!config.services.reCaptchaV3.siteKey) {
throw new Error('reCAPTCHA V3 site key is not set');
}
}, [ toast, fetch ]);
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 }
<GoogleReCaptcha
onVerify={ handleReCaptchaChange }
refreshReCaptcha
/>
) }
</Box>
<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) {
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,30 +109,46 @@ 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 (
<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 }>
<TokenInfoFieldTokenName { ...fieldProps }/>
<TokenInfoFieldAddress { ...fieldProps }/>
<TokenInfoFieldRequesterName { ...fieldProps }/>
<TokenInfoFieldRequesterEmail { ...fieldProps }/>
<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>
<TokenInfoFieldProjectName { ...fieldProps }/>
<FormFieldText<Fields> name="project_name" placeholder="Project name" { ...fieldProps }/>
<TokenInfoFieldProjectSector { ...fieldProps } config={ configQuery.data.projectSectors }/>
<TokenInfoFieldProjectEmail { ...fieldProps }/>
<TokenInfoFieldProjectWebsite { ...fieldProps }/>
<TokenInfoFieldDocs { ...fieldProps }/>
<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 } trigger={ trigger }/>
<TokenInfoFieldIconUrl { ...fieldProps }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldProjectDescription { ...fieldProps }/>
<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>
......@@ -155,14 +164,21 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) =>
<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"/>
<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 }}>
<TokenInfoFieldPriceTicker { ...fieldProps } name="ticker_defi_llama" label="DefiLlama URL "/>
<FormFieldUrl<Fields> name="ticker_defi_llama" placeholder="DefiLlama URL" { ...fieldProps }/>
</GridItem>
<GridItem colSpan={{ base: 1, lg: 2 }}>
<TokenInfoFieldComment { ...fieldProps }/>
<FormFieldText<Fields>
name="comment"
placeholder="Comment"
maxH="160px"
rules={{ maxLength: 300 }}
asComponent="Textarea"
{ ...fieldProps }
/>
</GridItem>
</Grid>
<Button
......@@ -176,6 +192,7 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) =>
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 }
<FormFieldUrl<Fields>
name="icon_url"
placeholder={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` }
isReadOnly={ isReadOnly }
autoComplete="off"
required
size={ size }
{ ...previewUtils.input }
/>
<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' }
<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' }
<FormFieldFancySelect<Fields, 'project_sector'>
name="project_sector"
placeholder="Project industry"
isDisabled={ formState.isSubmitting }
options={ options }
isReadOnly={ isReadOnly }
error={ fieldState.error }
/>
);
}, [ isReadOnly, options, isMobile ]);
return (
<Controller
name="project_sector"
control={ control }
render={ renderControl }
/>
);
};
......
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,25 +150,28 @@ 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
/>
) : (
<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>
) }
</GoogleReCaptchaProvider>
{ /* ONLY FOR TEST PURPOSES */ }
<chakra.form noValidate onSubmit={ handleFormSubmit } display="none">
<chakra.input
......@@ -177,6 +180,13 @@ const TokenInstanceMetadataFetcher = ({ hash, id }: Props) => {
/>
<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>
) }
</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>
{ 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 control={ control }/>
<AddressFormNotifications/>
</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') }
<FormFieldCheckbox<Inputs, 'notification'>
name="notification"
label="Email notifications"
/>
</>
) }
<Box marginTop={ 8 }>
<Button
size="lg"
type="submit"
isLoading={ pending }
isDisabled={ !isDirty }
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