Commit ea3d83c9 authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into fix-ci-cd

parents 58eeb850 1cc50d40
......@@ -4,6 +4,8 @@ NEXT_PUBLIC_FOOTER_GITHUB_LINK=APP_NEXT_NEXT_PUBLIC_FOOTER_GITHUB_LINK
NEXT_PUBLIC_FOOTER_TWITTER_LINK=APP_NEXT_NEXT_PUBLIC_FOOTER_TWITTER_LINK
NEXT_PUBLIC_FOOTER_TELEGRAM_LINK=APP_NEXT_NEXT_PUBLIC_FOOTER_TELEGRAM_LINK
NEXT_PUBLIC_FOOTER_STAKING_LINK=APP_NEXT_NEXT_PUBLIC_FOOTER_STAKING_LINK
NEXT_PUBLIC_FOOTER_STAKING_LINK=APP_NEXT_NEXT_PUBLIC_FOOTER_STAKING_LINK
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=APP_NEXT_NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM
NEXT_PUBLIC_SENTRY_DSN=APP_NEXT_NEXT_PUBLIC_SENTRY_DSN
NEXT_PUBLIC_APP_INSTANCE=APP_NEXT_NEXT_PUBLIC_APP_INSTANCE
NEXT_PUBLIC_NETWORK_NAME=APP_NEXT_NEXT_PUBLIC_NETWORK_NAME
......
{
"recommendations": [
"formulahendry.auto-close-tag",
"formulahendry.auto-rename-tag",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
]
}
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"problemMatcher": [],
"label": "dev server",
"detail": "start local dev server",
"presentation": {
"reveal": "silent",
"panel": "new",
"close": true,
"revealProblems": "onProblem",
},
"icon": {
"color": "terminal.ansiGreen",
"id": "server-process"
},
"runOptions": {
"instanceLimit": 1
}
},
{
"type": "typescript",
"label": "tsc build",
"detail": "run ts typechecking",
"tsconfig": "tsconfig.json",
"problemMatcher": [
"$tsc"
],
"icon": {
"color": "terminal.ansiCyan",
"id": "symbol-type-parameter"
},
"presentation": {
"reveal": "never",
"panel": "new",
"close": true,
"revealProblems": "onProblem",
},
"group": "build",
}
]
}
\ No newline at end of file
......@@ -20,7 +20,7 @@ And of course our premier language is [Typescript](https://www.typescriptlang.or
For local development please follow next steps:
- clone repo
- install dependencies with `yarn`
- clone `env.example` into `configs/envs/env.secrets` and fill it with necessary secret values (see description [below](#environment-variables))
- clone `.env.example` into `configs/envs/.env.secrets` and fill it with necessary secret values (see description [below](#environment-variables))
- to spin up local dev server
- for predefined networks configs (see full available list in `package.json`) you can just run `yarn dev:<app_name>`
- for custom network setup create `.env.local` file with all required environment variables from the [list](#environment-variables) and run `yarn dev`
......@@ -50,6 +50,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_FOOTER_TWITTER_LINK | `string` *(optional)* | Link to Twitter in the footer | `https://www.twitter.com/blockscoutcom` |
| NEXT_PUBLIC_FOOTER_TELEGRAM_LINK | `string` *(optional)* | Link to Telegram in the footer | `https://t.me/poa_network` |
| NEXT_PUBLIC_FOOTER_STAKING_LINK | `string` *(optional)* | Link to staking dashboard in the footer | `https://duneanalytics.com/maxaleks/xdai-staking` |
| NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM | `string` | Link to form where authors can submit their dapps to the marketplace | `https://airtable.com/shrqUAcjgGJ4jU88C` |
| NEXT_PUBLIC_APP_INSTANCE | `string` *(optional)* | Name of app instance | `wonderful_kepler` |
| NEXT_PUBLIC_APP_PROTOCOL | `http \| https` *(optional)* | App protocol (`https` used as default value) | `https` |
| NEXT_PUBLIC_APP_HOST | `string` | App host | `blockscout.com` |
......
......@@ -36,6 +36,7 @@ const config = Object.freeze({
featuredNetworks: process.env.NEXT_PUBLIC_FEATURED_NETWORKS?.replaceAll('\'', '"'),
blockScoutVersion: process.env.NEXT_PUBLIC_BLOCKSCOUT_VERSION,
isAccountSupported: process.env.NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED?.replaceAll('\'', '"') === 'true',
marketplaceSubmitForm: process.env.NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM,
protocol: process.env.NEXT_PUBLIC_APP_PROTOCOL,
host: process.env.NEXT_PUBLIC_APP_HOST,
port: process.env.NEXT_PUBLIC_APP_PORT,
......
......@@ -5,4 +5,5 @@ NEXT_PUBLIC_BLOCKSCOUT_VERSION=v4.1.7-beta
NEXT_PUBLIC_FOOTER_GITHUB_LINK=https://github.com/blockscout/blockscout
NEXT_PUBLIC_FOOTER_TWITTER_LINK=https://www.twitter.com/blockscoutcom
NEXT_PUBLIC_APP_INSTANCE=local
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_FEATURED_NETWORKS=[{'title':'Gnosis Chain','basePath':'/xdai/mainnet','group':'mainnets'}]
This diff is collapsed.
This diff is collapsed.
/* eslint-disable max-len */
export const tx = {
hash: '0x1ea365d2144796f793883534aa51bf20d23292b19478994eede23dfc599e7c34',
status: 'success' as TxStatus,
status: 'ok' as Transaction['status'],
block_num: 15006918,
confirmation_num: 283,
confirmation_duration: 30,
......@@ -45,11 +45,11 @@ export const tx = {
input_hex: '0x42842e0e0000000000000000000000007767dac225a233ea1055d79fb227b1696d538b75000000000000000000000000fc3017c31fe752fc48e904050ea5d6edfc38a1b00000000000000000000000000000000000000000000000000000000000000e3b',
transferred_tokens: [
{ from: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01', to: '0xF7A558692dFB5F456e291791da7FAE8Dd046574e', token: { symbol: 'VIK', hash: '0xADFE00d92e5A16e773891F59780e6e54f40B532e', name: 'Viktor Coin' }, amount: 192.7, usd: 194.05 },
{ from: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', to: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01', token: { symbol: 'PAO', hash: '0xC98a06220239818B086CD96756d4E3bC41EC848E', name: 'POA Candy' }, amount: 76.1851851851846, usd: 194.05 },
{ from: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', to: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01', token: { symbol: 'PAO', hash: '0xC98a06220239818B086CD96756d4E3bC41EC848f', name: 'POA Candy' }, amount: 76.1851851851846, usd: 194.05 },
],
txType: 'transaction' as TxType,
};
export type TxType = 'contract-call' | 'transaction' | 'token-transfer' | 'internal-tx' | 'multicall';
export type TxStatus = 'success' | 'failed' | 'pending';
import type { Transaction } from 'types/api/transaction';
import type { TxInternalsType } from 'types/api/tx';
export const data = [
{
id: 1,
type: 'call' as TxInternalsType,
status: 'success' as const,
from: { hash: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01' },
to: { hash: '0xF7A558692dFB5F456e291791da7FAE8Dd046574e' },
value: 0.25207646303,
gasLimit: 369472,
},
{
id: 2,
type: 'delegate_call' as TxInternalsType,
status: 'success' as const,
from: { hash: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45' },
to: { hash: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01' },
value: 0.5633333,
gasLimit: 340022,
},
{
id: 3,
type: 'static_call' as TxInternalsType,
status: 'failed' as const,
from: { hash: '0x97Aa2EfcF35c0f4c9AaDDCa8c2330fa7A9533830' },
to: { hash: '0x35317007D203b8a86CA727ad44E473E40450E378' },
value: 0.421152366,
gasLimit: 509333,
},
];
export const data = [
{
address: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01',
topics: [
{ hex: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' },
{ hex: '0x000000000000000000000000def171fe48cf0115b1d80b88dc8eab59176fee57' },
{ hex: '0x000000000000000000000000c465c0a16228ef6fe1bf29c04fdb04bb797fd537' },
],
data: '0x000000000000000000000000000000000000000000000000019faae14eb88000',
},
{
address: '0x73968b9a57c6e53d41345fd57a6e6ae27d6cdb2f',
topics: [
{ hex: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' },
{ hex: '0x000000000000000000000000c465c0a16228ef6fe1bf29c04fdb04bb797fd537' },
{ hex: '0x0000000000000000000000008453d9385af5f49edad9905345cd2411b5c5831b' },
],
data: '0x000000000000000000000000000000000000000000000013b6ee62022c95ced4',
},
];
import type { Transaction } from 'types/api/transaction';
import { tx } from './tx';
import type { TxType, TxStatus } from './tx';
import type { TxType } from './tx';
export const txs = [
{
......@@ -10,7 +12,7 @@ export const txs = [
},
{
...tx,
status: 'failed' as TxStatus,
status: 'error' as Transaction['status'],
errorText: 'Error: (Awaiting internal transactions for reason)',
txType: 'contract-call' as TxType,
method: 'CommitHash CommitHash CommitHash CommitHash',
......@@ -25,7 +27,7 @@ export const txs = [
},
{
...tx,
status: 'pending' as TxStatus,
status: null,
txType: 'token-transfer' as TxType,
method: 'Multicall',
address_from: {
......
declare module 'react-identicons'
declare module 'data/marketplaceApps.json' {
import type { AppItemOverview } from './types/client/apps';
const value: Array<AppItemOverview>;
export default value;
}
export const WEI = BigInt(10 ** 18);
export const GWEI = BigInt(10 ** 9);
......@@ -2,6 +2,8 @@ import appConfig from 'configs/app/config';
import featuredNetworks from 'lib/networks/featuredNetworks';
import getMarketplaceApps from '../getMarketplaceApps';
const KEY_WORDS = {
BLOB: 'blob:',
DATA: 'data:',
......@@ -27,6 +29,14 @@ function getNetworksExternalAssets() {
return logo ? icons.concat(logo) : icons;
}
function getMarketplaceAppsOrigins() {
return getMarketplaceApps().map(({ url }) => url);
}
function getMarketplaceAppsLogosOrigins() {
return getMarketplaceApps().map(({ logo }) => logo);
}
function makePolicyMap() {
const networkExternalAssets = getNetworksExternalAssets();
......@@ -84,6 +94,9 @@ function makePolicyMap() {
// network assets
...networkExternalAssets.map((url) => url.host),
// marketplace apps logos
...getMarketplaceAppsLogosOrigins(),
],
'font-src': [
......@@ -102,12 +115,13 @@ function makePolicyMap() {
KEY_WORDS.NONE,
],
'frame-src': getMarketplaceAppsOrigins(),
...(REPORT_URI ? {
'report-uri': [
REPORT_URI,
],
} : {}),
};
}
......
......@@ -36,37 +36,16 @@ dayjs.updateLocale('en', {
ss: '%d secs',
future: 'in %s',
past: '%s ago',
m: 'a min',
m: '1 min',
mm: '%d mins',
h: 'an hour',
hh: '%d hours',
d: 'a day',
dd: '%d days',
M: 'a month',
MM: '%d months',
y: 'a year',
yy: '%d years',
},
});
dayjs.locale('en-short', {
name: 'en-short',
relativeTime: {
s: '1s',
future: 'in %s',
past: '%s ago',
m: '1m',
mm: '%dm',
h: '1h',
hh: '%dh',
d: '1d',
dd: '%dd',
M: '1mo',
MM: '%dmo',
y: '1y',
yy: '%dy',
// have to trick typescript 🎩 🐇
...{ ss: '%ds' },
h: '1 h',
hh: '%d h',
d: '1 d',
dd: '%d d',
M: '1 mo',
MM: '%d mo',
y: '1 y',
yy: '%d y',
},
});
......
import data from 'data/marketplaceApps.json';
export default function getMarketplaceApps() {
return data;
}
import React from 'react';
import appConfig from 'configs/app/config';
import React, { useMemo } from 'react';
import marketplaceApps from 'data/marketplaceApps.json';
import abiIcon from 'icons/ABI.svg';
import apiKeysIcon from 'icons/API.svg';
import appsIcon from 'icons/apps.svg';
......@@ -13,8 +15,14 @@ import transactionsIcon from 'icons/transactions.svg';
import watchlistIcon from 'icons/watchlist.svg';
import useCurrentRoute from 'lib/link/useCurrentRoute';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
export default function useNavItems() {
const isMarketplaceFilled = useMemo(() =>
marketplaceApps.filter(item => item.chainIds.includes(appConfig.network.id)),
[ ])
.length > 0;
const link = useLink();
const currentRoute = useCurrentRoute()();
......@@ -23,12 +31,13 @@ export default function useNavItems() {
{ text: 'Blocks', url: link('blocks'), icon: blocksIcon, isActive: currentRoute.startsWith('block') },
{ text: 'Transactions', url: link('txs_validated'), icon: transactionsIcon, isActive: currentRoute.startsWith('tx') },
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens' },
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps' },
isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps' } : null,
// there should be custom site sections like Stats, Faucet, More, etc but never an 'other'
// examples https://explorer-edgenet.polygon.technology/ and https://explorer.celo.org/
// at this stage custom menu items is under development, we will implement it later
// { text: 'Other', url: link('other'), icon: gearIcon, isActive: currentRoute === 'other' },
];
].filter(notEmpty);
const accountNavItems = [
{ text: 'Watchlist', url: link('watchlist'), icon: watchlistIcon, isActive: currentRoute === 'watchlist' },
......@@ -41,5 +50,5 @@ export default function useNavItems() {
const profileItem = { text: 'My profile', url: link('profile'), icon: profileIcon, isActive: currentRoute === 'profile' };
return { mainNavItems, accountNavItems, profileItem };
}, [ link, currentRoute ]);
}, [ isMarketplaceFilled, link, currentRoute ]);
}
......@@ -104,6 +104,9 @@ export const ROUTES = {
apps: {
pattern: `${ BASE_PATH }/apps`,
},
app_index: {
pattern: `${ BASE_PATH }/apps/[id]`,
},
// SEARCH
search_results: {
......
export default function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
}
export default function getConfirmationString(durations: Array<number>) {
if (durations.length === 0) {
return '';
}
const [ lower, upper ] = durations.map((time) => time / 1_000);
if (!upper) {
return `Confirmed within ${ lower } secs`;
}
if (lower === 0) {
return `Confirmed within <= ${ upper } secs`;
}
return `Confirmed within ${ lower } - ${ upper } secs`;
}
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import App from 'ui/pages/App';
import type { AppItemOverview } from 'types/client/apps';
import marketplaceApps from 'data/marketplaceApps.json';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import MarketplaceApp from 'ui/pages/MarketplaceApp';
import Page from 'ui/shared/Page/Page';
const AppPage: NextPage = () => {
const router = useRouter();
const [ isLoading, setIsLoading ] = useState(true);
const [ app, setApp ] = useState<AppItemOverview | undefined>(undefined);
const { id }: { id?: string } = router.query;
useEffect(() => {
if (!id) {
return;
}
const app = marketplaceApps.find((app) => app.id === id);
setApp(app);
setIsLoading(false);
}, [ id ]);
if (app || isLoading) {
return (
<>
<Head><title>{ app ? `Blockscout | ${ app.title }` : 'Loading app..' }</title></Head>
<MarketplaceApp app={ app } isLoading={ isLoading }/>
</>
);
}
return (
<>
<Head><title>App Card Page</title></Head>
<App/>
</>
<Page>
<Head><title>Blockscout | No app found</title></Head>
<EmptySearchResult text={ `Couldn${ apos }t find an app.` }/>
</Page>
);
};
......
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/v2/transactions/${ req.query.id }`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/v2/transactions/${ req.query.id }/internal-transactions`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/v2/transactions/${ req.query.id }/logs`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import type { NextApiRequest } from 'next';
import handler from 'lib/api/handler';
const getUrl = (req: NextApiRequest) => {
return `/v2/transactions/${ req.query.id }/raw-trace`;
};
const requestHandler = handler(getUrl, [ 'GET' ]);
export default requestHandler;
import { formAnatomy as parts } from '@chakra-ui/anatomy';
import {
createMultiStyleConfigHelpers,
} from '@chakra-ui/styled-system';
import { createMultiStyleConfigHelpers } from '@chakra-ui/styled-system';
import type { StyleFunctionProps } from '@chakra-ui/theme-tools';
import { getColor, mode } from '@chakra-ui/theme-tools';
import type { Dict } from '@chakra-ui/utils';
import getDefaultFormColors from '../utils/getDefaultFormColors';
import FormLabel from './FormLabel';
import Input from './Input';
import Textarea from './Textarea';
const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(parts.keys);
const getActiveLabelStyles = (theme: Dict, fc: string, bc: string, size: 'md' | 'lg') => {
const baseStyles = {
backgroundColor: bc,
color: getColor(theme, fc),
fontSize: 'xs',
lineHeight: '16px',
borderTopRightRadius: 'none',
};
switch (size) {
case 'md': {
return {
...baseStyles,
padding: '10px 16px 2px 16px',
};
}
case 'lg': {
return {
...baseStyles,
padding: '16px 24px 2px 24px',
};
}
}
};
const getDefaultLabelStyles = (size: 'md' | 'lg') => {
switch (size) {
case 'md': {
return {
fontSize: 'md',
lineHeight: '20px',
padding: '18px 16px',
right: '18px',
};
}
case 'lg': {
return {
fontSize: 'md',
lineHeight: '24px',
padding: '28px 24px',
right: '26px',
};
}
}
};
const getPaddingX = (size: 'md' | 'lg') => {
switch (size) {
case 'md': {
return '16px';
}
function getFloatingVariantStylesForSize(size: 'md' | 'lg', props: StyleFunctionProps) {
const { theme } = props;
const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
case 'lg': {
return '24px';
const activeLabelStyles = {
...FormLabel.variants?.floating?.(props)._focusWithin,
...FormLabel.sizes?.[size](props)._focusWithin,
} || {};
const activeInputStyles = (() => {
switch (size) {
case 'md': {
return {
paddingTop: '26px',
paddingBottom: '10px',
};
}
case 'lg': {
return {
paddingTop: '38px',
paddingBottom: '18px',
};
}
}
}
};
})();
const getActiveInputStyles = (size: 'md' | 'lg') => {
switch (size) {
case 'md': {
return {
paddingTop: '26px',
paddingBottom: '10px',
};
}
const inputPx = (() => {
switch (size) {
case 'md': {
return '16px';
}
case 'lg': {
return {
paddingTop: '38px',
paddingBottom: '18px',
};
case 'lg': {
return '24px';
}
}
}
};
const variantFloating = definePartsStyle((props) => {
const { theme, backgroundColor, size = 'md' } = props;
const { focusColor: fc, errorColor: ec } = getDefaultFormColors(props);
const bc = backgroundColor || mode('white', 'black')(props);
const px = getPaddingX(size);
const activeInputStyles = getActiveInputStyles(size);
const activeLabelStyles = getActiveLabelStyles(theme, fc, bc, size);
})();
return {
container: {
// active styles
_focusWithin: {
label: {
...activeLabelStyles,
},
'input, textarea': {
...activeInputStyles,
},
'label .chakra-form__required-indicator': {
color: getColor(theme, fc),
},
},
// label's styles
label: {
...getDefaultLabelStyles(size),
left: '2px',
top: '2px',
zIndex: 2,
position: 'absolute',
borderRadius: 'base',
boxSizing: 'border-box',
color: 'gray.500',
backgroundColor: 'transparent',
pointerEvents: 'none',
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,
label: activeLabelStyles,
'input, textarea': activeInputStyles,
},
// label styles
label: FormLabel.sizes?.[size](props) || {},
'input:not(:placeholder-shown) + label, textarea:not(:placeholder-shown) + label': activeLabelStyles,
'input[aria-invalid=true] + label, textarea[aria-invalid=true] + label': {
color: getColor(theme, ec),
},
// input's styles
// input styles
input: Input.sizes?.[size].field,
textarea: Textarea.sizes?.[size],
'input, textarea': {
padding: px,
padding: inputPx,
},
'input:not(:placeholder-shown), textarea:not(:placeholder-shown)': activeInputStyles,
'input[disabled] + label, textarea[disabled] + label': {
backgroundColor: 'transparent',
},
'input:not(:placeholder-shown), textarea:not(:placeholder-shown)': {
...activeInputStyles,
},
// indicator's styles
// indicator styles
'input:not(:placeholder-shown) + label .chakra-form__required-indicator, textarea:not(:placeholder-shown) + label .chakra-form__required-indicator': {
color: getColor(theme, fc),
},
......@@ -153,6 +84,11 @@ const variantFloating = definePartsStyle((props) => {
color: getColor(theme, ec),
},
},
};
}
const baseStyle = definePartsStyle((props) => {
return {
requiredIndicator: {
marginStart: 0,
color: mode('gray.500', 'whiteAlpha.400')(props),
......@@ -160,12 +96,42 @@ const variantFloating = definePartsStyle((props) => {
};
});
const variantFloating = definePartsStyle((props) => {
return {
container: {
label: FormLabel.variants?.floating(props) || {},
},
};
});
const sizes = {
lg: definePartsStyle((props) => {
if (props.variant === 'floating') {
return getFloatingVariantStylesForSize('lg', props);
}
return {};
}),
md: definePartsStyle((props) => {
if (props.variant === 'floating') {
return getFloatingVariantStylesForSize('md', props);
}
return {};
}),
};
const variants = {
floating: variantFloating,
};
const Form = defineMultiStyleConfig({
baseStyle,
variants,
sizes,
defaultProps: {
size: 'md',
},
});
export default Form;
import { defineStyle, defineStyleConfig } from '@chakra-ui/styled-system';
import { getColor, mode } from '@chakra-ui/theme-tools';
import getDefaultFormColors from '../utils/getDefaultFormColors';
const baseStyle = defineStyle({
fontSize: 'md',
marginEnd: '3',
mb: '2',
fontWeight: 'medium',
transitionProperty: 'common',
transitionDuration: 'normal',
opacity: 1,
_disabled: {
opacity: 0.4,
},
});
const variantFloating = defineStyle((props) => {
const { theme, backgroundColor } = props;
const { focusColor: fc } = getDefaultFormColors(props);
const bc = backgroundColor || mode('white', 'black')(props);
return {
left: '2px',
top: '2px',
zIndex: 2,
position: 'absolute',
borderRadius: 'base',
boxSizing: 'border-box',
color: 'gray.500',
backgroundColor: 'transparent',
pointerEvents: 'none',
margin: 0,
transformOrigin: 'top left',
transitionProperty: 'font-size, line-height, padding, top, background-color',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
_focusWithin: {
backgroundColor: bc,
color: getColor(theme, fc),
fontSize: 'xs',
lineHeight: '16px',
borderTopRightRadius: 'none',
},
};
});
const variants = {
floating: variantFloating,
};
const sizes = {
lg: defineStyle((props) => {
if (props.variant === 'floating') {
return {
fontSize: 'md',
lineHeight: '24px',
padding: '28px 24px',
right: '26px',
_focusWithin: {
padding: '16px 24px 2px 24px',
},
};
}
return {};
}),
md: defineStyle((props) => {
if (props.variant === 'floating') {
return {
fontSize: 'md',
lineHeight: '20px',
padding: '18px 16px',
right: '18px',
_focusWithin: {
padding: '10px 16px 2px 16px',
},
};
}
return {};
}),
};
const FormLabel = defineStyleConfig({
variants,
baseStyle,
sizes,
});
export default FormLabel;
......@@ -4,6 +4,7 @@ import Button from './Button';
import Checkbox from './Checkbox';
import Drawer from './Drawer';
import Form from './Form';
import FormLabel from './FormLabel';
import Heading from './Heading';
import Input from './Input';
import Link from './Link';
......@@ -27,6 +28,7 @@ const components = {
Heading,
Input,
Form,
FormLabel,
Link,
Modal,
Popover,
......
export interface AddressParam {
hash: string;
implementation_name: string;
name: string;
is_contract: boolean;
}
import type { AddressParam } from 'types/api/addressParams';
import type { Reward } from 'types/api/reward';
export interface Block {
height: number;
timestamp: string;
tx_count: number;
miner: AddressParam;
size: number;
hash: string;
parent_hash: string;
difficulty: number;
total_difficulty: number;
gas_used: number;
gas_limit: number;
nonce: number;
base_fee_per_gas: number | null;
burnt_fees: number | null;
priority_fee: number | null;
extra_data: string | null;
state_root: string | null;
rewards?: Array<Reward>;
gas_target_percentage: number | null;
gas_used_percentage: number | null;
burnt_fees_percentage: number | null;
type: 'block' | 'reorg' | 'uncle';
tx_fees: string | null;
uncles_hashes: Array<string>;
}
export interface BlockResponse {
items: Array<Block>;
next_page_params: {
block_number: number;
items_count: number;
};
}
export interface DecodedInput {
method_call: string;
method_id: string;
parameters: Array<DecodedInputParams>;
}
export interface DecodedInputParams {
name: string;
type: string;
value: string;
indexed?: boolean;
}
export interface Fee {
type: string;
value: string;
}
import type { AddressParam } from './addressParams';
export type TxInternalsType = 'call' | 'delegatecall' | 'staticcall' | 'create' | 'create2' | 'selfdestruct' | 'reward'
export interface InternalTransaction {
error: string | null;
success: boolean;
type: TxInternalsType;
transaction_hash: string;
from: AddressParam;
to: AddressParam;
created_contract: AddressParam;
value: number;
index: number;
block: number;
timestamp: string;
}
export interface InternalTransactionsResponse {
items: Array<InternalTransaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
transaction_hash: string;
transaction_index: number;
};
}
import type { AddressParam } from './addressParams';
import type { DecodedInput } from './decodedInput';
export interface Log {
address: AddressParam;
topics: Array<string>;
data: string;
index: number;
decoded: DecodedInput | null;
}
export interface LogsResponse {
items: Array<Log>;
next_page_params: {
index: number;
items_count: number;
transaction_hash: string;
};
}
export interface RawTrace {
action: {
callType: string;
from: string;
gas: string;
input: string;
to: string;
value: string;
};
result: {
gasUsed: string;
output: string;
};
error: string | null;
subtraces: number;
traceAddress: Array<number>;
type: string;
}
export type RawTracesResponse = Array<RawTrace>;
export interface Reward {
reward: number;
type: 'Miner Reward' | 'Emission Reward' | 'Chore Reward' | 'Uncle Reward';
}
import type { AddressParam } from './addressParams';
export interface TokenTransfer {
type: string;
txHash: string;
from: AddressParam;
to: AddressParam;
token_address: string;
token_symbol: string;
token_type: string;
total: {
value: string;
};
exchange_rate: string;
}
......@@ -6,7 +6,7 @@ export type Tokenlist = {
export type TokenlistItem = {
balance: number;
contractAddress: string;
decimals?: number;
decimals: number | null;
id: number;
name: string;
symbol: string;
......
import type { AddressParam } from './addressParams';
import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee';
import type { TokenTransfer } from './tokenTransfer';
export interface Transaction {
hash: string;
result: string;
confirmations: number;
status: 'ok' | 'error' | null;
block: number;
timestamp: string;
confirmation_duration: Array<number>;
from: AddressParam;
to: AddressParam;
created_contract: AddressParam;
value: number;
fee: Fee;
gas_price: number;
type: number;
gas_used: string;
gas_limit: string;
max_fee_per_gas: number | null;
max_priority_fee_per_gas: number | null;
priority_fee: number | null;
base_fee_per_gas: number | null;
tx_burnt_fee: number | null;
nonce: number;
position: number;
revert_reason: {
raw: string;
decoded: string;
} | null;
raw_input: string;
decoded_input: DecodedInput | null;
token_transfers: Array<TokenTransfer> | null;
token_transfers_overflow: boolean;
exchange_rate: string;
}
export interface TransactionsResponse {
items: Array<Transaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
};
}
export type TxInternalsType = 'call' | 'delegate_call' | 'static_call' | 'create' | 'create2' | 'self_destruct' | 'reward'
export type AppCategory = {
id: string;
name: string;
export enum MarketplaceCategoryId {
'all',
'favorites',
'defi',
'exchanges',
'finance',
'games',
'marketplaces',
'nft',
'security',
'social',
'tools',
'yieldFarming',
}
export type MarketplaceCategoriesIds = keyof typeof MarketplaceCategoryId;
export type MarketplaceCategory = { id: MarketplaceCategoriesIds; name: string }
export type AppItemPreview = {
id: string;
title: string;
logo: string;
shortDescription: string;
categories: Array<AppCategory>;
categories: Array<MarketplaceCategoriesIds>;
}
export type AppItemOverview = AppItemPreview & {
chainIds: Array<number>;
author: string;
url: string;
description: string;
......
import { Box, Heading, Icon, IconButton, Image, Link, LinkBox, LinkOverlay, Text, useColorModeValue } from '@chakra-ui/react';
import NextLink from 'next/link';
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
......@@ -7,6 +8,10 @@ import type { AppItemPreview } from 'types/client/apps';
import northEastIcon from 'icons/arrows/north-east.svg';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
import { APP_CATEGORIES } from './constants';
interface Props extends AppItemPreview {
onInfoClick: (id: string) => void;
......@@ -23,8 +28,7 @@ const AppCard = ({ id,
isFavorite,
onFavoriteClick,
}: Props) => {
const categoriesLabel = categories.map(c => c.name).join(', ');
const categoriesLabel = categories.map(c => APP_CATEGORIES[c]).filter(notEmpty).join(', ');
const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault();
......@@ -35,6 +39,8 @@ const AppCard = ({ id,
onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]);
const link = useLink();
return (
<LinkBox
_hover={{
......@@ -48,6 +54,7 @@ const AppCard = ({ id,
padding={{ base: 3, sm: '20px' }}
border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') }
role="group"
>
<Box
display={{ base: 'grid', sm: 'block' }}
......@@ -64,6 +71,7 @@ const AppCard = ({ id,
h={{ base: '64px', sm: '96px' }}
>
<Image
borderRadius={ 8 }
src={ logo }
alt={ `${ title } app icon` }
/>
......@@ -76,11 +84,11 @@ const AppCard = ({ id,
fontSize={{ base: 'sm', sm: 'lg' }}
fontWeight="semibold"
>
<LinkOverlay
href="#"
>
{ title }
</LinkOverlay>
<NextLink href={ link('app_index', { id: id }) } passHref>
<LinkOverlay>
{ title }
</LinkOverlay>
</NextLink>
</Heading>
<Text
......@@ -103,9 +111,8 @@ const AppCard = ({ id,
position="absolute"
right={{ base: 3, sm: '20px' }}
bottom={{ base: 3, sm: '20px' }}
paddingTop={ 1 }
paddingLeft={ 8 }
bgGradient={ `linear(to-r, transparent, ${ useColorModeValue('white', 'black') } 20%)` }
bgGradient={ `linear(to-r, ${ useColorModeValue('whiteAlpha.50', 'blackAlpha.50') }, ${ useColorModeValue('white', 'black') } 20%)` }
>
<Link
fontSize={{ base: 'xs', sm: 'sm' }}
......@@ -127,9 +134,11 @@ const AppCard = ({ id,
</Box>
<IconButton
display={{ base: 'block', sm: isFavorite ? 'block' : 'none' }}
_groupHover={{ display: 'block' }}
position="absolute"
right={{ base: 3, sm: '20px' }}
top={{ base: 3, sm: '20px' }}
right={{ base: 3, sm: '10px' }}
top={{ base: 3, sm: '14px' }}
aria-label="Mark as favorite"
title="Mark as favorite"
variant="ghost"
......
import { Box, Heading, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
export const AppCardSkeleton = () => {
return (
<Box
borderRadius="md"
height="100%"
padding={{ base: 3, sm: '20px' }}
border="1px"
borderColor={ useColorModeValue('gray.200', 'gray.600') }
>
<Box
display={{ base: 'grid', sm: 'block' }}
gridTemplateColumns={{ base: '64px 1fr', sm: '1fr' }}
gridTemplateRows={{ base: '20px 20px auto', sm: 'none' }}
gridRowGap={{ base: 2, sm: 'none' }}
gridColumnGap={{ base: 4, sm: 'none' }}
height="100%"
>
<Box
gridRow={{ base: '1 / 4', sm: 'auto' }}
marginBottom={ 4 }
w={{ base: '64px', sm: '96px' }}
h={{ base: '64px', sm: '96px' }}
>
<SkeletonCircle w="100%" h="100%"/>
</Box>
<Heading
gridColumn={{ base: 2, sm: 'auto' }}
marginBottom={ 2 }
>
<Skeleton h={ 4 } w="50%"/>
</Heading>
<Box>
<Skeleton h={ 4 } mb={ 1 }/>
<Skeleton h={ 4 } mb={ 1 }/>
<Skeleton h={ 4 } w="50%"/>
</Box>
</Box>
</Box>
);
};
import { Grid, GridItem, VisuallyHidden, Heading } from '@chakra-ui/react';
import React, { useCallback, useEffect, useState } from 'react';
import { Grid, GridItem, Heading, VisuallyHidden } from '@chakra-ui/react';
import React from 'react';
import type { AppItemPreview } from 'types/client/apps';
......@@ -14,37 +14,11 @@ type Props = {
onAppClick: (id: string) => void;
displayedAppId: string | null;
onModalClose: () => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
}
function getFavoriteApps() {
try {
return JSON.parse(localStorage.getItem('favoriteApps') || '[]');
} catch (e) {
return [];
}
}
const AppList = ({ apps, onAppClick, displayedAppId, onModalClose }: Props) => {
const [ favoriteApps, setFavoriteApps ] = useState<Array<string>>([]);
const handleFavoriteClick = useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps();
if (isFavorite) {
const result = favoriteApps.filter((appId: string) => appId !== id);
setFavoriteApps(result);
localStorage.setItem('favoriteApps', JSON.stringify(result));
} else {
favoriteApps.push(id);
localStorage.setItem('favoriteApps', JSON.stringify(favoriteApps));
setFavoriteApps(favoriteApps);
}
}, [ ]);
useEffect(() => {
setFavoriteApps(getFavoriteApps());
}, [ ]);
const AppList = ({ apps, onAppClick, displayedAppId, onModalClose, favoriteApps, onFavoriteClick }: Props) => {
return (
<>
<VisuallyHidden>
......@@ -54,7 +28,7 @@ const AppList = ({ apps, onAppClick, displayedAppId, onModalClose }: Props) => {
{ apps.length > 0 ? (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(170px, 1fr))',
sm: 'repeat(auto-fill, minmax(178px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
......@@ -72,7 +46,7 @@ const AppList = ({ apps, onAppClick, displayedAppId, onModalClose }: Props) => {
shortDescription={ app.shortDescription }
categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) }
onFavoriteClick={ handleFavoriteClick }
onFavoriteClick={ onFavoriteClick }
/>
</GridItem>
)) }
......@@ -86,7 +60,7 @@ const AppList = ({ apps, onAppClick, displayedAppId, onModalClose }: Props) => {
id={ displayedAppId }
onClose={ onModalClose }
isFavorite={ favoriteApps.includes(displayedAppId) }
onFavoriteClick={ handleFavoriteClick }
onFavoriteClick={ onFavoriteClick }
/>
) }
</>
......
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import { AppCardSkeleton } from 'ui/apps/AppCardSkeleton';
const applicationStubs = [ ...Array(12) ];
const AppListSkeleton = () => {
return (
<Grid
templateColumns={{
sm: 'repeat(auto-fill, minmax(170px, 1fr))',
lg: 'repeat(auto-fill, minmax(260px, 1fr))',
}}
autoRows="1fr"
gap={{ base: '16px', sm: '24px' }}
>
{ applicationStubs.map((app, index) => (
<GridItem
key={ index }
>
<AppCardSkeleton/>
</GridItem>
)) }
</Grid>
);
};
export default AppListSkeleton;
......@@ -2,12 +2,12 @@ import {
Box, Button, Heading, Icon, IconButton, Image, Link, List, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Tag, Text,
} from '@chakra-ui/react';
import type { FunctionComponent } from 'react';
import NextLink from 'next/link';
import React, { useCallback } from 'react';
import type { AppCategory, AppItemOverview } from 'types/client/apps';
import type { AppItemOverview, MarketplaceCategoriesIds } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps';
import marketplaceApps from 'data/marketplaceApps.json';
import linkIcon from 'icons/link.svg';
import ghIcon from 'icons/social/git.svg';
import tgIcon from 'icons/social/telega.svg';
......@@ -15,6 +15,10 @@ import twIcon from 'icons/social/tweet.svg';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import { nbsp } from 'lib/html-entities';
import useLink from 'lib/link/useLink';
import notEmpty from 'lib/notEmpty';
import { APP_CATEGORIES } from './constants';
type Props = {
id: string;
......@@ -33,29 +37,30 @@ const AppModal = ({
title,
author,
description,
url,
site,
github,
telegram,
twitter,
logo,
categories,
} = TEMPORARY_DEMO_APPS.find(app => app.id === id) as AppItemOverview;
} = marketplaceApps.find(app => app.id === id) as AppItemOverview;
const link = useLink();
const socialLinks = [
Boolean(telegram) && {
telegram ? {
icon: tgIcon,
url: telegram,
},
Boolean(twitter) && {
} : null,
twitter ? {
icon: twIcon,
url: twitter,
},
Boolean(github) && {
} : null,
github ? {
icon: ghIcon,
url: github,
},
].filter(Boolean) as Array<{ icon: FunctionComponent; url: string }>;
} : null,
].filter(notEmpty);
const handleFavoriteClick = useCallback(() => {
onFavoriteClick(id, isFavorite);
......@@ -95,9 +100,6 @@ const AppModal = ({
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{ title }
</Heading>
......@@ -117,15 +119,16 @@ const AppModal = ({
marginTop={{ base: 6, sm: 0 }}
>
<Box display="flex">
<Button
href={ url }
as="a"
size="sm"
marginRight={ 2 }
width={{ base: '100%', sm: 'auto' }}
>
Launch app
</Button>
<NextLink href={ link('app_index', { id: id }) } passHref>
<Button
as="a"
size="sm"
marginRight={ 2 }
width={{ base: '100%', sm: 'auto' }}
>
Launch app
</Button>
</NextLink>
<IconButton
aria-label="Mark as favorite"
......@@ -155,14 +158,14 @@ const AppModal = ({
</Heading>
<Box marginBottom={ 2 }>
{ categories.map((category: AppCategory) => (
{ categories.map((category: MarketplaceCategoriesIds) => APP_CATEGORIES[category] && (
<Tag
colorScheme="blue"
marginRight={ 2 }
marginBottom={ 2 }
key={ category.id }
key={ category }
>
{ category.name }
{ APP_CATEGORIES[category] }
</Tag>
)) }
</Box>
......
import { Box, Button, Icon, Menu, MenuButton, MenuList } from '@chakra-ui/react';
import React from 'react';
import type { MarketplaceCategoriesIds, MarketplaceCategory } from 'types/client/apps';
import eastMiniArrowIcon from 'icons/arrows/east-mini.svg';
import CategoriesMenuItem from './CategoriesMenuItem';
import { APP_CATEGORIES } from './constants';
const categoriesList = Object.keys(APP_CATEGORIES).map((id: string) => ({
id: id,
name: APP_CATEGORIES[id as MarketplaceCategoriesIds],
})) as Array<MarketplaceCategory>;
type Props = {
selectedCategoryId: MarketplaceCategoriesIds;
onSelect: (category: MarketplaceCategoriesIds) => void;
}
const CategoriesMenu = ({ selectedCategoryId, onSelect }: Props) => {
const selectedCategory = categoriesList.find(category => category.id === selectedCategoryId);
return (
<Menu>
<MenuButton
as={ Button }
mb={{ base: 2, sm: 0 }}
mr={{ base: 0, sm: 2 }}
size="md"
variant="outline"
colorScheme="gray"
flexShrink={ 0 }
>
<Box
as="span"
display="flex"
alignItems="center"
>
{ selectedCategory?.name }
<Icon transform="rotate(-90deg)" ml={{ base: 'auto', sm: 1 }} as={ eastMiniArrowIcon } w={ 5 } h={ 5 }/>
</Box>
</MenuButton>
<MenuList zIndex={ 3 }>
{ categoriesList.map((category: MarketplaceCategory) => (
<CategoriesMenuItem
key={ category.id }
id={ category.id }
name={ category.name }
onClick={ onSelect }
/>
)) }
</MenuList>
</Menu>
);
};
export default React.memo(CategoriesMenu);
import { Icon, MenuItem } from '@chakra-ui/react';
import type { FunctionComponent, SVGAttributes } from 'react';
import React, { useCallback } from 'react';
import type { MarketplaceCategoriesIds } from 'types/client/apps';
import starFilledIcon from 'icons/star_filled.svg';
type Props = {
id: MarketplaceCategoriesIds;
name: string;
onClick: (category: MarketplaceCategoriesIds) => void;
}
const ICONS = {
favorites: starFilledIcon,
} as { [key in MarketplaceCategoriesIds]: FunctionComponent<SVGAttributes<SVGElement>> };
const CategoriesMenuItem = ({ id, name, onClick }: Props) => {
const handleSelection = useCallback(() => {
onClick(id);
}, [ id, onClick ]);
return (
<MenuItem
key={ id }
onClick={ handleSelection }
display="flex"
alignItems="center"
>
{ id in ICONS && (
<Icon mr={ 3 } as={ ICONS[id] } w={ 4 } h={ 4 } color="blackAlpha.800"/>
) }
{ name }
</MenuItem>
);
};
export default CategoriesMenuItem;
import type { AppCategory } from 'types/client/apps';
import type { MarketplaceCategoriesIds } from 'types/client/apps';
export const APP_CATEGORIES: Array<AppCategory> = [
{
id: 'defi',
name: 'DeFi',
},
{
id: 'exchanges',
name: 'Exchanges',
},
{
id: 'finance',
name: 'Finance',
},
{
id: 'games',
name: 'Games',
},
{
id: 'marketplaces',
name: 'Marketplaces',
},
{
id: 'nft',
name: 'NFT',
},
{
id: 'security',
name: 'Security',
},
{
id: 'social',
name: 'Social',
},
{
id: 'tools',
name: 'Tools',
},
{
id: 'Yield-farming',
name: 'yield-farming',
},
];
export const APP_CATEGORIES: {[key in MarketplaceCategoriesIds]: string} = {
favorites: 'Favorites',
all: 'All apps',
defi: 'DeFi',
exchanges: 'Exchanges',
finance: 'Finance',
games: 'Games',
marketplaces: 'Marketplaces',
nft: 'NFT',
security: 'Security',
social: 'Social',
tools: 'Tools',
yieldFarming: 'Yield farming',
};
import appConfig from 'configs/app/config';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useState } from 'react';
import type { AppItemOverview, MarketplaceCategoriesIds } from 'types/client/apps';
import marketplaceApps from 'data/marketplaceApps.json';
const favoriteAppsLocalStorageKey = 'favoriteApps';
function getFavoriteApps() {
try {
return JSON.parse(localStorage.getItem(favoriteAppsLocalStorageKey) || '[]');
} catch (e) {
return [];
}
}
function isAppNameMatches(q: string, app: AppItemOverview) {
return app.title.toLowerCase().includes(q.toLowerCase());
}
function isAppCategoryMatches(category: MarketplaceCategoriesIds, app: AppItemOverview, favoriteApps: Array<string>) {
return category === 'all' ||
(category === 'favorites' && favoriteApps.includes(app.id)) ||
app.categories.includes(category);
}
export default function useMarketplaceApps() {
const [ isLoading, setIsLoading ] = useState(true);
const [ defaultAppList, setDefaultAppList ] = useState<Array<AppItemOverview>>();
const [ displayedApps, setDisplayedApps ] = useState<Array<AppItemOverview>>([]);
const [ displayedAppId, setDisplayedAppId ] = useState<string | null>(null);
const [ category, setCategory ] = useState<MarketplaceCategoriesIds>('all');
const [ filterQuery, setFilterQuery ] = useState('');
const [ favoriteApps, setFavoriteApps ] = useState<Array<string>>([]);
const handleFavoriteClick = useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps();
if (isFavorite) {
const result = favoriteApps.filter((appId: string) => appId !== id);
setFavoriteApps(result);
localStorage.setItem(favoriteAppsLocalStorageKey, JSON.stringify(result));
} else {
favoriteApps.push(id);
localStorage.setItem(favoriteAppsLocalStorageKey, JSON.stringify(favoriteApps));
setFavoriteApps(favoriteApps);
}
}, [ ]);
const showAppInfo = useCallback((id: string) => {
setDisplayedAppId(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterApps = useCallback(debounce(q => setFilterQuery(q), 500), []);
const clearDisplayedAppId = useCallback(() => setDisplayedAppId(null), []);
const filterApps = useCallback((q: string, category: MarketplaceCategoriesIds) => {
const apps = defaultAppList
?.filter(app => {
return isAppNameMatches(q, app) && isAppCategoryMatches(category, app, favoriteApps);
});
setDisplayedApps(apps || []);
}, [ defaultAppList, favoriteApps ]);
const handleCategoryChange = useCallback((newCategory: MarketplaceCategoriesIds) => {
setCategory(newCategory);
}, []);
useEffect(() => {
setFavoriteApps(getFavoriteApps());
}, [ ]);
useEffect(() => {
filterApps(filterQuery, category);
}, [ filterQuery, category, filterApps ]);
useEffect(() => {
const defaultDisplayedApps = [ ...marketplaceApps ]
.filter(item => item.chainIds.includes(appConfig.network.id))
.sort((a, b) => a.title.localeCompare(b.title));
setDefaultAppList(defaultDisplayedApps);
setDisplayedApps(defaultDisplayedApps);
setIsLoading(false);
}, [ ]);
return React.useMemo(() => ({
category,
handleCategoryChange,
debounceFilterApps,
isLoading,
displayedApps,
showAppInfo,
displayedAppId,
clearDisplayedAppId,
favoriteApps,
handleFavoriteClick,
}), [ category,
clearDisplayedAppId,
debounceFilterApps,
displayedAppId, displayedApps,
favoriteApps,
handleCategoryChange,
handleFavoriteClick,
isLoading,
showAppInfo,
]);
}
......@@ -34,7 +34,7 @@ const BlocksListItem = ({ data, isPending }: Props) => {
{ data.height }
</Link>
</Flex>
<Text variant="secondary"fontWeight={ 400 }>{ dayjs(data.timestamp).locale('en-short').fromNow() }</Text>
<Text variant="secondary"fontWeight={ 400 }>{ dayjs(data.timestamp).fromNow() }</Text>
</Flex>
<Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Size</Text>
......
......@@ -33,7 +33,7 @@ const BlocksTableItem = ({ data, isPending }: Props) => {
{ data.height }
</Link>
</Flex>
<Text variant="secondary" mt={ 2 } fontWeight={ 400 }>{ dayjs(data.timestamp).locale('en-short').fromNow() }</Text>
<Text variant="secondary" mt={ 2 } fontWeight={ 400 }>{ dayjs(data.timestamp).fromNow() }</Text>
</Td>
<Td fontSize="sm">{ data.size.toLocaleString('en') } bytes</Td>
<Td fontSize="sm">
......
import { Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import Page from 'ui/shared/Page/Page';
const App = () => {
return (
<Page wrapChildren={ false }>
<Center as="main" bgColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') } h="100%" paddingTop={{ base: '138px', lg: 0 }}>
3rd party app content
</Center>
</Page>
);
};
export default App;
import debounce from 'lodash/debounce';
import React, { useCallback, useState } from 'react';
import { Box, Icon, Link } from '@chakra-ui/react';
import config from 'configs/app/config';
import React from 'react';
import type { AppItemOverview } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps';
import PlusIcon from 'icons/plus.svg';
import AppList from 'ui/apps/AppList';
import AppListSkeleton from 'ui/apps/AppListSkeleton';
import CategoriesMenu from 'ui/apps/CategoriesMenu';
import FilterInput from 'ui/shared/FilterInput';
const defaultDisplayedApps = [ ...TEMPORARY_DEMO_APPS ]
.sort((a, b) => a.title.localeCompare(b.title));
import useMarketplaceApps from '../apps/useMarkeplaceApps';
const Apps = () => {
const [ displayedApps, setDisplayedApps ] = useState<Array<AppItemOverview>>(defaultDisplayedApps);
const [ displayedAppId, setDisplayedAppId ] = useState<string | null>(null);
const showAppInfo = useCallback((id: string) => {
setDisplayedAppId(id);
}, []);
const {
isLoading,
category,
handleCategoryChange,
debounceFilterApps,
showAppInfo,
displayedApps,
displayedAppId,
clearDisplayedAppId,
favoriteApps,
handleFavoriteClick,
} = useMarketplaceApps();
const filterApps = (q: string) => {
const apps = displayedApps
.filter(app => app.title.toLowerCase().includes(q.toLowerCase()));
return (
<>
<Box
display="flex"
flexDirection={{ base: 'column', sm: 'row' }}
>
<CategoriesMenu
selectedCategoryId={ category }
onSelect={ handleCategoryChange }
/>
setDisplayedApps(apps);
};
<FilterInput onChange={ debounceFilterApps } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
</Box>
// eslint-disable-next-line react-hooks/exhaustive-deps
const debounceFilterApps = useCallback(debounce(q => filterApps(q), 500), []);
{ isLoading ? <AppListSkeleton/> : (
<AppList
apps={ displayedApps }
onAppClick={ showAppInfo }
displayedAppId={ displayedAppId }
onModalClose={ clearDisplayedAppId }
favoriteApps={ favoriteApps }
onFavoriteClick={ handleFavoriteClick }
/>
) }
const clearDisplayedAppId = useCallback(() => setDisplayedAppId(null), []);
{ config.marketplaceSubmitForm && (
<Link
fontWeight="bold"
display="inline-flex"
alignItems="baseline"
marginTop={{ base: 8, sm: 16 }}
href={ config.marketplaceSubmitForm }
isExternal
>
<Icon
as={ PlusIcon }
w={ 3 }
h={ 3 }
mr={ 2 }
/>
return (
<>
<FilterInput onChange={ debounceFilterApps } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
<AppList
apps={ displayedApps }
onAppClick={ showAppInfo }
displayedAppId={ displayedAppId }
onModalClose={ clearDisplayedAppId }
/>
Submit an App
</Link>
) }
</>
);
};
......
import { Box, Center, useColorMode } from '@chakra-ui/react';
import appConfig from 'configs/app/config';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import ContentLoader from 'ui/shared/ContentLoader';
import Page from 'ui/shared/Page/Page';
type Props = {
app?: AppItemOverview;
isLoading: boolean;
}
const MarketplaceApp = ({ app, isLoading }: Props) => {
const [ isFrameLoading, setIsFrameLoading ] = useState(isLoading);
const ref = useRef<HTMLIFrameElement>(null);
const { colorMode } = useColorMode();
const handleIframeLoad = useCallback(() => {
setIsFrameLoading(false);
}, []);
const sandboxAttributeValue = 'allow-forms allow-orientation-lock ' +
'allow-pointer-lock allow-popups-to-escape-sandbox ' +
'allow-same-origin allow-scripts ' +
'allow-top-navigation-by-user-activation allow-popups';
const allowAttributeValue = 'clipboard-read; clipboard-write;';
useEffect(() => {
if (app && !isFrameLoading) {
ref?.current?.contentWindow?.postMessage({ blockscoutColorMode: colorMode, blockscoutChainId: appConfig.network.id }, app.url);
}
}, [ isFrameLoading, app, colorMode, ref ]);
return (
<Page wrapChildren={ false }>
<Center
as="main"
h="100%"
paddingTop={{ base: '138px', lg: 0 }}
>
{ (isFrameLoading) && (
<ContentLoader/>
) }
{ app && (
<Box
allow={ allowAttributeValue }
ref={ ref }
sandbox={ sandboxAttributeValue }
as="iframe"
h="100%"
w="100%"
display={ isFrameLoading ? 'none' : 'block' }
src={ app.url }
title={ app.title }
onLoad={ handleIframeLoad }
/>
) }
</Center>
</Page>
);
};
export default MarketplaceApp;
......@@ -25,7 +25,6 @@ const MyProfile = () => {
<UserAvatar size={ 64 } data={ data }/>
<FormControl variant="floating" id="name" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.name || '' }
......@@ -34,7 +33,6 @@ const MyProfile = () => {
</FormControl>
<FormControl variant="floating" id="nickname" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.nickname || '' }
......@@ -43,7 +41,6 @@ const MyProfile = () => {
</FormControl>
<FormControl variant="floating" id="email" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.email }
......
......@@ -13,13 +13,14 @@ import TxDetails from 'ui/tx/TxDetails';
import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs';
import TxRawTrace from 'ui/tx/TxRawTrace';
import TxState from 'ui/tx/TxState';
// import TxState from 'ui/tx/TxState';
const TABS: Array<RoutedTab> = [
{ routeName: 'tx_index', title: 'Details', component: <TxDetails/> },
{ routeName: 'tx_internal', title: 'Internal txn', component: <TxInternals/> },
{ routeName: 'tx_logs', title: 'Logs', component: <TxLogs/> },
{ routeName: 'tx_state', title: 'State', component: <TxState/> },
// will be implemented later, api is not ready
// { routeName: 'tx_state', title: 'State', component: <TxState/> },
{ routeName: 'tx_raw_trace', title: 'Raw trace', component: <TxRawTrace/> },
];
......@@ -37,11 +38,21 @@ const TransactionPageContent = ({ tab }: Props) => {
<Icon as={ eastArrowIcon } boxSize={ 6 } mr={ 2 } transform="rotate(180deg)"/>
Transactions
</Link>
<PageTitle text="Transaction details"/>
<Flex marginLeft="auto" alignItems="center" flexWrap="wrap" columnGap={ 6 } rowGap={ 3 } mb={ 6 }>
<ExternalLink title="Open in Tenderly" href="#"/>
<ExternalLink title="Open in Blockchair" href="#"/>
<ExternalLink title="Open in Etherscan" href="#"/>
<Flex alignItems="flex-start" flexDir={{ base: 'column', lg: 'row' }}>
<PageTitle text="Transaction details"/>
<Flex
alignItems="center"
flexWrap="wrap"
columnGap={ 6 }
rowGap={ 3 }
ml={{ base: 'initial', lg: 'auto' }}
mb={{ base: 6, lg: 'initial' }}
py={ 2.5 }
>
<ExternalLink title="Open in Tenderly" href="#"/>
<ExternalLink title="Open in Blockchair" href="#"/>
<ExternalLink title="Open in Etherscan" href="#"/>
</Flex>
</Flex>
<RoutedTabs
tabs={ TABS }
......
import type { InputProps } 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';
......@@ -17,7 +18,7 @@ interface Props {
error?: FieldError;
onAddFieldClick: (e: React.SyntheticEvent) => void;
onRemoveFieldClick: (index: number) => (e: React.SyntheticEvent) => void;
size?: string;
size?: InputProps['size'];
}
const MAX_INPUTS_NUM = 10;
......
import type { InputProps } from '@chakra-ui/react';
import { FormControl, FormLabel, Textarea } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, Control, FieldError } from 'react-hook-form';
......@@ -12,7 +13,7 @@ const TEXT_INPUT_MAX_LENGTH = 255;
interface Props {
control: Control<Inputs>;
error?: FieldError;
size?: string;
size?: InputProps['size'];
}
export default function PublicTagFormComment({ control, error, size }: Props) {
......@@ -22,7 +23,6 @@ export default function PublicTagFormComment({ control, error, size }: Props) {
<Textarea
{ ...field }
isInvalid={ Boolean(error) }
size={ size }
/>
<FormLabel>
{ getPlaceholderWithError('Specify the reason for adding tags and color preference(s)', error?.message) }
......
......@@ -16,7 +16,6 @@ import type { PublicTags, PublicTag, PublicTagNew, PublicTagErrors } from 'types
import getErrorMessage from 'lib/getErrorMessage';
import type { ErrorType } from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import { EMAIL_REGEXP } from 'lib/validations/email';
import FormSubmitAlert from 'ui/shared/FormSubmitAlert';
......@@ -57,9 +56,8 @@ const ADDRESS_INPUT_BUTTONS_WIDTH = 100;
const PublicTagsForm = ({ changeToDataScreen, data }: Props) => {
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const fetch = useFetch();
const inputSize = isMobile ? 'md' : 'lg';
const inputSize = { base: 'md', lg: 'lg' };
const { control, handleSubmit, formState: { errors, isValid }, setError } = useForm<Inputs>({
defaultValues: {
......
import type { InputProps } from '@chakra-ui/react';
import { FormControl, FormLabel, Input } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { ControllerRenderProps, FieldError, FieldValues, Path, Control } from 'react-hook-form';
......@@ -14,7 +15,7 @@ interface Props<TInputs extends FieldValues> {
control: Control<TInputs, object>;
pattern?: RegExp;
error?: FieldError;
size?: string;
size?: InputProps['size'];
}
export default function PublicTagsFormInput<Inputs extends FieldValues>({
......@@ -31,7 +32,6 @@ export default function PublicTagsFormInput<Inputs extends FieldValues>({
<FormControl variant="floating" id={ field.name } isRequired={ required } size={ size }>
<Input
{ ...field }
size={ size }
required={ required }
isInvalid={ Boolean(error) }
maxLength={ TEXT_INPUT_MAX_LENGTH }
......
import type { InputProps } from '@chakra-ui/react';
import {
Input,
FormControl,
......@@ -11,7 +12,7 @@ import { ADDRESS_LENGTH } from 'lib/validations/address';
type Props<TInputs extends FieldValues, TInputName extends Path<TInputs>> = {
field: ControllerRenderProps<TInputs, TInputName>;
size?: string;
size?: InputProps['size'];
placeholder?: string;
backgroundColor?: string;
error?: FieldError;
......@@ -31,7 +32,6 @@ export default function AddressInput<Inputs extends FieldValues, Name extends Pa
{ ...field }
isInvalid={ Boolean(error) }
maxLength={ ADDRESS_LENGTH }
size={ size }
/>
<FormLabel>{ getPlaceholderWithError(placeholder, error?.message) }</FormLabel>
</FormControl>
......
import { Box, Text, chakra } from '@chakra-ui/react';
import { utils, constants } from 'ethers';
import React from 'react';
interface Props {
value: string;
unit?: 'wei' | 'gwei' | 'ether';
currency?: string;
exchangeRate?: string;
className?: string;
}
const CurrencyValue = ({ value, currency = '', unit = 'wei', exchangeRate, className }: Props) => {
const valueBn = utils.parseUnits(value, unit);
const exchangeRateBn = utils.parseUnits(exchangeRate || '0', 'ether');
const usdBn = valueBn.mul(exchangeRateBn).div(constants.WeiPerEther);
return (
<Box as="span" className={ className }>
<Text as="span">
{ Number(utils.formatUnits(valueBn)).toLocaleString() }{ currency ? ` ${ currency }` : '' }</Text>
{ exchangeRate !== undefined && exchangeRate !== null &&
<Text as="span" variant="secondary" whiteSpace="pre" fontWeight={ 400 }> (${ utils.formatUnits(usdBn) })</Text> }
</Box>
);
};
export default React.memo(chakra(CurrencyValue));
......@@ -49,10 +49,10 @@ export default function useAdaptiveTabs(tabs: Array<RoutedTab>, disabled?: boole
}, [ tabs, disabled ]);
React.useEffect(() => {
!disabled && setTabsRefs(tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
// imitate componentDidMount
setTabsRefs(disabled ? [] : tabsList.map((_, index) => tabsRefs[index] || React.createRef()));
// update refs only when disabled prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [ disabled ]);
React.useEffect(() => {
if (tabsRefs.length > 0) {
......
import { Tag, TagLabel, TagLeftIcon, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
import errorIcon from 'icons/status/error.svg';
import pendingIcon from 'icons/status/pending.svg';
import successIcon from 'icons/status/success.svg';
export interface Props {
status: 'success' | 'failed' | 'pending';
errorText?: string;
status: Transaction['status'];
errorText?: string | null;
}
const TxStatus = ({ status, errorText }: Props) => {
......@@ -16,17 +18,17 @@ const TxStatus = ({ status, errorText }: Props) => {
let colorScheme;
switch (status) {
case 'success':
case 'ok':
label = 'Success';
icon = successIcon;
colorScheme = 'green';
break;
case 'failed':
case 'error':
label = 'Failed';
icon = errorIcon;
colorScheme = 'red';
break;
case 'pending':
case null:
label = 'Pending';
icon = pendingIcon;
// FIXME: it's not gray on mockups
......
import { Flex, Icon, Text } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransfer as TTokenTransfer } from 'types/api/tokenTransfer';
import rightArrowIcon from 'icons/arrows/east.svg';
import { space } from 'lib/html-entities';
import AddressLink from 'ui/shared/address/AddressLink';
import CurrencyValue from 'ui/shared/CurrencyValue';
import TokenSnippet from 'ui/shared/TokenSnippet';
interface Props {
from: string;
to: string;
amount: number;
usd: number;
token: {
symbol: string;
hash: string;
name: string;
};
}
type Props = TTokenTransfer
const TokenTransfer = ({ from, to, amount, usd, token }: Props) => {
const TokenTransfer = ({ from, to, total, exchange_rate: exchangeRate, ...token }: Props) => {
return (
<Flex alignItems="center" flexWrap="wrap" columnGap={ 3 } rowGap={ 3 }>
<Flex alignItems="center">
<AddressLink fontWeight="500" hash={ from } truncation="constant"/>
<AddressLink fontWeight="500" hash={ from.hash } truncation="constant"/>
<Icon as={ rightArrowIcon } boxSize={ 6 } mx={ 2 } color="gray.500"/>
<AddressLink fontWeight="500" hash={ to } truncation="constant"/>
<AddressLink fontWeight="500" hash={ to.hash } truncation="constant"/>
</Flex>
<Text fontWeight={ 500 } as="span">For:{ space }
<Text fontWeight={ 600 } as="span">{ amount }</Text>{ space }
<Text fontWeight={ 400 } variant="secondary" as="span">(${ usd.toFixed(2) })</Text>
<CurrencyValue value={ total.value.replaceAll(',', '') } unit="ether" exchangeRate={ exchangeRate } fontWeight={ 600 }/>
</Text>
<TokenSnippet { ...token }/>
<TokenSnippet symbol={ token.token_symbol } hash={ token.token_address } name="Foo"/>
</Flex>
);
};
......
import { Flex, Text, Grid, GridItem, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { DecodedInput } from 'types/api/decodedInput';
import Address from 'ui/shared/address/Address';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
......@@ -39,14 +41,16 @@ const TableRow = ({ isLast, name, type, children, indexed }: RowProps) => {
>
{ type }
</GridItem>
<GridItem
pr={ GAP }
pt={ GAP }
pb={ isLast ? PADDING : 0 }
bgColor={ bgColor }
>
{ indexed ? 'true' : 'false' }
</GridItem>
{ indexed !== undefined && (
<GridItem
pr={ GAP }
pt={ GAP }
pb={ isLast ? PADDING : 0 }
bgColor={ bgColor }
>
{ indexed ? 'true' : 'false' }
</GridItem>
) }
<GridItem
pr={ PADDING }
pt={ GAP }
......@@ -60,14 +64,28 @@ const TableRow = ({ isLast, name, type, children, indexed }: RowProps) => {
);
};
const TxDecodedInputData = () => {
interface Props {
data: DecodedInput;
}
const TxDecodedInputData = ({ data }: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const hasIndexed = data.parameters.some(({ indexed }) => indexed !== undefined);
const gridTemplateColumns = hasIndexed ?
'minmax(80px, auto) minmax(80px, auto) minmax(80px, auto) minmax(0, 1fr)' :
'minmax(80px, auto) minmax(80px, auto) minmax(0, 1fr)';
const colNumber = hasIndexed ? 4 : 3;
return (
<Grid gridTemplateColumns="minmax(80px, auto) minmax(80px, auto) minmax(80px, auto) minmax(0, 1fr)" fontSize="sm" lineHeight={ 5 } w="100%">
<Grid gridTemplateColumns={ gridTemplateColumns } fontSize="sm" lineHeight={ 5 } w="100%">
{ /* FIRST PART OF BLOCK */ }
<GridItem fontWeight={ 600 } pl={{ base: 0, lg: PADDING }} pr={{ base: 0, lg: GAP }} colSpan={{ base: 4, lg: undefined }}>Method Id</GridItem>
<GridItem colSpan={{ base: 4, lg: 3 }} pr={{ base: 0, lg: PADDING }} mt={{ base: 2, lg: 0 }}>0xddf252ad</GridItem>
<GridItem fontWeight={ 600 } pl={{ base: 0, lg: PADDING }} pr={{ base: 0, lg: GAP }} colSpan={{ base: colNumber, lg: undefined }}>
Method Id
</GridItem>
<GridItem colSpan={{ base: colNumber, lg: colNumber - 1 }} pr={{ base: 0, lg: PADDING }} mt={{ base: 2, lg: 0 }}>
{ data.method_id }
</GridItem>
<GridItem
py={ 2 }
mt={ 2 }
......@@ -76,7 +94,7 @@ const TxDecodedInputData = () => {
fontWeight={ 600 }
borderTopColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderTopWidth="1px"
colSpan={{ base: 4, lg: undefined }}
colSpan={{ base: colNumber, lg: undefined }}
>
Call
</GridItem>
......@@ -84,13 +102,13 @@ const TxDecodedInputData = () => {
py={{ base: 0, lg: 2 }}
mt={{ base: 0, lg: 2 }}
mb={{ base: 2, lg: 0 }}
colSpan={{ base: 4, lg: 3 }}
colSpan={{ base: colNumber, lg: colNumber - 1 }}
pr={{ base: 0, lg: PADDING }}
borderTopColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderTopWidth={{ base: '0px', lg: '1px' }}
whiteSpace="normal"
>
Transfer(address indexed from, address indexed to, uint256 indexed tokenId)
{ data.method_call }
</GridItem>
{ /* TABLE INSIDE OF BLOCK */ }
<GridItem
......@@ -112,15 +130,17 @@ const TxDecodedInputData = () => {
>
Type
</GridItem>
<GridItem
pr={ GAP }
pt={ PADDING }
pb={ 1 }
bgColor={ bgColor }
fontWeight={ 600 }
>
Inde<wbr/>xed?
</GridItem>
{ hasIndexed && (
<GridItem
pr={ GAP }
pt={ PADDING }
pb={ 1 }
bgColor={ bgColor }
fontWeight={ 600 }
>
Inde<wbr/>xed?
</GridItem>
) }
<GridItem
pr={ PADDING }
pt={ PADDING }
......@@ -130,24 +150,23 @@ const TxDecodedInputData = () => {
>
Data
</GridItem>
<TableRow name="from" type="address">
<Address justifyContent="space-between">
<AddressLink hash="0x0000000000000000000000000000000000000000"/>
<CopyToClipboard text="0x0000000000000000000000000000000000000000"/>
</Address>
</TableRow>
<TableRow name="to" type="address" indexed>
<Address justifyContent="space-between">
<AddressLink hash="0xcf0c50b7ea8af37d57380a0ac199d55b0782c718"/>
<CopyToClipboard text="0xcf0c50b7ea8af37d57380a0ac199d55b0782c718"/>
</Address>
</TableRow>
<TableRow name="tokenId" type="uint256" isLast>
<Flex alignItems="center" justifyContent="space-between">
<Text>116842</Text>
<CopyToClipboard text="116842"/>
</Flex>
</TableRow>
{ data.parameters.map(({ name, type, value, indexed }, index) => {
return (
<TableRow key={ name } name={ name } type={ type } isLast={ index === data.parameters.length - 1 } indexed={ indexed }>
{ type === 'address' ? (
<Address justifyContent="space-between">
<AddressLink hash={ value }/>
<CopyToClipboard text={ value }/>
</Address>
) : (
<Flex alignItems="flex-start" justifyContent="space-between" whiteSpace="normal" wordBreak="break-all">
<Text>{ value }</Text>
<CopyToClipboard text={ value }/>
</Flex>
) }
</TableRow>
);
}) }
</Grid>
);
};
......
This diff is collapsed.
import { Box, Flex } from '@chakra-ui/react';
import { Box, Flex, Alert, Show } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { TxInternalsType } from 'types/api/tx';
import type ArrayElement from 'types/utils/ArrayElement';
import type { InternalTransactionsResponse, TxInternalsType, InternalTransaction } from 'types/api/internalTransaction';
import { data } from 'data/txInternal';
import useFetch from 'lib/hooks/useFetch';
import useIsMobile from 'lib/hooks/useIsMobile';
import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import FilterInput from 'ui/shared/FilterInput';
import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter';
import TxInternalsList from 'ui/tx/internals/TxInternalsList';
import TxInternalsSkeletonDesktop from 'ui/tx/internals/TxInternalsSkeletonDesktop';
import TxInternalsSkeletonMobile from 'ui/tx/internals/TxInternalsSkeletonMobile';
import TxInternalsTable from 'ui/tx/internals/TxInternalsTable';
import type { Sort, SortField } from 'ui/tx/internals/utils';
const searchFn = (searchTerm: string) => (item: ArrayElement<typeof data>): boolean => {
const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
value: [ 'value-desc', 'value-asc', undefined ],
'gas-limit': [ 'gas-limit-desc', 'gas-limit-asc', undefined ],
};
const getNextSortValue = (field: SortField) => (prevValue: Sort | undefined) => {
const sequence = SORT_SEQUENCE[field];
const curIndex = sequence.findIndex((sort) => sort === prevValue);
const nextIndex = curIndex + 1 > sequence.length - 1 ? 0 : curIndex + 1;
return sequence[nextIndex];
};
const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalTransaction) => {
switch (sort) {
case 'value-desc': {
const result = a.value > b.value ? -1 : 1;
return a.value === b.value ? 0 : result;
}
case 'value-asc': {
const result = a.value > b.value ? 1 : -1;
return a.value === b.value ? 0 : result;
}
// no gas limit in api yet
// case 'gas-limit-desc': {
// const result = a.gasLimit > b.gasLimit ? -1 : 1;
// return a.gasLimit === b.gasLimit ? 0 : result;
// }
// case 'gas-limit-asc': {
// const result = a.gasLimit > b.gasLimit ? 1 : -1;
// return a.gasLimit === b.gasLimit ? 0 : result;
// }
default:
return 0;
}
};
const searchFn = (searchTerm: string) => (item: InternalTransaction): boolean => {
const formattedSearchTerm = searchTerm.toLowerCase();
return item.type.toLowerCase().includes(formattedSearchTerm) ||
item.from.hash.toLowerCase().includes(formattedSearchTerm) ||
......@@ -21,24 +66,62 @@ const searchFn = (searchTerm: string) => (item: ArrayElement<typeof data>): bool
};
const TxInternals = () => {
const router = useRouter();
const fetch = useFetch();
const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const [ sort, setSort ] = React.useState<Sort>();
const { data, isLoading, isError } = useQuery<unknown, unknown, InternalTransactionsResponse>(
[ 'tx-internals', router.query.id ],
async() => await fetch(`/api/transactions/${ router.query.id }/internal-transactions`),
{
enabled: Boolean(router.query.id),
},
);
const isMobile = useIsMobile();
const handleFilterChange = React.useCallback((nextValue: Array<TxInternalsType>) => {
setFilters(nextValue);
}, []);
const handleSortToggle = React.useCallback((field: SortField) => {
return () => {
setSort(getNextSortValue(field));
};
}, []);
if (isLoading) {
return (
<>
<Show below="lg"><TxInternalsSkeletonMobile/></Show>
<Show above="lg"><TxInternalsSkeletonDesktop/></Show>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (data.items.length === 0) {
return <Alert>There are no internal transactions for this transaction.</Alert>;
}
const content = (() => {
const filteredData = data
const filteredData = data.items
.filter(({ type }) => filters.length > 0 ? filters.includes(type) : true)
.filter(searchFn(searchTerm));
.filter(searchFn(searchTerm))
.sort(sortFn(sort));
if (filteredData.length === 0) {
return <EmptySearchResult text={ `Couldn${ apos }t find any transaction that matches your query.` }/>;
}
return isMobile ? <TxInternalsList data={ filteredData }/> : <TxInternalsTable data={ filteredData }/>;
return isMobile ?
<TxInternalsList data={ filteredData }/> :
<TxInternalsTable data={ filteredData } sort={ sort } onSortToggle={ handleSortToggle }/>;
})();
return (
......
import { Box } from '@chakra-ui/react';
import { Box, Alert } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import { data } from 'data/txLogs';
import type { LogsResponse } from 'types/api/log';
import useFetch from 'lib/hooks/useFetch';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TxLogItem from 'ui/tx/logs/TxLogItem';
import TxLogSkeleton from 'ui/tx/logs/TxLogSkeleton';
const TxLogs = () => {
const router = useRouter();
const fetch = useFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, LogsResponse>(
[ 'tx-log', router.query.id ],
async() => await fetch(`/api/transactions/${ router.query.id }/logs`),
{
enabled: Boolean(router.query.id),
},
);
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return (
<Box>
<TxLogSkeleton/>
<TxLogSkeleton/>
</Box>
);
}
if (data.items.length === 0) {
return <Alert>There are no logs for this transaction.</Alert>;
}
return (
<Box>
{ data.map((item, index) => <TxLogItem key={ index } { ...item } index={ index }/>) }
{ data.items.map((item, index) => <TxLogItem key={ index } { ...item }/>) }
</Box>
);
};
......
import { Flex, Textarea } from '@chakra-ui/react';
import { Flex, Textarea, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { RawTracesResponse } from 'types/api/rawTrace';
import useFetch from 'lib/hooks/useFetch';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
const data = [
{
action: {
callType: 'delegatecall',
from: '0x296033cb983747b68911244ec1a3f01d7708851b',
gas: '0x1AB35C9',
// eslint-disable-next-line max-len
input: '0x6a76120200000000000000000000000099759357a9923bb164a7ae8b85703a6882cb84ea0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000014466d2a64d000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000013ef0000000000000000000000000000000000000000000000000000000000001bfb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000186b900000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041000000000000000000000000f4e5b62da2eee3b5811dae1fae480f7623bd4cd000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000',
to: '0x3e5c63644e683549055b9be8653de26e0b4cd36e',
value: '0x0',
},
result: {
blockHash: '0x43dd926aa138a58d3f4740dae387bcff3c7bc525db2d0a449f323f8b8f92a229',
blockNumber: '0xa4f285',
from: '0xea8a7ef30f894bce23b42314613458d13f9d43ea',
gas: '0x30d40',
gasPrice: '0x2e90edd000',
hash: '0x72ee43a3784cc6749f64fad1ecf0bbd51a54dd6892ae0573f211566809e0d511',
input: '0x',
nonce: '0x1e7',
to: '0xbd064928cdd4fd67fb99917c880e6560978d7ca1',
transactionIndex: '0x0',
value: '0xde0b6b3a7640000',
v: '0x25',
r: '0x7e833413ead52b8c538001b12ab5a85bac88db0b34b61251bb0fc81573ca093f',
s: '0x49634f1e439e3760265888434a2f9782928362412030db1429458ddc9dcee995',
},
},
{
action: {
callType: 'delegatecall',
from: '0x296033cb983747b68911244ec1a3f01d7708851b',
gas: '0x1AB35C9',
// eslint-disable-next-line max-len
input: '0x6a76120200000000000000000000000099759357a9923bb164a7ae8b85703a6882cb84ea0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000014466d2a64d000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000013ef0000000000000000000000000000000000000000000000000000000000001bfb000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000186b900000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041000000000000000000000000f4e5b62da2eee3b5811dae1fae480f7623bd4cd000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000',
to: '0x3e5c63644e683549055b9be8653de26e0b4cd36e',
value: '0x0',
},
result: {
blockHash: '0x43dd926aa138a58d3f4740dae387bcff3c7bc525db2d0a449f323f8b8f92a229',
blockNumber: '0xa4f285',
from: '0xea8a7ef30f894bce23b42314613458d13f9d43ea',
gas: '0x30d40',
gasPrice: '0x2e90edd000',
hash: '0x72ee43a3784cc6749f64fad1ecf0bbd51a54dd6892ae0573f211566809e0d511',
input: '0x',
nonce: '0x1e7',
to: '0xbd064928cdd4fd67fb99917c880e6560978d7ca1',
transactionIndex: '0x0',
value: '0xde0b6b3a7640000',
v: '0x25',
r: '0x7e833413ead52b8c538001b12ab5a85bac88db0b34b61251bb0fc81573ca093f',
s: '0x49634f1e439e3760265888434a2f9782928362412030db1429458ddc9dcee995',
const TxRawTrace = () => {
const router = useRouter();
const fetch = useFetch();
const { data, isLoading, isError } = useQuery<unknown, unknown, RawTracesResponse>(
[ 'tx-raw-trace', router.query.id ],
async() => await fetch(`/api/transactions/${ router.query.id }/raw-trace`),
{
enabled: Boolean(router.query.id),
},
},
];
);
if (isError) {
return <DataFetchAlert/>;
}
if (isLoading) {
return (
<>
<Flex justifyContent="end" mb={ 2 }>
<Skeleton w={ 5 } h={ 5 }/>
</Flex>
<Skeleton w="100%" h="500px"/>
</>
);
}
const TxRawTrace = () => {
const text = JSON.stringify(data, undefined, 4);
return (
<>
<Flex justifyContent="end" mb={ 2 }>
......@@ -69,7 +45,7 @@ const TxRawTrace = () => {
</Flex>
<Textarea
variant="filledInactive"
height="570px"
minHeight="500px"
p={ 4 }
value={ text }
/>
......
import { Grid, GridItem, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react';
const SkeletonRow = ({ w = '100%' }: { w?: string }) => (
<>
<GridItem display="flex" columnGap={ 2 } w={{ base: '50%', lg: 'auto' }} _notFirst={{ mt: { base: 3, lg: 0 } }}>
<SkeletonCircle h={ 5 } w={ 5 }/>
<Skeleton flexGrow={ 1 } h={ 5 } borderRadius="full"/>
</GridItem>
<GridItem pl={{ base: 7, lg: 0 }}>
<Skeleton h={ 5 } borderRadius="full" w={{ base: '100%', lg: w }}/>
</GridItem>
</>
);
const TxDetailsSkeleton = () => {
const sectionGap = <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>;
return (
<Grid columnGap={ 8 } rowGap={{ base: 5, lg: 7 }} templateColumns={{ base: '1fr', lg: '210px 1fr' }} maxW="1000px">
<SkeletonRow/>
<SkeletonRow w="20%"/>
<SkeletonRow w="50%"/>
<SkeletonRow/>
<SkeletonRow w="70%"/>
<SkeletonRow w="70%"/>
{ sectionGap }
<SkeletonRow w="40%"/>
<SkeletonRow w="40%"/>
<SkeletonRow w="40%"/>
<SkeletonRow w="40%"/>
<SkeletonRow w="40%"/>
{ sectionGap }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Skeleton h={ 5 } borderRadius="full" w="100px"/>
</GridItem>
</Grid>
);
};
export default TxDetailsSkeleton;
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, CheckboxGroup, Checkbox, Text, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { TxInternalsType } from 'types/api/tx';
import type { TxInternalsType } from 'types/api/internalTransaction';
import FilterButton from 'ui/shared/FilterButton';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { data as txData } from 'data/txInternal';
import type { InternalTransaction } from 'types/api/internalTransaction';
import TxInternalsListItem from 'ui/tx/internals/TxInternalsListItem';
const TxInternalsList = ({ data }: { data: typeof txData}) => {
const TxInternalsList = ({ data }: { data: Array<InternalTransaction>}) => {
return (
<Box mt={ 6 }>
{ data.map((item) => <TxInternalsListItem key={ item.id } { ...item }/>) }
{ data.map((item) => <TxInternalsListItem key={ item.transaction_hash } { ...item }/>) }
</Box>
);
};
......
import { Flex, Tag, Icon, Box, HStack, Text } from '@chakra-ui/react';
import appConfig from 'configs/app/config';
import capitalize from 'lodash/capitalize';
import React from 'react';
import type ArrayElement from 'types/utils/ArrayElement';
import type { InternalTransaction } from 'types/api/internalTransaction';
import type { data } from 'data/txInternal';
import eastArrowIcon from 'icons/arrows/east.svg';
import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import TxStatus from 'ui/shared/TxStatus';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = ArrayElement<typeof data>;
type Props = InternalTransaction;
const TxInternalsListItem = ({ type, from, to, value, success, error }: Props) => {
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const TxInternalsListItem = ({ type, status, from, to, value, gasLimit }: Props) => {
return (
<AccountListItemMobile rowGap={ 3 }>
<Flex>
<Tag colorScheme="cyan" mr={ 2 }>{ capitalize(type) }</Tag>
<TxStatus status={ status }/>
<Tag colorScheme="cyan" mr={ 2 }>{ typeTitle }</Tag>
<TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/>
</Flex>
<Box w="100%" display="flex" columnGap={ 3 }>
<Address width="calc((100% - 48px) / 2)">
......@@ -37,10 +38,11 @@ const TxInternalsListItem = ({ type, status, from, to, value, gasLimit }: Props)
<Text fontSize="sm" fontWeight={ 500 }>Value { appConfig.network.currency }</Text>
<Text fontSize="sm" variant="secondary">{ value }</Text>
</HStack>
<HStack spacing={ 3 }>
{ /* no gas limit in api yet */ }
{ /* <HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Gas limit</Text>
<Text fontSize="sm" variant="secondary">{ gasLimit.toLocaleString('en') }</Text>
</HStack>
</HStack> */ }
</AccountListItemMobile>
);
};
......
import { Skeleton, Flex } from '@chakra-ui/react';
import React from 'react';
import SkeletonTable from 'ui/shared/SkeletonTable';
const TxInternalsSkeletonDesktop = () => {
return (
<>
<Flex columnGap={ 3 } h={ 8 } mb={ 6 }>
<Skeleton w="78px"/>
<Skeleton w="360px"/>
</Flex>
<SkeletonTable columns={ [ '28%', '28%', '24px', '28%', '16%' ] }/>
</>
);
};
export default TxInternalsSkeletonDesktop;
import { Skeleton, SkeletonCircle, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const TxInternalsSkeletonMobile = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<>
<Flex columnGap={ 3 } h={ 8 } mb={ 6 }>
<Skeleton w="36px" flexShrink={ 0 }/>
<Skeleton w="100%"/>
</Flex>
<Box>
{ Array.from(Array(2)).map((item, index) => (
<Flex
key={ index }
rowGap={ 3 }
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
_last={{
borderBottomWidth: '1px',
}}
>
<Flex h={ 6 }>
<Skeleton w="100px" mr={ 2 }/>
<Skeleton w="90px"/>
</Flex>
<Flex h={ 6 }>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
<Skeleton w={ 6 } mr={ 3 }/>
<SkeletonCircle size="6" mr={ 2 } flexShrink={ 0 }/>
<Skeleton flexGrow={ 1 } mr={ 3 }/>
</Flex>
<Flex h={ 6 }>
<Skeleton w="70px" mr={ 2 }/>
<Skeleton w="30px"/>
</Flex>
</Flex>
)) }
</Box>
</>
);
};
export default TxInternalsSkeletonMobile;
import { Table, Thead, Tbody, Tr, Th, TableContainer } from '@chakra-ui/react';
import { Table, Thead, Tbody, Tr, Th, TableContainer, Link, Icon } from '@chakra-ui/react';
import appConfig from 'configs/app/config';
import React from 'react';
import type { data as txData } from 'data/txInternal';
import type { InternalTransaction } from 'types/api/internalTransaction';
import arrowIcon from 'icons/arrows/east.svg';
import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem';
import type { Sort, SortField } from 'ui/tx/internals/utils';
interface Props {
data: Array<InternalTransaction>;
sort: Sort | undefined;
onSortToggle: (field: SortField) => () => void;
}
const TxInternalsTable = ({ data }: { data: typeof txData}) => {
const TxInternalsTable = ({ data, sort, onSortToggle }: Props) => {
const sortIconTransform = sort?.includes('asc') ? 'rotate(-90deg)' : 'rotate(90deg)';
return (
<TableContainer width="100%" mt={ 6 }>
......@@ -13,16 +23,27 @@ const TxInternalsTable = ({ data }: { data: typeof txData}) => {
<Thead>
<Tr>
<Th width="28%">Type</Th>
<Th width="20%">From</Th>
<Th width="28%">From</Th>
<Th width="24px" px={ 0 }/>
<Th width="20%">To</Th>
<Th width="16%" isNumeric>Value { appConfig.network.currency }</Th>
<Th width="16%" isNumeric>Gas limit</Th>
<Th width="28%">To</Th>
<Th width="16%" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('value') } columnGap={ 1 }>
{ sort?.includes('value') && <Icon as={ arrowIcon } boxSize={ 4 } transform={ sortIconTransform }/> }
Value { appConfig.network.currency }
</Link>
</Th>
{ /* no gas limit in api yet */ }
{ /* <Th width="16%" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('gas-limit') } columnGap={ 1 }>
{ sort?.includes('gas-limit') && <Icon as={ arrowIcon } boxSize={ 4 } transform={ sortIconTransform }/> }
Gas limit
</Link>
</Th> */ }
</Tr>
</Thead>
<Tbody>
{ data.map((item) => (
<TxInternalsTableItem key={ item.id } { ...item }/>
<TxInternalsTableItem key={ item.transaction_hash } { ...item }/>
)) }
</Tbody>
</Table>
......
import { Tr, Td, Tag, Icon, Box } from '@chakra-ui/react';
import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import rightArrowIcon from 'icons/arrows/east.svg';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
......@@ -8,16 +10,9 @@ import AddressLink from 'ui/shared/address/AddressLink';
import TxStatus from 'ui/shared/TxStatus';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
interface Props {
type: string;
status: 'success' | 'failed' | 'pending';
from: { hash: string; alias?: string};
to: { hash: string; alias?: string};
value: number;
gasLimit: number;
}
type Props = InternalTransaction
const TxInternalTableItem = ({ type, status, from, to, value, gasLimit }: Props) => {
const TxInternalTableItem = ({ type, from, to, value, success, error }: Props) => {
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
return (
......@@ -28,12 +23,12 @@ const TxInternalTableItem = ({ type, status, from, to, value, gasLimit }: Props)
<Tag colorScheme="cyan" mr={ 5 }>{ typeTitle }</Tag>
</Box>
) }
<TxStatus status={ status }/>
<TxStatus status={ success ? 'ok' : 'error' } errorText={ error }/>
</Td>
<Td>
<Address>
<AddressIcon hash={ from.hash }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.alias } flexGrow={ 1 }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td px={ 0 }>
......@@ -42,15 +37,16 @@ const TxInternalTableItem = ({ type, status, from, to, value, gasLimit }: Props)
<Td>
<Address>
<AddressIcon hash={ to.hash }/>
<AddressLink hash={ to.hash } alias={ to.alias } fontWeight="500" ml={ 2 }/>
<AddressLink hash={ to.hash } alias={ to.name } fontWeight="500" ml={ 2 }/>
</Address>
</Td>
<Td isNumeric>
{ value }
</Td>
<Td isNumeric>
{ /* no gas limit in api yet */ }
{ /* <Td isNumeric>
{ gasLimit.toLocaleString('en') }
</Td>
</Td> */ }
</Tr>
);
};
......
import type { TxInternalsType } from 'types/api/tx';
import type { TxInternalsType } from 'types/api/internalTransaction';
export type Sort = 'value-asc' | 'value-desc' | 'gas-limit-asc' | 'gas-limit-desc';
export type SortField = 'value' | 'gas-limit';
interface TxInternalsTypeItem {
title: string;
......@@ -7,10 +10,10 @@ interface TxInternalsTypeItem {
export const TX_INTERNALS_ITEMS: Array<TxInternalsTypeItem> = [
{ title: 'Call', id: 'call' },
{ title: 'Delegate call', id: 'delegate_call' },
{ title: 'Static call', id: 'static_call' },
{ title: 'Delegate call', id: 'delegatecall' },
{ title: 'Static call', id: 'staticcall' },
{ title: 'Create', id: 'create' },
{ title: 'Create2', id: 'create2' },
{ title: 'Self-destruct', id: 'self_destruct' },
{ title: 'Self-destruct', id: 'selfdestruct' },
{ title: 'Reward', id: 'reward' },
];
import { Text, Grid, GridItem, Link, Tooltip, Button, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Log } from 'types/api/log';
import searchIcon from 'icons/search.svg';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
......@@ -8,12 +10,7 @@ import AddressLink from 'ui/shared/address/AddressLink';
import TxLogTopic from 'ui/tx/logs/TxLogTopic';
import DecodedInputData from 'ui/tx/TxDecodedInputData';
interface Props {
address: string;
topics: Array<{ hex: string }>;
data: string;
index: number;
}
type Props = Log;
const RowHeader = ({ children }: { children: React.ReactNode }) => (
<GridItem _notFirst={{ my: { base: 4, lg: 0 } }}>
......@@ -21,7 +18,8 @@ const RowHeader = ({ children }: { children: React.ReactNode }) => (
</GridItem>
);
const TxLogItem = ({ address, index, topics, data }: Props) => {
const TxLogItem = ({ address, index, topics, data, decoded }: Props) => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const dataBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
......@@ -41,27 +39,31 @@ const TxLogItem = ({ address, index, topics, data }: Props) => {
<RowHeader>Address</RowHeader>
<GridItem display="flex" alignItems="center">
<Address>
<AddressIcon hash={ address }/>
<AddressLink hash={ address } ml={ 2 }/>
<AddressIcon hash={ address.hash }/>
<AddressLink hash={ address.hash } alias={ address.name } ml={ 2 }/>
</Address>
<Tooltip label="Find matches topic">
<Link ml={ 2 } display="inline-flex">
<Link ml={ 2 } mr={{ base: 9, lg: 0 }} display="inline-flex">
<Icon as={ searchIcon } boxSize={ 5 }/>
</Link>
</Tooltip>
<Tooltip label="Log index">
<Button variant="outline" colorScheme="gray" isActive ml={{ base: 9, lg: 'auto' }} size="sm" fontWeight={ 400 }>
<Button variant="outline" colorScheme="gray" isActive ml="auto" size="sm" fontWeight={ 400 }>
{ index }
</Button>
</Tooltip>
</GridItem>
<RowHeader>Decode input data</RowHeader>
<GridItem>
<DecodedInputData/>
</GridItem>
{ decoded && (
<>
<RowHeader>Decode input data</RowHeader>
<GridItem>
<DecodedInputData data={ decoded }/>
</GridItem>
</>
) }
<RowHeader>Topics</RowHeader>
<GridItem>
{ topics.map((item, index) => <TxLogTopic key={ index } { ...item } index={ index }/>) }
{ topics.filter(Boolean).map((item, index) => <TxLogTopic key={ index } hex={ item } index={ index }/>) }
</GridItem>
<RowHeader>Data</RowHeader>
<GridItem p={ 4 } fontSize="sm" borderRadius="md" bgColor={ dataBgColor }>
......
import { Flex, Grid, GridItem, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
const RowHeader = () => (
<GridItem _notFirst={{ my: { base: 4, lg: 0 } }} _first={{ alignSelf: 'center' }}>
<Skeleton h={ 6 } borderRadius="full" w="150px"/>
</GridItem>
);
const TopicRow = () => (
<Flex columnGap={ 3 }>
<SkeletonCircle size="6"/>
<Skeleton h={ 6 } w="70px" borderRadius="full"/>
<Skeleton h={ 6 } flexGrow={ 1 } borderRadius="full"/>
</Flex>
);
const TxLogSkeleton = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Grid
gap={{ base: 2, lg: 8 }}
templateColumns={{ base: '1fr', lg: '200px 1fr' }}
py={ 8 }
_notFirst={{
borderTopWidth: '1px',
borderTopColor: borderColor,
}}
_first={{
pt: 0,
}}
>
<RowHeader/>
<GridItem display="flex" alignItems="center">
<SkeletonCircle size="6"/>
<Skeleton h={ 6 } flexGrow={ 1 } borderRadius="full" ml={ 2 } mr={ 9 }/>
<Skeleton h={ 8 } w={ 8 } borderRadius="base"/>
</GridItem>
<RowHeader/>
<GridItem>
<Skeleton h="150px" w="100%" borderRadius="base"/>
</GridItem>
<RowHeader/>
<GridItem display="flex" flexDir="column" rowGap={ 3 }>
<TopicRow/>
<TopicRow/>
<TopicRow/>
</GridItem>
<RowHeader/>
<GridItem>
<Skeleton h="60px" w="100%" borderRadius="base"/>
</GridItem>
</Grid>
);
};
export default TxLogSkeleton;
......@@ -23,22 +23,18 @@ const TxLogTopic = ({ hex, index }: Props) => {
<Button variant="outline" colorScheme="gray" isActive size="xs" fontWeight={ 400 } mr={ 3 } w={ 6 }>
{ index }
</Button>
{ /* temporary condition juse to show different states of the component */ }
{ /* delete when ther will be real data */ }
{ index > 0 && (
<Select
size="sm"
borderRadius="base"
value={ selectedDataType }
onChange={ handleSelectChange }
focusBorderColor="none"
w="75px"
mr={ 3 }
flexShrink={ 0 }
>
{ OPTIONS.map((option) => <option key={ option } value={ option }>{ option }</option>) }
</Select>
) }
<Select
size="sm"
borderRadius="base"
value={ selectedDataType }
onChange={ handleSelectChange }
focusBorderColor="none"
w="75px"
mr={ 3 }
flexShrink={ 0 }
>
{ OPTIONS.map((option) => <option key={ option } value={ option }>{ option }</option>) }
</Select>
<Box overflow="hidden" whiteSpace="nowrap">
<HashStringShortenDynamic hash={ hex }/>
</Box>
......
import { AccordionItem, AccordionButton, AccordionIcon, Button, Flex, Text, Link, StatArrow, Stat, AccordionPanel } from '@chakra-ui/react';
import { AccordionItem, AccordionButton, AccordionIcon, Button, Box, Flex, Text, Link, StatArrow, Stat, AccordionPanel } from '@chakra-ui/react';
import appConfig from 'configs/app/config';
import React from 'react';
......@@ -10,7 +10,6 @@ import AccountListItemMobile from 'ui/shared/AccountListItemMobile';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import TextSeparator from 'ui/shared/TextSeparator';
import TxStateStorageItem from './TxStateStorageItem';
......@@ -22,18 +21,14 @@ const TxStateListItem = ({ storage, address, miner, after, before, diff }: Props
return (
<AccountListItemMobile>
<AccordionItem isDisabled={ !hasStorageData } border={ 0 } w="100%" display="flex" flexDirection="column" rowGap={ 3 }>
<AccordionItem isDisabled={ !hasStorageData } border={ 0 } w="100%" display="flex" flexDirection="column">
{ ({ isExpanded }) => (
<>
<Flex>
<Address flexGrow={ 1 }>
<AddressIcon hash={ address }/>
<AddressLink hash={ address } fontWeight="500" ml={ 2 }/>
</Address>
<Flex mb={ 6 }>
<AccordionButton
_hover={{ background: 'unset' }}
padding="0"
ml={ 4 }
mr={ 5 }
w="auto"
>
<Button
......@@ -53,36 +48,47 @@ const TxStateListItem = ({ storage, address, miner, after, before, diff }: Props
</Button>
<AccordionIcon color="blue.600" width="30px"/>
</AccordionButton>
</Flex>
<Flex rowGap={ 2 } flexDir="column" fontSize="sm">
<Text fontWeight={ 600 }>Miner</Text>
<Link>{ miner }</Link>
</Flex>
<Flex rowGap={ 2 } flexDir="column" fontSize="sm">
<Text fontWeight={ 600 }>Before</Text>
<Flex>
<Text>{ before.balance } { appConfig.network.currency }</Text>
<TextSeparator/>
{ typeof before.nonce !== 'undefined' && <Text>Nonce:{ nbsp }{ before.nonce }</Text> }
</Flex>
</Flex>
<Flex rowGap={ 2 } flexDir="column" fontSize="sm">
<Text fontWeight={ 600 }>After</Text>
<Text>{ after.balance } { appConfig.network.currency }</Text>
{ typeof after.nonce !== 'undefined' && <Text>Nonce:{ nbsp }{ after.nonce }</Text> }
</Flex>
<Flex rowGap={ 2 } flexDir="column" fontSize="sm">
<Text fontWeight={ 600 }>State difference</Text>
<Stat>
{ diff } { appConfig.network.currency }
<StatArrow ml={ 2 } type={ Number(diff) > 0 ? 'increase' : 'decrease' }/>
</Stat>
<Address flexGrow={ 1 }>
<AddressIcon hash={ address }/>
<AddressLink hash={ address } ml={ 2 }/>
</Address>
</Flex>
{ hasStorageData && (
<AccordionPanel fontWeight={ 500 } p={ 0 }>
{ storage?.map((storageItem, index) => <TxStateStorageItem key={ index } storageItem={ storageItem }/>) }
</AccordionPanel>
) }
<Flex rowGap={ 2 } flexDir="column" fontSize="sm" whiteSpace="pre" fontWeight={ 500 }>
<Box>
<Text as="span">Miner </Text>
<Link>{ miner }</Link>
</Box>
<Box>
<Text as="span">Before { appConfig.network.currency } </Text>
<Text as="span" variant="secondary">{ before.balance }</Text>
</Box>
{ typeof before.nonce !== 'undefined' && (
<Box>
<Text as="span">Nonce:</Text>
<Text as="span" fontWeight={ 600 }>{ nbsp }{ before.nonce }</Text>
</Box>
) }
<Box>
<Text as="span">After { appConfig.network.currency } </Text>
<Text as="span" variant="secondary">{ after.balance }</Text>
</Box>
{ typeof after.nonce !== 'undefined' && (
<Box>
<Text as="span">Nonce:</Text>
<Text as="span" fontWeight={ 600 }>{ nbsp }{ after.nonce }</Text>
</Box>
) }
<Text>State difference { appConfig.network.currency }</Text>
<Stat>
{ diff }
<StatArrow ml={ 2 } type={ Number(diff) > 0 ? 'increase' : 'decrease' }/>
</Stat>
</Flex>
</>
) }
</AccordionItem>
......
......@@ -8,6 +8,7 @@ import {
import React from 'react';
import type { TTxStateItemStorage } from 'data/txState';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
const TxStateStorageItem = ({ storageItem }: {storageItem: TTxStateItemStorage}) => {
const gridData = [
......@@ -21,20 +22,27 @@ const TxStateStorageItem = ({ storageItem }: {storageItem: TTxStateItemStorage})
const OPTIONS = [ 'Hex', 'Number', 'Text', 'Address' ];
return (
<Grid
gridTemplateColumns={{ base: '70px minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }}
gridTemplateColumns={{ base: 'minmax(0, 1fr)', lg: 'auto minmax(0, 1fr)' }}
columnGap={ 3 }
rowGap={ 4 }
px={ 6 }
py={ 4 }
background="blackAlpha.50"
rowGap={{ base: 2.5, lg: 4 }}
px={{ base: 3, lg: 6 }}
py={{ base: 3, lg: 4 }}
backgroundColor={ useColorModeValue('blackAlpha.50', 'whiteAlpha.100') }
borderRadius="12px"
mb={ 4 }
fontSize="sm"
>
{ gridData.map((item) => (
<React.Fragment key={ item.name }>
<GridItem alignSelf={{ base: 'start', lg: 'center' }} fontWeight={{ base: 500, lg: 600 }} textAlign="end">{ item.name }</GridItem>
<GridItem display="flex" flexDir={{ base: 'column', lg: 'row' }} rowGap={ 2 } alignItems={{ base: 'flex-start', lg: 'center' }} >
<GridItem
alignSelf="center"
fontWeight={ 600 }
textAlign={{ base: 'start', lg: 'end' }}
_notFirst={{ mt: { base: 0.5, lg: 0 } }}
>
{ item.name }
</GridItem>
<GridItem display="flex" flexDir="row" columnGap={ 3 } alignItems="center" >
{ item.select && (
<Select
size="sm"
......@@ -42,14 +50,14 @@ const TxStateStorageItem = ({ storageItem }: {storageItem: TTxStateItemStorage})
focusBorderColor="none"
display="inline-block"
w="auto"
mr={ 3 }
flexShrink={ 0 }
background={ backgroundColor }
>
{ OPTIONS.map((option) => <option key={ option } value={ option }>{ option }</option>) }
</Select>
) }
<Box fontWeight={{ base: 400, lg: 500 }} maxW="100%">
{ item.value }
<Box fontWeight={ 500 } whiteSpace="nowrap" overflow="hidden">
<HashStringShortenDynamic fontWeight="500" hash={ item.value }/>
</Box>
</GridItem>
</React.Fragment>
......
......@@ -15,7 +15,7 @@ import TxStateTableItem from 'ui/tx/state/TxStateTableItem';
const TxStateTable = () => {
return (
<TableContainer width="100%" mt={ 6 }>
<Table variant="simple" minWidth="950px" size="sm">
<Table variant="simple" minWidth="950px" size="sm" w="auto">
<Thead>
<Tr>
<Th width="92px">Storage</Th>
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type ArrayElement from 'types/utils/ArrayElement';
import type { txs } from 'data/txs';
import { nbsp } from 'lib/html-entities';
import useLink from 'lib/link/useLink';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization';
......@@ -21,6 +22,7 @@ const TxAdditionalInfo = ({ tx }: { tx: ArrayElement<typeof txs> }) => {
color: 'gray.500',
fontWeight: 600,
marginBottom: 3,
fontSize: 'sm',
};
const link = useLink();
......@@ -31,7 +33,7 @@ const TxAdditionalInfo = ({ tx }: { tx: ArrayElement<typeof txs> }) => {
<Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Transaction fee</Text>
<Flex>
<Text>{ tx.fee.value } { appConfig.network.currency }</Text>
<Text>{ tx.fee.value }{ nbsp }{ appConfig.network.currency }</Text>
<Text variant="secondary" ml={ 1 }>(${ tx.fee.value_usd.toFixed(2) })</Text>
</Flex>
</Box>
......@@ -64,7 +66,7 @@ const TxAdditionalInfo = ({ tx }: { tx: ArrayElement<typeof txs> }) => {
<Box>
<Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ tx.type.value }</Text>
<Text fontWeight="400" as="span" ml={ 1 }>({ tx.type.eip })</Text>
<Text fontWeight="400" as="span" ml={ 1 } color="gray.500">({ tx.type.eip })</Text>
</Box>
<Box>
<Text as="span" fontWeight="500">Nonce: </Text>
......@@ -75,7 +77,7 @@ const TxAdditionalInfo = ({ tx }: { tx: ArrayElement<typeof txs> }) => {
<Text fontWeight="600" as="span">{ tx.position }</Text>
</Box>
</Box>
<Link href={ link('tx_index', { id: tx.hash }) }>More details</Link>
<Link fontSize="sm" href={ link('tx_index', { id: tx.hash }) }>More details</Link>
</>
);
};
......
......@@ -13,7 +13,7 @@ const TxAdditionalInfoButton = ({ isOpen, onClick }: {isOpen?: boolean; onClick?
const infoColor = useColorModeValue('blue.600', 'blue.300');
return (
<Center ref={ ref } background={ isOpen ? infoBgColor : 'unset' } borderRadius="8px" w="30px" h="30px" onClick={ onClick }>
<Center ref={ ref } background={ isOpen ? infoBgColor : 'unset' } borderRadius="8px" w="24px" h="24px" onClick={ onClick } cursor="pointer">
<Icon
as={ infoIcon }
boxSize={ 5 }
......
......@@ -62,7 +62,7 @@ const TxsListItem = ({ tx }: {tx: ArrayElement<typeof txs>}) => {
/>
</Address>
</Flex>
<Text variant="secondary" fontWeight="400">{ dayjs(tx.timestamp).fromNow() }</Text>
<Text variant="secondary" fontWeight="400" fontSize="sm">{ dayjs(tx.timestamp).fromNow() }</Text>
</Flex>
<Flex mt={ 3 }>
<Text as="span" whiteSpace="pre">Method </Text>
......
......@@ -26,9 +26,9 @@ const TxsTable = ({ txs, sort, sorting }: Props) => {
<Th width="18%">Txn hash</Th>
<Th width="15%">Method</Th>
<Th width="11%">Block</Th>
<Th width={{ xl: '128px', base: '58px' }}>From</Th>
<Th width={{ xl: '128px', base: '66px' }}>From</Th>
<Th width={{ xl: '36px', base: '0' }}></Th>
<Th width={{ xl: '128px', base: '58px' }}>To</Th>
<Th width={{ xl: '128px', base: '66px' }}>To</Th>
<Th width="18%" isNumeric>
<Link onClick={ sort('val') } display="flex" justifyContent="end">
{ sorting === 'val-asc' && <Icon boxSize={ 5 } as={ rightArrowIcon } transform="rotate(-90deg)"/> }
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment