diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 3ff37aeb2f..30e2b606ae 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -37,6 +37,7 @@ import type { AddressMudRecordsFilter, AddressMudRecordsSorting, AddressMudRecord, + AddressEpochRewardsResponse, } from 'types/api/address'; import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadataSearchFilters } from 'types/api/addresses'; import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata'; @@ -505,6 +506,11 @@ export const RESOURCES = { pathParams: [ 'hash' as const ], filterFields: [], }, + address_epoch_rewards: { + path: '/api/v2/addresses/:hash/election-rewards', + pathParams: [ 'hash' as const ], + filterFields: [], + }, // CONTRACT contract: { @@ -1002,7 +1008,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward 'addresses' | 'addresses_metadata_search' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'search' | -'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' | +'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' | 'address_epoch_rewards' | 'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' | 'token_instance_transfers' | 'token_instance_holders' | 'verified_contracts' | @@ -1182,6 +1188,7 @@ Q extends 'address_mud_tables' ? AddressMudTables : Q extends 'address_mud_tables_count' ? number : Q extends 'address_mud_records' ? AddressMudRecords : Q extends 'address_mud_record' ? AddressMudRecord : +Q extends 'address_epoch_rewards' ? AddressEpochRewardsResponse : Q extends 'withdrawals' ? WithdrawalsResponse : Q extends 'withdrawals_counters' ? WithdrawalsCounters : never; diff --git a/mocks/address/epochRewards.ts b/mocks/address/epochRewards.ts new file mode 100644 index 0000000000..a504e47cae --- /dev/null +++ b/mocks/address/epochRewards.ts @@ -0,0 +1,41 @@ +import type { AddressEpochRewardsResponse } from 'types/api/address'; + +import { tokenInfo } from 'mocks/tokens/tokenInfo'; + +import { withEns, withName, withoutName } from './address'; + +export const epochRewards: AddressEpochRewardsResponse = { + items: [ + { + type: 'delegated_payment', + amount: '136609473658452408568', + account: withName, + associated_account: withName, + block_hash: '0x', + block_number: 26369280, + epoch_number: 1526, + token: tokenInfo, + }, + { + type: 'group', + amount: '117205842355246195095', + account: withoutName, + associated_account: withoutName, + block_hash: '0x', + block_number: 26352000, + epoch_number: 1525, + token: tokenInfo, + }, + { + type: 'validator', + amount: '125659647325556554060', + account: withEns, + associated_account: withEns, + block_hash: '0x', + block_number: 26300160, + epoch_number: 1524, + token: tokenInfo, + }, + ], + next_page_params: null, +}; diff --git a/stubs/address.ts b/stubs/address.ts index 774a66af5f..29950028d3 100644 --- a/stubs/address.ts +++ b/stubs/address.ts @@ -3,6 +3,7 @@ import type { AddressCoinBalanceHistoryItem, AddressCollection, AddressCounters, + AddressEpochRewardsItem, AddressMudTableItem, AddressNFT, AddressTabsCounters, @@ -10,7 +11,7 @@ import type { } from 'types/api/address'; import type { AddressesItem } from 'types/api/addresses'; -import { ADDRESS_HASH } from './addressParams'; +import { ADDRESS_HASH, ADDRESS_PARAMS } from './addressParams'; import { MUD_SCHEMA, MUD_TABLE } from './mud'; import { TOKEN_INFO_ERC_1155, TOKEN_INFO_ERC_20, TOKEN_INFO_ERC_721, TOKEN_INFO_ERC_404, TOKEN_INSTANCE } from './token'; import { TX_HASH } from './tx'; @@ -116,3 +117,14 @@ export const ADDRESS_MUD_TABLE_ITEM: AddressMudTableItem = { schema: MUD_SCHEMA, table: MUD_TABLE, }; + +export const EPOCH_REWARD_ITEM: AddressEpochRewardsItem = { + amount: '136609473658452408568', + block_number: 10355938, + type: 'voter', + token: TOKEN_INFO_ERC_20, + block_hash: '0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6', + account: ADDRESS_PARAMS, + epoch_number: 1526, + associated_account: ADDRESS_PARAMS, +}; diff --git a/types/api/address.ts b/types/api/address.ts index 2fe4e2d3ab..733350cc93 100644 --- a/types/api/address.ts +++ b/types/api/address.ts @@ -1,7 +1,7 @@ import type { Transaction } from 'types/api/transaction'; -import type { UserTags, AddressImplementation } from './addressParams'; -import type { Block } from './block'; +import type { UserTags, AddressImplementation, AddressParam } from './addressParams'; +import type { Block, EpochRewardsType } from './block'; import type { InternalTransaction } from './internalTransaction'; import type { MudWorldSchema, MudWorldTable } from './mudWorlds'; import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token'; @@ -191,6 +191,7 @@ export type AddressTabsCounters = { transactions_count: number | null; validations_count: number | null; withdrawals_count: number | null; + celo_election_rewards_count?: number | null; } // MUD framework @@ -245,3 +246,25 @@ export type AddressMudRecord = { schema: MudWorldSchema; table: MudWorldTable; } + +export type AddressEpochRewardsResponse = { + items: Array; + next_page_params: { + amount: string; + associated_account_address_hash: string; + block_number: number; + items_count: number; + type: EpochRewardsType; + } | null; +} + +export type AddressEpochRewardsItem = { + type: EpochRewardsType; + token: TokenInfo; + amount: string; + block_number: number; + block_hash: string; + account: AddressParam; + epoch_number: number; + associated_account: AddressParam; +} diff --git a/types/api/block.ts b/types/api/block.ts index 73d46bb973..2b4d5bc29c 100644 --- a/types/api/block.ts +++ b/types/api/block.ts @@ -144,6 +144,8 @@ export interface BlockEpochElectionReward { total: string; } +export type EpochRewardsType = 'group' | 'validator' | 'delegated_payment' | 'voter'; + export interface BlockEpoch { number: number; distribution: { @@ -151,12 +153,7 @@ export interface BlockEpoch { 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; - }; + aggregated_election_rewards: Record; } export interface BlockEpochElectionRewardDetails { diff --git a/ui/address/AddressEpochRewards.pw.tsx b/ui/address/AddressEpochRewards.pw.tsx new file mode 100644 index 0000000000..8732a7c866 --- /dev/null +++ b/ui/address/AddressEpochRewards.pw.tsx @@ -0,0 +1,25 @@ +import { Box } from '@chakra-ui/react'; +import React from 'react'; + +import { epochRewards } from 'mocks/address/epochRewards'; +import { test, expect } from 'playwright/lib'; + +import AddressEpochRewards from './AddressEpochRewards'; + +const ADDRESS_HASH = '0x1234'; +const hooksConfig = { + router: { + query: { hash: ADDRESS_HASH }, + }, +}; + +test('base view +@mobile', async({ render, mockApiResponse }) => { + await mockApiResponse('address_epoch_rewards', epochRewards, { pathParams: { hash: ADDRESS_HASH } }); + const component = await render( + + + , + { hooksConfig }, + ); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/AddressEpochRewards.tsx b/ui/address/AddressEpochRewards.tsx new file mode 100644 index 0000000000..1222735278 --- /dev/null +++ b/ui/address/AddressEpochRewards.tsx @@ -0,0 +1,89 @@ +import { Hide, Show } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import React from 'react'; + +import useIsMounted from 'lib/hooks/useIsMounted'; +import getQueryParamString from 'lib/router/getQueryParamString'; +import { EPOCH_REWARD_ITEM } from 'stubs/address'; +import { generateListStub } from 'stubs/utils'; +import AddressEpochRewardsTable from 'ui/address/epochRewards/AddressEpochRewardsTable'; +import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar'; +import DataListDisplay from 'ui/shared/DataListDisplay'; +import Pagination from 'ui/shared/pagination/Pagination'; +import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; + +import AddressEpochRewardsListItem from './epochRewards/AddressEpochRewardsListItem'; + +type Props = { + scrollRef?: React.RefObject; + shouldRender?: boolean; + isQueryEnabled?: boolean; +} + +const AddressEpochRewards = ({ scrollRef, shouldRender = true, isQueryEnabled = true }: Props) => { + const router = useRouter(); + const isMounted = useIsMounted(); + + const hash = getQueryParamString(router.query.hash); + + const rewardsQuery = useQueryWithPages({ + resourceName: 'address_epoch_rewards', + pathParams: { + hash, + }, + scrollRef, + options: { + enabled: isQueryEnabled && Boolean(hash), + placeholderData: generateListStub<'address_epoch_rewards'>(EPOCH_REWARD_ITEM, 50, { next_page_params: { + amount: '1', + items_count: 50, + type: 'voter', + associated_account_address_hash: '1', + block_number: 10355938, + } }), + }, + }); + + if (!isMounted || !shouldRender) { + return null; + } + + const content = rewardsQuery.data?.items ? ( + <> + + + + + { rewardsQuery.data.items.map((item, index) => ( + + )) } + + + ) : null; + + const actionBar = rewardsQuery.pagination.isVisible ? ( + + + + ) : null; + + return ( + + ); +}; + +export default AddressEpochRewards; diff --git a/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_default_base-view-mobile-1.png b/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..c444f63ebf Binary files /dev/null and b/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..d20553d832 Binary files /dev/null and b/ui/address/__screenshots__/AddressEpochRewards.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/epochRewards/AddressEpochRewardsListItem.tsx b/ui/address/epochRewards/AddressEpochRewardsListItem.tsx new file mode 100644 index 0000000000..958451386e --- /dev/null +++ b/ui/address/epochRewards/AddressEpochRewardsListItem.tsx @@ -0,0 +1,72 @@ +import { Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressEpochRewardsItem } from 'types/api/address'; + +import getCurrencyValue from 'lib/getCurrencyValue'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; +import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; + +type Props = { + item: AddressEpochRewardsItem; + isLoading?: boolean; +}; + +const UserOpsListItem = ({ item, isLoading }: Props) => { + const { valueStr } = getCurrencyValue({ value: item.amount, accuracy: 2, decimals: item.token.decimals }); + return ( + + + Block + + + + + Epoch # + + { item.epoch_number } + + + { /* Age + + + */ } + + Reward type + + + + + Associated address + + + + + Value + + + { valueStr } + + + + + + ); +}; + +export default UserOpsListItem; diff --git a/ui/address/epochRewards/AddressEpochRewardsTable.tsx b/ui/address/epochRewards/AddressEpochRewardsTable.tsx new file mode 100644 index 0000000000..bd85375df3 --- /dev/null +++ b/ui/address/epochRewards/AddressEpochRewardsTable.tsx @@ -0,0 +1,42 @@ +import { Table, Tbody, Th, Tr } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressEpochRewardsItem } from 'types/api/address'; + +import { default as Thead } from 'ui/shared/TheadSticky'; + +import AddressEpochRewardsTableItem from './AddressEpochRewardsTableItem'; + + type Props = { + items: Array; + isLoading?: boolean; + top: number; + }; + +const AddressEpochRewardsTable = ({ items, isLoading, top }: Props) => { + return ( + + + + + + + + + + + { items.map((item, index) => { + return ( + + ); + }) } + +
BlockReward typeAssociated addressValue
+ ); +}; + +export default AddressEpochRewardsTable; diff --git a/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx b/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx new file mode 100644 index 0000000000..6cac82a9e2 --- /dev/null +++ b/ui/address/epochRewards/AddressEpochRewardsTableItem.tsx @@ -0,0 +1,45 @@ +import { Flex, Td, Tr, Text, Skeleton } from '@chakra-ui/react'; +import React from 'react'; + +import type { AddressEpochRewardsItem } from 'types/api/address'; + +import getCurrencyValue from 'lib/getCurrencyValue'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; + + type Props = { + item: AddressEpochRewardsItem; + isLoading?: boolean; + }; + +const AddressEpochRewardsTableItem = ({ item, isLoading }: Props) => { + const { valueStr } = getCurrencyValue({ value: item.amount, decimals: item.token.decimals }); + return ( + + + + + { `Epoch # ${ item.epoch_number }` } + { /* no timestamp from API, will be added later */ } + { /* */ } + + + + + + + + + + + { valueStr } + + + + + ); +}; + +export default AddressEpochRewardsTableItem; diff --git a/ui/block/epochRewards/BlockEpochElectionRewardType.tsx b/ui/block/epochRewards/BlockEpochElectionRewardType.tsx deleted file mode 100644 index 3534837b1f..0000000000 --- a/ui/block/epochRewards/BlockEpochElectionRewardType.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/BlockEpochElectionRewardsListItem.tsx b/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx index 359e08a646..e0c79c2a35 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx +++ b/ui/block/epochRewards/BlockEpochElectionRewardsListItem.tsx @@ -5,10 +5,10 @@ import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block'; import getCurrencyValue from 'lib/getCurrencyValue'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import IconSvg from 'ui/shared/IconSvg'; import BlockEpochElectionRewardDetailsMobile from './BlockEpochElectionRewardDetailsMobile'; -import BlockEpochElectionRewardType from './BlockEpochElectionRewardType'; interface Props { data: BlockEpochElectionReward; @@ -53,7 +53,7 @@ const BlockEpochElectionRewardsListItem = ({ data, isLoading, type }: Props) => /> ) : } - + { data.count } { valueStr } diff --git a/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx b/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx index b6062710b7..b12cb6dea1 100644 --- a/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx +++ b/ui/block/epochRewards/BlockEpochElectionRewardsTableItem.tsx @@ -5,10 +5,10 @@ import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block'; import getCurrencyValue from 'lib/getCurrencyValue'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag'; import IconSvg from 'ui/shared/IconSvg'; import BlockEpochElectionRewardDetailsDesktop from './BlockEpochElectionRewardDetailsDesktop'; -import BlockEpochElectionRewardType from './BlockEpochElectionRewardType'; import { getRewardNumText } from './utils'; interface Props { @@ -54,7 +54,7 @@ const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) => ) } - + 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 index 8e374f0aa1..a725337fc2 100644 Binary files a/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-mobile-1.png and b/ui/block/epochRewards/__screenshots__/BlockEpochElectionRewards.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 233a365c1e..ab1a9c52f5 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -24,6 +24,7 @@ import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressContract from 'ui/address/AddressContract'; import AddressDetails from 'ui/address/AddressDetails'; +import AddressEpochRewards from 'ui/address/AddressEpochRewards'; import AddressInternalTxs from 'ui/address/AddressInternalTxs'; import AddressLogs from 'ui/address/AddressLogs'; import AddressMud from 'ui/address/AddressMud'; @@ -195,6 +196,12 @@ const AddressPageContent = () => { count: addressTabsCountersQuery.data?.internal_txs_count, component: , }, + addressTabsCountersQuery.data?.celo_election_rewards_count ? { + id: 'epoch_rewards', + title: 'Epoch rewards', + count: addressTabsCountersQuery.data?.celo_election_rewards_count, + component: , + } : undefined, { id: 'coin_balance_history', title: 'Coin balance history', diff --git a/ui/shared/EpochRewardTypeTag.tsx b/ui/shared/EpochRewardTypeTag.tsx new file mode 100644 index 0000000000..c47e215b89 --- /dev/null +++ b/ui/shared/EpochRewardTypeTag.tsx @@ -0,0 +1,50 @@ +import { Tooltip } from '@chakra-ui/react'; +import React from 'react'; + +import type { EpochRewardsType } from 'types/api/block'; + +import Tag from 'ui/shared/chakra/Tag'; + +type Props = { + type: EpochRewardsType; + isLoading?: boolean; +}; + +const TYPE_TAGS: Record = { + group: { + text: 'Validator group rewards', + // eslint-disable-next-line max-len + label: 'Reward given to a validator group. The address being viewed is the group\'s address; the associated address is the validator\'s address on whose behalf the reward was paid.', + color: 'teal', + }, + validator: { + text: 'Validator rewards', + label: 'Reward given to a validator. The address being viewed is the validator\'s address; the associated address is the validator group\'s address.', + color: 'purple', + }, + delegated_payment: { + text: 'Delegated payments', + // eslint-disable-next-line max-len + label: 'Reward portion delegated by a validator to another address. The address being viewed is the beneficiary receiving the reward; the associated address is the validator who set the delegation.', + color: 'blue', + }, + voter: { + text: 'Voting rewards', + label: 'Reward given to a voter. The address being viewed is the voter\'s address; the associated address is the group address.', + color: 'yellow', + }, +}; + +const EpochRewardTypeTag = ({ type, isLoading }: Props) => { + const { text, label, color } = TYPE_TAGS[type]; + + return ( + + + { text } + + + ); +}; + +export default React.memo(EpochRewardTypeTag);