Commit df9e2913 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

pools pages (#2468)

* pools pages

* fixes and changes

* revert demo config
parent 8538b698
...@@ -23,6 +23,7 @@ export { default as mixpanel } from './mixpanel'; ...@@ -23,6 +23,7 @@ export { default as mixpanel } from './mixpanel';
export { default as mudFramework } from './mudFramework'; export { default as mudFramework } from './mudFramework';
export { default as multichainButton } from './multichainButton'; export { default as multichainButton } from './multichainButton';
export { default as nameService } from './nameService'; export { default as nameService } from './nameService';
export { default as pools } from './pools';
export { default as publicTagsSubmission } from './publicTagsSubmission'; export { default as publicTagsSubmission } from './publicTagsSubmission';
export { default as restApiDocs } from './restApiDocs'; export { default as restApiDocs } from './restApiDocs';
export { default as rewards } from './rewards'; export { default as rewards } from './rewards';
......
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const contractInfoApiHost = getEnvValue('NEXT_PUBLIC_CONTRACT_INFO_API_HOST');
const dexPoolsEnabled = getEnvValue('NEXT_PUBLIC_DEX_POOLS_ENABLED') === 'true';
const title = 'DEX Pools';
const config: Feature<{ api: { endpoint: string; basePath: string } }> = (() => {
if (contractInfoApiHost && dexPoolsEnabled) {
return Object.freeze({
title,
isEnabled: true,
api: {
endpoint: contractInfoApiHost,
basePath: '',
},
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -17,7 +17,7 @@ NEXT_PUBLIC_API_BASE_PATH=/ ...@@ -17,7 +17,7 @@ NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}] NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.ethereum.org/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED=true
NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'cow-swap'},{'text':'Payment link','icon':'payment_link','dappId':'peanut-protocol'}] NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS=[{'text':'Swap','icon':'swap','dappId':'cow-swap'},{'text':'Payment link','icon':'payment_link','dappId':'peanut-protocol'}]
NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-sepolia.json NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/eth-sepolia.json
...@@ -69,3 +69,4 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves ...@@ -69,3 +69,4 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=noves
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_DEX_POOLS_ENABLED=true
...@@ -870,6 +870,16 @@ const schema = yup ...@@ -870,6 +870,16 @@ const schema = yup
value => value === undefined, value => value === undefined,
), ),
}), }),
NEXT_PUBLIC_DEX_POOLS_ENABLED: yup.boolean()
.when('NEXT_PUBLIC_CONTRACT_INFO_API_HOST', {
is: (value: string) => Boolean(value),
then: (schema) => schema,
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_DEX_POOLS_ENABLED can only be used with NEXT_PUBLIC_CONTRACT_INFO_API_HOST',
value => value === undefined,
),
}),
NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(), NEXT_PUBLIC_SAVE_ON_GAS_ENABLED: yup.boolean(),
NEXT_PUBLIC_ADDRESS_USERNAME_TAG: yup NEXT_PUBLIC_ADDRESS_USERNAME_TAG: yup
.mixed() .mixed()
......
...@@ -68,6 +68,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -68,6 +68,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Get gas button](ENVS.md#get-gas-button) - [Get gas button](ENVS.md#get-gas-button)
- [Save on gas with GasHawk](ENVS.md#save-on-gas-with-gashawk) - [Save on gas with GasHawk](ENVS.md#save-on-gas-with-gashawk)
- [Rewards service API](ENVS.md#rewards-service-api) - [Rewards service API](ENVS.md#rewards-service-api)
- [DEX pools](ENVS.md#dex-pools)
- [3rd party services configuration](ENVS.md#external-services-configuration) - [3rd party services configuration](ENVS.md#external-services-configuration)
&nbsp; &nbsp;
...@@ -852,6 +853,15 @@ This feature enables Blockscout Merits program. It requires that the [My account ...@@ -852,6 +853,15 @@ This feature enables Blockscout Merits program. It requires that the [My account
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_REWARDS_SERVICE_API_HOST | `string` | API URL | - | - | `https://example.com` | v1.36.0+ | | NEXT_PUBLIC_REWARDS_SERVICE_API_HOST | `string` | API URL | - | - | `https://example.com` | v1.36.0+ |
&nbsp;
### DEX pools
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_DEX_POOLS_ENABLED | `boolean` | Set to true to enable the feature | Required | - | `true` | v1.37.0+ |
| NEXT_PUBLIC_CONTRACT_INFO_API_HOST | `string` | Contract Info API endpoint url | Required | - | `https://contracts-info.services.blockscout.com` | v1.0.x+ |
## External services configuration ## External services configuration
### Google ReCaptcha ### Google ReCaptcha
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" fill-rule="evenodd" d="M13.776 20.692a8.5 8.5 0 0 1-1.695.288 7.3 7.3 0 1 0 7.632-10.68q.22.82.272 1.696a5.7 5.7 0 1 1-6.21 8.696m5.33-4.392q.312-.624.52-1.3H21v5h-5v-.287a8.5 8.5 0 0 0 1.315-1.013H19.7v-2.4z" clip-rule="evenodd"/>
<path fill="currentColor" fill-rule="evenodd" d="M5.8 12.5a5.7 5.7 0 1 1 11.4 0 5.7 5.7 0 0 1-11.4 0m5.7-7.3a7.3 7.3 0 1 0 0 14.6 7.3 7.3 0 0 0 0-14.6m.563 4.675L11.5 8.9l-.563.975-2.165 3.75-.563.975h6.582l-.563-.975zM10.46 13.3l1.04-1.8 1.039 1.8z" clip-rule="evenodd"/>
</svg>
...@@ -94,6 +94,7 @@ import type { ...@@ -94,6 +94,7 @@ import type {
OptimismL2BatchTxs, OptimismL2BatchTxs,
OptimismL2BatchBlocks, OptimismL2BatchBlocks,
} from 'types/api/optimisticL2'; } from 'types/api/optimisticL2';
import type { Pool, PoolsResponse } from 'types/api/pools';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { import type {
RewardsConfigResponse, RewardsConfigResponse,
...@@ -1128,6 +1129,22 @@ export const RESOURCES = { ...@@ -1128,6 +1129,22 @@ export const RESOURCES = {
path: '/api/v2/advanced-filters/csv', path: '/api/v2/advanced-filters/csv',
}, },
// POOLS
pools: {
path: '/api/v1/chains/:chainId/pools',
pathParams: [ 'chainId' as const ],
filterFields: [ 'query' as const ],
endpoint: getFeaturePayload(config.features.pools)?.api.endpoint,
basePath: getFeaturePayload(config.features.pools)?.api.basePath,
},
pool: {
path: '/api/v1/chains/:chainId/pools/:hash',
pathParams: [ 'chainId' as const, 'hash' as const ],
endpoint: getFeaturePayload(config.features.pools)?.api.endpoint,
basePath: getFeaturePayload(config.features.pools)?.api.basePath,
},
// CONFIGS // CONFIGS
config_backend_version: { config_backend_version: {
path: '/api/v2/config/backend-version', path: '/api/v2/config/backend-version',
...@@ -1222,7 +1239,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward ...@@ -1222,7 +1239,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'noves_address_history' | 'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'noves_address_history' |
'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' | 'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' |
'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter'; 'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter' | 'pools';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -1416,6 +1433,8 @@ Q extends 'scroll_l2_withdrawals' ? ScrollL2MessagesResponse : ...@@ -1416,6 +1433,8 @@ Q extends 'scroll_l2_withdrawals' ? ScrollL2MessagesResponse :
Q extends 'scroll_l2_withdrawals_count' ? number : Q extends 'scroll_l2_withdrawals_count' ? number :
Q extends 'advanced_filter' ? AdvancedFilterResponse : Q extends 'advanced_filter' ? AdvancedFilterResponse :
Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse : Q extends 'advanced_filter_methods' ? AdvancedFilterMethodsResponse :
Q extends 'pools' ? PoolsResponse :
Q extends 'pool' ? Pool :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
...@@ -1452,6 +1471,7 @@ Q extends 'address_mud_tables' ? AddressMudTablesFilter : ...@@ -1452,6 +1471,7 @@ Q extends 'address_mud_tables' ? AddressMudTablesFilter :
Q extends 'address_mud_records' ? AddressMudRecordsFilter : Q extends 'address_mud_records' ? AddressMudRecordsFilter :
Q extends 'token_transfers_all' ? TokenTransferFilters : Q extends 'token_transfers_all' ? TokenTransferFilters :
Q extends 'advanced_filter' ? AdvancedFilterParams : Q extends 'advanced_filter' ? AdvancedFilterParams :
Q extends 'pools' ? { query: string } :
never; never;
/* eslint-enable @stylistic/indent */ /* eslint-enable @stylistic/indent */
......
const DEFAULT_PAGE_SIZE = 50;
export default function getItemIndex(index: number, page: number, pageSize: number = DEFAULT_PAGE_SIZE) {
return (page - 1) * pageSize + index + 1;
};
...@@ -197,7 +197,13 @@ export default function useNavItems(): ReturnType { ...@@ -197,7 +197,13 @@ export default function useNavItems(): ReturnType {
icon: 'token-transfers', icon: 'token-transfers',
isActive: pathname === '/token-transfers', isActive: pathname === '/token-transfers',
}, },
]; config.features.pools.isEnabled && {
text: 'DEX tracker',
nextRoute: { pathname: '/pools' as const },
icon: 'dex-tracker',
isActive: pathname === '/pools' || pathname.startsWith('/pool/'),
},
].filter(Boolean);
const apiNavItems: Array<NavItem> = [ const apiNavItems: Array<NavItem> = [
config.features.restApiDocs.isEnabled ? { config.features.restApiDocs.isEnabled ? {
......
...@@ -54,6 +54,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -54,6 +54,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/mud-worlds': 'Root page', '/mud-worlds': 'Root page',
'/token-transfers': 'Root page', '/token-transfers': 'Root page',
'/advanced-filter': 'Root page', '/advanced-filter': 'Root page',
'/pools': 'Root page',
'/pools/[hash]': 'Regular page',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Regular page', '/login': 'Regular page',
......
...@@ -57,6 +57,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -57,6 +57,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/mud-worlds': DEFAULT_TEMPLATE, '/mud-worlds': DEFAULT_TEMPLATE,
'/token-transfers': DEFAULT_TEMPLATE, '/token-transfers': DEFAULT_TEMPLATE,
'/advanced-filter': DEFAULT_TEMPLATE, '/advanced-filter': DEFAULT_TEMPLATE,
'/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE, '/login': DEFAULT_TEMPLATE,
......
...@@ -54,6 +54,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -54,6 +54,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/mud-worlds': '%network_name% MUD worlds list', '/mud-worlds': '%network_name% MUD worlds list',
'/token-transfers': '%network_name% token transfers', '/token-transfers': '%network_name% token transfers',
'/advanced-filter': '%network_name% advanced filter', '/advanced-filter': '%network_name% advanced filter',
'/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': '%network_name% login', '/login': '%network_name% login',
......
...@@ -52,6 +52,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -52,6 +52,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/mud-worlds': 'MUD worlds', '/mud-worlds': 'MUD worlds',
'/token-transfers': 'Token transfers', '/token-transfers': 'Token transfers',
'/advanced-filter': 'Advanced filter', '/advanced-filter': 'Advanced filter',
'/pools': 'DEX pools',
'/pools/[hash]': 'Pool details',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Login', '/login': 'Login',
......
import type { Pool } from 'types/api/pools';
type PoolLink = {
url: string;
image: string;
title: string;
};
export default function getPoolLinks(pool?: Pool): Array<PoolLink> {
if (!pool) {
return [];
}
return [
{
url: pool.coin_gecko_terminal_url,
image: '/static/gecko_terminal.png',
title: 'GeckoTerminal',
},
].filter(link => Boolean(link.url));
}
import type { Pool } from 'types/api/pools';
export const getPoolTitle = (pool: Pool) => {
return `${ pool.base_token_symbol } / ${ pool.quote_token_symbol } ${ pool.fee ? `(${ pool.fee }%)` : '' }`;
};
import type { Pool } from 'types/api/pools';
export const base: Pool = {
contract_address: '0x06da0fd433c1a5d7a4faa01111c044910a184553',
chain_id: '1',
base_token_address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
base_token_symbol: 'USDT',
base_token_icon_url: 'https://localhost:3000/utia.jpg',
quote_token_address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
quote_token_symbol: 'WETH',
quote_token_icon_url: 'https://localhost:3000/secondary_utia.jpg',
fully_diluted_valuation_usd: '75486579078',
market_cap_usd: '139312819076.195',
liquidity: '2099941.2238',
dex: { id: 'sushiswap', name: 'SushiSwap' },
fee: '0.03',
coin_gecko_terminal_url: 'https://www.geckoterminal.com/eth/pools/0x06da0fd433c1a5d7a4faa01111c044910a184553',
};
export const noIcons: Pool = {
...base,
base_token_icon_url: null,
quote_token_icon_url: null,
};
...@@ -315,3 +315,13 @@ export const mud: GetServerSideProps<Props> = async(context) => { ...@@ -315,3 +315,13 @@ export const mud: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const pools: GetServerSideProps<Props> = async(context) => {
if (!config.features.pools.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
...@@ -52,6 +52,8 @@ declare module "nextjs-routes" { ...@@ -52,6 +52,8 @@ declare module "nextjs-routes" {
| DynamicRoute<"/op/[hash]", { "hash": string }> | DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops"> | StaticRoute<"/ops">
| StaticRoute<"/output-roots"> | StaticRoute<"/output-roots">
| DynamicRoute<"/pools/[hash]", { "hash": string }>
| StaticRoute<"/pools">
| StaticRoute<"/public-tags/submit"> | StaticRoute<"/public-tags/submit">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/sprite"> | StaticRoute<"/sprite">
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const Pool = dynamic(() => import('ui/pages/Pool'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/pools/[hash]" query={ props.query }>
<Pool/>
</PageNextJs>
);
};
export default Page;
export { pools as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const Pools = dynamic(() => import('ui/pages/Pools'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/pools">
<Pools/>
</PageNextJs>
);
};
export default Page;
export { pools as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -46,6 +46,7 @@ ...@@ -46,6 +46,7 @@
| "copy" | "copy"
| "cross" | "cross"
| "delete" | "delete"
| "dex-tracker"
| "docs" | "docs"
| "donate" | "donate"
| "dots" | "dots"
......
export const POOL = {
contract_address: '0x6a1041865b76d1dc33da0257582591227c57832c',
chain_id: '1',
base_token_address: '0xf63e309818e4ea13782678ce6c31c1234fa61809',
base_token_symbol: 'JANET',
base_token_icon_url: null,
quote_token_address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
quote_token_symbol: 'WETH',
quote_token_icon_url: 'https://coin-images.coingecko.com/coins/images/2518/small/weth.png?1696503332',
fully_diluted_valuation_usd: '15211385',
market_cap_usd: '15211385',
liquidity: '394101.2428',
dex: { id: 'uniswap_v2', name: 'Uniswap V2' },
coin_gecko_terminal_url: 'https://www.geckoterminal.com/eth/pools/0x6a1041865b76d1dc33da0257582591227c57832c',
};
export type PoolsResponse = {
items: Array<Pool>;
next_page_params: {
page_token: string;
page_size: number;
} | null;
};
export type Pool = {
contract_address: string;
chain_id: string;
base_token_address: string;
base_token_symbol: string;
base_token_icon_url: string | null;
quote_token_address: string;
quote_token_symbol: string;
quote_token_icon_url: string | null;
fully_diluted_valuation_usd: string;
market_cap_usd: string;
liquidity: string;
dex: {
id: string;
name: string;
};
fee?: string;
coin_gecko_terminal_url: string;
};
...@@ -16,7 +16,7 @@ test.describe('mobile', () => { ...@@ -16,7 +16,7 @@ test.describe('mobile', () => {
test('base view', async({ render, page }) => { test('base view', async({ render, page }) => {
await render(<MarketplaceAppInfo data={ appsMock[0] }/>); await render(<MarketplaceAppInfo data={ appsMock[0] }/>);
await page.getByLabel('Show project info').click(); await page.getByLabel('Show info').click();
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}); });
}); });
import {
PopoverTrigger, PopoverContent, PopoverBody,
Modal, ModalContent, ModalCloseButton, useDisclosure,
} from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace'; import type { MarketplaceAppOverview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile'; import InfoButton from 'ui/shared/InfoButton';
import Popover from 'ui/shared/chakra/Popover';
import Content from './MarketplaceAppInfo/Content'; import Content from './MarketplaceAppInfo/Content';
import TriggerButton from './MarketplaceAppInfo/TriggerButton';
interface Props { interface Props {
data: MarketplaceAppOverview | undefined; data: MarketplaceAppOverview | undefined;
} }
const MarketplaceAppInfo = ({ data }: Props) => { const MarketplaceAppInfo = ({ data }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
if (isMobile) {
return (
<>
<TriggerButton onClick={ onToggle } onlyIcon/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
<Content data={ data }/>
</ModalContent>
</Modal>
</>
);
}
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <InfoButton>
<PopoverTrigger> <Content data={ data }/>
<TriggerButton onClick={ onToggle } isActive={ isOpen }/> </InfoButton>
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
<Content data={ data }/>
</PopoverBody>
</PopoverContent>
</Popover>
); );
}; };
......
import { Button } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
onClick: () => void;
onlyIcon?: boolean;
isActive?: boolean;
}
const TriggerButton = ({ onClick, onlyIcon, isActive }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onClick }
isActive={ isActive }
aria-label="Show project info"
fontWeight={ 500 }
px={ onlyIcon ? 1 : 2 }
h="32px"
>
<IconSvg name="info" boxSize={ 6 } mr={ onlyIcon ? 0 : 1 }/>
{ !onlyIcon && <span>Info</span> }
</Button>
);
};
export default React.forwardRef(TriggerButton);
...@@ -2,6 +2,7 @@ import { Hide, Show } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Hide, Show } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import getItemIndex from 'lib/getItemIndex';
import { TOP_ADDRESS } from 'stubs/address'; import { TOP_ADDRESS } from 'stubs/address';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import AddressesListItem from 'ui/addresses/AddressesListItem'; import AddressesListItem from 'ui/addresses/AddressesListItem';
...@@ -12,8 +13,6 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -12,8 +13,6 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
const PAGE_SIZE = 50;
const Accounts = () => { const Accounts = () => {
const { isError, isPlaceholderData, data, pagination } = useQueryWithPages({ const { isError, isPlaceholderData, data, pagination } = useQueryWithPages({
resourceName: 'addresses', resourceName: 'addresses',
...@@ -39,7 +38,7 @@ const Accounts = () => { ...@@ -39,7 +38,7 @@ const Accounts = () => {
</ActionBar> </ActionBar>
); );
const pageStartIndex = (pagination.page - 1) * PAGE_SIZE + 1; const pageStartIndex = getItemIndex(0, pagination.page);
const totalSupply = React.useMemo(() => { const totalSupply = React.useMemo(() => {
return BigNumber(data?.total_supply || '0'); return BigNumber(data?.total_supply || '0');
}, [ data?.total_supply ]); }, [ data?.total_supply ]);
......
import React from 'react';
import config from 'configs/app';
import * as addressMock from 'mocks/address/address';
import * as poolMock from 'mocks/pools/pool';
import { test, expect } from 'playwright/lib';
import Pool from './Pool';
const addressHash = '0x1234';
const hooksConfig = {
router: {
query: { hash: addressHash },
},
};
test('base view +@mobile +@dark-mode', async({ render, mockApiResponse, mockTextAd, mockAssetResponse }) => {
await mockTextAd();
await mockApiResponse('pool', poolMock.base, { pathParams: { chainId: config.chain.id, hash: addressHash } });
await mockApiResponse('address', addressMock.contract, { pathParams: { hash: poolMock.base.contract_address } });
await mockAssetResponse(poolMock.base.quote_token_icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(poolMock.base.base_token_icon_url as string, './playwright/mocks/image_md.jpg');
const component = await render(<Pool/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
import { Tag, Box, Flex, Image, Skeleton } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getPoolLinks from 'lib/pools/getPoolLinks';
import { getPoolTitle } from 'lib/pools/getPoolTitle';
import getQueryParamString from 'lib/router/getQueryParamString';
import * as addressStubs from 'stubs/address';
import { POOL } from 'stubs/pools';
import PoolInfo from 'ui/pool/PoolInfo';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as PoolEntity from 'ui/shared/entities/pool/PoolEntity';
import InfoButton from 'ui/shared/InfoButton';
import LinkExternal from 'ui/shared/links/LinkExternal';
import PageTitle from 'ui/shared/Page/PageTitle';
import VerifyWith from 'ui/shared/VerifyWith';
const Pool = () => {
const router = useRouter();
const appProps = useAppContext();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, error } = useApiQuery('pool', {
pathParams: { hash, chainId: config.chain.id },
queryOptions: {
placeholderData: POOL,
refetchOnMount: false,
},
});
const addressQuery = useApiQuery('address', {
pathParams: { hash: data?.contract_address },
queryOptions: {
enabled: Boolean(data?.contract_address),
placeholderData: addressStubs.ADDRESS_INFO,
},
});
const content = (() => {
if (isError) {
if (isCustomAppError(error)) {
throwOnResourceLoadError({ resource: 'pool', error, isError: true });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
return (
<PoolInfo
data={ data }
isPlaceholderData={ isPlaceholderData }
/>
);
})();
const externalLinks = getPoolLinks(data);
const hasLinks = externalLinks.length > 0;
const externalLinksComponents = React.useMemo(() => {
return externalLinks
.map((link) => {
return (
<LinkExternal h="34px" key={ link.url } href={ link.url } alignItems="center" display="inline-flex" minW="120px">
<Image boxSize={ 5 } mr={ 2 } src={ link.image } alt={ `${ link.title } icon` }/>
{ link.title }
</LinkExternal>
);
});
}, [ externalLinks ]);
const titleSecondRow = (
<Flex alignItems="center" justifyContent="space-between" w="100%">
{ addressQuery.data ? <AddressEntity address={ addressQuery.data } isLoading={ addressQuery.isPlaceholderData }/> : <Box/> }
<Flex gap={ 2 }>
<InfoButton>
{ `This Liquidity Provider (LP) token represents ${ data?.base_token_symbol }/${ data?.quote_token_symbol } pairing.` }
</InfoButton>
{ hasLinks && (
<VerifyWith
links={ externalLinksComponents }
label="Verify with"
longText="View in"
shortText=""
/>
) }
</Flex>
</Flex>
);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/pools');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to pools list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const poolTitle = data ? getPoolTitle(data) : '';
return (
<>
<PageTitle
title={ poolTitle }
backLink={ backLink }
beforeTitle={ data ? (
<PoolEntity.Icon
pool={ data }
isLoading={ isPlaceholderData }
size="lg"
/>
) : null }
contentAfter={ <Skeleton isLoaded={ !isPlaceholderData }><Tag>Pool</Tag></Skeleton> }
secondRow={ titleSecondRow }
isLoading={ isPlaceholderData }
withTextAd
/>
{ content }
</>
);
};
export default Pool;
import React from 'react';
import config from 'configs/app';
import * as poolMock from 'mocks/pools/pool';
import { test, expect, devices } from 'playwright/lib';
import Pools from './Pools';
test('base view +@dark-mode', async({ render, mockApiResponse, mockTextAd, mockAssetResponse }) => {
await mockTextAd();
await mockApiResponse(
'pools',
{ items: [ poolMock.base, poolMock.noIcons, poolMock.base ], next_page_params: null },
{ pathParams: { chainId: config.chain.id } },
);
await mockAssetResponse(poolMock.base.quote_token_icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(poolMock.base.base_token_icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Pools/>);
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ render, mockApiResponse, mockTextAd, mockAssetResponse }) => {
await mockTextAd();
await mockApiResponse(
'pools',
{ items: [ poolMock.base, poolMock.noIcons, poolMock.base ], next_page_params: null },
{ pathParams: { chainId: config.chain.id } },
);
await mockAssetResponse(poolMock.base.quote_token_icon_url as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(poolMock.base.base_token_icon_url as string, './playwright/mocks/image_s.jpg');
const component = await render(<Pools/>);
await expect(component).toHaveScreenshot();
});
});
import { Show, Hide, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useDebounce from 'lib/hooks/useDebounce';
import { apos } from 'lib/html-entities';
import getQueryParamString from 'lib/router/getQueryParamString';
import { POOL } from 'stubs/pools';
import PoolsListItem from 'ui/pools/PoolsListItem';
import PoolsTable from 'ui/pools/PoolsTable';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import FilterInput from 'ui/shared/filters/FilterInput';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
const Pools = () => {
const router = useRouter();
const q = getQueryParamString(router.query.query);
const [ searchTerm, setSearchTerm ] = React.useState<string>(q ?? '');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const poolsQuery = useQueryWithPages({
resourceName: 'pools',
pathParams: { chainId: config.chain.id },
filters: { query: debouncedSearchTerm },
options: {
placeholderData: { items: Array(50).fill(POOL), next_page_params: { page_token: 'a', page_size: 50 } },
},
});
const handleSearchTermChange = React.useCallback((value: string) => {
poolsQuery.onFilterChange({ query: value });
setSearchTerm(value);
}, [ poolsQuery ]);
const content = (
<>
<Show below="lg" ssr={ false }>
{ poolsQuery.data?.items.map((item, index) => (
<PoolsListItem
key={ item.contract_address + (poolsQuery.isPlaceholderData ? index : '') }
isLoading={ poolsQuery.isPlaceholderData }
item={ item }
/>
)) }
</Show>
<Hide below="lg" ssr={ false }>
<PoolsTable
items={ poolsQuery.data?.items ?? [] }
top={ poolsQuery.pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ poolsQuery.isPlaceholderData }
page={ poolsQuery.pagination.page }
/>
</Hide>
</>
);
const filter = (
<FilterInput
w={{ base: '100%', lg: '360px' }}
size="xs"
onChange={ handleSearchTermChange }
placeholder="Pair, token symbol or token address"
initialValue={ searchTerm }
/>
);
const actionBar = (
<>
<Flex mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filter }
</Flex>
<ActionBar
mt={ -6 }
display={{ base: poolsQuery.pagination.isVisible ? 'flex' : 'none', lg: 'flex' }}
>
<Hide below="lg">
{ filter }
</Hide>
<Pagination { ...poolsQuery.pagination } ml="auto"/>
</ActionBar>
</>
);
return (
<>
<PageTitle
title="DEX tracker"
withTextAd
/>
<DataListDisplay
isError={ poolsQuery.isError }
items={ poolsQuery.data?.items }
emptyText="There are no pools."
content={ content }
actionBar={ actionBar }
filterProps={{
emptyFilteredText: `Couldn${ apos }t find pools that matches your filter query.`,
hasActiveFilters: Boolean(debouncedSearchTerm),
}}
/>
</>
);
};
export default Pools;
...@@ -56,7 +56,7 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse, ...@@ -56,7 +56,7 @@ test('with verified info', async({ render, page, createSocket, mockApiResponse,
const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`); const channel = await socketServer.joinChannel(socket, `tokens:${ hash }`);
socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 }); socketServer.sendMessage(socket, channel, 'total_supply', { total_supply: 10 ** 20 });
await page.getByRole('button', { name: /project info/i }).click(); await page.getByLabel('Show info').click();
await expect(component).toHaveScreenshot({ await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ], mask: [ page.locator(pwConfig.adsBannerSelector) ],
......
import { Grid, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { Pool } from 'types/api/pools';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
type Props = {
data: Pool;
isPlaceholderData: boolean;
};
const PoolInfo = ({ data, isPlaceholderData }: Props) => {
return (
<Grid
columnGap={ 8 }
rowGap={{ base: 1, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(min-content, 200px) minmax(0, 1fr)' }}
overflow="hidden"
>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="The base token in a liquidity pool pair"
>
Base token
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<TokenEntity
token={{
type: 'ERC-20',
address: data.base_token_address,
name: data.base_token_symbol,
symbol: data.base_token_symbol,
icon_url: data.base_token_icon_url,
}}
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="The quote token in a liquidity pool pair"
>
Quote token
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<TokenEntity
token={{
type: 'ERC-20',
address: data.quote_token_address,
name: data.quote_token_symbol,
symbol: data.quote_token_symbol,
icon_url: data.quote_token_icon_url,
}}
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="Fully Diluted Valuation: theoretical market cap if all tokens were in circulation"
>
FDV
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<Skeleton isLoaded={ !isPlaceholderData }>
${ Number(data.fully_diluted_valuation_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="Current market capitalization of the pool"
>
Market cap
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<Skeleton isLoaded={ !isPlaceholderData }>
${ Number(data.market_cap_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="Current liquidity of the pool"
>
Liquidity
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<Skeleton isLoaded={ !isPlaceholderData }>
${ Number(data.liquidity).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</DetailsInfoItem.Value>
<DetailsInfoItem.Label
isLoading={ isPlaceholderData }
hint="DEX where the pool is traded"
>
DEX
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<Skeleton isLoaded={ !isPlaceholderData }>
{ data.dex.name }
</Skeleton>
</DetailsInfoItem.Value>
<DetailsSponsoredItem isLoading={ isPlaceholderData }/>
</Grid>
);
};
export default PoolInfo;
import { Skeleton, Image } from '@chakra-ui/react';
import React from 'react';
import type { Pool } from 'types/api/pools';
import getPoolLinks from 'lib/pools/getPoolLinks';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PoolEntity from 'ui/shared/entities/pool/PoolEntity';
import LinkExternal from 'ui/shared/links/LinkExternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
type Props = {
item: Pool;
isLoading?: boolean;
};
const UserOpsListItem = ({ item, isLoading }: Props) => {
const externalLinks = getPoolLinks(item);
return (
<ListItemMobileGrid.Container gridTemplateColumns="100px auto">
<ListItemMobileGrid.Label isLoading={ isLoading }>Pool</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<PoolEntity pool={ item } fontWeight={ 700 } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Contract</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity address={{ hash: item.contract_address }} noIcon isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>FDV</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.fully_diluted_valuation_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Market cap</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.market_cap_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Liquidity</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.liquidity).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>View in</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
{ externalLinks.map((link) => (
<LinkExternal href={ link.url } key={ link.url } display="inline-flex">
<Image src={ link.image } alt={ link.title } boxSize={ 5 } mr={ 2 }/>
{ link.title }
</LinkExternal>
)) }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default UserOpsListItem;
import { Table, Tbody, Th, Tr, Flex } from '@chakra-ui/react';
import React from 'react';
import type { Pool } from 'types/api/pools';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import Hint from 'ui/shared/Hint';
import { default as Thead } from 'ui/shared/TheadSticky';
import PoolsTableItem from './PoolsTableItem';
type Props = {
items: Array<Pool>;
page: number;
isLoading?: boolean;
top?: number;
};
const PoolsTable = ({ items, page, isLoading, top }: Props) => {
return (
<Table>
<Thead top={ top ?? ACTION_BAR_HEIGHT_DESKTOP }>
<Tr>
<Th width="70%">Pool</Th>
<Th width="30%">DEX </Th>
<Th width="120px" isNumeric>
<Flex alignItems="center" justifyContent="end">
FDV
<Hint
label="Fully Diluted Valuation: theoretical market cap if all tokens were in circulation"
boxSize={ 5 }
ml={ 1 }
/>
</Flex>
</Th>
<Th width="120px" isNumeric>Market cap</Th>
<Th width="120px" isNumeric>Liquidity</Th>
<Th width="75px" isNumeric>View in</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<PoolsTableItem key={ item.contract_address + (isLoading ? index : '') } item={ item } index={ index } page={ page } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default PoolsTable;
import { Flex, Box, Td, Tr, Skeleton, Text, Image, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { Pool } from 'types/api/pools';
import getItemIndex from 'lib/getItemIndex';
import getPoolLinks from 'lib/pools/getPoolLinks';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PoolEntity from 'ui/shared/entities/pool/PoolEntity';
import LinkExternal from 'ui/shared/links/LinkExternal';
type Props = {
item: Pool;
index: number;
page: number;
isLoading?: boolean;
};
const PoolsTableItem = ({
item,
page,
index,
isLoading,
}: Props) => {
const externalLinks = getPoolLinks(item);
return (
<Tr>
<Td>
<Flex gap={ 2 } alignItems="start">
<Skeleton isLoaded={ !isLoading }>
<Text px={ 2 }>{ getItemIndex(index, page) }</Text>
</Skeleton>
<Box>
<PoolEntity pool={ item } fontWeight={ 700 } mb={ 2 } isLoading={ isLoading }/>
<AddressEntity address={{ hash: item.contract_address }} noIcon isLoading={ isLoading }/>
</Box>
</Flex>
</Td>
<Td>
<Skeleton isLoaded={ !isLoading }>{ item.dex.name }</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.fully_diluted_valuation_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.market_cap_usd).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading }>
${ Number(item.liquidity).toLocaleString(undefined, { maximumFractionDigits: 2, notation: 'compact' }) }
</Skeleton>
</Td>
<Td isNumeric>
<Skeleton isLoaded={ !isLoading } display="flex" gap={ 2 } justifyContent="center">
{ externalLinks.map((link) => (
<Tooltip label={ link.title } key={ link.url }>
<Box display="inline-block">
<LinkExternal href={ link.url } display="inline-flex">
<Image src={ link.image } alt={ link.title } boxSize={ 5 }/>
</LinkExternal>
</Box>
</Tooltip>
)) }
</Skeleton>
</Td>
</Tr>
);
};
export default PoolsTableItem;
import {
PopoverTrigger, PopoverContent, PopoverBody,
Modal, ModalContent, ModalCloseButton,
useDisclosure,
Button,
} from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from './IconSvg';
interface Props {
children: React.ReactNode;
}
const InfoButton = ({ children }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const triggerButton = (
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
isActive={ isOpen }
aria-label="Show info"
fontWeight={ 500 }
lineHeight={ 6 }
pl={ 1 }
pr={ isMobile ? 1 : 2 }
h="32px"
>
<IconSvg name="info" boxSize={ 6 } mr={ isMobile ? 0 : 1 }/>
{ !isMobile && <span>Info</span> }
</Button>
);
if (isMobile) {
return (
<>
{ triggerButton }
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
{ children }
</ModalContent>
</Modal>
</>
);
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
{ triggerButton }
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
{ children }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(InfoButton);
import { import {
Image, Image,
Button,
PopoverTrigger,
PopoverBody,
PopoverContent,
Show,
Hide,
useColorModeValue, useColorModeValue,
chakra, chakra,
useDisclosure,
Grid,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -17,10 +9,9 @@ import type { NetworkExplorer as TNetworkExplorer } from 'types/networks'; ...@@ -17,10 +9,9 @@ import type { NetworkExplorer as TNetworkExplorer } from 'types/networks';
import config from 'configs/app'; import config from 'configs/app';
import stripTrailingSlash from 'lib/stripTrailingSlash'; import stripTrailingSlash from 'lib/stripTrailingSlash';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import PopoverTriggerTooltip from 'ui/shared/PopoverTriggerTooltip'; import VerifyWith from 'ui/shared/VerifyWith';
interface Props { interface Props {
className?: string; className?: string;
...@@ -29,7 +20,6 @@ interface Props { ...@@ -29,7 +20,6 @@ interface Props {
} }
const NetworkExplorers = ({ className, type, pathParam }: Props) => { const NetworkExplorers = ({ className, type, pathParam }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const defaultIconColor = useColorModeValue('gray.400', 'gray.500'); const defaultIconColor = useColorModeValue('gray.400', 'gray.500');
const explorersLinks = React.useMemo(() => { const explorersLinks = React.useMemo(() => {
...@@ -54,46 +44,13 @@ const NetworkExplorers = ({ className, type, pathParam }: Props) => { ...@@ -54,46 +44,13 @@ const NetworkExplorers = ({ className, type, pathParam }: Props) => {
} }
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <VerifyWith
<PopoverTrigger> className={ className }
<PopoverTriggerTooltip label="Verify with other explorers" className={ className }> links={ explorersLinks }
<Button label="Verify with other explorers"
size="sm" longText={ `${ explorersLinks.length } Explorer${ explorersLinks.length > 1 ? 's' : '' }` }
variant="outline" shortText={ explorersLinks.length.toString() }
colorScheme="gray" />
onClick={ onToggle }
isActive={ isOpen }
aria-label="Verify in other explorers"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<IconSvg name="explorer" boxSize={ 5 }/>
<Show above="xl">
<chakra.span ml={ 1 }>{ explorersLinks.length } Explorer{ explorersLinks.length > 1 ? 's' : '' }</chakra.span>
</Show>
<Hide above="xl">
<chakra.span ml={ 1 }>{ explorersLinks.length }</chakra.span>
</Hide>
</Button>
</PopoverTriggerTooltip>
</PopoverTrigger>
<PopoverContent w="auto">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">Verify with other explorers</chakra.span>
<Grid
alignItems="center"
templateColumns={ explorersLinks.length > 1 ? 'auto auto' : '1fr' }
columnGap={ 4 }
rowGap={ 2 }
mt={ 3 }
>
{ explorersLinks }
</Grid>
</PopoverBody>
</PopoverContent>
</Popover>
); );
}; };
......
import {
Button,
PopoverTrigger,
PopoverBody,
PopoverContent,
Show,
Hide,
chakra,
useDisclosure,
Grid,
} from '@chakra-ui/react';
import React from 'react';
import Popover from 'ui/shared/chakra/Popover';
import IconSvg from 'ui/shared/IconSvg';
import PopoverTriggerTooltip from 'ui/shared/PopoverTriggerTooltip';
interface Props {
className?: string;
links: Array<React.ReactNode>;
label: string;
longText: string;
shortText?: string;
}
const VerifyWith = ({ className, links, label, longText, shortText }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<PopoverTriggerTooltip label={ label } className={ className }>
<Button
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
isActive={ isOpen }
aria-label={ label }
fontWeight={ 500 }
px={ shortText ? 2 : 1 }
h="32px"
flexShrink={ 0 }
>
<IconSvg name="explorer" boxSize={ 5 }/>
<Show above="xl">
<chakra.span ml={ 1 }>{ longText }</chakra.span>
</Show>
{ shortText && (
<Hide above="xl">
<chakra.span ml={ 1 }>{ shortText }</chakra.span>
</Hide>
) }
</Button>
</PopoverTriggerTooltip>
</PopoverTrigger>
<PopoverContent w="auto">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">{ label }</chakra.span>
<Grid
alignItems="center"
templateColumns={ links.length > 1 ? 'auto auto' : '1fr' }
columnGap={ 4 }
rowGap={ 2 }
mt={ 3 }
>
{ links }
</Grid>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default chakra(VerifyWith);
import React from 'react';
import * as poolMock from 'mocks/pools/pool';
import { test, expect } from 'playwright/lib';
import PoolEntity from './PoolEntity';
test.use({ viewport: { width: 300, height: 100 } });
test('with icons +@dark-mode', async({ render, mockAssetResponse }) => {
await mockAssetResponse('https://localhost:3000/utia.jpg', './playwright/mocks/image_s.jpg');
await mockAssetResponse('https://localhost:3000/secondary_utia.jpg', './playwright/mocks/image_md.jpg');
const component = await render(
<PoolEntity
pool={ poolMock.base }
/>,
);
await expect(component).toHaveScreenshot();
});
test('no icons +@dark-mode', async({ render }) => {
const component = await render(
<PoolEntity
pool={ poolMock.noIcons }
/>,
);
await expect(component).toHaveScreenshot();
});
import type { As } from '@chakra-ui/react';
import { Flex, Skeleton, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { Pool } from 'types/api/pools';
import { route } from 'nextjs-routes';
import { getPoolTitle } from 'lib/pools/getPoolTitle';
import * as EntityBase from 'ui/shared/entities/base/components';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
import { distributeEntityProps } from '../base/utils';
import * as TokenEntity from '../token/TokenEntity';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'pool'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/pools/[hash]', query: { hash: props.pool.contract_address } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
type IconProps = Pick<EntityProps, 'pool' | 'className'> & EntityBase.IconBaseProps;
const Icon = (props: IconProps) => {
const bgColor = useColorModeValue('white', 'black');
const borderColor = useColorModeValue('whiteAlpha.800', 'blackAlpha.800');
return (
<Flex>
<Flex
bgColor={ bgColor }
borderRadius="full"
border="1px solid"
borderColor={ borderColor }
>
<TokenEntity.Icon
marginRight={ 0 }
size={ props.size }
token={{
icon_url: props.pool.base_token_icon_url,
symbol: props.pool.base_token_symbol,
address: props.pool.base_token_address,
name: '',
type: 'ERC-20',
}}
isLoading={ props.isLoading }
/>
</Flex>
<Flex
transform="translateX(-8px)"
bgColor={ bgColor }
borderRadius="full"
border="1px solid"
borderColor={ borderColor }
>
<TokenEntity.Icon
marginRight={ 0 }
size={ props.size }
token={{
icon_url: props.pool.quote_token_icon_url,
symbol: props.pool.quote_token_symbol,
address: props.pool.quote_token_address,
name: '',
type: 'ERC-20',
}}
isLoading={ props.isLoading }
/>
</Flex>
</Flex>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'pool'>;
const Content = chakra((props: ContentProps) => {
const nameString = getPoolTitle(props.pool);
return (
<TruncatedTextTooltip label={ nameString }>
<Skeleton
isLoaded={ !props.isLoading }
display="inline-block"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
height="fit-content"
>
{ nameString }
</Skeleton>
</TruncatedTextTooltip>
);
});
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
pool: Pool;
}
const PoolEntity = (props: EntityProps) => {
const partsProps = distributeEntityProps(props);
return (
<Container w="100%" { ...partsProps.container }>
<Icon { ...partsProps.icon }/>
<Link { ...partsProps.link }>
<Content { ...partsProps.content }/>
</Link>
</Container>
);
};
export default React.memo(chakra<As, EntityProps>(PoolEntity));
export {
Container,
Link,
Icon,
Content,
};
...@@ -37,7 +37,7 @@ const Icon = (props: IconProps) => { ...@@ -37,7 +37,7 @@ const Icon = (props: IconProps) => {
const styles = { const styles = {
marginRight: props.marginRight ?? 2, marginRight: props.marginRight ?? 2,
boxSize: props.boxSize ?? getIconProps(props.size).boxSize, boxSize: props.boxSize ?? getIconProps(props.size).boxSize,
borderRadius: 'base', borderRadius: props.token.type === 'ERC-20' ? 'full' : 'base',
flexShrink: 0, flexShrink: 0,
}; };
...@@ -48,7 +48,6 @@ const Icon = (props: IconProps) => { ...@@ -48,7 +48,6 @@ const Icon = (props: IconProps) => {
return ( return (
<Image <Image
{ ...styles } { ...styles }
borderRadius={ props.token.type === 'ERC-20' ? 'full' : 'base' }
className={ props.className } className={ props.className }
src={ props.token.icon_url ?? undefined } src={ props.token.icon_url ?? undefined }
alt={ `${ props.token.name || 'token' } logo` } alt={ `${ props.token.name || 'token' } logo` }
......
import {
PopoverTrigger, PopoverContent, PopoverBody,
Modal, ModalContent, ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TokenVerifiedInfo } from 'types/api/token'; import type { TokenVerifiedInfo } from 'types/api/token';
import useIsMobile from 'lib/hooks/useIsMobile'; import InfoButton from 'ui/shared/InfoButton';
import Popover from 'ui/shared/chakra/Popover';
import Content, { hasContent } from './TokenProjectInfo/Content'; import Content, { hasContent } from './TokenProjectInfo/Content';
import TriggerButton from './TokenProjectInfo/TriggerButton';
interface Props { interface Props {
data: TokenVerifiedInfo; data: TokenVerifiedInfo;
} }
const TokenProjectInfo = ({ data }: Props) => { const TokenProjectInfo = ({ data }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
if (!hasContent(data)) { if (!hasContent(data)) {
return null; return null;
} }
if (isMobile) {
return (
<>
<TriggerButton onClick={ onToggle } isActive={ isOpen }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
<Content data={ data }/>
</ModalContent>
</Modal>
</>
);
}
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <InfoButton>
<PopoverTrigger> <Content data={ data }/>
<TriggerButton onClick={ onToggle } isActive={ isOpen }/> </InfoButton>
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
<Content data={ data }/>
</PopoverBody>
</PopoverContent>
</Popover>
); );
}; };
......
import { Button } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
onClick: () => void;
isActive: boolean;
}
const TriggerButton = ({ onClick, isActive }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onClick }
isActive={ isActive }
aria-label="Show project info"
fontWeight={ 500 }
lineHeight={ 6 }
pl={ 1 }
pr={ 2 }
h="32px"
>
<IconSvg name="info" boxSize={ 6 } mr={ 1 }/>
<span>Info</span>
</Button>
);
};
export default React.forwardRef(TriggerButton);
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import config from 'configs/app'; import config from 'configs/app';
import getItemIndex from 'lib/getItemIndex';
import { getTokenTypeName } from 'lib/token/tokenTypes'; import { getTokenTypeName } from 'lib/token/tokenTypes';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -19,8 +20,6 @@ type Props = { ...@@ -19,8 +20,6 @@ type Props = {
isLoading?: boolean; isLoading?: boolean;
}; };
const PAGE_SIZE = 50;
const bridgedTokensFeature = config.features.bridgedTokens; const bridgedTokensFeature = config.features.bridgedTokens;
const TokensTableItem = ({ const TokensTableItem = ({
...@@ -65,7 +64,7 @@ const TokensTableItem = ({ ...@@ -65,7 +64,7 @@ const TokensTableItem = ({
{ bridgedChainTag && <Tag isLoading={ isLoading }>{ bridgedChainTag }</Tag> } { bridgedChainTag && <Tag isLoading={ isLoading }>{ bridgedChainTag }</Tag> }
</Flex> </Flex>
<Skeleton isLoaded={ !isLoading } fontSize="sm" ml="auto" color="text_secondary" minW="24px" textAlign="right" lineHeight={ 6 }> <Skeleton isLoaded={ !isLoading } fontSize="sm" ml="auto" color="text_secondary" minW="24px" textAlign="right" lineHeight={ 6 }>
<span>{ (page - 1) * PAGE_SIZE + index + 1 }</span> <span>{ getItemIndex(index, page) }</span>
</Skeleton> </Skeleton>
</GridItem> </GridItem>
</Grid> </Grid>
......
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { TokenInfo } from 'types/api/token'; import type { TokenInfo } from 'types/api/token';
import config from 'configs/app'; import config from 'configs/app';
import getItemIndex from 'lib/getItemIndex';
import { getTokenTypeName } from 'lib/token/tokenTypes'; import { getTokenTypeName } from 'lib/token/tokenTypes';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet'; import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -19,8 +20,6 @@ type Props = { ...@@ -19,8 +20,6 @@ type Props = {
isLoading?: boolean; isLoading?: boolean;
}; };
const PAGE_SIZE = 50;
const bridgedTokensFeature = config.features.bridgedTokens; const bridgedTokensFeature = config.features.bridgedTokens;
const TokensTableItem = ({ const TokensTableItem = ({
...@@ -70,7 +69,7 @@ const TokensTableItem = ({ ...@@ -70,7 +69,7 @@ const TokensTableItem = ({
mr={ 3 } mr={ 3 }
minW="28px" minW="28px"
> >
{ (page - 1) * PAGE_SIZE + index + 1 } { getItemIndex(index, page) }
</Skeleton> </Skeleton>
<Flex overflow="hidden" flexDir="column" rowGap={ 2 }> <Flex overflow="hidden" flexDir="column" rowGap={ 2 }>
<TokenEntity <TokenEntity
......
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