diff --git a/icons/checkered_flag.svg b/icons/checkered_flag.svg new file mode 100644 index 0000000000..918c29cea4 --- /dev/null +++ b/icons/checkered_flag.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 04644efcb6..d230e49c1c 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -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 { @@ -322,6 +331,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', }, @@ -933,7 +952,7 @@ export interface ResourceError { export type ResourceErrorAccount = 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' | @@ -993,6 +1012,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 : diff --git a/lib/api/useApiFetch.tsx b/lib/api/useApiFetch.tsx index 85da773ab1..86e5fa9ea9 100644 --- a/lib/api/useApiFetch.tsx +++ b/lib/api/useApiFetch.tsx @@ -19,7 +19,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources' export interface Params { pathParams?: ResourcePathParams; - queryParams?: Record | number | boolean | undefined>; + queryParams?: Record | number | boolean | undefined | null>; fetchParams?: Pick; } diff --git a/lib/api/useApiInfiniteQuery.tsx b/lib/api/useApiInfiniteQuery.tsx new file mode 100644 index 0000000000..fd663f1388 --- /dev/null +++ b/lib/api/useApiInfiniteQuery.tsx @@ -0,0 +1,43 @@ +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 = ResourcePayload; +type TError = ResourceError; +type TPageParam = ApiFetchParams['queryParams'] | null; + +export interface Params { + resourceName: R; + // eslint-disable-next-line max-len + queryOptions?: Omit, TError, InfiniteData>, TQueryData, QueryKey, TPageParam>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'>; + pathParams?: ApiFetchParams['pathParams']; +} + +type ReturnType = UseInfiniteQueryResult>, ResourceError>; + +export default function useApiInfiniteQuery({ + resourceName, + queryOptions, + pathParams, +}: Params): ReturnType { + const apiFetch = useApiFetch(); + + return useInfiniteQuery, TError, InfiniteData>, QueryKey, TPageParam>({ + // 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>; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => { + return lastPage.next_page_params as TPageParam; + }, + ...queryOptions, + }); +} diff --git a/lib/api/useApiQuery.tsx b/lib/api/useApiQuery.tsx index 1131a07005..925266680f 100644 --- a/lib/api/useApiQuery.tsx +++ b/lib/api/useApiQuery.tsx @@ -1,4 +1,4 @@ -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; queryParams?: Record | number | boolean | undefined>; fetchParams?: Pick; - queryOptions?: Omit, ResourceError, D>, 'queryKey' | 'queryFn'>; - queryKey?: QueryKey; + queryOptions?: Partial, ResourceError, D>, 'queryFn'>>; } export function getResourceKey(resource: R, { pathParams, queryParams }: Params = {}) { @@ -24,13 +23,13 @@ export function getResourceKey(resource: R, { pathParams export default function useApiQuery>( resource: R, - { queryOptions, pathParams, queryParams, queryKey, fetchParams }: Params = {}, + { queryOptions, pathParams, queryParams, fetchParams }: Params = {}, ) { const apiFetch = useApiFetch(); return useQuery, ResourceError, 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 diff --git a/mocks/blocks/block.ts b/mocks/blocks/block.ts index 56b3b8339e..281665fc01 100644 --- a/mocks/blocks/block.ts +++ b/mocks/blocks/block.ts @@ -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', diff --git a/mocks/blocks/epoch.ts b/mocks/blocks/epoch.ts new file mode 100644 index 0000000000..58f614fbe6 --- /dev/null +++ b/mocks/blocks/epoch.ts @@ -0,0 +1,57 @@ +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, +}; diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index eb787ac206..5b8ff170aa 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -29,6 +29,7 @@ | "burger" | "certified" | "check" + | "checkered_flag" | "clock-light" | "clock" | "coins/bitcoin" diff --git a/stubs/block.ts b/stubs/block.ts index aea58c499b..98aab1a92b 100644 --- a/stubs/block.ts +++ b/stubs/block.ts @@ -1,6 +1,7 @@ -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, + }, +}; diff --git a/stubs/token.ts b/stubs/token.ts index 756a311ca0..3f06faa420 100644 --- a/stubs/token.ts +++ b/stubs/token.ts @@ -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', diff --git a/theme/foundations/colors.ts b/theme/foundations/colors.ts index f8b2ca521f..d1e3dae714 100644 --- a/theme/foundations/colors.ts +++ b/theme/foundations/colors.ts @@ -60,6 +60,7 @@ const colors = { facebook: '#4460A0', medium: '#231F20', reddit: '#FF4500', + celo: '#FCFF52', }; export default colors; diff --git a/types/api/block.ts b/types/api/block.ts index f18dcfbfd4..fedf34b539 100644 --- a/types/api/block.ts +++ b/types/api/block.ts @@ -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; + next_page_params: null; +} diff --git a/types/api/stats.ts b/types/api/stats.ts index 3d75a5f3e9..e045c61ac0 100644 --- a/types/api/stats.ts +++ b/types/api/stats.ts @@ -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 = { diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index b0e68fec3e..8330b30ff4 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -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) => { + { data.celo?.base_fee && } + { + const query = useApiQuery('block_epoch', { + pathParams: { + height_or_hash: heightOrHash, + }, + queryOptions: { + placeholderData: BLOCK_EPOCH, + }, + }); + + if (query.isError) { + return ; + } + + if (!query.data) { + return No block epoch rewards data; + } + + return ( + <> + + + + ); +}; + +export default React.memo(BlockEpochRewards); diff --git a/ui/block/details/BlockDetailsBaseFeeCelo.pw.tsx b/ui/block/details/BlockDetailsBaseFeeCelo.pw.tsx new file mode 100644 index 0000000000..699f88b544 --- /dev/null +++ b/ui/block/details/BlockDetailsBaseFeeCelo.pw.tsx @@ -0,0 +1,23 @@ +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( + + + , + ); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/block/details/BlockDetailsBaseFeeCelo.tsx b/ui/block/details/BlockDetailsBaseFeeCelo.tsx new file mode 100644 index 0000000000..a8d1e78357 --- /dev/null +++ b/ui/block/details/BlockDetailsBaseFeeCelo.tsx @@ -0,0 +1,97 @@ +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 ( + + { percentage }% of amount + + { BigNumber(amount).dividedBy(WEI).toFixed() } + + + { isBurning ? ( + <> + + + burnt + + ) : } + + ); +}; + +interface Props { + data: BlockBaseFeeCelo; +} + +const BlockDetailsBaseFeeCelo = ({ data }: Props) => { + const totalBaseFee = BigNumber(data.amount).dividedBy(WEI).toFixed(); + + const totalFeeLabel = ( + + The FeeHandler regularly burns 80% of its tokens. Non-CELO tokens are swapped to CELO beforehand. The remaining 20% are sent to the + Green Fund + . + + ); + + return ( + <> + + Base fee handler + + + + + + Base fee total + + + + { totalBaseFee } + + + { data.breakdown.length > 0 && ( + + { data.breakdown.map((item, index) => ( + + )) } + + ) } + + + + ); +}; + +export default React.memo(BlockDetailsBaseFeeCelo); diff --git a/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_default_base-view-mobile-1.png b/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..f79c5b535a Binary files /dev/null and b/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_mobile_base-view-mobile-1.png b/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..a3a82ac3b1 Binary files /dev/null and b/ui/block/details/__screenshots__/BlockDetailsBaseFeeCelo.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx b/ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx new file mode 100644 index 0000000000..3f118103c3 --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewardDetailsDesktop.tsx @@ -0,0 +1,102 @@ +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(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 ( + + { query.data && ( + + + { titles[0] } + + + Amount { token.symbol } + + + { titles[1] } + + + { query.data?.pages + .map((page) => page.items) + .flat() + .map((item, index) => { + + const amount = getCurrencyValue({ + value: item.amount, + decimals: token.decimals, + }); + + return ( + + + + + + { amount.valueStr } + + + + + + ); + }) } + + ) } + + { query.isFetching && } + + { query.isError && Something went wrong. Unable to load next page. } + + + + ); +}; + +export default React.memo(BlockEpochElectionRewardDetailsDesktop); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx b/ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx new file mode 100644 index 0000000000..c22fa452d4 --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewardDetailsMobile.tsx @@ -0,0 +1,77 @@ +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(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 ( + + + { query.data?.pages + .map((page) => page.items) + .flat() + .map((item, index) => { + + const amount = getCurrencyValue({ + value: item.amount, + decimals: token.decimals, + }); + + return ( + + + + got + { amount.valueStr } + + + + on behalf of + + + + ); + }) } + + { query.isFetching && } + + { query.isError && Something went wrong. Unable to load next page. } + + + + ); +}; + +export default React.memo(BlockEpochElectionRewardDetailsMobile); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardType.tsx b/ui/block/epochRewards/BlockEpochElectionRewardType.tsx new file mode 100644 index 0000000000..3534837b1f --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewardType.tsx @@ -0,0 +1,25 @@ +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 Delegated payments; + case 'group': + return Validator group rewards; + case 'validator': + return Validator rewards; + case 'voter': + return Voting rewards; + } +}; + +export default React.memo(BlockEpochElectionRewardType); diff --git a/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx b/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx new file mode 100644 index 0000000000..4bc4b1264c --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewards.pw.tsx @@ -0,0 +1,24 @@ +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(, { hooksConfig }); + await component.getByText('Voting rewards').click(); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/block/epochRewards/BlockEpochElectionRewards.tsx b/ui/block/epochRewards/BlockEpochElectionRewards.tsx new file mode 100644 index 0000000000..e5002878eb --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewards.tsx @@ -0,0 +1,72 @@ +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 ( + + Election rewards + + + + + + + + + + { 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 ( + + ); + }) } + +
+ Reward type + Value
+
+ + { 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 ( + + ); + }) } + +
+ ); +}; + +export default React.memo(BlockEpochElectionRewards); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx b/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx new file mode 100644 index 0000000000..359e08a646 --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx @@ -0,0 +1,78 @@ +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 ( + + + { data.count ? ( + + + ) } + /> + + ) : } + + { data.count } + + { valueStr } + + + + { section.isOpen && ( + + + + ) } + + ); +}; + +export default React.memo(BlockEpochElectionRewardsListItem); diff --git a/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx b/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx new file mode 100644 index 0000000000..b6062710b7 --- /dev/null +++ b/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx @@ -0,0 +1,89 @@ +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 ( + <> + + + { Boolean(data.count) && ( + + + ) } + /> + + ) } + + + + + + + { getRewardNumText(type, data.count) } + + + + + { valueStr } + + + + + { section.isOpen && ( + + + + + + + ) } + + ); +}; + +export default React.memo(BlockEpochElectionRewardsTableItem); diff --git a/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx b/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx new file mode 100644 index 0000000000..449bde3730 --- /dev/null +++ b/ui/block/epochRewards/BlockEpochRewardsDistribution.tsx @@ -0,0 +1,70 @@ +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 ( + + { data.distribution.community_transfer && ( + <> + + Community fund + + + + + + ) } + { data.distribution.carbon_offsetting_transfer && ( + <> + + Carbon offset fund + + + + + + ) } + { data.distribution.reserve_bolster_transfer && ( + <> + + Reserve bolster + + + + + + ) } + + ); +}; + +export default React.memo(BlockEpochRewardsDistribution); diff --git a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-mobile-1.png b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..8e374f0aa1 Binary files /dev/null and b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile-1.png b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..6bff78753a Binary files /dev/null and b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/block/epochRewards/utils.ts b/ui/block/epochRewards/utils.ts new file mode 100644 index 0000000000..8c318e1fc1 --- /dev/null +++ b/ui/block/epochRewards/utils.ts @@ -0,0 +1,44 @@ +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('_', '-'); +} diff --git a/ui/blocks/BlocksListItem.tsx b/ui/blocks/BlocksListItem.tsx index 0d6d8ce58f..6bf4d34bbf 100644 --- a/ui/blocks/BlocksListItem.tsx +++ b/ui/blocks/BlocksListItem.tsx @@ -1,4 +1,4 @@ -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 && ( + + + + ) } - Block + Block Size, bytes { !config.UI.views.block.hiddenFields?.miner && { capitalize(getNetworkValidatorTitle()) } } diff --git a/ui/blocks/BlocksTableItem.tsx b/ui/blocks/BlocksTableItem.tsx index 8ded36820f..80af501a61 100644 --- a/ui/blocks/BlocksTableItem.tsx +++ b/ui/blocks/BlocksTableItem.tsx @@ -44,6 +44,11 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { > + { data.celo?.is_epoch_block && ( + + + + ) } { ) } + { statsQueryResult.data?.celo && ( + + Current epoch: + #{ statsQueryResult.data.celo.epoch_number } + + ) } { content } diff --git a/ui/home/LatestBlocksItem.tsx b/ui/home/LatestBlocksItem.tsx index 72b428d93c..54419c2e0d 100644 --- a/ui/home/LatestBlocksItem.tsx +++ b/ui/home/LatestBlocksItem.tsx @@ -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 && ( + + + + ) } { 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 ( diff --git a/ui/pages/Block.tsx b/ui/pages/Block.tsx index 29cd7f2e8e..573f58e5d5 100644 --- a/ui/pages/Block.tsx +++ b/ui/pages/Block.tsx @@ -1,4 +1,4 @@ -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: , + } : 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 ( + + Epoch #{ blockQuery.data.celo.epoch_number } + + ); + } + + return ( + + Finalized epoch #{ blockQuery.data.celo.epoch_number } + + ); + })(); const titleSecondRow = ( <> { !config.UI.views.block.hiddenFields?.miner && ( @@ -166,6 +192,7 @@ const BlockPageContent = () => { diff --git a/ui/pages/__screenshots__/Blocks.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/pages/__screenshots__/Blocks.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 0b0e83ab7c..692b38d152 100644 Binary files a/ui/pages/__screenshots__/Blocks.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/pages/__screenshots__/Blocks.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Blocks.pw.tsx_default_base-view-dark-mode-1.png b/ui/pages/__screenshots__/Blocks.pw.tsx_default_base-view-dark-mode-1.png index f323f61a1c..dbe834000d 100644 Binary files a/ui/pages/__screenshots__/Blocks.pw.tsx_default_base-view-dark-mode-1.png and b/ui/pages/__screenshots__/Blocks.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/pages/__screenshots__/Blocks.pw.tsx_default_hidden-fields-1.png b/ui/pages/__screenshots__/Blocks.pw.tsx_default_hidden-fields-1.png index 4db3cd98f2..510e3019c5 100644 Binary files a/ui/pages/__screenshots__/Blocks.pw.tsx_default_hidden-fields-1.png and b/ui/pages/__screenshots__/Blocks.pw.tsx_default_hidden-fields-1.png differ diff --git a/ui/pages/__screenshots__/Blocks.pw.tsx_default_new-item-from-socket-1.png b/ui/pages/__screenshots__/Blocks.pw.tsx_default_new-item-from-socket-1.png index 6e23cda129..d2fa2ec9c8 100644 Binary files a/ui/pages/__screenshots__/Blocks.pw.tsx_default_new-item-from-socket-1.png and b/ui/pages/__screenshots__/Blocks.pw.tsx_default_new-item-from-socket-1.png differ diff --git a/ui/pages/__screenshots__/Blocks.pw.tsx_default_socket-error-1.png b/ui/pages/__screenshots__/Blocks.pw.tsx_default_socket-error-1.png index 1c3da310ea..ccd7bde953 100644 Binary files a/ui/pages/__screenshots__/Blocks.pw.tsx_default_socket-error-1.png and b/ui/pages/__screenshots__/Blocks.pw.tsx_default_socket-error-1.png differ diff --git a/ui/shared/DetailsInfoItem.tsx b/ui/shared/DetailsInfoItem.tsx index 20982f6f3b..bae00ec3ad 100644 --- a/ui/shared/DetailsInfoItem.tsx +++ b/ui/shared/DetailsInfoItem.tsx @@ -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 = () => ( @@ -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 ( - { hint && } + { hint && (type === 'popover' ? + : + ) } { children } { hasScroll && } diff --git a/ui/shared/HintPopover.tsx b/ui/shared/HintPopover.tsx new file mode 100644 index 0000000000..7dde21f4fa --- /dev/null +++ b/ui/shared/HintPopover.tsx @@ -0,0 +1,57 @@ +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; + popoverContentProps?: Partial; + popoverBodyProps?: Partial; +} + +const HintPopover = ({ label, isLoading, className, popoverProps, popoverContentProps, popoverBodyProps }: Props) => { + const bgColor = useColorModeValue('gray.700', 'gray.900'); + + if (isLoading) { + return ; + } + + return ( + + + + + + + + + + { label } + + + + + + ); +}; + +export default React.memo(chakra(HintPopover)); diff --git a/ui/tx/details/TxDetailsTokenTransfer.tsx b/ui/shared/TokenTransferSnippet/TokenTransferSnippet.tsx similarity index 66% rename from ui/tx/details/TxDetailsTokenTransfer.tsx rename to ui/shared/TokenTransferSnippet/TokenTransferSnippet.tsx index 5c5762b184..1fde813b2f 100644 --- a/ui/tx/details/TxDetailsTokenTransfer.tsx +++ b/ui/shared/TokenTransferSnippet/TokenTransferSnippet.tsx @@ -1,8 +1,8 @@ -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 ; + } + switch (data.token.type) { case 'ERC-20': { const total = data.total as Erc20TotalPayload; - return ; + return ; } case 'ERC-721': { const total = data.total as Erc721TotalPayload; return ( - { case 'ERC-1155': { const total = data.total as Erc1155TotalPayload; return ( - { if (total.token_id !== null) { return ( - { return null; } - return ; + return ; } } } @@ -86,12 +93,13 @@ const TxDetailsTokenTransfer = ({ data }: Props) => { from={ data.from } to={ data.to } truncation="constant" - noIcon + noIcon={ noAddressIcons } fontWeight="500" + isLoading={ isLoading } /> { content } ); }; -export default React.memo(TxDetailsTokenTransfer); +export default React.memo(TokenTransferSnippet); diff --git a/ui/tx/FtTokenTransferSnippet.tsx b/ui/shared/TokenTransferSnippet/TokenTransferSnippetFiat.tsx similarity index 100% rename from ui/tx/FtTokenTransferSnippet.tsx rename to ui/shared/TokenTransferSnippet/TokenTransferSnippetFiat.tsx diff --git a/ui/tx/NftTokenTransferSnippet.tsx b/ui/shared/TokenTransferSnippet/TokenTransferSnippetNft.tsx similarity index 100% rename from ui/tx/NftTokenTransferSnippet.tsx rename to ui/shared/TokenTransferSnippet/TokenTransferSnippetNft.tsx diff --git a/ui/shared/entities/address/AddressEntityContentProxy.tsx b/ui/shared/entities/address/AddressEntityContentProxy.tsx index 066c8e5448..66fbb4146f 100644 --- a/ui/shared/entities/address/AddressEntityContentProxy.tsx +++ b/ui/shared/entities/address/AddressEntityContentProxy.tsx @@ -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 />
diff --git a/ui/shared/entities/base/components.tsx b/ui/shared/entities/base/components.tsx index f86ec7a798..67a96e33d5 100644 --- a/ui/shared/entities/base/components.tsx +++ b/ui/shared/entities/base/components.tsx @@ -113,9 +113,10 @@ const Icon = ({ isLoading, iconSize, noIcon, name, color, borderRadius }: IconBa export interface ContentBaseProps extends Pick { 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 ); 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': diff --git a/ui/shared/pagination/useLazyLoadedList.tsx b/ui/shared/pagination/useLazyLoadedList.tsx new file mode 100644 index 0000000000..f53125c1d6 --- /dev/null +++ b/ui/shared/pagination/useLazyLoadedList.tsx @@ -0,0 +1,45 @@ +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 extends ApiInfiniteQueryParams { + rootRef: React.RefObject; +} + +interface ReturnType { + cutRef: (node?: Element | null) => void; + query: UseInfiniteQueryResult>, ResourceError>; +} + +export default function useLazyLoadedList({ + rootRef, + resourceName, + queryOptions, + pathParams, +}: Params): ReturnType { + 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 }; +} diff --git a/ui/sol2uml/Sol2UmlDiagram.tsx b/ui/sol2uml/Sol2UmlDiagram.tsx index 9ba28ad7b1..8af184211f 100644 --- a/ui/sol2uml/Sol2UmlDiagram.tsx +++ b/ui/sol2uml/Sol2UmlDiagram.tsx @@ -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, }, diff --git a/ui/tx/details/TxDetailsTokenTransfers.tsx b/ui/tx/details/TxDetailsTokenTransfers.tsx index c9cc9673c5..d8e57c8450 100644 --- a/ui/tx/details/TxDetailsTokenTransfers.tsx +++ b/ui/tx/details/TxDetailsTokenTransfers.tsx @@ -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; @@ -54,7 +53,7 @@ const TxDetailsTokenTransfers = ({ data, txHash, isOverflow }: Props) => { w="100%" overflow="hidden" > - { items.map((item, index) => ) } + { items.map((item, index) => ) }