Commit ebb521ba authored by tom's avatar tom

Merge branch 'main' of github.com:tom2drum/block-scout into favicons-titles

parents 1b8aca40 67272a9c
...@@ -4,6 +4,8 @@ NEXT_PUBLIC_SENTRY_DSN=xxx ...@@ -4,6 +4,8 @@ NEXT_PUBLIC_SENTRY_DSN=xxx
SENTRY_ORG=block-scout SENTRY_ORG=block-scout
SENTRY_PROJECT=new-ui SENTRY_PROJECT=new-ui
SENTRY_AUTH_TOKEN=xxx SENTRY_AUTH_TOKEN=xxx
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
SENTRY_CSP_REPORT_URI=xxx
NEXT_PUBLIC_BLOCKSCOUT_VERSION=xxx NEXT_PUBLIC_BLOCKSCOUT_VERSION=xxx
NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
......
const getCspPolicy = require('../../lib/csp/getCspPolicy');
async function headers() {
return [
{
source: '/:path*',
headers: [
// security headers from here - https://nextjs.org/docs/advanced-features/security-headers
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Content-Security-Policy-Report-Only',
value: getCspPolicy(),
},
],
},
];
}
module.exports = headers;
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 9a7 7 0 1 1 7 7h-2v2h-2v2H9v2H2v-5.414l6.148-6.148A7.025 7.025 0 0 1 8 9Zm3 5h4a5 5 0 1 0-4.786-3.547l.174.573L4 17.414V20h3v-2h2v-2h2v-2Zm4-3a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z" fill="currentColor"/>
</svg>
\ No newline at end of file
<svg width="30" height="30" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M23.433 12.64a1.212 1.212 0 0 1-.857.354H6.212a1.212 1.212 0 0 1-1.12-.745 1.212 1.212 0 0 1 .266-1.321L8.994 7.29a1.212 1.212 0 0 1 1.71 1.71L9.14 10.57h13.436a1.212 1.212 0 0 1 .857 2.068ZM7.424 17.236h16.363a1.213 1.213 0 0 1 1.121.745 1.212 1.212 0 0 1-.266 1.321l-3.636 3.637a1.213 1.213 0 1 1-1.71-1.71l1.564-1.569H7.424a1.212 1.212 0 0 1 0-2.424Z"/></svg> <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
\ No newline at end of file <path fill-rule="evenodd" clip-rule="evenodd" d="M23.433 12.64a1.212 1.212 0 0 1-.857.354H6.212a1.212 1.212 0 0 1-1.12-.745 1.212 1.212 0 0 1 .266-1.321L8.994 7.29a1.212 1.212 0 0 1 1.71 1.71L9.14 10.57h13.436a1.212 1.212 0 0 1 .857 2.068ZM7.424 17.236h16.363a1.213 1.213 0 0 1 1.121.745 1.212 1.212 0 0 1-.266 1.321l-3.636 3.637a1.213 1.213 0 1 1-1.71-1.71l1.564-1.569H7.424a1.212 1.212 0 0 1 0-2.424Z" fill="currentColor"/>
</svg>
\ No newline at end of file
import * as Sentry from '@sentry/nextjs';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
...@@ -7,8 +8,7 @@ export default function getUrlWithNetwork(_req: NextApiRequest, path: string) { ...@@ -7,8 +8,7 @@ export default function getUrlWithNetwork(_req: NextApiRequest, path: string) {
const networkSubType = _req.cookies[cookies.NAMES.NETWORK_SUB_TYPE]; const networkSubType = _req.cookies[cookies.NAMES.NETWORK_SUB_TYPE];
if (!networkType || !networkSubType) { if (!networkType || !networkSubType) {
// eslint-disable-next-line no-console Sentry.captureException(new Error('Incorrect network'), { extra: { networkType, networkSubType } });
console.error(`Incorrect network: NETWORK_TYPE=${ networkType } NETWORK_SUB_TYPE=${ networkSubType }`);
} }
return `/${ networkType }/${ networkSubType }/${ path }`; return `/${ networkType }/${ networkSubType }/${ path }`;
......
import { withSentry } from '@sentry/nextjs';
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import fetch from 'lib/api/fetch'; import fetch from 'lib/api/fetch';
...@@ -5,28 +6,39 @@ import getUrlWithNetwork from 'lib/api/getUrlWithNetwork'; ...@@ -5,28 +6,39 @@ import getUrlWithNetwork from 'lib/api/getUrlWithNetwork';
type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE'; type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE';
export default function handler<TRes, TErrRes>(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) { export default function createHandler(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
return async(_req: NextApiRequest, res: NextApiResponse<TRes | TErrRes>) => { const handler = async(_req: NextApiRequest, res: NextApiResponse) => {
if (_req.method && allowedMethods.includes(_req.method as Methods)) { if (!_req.method || !allowedMethods.includes(_req.method as Methods)) {
const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD'; res.setHeader('Allow', allowedMethods);
res.status(405).end(`Method ${ _req.method } Not Allowed`);
return;
}
const url = getUrlWithNetwork(_req, `/api${ getUrl(_req) }`); const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD';
const response = await fetch(url, {
method: _req.method,
body: isBodyDisallowed ? undefined : _req.body,
});
if (response.status !== 200) { const url = getUrlWithNetwork(_req, `/api${ getUrl(_req) }`);
const error = await response.json() as { errors: TErrRes }; const response = await fetch(url, {
res.status(500).json(error?.errors || {} as TErrRes); method: _req.method,
return; body: isBodyDisallowed ? undefined : _req.body,
} });
const data = await response.json() as TRes; if (response.status === 200) {
const data = await response.json();
res.status(200).json(data); res.status(200).json(data);
} else { return;
res.setHeader('Allow', allowedMethods);
res.status(405).end(`Method ${ _req.method } Not Allowed`);
} }
let responseError;
try {
const error = await response.json() as { errors: unknown };
responseError = error?.errors || {};
} catch (error) {
responseError = { statusText: response.statusText, status: response.status };
}
res.status(500).json(responseError);
}; };
return withSentry(handler);
} }
import * as Sentry from '@sentry/nextjs';
export interface ErrorType<T> { export interface ErrorType<T> {
error?: T; error?: T;
status: Response['status']; status: Response['status'];
...@@ -13,10 +15,15 @@ export default function clientFetch<Success, Error>(path: string, init?: Request ...@@ -13,10 +15,15 @@ export default function clientFetch<Success, Error>(path: string, init?: Request
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
}), }),
() => Promise.reject({ () => {
status: response.status, const error = {
statusText: response.statusText, status: response.status,
}), statusText: response.statusText,
};
Sentry.captureException(new Error('Client fetch failed'), { extra: error, tags: { source: 'fetch' } });
return Promise.reject(error);
},
); );
} else { } else {
......
const parseNetworkConfig = require('../networks/parseNetworkConfig');
const KEY_WORDS = {
BLOB: 'blob:',
DATA: 'data:',
NONE: '\'none\'',
REPORT_SAMPLE: `'report-sample'`,
SELF: '\'self\'',
STRICT_DYNAMIC: `'strict-dynamic'`,
UNSAFE_INLINE: '\'unsafe-inline\'',
UNSAFE_EVAL: '\'unsafe-eval\'',
};
const MAIN_DOMAINS = [ '*.blockscout.com', 'blockscout.com' ];
const isDev = process.env.NODE_ENV === 'development';
function getNetworksExternalAssets() {
const icons = parseNetworkConfig()
.filter(({ icon }) => typeof icon === 'string')
.map(({ icon }) => new URL(icon));
return icons;
}
function makePolicyMap() {
const networkExternalAssets = getNetworksExternalAssets();
return {
'default-src': [
KEY_WORDS.NONE,
],
'connect-src': [
KEY_WORDS.SELF,
// webpack hmr in safari doesn't recognize localhost as 'self' for some reason
isDev ? 'ws://localhost:3000/_next/webpack-hmr' : '',
// client error monitoring
'sentry.io', '*.sentry.io',
],
'script-src': [
KEY_WORDS.SELF,
// next.js generates and rebuilds source maps in dev using eval()
// https://github.com/vercel/next.js/issues/14221#issuecomment-657258278
isDev ? KEY_WORDS.UNSAFE_EVAL : '',
...MAIN_DOMAINS,
// hash of ColorModeScript
'\'sha256-e7MRMmTzLsLQvIy1iizO1lXf7VWYoQ6ysj5fuUzvRwE=\'',
],
'style-src': [
KEY_WORDS.SELF,
...MAIN_DOMAINS,
// google fonts
'fonts.googleapis.com',
// yes, it is unsafe as it stands, but
// - we cannot use hashes because all styles are generated dynamically
// - we cannot use nonces since we are not following along SSR path
// - and still there is very small damage that can be cause by CSS-based XSS-attacks
// so we hope we are fine here till the first major incident :)
KEY_WORDS.UNSAFE_INLINE,
],
'img-src': [
KEY_WORDS.SELF,
KEY_WORDS.DATA,
...MAIN_DOMAINS,
// github avatars
'avatars.githubusercontent.com',
// network assets
...networkExternalAssets.map((url) => url.host),
],
'font-src': [
KEY_WORDS.DATA,
// google fonts
'*.gstatic.com',
'fonts.googleapis.com',
],
'object-src': [
KEY_WORDS.NONE,
],
'base-uri': [
KEY_WORDS.NONE,
],
'report-uri': [
process.env.SENTRY_CSP_REPORT_URI,
],
};
}
function getCspPolicy() {
const policyMap = makePolicyMap();
const policyString = Object.entries(policyMap)
.map(([ key, value ]) => {
if (!value || value.length === 0) {
return;
}
return [ key, value.join(' ') ].join(' ');
})
.filter(Boolean)
.join(';');
return policyString;
}
module.exports = getCspPolicy;
...@@ -10,6 +10,8 @@ import poaSokolIcon from 'icons/networks/poa-sokol.svg'; ...@@ -10,6 +10,8 @@ import poaSokolIcon from 'icons/networks/poa-sokol.svg';
import poaIcon from 'icons/networks/poa.svg'; import poaIcon from 'icons/networks/poa.svg';
import rskIcon from 'icons/networks/rsk.svg'; import rskIcon from 'icons/networks/rsk.svg';
import parseNetworkConfig from './parseNetworkConfig';
// will change later when we agree how to host network icons // will change later when we agree how to host network icons
const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGElement>>> = { const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGElement>>> = {
'xdai/mainnet': gnosisIcon, 'xdai/mainnet': gnosisIcon,
...@@ -24,15 +26,13 @@ const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGEleme ...@@ -24,15 +26,13 @@ const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGEleme
'artis/sigma1': artisIcon, 'artis/sigma1': artisIcon,
}; };
export const NETWORKS: Array<Network> = (() => { const NETWORKS: Array<Network> = (() => {
try { const networksFromConfig: Array<Network> = parseNetworkConfig();
const networksFromConfig: Array<Network> = JSON.parse(process.env.NEXT_PUBLIC_SUPPORTED_NETWORKS || '[]'); return networksFromConfig.map((network) => ({ ...network, icon: network.icon || ICONS[`${ network.type }/${ network.subType }`] }));
return networksFromConfig.map((network) => ({ ...network, icon: network.icon || ICONS[`${ network.type }/${ network.subType }`] }));
} catch (error) {
return [];
}
})(); })();
export default NETWORKS;
// for easy env creation // for easy env creation
// const FOR_CONFIG = [ // const FOR_CONFIG = [
// { // {
...@@ -112,21 +112,3 @@ export const NETWORKS: Array<Network> = (() => { ...@@ -112,21 +112,3 @@ export const NETWORKS: Array<Network> = (() => {
// group: 'other', // group: 'other',
// }, // },
// ]; // ];
export const ACCOUNT_ROUTES = [ '/watchlist', '/tag_address', '/tag_transaction', '/public_tags_request', '/api_key', '/custom_abi' ];
export function isAccountRoute(route: string) {
return ACCOUNT_ROUTES.includes(route);
}
export function getAvailablePaths() {
return NETWORKS.map(({ type, subType }) => ({ params: { network_type: type, network_sub_type: subType } }));
}
export function getNetworkTitle({ network_type: type, network_sub_type: subType }: {network_type: string; network_sub_type: string}) {
const currentNetwork = NETWORKS.find(n => n.type === type && n.subType === subType);
if (currentNetwork) {
return currentNetwork.name + (currentNetwork.shortName ? ` (${ currentNetwork.shortName })` : '') + ' Explorer';
}
return '';
}
import NETWORKS from './availableNetworks';
export default function getAvailablePaths() {
return NETWORKS.map(({ type, subType }) => ({ params: { network_type: type, network_sub_type: subType } }));
}
import NETWORKS from './availableNetworks';
export default function getNetworkTitle({ network_type: type, network_sub_type: subType }: {network_type: string; network_sub_type: string}) {
const currentNetwork = NETWORKS.find(n => n.type === type && n.subType === subType);
if (currentNetwork) {
return currentNetwork.name + (currentNetwork.shortName ? ` (${ currentNetwork.shortName })` : '') + ' Explorer';
}
return '';
}
export const ACCOUNT_ROUTES = [ '/watchlist', '/tag_address', '/tag_transaction', '/public_tags_request', '/api_key', '/custom_abi' ];
export default function isAccountRoute(route: string) {
return ACCOUNT_ROUTES.includes(route);
}
// should be CommonJS module since it used for next.config.js
function parseNetworkConfig() {
try {
return JSON.parse(process.env.NEXT_PUBLIC_SUPPORTED_NETWORKS || '[]');
} catch (error) {
return [];
}
}
module.exports = parseNetworkConfig;
...@@ -2,10 +2,19 @@ const { withSentryConfig } = require('@sentry/nextjs'); ...@@ -2,10 +2,19 @@ const { withSentryConfig } = require('@sentry/nextjs');
const withReactSvg = require('next-react-svg'); const withReactSvg = require('next-react-svg');
const path = require('path'); const path = require('path');
const headers = require('./configs/nextjs/headers');
const moduleExports = { const moduleExports = {
include: path.resolve(__dirname, 'icons'), include: path.resolve(__dirname, 'icons'),
reactStrictMode: true, reactStrictMode: true,
webpack(config) { webpack(config, { webpack }) {
config.plugins.push(
new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
__SENTRY_TRACING__: false,
}),
);
return config; return config;
}, },
async redirects() { async redirects() {
...@@ -17,6 +26,7 @@ const moduleExports = { ...@@ -17,6 +26,7 @@ const moduleExports = {
}, },
]; ];
}, },
headers,
output: 'standalone', output: 'standalone',
}; };
...@@ -30,6 +40,9 @@ const sentryWebpackPluginOptions = { ...@@ -30,6 +40,9 @@ const sentryWebpackPluginOptions = {
silent: true, // Suppresses all logs silent: true, // Suppresses all logs
// For all available options, see: // For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options. // https://github.com/getsentry/sentry-webpack-plugin#options.
deploy: {
env: process.env.VERCEL_ENV || process.env.NODE_ENV,
},
}; };
module.exports = withReactSvg(withSentryConfig(moduleExports, sentryWebpackPluginOptions)); module.exports = withReactSvg(withSentryConfig(moduleExports, sentryWebpackPluginOptions));
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import ApiKeys from 'ui/pages/ApiKeys'; import ApiKeys from 'ui/pages/ApiKeys';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import CustomAbi from 'ui/pages/CustomAbi'; import CustomAbi from 'ui/pages/CustomAbi';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PublicTags from 'ui/pages/PublicTags'; import PublicTags from 'ui/pages/PublicTags';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PrivateTags from 'ui/pages/PrivateTags'; import PrivateTags from 'ui/pages/PrivateTags';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import PrivateTags from 'ui/pages/PrivateTags'; import PrivateTags from 'ui/pages/PrivateTags';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f ...@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths, getNetworkTitle } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import WatchList from 'ui/pages/Watchlist'; import WatchList from 'ui/pages/Watchlist';
type PageParams = { type PageParams = {
......
...@@ -2,7 +2,7 @@ import type { NextPage, GetStaticPaths } from 'next'; ...@@ -2,7 +2,7 @@ import type { NextPage, GetStaticPaths } from 'next';
import Head from 'next/head'; import Head from 'next/head';
import React from 'react'; import React from 'react';
import { getAvailablePaths } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import MyProfile from 'ui/pages/MyProfile'; import MyProfile from 'ui/pages/MyProfile';
const MyProfilePage: NextPage = () => { const MyProfilePage: NextPage = () => {
......
...@@ -3,7 +3,7 @@ import type { NextPage, GetStaticPaths } from 'next'; ...@@ -3,7 +3,7 @@ import type { NextPage, GetStaticPaths } from 'next';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { getAvailablePaths } from 'lib/networks'; import getAvailablePaths from 'lib/networks/getAvailablePaths';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
const Home: NextPage = () => { const Home: NextPage = () => {
......
...@@ -7,16 +7,12 @@ import theme from 'theme'; ...@@ -7,16 +7,12 @@ import theme from 'theme';
class MyDocument extends Document { class MyDocument extends Document {
render() { render() {
return ( return (
<Html> <Html lang="en">
<Head> <Head>
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link
href="https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,500;0,600;1,400&display=swap"
rel="stylesheet"
/>
<link rel="icon" sizes="32x32" type="image/png" href="/favicon-32x32.png"/> <link rel="icon" sizes="32x32" type="image/png" href="/favicon-32x32.png"/>
<link rel="icon" sizes="16x16" type="image/png"href="/favicon-16x16.png"/> <link rel="icon" sizes="16x16" type="image/png"href="/favicon-16x16.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"/>
......
import type { ApiKeys, ApiKeyErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const apiKeysHandler = handler<ApiKeys, ApiKeyErrors>(() => '/account/v1/user/api_keys', [ 'GET', 'POST' ]); const apiKeysHandler = handler(() => '/account/v1/user/api_keys', [ 'GET', 'POST' ]);
export default apiKeysHandler; export default apiKeysHandler;
import type { CustomAbis, CustomAbiErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const customAbiHandler = handler<CustomAbis, CustomAbiErrors>(() => '/account/v1/user/custom_abis', [ 'GET', 'POST' ]); const customAbiHandler = handler(() => '/account/v1/user/custom_abis', [ 'GET', 'POST' ]);
export default customAbiHandler; export default customAbiHandler;
import type { AddressTags, AddressTagErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const addressHandler = handler<AddressTags, AddressTagErrors>(() => '/account/v1/user/tags/address', [ 'GET', 'POST' ]); const addressHandler = handler(() => '/account/v1/user/tags/address', [ 'GET', 'POST' ]);
export default addressHandler; export default addressHandler;
import type { TransactionTags, TransactionTagErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const transactionHandler = handler<TransactionTags, TransactionTagErrors>(() => '/account/v1/user/tags/transaction', [ 'GET', 'POST' ]); const transactionHandler = handler(() => '/account/v1/user/tags/transaction', [ 'GET', 'POST' ]);
export default transactionHandler; export default transactionHandler;
import type { UserInfo } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const profileHandler = handler<UserInfo, unknown>(() => '/account/v1/user/info', [ 'GET' ]); const profileHandler = handler(() => '/account/v1/user/info', [ 'GET' ]);
export default profileHandler; export default profileHandler;
import type { PublicTags, PublicTagErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const publicKeysHandler = handler<PublicTags, PublicTagErrors>(() => '/account/v1/user/public_tags', [ 'GET', 'POST' ]); const publicKeysHandler = handler(() => '/account/v1/user/public_tags', [ 'GET', 'POST' ]);
export default publicKeysHandler; export default publicKeysHandler;
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => { const getUrl = (req: NextApiRequest) => {
return `/account/v1/user/watchlist/${ req.query.id }`; return `/account/v1/user/watchlist/${ req.query.id }`;
}; };
const addressEditHandler = handler<WatchlistAddresses, WatchlistErrors>(getUrl, [ 'DELETE', 'PUT' ]); const addressEditHandler = handler(getUrl, [ 'DELETE', 'PUT' ]);
export default addressEditHandler; export default addressEditHandler;
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
import handler from 'lib/api/handler'; import handler from 'lib/api/handler';
const watchlistHandler = handler<WatchlistAddresses, WatchlistErrors>(() => '/account/v1/user/watchlist', [ 'GET', 'POST' ]); const watchlistHandler = handler(() => '/account/v1/user/watchlist', [ 'GET', 'POST' ]);
export default watchlistHandler; export default watchlistHandler;
...@@ -5,8 +5,10 @@ ...@@ -5,8 +5,10 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
const ENV = process.env.NEXT_PUBLIC_VERCEL_ENV || process.env.NODE_ENV;
Sentry.init({ Sentry.init({
environment: ENV,
dsn: SENTRY_DSN, dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control // Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
......
...@@ -5,8 +5,10 @@ ...@@ -5,8 +5,10 @@
import * as Sentry from '@sentry/nextjs'; import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
const ENV = process.env.VERCEL_ENV || process.env.NODE_ENV;
Sentry.init({ Sentry.init({
environment: ENV,
dsn: SENTRY_DSN, dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control // Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
......
import { alertAnatomy as parts } from '@chakra-ui/anatomy'; import { alertAnatomy as parts } from '@chakra-ui/anatomy';
import type { StyleFunctionProps } from '@chakra-ui/styled-system';
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system'; import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
import { getColor, mode, transparentize } from '@chakra-ui/theme-tools';
const { definePartsStyle, defineMultiStyleConfig } = const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(parts.keys); createMultiStyleConfigHelpers(parts.keys);
function getBg(props: StyleFunctionProps): string {
const { theme, colorScheme: c } = props;
const lightBg = getColor(theme, `${ c }.100`, c);
const darkBg = transparentize(`${ c }.200`, 0.16)(theme);
return mode(lightBg, darkBg)(props);
}
const baseStyle = definePartsStyle({ const baseStyle = definePartsStyle({
container: { container: {
borderRadius: 'md', borderRadius: 'md',
...@@ -12,8 +22,21 @@ const baseStyle = definePartsStyle({ ...@@ -12,8 +22,21 @@ const baseStyle = definePartsStyle({
}, },
}); });
const variantSubtle = definePartsStyle((props) => {
return {
container: {
bgColor: getBg(props),
},
};
});
const variants = {
subtle: variantSubtle,
};
const Alert = defineMultiStyleConfig({ const Alert = defineMultiStyleConfig({
baseStyle, baseStyle,
variants,
}); });
export default Alert; export default Alert;
...@@ -125,6 +125,9 @@ const variantFloating = definePartsStyle((props) => { ...@@ -125,6 +125,9 @@ const variantFloating = definePartsStyle((props) => {
margin: 0, margin: 0,
transformOrigin: 'top left', transformOrigin: 'top left',
transitionProperty: 'font-size, line-height, padding, top, background-color', transitionProperty: 'font-size, line-height, padding, top, background-color',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}, },
'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': { 'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': {
...activeLabelStyles, ...activeLabelStyles,
......
...@@ -35,6 +35,7 @@ const baseStyleHeader = defineStyle((props) => ({ ...@@ -35,6 +35,7 @@ const baseStyleHeader = defineStyle((props) => ({
const baseStyleBody = defineStyle({ const baseStyleBody = defineStyle({
padding: 0, padding: 0,
marginBottom: 8, marginBottom: 8,
flex: 'initial',
}); });
const baseStyleFooter = defineStyle({ const baseStyleFooter = defineStyle({
...@@ -70,16 +71,33 @@ const baseStyle = definePartsStyle((props) => ({ ...@@ -70,16 +71,33 @@ const baseStyle = definePartsStyle((props) => ({
const sizes = { const sizes = {
md: definePartsStyle({ md: definePartsStyle({
dialogContainer: {
height: '100%',
},
dialog: { dialog: {
maxW: '760px', maxW: '760px',
}, },
}), }),
full: definePartsStyle({ full: definePartsStyle({
dialogContainer: {
height: '100%',
},
dialog: { dialog: {
maxW: '100vw', maxW: '100vw',
minH: '100vh',
my: '0', my: '0',
borderRadius: '0', borderRadius: '0',
padding: '80px 16px 32px 16px',
height: '100%',
overflowY: 'scroll',
},
closeButton: {
top: 4,
right: 6,
width: 6,
height: 6,
},
header: {
mb: 6,
}, },
}), }),
}; };
......
...@@ -4,6 +4,12 @@ import { defineStyle, defineStyleConfig } from '@chakra-ui/styled-system'; ...@@ -4,6 +4,12 @@ import { defineStyle, defineStyleConfig } from '@chakra-ui/styled-system';
import getOutlinedFieldStyles from '../utils/getOutlinedFieldStyles'; import getOutlinedFieldStyles from '../utils/getOutlinedFieldStyles';
const sizes = { const sizes = {
md: defineStyle({
fontSize: 'md',
lineHeight: '20px',
h: '160px',
borderRadius: 'base',
}),
lg: defineStyle({ lg: defineStyle({
fontSize: 'md', fontSize: 'md',
lineHeight: '20px', lineHeight: '20px',
......
import React, { useCallback } from 'react';
import type { ApiKey } from 'types/api/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import ApiKeySnippet from 'ui/shared/ApiKeySnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: ApiKey;
onEditClick: (item: ApiKey) => void;
onDeleteClick: (item: ApiKey) => void;
}
const ApiKeyListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<AccountListItemMobile>
<ApiKeySnippet apiKey={ item.api_key } name={ item.name }/>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</AccountListItemMobile>
);
};
export default ApiKeyListItem;
import { import {
Tr, Tr,
Td, Td,
HStack,
Text,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { ApiKey } from 'types/api/account'; import type { ApiKey } from 'types/api/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import ApiKeySnippet from 'ui/shared/ApiKeySnippet';
import DeleteButton from 'ui/shared/DeleteButton'; import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import EditButton from 'ui/shared/EditButton';
interface Props { interface Props {
item: ApiKey; item: ApiKey;
...@@ -31,17 +28,10 @@ const ApiKeyTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -31,17 +28,10 @@ const ApiKeyTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return ( return (
<Tr alignItems="top" key={ item.api_key }> <Tr alignItems="top" key={ item.api_key }>
<Td> <Td>
<HStack> <ApiKeySnippet apiKey={ item.api_key } name={ item.name }/>
<Text fontSize="md" fontWeight={ 600 }>{ item.api_key }</Text>
<CopyToClipboard text={ item.api_key }/>
</HStack>
<Text fontSize="sm" marginTop={ 0.5 } variant="secondary">{ item.name }</Text>
</Td> </Td>
<Td> <Td>
<HStack spacing={ 6 }> <TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -28,7 +28,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -28,7 +28,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text display="flex">API key for<Text fontWeight="600" whiteSpace="pre">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text> <Text> API key for <Text fontWeight="600" as="span">{ ` "${ data.name || 'name' }" ` }</Text> will be deleted </Text>
); );
}, [ data.name ]); }, [ data.name ]);
......
...@@ -28,7 +28,7 @@ const Header = () => { ...@@ -28,7 +28,7 @@ const Header = () => {
width="100%" width="100%"
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
zIndex={ 10 } zIndex="sticky"
> >
<Burger/> <Burger/>
<NetworkLogo/> <NetworkLogo/>
......
...@@ -9,10 +9,10 @@ import useIsMobile from 'lib/hooks/useIsMobile'; ...@@ -9,10 +9,10 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps'; import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
const SOCIAL_LINKS = [ const SOCIAL_LINKS = [
{ link: process.env.NEXT_PUBLIC_FOOTER_GITHUB_LINK, icon: ghIcon }, { link: process.env.NEXT_PUBLIC_FOOTER_GITHUB_LINK, icon: ghIcon, label: 'Github link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_TWITTER_LINK, icon: twIcon }, { link: process.env.NEXT_PUBLIC_FOOTER_TWITTER_LINK, icon: twIcon, label: 'Twitter link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK, icon: tgIcon }, { link: process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK, icon: tgIcon, label: 'Telegram link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_STAKING_LINK, icon: statsIcon }, { link: process.env.NEXT_PUBLIC_FOOTER_STAKING_LINK, icon: statsIcon, label: 'Staking analytic link' },
].filter(({ link }) => link !== undefined); ].filter(({ link }) => link !== undefined);
const BLOCKSCOUT_VERSION = process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION; const BLOCKSCOUT_VERSION = process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION;
...@@ -51,7 +51,7 @@ const NavFooter = ({ isCollapsed }: Props) => { ...@@ -51,7 +51,7 @@ const NavFooter = ({ isCollapsed }: Props) => {
<Stack direction={ isCollapsed ? 'column' : 'row' }> <Stack direction={ isCollapsed ? 'column' : 'row' }>
{ SOCIAL_LINKS.map(sl => { { SOCIAL_LINKS.map(sl => {
return ( return (
<Link href={ sl.link } key={ sl.link } variant="secondary" w={ 5 } h={ 5 }> <Link href={ sl.link } key={ sl.link } variant="secondary" w={ 5 } h={ 5 } aria-label={ sl.label }>
<Icon as={ sl.icon } boxSize={ 5 }/> <Icon as={ sl.icon } boxSize={ 5 }/>
</Link> </Link>
); );
......
...@@ -24,6 +24,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => { ...@@ -24,6 +24,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => {
overflow="hidden" overflow="hidden"
onClick={ onClick } onClick={ onClick }
{ ...getDefaultTransitionProps({ transitionProperty: 'width' }) } { ...getDefaultTransitionProps({ transitionProperty: 'width' }) }
aria-label="Link to main page"
> >
<Icon <Icon
as={ logoIcon } as={ logoIcon }
......
...@@ -26,6 +26,8 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.Fo ...@@ -26,6 +26,8 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.Fo
borderRadius="base" borderRadius="base"
backgroundColor={ isActive ? bgColorMobile : 'none' } backgroundColor={ isActive ? bgColorMobile : 'none' }
onClick={ onClick } onClick={ onClick }
aria-label="Network menu"
aria-roledescription="menu"
> >
<Icon <Icon
as={ networksIcon } as={ networksIcon }
......
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import type { NetworkGroup } from 'types/networks'; import type { NetworkGroup } from 'types/networks';
import { NETWORKS } from 'lib/networks'; import NETWORKS from 'lib/networks/availableNetworks';
import NetworkMenuLink from './NetworkMenuLink'; import NetworkMenuLink from './NetworkMenuLink';
......
...@@ -5,7 +5,7 @@ import React from 'react'; ...@@ -5,7 +5,7 @@ import React from 'react';
import type { NetworkGroup } from 'types/networks'; import type { NetworkGroup } from 'types/networks';
import { NETWORKS } from 'lib/networks'; import NETWORKS from 'lib/networks/availableNetworks';
import NetworkMenuLink from './NetworkMenuLink'; import NetworkMenuLink from './NetworkMenuLink';
......
...@@ -6,7 +6,7 @@ import type { Network } from 'types/networks'; ...@@ -6,7 +6,7 @@ import type { Network } from 'types/networks';
import checkIcon from 'icons/check.svg'; import checkIcon from 'icons/check.svg';
import placeholderIcon from 'icons/networks/placeholder.svg'; import placeholderIcon from 'icons/networks/placeholder.svg';
import { isAccountRoute } from 'lib/networks'; import isAccountRoute from 'lib/networks/isAccountRoute';
import useColors from './useColors'; import useColors from './useColors';
......
...@@ -56,6 +56,7 @@ const SearchBarMobile = ({ onChange, onSubmit }: Props) => { ...@@ -56,6 +56,7 @@ const SearchBarMobile = ({ onChange, onSubmit }: Props) => {
position="fixed" position="fixed"
top="56px" top="56px"
left="0" left="0"
zIndex="docked"
bgColor={ bgColor } bgColor={ bgColor }
transform={ isVisible ? 'translateY(0)' : 'translateY(-100%)' } transform={ isVisible ? 'translateY(0)' : 'translateY(-100%)' }
transitionProperty="transform" transitionProperty="transform"
......
...@@ -128,6 +128,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -128,6 +128,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Textarea <Textarea
{ ...field } { ...field }
size="lg" size="lg"
minH="300px"
isInvalid={ Boolean(errors.abi) } isInvalid={ Boolean(errors.abi) }
/> />
<FormLabel>{ getPlaceholderWithError(`Custom ABI [{...}] (JSON format)`, errors.abi?.message) }</FormLabel> <FormLabel>{ getPlaceholderWithError(`Custom ABI [{...}] (JSON format)`, errors.abi?.message) }</FormLabel>
......
import React, { useCallback } from 'react';
import type { CustomAbi } from 'types/api/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: CustomAbi;
onEditClick: (item: CustomAbi) => void;
onDeleteClick: (item: CustomAbi) => void;
}
const CustomAbiListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<AccountListItemMobile>
<AddressSnippet address={ item.contract_address_hash } subtitle={ item.name }/>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</AccountListItemMobile>
);
};
export default React.memo(CustomAbiListItem);
import { import {
Tr, Tr,
Td, Td,
HStack,
Text,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { CustomAbi } from 'types/api/account'; import type { CustomAbi } from 'types/api/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import AddressSnippet from 'ui/shared/AddressSnippet';
import DeleteButton from 'ui/shared/DeleteButton'; import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import EditButton from 'ui/shared/EditButton';
interface Props { interface Props {
item: CustomAbi; item: CustomAbi;
...@@ -31,17 +28,10 @@ const CustomAbiTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -31,17 +28,10 @@ const CustomAbiTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return ( return (
<Tr alignItems="top" key={ item.id }> <Tr alignItems="top" key={ item.id }>
<Td> <Td>
<HStack> <AddressSnippet address={ item.contract_address_hash } subtitle={ item.name }/>
<Text fontSize="md" fontWeight={ 600 }>{ item.contract_address_hash }</Text>
<CopyToClipboard text={ item.contract_address_hash }/>
</HStack>
<Text fontSize="sm" marginTop={ 0.5 } variant="secondary">{ item.name }</Text>
</Td> </Td>
<Td> <Td>
<HStack spacing={ 6 }> <TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -28,7 +28,7 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -28,7 +28,7 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text display="flex">Custom ABI for<Text fontWeight="600" whiteSpace="pre">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text> <Text>Custom ABI for<Text fontWeight="600" as="span">{ ` "${ data.name || 'name' }" ` }</Text>will be deleted</Text>
); );
}, [ data.name ]); }, [ data.name ]);
......
import { Box, Button, HStack, Link, Text, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Stack, Link, Text, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { ApiKey, ApiKeys } from 'types/api/account'; import type { ApiKey, ApiKeys } from 'types/api/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal'; import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal';
import ApiKeyListItem from 'ui/apiKey/ApiKeyTable/ApiKeyListItem';
import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable'; import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal'; import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import AccountPageHeader from 'ui/shared/AccountPageHeader'; import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert'; import DataFetchAlert from '../shared/DataFetchAlert';
...@@ -20,6 +24,7 @@ const DATA_LIMIT = 3; ...@@ -20,6 +24,7 @@ const DATA_LIMIT = 3;
const ApiKeysPage: React.FC = () => { const ApiKeysPage: React.FC = () => {
const apiKeyModalProps = useDisclosure(); const apiKeyModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>(); const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>(); const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>();
...@@ -47,40 +52,59 @@ const ApiKeysPage: React.FC = () => { ...@@ -47,40 +52,59 @@ const ApiKeysPage: React.FC = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space } Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<Link href="#">“How to use a Blockscout API key”</Link>. <Link href="#">“How to use a Blockscout API key”</Link>.
</Text> </AccountPageDescription>
); );
const content = (() => { const content = (() => {
if (isLoading && !data) { if (isLoading && !data) {
return ( const loader = isMobile ? <SkeletonAccountMobile/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/> <SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="48px" width="156px" marginTop={ 8 }/> <Skeleton height="48px" width="156px" marginTop={ 8 }/>
</> </>
); );
return (
<>
{ description }
{ loader }
</>
);
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const list = isMobile ? (
<Box>
{ data.map((item) => (
<ApiKeyListItem
item={ item }
key={ item.api_key }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
)) }
</Box>
) : (
<ApiKeyTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
limit={ DATA_LIMIT }
/>
);
const canAdd = data.length < DATA_LIMIT; const canAdd = data.length < DATA_LIMIT;
return ( return (
<> <>
{ description } { description }
{ Boolean(data.length) && ( { Boolean(data.length) && list }
<ApiKeyTable <Stack marginTop={ 8 } spacing={ 5 } direction={{ base: 'column', lg: 'row' }}>
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
limit={ DATA_LIMIT }
/>
) }
<HStack marginTop={ 8 } spacing={ 5 }>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
...@@ -94,7 +118,7 @@ const ApiKeysPage: React.FC = () => { ...@@ -94,7 +118,7 @@ const ApiKeysPage: React.FC = () => {
{ `You have added the maximum number of API keys (${ DATA_LIMIT }). Contact us to request additional keys.` } { `You have added the maximum number of API keys (${ DATA_LIMIT }). Contact us to request additional keys.` }
</Text> </Text>
) } ) }
</HStack> </Stack>
<ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/> <ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/>
{ deleteModalData && <DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> } { deleteModalData && <DeleteApiKeyModal { ...deleteModalProps } onClose={ onDeleteModalClose } data={ deleteModalData }/> }
</> </>
......
import { Box, Button, HStack, Text, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, HStack, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account'; import type { CustomAbi, CustomAbis } from 'types/api/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal'; import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import CustomAbiListItem from 'ui/customAbi/CustomAbiTable/CustomAbiListItem';
import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable'; import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal'; import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import AccountPageHeader from 'ui/shared/AccountPageHeader'; import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert'; import DataFetchAlert from '../shared/DataFetchAlert';
...@@ -17,6 +21,7 @@ import DataFetchAlert from '../shared/DataFetchAlert'; ...@@ -17,6 +21,7 @@ import DataFetchAlert from '../shared/DataFetchAlert';
const CustomAbiPage: React.FC = () => { const CustomAbiPage: React.FC = () => {
const customAbiModalProps = useDisclosure(); const customAbiModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>(); const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>();
const [ deleteModalData, setDeleteModalData ] = useState<CustomAbi>(); const [ deleteModalData, setDeleteModalData ] = useState<CustomAbi>();
...@@ -44,36 +49,55 @@ const CustomAbiPage: React.FC = () => { ...@@ -44,36 +49,55 @@ const CustomAbiPage: React.FC = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
Add custom ABIs for any contract and access when logged into your account. Helpful for debugging, functional testing and contract interaction. Add custom ABIs for any contract and access when logged into your account. Helpful for debugging, functional testing and contract interaction.
</Text> </AccountPageDescription>
); );
const content = (() => { const content = (() => {
if (isLoading && !data) { if (isLoading && !data) {
return ( const loader = isMobile ? <SkeletonAccountMobile/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/> <SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/> <Skeleton height="44px" width="156px" marginTop={ 8 }/>
</> </>
); );
return (
<>
{ description }
{ loader }
</>
);
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
return ( const list = isMobile ? (
<> <Box>
{ description } { data.map((item) => (
{ data.length > 0 && ( <CustomAbiListItem
<CustomAbiTable item={ item }
data={ data } key={ item.id }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
/> />
) } )) }
</Box>
) : (
<CustomAbiTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ data.length > 0 && list }
<HStack marginTop={ 8 } spacing={ 5 }> <HStack marginTop={ 8 } spacing={ 5 }>
<Button <Button
variant="primary" variant="primary"
......
...@@ -38,7 +38,7 @@ const PrivateTags = ({ tab }: Props) => { ...@@ -38,7 +38,7 @@ const PrivateTags = ({ tab }: Props) => {
<Box h="100%"> <Box h="100%">
<AccountPageHeader text="Private tags"/> <AccountPageHeader text="Private tags"/>
<Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ onChangeTab } defaultIndex={ TABS.indexOf(tab) }> <Tabs variant="soft-rounded" colorScheme="blue" isLazy onChange={ onChangeTab } defaultIndex={ TABS.indexOf(tab) }>
<TabList marginBottom={ 8 }> <TabList marginBottom={{ base: 6, lg: 8 }}>
<Tab>Address</Tab> <Tab>Address</Tab>
<Tab>Transaction</Tab> <Tab>Transaction</Tab>
</TabList> </TabList>
......
import { Box } from '@chakra-ui/react'; import { ArrowBackIcon } from '@chakra-ui/icons';
import { Box, Link, Text } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { animateScroll } from 'react-scroll'; import { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account'; import type { PublicTag } from 'types/api/account';
import useIsMobile from 'lib/hooks/useIsMobile';
import useToast from 'lib/hooks/useToast'; import useToast from 'lib/hooks/useToast';
import PublicTagsData from 'ui/publicTags/PublicTagsData'; import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm'; import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
...@@ -24,6 +26,7 @@ const PublicTagsComponent: React.FC = () => { ...@@ -24,6 +26,7 @@ const PublicTagsComponent: React.FC = () => {
const [ formData, setFormData ] = useState<PublicTag>(); const [ formData, setFormData ] = useState<PublicTag>();
const toast = useToast(); const toast = useToast();
const isMobile = useIsMobile();
const showToast = useCallback((action: TToastAction) => { const showToast = useCallback((action: TToastAction) => {
toast({ toast({
...@@ -59,6 +62,7 @@ const PublicTagsComponent: React.FC = () => { ...@@ -59,6 +62,7 @@ const PublicTagsComponent: React.FC = () => {
}, [ showToast ]); }, [ showToast ]);
const onTagDelete = useCallback(() => showToast('removed'), [ showToast ]); const onTagDelete = useCallback(() => showToast('removed'), [ showToast ]);
const onGoBack = useCallback(() => setScreen('data'), [ ]);
let content; let content;
let header; let header;
...@@ -74,6 +78,12 @@ const PublicTagsComponent: React.FC = () => { ...@@ -74,6 +78,12 @@ const PublicTagsComponent: React.FC = () => {
return ( return (
<Page> <Page>
<Box h="100%"> <Box h="100%">
{ isMobile && screen === 'form' && (
<Link display="inline-flex" alignItems="center" mb={ 6 } onClick={ onGoBack }>
<ArrowBackIcon w={ 6 } h={ 6 }/>
<Text variant="inherit" fontSize="sm" ml={ 2 }>Public tags</Text>
</Link>
) }
<AccountPageHeader text={ header }/> <AccountPageHeader text={ header }/>
{ content } { content }
</Box> </Box>
......
import { Box, Button, Text, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { TWatchlist, TWatchlistItem } from 'types/client/account'; import type { TWatchlist, TWatchlistItem } from 'types/client/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import AccountPageHeader from 'ui/shared/AccountPageHeader'; import AccountPageHeader from 'ui/shared/AccountPageHeader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page'; import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from 'ui/watchlist/AddressModal/AddressModal'; import AddressModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal'; import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
import WatchListItem from 'ui/watchlist/WatchlistTable/WatchListItem';
import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable'; import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => { const WatchList: React.FC = () => {
...@@ -19,6 +23,7 @@ const WatchList: React.FC = () => { ...@@ -19,6 +23,7 @@ const WatchList: React.FC = () => {
const addressModalProps = useDisclosure(); const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ addressModalData, setAddressModalData ] = useState<TWatchlistItem>(); const [ addressModalData, setAddressModalData ] = useState<TWatchlistItem>();
const [ deleteModalData, setDeleteModalData ] = useState<TWatchlistItem>(); const [ deleteModalData, setDeleteModalData ] = useState<TWatchlistItem>();
...@@ -44,32 +49,52 @@ const WatchList: React.FC = () => { ...@@ -44,32 +49,52 @@ const WatchList: React.FC = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
An email notification can be sent to you when an address on your watch list sends or receives any transactions. An email notification can be sent to you when an address on your watch list sends or receives any transactions.
</Text> </AccountPageDescription>
); );
let content; let content;
if (isLoading && !data) { if (isLoading && !data) {
content = ( const loader = isMobile ? <SkeletonAccountMobile showFooterSlot/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/> <SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/> <Skeleton height="44px" width="156px" marginTop={ 8 }/>
</> </>
); );
content = (
<>
{ description }
{ loader }
</>
);
} else if (isError) { } else if (isError) {
content = <DataFetchAlert/>; content = <DataFetchAlert/>;
} else { } else {
content = ( const list = isMobile ? (
<> <Box>
{ Boolean(data?.length) && ( { data.map((item) => (
<WatchlistTable <WatchListItem
data={ data } item={ item }
key={ item.address_hash }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
/> />
) } )) }
</Box>
) : (
<WatchlistTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
content = (
<>
{ description }
{ Boolean(data?.length) && list }
<Box marginTop={ 8 }> <Box marginTop={ 8 }>
<Button <Button
variant="primary" variant="primary"
......
import { Tag, Flex, HStack, Text } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { AddressTag } from 'types/api/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: AddressTag;
onEditClick: (data: AddressTag) => void;
onDeleteClick: (data: AddressTag) => void;
}
const AddressTagListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<AccountListItemMobile>
<Flex alignItems="flex-start" flexDirection="column" maxW="100%">
<AddressSnippet address={ item.address_hash }/>
<HStack spacing={ 3 } mt={ 4 }>
<Text fontSize="sm" fontWeight={ 500 }>Private tag</Text>
<Tag variant="gray" lineHeight="24px">
{ item.name }
</Tag>
</HStack>
</Flex>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</AccountListItemMobile>
);
};
export default React.memo(AddressTagListItem);
...@@ -2,16 +2,13 @@ import { ...@@ -2,16 +2,13 @@ import {
Tag, Tag,
Tr, Tr,
Td, Td,
HStack,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { AddressTag } from 'types/api/account'; import type { AddressTag } from 'types/api/account';
import AddressIcon from 'ui/shared/AddressIcon'; import AddressSnippet from 'ui/shared/AddressSnippet';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip'; import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props { interface Props {
...@@ -32,10 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -32,10 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return ( return (
<Tr alignItems="top" key={ item.id }> <Tr alignItems="top" key={ item.id }>
<Td> <Td>
<HStack spacing={ 4 }> <AddressSnippet address={ item.address_hash }/>
<AddressIcon address={ item.address_hash }/>
<AddressLinkWithTooltip address={ item.address_hash }/>
</HStack>
</Td> </Td>
<Td> <Td>
<TruncatedTextTooltip label={ item.name }> <TruncatedTextTooltip label={ item.name }>
...@@ -45,10 +39,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -45,10 +39,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</TruncatedTextTooltip> </TruncatedTextTooltip>
</Td> </Td>
<Td> <Td>
<HStack spacing={ 6 }> <TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -37,7 +37,7 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type }) ...@@ -37,7 +37,7 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type })
const renderText = useCallback(() => { const renderText = useCallback(() => {
return ( return (
<Text display="flex">Tag<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag || 'tag' }" ` }</Text>will be deleted</Text> <Text>Tag<Text fontWeight="600" as="span">{ ` "${ tag || 'tag' }" ` }</Text>will be deleted</Text>
); );
}, [ tag ]); }, [ tag ]);
......
import { Box, Button, Text, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { AddressTags, AddressTag } from 'types/api/account'; import type { AddressTags, AddressTag } from 'types/api/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from './AddressModal/AddressModal'; import AddressModal from './AddressModal/AddressModal';
import AddressTagListItem from './AddressTagTable/AddressTagListItem';
import AddressTagTable from './AddressTagTable/AddressTagTable'; import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal'; import DeletePrivateTagModal from './DeletePrivateTagModal';
...@@ -18,6 +22,7 @@ const PrivateAddressTags = () => { ...@@ -18,6 +22,7 @@ const PrivateAddressTags = () => {
const addressModalProps = useDisclosure(); const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ addressModalData, setAddressModalData ] = useState<AddressTag>(); const [ addressModalData, setAddressModalData ] = useState<AddressTag>();
const [ deleteModalData, setDeleteModalData ] = useState<AddressTag>(); const [ deleteModalData, setDeleteModalData ] = useState<AddressTag>();
...@@ -43,36 +48,55 @@ const PrivateAddressTags = () => { ...@@ -43,36 +48,55 @@ const PrivateAddressTags = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
Use private transaction tags to label any transactions of interest. Use private address tags to track any addresses of interest.
Private tags are saved in your account and are only visible when you are logged in. Private tags are saved in your account and are only visible when you are logged in.
</Text> </AccountPageDescription>
); );
if (isLoading && !addressTagsData) { if (isLoading && !addressTagsData) {
return ( const loader = isMobile ? <SkeletonAccountMobile/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '60%', '40%', '108px' ] }/> <SkeletonTable columns={ [ '60%', '40%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/> <Skeleton height="44px" width="156px" marginTop={ 8 }/>
</> </>
); );
return (
<>
{ description }
{ loader }
</>
);
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
return ( const list = isMobile ? (
<> <Box>
{ description } { addressTagsData.map((item: AddressTag) => (
{ Boolean(addressTagsData?.length) && ( <AddressTagListItem
<AddressTagTable item={ item }
data={ addressTagsData } key={ item.id }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
/> />
) } )) }
</Box>
) : (
<AddressTagTable
data={ addressTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ Boolean(addressTagsData?.length) && list }
<Box marginTop={ 8 }> <Box marginTop={ 8 }>
<Button <Button
variant="primary" variant="primary"
......
import { Box, Button, Skeleton, Text, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { TransactionTags, TransactionTag } from 'types/api/account'; import type { TransactionTags, TransactionTag } from 'types/api/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePrivateTagModal from './DeletePrivateTagModal'; import DeletePrivateTagModal from './DeletePrivateTagModal';
import TransactionModal from './TransactionModal/TransactionModal'; import TransactionModal from './TransactionModal/TransactionModal';
import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem';
import TransactionTagTable from './TransactionTagTable/TransactionTagTable'; import TransactionTagTable from './TransactionTagTable/TransactionTagTable';
const PrivateTransactionTags = () => { const PrivateTransactionTags = () => {
...@@ -18,6 +22,7 @@ const PrivateTransactionTags = () => { ...@@ -18,6 +22,7 @@ const PrivateTransactionTags = () => {
const transactionModalProps = useDisclosure(); const transactionModalProps = useDisclosure();
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ transactionModalData, setTransactionModalData ] = useState<TransactionTag>(); const [ transactionModalData, setTransactionModalData ] = useState<TransactionTag>();
const [ deleteModalData, setDeleteModalData ] = useState<TransactionTag>(); const [ deleteModalData, setDeleteModalData ] = useState<TransactionTag>();
...@@ -43,36 +48,55 @@ const PrivateTransactionTags = () => { ...@@ -43,36 +48,55 @@ const PrivateTransactionTags = () => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
Use private transaction tags to label any transactions of interest. Use private transaction tags to label any transactions of interest.
Private tags are saved in your account and are only visible when you are logged in. Private tags are saved in your account and are only visible when you are logged in.
</Text> </AccountPageDescription>
); );
if (isLoading && !transactionTagsData) { if (isLoading && !transactionTagsData) {
return ( const loader = isMobile ? <SkeletonAccountMobile/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '75%', '25%', '108px' ] }/> <SkeletonTable columns={ [ '75%', '25%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/> <Skeleton height="44px" width="156px" marginTop={ 8 }/>
</> </>
); );
return (
<>
{ description }
{ loader }
</>
);
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
return ( const list = isMobile ? (
<> <Box>
{ description } { transactionTagsData.map((item) => (
{ Boolean(transactionTagsData.length) && ( <TransactionTagListItem
<TransactionTagTable item={ item }
data={ transactionTagsData } key={ item.id }
onDeleteClick={ onDeleteClick } onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick } onEditClick={ onEditClick }
/> />
) } )) }
</Box>
) : (
<TransactionTagTable
data={ transactionTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ Boolean(transactionTagsData.length) && list }
<Box marginTop={ 8 }> <Box marginTop={ 8 }>
<Button <Button
variant="primary" variant="primary"
......
import { Tag, HStack, Text, Flex } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { TransactionTag } from 'types/api/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TransactionSnippet from 'ui/shared/TransactionSnippet';
interface Props {
item: TransactionTag;
onEditClick: (data: TransactionTag) => void;
onDeleteClick: (data: TransactionTag) => void;
}
const TransactionTagListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<AccountListItemMobile>
<Flex alignItems="flex-start" flexDirection="column" maxW="100%">
<TransactionSnippet hash={ item.transaction_hash }/>
<HStack spacing={ 3 } mt={ 4 }>
<Text fontSize="sm" fontWeight={ 500 }>Private tag</Text>
<Tag variant="gray" lineHeight="24px">
{ item.name }
</Tag>
</HStack>
</Flex>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</AccountListItemMobile>
);
};
export default React.memo(TransactionTagListItem);
...@@ -2,16 +2,14 @@ import { ...@@ -2,16 +2,14 @@ import {
Tag, Tag,
Tr, Tr,
Td, Td,
HStack,
Tooltip, Tooltip,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { TransactionTag } from 'types/api/account'; import type { TransactionTag } from 'types/api/account';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip'; import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import DeleteButton from 'ui/shared/DeleteButton'; import TransactionSnippet from 'ui/shared/TransactionSnippet';
import EditButton from 'ui/shared/EditButton';
interface Props { interface Props {
item: TransactionTag; item: TransactionTag;
...@@ -19,7 +17,7 @@ interface Props { ...@@ -19,7 +17,7 @@ interface Props {
onDeleteClick: (data: TransactionTag) => void; onDeleteClick: (data: TransactionTag) => void;
} }
const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { const TransactionTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => { const onItemEditClick = useCallback(() => {
return onEditClick(item); return onEditClick(item);
}, [ item, onEditClick ]); }, [ item, onEditClick ]);
...@@ -31,7 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -31,7 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return ( return (
<Tr alignItems="top" key={ item.id }> <Tr alignItems="top" key={ item.id }>
<Td> <Td>
<AddressLinkWithTooltip address={ item.transaction_hash }/> <TransactionSnippet hash={ item.transaction_hash }/>
</Td> </Td>
<Td> <Td>
<Tooltip label={ item.name }> <Tooltip label={ item.name }>
...@@ -41,13 +39,10 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -41,13 +39,10 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</Tooltip> </Tooltip>
</Td> </Td>
<Td> <Td>
<HStack spacing={ 6 }> <TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td> </Td>
</Tr> </Tr>
); );
}; };
export default AddressTagTableItem; export default TransactionTagTableItem;
import { Flex, Text, FormControl, FormLabel, Textarea } from '@chakra-ui/react'; import { Box, Text, FormControl, FormLabel, Textarea, useColorModeValue } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
...@@ -22,6 +22,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -22,6 +22,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
const tags = data.tags.split(';'); const tags = data.tags.split(';');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const deleteApiKey = useCallback(() => { const deleteApiKey = useCallback(() => {
const body = JSON.stringify({ remove_reason: reason }); const body = JSON.stringify({ remove_reason: reason });
...@@ -44,9 +45,9 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -44,9 +45,9 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
if (tags.length === 1) { if (tags.length === 1) {
text = ( text = (
<> <>
<Text display="flex">Public tag</Text> <Text display="inline" as="span">Public tag</Text>
<Text fontWeight="600" whiteSpace="pre">{ ` "${ tags[0] }" ` }</Text> <Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tags[0] }" ` }</Text>
<Text>will be removed.</Text> <Text as="span">will be removed.</Text>
</> </>
); );
} }
...@@ -54,29 +55,29 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -54,29 +55,29 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
const tagsText: Array<JSX.Element | string> = []; const tagsText: Array<JSX.Element | string> = [];
tags.forEach((tag, index) => { tags.forEach((tag, index) => {
if (index < tags.length - 2) { if (index < tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }"` }</Text>); tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }"` }</Text>);
tagsText.push(','); tagsText.push(',');
} }
if (index === tags.length - 2) { if (index === tags.length - 2) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }" ` }</Text>); tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
tagsText.push('and'); tagsText.push('and');
} }
if (index === tags.length - 1) { if (index === tags.length - 1) {
tagsText.push(<Text fontWeight="600" whiteSpace="pre">{ ` "${ tag }" ` }</Text>); tagsText.push(<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tag }" ` }</Text>);
} }
}); });
text = ( text = (
<> <>
<Text>Public tags</Text>{ tagsText }<Text>will be removed.</Text> <Text as="span">Public tags</Text>{ tagsText }<Text as="span">will be removed.</Text>
</> </>
); );
} }
return ( return (
<> <>
<Flex marginBottom={ 12 }> <Box marginBottom={ 12 }>
{ text } { text }
</Flex> </Box>
<FormControl variant="floating" id="tag-delete"> <FormControl variant="floating" id="tag-delete" backgroundColor={ formBackgroundColor }>
<Textarea <Textarea
size="lg" size="lg"
value={ reason } value={ reason }
...@@ -86,7 +87,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete ...@@ -86,7 +87,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
</FormControl> </FormControl>
</> </>
); );
}, [ tags, reason, onFieldChange ]); }, [ tags, reason, onFieldChange, formBackgroundColor ]);
return ( return (
<DeleteModal <DeleteModal
......
import { Tag, VStack, Text, HStack } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { PublicTag } from 'types/api/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props {
item: PublicTag;
onEditClick: (data: PublicTag) => void;
onDeleteClick: (data: PublicTag) => void;
}
const PublicTagListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
return (
<AccountListItemMobile>
<VStack spacing={ 3 } alignItems="flex-start" maxW="100%">
<VStack spacing={ 4 } alignItems="unset" maxW="100%">
{ item.addresses.map((address) => <AddressSnippet key={ address } address={ address }/>) }
</VStack>
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Public tags</Text>
<HStack spacing={ 2 } alignItems="baseline">
{ item.tags.split(';').map((tag) => {
return (
<TruncatedTextTooltip label={ tag } key={ tag }>
<Tag variant="gray" lineHeight="24px">
{ tag }
</Tag>
</TruncatedTextTooltip>
);
}) }
</HStack>
</HStack>
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Status</Text>
<Text fontSize="sm" variant="secondary">Submitted</Text>
</HStack>
</VStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</AccountListItemMobile>
);
};
export default React.memo(PublicTagListItem);
import { import {
Box,
Tag, Tag,
Tr, Tr,
Td, Td,
HStack,
VStack, VStack,
Text, Text,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
...@@ -11,10 +9,8 @@ import React, { useCallback } from 'react'; ...@@ -11,10 +9,8 @@ import React, { useCallback } from 'react';
import type { PublicTag } from 'types/api/account'; import type { PublicTag } from 'types/api/account';
import AddressIcon from 'ui/shared/AddressIcon'; import AddressSnippet from 'ui/shared/AddressSnippet';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip'; import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props { interface Props {
...@@ -36,18 +32,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -36,18 +32,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
<Tr alignItems="top" key={ item.id }> <Tr alignItems="top" key={ item.id }>
<Td> <Td>
<VStack spacing={ 4 } alignItems="unset"> <VStack spacing={ 4 } alignItems="unset">
{ item.addresses.map((address) => { { item.addresses.map((address) => <AddressSnippet key={ address } address={ address }/>) }
return (
<HStack spacing={ 4 } key={ address } overflow="hidden" alignItems="start">
<AddressIcon address={ address }/>
<Box overflow="hidden">
<AddressLinkWithTooltip address={ address }/>
{ /* will be added later */ }
{ /* <Text fontSize="sm" variant="secondary" mt={ 0.5 }>Address Name</Text> */ }
</Box>
</HStack>
);
}) }
</VStack> </VStack>
</Td> </Td>
<Td> <Td>
...@@ -69,10 +54,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -69,10 +54,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</VStack> </VStack>
</Td> </Td>
<Td> <Td>
<HStack spacing={ 6 }> <TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
</Td> </Td>
</Tr> </Tr>
); );
......
import { Box, Text, Button, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Box, Button, Skeleton, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { PublicTags, PublicTag } from 'types/api/account'; import type { PublicTags, PublicTag } from 'types/api/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import PublicTagListItem from 'ui/publicTags/PublicTagTable/PublicTagListItem';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable'; import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePublicTagModal from './DeletePublicTagModal'; import DeletePublicTagModal from './DeletePublicTagModal';
...@@ -19,6 +23,7 @@ type Props = { ...@@ -19,6 +23,7 @@ type Props = {
const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const deleteModalProps = useDisclosure(); const deleteModalProps = useDisclosure();
const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>(); const [ deleteModalData, setDeleteModalData ] = useState<PublicTag>();
const isMobile = useIsMobile();
const { data, isLoading, isError } = useQuery<unknown, unknown, PublicTags>([ 'public-tags' ], async() => await fetch('/api/account/public-tags')); const { data, isLoading, isError } = useQuery<unknown, unknown, PublicTags>([ 'public-tags' ], async() => await fetch('/api/account/public-tags'));
...@@ -41,32 +46,53 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { ...@@ -41,32 +46,53 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
}, [ deleteModalProps ]); }, [ deleteModalProps ]);
const description = ( const description = (
<Text marginBottom={ 12 }> <AccountPageDescription>
You can request a public category tag which is displayed to all Blockscout users. You can request a public category tag which is displayed to all Blockscout users.
Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag. Public tags may be added to contract or external addresses, and any associated transactions will inherit that tag.
Clicking a tag opens a page with related information and helps provide context and data organization. Clicking a tag opens a page with related information and helps provide context and data organization.
Requests are sent to a moderator for review and approval. This process can take several days. Requests are sent to a moderator for review and approval. This process can take several days.
</Text> </AccountPageDescription>
); );
if (isLoading) { if (isLoading) {
return ( const loader = isMobile ? <SkeletonAccountMobile/> : (
<> <>
{ description }
<SkeletonTable columns={ [ '50%', '25%', '25%', '108px' ] }/> <SkeletonTable columns={ [ '50%', '25%', '25%', '108px' ] }/>
<Skeleton height="48px" width="270px" marginTop={ 8 }/> <Skeleton height="48px" width="270px" marginTop={ 8 }/>
</> </>
); );
return (
<>
{ description }
{ loader }
</>
);
} }
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const list = isMobile ? (
<Box>
{ data.map((item) => (
<PublicTagListItem
item={ item }
key={ item.id }
onDeleteClick={ onItemDeleteClick }
onEditClick={ onItemEditClick }
/>
)) }
</Box>
) : (
<PublicTagTable data={ data } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/>
);
return ( return (
<> <>
{ description } { description }
{ data.length > 0 && <PublicTagTable data={ data } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/> } { data.length > 0 && list }
<Box marginTop={ 8 }> <Box marginTop={ 8 }>
<Button <Button
variant="primary" variant="primary"
......
import { IconButton, Icon } from '@chakra-ui/react'; import { IconButton, Icon, Flex } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form'; import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form'; import { Controller } from 'react-hook-form';
...@@ -17,24 +17,25 @@ interface Props { ...@@ -17,24 +17,25 @@ interface Props {
error?: FieldError; error?: FieldError;
onAddFieldClick: (e: React.SyntheticEvent) => void; onAddFieldClick: (e: React.SyntheticEvent) => void;
onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void; onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void;
size?: string;
} }
const MAX_INPUTS_NUM = 10; const MAX_INPUTS_NUM = 10;
export default function PublicTagFormAction({ control, index, fieldsLength, error, onAddFieldClick, onRemoveFieldClick }: Props) { export default function PublicTagFormAction({ control, index, fieldsLength, error, onAddFieldClick, onRemoveFieldClick, size }: Props) {
const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, `addresses.${ number }.address`>}) => { const renderAddressInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, `addresses.${ number }.address`>}) => {
return ( return (
<AddressInput<Inputs, `addresses.${ number }.address`> <AddressInput<Inputs, `addresses.${ number }.address`>
field={ field } field={ field }
error={ error } error={ error }
size="lg" size={ size }
placeholder="Smart contract / Address (0x...)" placeholder="Smart contract / Address (0x...)"
/> />
); );
}, [ error ]); }, [ error, size ]);
return ( return (
<> <Flex flexDir="column" rowGap={ 5 } alignItems="flex-end">
<Controller <Controller
name={ `addresses.${ index }.address` } name={ `addresses.${ index }.address` }
control={ control } control={ control }
...@@ -44,31 +45,34 @@ export default function PublicTagFormAction({ control, index, fieldsLength, erro ...@@ -44,31 +45,34 @@ export default function PublicTagFormAction({ control, index, fieldsLength, erro
required: index === 0, required: index === 0,
}} }}
/> />
{ index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && ( <Flex
<IconButton columnGap={ 5 }
aria-label="add" position={{ base: 'static', lg: 'absolute' }}
variant="iconBorder" left={{ base: 'auto', lg: 'calc(100% + 20px)' }}
w="30px" h="100%"
h="30px" alignItems="center"
onClick={ onAddFieldClick } >
icon={ <Icon as={ PlusIcon } w="20px" h="20px"/> } { fieldsLength > 1 && (
position="absolute" <IconButton
right={ index === 0 ? '-50px' : '-100px' } aria-label="delete"
top="25px" variant="iconBorder"
/> w="30px"
) } h="30px"
{ fieldsLength > 1 && ( onClick={ onRemoveFieldClick(index) }
<IconButton icon={ <Icon as={ MinusIcon } w="20px" h="20px"/> }
aria-label="delete" />
variant="iconBorder" ) }
w="30px" { index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && (
h="30px" <IconButton
onClick={ onRemoveFieldClick(index) } aria-label="add"
icon={ <Icon as={ MinusIcon } w="20px" h="20px"/> } variant="iconBorder"
position="absolute" w="30px"
right="-50px" h="30px"
top="25px" onClick={ onAddFieldClick }
/> icon={ <Icon as={ PlusIcon } w="20px" h="20px"/> }
) }</> />
) }
</Flex>
</Flex>
); );
} }
...@@ -12,23 +12,24 @@ const TEXT_INPUT_MAX_LENGTH = 255; ...@@ -12,23 +12,24 @@ const TEXT_INPUT_MAX_LENGTH = 255;
interface Props { interface Props {
control: Control<Inputs>; control: Control<Inputs>;
error?: FieldError; error?: FieldError;
size?: string;
} }
export default function PublicTagFormComment({ control, error }: Props) { export default function PublicTagFormComment({ control, error, size }: Props) {
const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => { const renderComment = useCallback(({ field }: {field: ControllerRenderProps<Inputs, 'comment'>}) => {
return ( return (
<FormControl variant="floating" id={ field.name } size="lg" isRequired> <FormControl variant="floating" id={ field.name } size={ size } isRequired>
<Textarea <Textarea
{ ...field } { ...field }
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
size="lg" size={ size }
/> />
<FormLabel> <FormLabel>
{ getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error?.message) } { getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error?.message) }
</FormLabel> </FormLabel>
</FormControl> </FormControl>
); );
}, [ error ]); }, [ error, size ]);
return ( return (
<Controller <Controller
......
...@@ -16,6 +16,7 @@ import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types ...@@ -16,6 +16,7 @@ import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types
import type { ErrorType } from 'lib/client/fetch'; import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage'; import getErrorMessage from 'lib/getErrorMessage';
import useIsMobile from 'lib/hooks/useIsMobile';
import { EMAIL_REGEXP } from 'lib/validations/email'; import { EMAIL_REGEXP } from 'lib/validations/email';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert'; import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
...@@ -52,10 +53,12 @@ const placeholders = { ...@@ -52,10 +53,12 @@ const placeholders = {
comment: 'Specify the reason for adding tags and color preference(s).', comment: 'Specify the reason for adding tags and color preference(s).',
} as Record<Path<Inputs>, string>; } as Record<Path<Inputs>, string>;
const ADDRESS_INPUT_BUTTONS_WIDTH = 170; const ADDRESS_INPUT_BUTTONS_WIDTH = 100;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile();
const inputSize = isMobile ? 'md' : 'lg';
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({ const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({
defaultValues: { defaultValues: {
...@@ -149,10 +152,10 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -149,10 +152,10 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
}, [ changeToDataScreen ]); }, [ changeToDataScreen ]);
return ( return (
<Box width={ `calc(100% - ${ ADDRESS_INPUT_BUTTONS_WIDTH }px)` } maxWidth="844px"> <Box width={{ base: 'auto', lg: `calc(100% - ${ ADDRESS_INPUT_BUTTONS_WIDTH }px)` }} maxWidth="844px">
{ isAlertVisible && <Box mb={ 4 }><FormSubmitAlert/></Box> } { isAlertVisible && <Box mb={ 4 }><FormSubmitAlert/></Box> }
<Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text> <Text size="sm" variant="secondary" paddingBottom={ 5 }>Company info</Text>
<Grid templateColumns="1fr 1fr" rowGap={ 4 } columnGap={ 5 }> <Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 5 }>
<GridItem> <GridItem>
<PublicTagsFormInput<Inputs> <PublicTagsFormInput<Inputs>
fieldName="fullName" fieldName="fullName"
...@@ -160,6 +163,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -160,6 +163,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
label={ placeholders.fullName } label={ placeholders.fullName }
error={ errors.fullName } error={ errors.fullName }
required required
size={ inputSize }
/> />
</GridItem> </GridItem>
<GridItem> <GridItem>
...@@ -168,6 +172,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -168,6 +172,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control } control={ control }
label={ placeholders.companyName } label={ placeholders.companyName }
error={ errors.companyName } error={ errors.companyName }
size={ inputSize }
/> />
</GridItem> </GridItem>
<GridItem> <GridItem>
...@@ -178,6 +183,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -178,6 +183,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
pattern={ EMAIL_REGEXP } pattern={ EMAIL_REGEXP }
error={ errors.email } error={ errors.email }
required required
size={ inputSize }
/> />
</GridItem> </GridItem>
<GridItem> <GridItem>
...@@ -186,10 +192,11 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -186,10 +192,11 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control } control={ control }
label={ placeholders.companyUrl } label={ placeholders.companyUrl }
error={ errors?.companyUrl } error={ errors?.companyUrl }
size={ inputSize }
/> />
</GridItem> </GridItem>
</Grid> </Grid>
<Box marginTop={ 4 } marginBottom={ 8 }> <Box marginTop={{ base: 5, lg: 8 }} marginBottom={{ base: 5, lg: 8 }}>
<PublicTagFormAction control={ control }/> <PublicTagFormAction control={ control }/>
</Box> </Box>
<Text size="sm" variant="secondary" marginBottom={ 5 }>Public tags (2 tags maximum, please use &quot;;&quot; as a divider)</Text> <Text size="sm" variant="secondary" marginBottom={ 5 }>Public tags (2 tags maximum, please use &quot;;&quot; as a divider)</Text>
...@@ -199,7 +206,9 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -199,7 +206,9 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control } control={ control }
label={ placeholders.tags } label={ placeholders.tags }
error={ errors.tags } error={ errors.tags }
required/> required
size={ inputSize }
/>
</Box> </Box>
{ fields.map((field, index) => { { fields.map((field, index) => {
return ( return (
...@@ -211,12 +220,13 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => { ...@@ -211,12 +220,13 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldsLength={ fields.length } fieldsLength={ fields.length }
onAddFieldClick={ onAddFieldClick } onAddFieldClick={ onAddFieldClick }
onRemoveFieldClick={ onRemoveFieldClick } onRemoveFieldClick={ onRemoveFieldClick }
size={ inputSize }
/> />
</Box> </Box>
); );
}) } }) }
<Box marginBottom={ 8 }> <Box marginBottom={ 8 }>
<PublicTagFormComment control={ control } error={ errors.comment }/> <PublicTagFormComment control={ control } error={ errors.comment } size={ inputSize }/>
</Box> </Box>
<HStack spacing={ 6 }> <HStack spacing={ 6 }>
<Button <Button
......
...@@ -14,6 +14,7 @@ interface Props<TInputs extends FieldValues> { ...@@ -14,6 +14,7 @@ interface Props<TInputs extends FieldValues> {
control: Control<TInputs, object>; control: Control<TInputs, object>;
pattern?: RegExp; pattern?: RegExp;
error?: FieldError; error?: FieldError;
size?: string;
} }
export default function PublicTagsFormInput<Inputs extends FieldValues>({ export default function PublicTagsFormInput<Inputs extends FieldValues>({
...@@ -23,13 +24,14 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({ ...@@ -23,13 +24,14 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
fieldName, fieldName,
pattern, pattern,
error, error,
size,
}: Props<Inputs>) { }: Props<Inputs>) {
const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => { const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => {
return ( return (
<FormControl variant="floating" id={ field.name } isRequired={ required } size="lg"> <FormControl variant="floating" id={ field.name } isRequired={ required } size={ size }>
<Input <Input
{ ...field } { ...field }
size="lg" size={ size }
required={ required } required={ required }
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH } maxLength={ TEXT_INPUT_MAX_LENGTH }
...@@ -37,7 +39,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({ ...@@ -37,7 +39,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
<FormLabel>{ getPlaceholderWithError(label, error?.message) }</FormLabel> <FormLabel>{ getPlaceholderWithError(label, error?.message) }</FormLabel>
</FormControl> </FormControl>
); );
}, [ label, required, error ]); }, [ label, required, error, size ]);
return ( return (
<Controller <Controller
name={ fieldName } name={ fieldName }
......
import { VStack, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const AccountListItemMobile = ({ children }: Props) => {
return (
<VStack
gap={ 4 }
alignItems="flex-start"
paddingY={ 6 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderTopWidth="1px"
_last={{
borderBottomWidth: '1px',
}}
>
{ children }
</VStack>
);
};
export default AccountListItemMobile;
import { Text } from '@chakra-ui/react';
import React from 'react';
const AccountPageDescription = ({ children }: {children: React.ReactNode}) => {
return (
<Text marginBottom={{ base: 6, lg: 12 }}>
{ children }
</Text>
);
};
export default AccountPageDescription;
import { Heading } from '@chakra-ui/react'; import { Heading } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
const PageHeader = ({ text }: {text: string}) => { const AccountPageHeader = ({ text }: {text: string}) => {
return ( return (
<Heading as="h1" size="lg" marginBottom={ 8 }>{ text }</Heading> <Heading as="h1" size="lg" marginBottom={{ base: 6, lg: 8 }}>{ text }</Heading>
); );
}; };
export default PageHeader; export default AccountPageHeader;
...@@ -4,7 +4,7 @@ import Jazzicon, { jsNumberForAddress } from 'react-jazzicon'; ...@@ -4,7 +4,7 @@ import Jazzicon, { jsNumberForAddress } from 'react-jazzicon';
const AddressIcon = ({ address }: {address: string}) => { const AddressIcon = ({ address }: {address: string}) => {
return ( return (
<Box width="24px"> <Box width="24px" display="inline-flex">
<Jazzicon diameter={ 24 } seed={ jsNumberForAddress(address) }/> <Jazzicon diameter={ 24 } seed={ jsNumberForAddress(address) }/>
</Box> </Box>
); );
......
...@@ -8,17 +8,28 @@ import CopyToClipboard from './CopyToClipboard'; ...@@ -8,17 +8,28 @@ import CopyToClipboard from './CopyToClipboard';
const FONT_WEIGHT = '600'; const FONT_WEIGHT = '600';
const AddressLinkWithTooltip = ({ address }: {address: string}) => { type Props = {
address: string;
type?: 'address' | 'transaction';
}
const AddressLinkWithTooltip = ({ address, type = 'address' }: Props) => {
const basePath = useBasePath(); const basePath = useBasePath();
const url = basePath + '/address/' + address + '/tokens#address-tabs'; let url;
if (type === 'transaction') {
url = basePath + '/tx/' + address;
} else {
url = basePath + '/address/' + address + '/tokens#address-tabs';
}
return ( return (
<HStack spacing={ 2 } alignContent="center" overflow="hidden"> <HStack spacing={ 2 } alignContent="center" overflow="hidden" maxW="100%">
<Link <Link
href={ url } href={ url }
target="_blank" target="_blank"
overflow="hidden" overflow="hidden"
fontWeight={ FONT_WEIGHT } fontWeight={ FONT_WEIGHT }
lineHeight="24px" lineHeight="24px"
whiteSpace="nowrap"
> >
<AddressWithDots address={ address } fontWeight={ FONT_WEIGHT }/> <AddressWithDots address={ address } fontWeight={ FONT_WEIGHT }/>
</Link> </Link>
......
import { Box, HStack, Text } from '@chakra-ui/react';
import React from 'react';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
interface Props {
address: string;
subtitle?: string;
}
const AddressSnippet = ({ address, subtitle }: Props) => {
return (
<HStack spacing={ 4 } key={ address } overflow="hidden" alignItems="start" maxW="100%">
<AddressIcon address={ address }/>
<Box overflow="hidden">
<AddressLinkWithTooltip address={ address }/>
{ subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 }>{ subtitle }</Text> }
</Box>
</HStack>
);
};
export default React.memo(AddressSnippet);
import { Box, HStack, Icon, Flex, Text, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import keyIcon from 'icons/key.svg';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
apiKey: string;
name: string;
}
const ApiKeySnippet = ({ apiKey, name }: Props) => {
return (
<HStack spacing={ 2 } alignItems="start">
<Icon as={ keyIcon } boxSize={ 6 } color={ useColorModeValue('gray.500', 'gray.400') }/>
<Box>
<Flex alignItems={{ base: 'flex-start', lg: 'center' }}>
<Text fontSize="md" lineHeight={ 6 } fontWeight={ 600 } mr={ 1 }>{ apiKey }</Text>
<CopyToClipboard text={ apiKey }/>
</Flex>
{ name && <Text fontSize="sm" variant="secondary" mt={ 1 }>{ name }</Text> }
</Box>
</HStack>
);
};
export default React.memo(ApiKeySnippet);
import { Tooltip, IconButton, Icon } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import DeleteIcon from 'icons/delete.svg';
type Props = {
onClick: () => void;
}
const DeleteButton = ({ onClick }: Props) => {
const onFocusCapture = useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
return (
<Tooltip label="Delete">
<IconButton
aria-label="delete"
variant="icon"
w="30px"
h="30px"
onClick={ onClick }
icon={ <Icon as={ DeleteIcon } w="20px" h="20px"/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
);
};
export default DeleteButton;
...@@ -54,7 +54,7 @@ const DeleteModal: React.FC<Props> = ({ ...@@ -54,7 +54,7 @@ const DeleteModal: React.FC<Props> = ({
}, [ setAlertVisible, mutation ]); }, [ setAlertVisible, mutation ]);
return ( return (
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md"> <Modal isOpen={ isOpen } onClose={ onModalClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/> <ModalOverlay/>
<ModalContent> <ModalContent>
<ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader> <ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader>
......
import { Tooltip, IconButton, Icon } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import EditIcon from 'icons/edit.svg';
type Props = {
onClick: () => void;
}
const EditButton = ({ onClick }: Props) => {
const onFocusCapture = useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
return (
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="icon"
w="30px"
h="30px"
onClick={ onClick }
icon={ <Icon as={ EditIcon } w="20px" h="20px"/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
);
};
export default EditButton;
...@@ -39,14 +39,14 @@ export default function FormModal<TData>({ ...@@ -39,14 +39,14 @@ export default function FormModal<TData>({
}, [ onClose, setAlertVisible ]); }, [ onClose, setAlertVisible ]);
return ( return (
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md" > <Modal isOpen={ isOpen } onClose={ onModalClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/> <ModalOverlay/>
<ModalContent> <ModalContent>
<ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader> <ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader>
<ModalCloseButton/> <ModalCloseButton/>
<ModalBody mb={ 0 }> <ModalBody mb={ 0 }>
{ (isAlertVisible || text) && ( { (isAlertVisible || text) && (
<Box marginBottom={ 12 }> <Box marginBottom={{ base: 6, lg: 12 }}>
{ text && ( { text && (
<Text lineHeight="30px" mb={ 3 }> <Text lineHeight="30px" mb={ 3 }>
{ text } { text }
......
import { Box, Flex, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface Props {
showFooterSlot?: boolean;
}
const SkeletonAccountMobile = ({ showFooterSlot }: Props) => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Box>
{ Array.from(Array(2)).map((item, index) => (
<Flex
key={ index }
rowGap={ 3 }
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
_first={{
borderTopWidth: '0',
pt: '0',
}}
>
<Flex columnGap={ 2 } w="100%" alignItems="center">
<SkeletonCircle size="6" flexShrink="0"/>
<Skeleton h={ 4 } w="100%"/>
</Flex>
<Skeleton h={ 4 } w="164px"/>
<Skeleton h={ 4 } w="164px"/>
<Flex columnGap={ 3 } mt={ 7 }>
{ showFooterSlot && (
<Flex alignItems="center" columnGap={ 2 }>
<Skeleton h={ 4 } w="164px"/>
<SkeletonCircle size="6" flexShrink="0"/>
</Flex>
) }
<SkeletonCircle size="6" flexShrink="0" ml="auto"/>
<SkeletonCircle size="6" flexShrink="0"/>
</Flex>
</Flex>
)) }
</Box>
);
};
export default SkeletonAccountMobile;
import { Tooltip, IconButton, Icon, HStack } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import DeleteIcon from 'icons/delete.svg';
import EditIcon from 'icons/edit.svg';
type Props = {
onEditClick: () => void;
onDeleteClick: () => void;
}
const TableItemActionButtons = ({ onEditClick, onDeleteClick }: Props) => {
// prevent set focus on button when closing modal
const onFocusCapture = useCallback((e: React.SyntheticEvent) => e.stopPropagation(), []);
return (
<HStack spacing={ 6 } alignSelf="flex-end">
<Tooltip label="Edit">
<IconButton
aria-label="edit"
variant="icon"
w="30px"
h="30px"
onClick={ onEditClick }
icon={ <Icon as={ EditIcon } w="20px" h="20px"/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
<Tooltip label="Delete">
<IconButton
aria-label="delete"
variant="icon"
w="30px"
h="30px"
onClick={ onDeleteClick }
icon={ <Icon as={ DeleteIcon } w="20px" h="20px"/> }
onFocusCapture={ onFocusCapture }
/>
</Tooltip>
</HStack>
);
};
export default React.memo(TableItemActionButtons);
import { Box, HStack, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import transactionIcon from 'icons/transactions.svg';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
interface Props {
hash: string;
}
const TransactionSnippet = ({ hash }: Props) => {
return (
<HStack spacing={ 2 } overflow="hidden" alignItems="start" maxW="100%">
<Icon as={ transactionIcon } boxSize={ 6 } color={ useColorModeValue('gray.500', 'gray.400') }/>
<Box overflow="hidden">
<AddressLinkWithTooltip address={ hash } type="transaction"/>
</Box>
</HStack>
);
};
export default React.memo(TransactionSnippet);
...@@ -150,7 +150,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -150,7 +150,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
return ( return (
<> <>
<Box marginBottom={ 5 } marginTop={ 5 }> <Box marginBottom={ 5 }>
<Controller <Controller
name="address" name="address"
control={ control } control={ control }
...@@ -178,7 +178,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => { ...@@ -178,7 +178,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Box marginBottom={ 8 }> <Box marginBottom={ 8 }>
<AddressFormNotifications control={ control }/> <AddressFormNotifications control={ control }/>
</Box> </Box>
<Text variant="secondary" fontSize="sm" marginBottom={ 5 }>Notification methods</Text> <Text variant="secondary" fontSize="sm" marginBottom={{ base: '10px', lg: 5 }}>Notification methods</Text>
<Controller <Controller
name={ 'notification' as Checkboxes } name={ 'notification' as Checkboxes }
control={ control } control={ control }
......
...@@ -20,13 +20,21 @@ export default function AddressFormNotifications<Inputs extends FieldValues, Che ...@@ -20,13 +20,21 @@ export default function AddressFormNotifications<Inputs extends FieldValues, Che
), []); ), []);
return ( return (
<Grid templateColumns="repeat(3, max-content)" gap="20px 24px"> <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) => { { NOTIFICATIONS.map((notification: string, index: number) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes; const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes; const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes;
return ( return (
<React.Fragment key={ notification }> <React.Fragment key={ notification }>
<GridItem>{ NOTIFICATIONS_NAMES[index] }</GridItem> <GridItem
gridColumnStart={{ base: 1, lg: 1 }}
gridColumnEnd={{ base: 3, lg: 1 }}
_notFirst={{
mt: { base: 3, lg: 0 },
}}
>
{ NOTIFICATIONS_NAMES[index] }
</GridItem>
<GridItem> <GridItem>
<Controller <Controller
name={ incomingFieldName } name={ incomingFieldName }
......
...@@ -5,6 +5,7 @@ import React, { useCallback } from 'react'; ...@@ -5,6 +5,7 @@ import React, { useCallback } from 'react';
import type { TWatchlistItem, TWatchlist } from 'types/client/account'; import type { TWatchlistItem, TWatchlist } from 'types/client/account';
import fetch from 'lib/client/fetch'; import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import DeleteModal from 'ui/shared/DeleteModal'; import DeleteModal from 'ui/shared/DeleteModal';
type Props = { type Props = {
...@@ -15,6 +16,7 @@ type Props = { ...@@ -15,6 +16,7 @@ type Props = {
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isMobile = useIsMobile();
const mutationFn = useCallback(() => { const mutationFn = useCallback(() => {
return fetch(`/api/account1/watchlist/${ data?.id }`, { method: 'DELETE' }); return fetch(`/api/account1/watchlist/${ data?.id }`, { method: 'DELETE' });
...@@ -29,10 +31,11 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => { ...@@ -29,10 +31,11 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const address = data?.address_hash; const address = data?.address_hash;
const renderModalContent = useCallback(() => { const renderModalContent = useCallback(() => {
const addressString = isMobile ? [ address.slice(0, 4), address.slice(-4) ].join('...') : address;
return ( return (
<Text display="flex">Address <Text fontWeight="600" whiteSpace="pre"> { address || 'address' } </Text> will be deleted</Text> <Text>Address <Text fontWeight="600" as="span"> { addressString || 'address' }</Text> will be deleted</Text>
); );
}, [ address ]); }, [ address, isMobile ]);
return ( return (
<DeleteModal <DeleteModal
......
...@@ -6,8 +6,7 @@ import type { TWatchlistItem } from 'types/client/account'; ...@@ -6,8 +6,7 @@ import type { TWatchlistItem } from 'types/client/account';
import TokensIcon from 'icons/tokens.svg'; import TokensIcon from 'icons/tokens.svg';
// import WalletIcon from 'icons/wallet.svg'; // import WalletIcon from 'icons/wallet.svg';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import AddressIcon from 'ui/shared/AddressIcon'; import AddressSnippet from 'ui/shared/AddressSnippet';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
// now this component works only for xDAI // now this component works only for xDAI
// for other networks later we will use config or smth // for other networks later we will use config or smth
...@@ -18,36 +17,34 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => { ...@@ -18,36 +17,34 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const nativeBalance = ((item.address_balance || 0) / 10 ** DECIMALS).toFixed(1); const nativeBalance = ((item.address_balance || 0) / 10 ** DECIMALS).toFixed(1);
const nativeBalanceUSD = item.exchange_rate ? `$${ Number(nativeBalance) * item.exchange_rate } USD` : 'N/A'; const nativeBalanceUSD = item.exchange_rate ? `$${ Number(nativeBalance) * item.exchange_rate } USD` : 'N/A';
const infoItemsPaddingLeft = { base: 0, lg: 10 };
return ( return (
<HStack spacing={ 3 } align="top"> <VStack spacing={ 2 } align="stretch" overflow="hidden" fontWeight={ 500 } color="gray.700">
<AddressIcon address={ item.address_hash }/> <AddressSnippet address={ item.address_hash }/>
<VStack spacing={ 2 } align="stretch" overflow="hidden" fontWeight={ 500 } color="gray.700"> <HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<AddressLinkWithTooltip address={ item.address_hash }/> <Image src="/xdai.png" alt="chain-logo" marginRight="10px" w="16px" h="16px"/>
<HStack spacing={ 0 } fontSize="sm" h={ 6 }> <Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + nativeBalance }</Text>
<Image src="/xdai.png" alt="chain-logo" marginRight="10px" w="16px" h="16px"/> <Text variant="secondary">{ `${ nbsp }(${ nativeBalanceUSD })` }</Text>
<Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + nativeBalance }</Text> </HStack>
<Text variant="secondary">{ `${ nbsp }(${ nativeBalanceUSD })` }</Text> { item.tokens_count && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<Icon as={ TokensIcon } marginRight="10px" w="17px" h="16px"/>
<Text color={ mainTextColor }>{ `Tokens:${ nbsp }` + item.tokens_count }</Text>
{ /* api does not provide token prices */ }
{ /* <Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text> */ }
<Text variant="secondary">{ `${ nbsp }(N/A)` }</Text>
</HStack> </HStack>
{ item.tokens_count && ( ) }
<HStack spacing={ 0 } fontSize="sm" h={ 6 }> { /* api does not provide token prices */ }
<Icon as={ TokensIcon } marginRight="10px" w="17px" h="16px"/> { /* { item.address_balance && (
<Text color={ mainTextColor }>{ `Tokens:${ nbsp }` + item.tokens_count }</Text> <HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
{ /* api does not provide token prices */ }
{ /* <Text variant="secondary">{ `${ nbsp }($${ item.tokensUSD } USD)` }</Text> */ }
<Text variant="secondary">{ `${ nbsp }(N/A)` }</Text>
</HStack>
) }
{ /* api does not provide token prices */ }
{ /* { item.address_balance && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<Icon as={ WalletIcon } marginRight="10px" w="16px" h="16px"/> <Icon as={ WalletIcon } marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `Net worth:${ nbsp }` }</Text> <Text color={ mainTextColor }>{ `Net worth:${ nbsp }` }</Text>
<Link href="#">{ `$${ item.totalUSD } USD` }</Link> <Link href="#">{ `$${ item.totalUSD } USD` }</Link>
</HStack> </HStack>
) } */ } ) } */ }
</VStack> </VStack>
</HStack>
); );
}; };
......
import { Tag, Box, Switch, Text, HStack, Flex } from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import WatchListAddressItem from './WatchListAddressItem';
interface Props {
item: TWatchlistItem;
onEditClick: (data: TWatchlistItem) => void;
onDeleteClick: (data: TWatchlistItem) => void;
}
const WatchListItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
const onItemDeleteClick = useCallback(() => {
return onDeleteClick(item);
}, [ item, onDeleteClick ]);
const { mutate } = useMutation(() => {
const data = { ...item, notification_methods: { email: !notificationEnabled } };
return fetch(`/api/account/watchlist/${ item.id }`, { method: 'PUT', body: JSON.stringify(data) });
}, {
onError: () => {
// eslint-disable-next-line no-console
console.log('error');
},
onSuccess: () => {
setNotificationEnabled(prevState => !prevState);
},
});
const onSwitch = useCallback(() => {
return mutate();
}, [ mutate ]);
return (
<AccountListItemMobile>
<Box maxW="100%">
<WatchListAddressItem item={ item }/>
<HStack spacing={ 3 } mt={ 6 }>
<Text fontSize="sm" fontWeight={ 500 }>Private tag</Text>
<Tag variant="gray" lineHeight="24px">
{ item.name }
</Tag>
</HStack>
</Box>
<Flex alignItems="center" justifyContent="space-between" mt={ 6 } w="100%">
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Email notification</Text>
<Switch colorScheme="blue" size="md" isChecked={ notificationEnabled } onChange={ onSwitch } aria-label="Email notification"/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Flex>
</AccountListItemMobile>
);
};
export default WatchListItem;
...@@ -3,15 +3,15 @@ import { ...@@ -3,15 +3,15 @@ import {
Tr, Tr,
Td, Td,
Switch, Switch,
HStack,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account'; import type { TWatchlistItem } from 'types/client/account';
import DeleteButton from 'ui/shared/DeleteButton'; import fetch from 'lib/client/fetch';
import EditButton from 'ui/shared/EditButton'; import useToast from 'lib/hooks/useToast';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip'; import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
import WatchListAddressItem from './WatchListAddressItem'; import WatchListAddressItem from './WatchListAddressItem';
...@@ -24,6 +24,7 @@ interface Props { ...@@ -24,6 +24,7 @@ interface Props {
const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email); const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const [ switchDisabled, setSwitchDisabled ] = useState(false);
const onItemEditClick = useCallback(() => { const onItemEditClick = useCallback(() => {
return onEditClick(item); return onEditClick(item);
}, [ item, onEditClick ]); }, [ item, onEditClick ]);
...@@ -32,16 +33,33 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -32,16 +33,33 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return onDeleteClick(item); return onDeleteClick(item);
}, [ item, onDeleteClick ]); }, [ item, onDeleteClick ]);
const toast = useToast();
const showToast = useCallback(() => {
toast({
position: 'top-right',
description: 'There has been an error processing your request',
colorScheme: 'red',
status: 'error',
variant: 'subtle',
isClosable: true,
icon: null,
});
}, [ toast ]);
const { mutate } = useMutation(() => { const { mutate } = useMutation(() => {
setSwitchDisabled(true);
const data = { ...item, notification_methods: { email: !notificationEnabled } }; const data = { ...item, notification_methods: { email: !notificationEnabled } };
return fetch(`/api/account/watchlist/${ item.id }`, { method: 'PUT', body: JSON.stringify(data) }); setNotificationEnabled(prevState => !prevState);
return fetch(`/api/account1/watchlist/${ item.id }`, { method: 'PUT', body: JSON.stringify(data) });
}, { }, {
onError: () => { onError: () => {
// eslint-disable-next-line no-console showToast();
console.log('error'); setNotificationEnabled(prevState => !prevState);
setSwitchDisabled(false);
}, },
onSuccess: () => { onSuccess: () => {
setNotificationEnabled(prevState => !prevState); setSwitchDisabled(false);
}, },
}); });
...@@ -59,12 +77,18 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => { ...@@ -59,12 +77,18 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</Tag> </Tag>
</TruncatedTextTooltip> </TruncatedTextTooltip>
</Td> </Td>
<Td><Switch colorScheme="blue" size="md" isChecked={ notificationEnabled } onChange={ onSwitch }/></Td>
<Td> <Td>
<HStack spacing={ 6 }> <Switch
<EditButton onClick={ onItemEditClick }/> colorScheme="blue"
<DeleteButton onClick={ onItemDeleteClick }/> size="md"
</HStack> isChecked={ notificationEnabled }
onChange={ onSwitch }
isDisabled={ switchDisabled }
aria-label="Email notification"
/>
</Td>
<Td>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td> </Td>
</Tr> </Tr>
); );
......
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