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
SENTRY_ORG=block-scout
SENTRY_PROJECT=new-ui
SENTRY_AUTH_TOKEN=xxx
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
SENTRY_CSP_REPORT_URI=xxx
NEXT_PUBLIC_BLOCKSCOUT_VERSION=xxx
NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
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>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<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 * as cookies from 'lib/cookies';
......@@ -7,8 +8,7 @@ export default function getUrlWithNetwork(_req: NextApiRequest, path: string) {
const networkSubType = _req.cookies[cookies.NAMES.NETWORK_SUB_TYPE];
if (!networkType || !networkSubType) {
// eslint-disable-next-line no-console
console.error(`Incorrect network: NETWORK_TYPE=${ networkType } NETWORK_SUB_TYPE=${ networkSubType }`);
Sentry.captureException(new Error('Incorrect network'), { extra: { networkType, networkSubType } });
}
return `/${ networkType }/${ networkSubType }/${ path }`;
......
import { withSentry } from '@sentry/nextjs';
import type { NextApiRequest, NextApiResponse } from 'next';
import fetch from 'lib/api/fetch';
......@@ -5,28 +6,39 @@ import getUrlWithNetwork from 'lib/api/getUrlWithNetwork';
type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE';
export default function handler<TRes, TErrRes>(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
return async(_req: NextApiRequest, res: NextApiResponse<TRes | TErrRes>) => {
if (_req.method && allowedMethods.includes(_req.method as Methods)) {
const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD';
export default function createHandler(getUrl: (_req: NextApiRequest) => string, allowedMethods: Array<Methods>) {
const handler = async(_req: NextApiRequest, res: NextApiResponse) => {
if (!_req.method || !allowedMethods.includes(_req.method as Methods)) {
res.setHeader('Allow', allowedMethods);
res.status(405).end(`Method ${ _req.method } Not Allowed`);
return;
}
const url = getUrlWithNetwork(_req, `/api${ getUrl(_req) }`);
const response = await fetch(url, {
method: _req.method,
body: isBodyDisallowed ? undefined : _req.body,
});
const isBodyDisallowed = _req.method === 'GET' || _req.method === 'HEAD';
if (response.status !== 200) {
const error = await response.json() as { errors: TErrRes };
res.status(500).json(error?.errors || {} as TErrRes);
return;
}
const url = getUrlWithNetwork(_req, `/api${ getUrl(_req) }`);
const response = await fetch(url, {
method: _req.method,
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);
} else {
res.setHeader('Allow', allowedMethods);
res.status(405).end(`Method ${ _req.method } Not Allowed`);
return;
}
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> {
error?: T;
status: Response['status'];
......@@ -13,10 +15,15 @@ export default function clientFetch<Success, Error>(path: string, init?: Request
status: response.status,
statusText: response.statusText,
}),
() => Promise.reject({
status: response.status,
statusText: response.statusText,
}),
() => {
const error = {
status: response.status,
statusText: response.statusText,
};
Sentry.captureException(new Error('Client fetch failed'), { extra: error, tags: { source: 'fetch' } });
return Promise.reject(error);
},
);
} 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';
import poaIcon from 'icons/networks/poa.svg';
import rskIcon from 'icons/networks/rsk.svg';
import parseNetworkConfig from './parseNetworkConfig';
// will change later when we agree how to host network icons
const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGElement>>> = {
'xdai/mainnet': gnosisIcon,
......@@ -24,15 +26,13 @@ const ICONS: Record<string, React.FunctionComponent<React.SVGAttributes<SVGEleme
'artis/sigma1': artisIcon,
};
export const NETWORKS: Array<Network> = (() => {
try {
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 }`] }));
} catch (error) {
return [];
}
const NETWORKS: Array<Network> = (() => {
const networksFromConfig: Array<Network> = parseNetworkConfig();
return networksFromConfig.map((network) => ({ ...network, icon: network.icon || ICONS[`${ network.type }/${ network.subType }`] }));
})();
export default NETWORKS;
// for easy env creation
// const FOR_CONFIG = [
// {
......@@ -112,21 +112,3 @@ export const NETWORKS: Array<Network> = (() => {
// 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');
const withReactSvg = require('next-react-svg');
const path = require('path');
const headers = require('./configs/nextjs/headers');
const moduleExports = {
include: path.resolve(__dirname, 'icons'),
reactStrictMode: true,
webpack(config) {
webpack(config, { webpack }) {
config.plugins.push(
new webpack.DefinePlugin({
__SENTRY_DEBUG__: false,
__SENTRY_TRACING__: false,
}),
);
return config;
},
async redirects() {
......@@ -17,6 +26,7 @@ const moduleExports = {
},
];
},
headers,
output: 'standalone',
};
......@@ -30,6 +40,9 @@ const sentryWebpackPluginOptions = {
silent: true, // Suppresses all logs
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
deploy: {
env: process.env.VERCEL_ENV || process.env.NODE_ENV,
},
};
module.exports = withReactSvg(withSentryConfig(moduleExports, sentryWebpackPluginOptions));
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,8 @@ import type { NextPage, GetStaticPaths, GetStaticProps, GetStaticPropsResult } f
import Head from 'next/head';
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';
type PageParams = {
......
......@@ -2,7 +2,7 @@ import type { NextPage, GetStaticPaths } from 'next';
import Head from 'next/head';
import React from 'react';
import { getAvailablePaths } from 'lib/networks';
import getAvailablePaths from 'lib/networks/getAvailablePaths';
import MyProfile from 'ui/pages/MyProfile';
const MyProfilePage: NextPage = () => {
......
......@@ -3,7 +3,7 @@ import type { NextPage, GetStaticPaths } from 'next';
import { useRouter } from 'next/router';
import React from 'react';
import { getAvailablePaths } from 'lib/networks';
import getAvailablePaths from 'lib/networks/getAvailablePaths';
import Page from 'ui/shared/Page/Page';
const Home: NextPage = () => {
......
......@@ -7,16 +7,12 @@ import theme from 'theme';
class MyDocument extends Document {
render() {
return (
<Html>
<Html lang="en">
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
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="16x16" type="image/png"href="/favicon-16x16.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';
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;
import type { CustomAbis, CustomAbiErrors } from 'types/api/account';
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;
import type { AddressTags, AddressTagErrors } from 'types/api/account';
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;
import type { TransactionTags, TransactionTagErrors } from 'types/api/account';
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;
import type { UserInfo } from 'types/api/account';
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;
import type { PublicTags, PublicTagErrors } from 'types/api/account';
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;
import type { NextApiRequest } from 'next';
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
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;
import type { WatchlistAddresses, WatchlistErrors } from 'types/api/account';
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;
......@@ -5,8 +5,10 @@
import * as Sentry from '@sentry/nextjs';
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({
environment: ENV,
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
......
......@@ -5,8 +5,10 @@
import * as Sentry from '@sentry/nextjs';
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({
environment: ENV,
dsn: SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
......
import { alertAnatomy as parts } from '@chakra-ui/anatomy';
import type { StyleFunctionProps } from '@chakra-ui/styled-system';
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
import { getColor, mode, transparentize } from '@chakra-ui/theme-tools';
const { definePartsStyle, defineMultiStyleConfig } =
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({
container: {
borderRadius: 'md',
......@@ -12,8 +22,21 @@ const baseStyle = definePartsStyle({
},
});
const variantSubtle = definePartsStyle((props) => {
return {
container: {
bgColor: getBg(props),
},
};
});
const variants = {
subtle: variantSubtle,
};
const Alert = defineMultiStyleConfig({
baseStyle,
variants,
});
export default Alert;
......@@ -125,6 +125,9 @@ const variantFloating = definePartsStyle((props) => {
margin: 0,
transformOrigin: 'top left',
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': {
...activeLabelStyles,
......
......@@ -35,6 +35,7 @@ const baseStyleHeader = defineStyle((props) => ({
const baseStyleBody = defineStyle({
padding: 0,
marginBottom: 8,
flex: 'initial',
});
const baseStyleFooter = defineStyle({
......@@ -70,16 +71,33 @@ const baseStyle = definePartsStyle((props) => ({
const sizes = {
md: definePartsStyle({
dialogContainer: {
height: '100%',
},
dialog: {
maxW: '760px',
},
}),
full: definePartsStyle({
dialogContainer: {
height: '100%',
},
dialog: {
maxW: '100vw',
minH: '100vh',
my: '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';
import getOutlinedFieldStyles from '../utils/getOutlinedFieldStyles';
const sizes = {
md: defineStyle({
fontSize: 'md',
lineHeight: '20px',
h: '160px',
borderRadius: 'base',
}),
lg: defineStyle({
fontSize: 'md',
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 {
Tr,
Td,
HStack,
Text,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ApiKey } from 'types/api/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import ApiKeySnippet from 'ui/shared/ApiKeySnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: ApiKey;
......@@ -31,17 +28,10 @@ const ApiKeyTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return (
<Tr alignItems="top" key={ item.api_key }>
<Td>
<HStack>
<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>
<ApiKeySnippet apiKey={ item.api_key } name={ item.name }/>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</Tr>
);
......
......@@ -28,7 +28,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => {
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 ]);
......
......@@ -28,7 +28,7 @@ const Header = () => {
width="100%"
alignItems="center"
justifyContent="space-between"
zIndex={ 10 }
zIndex="sticky"
>
<Burger/>
<NetworkLogo/>
......
......@@ -9,10 +9,10 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
const SOCIAL_LINKS = [
{ link: process.env.NEXT_PUBLIC_FOOTER_GITHUB_LINK, icon: ghIcon },
{ link: process.env.NEXT_PUBLIC_FOOTER_TWITTER_LINK, icon: twIcon },
{ link: process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK, icon: tgIcon },
{ link: process.env.NEXT_PUBLIC_FOOTER_STAKING_LINK, icon: statsIcon },
{ link: process.env.NEXT_PUBLIC_FOOTER_GITHUB_LINK, icon: ghIcon, label: 'Github link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_TWITTER_LINK, icon: twIcon, label: 'Twitter link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_TELEGRAM_LINK, icon: tgIcon, label: 'Telegram link' },
{ link: process.env.NEXT_PUBLIC_FOOTER_STAKING_LINK, icon: statsIcon, label: 'Staking analytic link' },
].filter(({ link }) => link !== undefined);
const BLOCKSCOUT_VERSION = process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION;
......@@ -51,7 +51,7 @@ const NavFooter = ({ isCollapsed }: Props) => {
<Stack direction={ isCollapsed ? 'column' : 'row' }>
{ SOCIAL_LINKS.map(sl => {
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 }/>
</Link>
);
......
......@@ -24,6 +24,7 @@ const NetworkLogo = ({ isCollapsed, onClick }: Props) => {
overflow="hidden"
onClick={ onClick }
{ ...getDefaultTransitionProps({ transitionProperty: 'width' }) }
aria-label="Link to main page"
>
<Icon
as={ logoIcon }
......
......@@ -26,6 +26,8 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick }: Props, ref: React.Fo
borderRadius="base"
backgroundColor={ isActive ? bgColorMobile : 'none' }
onClick={ onClick }
aria-label="Network menu"
aria-roledescription="menu"
>
<Icon
as={ networksIcon }
......
......@@ -4,7 +4,7 @@ import React from 'react';
import type { NetworkGroup } from 'types/networks';
import { NETWORKS } from 'lib/networks';
import NETWORKS from 'lib/networks/availableNetworks';
import NetworkMenuLink from './NetworkMenuLink';
......
......@@ -5,7 +5,7 @@ import React from 'react';
import type { NetworkGroup } from 'types/networks';
import { NETWORKS } from 'lib/networks';
import NETWORKS from 'lib/networks/availableNetworks';
import NetworkMenuLink from './NetworkMenuLink';
......
......@@ -6,7 +6,7 @@ import type { Network } from 'types/networks';
import checkIcon from 'icons/check.svg';
import placeholderIcon from 'icons/networks/placeholder.svg';
import { isAccountRoute } from 'lib/networks';
import isAccountRoute from 'lib/networks/isAccountRoute';
import useColors from './useColors';
......
......@@ -56,6 +56,7 @@ const SearchBarMobile = ({ onChange, onSubmit }: Props) => {
position="fixed"
top="56px"
left="0"
zIndex="docked"
bgColor={ bgColor }
transform={ isVisible ? 'translateY(0)' : 'translateY(-100%)' }
transitionProperty="transform"
......
......@@ -128,6 +128,7 @@ const CustomAbiForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Textarea
{ ...field }
size="lg"
minH="300px"
isInvalid={ Boolean(errors.abi) }
/>
<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 {
Tr,
Td,
HStack,
Text,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { CustomAbi } from 'types/api/account';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
interface Props {
item: CustomAbi;
......@@ -31,17 +28,10 @@ const CustomAbiTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<HStack>
<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>
<AddressSnippet address={ item.contract_address_hash } subtitle={ item.name }/>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</Tr>
);
......
......@@ -28,7 +28,7 @@ const DeleteCustomAbiModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const renderText = useCallback(() => {
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 ]);
......
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 React, { useCallback, useState } from 'react';
import type { ApiKey, ApiKeys } from 'types/api/account';
import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import { space } from 'lib/html-entities';
import ApiKeyModal from 'ui/apiKey/ApiKeyModal/ApiKeyModal';
import ApiKeyListItem from 'ui/apiKey/ApiKeyTable/ApiKeyListItem';
import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable';
import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert';
......@@ -20,6 +24,7 @@ const DATA_LIMIT = 3;
const ApiKeysPage: React.FC = () => {
const apiKeyModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ apiKeyModalData, setApiKeyModalData ] = useState<ApiKey>();
const [ deleteModalData, setDeleteModalData ] = useState<ApiKey>();
......@@ -47,40 +52,59 @@ const ApiKeysPage: React.FC = () => {
}, [ deleteModalProps ]);
const description = (
<Text marginBottom={ 12 }>
Create API keys to use for your RPC and EthRPC API requests. For more information, see { space }
<AccountPageDescription>
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>.
</Text>
</AccountPageDescription>
);
const content = (() => {
if (isLoading && !data) {
return (
const loader = isMobile ? <SkeletonAccountMobile/> : (
<>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="48px" width="156px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
if (isError) {
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;
return (
<>
{ description }
{ Boolean(data.length) && (
<ApiKeyTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
limit={ DATA_LIMIT }
/>
) }
<HStack marginTop={ 8 } spacing={ 5 }>
{ Boolean(data.length) && list }
<Stack marginTop={ 8 } spacing={ 5 } direction={{ base: 'column', lg: 'row' }}>
<Button
variant="primary"
size="lg"
......@@ -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.` }
</Text>
) }
</HStack>
</Stack>
<ApiKeyModal { ...apiKeyModalProps } onClose={ onApiKeyModalClose } data={ apiKeyModalData }/>
{ 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 React, { useCallback, useState } from 'react';
import type { CustomAbi, CustomAbis } from 'types/api/account';
import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import CustomAbiModal from 'ui/customAbi/CustomAbiModal/CustomAbiModal';
import CustomAbiListItem from 'ui/customAbi/CustomAbiTable/CustomAbiListItem';
import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable';
import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal';
import AccountPageDescription from 'ui/shared/AccountPageDescription';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DataFetchAlert from '../shared/DataFetchAlert';
......@@ -17,6 +21,7 @@ import DataFetchAlert from '../shared/DataFetchAlert';
const CustomAbiPage: React.FC = () => {
const customAbiModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ customAbiModalData, setCustomAbiModalData ] = useState<CustomAbi>();
const [ deleteModalData, setDeleteModalData ] = useState<CustomAbi>();
......@@ -44,36 +49,55 @@ const CustomAbiPage: React.FC = () => {
}, [ deleteModalProps ]);
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.
</Text>
</AccountPageDescription>
);
const content = (() => {
if (isLoading && !data) {
return (
const loader = isMobile ? <SkeletonAccountMobile/> : (
<>
{ description }
<SkeletonTable columns={ [ '100%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ data.length > 0 && (
<CustomAbiTable
data={ data }
const list = isMobile ? (
<Box>
{ data.map((item) => (
<CustomAbiListItem
item={ item }
key={ item.id }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
)) }
</Box>
) : (
<CustomAbiTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ data.length > 0 && list }
<HStack marginTop={ 8 } spacing={ 5 }>
<Button
variant="primary"
......
......@@ -38,7 +38,7 @@ const PrivateTags = ({ tab }: Props) => {
<Box h="100%">
<AccountPageHeader text="Private tags"/>
<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>Transaction</Tab>
</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 { animateScroll } from 'react-scroll';
import type { PublicTag } from 'types/api/account';
import useIsMobile from 'lib/hooks/useIsMobile';
import useToast from 'lib/hooks/useToast';
import PublicTagsData from 'ui/publicTags/PublicTagsData';
import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm';
......@@ -24,6 +26,7 @@ const PublicTagsComponent: React.FC = () => {
const [ formData, setFormData ] = useState<PublicTag>();
const toast = useToast();
const isMobile = useIsMobile();
const showToast = useCallback((action: TToastAction) => {
toast({
......@@ -59,6 +62,7 @@ const PublicTagsComponent: React.FC = () => {
}, [ showToast ]);
const onTagDelete = useCallback(() => showToast('removed'), [ showToast ]);
const onGoBack = useCallback(() => setScreen('data'), [ ]);
let content;
let header;
......@@ -74,6 +78,12 @@ const PublicTagsComponent: React.FC = () => {
return (
<Page>
<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 }/>
{ content }
</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 React, { useCallback, useState } from 'react';
import type { TWatchlist, TWatchlistItem } from 'types/client/account';
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 DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from 'ui/watchlist/AddressModal/AddressModal';
import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal';
import WatchListItem from 'ui/watchlist/WatchlistTable/WatchListItem';
import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable';
const WatchList: React.FC = () => {
......@@ -19,6 +23,7 @@ const WatchList: React.FC = () => {
const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ addressModalData, setAddressModalData ] = useState<TWatchlistItem>();
const [ deleteModalData, setDeleteModalData ] = useState<TWatchlistItem>();
......@@ -44,32 +49,52 @@ const WatchList: React.FC = () => {
}, [ deleteModalProps ]);
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.
</Text>
</AccountPageDescription>
);
let content;
if (isLoading && !data) {
content = (
const loader = isMobile ? <SkeletonAccountMobile showFooterSlot/> : (
<>
{ description }
<SkeletonTable columns={ [ '70%', '30%', '160px', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
content = (
<>
{ description }
{ loader }
</>
);
} else if (isError) {
content = <DataFetchAlert/>;
} else {
content = (
<>
{ Boolean(data?.length) && (
<WatchlistTable
data={ data }
const list = isMobile ? (
<Box>
{ data.map((item) => (
<WatchListItem
item={ item }
key={ item.address_hash }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
)) }
</Box>
) : (
<WatchlistTable
data={ data }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
content = (
<>
{ description }
{ Boolean(data?.length) && list }
<Box marginTop={ 8 }>
<Button
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 {
Tag,
Tr,
Td,
HStack,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { AddressTag } from 'types/api/account';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props {
......@@ -32,10 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<HStack spacing={ 4 }>
<AddressIcon address={ item.address_hash }/>
<AddressLinkWithTooltip address={ item.address_hash }/>
</HStack>
<AddressSnippet address={ item.address_hash }/>
</Td>
<Td>
<TruncatedTextTooltip label={ item.name }>
......@@ -45,10 +39,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</TruncatedTextTooltip>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</Tr>
);
......
......@@ -37,7 +37,7 @@ const DeletePrivateTagModal: React.FC<Props> = ({ isOpen, onClose, data, type })
const renderText = useCallback(() => {
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 ]);
......
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 React, { useCallback, useState } from 'react';
import type { AddressTags, AddressTag } from 'types/api/account';
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 SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import AddressModal from './AddressModal/AddressModal';
import AddressTagListItem from './AddressTagTable/AddressTagListItem';
import AddressTagTable from './AddressTagTable/AddressTagTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
......@@ -18,6 +22,7 @@ const PrivateAddressTags = () => {
const addressModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ addressModalData, setAddressModalData ] = useState<AddressTag>();
const [ deleteModalData, setDeleteModalData ] = useState<AddressTag>();
......@@ -43,36 +48,55 @@ const PrivateAddressTags = () => {
}, [ deleteModalProps ]);
const description = (
<Text marginBottom={ 12 }>
Use private transaction tags to label any transactions of interest.
<AccountPageDescription>
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.
</Text>
</AccountPageDescription>
);
if (isLoading && !addressTagsData) {
return (
const loader = isMobile ? <SkeletonAccountMobile/> : (
<>
{ description }
<SkeletonTable columns={ [ '60%', '40%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ Boolean(addressTagsData?.length) && (
<AddressTagTable
data={ addressTagsData }
const list = isMobile ? (
<Box>
{ addressTagsData.map((item: AddressTag) => (
<AddressTagListItem
item={ item }
key={ item.id }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
)) }
</Box>
) : (
<AddressTagTable
data={ addressTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ Boolean(addressTagsData?.length) && list }
<Box marginTop={ 8 }>
<Button
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 React, { useCallback, useState } from 'react';
import type { TransactionTags, TransactionTag } from 'types/api/account';
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 SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePrivateTagModal from './DeletePrivateTagModal';
import TransactionModal from './TransactionModal/TransactionModal';
import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem';
import TransactionTagTable from './TransactionTagTable/TransactionTagTable';
const PrivateTransactionTags = () => {
......@@ -18,6 +22,7 @@ const PrivateTransactionTags = () => {
const transactionModalProps = useDisclosure();
const deleteModalProps = useDisclosure();
const isMobile = useIsMobile();
const [ transactionModalData, setTransactionModalData ] = useState<TransactionTag>();
const [ deleteModalData, setDeleteModalData ] = useState<TransactionTag>();
......@@ -43,36 +48,55 @@ const PrivateTransactionTags = () => {
}, [ deleteModalProps ]);
const description = (
<Text marginBottom={ 12 }>
<AccountPageDescription>
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.
</Text>
</AccountPageDescription>
);
if (isLoading && !transactionTagsData) {
return (
const loader = isMobile ? <SkeletonAccountMobile/> : (
<>
{ description }
<SkeletonTable columns={ [ '75%', '25%', '108px' ] }/>
<Skeleton height="44px" width="156px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<>
{ description }
{ Boolean(transactionTagsData.length) && (
<TransactionTagTable
data={ transactionTagsData }
const list = isMobile ? (
<Box>
{ transactionTagsData.map((item) => (
<TransactionTagListItem
item={ item }
key={ item.id }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
) }
)) }
</Box>
) : (
<TransactionTagTable
data={ transactionTagsData }
onDeleteClick={ onDeleteClick }
onEditClick={ onEditClick }
/>
);
return (
<>
{ description }
{ Boolean(transactionTagsData.length) && list }
<Box marginTop={ 8 }>
<Button
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 {
Tag,
Tr,
Td,
HStack,
Tooltip,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { TransactionTag } from 'types/api/account';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TransactionSnippet from 'ui/shared/TransactionSnippet';
interface Props {
item: TransactionTag;
......@@ -19,7 +17,7 @@ interface Props {
onDeleteClick: (data: TransactionTag) => void;
}
const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const TransactionTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
......@@ -31,7 +29,7 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return (
<Tr alignItems="top" key={ item.id }>
<Td>
<AddressLinkWithTooltip address={ item.transaction_hash }/>
<TransactionSnippet hash={ item.transaction_hash }/>
</Td>
<Td>
<Tooltip label={ item.name }>
......@@ -41,13 +39,10 @@ const AddressTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</Tooltip>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</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 React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react';
......@@ -22,6 +22,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
const tags = data.tags.split(';');
const queryClient = useQueryClient();
const formBackgroundColor = useColorModeValue('white', 'gray.900');
const deleteApiKey = useCallback(() => {
const body = JSON.stringify({ remove_reason: reason });
......@@ -44,9 +45,9 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
if (tags.length === 1) {
text = (
<>
<Text display="flex">Public tag</Text>
<Text fontWeight="600" whiteSpace="pre">{ ` "${ tags[0] }" ` }</Text>
<Text>will be removed.</Text>
<Text display="inline" as="span">Public tag</Text>
<Text fontWeight="600" whiteSpace="pre" as="span">{ ` "${ tags[0] }" ` }</Text>
<Text as="span">will be removed.</Text>
</>
);
}
......@@ -54,29 +55,29 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
const tagsText: Array<JSX.Element | string> = [];
tags.forEach((tag, index) => {
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(',');
}
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');
}
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>Public tags</Text>{ tagsText }<Text>will be removed.</Text>
<Text as="span">Public tags</Text>{ tagsText }<Text as="span">will be removed.</Text>
</>
);
}
return (
<>
<Flex marginBottom={ 12 }>
<Box marginBottom={ 12 }>
{ text }
</Flex>
<FormControl variant="floating" id="tag-delete">
</Box>
<FormControl variant="floating" id="tag-delete" backgroundColor={ formBackgroundColor }>
<Textarea
size="lg"
value={ reason }
......@@ -86,7 +87,7 @@ const DeletePublicTagModal: React.FC<Props> = ({ isOpen, onClose, data, onDelete
</FormControl>
</>
);
}, [ tags, reason, onFieldChange ]);
}, [ tags, reason, onFieldChange, formBackgroundColor ]);
return (
<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 {
Box,
Tag,
Tr,
Td,
HStack,
VStack,
Text,
} from '@chakra-ui/react';
......@@ -11,10 +9,8 @@ import React, { useCallback } from 'react';
import type { PublicTag } from 'types/api/account';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import AddressSnippet from 'ui/shared/AddressSnippet';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props {
......@@ -36,18 +32,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
<Tr alignItems="top" key={ item.id }>
<Td>
<VStack spacing={ 4 } alignItems="unset">
{ item.addresses.map((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>
);
}) }
{ item.addresses.map((address) => <AddressSnippet key={ address } address={ address }/>) }
</VStack>
</Td>
<Td>
......@@ -69,10 +54,7 @@ const PublicTagTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</VStack>
</Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</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 React, { useCallback, useState } from 'react';
import type { PublicTags, PublicTag } from 'types/api/account';
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 SkeletonAccountMobile from 'ui/shared/SkeletonAccountMobile';
import SkeletonTable from 'ui/shared/SkeletonTable';
import DeletePublicTagModal from './DeletePublicTagModal';
......@@ -19,6 +23,7 @@ type Props = {
const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
const deleteModalProps = useDisclosure();
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'));
......@@ -41,32 +46,53 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => {
}, [ deleteModalProps ]);
const description = (
<Text marginBottom={ 12 }>
<AccountPageDescription>
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.
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.
</Text>
</AccountPageDescription>
);
if (isLoading) {
return (
const loader = isMobile ? <SkeletonAccountMobile/> : (
<>
{ description }
<SkeletonTable columns={ [ '50%', '25%', '25%', '108px' ] }/>
<Skeleton height="48px" width="270px" marginTop={ 8 }/>
</>
);
return (
<>
{ description }
{ loader }
</>
);
}
if (isError) {
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 (
<>
{ description }
{ data.length > 0 && <PublicTagTable data={ data } onEditClick={ onItemEditClick } onDeleteClick={ onItemDeleteClick }/> }
{ data.length > 0 && list }
<Box marginTop={ 8 }>
<Button
variant="primary"
......
import { IconButton, Icon } from '@chakra-ui/react';
import { IconButton, Icon, Flex } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
import { Controller } from 'react-hook-form';
......@@ -17,24 +17,25 @@ interface Props {
error?: FieldError;
onAddFieldClick: (e: React.SyntheticEvent) => void;
onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void;
size?: string;
}
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`>}) => {
return (
<AddressInput<Inputs, `addresses.${ number }.address`>
field={ field }
error={ error }
size="lg"
size={ size }
placeholder="Smart contract / Address (0x...)"
/>
);
}, [ error ]);
}, [ error, size ]);
return (
<>
<Flex flexDir="column" rowGap={ 5 } alignItems="flex-end">
<Controller
name={ `addresses.${ index }.address` }
control={ control }
......@@ -44,31 +45,34 @@ export default function PublicTagFormAction({ control, index, fieldsLength, erro
required: index === 0,
}}
/>
{ index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && (
<IconButton
aria-label="add"
variant="iconBorder"
w="30px"
h="30px"
onClick={ onAddFieldClick }
icon={ <Icon as={ PlusIcon } w="20px" h="20px"/> }
position="absolute"
right={ index === 0 ? '-50px' : '-100px' }
top="25px"
/>
) }
{ fieldsLength > 1 && (
<IconButton
aria-label="delete"
variant="iconBorder"
w="30px"
h="30px"
onClick={ onRemoveFieldClick(index) }
icon={ <Icon as={ MinusIcon } w="20px" h="20px"/> }
position="absolute"
right="-50px"
top="25px"
/>
) }</>
<Flex
columnGap={ 5 }
position={{ base: 'static', lg: 'absolute' }}
left={{ base: 'auto', lg: 'calc(100% + 20px)' }}
h="100%"
alignItems="center"
>
{ fieldsLength > 1 && (
<IconButton
aria-label="delete"
variant="iconBorder"
w="30px"
h="30px"
onClick={ onRemoveFieldClick(index) }
icon={ <Icon as={ MinusIcon } w="20px" h="20px"/> }
/>
) }
{ index === fieldsLength - 1 && fieldsLength < MAX_INPUTS_NUM && (
<IconButton
aria-label="add"
variant="iconBorder"
w="30px"
h="30px"
onClick={ onAddFieldClick }
icon={ <Icon as={ PlusIcon } w="20px" h="20px"/> }
/>
) }
</Flex>
</Flex>
);
}
......@@ -12,23 +12,24 @@ const TEXT_INPUT_MAX_LENGTH = 255;
interface Props {
control: Control<Inputs>;
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'>}) => {
return (
<FormControl variant="floating" id={ field.name } size="lg" isRequired>
<FormControl variant="floating" id={ field.name } size={ size } isRequired>
<Textarea
{ ...field }
isInvalid={ Boolean(error) }
size="lg"
size={ size }
/>
<FormLabel>
{ getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error?.message) }
</FormLabel>
</FormControl>
);
}, [ error ]);
}, [ error, size ]);
return (
<Controller
......
......@@ -16,6 +16,7 @@ import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types
import type { ErrorType } from 'lib/client/fetch';
import fetch from 'lib/client/fetch';
import getErrorMessage from 'lib/getErrorMessage';
import useIsMobile from 'lib/hooks/useIsMobile';
import { EMAIL_REGEXP } from 'lib/validations/email';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
......@@ -52,10 +53,12 @@ const placeholders = {
comment: 'Specify the reason for adding tags and color preference(s).',
} as Record<Path<Inputs>, string>;
const ADDRESS_INPUT_BUTTONS_WIDTH = 170;
const ADDRESS_INPUT_BUTTONS_WIDTH = 100;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const inputSize = isMobile ? 'md' : 'lg';
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({
defaultValues: {
......@@ -149,10 +152,10 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
}, [ changeToDataScreen ]);
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> }
<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>
<PublicTagsFormInput<Inputs>
fieldName="fullName"
......@@ -160,6 +163,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
label={ placeholders.fullName }
error={ errors.fullName }
required
size={ inputSize }
/>
</GridItem>
<GridItem>
......@@ -168,6 +172,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control }
label={ placeholders.companyName }
error={ errors.companyName }
size={ inputSize }
/>
</GridItem>
<GridItem>
......@@ -178,6 +183,7 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
pattern={ EMAIL_REGEXP }
error={ errors.email }
required
size={ inputSize }
/>
</GridItem>
<GridItem>
......@@ -186,10 +192,11 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
control={ control }
label={ placeholders.companyUrl }
error={ errors?.companyUrl }
size={ inputSize }
/>
</GridItem>
</Grid>
<Box marginTop={ 4 } marginBottom={ 8 }>
<Box marginTop={{ base: 5, lg: 8 }} marginBottom={{ base: 5, lg: 8 }}>
<PublicTagFormAction control={ control }/>
</Box>
<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) => {
control={ control }
label={ placeholders.tags }
error={ errors.tags }
required/>
required
size={ inputSize }
/>
</Box>
{ fields.map((field, index) => {
return (
......@@ -211,12 +220,13 @@ const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
fieldsLength={ fields.length }
onAddFieldClick={ onAddFieldClick }
onRemoveFieldClick={ onRemoveFieldClick }
size={ inputSize }
/>
</Box>
);
}) }
<Box marginBottom={ 8 }>
<PublicTagFormComment control={ control } error={ errors.comment }/>
<PublicTagFormComment control={ control } error={ errors.comment } size={ inputSize }/>
</Box>
<HStack spacing={ 6 }>
<Button
......
......@@ -14,6 +14,7 @@ interface Props<TInputs extends FieldValues> {
control: Control<TInputs, object>;
pattern?: RegExp;
error?: FieldError;
size?: string;
}
export default function PublicTagsFormInput<Inputs extends FieldValues>({
......@@ -23,13 +24,14 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
fieldName,
pattern,
error,
size,
}: Props<Inputs>) {
const renderInput = useCallback(({ field }: {field: ControllerRenderProps<Inputs, typeof fieldName>}) => {
return (
<FormControl variant="floating" id={ field.name } isRequired={ required } size="lg">
<FormControl variant="floating" id={ field.name } isRequired={ required } size={ size }>
<Input
{ ...field }
size="lg"
size={ size }
required={ required }
isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH }
......@@ -37,7 +39,7 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
<FormLabel>{ getPlaceholderWithError(label, error?.message) }</FormLabel>
</FormControl>
);
}, [ label, required, error ]);
}, [ label, required, error, size ]);
return (
<Controller
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 React from 'react';
const PageHeader = ({ text }: {text: string}) => {
const AccountPageHeader = ({ text }: {text: string}) => {
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';
const AddressIcon = ({ address }: {address: string}) => {
return (
<Box width="24px">
<Box width="24px" display="inline-flex">
<Jazzicon diameter={ 24 } seed={ jsNumberForAddress(address) }/>
</Box>
);
......
......@@ -8,17 +8,28 @@ import CopyToClipboard from './CopyToClipboard';
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 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 (
<HStack spacing={ 2 } alignContent="center" overflow="hidden">
<HStack spacing={ 2 } alignContent="center" overflow="hidden" maxW="100%">
<Link
href={ url }
target="_blank"
overflow="hidden"
fontWeight={ FONT_WEIGHT }
lineHeight="24px"
whiteSpace="nowrap"
>
<AddressWithDots address={ address } fontWeight={ FONT_WEIGHT }/>
</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> = ({
}, [ setAlertVisible, mutation ]);
return (
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md">
<Modal isOpen={ isOpen } onClose={ onModalClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/>
<ModalContent>
<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>({
}, [ onClose, setAlertVisible ]);
return (
<Modal isOpen={ isOpen } onClose={ onModalClose } size="md" >
<Modal isOpen={ isOpen } onClose={ onModalClose } size={{ base: 'full', lg: 'md' }}>
<ModalOverlay/>
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3">{ title }</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
{ (isAlertVisible || text) && (
<Box marginBottom={ 12 }>
<Box marginBottom={{ base: 6, lg: 12 }}>
{ text && (
<Text lineHeight="30px" mb={ 3 }>
{ 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 }) => {
return (
<>
<Box marginBottom={ 5 } marginTop={ 5 }>
<Box marginBottom={ 5 }>
<Controller
name="address"
control={ control }
......@@ -178,7 +178,7 @@ const AddressForm: React.FC<Props> = ({ data, onClose, setAlertVisible }) => {
<Box marginBottom={ 8 }>
<AddressFormNotifications control={ control }/>
</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
name={ 'notification' as Checkboxes }
control={ control }
......
......@@ -20,13 +20,21 @@ export default function AddressFormNotifications<Inputs extends FieldValues, Che
), []);
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) => {
const incomingFieldName = `notification_settings.${ notification }.incoming` as Checkboxes;
const outgoingFieldName = `notification_settings.${ notification }.outcoming` as Checkboxes;
return (
<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>
<Controller
name={ incomingFieldName }
......
......@@ -5,6 +5,7 @@ import React, { useCallback } from 'react';
import type { TWatchlistItem, TWatchlist } from 'types/client/account';
import fetch from 'lib/client/fetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import DeleteModal from 'ui/shared/DeleteModal';
type Props = {
......@@ -15,6 +16,7 @@ type Props = {
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const mutationFn = useCallback(() => {
return fetch(`/api/account1/watchlist/${ data?.id }`, { method: 'DELETE' });
......@@ -29,10 +31,11 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const address = data?.address_hash;
const renderModalContent = useCallback(() => {
const addressString = isMobile ? [ address.slice(0, 4), address.slice(-4) ].join('...') : address;
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 (
<DeleteModal
......
......@@ -6,8 +6,7 @@ import type { TWatchlistItem } from 'types/client/account';
import TokensIcon from 'icons/tokens.svg';
// import WalletIcon from 'icons/wallet.svg';
import { nbsp } from 'lib/html-entities';
import AddressIcon from 'ui/shared/AddressIcon';
import AddressLinkWithTooltip from 'ui/shared/AddressLinkWithTooltip';
import AddressSnippet from 'ui/shared/AddressSnippet';
// now this component works only for xDAI
// for other networks later we will use config or smth
......@@ -18,36 +17,34 @@ const WatchListAddressItem = ({ item }: {item: TWatchlistItem}) => {
const nativeBalance = ((item.address_balance || 0) / 10 ** DECIMALS).toFixed(1);
const nativeBalanceUSD = item.exchange_rate ? `$${ Number(nativeBalance) * item.exchange_rate } USD` : 'N/A';
const infoItemsPaddingLeft = { base: 0, lg: 10 };
return (
<HStack spacing={ 3 } align="top">
<AddressIcon address={ item.address_hash }/>
<VStack spacing={ 2 } align="stretch" overflow="hidden" fontWeight={ 500 } color="gray.700">
<AddressLinkWithTooltip address={ item.address_hash }/>
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<Image src="/xdai.png" alt="chain-logo" marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + nativeBalance }</Text>
<Text variant="secondary">{ `${ nbsp }(${ nativeBalanceUSD })` }</Text>
<VStack spacing={ 2 } align="stretch" overflow="hidden" fontWeight={ 500 } color="gray.700">
<AddressSnippet address={ item.address_hash }/>
<HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<Image src="/xdai.png" alt="chain-logo" marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `xDAI balance:${ nbsp }` + nativeBalance }</Text>
<Text variant="secondary">{ `${ nbsp }(${ nativeBalanceUSD })` }</Text>
</HStack>
{ 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>
{ item.tokens_count && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
<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>
) }
{ /* api does not provide token prices */ }
{ /* { item.address_balance && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 }>
) }
{ /* api does not provide token prices */ }
{ /* { item.address_balance && (
<HStack spacing={ 0 } fontSize="sm" h={ 6 } pl={ infoItemsPaddingLeft }>
<Icon as={ WalletIcon } marginRight="10px" w="16px" h="16px"/>
<Text color={ mainTextColor }>{ `Net worth:${ nbsp }` }</Text>
<Link href="#">{ `$${ item.totalUSD } USD` }</Link>
</HStack>
) } */ }
</VStack>
</HStack>
</VStack>
);
};
......
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 {
Tr,
Td,
Switch,
HStack,
} from '@chakra-ui/react';
import { useMutation } from '@tanstack/react-query';
import React, { useCallback, useState } from 'react';
import type { TWatchlistItem } from 'types/client/account';
import DeleteButton from 'ui/shared/DeleteButton';
import EditButton from 'ui/shared/EditButton';
import fetch from 'lib/client/fetch';
import useToast from 'lib/hooks/useToast';
import TableItemActionButtons from 'ui/shared/TableItemActionButtons';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
import WatchListAddressItem from './WatchListAddressItem';
......@@ -24,6 +24,7 @@ interface Props {
const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
const [ notificationEnabled, setNotificationEnabled ] = useState(item.notification_methods.email);
const [ switchDisabled, setSwitchDisabled ] = useState(false);
const onItemEditClick = useCallback(() => {
return onEditClick(item);
}, [ item, onEditClick ]);
......@@ -32,16 +33,33 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
return onDeleteClick(item);
}, [ 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(() => {
setSwitchDisabled(true);
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: () => {
// eslint-disable-next-line no-console
console.log('error');
showToast();
setNotificationEnabled(prevState => !prevState);
setSwitchDisabled(false);
},
onSuccess: () => {
setNotificationEnabled(prevState => !prevState);
setSwitchDisabled(false);
},
});
......@@ -59,12 +77,18 @@ const WatchlistTableItem = ({ item, onEditClick, onDeleteClick }: Props) => {
</Tag>
</TruncatedTextTooltip>
</Td>
<Td><Switch colorScheme="blue" size="md" isChecked={ notificationEnabled } onChange={ onSwitch }/></Td>
<Td>
<HStack spacing={ 6 }>
<EditButton onClick={ onItemEditClick }/>
<DeleteButton onClick={ onItemDeleteClick }/>
</HStack>
<Switch
colorScheme="blue"
size="md"
isChecked={ notificationEnabled }
onChange={ onSwitch }
isDisabled={ switchDisabled }
aria-label="Email notification"
/>
</Td>
<Td>
<TableItemActionButtons onDeleteClick={ onItemDeleteClick } onEditClick={ onItemEditClick }/>
</Td>
</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