Commit 54a512b8 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Celo: Block views customizations (#2185)

* show flag next to epoch block

* block view customizations

* add hint with link

* display block epoch transfers

* show epoch election reward types

* show reward details

* show base fee token from API

* display current epoch on main page

* mobile view

* tests

* add infinite scroll loading to reward details resource

* update useApiQuery options

* update screenshots

* update layout of reward details on desktop

* change hints of block epoch reward distribution
parent 4221b04b
<svg viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="2.4" height="16" rx="1.2" fill="currentColor"/>
<path d="M2.9 2.1h9.18a1.5 1.5 0 0 1 1.5 1.5v5.6a1.5 1.5 0 0 1-1.5 1.5H2.9V2.1Z" stroke="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.12 2.4H2.4v2.667h2.72V2.4Zm5.44 0H7.84v2.667h2.72V2.4ZM2.4 7.733h2.72V10.4H2.4V7.733Zm8.16 0H7.84V10.4h2.72V7.733ZM5.12 5.067h2.72v2.666H5.12V5.067Zm8.16 0h-2.72v2.666h2.72V5.067Z" fill="currentColor"/>
</svg>
......@@ -51,7 +51,16 @@ import type {
ArbitrumL2TxnBatchesItem,
} from 'types/api/arbitrumL2';
import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse, BlockCountdownResponse } from 'types/api/block';
import type {
BlocksResponse,
BlockTransactionsResponse,
Block,
BlockFilters,
BlockWithdrawalsResponse,
BlockCountdownResponse,
BlockEpoch,
BlockEpochElectionRewardDetailsResponse,
} from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs';
import type {
......@@ -327,6 +336,16 @@ export const RESOURCES = {
pathParams: [ 'height_or_hash' as const ],
filterFields: [],
},
block_epoch: {
path: '/api/v2/blocks/:height_or_hash/epoch',
pathParams: [ 'height_or_hash' as const ],
filterFields: [],
},
block_election_rewards: {
path: '/api/v2/blocks/:height_or_hash/election-rewards/:reward_type',
pathParams: [ 'height_or_hash' as const, 'reward_type' as const ],
filterFields: [],
},
txs_stats: {
path: '/api/v2/transactions/stats',
},
......@@ -938,7 +957,7 @@ export interface ResourceError<T = unknown> {
export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' |
export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_rewards' |
'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
'addresses' |
......@@ -998,6 +1017,8 @@ Q extends 'block' ? Block :
Q extends 'block_countdown' ? BlockCountdownResponse :
Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'block_epoch' ? BlockEpoch :
Q extends 'block_election_rewards' ? BlockEpochElectionRewardDetailsResponse :
Q extends 'txs_stats' ? TransactionsStats :
Q extends 'txs_validated' ? TransactionsResponseValidated :
Q extends 'txs_pending' ? TransactionsResponsePending :
......
......@@ -19,7 +19,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources'
export interface Params<R extends ResourceName> {
pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined | null>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>;
}
......
import type { InfiniteData, QueryKey, UseInfiniteQueryResult } from '@tanstack/react-query';
import { useInfiniteQuery, type UseInfiniteQueryOptions } from '@tanstack/react-query';
import type { PaginatedResources, ResourceError, ResourcePayload } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import type { Params as ApiFetchParams } from 'lib/api/useApiFetch';
import { getResourceKey } from './useApiQuery';
type TQueryData<R extends PaginatedResources> = ResourcePayload<R>;
type TError = ResourceError<unknown>;
type TPageParam<R extends PaginatedResources> = ApiFetchParams<R>['queryParams'] | null;
export interface Params<R extends PaginatedResources> {
resourceName: R;
// eslint-disable-next-line max-len
queryOptions?: Omit<UseInfiniteQueryOptions<TQueryData<R>, TError, InfiniteData<TQueryData<R>>, TQueryData<R>, QueryKey, TPageParam<R>>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'>;
pathParams?: ApiFetchParams<R>['pathParams'];
}
type ReturnType<Resource extends PaginatedResources> = UseInfiniteQueryResult<InfiniteData<ResourcePayload<Resource>>, ResourceError<unknown>>;
export default function useApiInfiniteQuery<R extends PaginatedResources>({
resourceName,
queryOptions,
pathParams,
}: Params<R>): ReturnType<R> {
const apiFetch = useApiFetch();
return useInfiniteQuery<TQueryData<R>, TError, InfiniteData<TQueryData<R>>, QueryKey, TPageParam<R>>({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: getResourceKey(resourceName, { pathParams }),
queryFn: (context) => {
const queryParams = 'pageParam' in context ? (context.pageParam || undefined) : undefined;
return apiFetch(resourceName, { pathParams, queryParams }) as Promise<TQueryData<R>>;
},
initialPageParam: null,
getNextPageParam: (lastPage) => {
return lastPage.next_page_params as TPageParam<R>;
},
...queryOptions,
});
}
import type { QueryKey, UseQueryOptions } from '@tanstack/react-query';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import type { Params as FetchParams } from 'lib/hooks/useFetch';
......@@ -10,8 +10,7 @@ export interface Params<R extends ResourceName, E = unknown, D = ResourcePayload
pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'headers'>;
queryOptions?: Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryKey' | 'queryFn'>;
queryKey?: QueryKey;
queryOptions?: Partial<Omit<UseQueryOptions<ResourcePayload<R>, ResourceError<E>, D>, 'queryFn'>>;
}
export function getResourceKey<R extends ResourceName>(resource: R, { pathParams, queryParams }: Params<R> = {}) {
......@@ -24,13 +23,13 @@ export function getResourceKey<R extends ResourceName>(resource: R, { pathParams
export default function useApiQuery<R extends ResourceName, E = unknown, D = ResourcePayload<R>>(
resource: R,
{ queryOptions, pathParams, queryParams, queryKey, fetchParams }: Params<R, E, D> = {},
{ queryOptions, pathParams, queryParams, fetchParams }: Params<R, E, D> = {},
) {
const apiFetch = useApiFetch();
return useQuery<ResourcePayload<R>, ResourceError<E>, D>({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: queryKey || getResourceKey(resource, { pathParams, queryParams }),
queryKey: queryOptions?.queryKey || getResourceKey(resource, { pathParams, queryParams }),
queryFn: async({ signal }) => {
// all errors and error typing is handled by react-query
// so error response will never go to the data
......
......@@ -3,6 +3,11 @@ import type { RpcBlock } from 'viem';
import type { Block, BlocksResponse } from 'types/api/block';
import { ZERO_ADDRESS } from 'lib/consts';
import * as addressMock from '../address/address';
import * as tokenMock from '../tokens/tokenInfo';
export const base: Block = {
base_fee_per_gas: '10000000000',
burnt_fees: '5449200000000000',
......@@ -137,6 +142,34 @@ export const rootstock: Block = {
minimum_gas_price: '59240000',
};
export const celo: Block = {
...base,
celo: {
base_fee: {
token: tokenMock.tokenInfoERC20a,
amount: '445690000000000',
breakdown: [
{
address: addressMock.withName,
amount: '356552000000000.0000000000000',
percentage: 80,
},
{
address: {
...addressMock.withoutName,
hash: ZERO_ADDRESS,
},
amount: '89138000000000.0000000000000',
percentage: 20,
},
],
recipient: addressMock.contract,
},
epoch_number: 1486,
is_epoch_block: true,
},
};
export const withBlobTxs: Block = {
...base,
blob_gas_price: '21518435987',
......
import _padStart from 'lodash/padStart';
import type { BlockEpoch, BlockEpochElectionRewardDetails, BlockEpochElectionRewardDetailsResponse } from 'types/api/block';
import * as addressMock from '../address/address';
import * as tokenMock from '../tokens/tokenInfo';
import * as tokenTransferMock from '../tokens/tokenTransfer';
export const blockEpoch1: BlockEpoch = {
number: 1486,
distribution: {
carbon_offsetting_transfer: tokenTransferMock.erc20,
community_transfer: tokenTransferMock.erc20,
reserve_bolster_transfer: null,
},
aggregated_election_rewards: {
delegated_payment: {
count: 0,
total: '71210001063118670575',
token: tokenMock.tokenInfoERC20d,
},
group: {
count: 10,
total: '157705500305820107521',
token: tokenMock.tokenInfoERC20b,
},
validator: {
count: 10,
total: '1348139501689262297152',
token: tokenMock.tokenInfoERC20c,
},
voter: {
count: 38,
total: '2244419545166303388',
token: tokenMock.tokenInfoERC20a,
},
},
};
function getRewardDetailsItem(index: number): BlockEpochElectionRewardDetails {
return {
amount: `${ 100 - index }210001063118670575`,
account: {
...addressMock.withoutName,
hash: `0x30D060F129817c4DE5fBc1366d53e19f43c8c6${ _padStart(String(index), 2, '0') }`,
},
associated_account: {
...addressMock.withoutName,
hash: `0x456f41406B32c45D59E539e4BBA3D7898c3584${ _padStart(String(index), 2, '0') }`,
},
};
}
export const electionRewardDetails1: BlockEpochElectionRewardDetailsResponse = {
items: Array(15).fill('').map((item, index) => getRewardDetailsItem(index)),
next_page_params: null,
};
......@@ -29,6 +29,7 @@
| "burger"
| "certified"
| "check"
| "checkered_flag"
| "clock-light"
| "clock"
| "coins/bitcoin"
......
import type { Block } from 'types/api/block';
import type { Block, BlockEpochElectionReward, BlockEpoch } from 'types/api/block';
import { ADDRESS_PARAMS } from './addressParams';
import { TOKEN_INFO_ERC_20, TOKEN_TRANSFER_ERC_20 } from './token';
export const BLOCK_HASH = '0x8fa7b9e5e5e79deeb62d608db22ba9a5cb45388c7ebb9223ae77331c6080dc70';
......@@ -35,3 +36,24 @@ export const BLOCK: Block = {
type: 'block',
uncles_hashes: [],
};
const BLOCK_EPOCH_REWARD: BlockEpochElectionReward = {
count: 10,
total: '157705500305820107521',
token: TOKEN_INFO_ERC_20,
};
export const BLOCK_EPOCH: BlockEpoch = {
number: 1486,
aggregated_election_rewards: {
group: BLOCK_EPOCH_REWARD,
validator: BLOCK_EPOCH_REWARD,
voter: BLOCK_EPOCH_REWARD,
delegated_payment: BLOCK_EPOCH_REWARD,
},
distribution: {
carbon_offsetting_transfer: TOKEN_TRANSFER_ERC_20,
community_transfer: TOKEN_TRANSFER_ERC_20,
reserve_bolster_transfer: TOKEN_TRANSFER_ERC_20,
},
};
......@@ -11,10 +11,11 @@ import type { TokenInstanceTransferPagination, TokenInstanceTransferResponse } f
import type { TokenTransfer, TokenTransferPagination, TokenTransferResponse } from 'types/api/tokenTransfer';
import { ADDRESS_PARAMS, ADDRESS_HASH } from './addressParams';
import { BLOCK_HASH } from './block';
import { TX_HASH } from './tx';
import { generateListStub } from './utils';
export const BLOCK_HASH = '0x8fa7b9e5e5e79deeb62d608db22ba9a5cb45388c7ebb9223ae77331c6080dc70';
export const TOKEN_INFO_ERC_20: TokenInfo<'ERC-20'> = {
address: ADDRESS_HASH,
circulating_market_cap: '117629601.61913824',
......
......@@ -60,6 +60,7 @@ const colors = {
facebook: '#4460A0',
medium: '#231F20',
reddit: '#FF4500',
celo: '#FCFF52',
};
export default colors;
......@@ -3,10 +3,19 @@ import type { Reward } from 'types/api/reward';
import type { Transaction } from 'types/api/transaction';
import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2';
import type { TokenInfo } from './token';
import type { TokenTransfer } from './tokenTransfer';
import type { ZkSyncBatchesItem } from './zkSyncL2';
export type BlockType = 'block' | 'reorg' | 'uncle';
export interface BlockBaseFeeCelo {
amount: string;
breakdown: Array<{ amount: string; percentage: number; address: AddressParam }>;
recipient: AddressParam;
token: TokenInfo;
}
export interface Block {
height: number;
timestamp: string;
......@@ -50,6 +59,12 @@ export interface Block {
'batch_number': number | null;
};
arbitrum?: ArbitrumBlockData;
// CELO FIELDS
celo?: {
epoch_number: number;
is_epoch_block: boolean;
base_fee?: BlockBaseFeeCelo;
};
}
type ArbitrumBlockData = {
......@@ -112,3 +127,35 @@ export interface BlockCountdownResponse {
RemainingBlock: string;
} | null;
}
export interface BlockEpochElectionReward {
count: number;
token: TokenInfo<'ERC-20'>;
total: string;
}
export interface BlockEpoch {
number: number;
distribution: {
carbon_offsetting_transfer: TokenTransfer | null;
community_transfer: TokenTransfer | null;
reserve_bolster_transfer: TokenTransfer | null;
};
aggregated_election_rewards: {
delegated_payment: BlockEpochElectionReward | null;
group: BlockEpochElectionReward | null;
validator: BlockEpochElectionReward | null;
voter: BlockEpochElectionReward | null;
};
}
export interface BlockEpochElectionRewardDetails {
account: AddressParam;
amount: string;
associated_account: AddressParam;
}
export interface BlockEpochElectionRewardDetailsResponse {
items: Array<BlockEpochElectionRewardDetails>;
next_page_params: null;
}
......@@ -19,6 +19,9 @@ export type HomeStats = {
rootstock_locked_btc?: string | null;
last_output_root_size?: string | null;
secondary_coin_price?: string | null;
celo?: {
epoch_number: number;
};
}
export type GasPrices = {
......
......@@ -38,6 +38,7 @@ import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
import BlockDetailsBaseFeeCelo from './details/BlockDetailsBaseFeeCelo';
import BlockDetailsBlobInfo from './details/BlockDetailsBlobInfo';
import type { BlockQuery } from './useBlockQuery';
......@@ -394,6 +395,8 @@ const BlockDetails = ({ query }: Props) => {
<DetailsInfoItemDivider/>
{ data.celo?.base_fee && <BlockDetailsBaseFeeCelo data={ data.celo.base_fee }/> }
<DetailsInfoItem.Label
hint="The total gas amount used in the block and its percentage of gas filled in the block"
isLoading={ isPlaceholderData }
......
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { BLOCK_EPOCH } from 'stubs/block';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import BlockEpochElectionRewards from './epochRewards/BlockEpochElectionRewards';
import BlockEpochRewardsDistribution from './epochRewards/BlockEpochRewardsDistribution';
interface Props {
heightOrHash: string;
}
const BlockEpochRewards = ({ heightOrHash }: Props) => {
const query = useApiQuery('block_epoch', {
pathParams: {
height_or_hash: heightOrHash,
},
queryOptions: {
placeholderData: BLOCK_EPOCH,
},
});
if (query.isError) {
return <DataFetchAlert/>;
}
if (!query.data) {
return <span>No block epoch rewards data</span>;
}
return (
<>
<BlockEpochRewardsDistribution data={ query.data } isLoading={ query.isPlaceholderData }/>
<BlockEpochElectionRewards data={ query.data } isLoading={ query.isPlaceholderData }/>
</>
);
};
export default React.memo(BlockEpochRewards);
import { Grid } from '@chakra-ui/react';
import React from 'react';
import type { BlockBaseFeeCelo } from 'types/api/block';
import * as blockMock from 'mocks/blocks/block';
import { test, expect } from 'playwright/lib';
import BlockDetailsBaseFeeCelo from './BlockDetailsBaseFeeCelo';
test('base view +@mobile', async({ render }) => {
const component = await render(
<Grid
columnGap={ 8 }
rowGap={{ base: 3, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(min-content, 200px) minmax(0, 1fr)' }}
overflow="hidden"
>
<BlockDetailsBaseFeeCelo data={ blockMock.celo.celo?.base_fee as BlockBaseFeeCelo }/>
</Grid>,
);
await expect(component).toHaveScreenshot();
});
import { Box, Flex, Link } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressParam } from 'types/api/addressParams';
import type { BlockBaseFeeCelo } from 'types/api/block';
import type { TokenInfo } from 'types/api/token';
import { WEI, ZERO_ADDRESS } from 'lib/consts';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg';
type ItemProps = BlockBaseFeeCelo['breakdown'][number] & {
addressFrom: AddressParam;
token: TokenInfo;
}
const BreakDownItem = ({ amount, percentage, address, addressFrom, token }: ItemProps) => {
const isBurning = address.hash === ZERO_ADDRESS;
return (
<Flex alignItems="center" columnGap={ 2 } rowGap={ 1 } flexWrap="wrap">
<Box color="text_secondary">{ percentage }% of amount</Box>
<Flex columnGap={ 2 }>
{ BigNumber(amount).dividedBy(WEI).toFixed() }
<TokenEntity token={ token } noCopy onlySymbol/>
</Flex>
{ isBurning ? (
<>
<AddressEntity address={ addressFrom } truncation="constant"/>
<IconSvg name="flame" boxSize={ 5 } color="gray.500"/>
<Box color="text_secondary">burnt</Box>
</>
) : <AddressFromTo from={ addressFrom } to={ address }/> }
</Flex>
);
};
interface Props {
data: BlockBaseFeeCelo;
}
const BlockDetailsBaseFeeCelo = ({ data }: Props) => {
const totalBaseFee = BigNumber(data.amount).dividedBy(WEI).toFixed();
const totalFeeLabel = (
<Box whiteSpace="pre-wrap">
<span>The FeeHandler regularly burns 80% of its tokens. Non-CELO tokens are swapped to CELO beforehand. The remaining 20% are sent to the </span>
<Link isExternal href="https://www.ultragreen.money">Green Fund</Link>
<span>.</span>
</Box>
);
return (
<>
<DetailsInfoItem.Label
hint="The contract receiving the base fee, responsible for handling fee usage. This contract is controlled by governance process."
>
Base fee handler
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<AddressEntity address={ data.recipient }/>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
hint={ totalFeeLabel }
type="popover"
>
Base fee total
</DetailsInfoItem.Label>
<DetailsInfoItem.Value display="block">
<Flex columnGap={ 2 }>
{ totalBaseFee }
<TokenEntity token={ data.token } noCopy onlySymbol/>
</Flex>
{ data.breakdown.length > 0 && (
<Flex flexDir="column" rowGap={ 2 } mt={ 2 }>
{ data.breakdown.map((item, index) => (
<BreakDownItem
key={ index }
{ ...item }
addressFrom={ data.recipient }
token={ data.token }
/>
)) }
</Flex>
) }
</DetailsInfoItem.Value>
<DetailsInfoItemDivider/>
</>
);
};
export default React.memo(BlockDetailsBaseFeeCelo);
import { Box, Grid, GridItem, Text, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import type { TokenInfo } from 'types/api/token';
import getCurrencyValue from 'lib/getCurrencyValue';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import useLazyLoadedList from 'ui/shared/pagination/useLazyLoadedList';
import { formatRewardType, getRewardDetailsTableTitles } from './utils';
interface Props {
type: keyof BlockEpoch['aggregated_election_rewards'];
token: TokenInfo;
}
const BlockEpochElectionRewardDetailsDesktop = ({ type, token }: Props) => {
const rootRef = React.useRef<HTMLDivElement>(null);
const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash);
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const { cutRef, query } = useLazyLoadedList({
rootRef,
resourceName: 'block_election_rewards',
pathParams: { height_or_hash: heightOrHash, reward_type: formatRewardType(type) },
queryOptions: {
refetchOnMount: false,
},
});
const titles = getRewardDetailsTableTitles(type);
return (
<Box
p={ 4 }
bgColor={ bgColor }
borderRadius="base"
maxH="360px"
overflowY="scroll"
fontWeight={ 400 }
lineHeight={ 5 }
>
{ query.data && (
<Grid
gridTemplateColumns="min-content min-content min-content"
rowGap={ 5 }
columnGap={ 5 }
>
<GridItem fontWeight={ 600 } mb={ 1 } whiteSpace="nowrap">
{ titles[0] }
</GridItem>
<GridItem fontWeight={ 600 } mb={ 1 } whiteSpace="nowrap" textAlign="right">
Amount { token.symbol }
</GridItem>
<GridItem fontWeight={ 600 } mb={ 1 } whiteSpace="nowrap">
{ titles[1] }
</GridItem>
{ query.data?.pages
.map((page) => page.items)
.flat()
.map((item, index) => {
const amount = getCurrencyValue({
value: item.amount,
decimals: token.decimals,
});
return (
<React.Fragment key={ index }>
<GridItem>
<AddressEntity address={ item.account } noIcon truncation="constant"/>
</GridItem>
<GridItem textAlign="right">
{ amount.valueStr }
</GridItem>
<GridItem>
<AddressEntity address={ item.associated_account } noIcon truncation="constant"/>
</GridItem>
</React.Fragment>
);
}) }
</Grid>
) }
{ query.isFetching && <ContentLoader maxW="200px" mt={ 3 }/> }
{ query.isError && <Text color="error" mt={ 3 }>Something went wrong. Unable to load next page.</Text> }
<Box h="0" w="100px" ref={ cutRef }/>
</Box>
);
};
export default React.memo(BlockEpochElectionRewardDetailsDesktop);
import { Box, Flex, Text, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import type { TokenInfo } from 'types/api/token';
import getCurrencyValue from 'lib/getCurrencyValue';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import useLazyLoadedList from 'ui/shared/pagination/useLazyLoadedList';
import { formatRewardType } from './utils';
interface Props {
type: keyof BlockEpoch['aggregated_election_rewards'];
token: TokenInfo;
}
const BlockEpochElectionRewardDetailsMobile = ({ type, token }: Props) => {
const rootRef = React.useRef<HTMLDivElement>(null);
const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash);
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const { cutRef, query } = useLazyLoadedList({
rootRef,
resourceName: 'block_election_rewards',
pathParams: { height_or_hash: heightOrHash, reward_type: formatRewardType(type) },
queryOptions: {
refetchOnMount: false,
},
});
return (
<Flex flexDir="column" rowGap={ 3 } p={ 4 } bgColor={ bgColor } borderRadius="base" maxH="360px" overflowY="scroll">
{ query.data?.pages
.map((page) => page.items)
.flat()
.map((item, index) => {
const amount = getCurrencyValue({
value: item.amount,
decimals: token.decimals,
});
return (
<Flex key={ index } flexDir="column" alignItems="flex-start" rowGap={ 1 } fontWeight={ 400 }>
<AddressEntity address={ item.account } noIcon w="100%"/>
<Flex columnGap={ 1 } alignItems="center">
<Box flexShrink={ 0 } color="text_secondary">got</Box>
<Box>{ amount.valueStr }</Box>
<TokenEntity token={ token } noIcon onlySymbol w="auto"/>
</Flex>
<Flex columnGap={ 1 } alignItems="center" w="100%">
<Box flexShrink={ 0 } color="text_secondary">on behalf of</Box>
<AddressEntity address={ item.associated_account } noIcon/>
</Flex>
</Flex>
);
}) }
{ query.isFetching && <ContentLoader maxW="200px" mt={ 3 }/> }
{ query.isError && <Text color="error" mt={ 3 }>Something went wrong. Unable to load next page.</Text> }
<Box h="0" w="100px" mt="-12px" ref={ cutRef }/>
</Flex>
);
};
export default React.memo(BlockEpochElectionRewardDetailsMobile);
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import Tag from 'ui/shared/chakra/Tag';
interface Props {
type: keyof BlockEpoch['aggregated_election_rewards'];
isLoading?: boolean;
}
const BlockEpochElectionRewardType = ({ type, isLoading }: Props) => {
switch (type) {
case 'delegated_payment':
return <Tag colorScheme="blue" isLoading={ isLoading }>Delegated payments</Tag>;
case 'group':
return <Tag colorScheme="teal" isLoading={ isLoading }>Validator group rewards</Tag>;
case 'validator':
return <Tag colorScheme="purple" isLoading={ isLoading }>Validator rewards</Tag>;
case 'voter':
return <Tag colorScheme="yellow" isLoading={ isLoading }>Voting rewards</Tag>;
}
};
export default React.memo(BlockEpochElectionRewardType);
import React from 'react';
import * as blockEpochMock from 'mocks/blocks/epoch';
import { test, expect } from 'playwright/lib';
import BlockEpochElectionRewards from './BlockEpochElectionRewards';
const heightOrHash = '1234';
const hooksConfig = {
router: {
query: { height_or_hash: heightOrHash },
},
};
test('base view +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse(
'block_election_rewards',
blockEpochMock.electionRewardDetails1,
{ pathParams: { height_or_hash: heightOrHash, reward_type: 'voter' } },
);
const component = await render(<BlockEpochElectionRewards data={ blockEpochMock.blockEpoch1 }/>, { hooksConfig });
await component.getByText('Voting rewards').click();
await expect(component).toHaveScreenshot();
});
import { Box, Heading, Hide, Show, Table, Tbody, Th, Thead, Tr } from '@chakra-ui/react';
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import BlockEpochElectionRewardsListItem from './BlockEpochElectionRewardsListItem';
import BlockEpochElectionRewardsTableItem from './BlockEpochElectionRewardsTableItem';
interface Props {
data: BlockEpoch;
isLoading?: boolean;
}
const BlockEpochElectionRewards = ({ data, isLoading }: Props) => {
return (
<Box mt={ 8 }>
<Heading as="h4" size="sm" mb={ 3 }>Election rewards</Heading>
<Hide below="lg" ssr={ false }>
<Table variant="simple" size="sm" style={{ tableLayout: 'auto' }}>
<Thead>
<Tr>
<Th width="24px"/>
<Th width="180px">Reward type</Th>
<Th/>
<Th isNumeric>Value</Th>
</Tr>
</Thead>
<Tbody>
{ Object.entries(data.aggregated_election_rewards).map((entry) => {
const key = entry[0] as keyof BlockEpoch['aggregated_election_rewards'];
const value = entry[1];
if (!value) {
return null;
}
return (
<BlockEpochElectionRewardsTableItem
key={ key }
type={ key }
isLoading={ isLoading }
data={ value }
/>
);
}) }
</Tbody>
</Table>
</Hide>
<Show below="lg" ssr={ false }>
{ Object.entries(data.aggregated_election_rewards).map((entry) => {
const key = entry[0] as keyof BlockEpoch['aggregated_election_rewards'];
const value = entry[1];
if (!value) {
return null;
}
return (
<BlockEpochElectionRewardsListItem
key={ key }
type={ key }
isLoading={ isLoading }
data={ value }
/>
);
}) }
</Show>
</Box>
);
};
export default React.memo(BlockEpochElectionRewards);
import { Box, Flex, IconButton, Skeleton, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block';
import getCurrencyValue from 'lib/getCurrencyValue';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg';
import BlockEpochElectionRewardDetailsMobile from './BlockEpochElectionRewardDetailsMobile';
import BlockEpochElectionRewardType from './BlockEpochElectionRewardType';
interface Props {
data: BlockEpochElectionReward;
type: keyof BlockEpoch['aggregated_election_rewards'];
isLoading?: boolean;
}
const BlockEpochElectionRewardsListItem = ({ data, isLoading, type }: Props) => {
const section = useDisclosure();
const { valueStr } = getCurrencyValue({
value: data.total,
decimals: data.token.decimals,
accuracy: 2,
});
return (
<Box
py={ 3 }
borderBottomWidth="1px"
borderColor="divider"
fontSize="sm"
onClick={ isLoading || !data.count ? undefined : section.onToggle }
cursor={ isLoading || !data.count ? undefined : 'pointer' }
>
<Flex my="3px" columnGap={ 3 } alignItems="center" flexWrap="wrap" rowGap={ 1 }>
{ data.count ? (
<Skeleton isLoaded={ !isLoading } display="flex" borderRadius="sm">
<IconButton
aria-label={ section.isOpen ? 'Collapse section' : 'Expand section' }
variant="link"
boxSize={ 6 }
flexShrink={ 0 }
icon={ (
<IconSvg
name="arrows/east-mini"
boxSize={ 6 }
transform={ section.isOpen ? 'rotate(270deg)' : 'rotate(180deg)' }
transitionDuration="faster"
/>
) }
/>
</Skeleton>
) : <Box boxSize={ 6 }/> }
<BlockEpochElectionRewardType type={ type } isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading }>{ data.count }</Skeleton>
<Flex columnGap={ 2 } alignItems="center" ml="auto" fontWeight={ 500 }>
<Skeleton isLoaded={ !isLoading }>{ valueStr }</Skeleton>
<TokenEntity
token={ data.token }
noCopy
onlySymbol
w="auto"
isLoading={ isLoading }
/>
</Flex>
</Flex>
{ section.isOpen && (
<Box mt={ 2 }>
<BlockEpochElectionRewardDetailsMobile type={ type } token={ data.token }/>
</Box>
) }
</Box>
);
};
export default React.memo(BlockEpochElectionRewardsListItem);
import { Flex, IconButton, Skeleton, Td, Tr, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block';
import getCurrencyValue from 'lib/getCurrencyValue';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg';
import BlockEpochElectionRewardDetailsDesktop from './BlockEpochElectionRewardDetailsDesktop';
import BlockEpochElectionRewardType from './BlockEpochElectionRewardType';
import { getRewardNumText } from './utils';
interface Props {
data: BlockEpochElectionReward;
type: keyof BlockEpoch['aggregated_election_rewards'];
isLoading?: boolean;
}
const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => {
const section = useDisclosure();
const { valueStr } = getCurrencyValue({
value: data.total,
decimals: data.token.decimals,
});
const mainRowBorderColor = section.isOpen ? 'transparent' : 'divider';
return (
<>
<Tr
onClick={ isLoading || !data.count ? undefined : section.onToggle }
cursor={ isLoading || !data.count ? undefined : 'pointer' }
>
<Td borderColor={ mainRowBorderColor }>
{ Boolean(data.count) && (
<Skeleton isLoaded={ !isLoading } display="flex" borderRadius="sm">
<IconButton
aria-label={ section.isOpen ? 'Collapse section' : 'Expand section' }
variant="link"
boxSize={ 6 }
flexShrink={ 0 }
icon={ (
<IconSvg
name="arrows/east-mini"
boxSize={ 6 }
transform={ section.isOpen ? 'rotate(270deg)' : 'rotate(180deg)' }
transitionDuration="faster"
/>
) }
/>
</Skeleton>
) }
</Td>
<Td borderColor={ mainRowBorderColor }>
<BlockEpochElectionRewardType type={ type } isLoading={ isLoading }/>
</Td>
<Td borderColor={ mainRowBorderColor }>
<Skeleton isLoaded={ !isLoading } fontWeight={ 400 } my={ 1 }>
{ getRewardNumText(type, data.count) }
</Skeleton>
</Td>
<Td borderColor={ mainRowBorderColor }>
<Flex columnGap={ 2 } alignItems="center" justifyContent="flex-end" my="2px">
<Skeleton isLoaded={ !isLoading }>{ valueStr }</Skeleton>
<TokenEntity
token={ data.token }
noCopy
onlySymbol
w="auto"
isLoading={ isLoading }
/>
</Flex>
</Td>
</Tr>
{ section.isOpen && (
<Tr>
<Td/>
<Td colSpan={ 3 } pr={ 0 } pt={ 0 }>
<BlockEpochElectionRewardDetailsDesktop type={ type } token={ data.token }/>
</Td>
</Tr>
) }
</>
);
};
export default React.memo(BlockEpochElectionRewardsTableItem);
import { Grid } from '@chakra-ui/react';
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import TokenTransferSnippet from 'ui/shared/TokenTransferSnippet/TokenTransferSnippet';
interface Props {
data: BlockEpoch;
isLoading?: boolean;
}
const BlockEpochRewardsDistribution = ({ data, isLoading }: Props) => {
if (!data.distribution.community_transfer && !data.distribution.carbon_offsetting_transfer && !data.distribution.reserve_bolster_transfer) {
return null;
}
return (
<Grid
columnGap={ 8 }
rowGap={{ base: 3, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(min-content, 200px) minmax(0, 1fr)' }}
overflow="hidden"
>
{ data.distribution.community_transfer && (
<>
<DetailsInfoItem.Label
hint="Funds allocation to support Celo projects and community initiatives"
isLoading={ isLoading }
>
Community fund
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<TokenTransferSnippet data={ data.distribution.community_transfer } isLoading={ isLoading } noAddressIcons={ false }/>
</DetailsInfoItem.Value>
</>
) }
{ data.distribution.carbon_offsetting_transfer && (
<>
<DetailsInfoItem.Label
hint="Funds allocation to support projects that make Celo carbon-negative"
isLoading={ isLoading }
>
Carbon offset fund
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<TokenTransferSnippet data={ data.distribution.carbon_offsetting_transfer } isLoading={ isLoading } noAddressIcons={ false }/>
</DetailsInfoItem.Value>
</>
) }
{ data.distribution.reserve_bolster_transfer && (
<>
<DetailsInfoItem.Label
hint="Funds allocation to strengthen Celo’s reserve for network stability and security"
isLoading={ isLoading }
>
Reserve bolster
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<TokenTransferSnippet data={ data.distribution.reserve_bolster_transfer } isLoading={ isLoading } noAddressIcons={ false }/>
</DetailsInfoItem.Value>
</>
) }
</Grid>
);
};
export default React.memo(BlockEpochRewardsDistribution);
import type { BlockEpoch } from 'types/api/block';
export function getRewardNumText(type: keyof BlockEpoch['aggregated_election_rewards'], num: number) {
const postfix1 = num !== 1 ? 's' : '';
const postfix2 = num !== 1 ? 'es' : '';
const text = (() => {
switch (type) {
case 'delegated_payment':
return 'payment' + postfix1;
case 'group':
return 'group reward' + postfix1;
case 'validator':
return 'validator' + postfix1;
case 'voter':
return 'voting address' + postfix2;
default:
return '';
}
})();
if (!text) {
return '';
}
return `${ num } ${ text }`;
}
export function getRewardDetailsTableTitles(type: keyof BlockEpoch['aggregated_election_rewards']): [string, string] {
switch (type) {
case 'delegated_payment':
return [ 'Beneficiary', 'Validator' ];
case 'group':
return [ 'Validator group', 'Associated validator' ];
case 'validator':
return [ 'Validator', 'Validator group' ];
case 'voter':
return [ 'Voter', 'Validator group' ];
}
}
export function formatRewardType(type: keyof BlockEpoch['aggregated_election_rewards']) {
return type.replaceAll('_', '-');
}
import { Flex, Skeleton, Text, Box } from '@chakra-ui/react';
import { Flex, Skeleton, Text, Box, Tooltip } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize';
import React from 'react';
......@@ -45,6 +45,11 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
noIcon
fontWeight={ 600 }
/>
{ data.celo?.is_epoch_block && (
<Tooltip label={ `Finalized epoch #${ data.celo.epoch_number }` }>
<IconSvg name="checkered_flag" boxSize={ 5 } p="1px" isLoading={ isLoading } flexShrink={ 0 }/>
</Tooltip>
) }
</Flex>
<TimeAgoWithTooltip
timestamp={ data.timestamp }
......
......@@ -43,7 +43,7 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
<Table variant="simple" minWidth="1040px" size="md" fontWeight={ 500 }>
<Thead top={ top }>
<Tr>
<Th width="125px">Block</Th>
<Th width="150px">Block</Th>
<Th width="120px">Size, bytes</Th>
{ !config.UI.views.block.hiddenFields?.miner &&
<Th width={ `${ VALIDATOR_COL_WEIGHT / widthBase * 100 }%` } minW="160px">{ capitalize(getNetworkValidatorTitle()) }</Th> }
......
......@@ -44,6 +44,11 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
>
<Td fontSize="sm">
<Flex columnGap={ 2 } alignItems="center" mb={ 2 }>
{ data.celo?.is_epoch_block && (
<Tooltip label={ `Finalized epoch #${ data.celo.epoch_number }` }>
<IconSvg name="checkered_flag" boxSize={ 5 } p="1px" isLoading={ isLoading } flexShrink={ 0 }/>
</Tooltip>
) }
<Tooltip isDisabled={ data.type !== 'reorg' } label="Chain reorganizations">
<BlockEntity
isLoading={ isLoading }
......
import { Box, Heading, Flex, Text, VStack, Skeleton } from '@chakra-ui/react';
import { chakra, Box, Heading, Flex, Text, VStack, Skeleton } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
import React from 'react';
......@@ -108,6 +108,12 @@ const LatestBlocks = () => {
</Text>
</Skeleton>
) }
{ statsQueryResult.data?.celo && (
<Box whiteSpace="pre-wrap" fontSize="sm">
<span>Current epoch: </span>
<chakra.span fontWeight={ 700 }>#{ statsQueryResult.data.celo.epoch_number }</chakra.span>
</Box>
) }
<Box mt={ 3 }>
{ content }
</Box>
......
......@@ -3,6 +3,7 @@ import {
Flex,
Grid,
Skeleton,
Tooltip,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import React from 'react';
......@@ -14,6 +15,7 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import IconSvg from 'ui/shared/IconSvg';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = {
......@@ -46,6 +48,11 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => {
fontWeight={ 500 }
mr="auto"
/>
{ block.celo?.is_epoch_block && (
<Tooltip label={ `Finalized epoch #${ block.celo.epoch_number }` }>
<IconSvg name="checkered_flag" boxSize={ 5 } p="1px" ml={ 2 } isLoading={ isLoading } flexShrink={ 0 }/>
</Tooltip>
) }
<TimeAgoWithTooltip
timestamp={ block.timestamp }
enableIncrement={ !isLoading }
......
......@@ -140,6 +140,12 @@ const Stats = () => {
value: `${ BigNumber(data.rootstock_locked_btc).div(WEI).dp(0).toFormat() } RBTC`,
isLoading,
},
data.celo && {
icon: 'hourglass' as const,
label: 'Current epoch',
value: `#${ data.celo.epoch_number }`,
isLoading,
},
].filter(Boolean);
return (
......
import { chakra, Skeleton } from '@chakra-ui/react';
import { chakra, Skeleton, Tooltip } from '@chakra-ui/react';
import capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -14,6 +14,7 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getNetworkValidationActionText from 'lib/networks/getNetworkValidationActionText';
import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails';
import BlockEpochRewards from 'ui/block/BlockEpochRewards';
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
import useBlockQuery from 'ui/block/useBlockQuery';
......@@ -21,6 +22,7 @@ import useBlockTxsQuery from 'ui/block/useBlockTxsQuery';
import useBlockWithdrawalsQuery from 'ui/block/useBlockWithdrawalsQuery';
import TextAd from 'ui/shared/ad/TextAd';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -94,7 +96,12 @@ const BlockPageContent = () => {
</>
),
} : null,
].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]);
blockQuery.data?.celo?.is_epoch_block ? {
id: 'epoch_rewards',
title: 'Epoch rewards',
component: <BlockEpochRewards heightOrHash={ heightOrHash }/>,
} : null,
].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination, heightOrHash ]);
let pagination;
if (tab === 'txs') {
......@@ -139,6 +146,25 @@ const BlockPageContent = () => {
return `Block #${ blockQuery.data?.height }`;
}
})();
const contentAfter = (() => {
if (!blockQuery.data?.celo) {
return null;
}
if (!blockQuery.data.celo.is_epoch_block) {
return (
<Tooltip label="Displays the epoch this block belongs to before the epoch is finalized" maxW="280px" textAlign="center">
<Tag>Epoch #{ blockQuery.data.celo.epoch_number }</Tag>
</Tooltip>
);
}
return (
<Tooltip label="Displays the epoch finalized by this block" maxW="280px" textAlign="center">
<Tag bgColor="celo" color="blackAlpha.800">Finalized epoch #{ blockQuery.data.celo.epoch_number }</Tag>
</Tooltip>
);
})();
const titleSecondRow = (
<>
{ !config.UI.views.block.hiddenFields?.miner && (
......@@ -166,6 +192,7 @@ const BlockPageContent = () => {
<PageTitle
title={ title }
backLink={ backLink }
contentAfter={ contentAfter }
secondRow={ titleSecondRow }
isLoading={ blockQuery.isPlaceholderData }
/>
......
......@@ -3,6 +3,7 @@ import React from 'react';
import * as ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
import Hint from 'ui/shared/Hint';
import HintPopover from 'ui/shared/HintPopover';
const LabelScrollText = () => (
<Text fontWeight={ 500 } variant="secondary" fontSize="xs" className="note" align="right">
......@@ -11,15 +12,16 @@ const LabelScrollText = () => (
);
interface LabelProps {
hint?: string;
hint?: React.ReactNode;
children: React.ReactNode;
isLoading?: boolean;
className?: string;
id?: string;
hasScroll?: boolean;
type?: 'tooltip' | 'popover';
}
const Label = chakra(({ hint, children, isLoading, id, className, hasScroll }: LabelProps) => {
const Label = chakra(({ hint, children, isLoading, id, className, hasScroll, type }: LabelProps) => {
return (
<GridItem
id={ id }
......@@ -29,7 +31,9 @@ const Label = chakra(({ hint, children, isLoading, id, className, hasScroll }: L
_notFirst={{ mt: { base: 3, lg: 0 } }}
>
<Flex columnGap={ 2 } alignItems="flex-start">
{ hint && <Hint label={ hint } isLoading={ isLoading } my={{ lg: '2px' }}/> }
{ hint && (type === 'popover' ?
<HintPopover label={ hint } isLoading={ isLoading } my={{ lg: '2px' }}/> :
<Hint label={ hint } isLoading={ isLoading } my={{ lg: '2px' }}/>) }
<Skeleton isLoaded={ !isLoading } fontWeight={{ base: 700, lg: 500 }}>
{ children }
{ hasScroll && <LabelScrollText/> }
......
import type {
PopoverBodyProps,
PopoverContentProps,
PopoverProps } from '@chakra-ui/react';
import {
Skeleton,
DarkMode,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
Portal,
chakra,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from './IconSvg';
interface Props {
label: React.ReactNode;
className?: string;
isLoading?: boolean;
popoverProps?: Partial<PopoverProps>;
popoverContentProps?: Partial<PopoverContentProps>;
popoverBodyProps?: Partial<PopoverBodyProps>;
}
const HintPopover = ({ label, isLoading, className, popoverProps, popoverContentProps, popoverBodyProps }: Props) => {
const bgColor = useColorModeValue('gray.700', 'gray.900');
if (isLoading) {
return <Skeleton className={ className } boxSize={ 5 } borderRadius="sm"/>;
}
return (
<Popover trigger="hover" isLazy placement="top" { ...popoverProps }>
<PopoverTrigger>
<IconSvg className={ className } name="info" boxSize={ 5 } color="icon_info" _hover={{ color: 'link_hovered' }} cursor="pointer"/>
</PopoverTrigger>
<Portal>
<PopoverContent bgColor={ bgColor } maxW={{ base: 'calc(100vw - 8px)', lg: '320px' }} borderRadius="sm" { ...popoverContentProps }>
<PopoverArrow bgColor={ bgColor }/>
<PopoverBody color="white" fontSize="sm" lineHeight="20px" px={ 2 } py="2px" { ...popoverBodyProps }>
<DarkMode>
{ label }
</DarkMode>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default React.memo(chakra(HintPopover));
import { Flex } from '@chakra-ui/react';
import { Flex, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type {
TokenTransfer as TTokenTransfer,
TokenTransfer,
Erc20TotalPayload,
Erc721TotalPayload,
Erc1155TotalPayload,
......@@ -10,27 +10,34 @@ import type {
} from 'types/api/tokenTransfer';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import NftTokenTransferSnippet from 'ui/tx/NftTokenTransferSnippet';
import FtTokenTransferSnippet from '../FtTokenTransferSnippet';
import TokenTransferSnippetFiat from './TokenTransferSnippetFiat';
import TokenTransferSnippetNft from './TokenTransferSnippetNft';
interface Props {
data: TTokenTransfer;
data: TokenTransfer;
noAddressIcons?: boolean;
isLoading?: boolean;
}
const TxDetailsTokenTransfer = ({ data }: Props) => {
const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) => {
const content = (() => {
if (isLoading) {
return <Skeleton w="250px" h={ 6 }/>;
}
switch (data.token.type) {
case 'ERC-20': {
const total = data.total as Erc20TotalPayload;
return <FtTokenTransferSnippet token={ data.token } value={ total.value } decimals={ total.decimals }/>;
return <TokenTransferSnippetFiat token={ data.token } value={ total.value } decimals={ total.decimals }/>;
}
case 'ERC-721': {
const total = data.total as Erc721TotalPayload;
return (
<NftTokenTransferSnippet
<TokenTransferSnippetNft
token={ data.token }
tokenId={ total.token_id }
value="1"
......@@ -41,7 +48,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
case 'ERC-1155': {
const total = data.total as Erc1155TotalPayload;
return (
<NftTokenTransferSnippet
<TokenTransferSnippetNft
key={ total.token_id }
token={ data.token }
tokenId={ total.token_id }
......@@ -55,7 +62,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
if (total.token_id !== null) {
return (
<NftTokenTransferSnippet
<TokenTransferSnippetNft
token={ data.token }
tokenId={ total.token_id }
value="1"
......@@ -66,7 +73,7 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
return null;
}
return <FtTokenTransferSnippet token={ data.token } value={ total.value } decimals={ total.decimals }/>;
return <TokenTransferSnippetFiat token={ data.token } value={ total.value } decimals={ total.decimals }/>;
}
}
}
......@@ -86,12 +93,13 @@ const TxDetailsTokenTransfer = ({ data }: Props) => {
from={ data.from }
to={ data.to }
truncation="constant"
noIcon
noIcon={ noAddressIcons }
fontWeight="500"
isLoading={ isLoading }
/>
{ content }
</Flex>
);
};
export default React.memo(TxDetailsTokenTransfer);
export default React.memo(TokenTransferSnippet);
......@@ -33,6 +33,7 @@ const AddressEntityContentProxy = (props: ContentProps) => {
{ ...props }
truncation={ nameTag || implementationName || props.address.name ? 'tail' : props.truncation }
text={ nameTag || implementationName || props.address.name || props.address.hash }
isTooltipDisabled
/>
</Box>
</PopoverTrigger>
......
......@@ -113,9 +113,10 @@ const Icon = ({ isLoading, iconSize, noIcon, name, color, borderRadius }: IconBa
export interface ContentBaseProps extends Pick<EntityBaseProps, 'className' | 'isLoading' | 'truncation' | 'tailLength'> {
asProp?: As;
text: string;
isTooltipDisabled?: boolean;
}
const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dynamic', tailLength }: ContentBaseProps) => {
const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dynamic', tailLength, isTooltipDisabled }: ContentBaseProps) => {
const children = (() => {
switch (truncation) {
......@@ -125,6 +126,7 @@ const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dyna
hash={ text }
as={ asProp }
type="long"
isTooltipDisabled={ isTooltipDisabled }
/>
);
case 'constant':
......@@ -132,6 +134,7 @@ const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dyna
<HashStringShorten
hash={ text }
as={ asProp }
isTooltipDisabled={ isTooltipDisabled }
/>
);
case 'dynamic':
......@@ -140,6 +143,7 @@ const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dyna
hash={ text }
as={ asProp }
tailLength={ tailLength }
isTooltipDisabled={ isTooltipDisabled }
/>
);
case 'tail':
......
import type { InfiniteData, UseInfiniteQueryResult } from '@tanstack/react-query';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import type { PaginatedResources, ResourceError, ResourcePayload } from 'lib/api/resources';
import type { Params as ApiInfiniteQueryParams } from 'lib/api/useApiInfiniteQuery';
import useApiInfiniteQuery from 'lib/api/useApiInfiniteQuery';
interface Params<Resource extends PaginatedResources> extends ApiInfiniteQueryParams<Resource> {
rootRef: React.RefObject<HTMLElement>;
}
interface ReturnType<Resource extends PaginatedResources> {
cutRef: (node?: Element | null) => void;
query: UseInfiniteQueryResult<InfiniteData<ResourcePayload<Resource>>, ResourceError<unknown>>;
}
export default function useLazyLoadedList<Resource extends PaginatedResources>({
rootRef,
resourceName,
queryOptions,
pathParams,
}: Params<Resource>): ReturnType<Resource> {
const query = useApiInfiniteQuery({
resourceName,
pathParams,
queryOptions,
});
const { ref, inView } = useInView({
root: rootRef.current,
triggerOnce: false,
skip: queryOptions?.enabled === false || query.isFetchingNextPage || !query.hasNextPage,
});
React.useEffect(() => {
if (inView) {
query.fetchNextPage();
}
// should run only on inView state change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ inView ]);
return { cutRef: ref, query };
}
......@@ -45,8 +45,8 @@ const Sol2UmlDiagram = ({ addressHash }: Props) => {
sources: composeSources(contractQuery.data),
},
},
queryKey: [ 'visualize_sol2uml', addressHash ],
queryOptions: {
queryKey: [ 'visualize_sol2uml', addressHash ],
enabled: Boolean(contractQuery.data),
refetchOnMount: false,
},
......
......@@ -8,8 +8,7 @@ import { route } from 'nextjs-routes';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import TxDetailsTokenTransfer from './TxDetailsTokenTransfer';
import TokenTransferSnippet from 'ui/shared/TokenTransferSnippet/TokenTransferSnippet';
interface Props {
data: Array<TokenTransfer>;
......@@ -54,7 +53,7 @@ const TxDetailsTokenTransfers = ({ data, txHash, isOverflow }: Props) => {
w="100%"
overflow="hidden"
>
{ items.map((item, index) => <TxDetailsTokenTransfer key={ index } data={ item }/>) }
{ items.map((item, index) => <TokenTransferSnippet key={ index } data={ item }/>) }
</Flex>
</DetailsInfoItem.Value>
</React.Fragment>
......
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