diff --git a/configs/app/features/marketplace.ts b/configs/app/features/marketplace.ts index 937397be6a..72f5d1ebde 100644 --- a/configs/app/features/marketplace.ts +++ b/configs/app/features/marketplace.ts @@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP'); const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL'); const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL'); +const ratingAirtableApiKey = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY'); +const ratingAirtableBaseId = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID'); const title = 'Marketplace'; @@ -27,6 +29,7 @@ const config: Feature<( securityReportsUrl: string | undefined; featuredApp: string | undefined; banner: { contentUrl: string; linkUrl: string } | undefined; + rating: { airtableApiKey: string; airtableBaseId: string } | undefined; }> = (() => { if (enabled === 'true' && chain.rpcUrl && submitFormUrl) { const props = { @@ -39,6 +42,10 @@ const config: Feature<( contentUrl: bannerContentUrl, linkUrl: bannerLinkUrl, } : undefined, + rating: ratingAirtableApiKey && ratingAirtableBaseId ? { + airtableApiKey: ratingAirtableApiKey, + airtableBaseId: ratingAirtableBaseId, + } : undefined, }; if (configUrl) { diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 09150ec6a5..b56eec0740 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com NEXT_PUBLIC_METASUITES_ENABLED=true NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'} @@ -59,4 +60,4 @@ NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true -NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com \ No newline at end of file +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 12c35f6392..b8adbc940a 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -223,6 +223,22 @@ const marketplaceSchema = yup // eslint-disable-next-line max-len otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'), }), + NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: yup + .string() + .when('NEXT_PUBLIC_MARKETPLACE_ENABLED', { + is: true, + then: (schema) => schema, + // eslint-disable-next-line max-len + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'), + }), + NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID: yup + .string() + .when('NEXT_PUBLIC_MARKETPLACE_ENABLED', { + is: true, + then: (schema) => schema, + // eslint-disable-next-line max-len + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'), + }), }); const beaconChainSchema = yup diff --git a/deploy/tools/envs-validator/test/.env.marketplace b/deploy/tools/envs-validator/test/.env.marketplace index 01eab57086..6cc6b1f839 100644 --- a/deploy/tools/envs-validator/test/.env.marketplace +++ b/deploy/tools/envs-validator/test/.env.marketplace @@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=test +NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=test diff --git a/deploy/values/review-l2/values.yaml.gotmpl b/deploy/values/review-l2/values.yaml.gotmpl index 09a609ed13..5647779fde 100644 --- a/deploy/values/review-l2/values.yaml.gotmpl +++ b/deploy/values/review-l2/values.yaml.gotmpl @@ -86,3 +86,4 @@ frontend: NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png + NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index ef5a6388c2..c37f4ee6c6 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -96,3 +96,4 @@ frontend: FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN + NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY diff --git a/docs/ENVS.md b/docs/ENVS.md index 1d7cfe7829..706a9fd58e 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin | NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` | v1.29.0+ | | NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ | | NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ | +| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY | `string` | Airtable API key | - | - | - | v1.33.0+ | +| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID | `string` | Airtable base ID with dapp ratings | - | - | - | v1.33.0+ | #### Marketplace app configuration properties diff --git a/icons/heart_filled.svg b/icons/heart_filled.svg new file mode 100644 index 0000000000..80926b1668 --- /dev/null +++ b/icons/heart_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/heart_outline.svg b/icons/heart_outline.svg new file mode 100644 index 0000000000..8bf7ce3e36 --- /dev/null +++ b/icons/heart_outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/star_filled.svg b/icons/star_filled.svg index 2bdea23a41..7b6312c876 100644 --- a/icons/star_filled.svg +++ b/icons/star_filled.svg @@ -1,3 +1,3 @@ - - + + diff --git a/icons/star_outline.svg b/icons/star_outline.svg index bf2eca9845..05286fa1d5 100644 --- a/icons/star_outline.svg +++ b/icons/star_outline.svg @@ -1,3 +1,3 @@ - + diff --git a/lib/growthbook/init.ts b/lib/growthbook/init.ts index 4aef06e705..d98b2b94b7 100644 --- a/lib/growthbook/init.ts +++ b/lib/growthbook/init.ts @@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; export interface GrowthBookFeatures { test_value: string; - action_button_exp: boolean; } export const growthBook = (() => { diff --git a/lib/hooks/useLazyRenderedList.tsx b/lib/hooks/useLazyRenderedList.tsx index 245d8a0b0b..3f2d828fe0 100644 --- a/lib/hooks/useLazyRenderedList.tsx +++ b/lib/hooks/useLazyRenderedList.tsx @@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer'; const STEP = 10; const MIN_ITEMS_NUM = 50; -export default function useLazyRenderedList(list: Array, isEnabled: boolean) { - const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM); +export default function useLazyRenderedList(list: Array, isEnabled: boolean, minItemsNum: number = MIN_ITEMS_NUM) { + const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(minItemsNum); const { ref, inView } = useInView({ rootMargin: '200px', triggerOnce: false, - skip: !isEnabled || list.length <= MIN_ITEMS_NUM, + skip: !isEnabled || list.length <= minItemsNum, }); React.useEffect(() => { diff --git a/lib/hooks/useToast.tsx b/lib/hooks/useToast.tsx index c9e0f3d63f..7afc6f3e11 100644 --- a/lib/hooks/useToast.tsx +++ b/lib/hooks/useToast.tsx @@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC position: 'top-right', isClosable: true, containerStyle: { - margin: 8, + margin: 3, + marginBottom: 0, }, variant: 'subtle', }; diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index bc69e5e9dc..f769c3e29a 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -20,6 +20,7 @@ export enum EventTypes { FILTERS = 'Filters', BUTTON_CLICK = 'Button click', PROMO_BANNER = 'Promo banner', + APP_FEEDBACK = 'App feedback', } /* eslint-disable @typescript-eslint/indent */ @@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? { 'Source': 'Marketplace'; 'Link': string; } : +Type extends EventTypes.APP_FEEDBACK ? { + 'Action': 'Rating'; + 'Source': 'Discovery' | 'App modal' | 'App page'; + 'AppId': string; + 'Score': number; +} : undefined; /* eslint-enable @typescript-eslint/indent */ diff --git a/mocks/apps/ratings.ts b/mocks/apps/ratings.ts new file mode 100644 index 0000000000..3eeca5b693 --- /dev/null +++ b/mocks/apps/ratings.ts @@ -0,0 +1,12 @@ +import { apps } from './apps'; + +export const ratings = { + records: [ + { + fields: { + appId: apps[0].id, + rating: 4.3, + }, + }, + ], +}; diff --git a/mocks/apps/securityReports.ts b/mocks/apps/securityReports.ts index 824a6fbe13..33457ddf2f 100644 --- a/mocks/apps/securityReports.ts +++ b/mocks/apps/securityReports.ts @@ -1,6 +1,8 @@ +import { apps } from './apps'; + export const securityReports = [ { - appName: 'token-approval-tracker', + appName: apps[0].id, doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet', chainsData: { '1': { diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index bf7b1236ef..758612f3f6 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -10,6 +10,7 @@ function generateCspPolicy() { descriptors.googleFonts(), descriptors.googleReCaptcha(), descriptors.growthBook(), + descriptors.marketplace(), descriptors.mixpanel(), descriptors.monaco(), descriptors.safe(), diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index 5734f85a76..cadb26363e 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -31,8 +31,6 @@ const getCspReportUrl = () => { }; export function app(): CspDev.DirectiveDescriptor { - const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace); - return { 'default-src': [ // KEY_WORDS.NONE, @@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor { getFeaturePayload(config.features.addressVerification)?.api.endpoint, getFeaturePayload(config.features.nameService)?.api.endpoint, getFeaturePayload(config.features.addressMetadata)?.api.endpoint, - marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '', // chain RPC server config.chain.rpcUrl, diff --git a/nextjs/csp/policies/index.ts b/nextjs/csp/policies/index.ts index af8e24b2db..1cbe44f1bc 100644 --- a/nextjs/csp/policies/index.ts +++ b/nextjs/csp/policies/index.ts @@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics'; export { googleFonts } from './googleFonts'; export { googleReCaptcha } from './googleReCaptcha'; export { growthBook } from './growthBook'; +export { marketplace } from './marketplace'; export { mixpanel } from './mixpanel'; export { monaco } from './monaco'; export { safe } from './safe'; diff --git a/nextjs/csp/policies/marketplace.ts b/nextjs/csp/policies/marketplace.ts new file mode 100644 index 0000000000..08474a4bc1 --- /dev/null +++ b/nextjs/csp/policies/marketplace.ts @@ -0,0 +1,22 @@ +import type CspDev from 'csp-dev'; + +import config from 'configs/app'; + +const feature = config.features.marketplace; + +export function marketplace(): CspDev.DirectiveDescriptor { + if (!feature.isEnabled) { + return {}; + } + + return { + 'connect-src': [ + 'api' in feature ? feature.api.endpoint : '', + feature.rating ? 'https://api.airtable.com' : '', + ], + + 'frame-src': [ + '*', + ], + }; +} diff --git a/package.json b/package.json index f127afd7e5..f8468e02a9 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/papaparse": "^5.3.5", "@types/react-scroll": "^1.8.4", "@web3modal/wagmi": "4.2.1", + "airtable": "^0.12.2", "bignumber.js": "^9.1.0", "blo": "^1.1.1", "chakra-react-select": "^4.4.3", diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 535a4c1d3e..333de6e887 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -71,6 +71,8 @@ | "globe-b" | "globe" | "graphQL" + | "heart_filled" + | "heart_outline" | "hourglass" | "info" | "integration/full" diff --git a/theme/components/Tooltip/Tooltip.ts b/theme/components/Tooltip/Tooltip.ts index a98dd867ce..d0bbae57e2 100644 --- a/theme/components/Tooltip/Tooltip.ts +++ b/theme/components/Tooltip/Tooltip.ts @@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => { [$bg.variable]: `colors.${ bg }`, [$fg.variable]: `colors.${ fg }`, [$arrowBg.variable]: $bg.reference, - maxWidth: props.maxWidth || props.maxW || 'unset', + maxWidth: props.maxWidth || props.maxW || 'calc(100vw - 8px)', + marginX: '4px', }; }); diff --git a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index 44b6eff06f..c80b68fb63 100644 Binary files a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png index 4667657549..c09ef160cb 100644 Binary files a/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png and b/theme/components/Tooltip/__screenshots__/Tooltip.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/types/client/marketplace.ts b/types/client/marketplace.ts index 5f9255abec..9b34c46206 100644 --- a/types/client/marketplace.ts +++ b/types/client/marketplace.ts @@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia site?: string; } +export type AppRating = { + recordId: string; + value: number | undefined; +} + export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & { securityReport?: MarketplaceAppSecurityReport; + rating?: AppRating; } export enum MarketplaceCategory { diff --git a/ui/address/AddressCoinBalance.pw.tsx b/ui/address/AddressCoinBalance.pw.tsx index a8fdd971a3..3950798784 100644 --- a/ui/address/AddressCoinBalance.pw.tsx +++ b/ui/address/AddressCoinBalance.pw.tsx @@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) await page.waitForFunction(() => { return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1'; }); + await page.mouse.move(100, 100); await page.mouse.move(240, 100); await expect(component).toHaveScreenshot(); }); diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png index bb79528b20..4b2d50bcea 100644 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png index da77d32fc9..58053b91e2 100644 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png index fad359546d..c86acec370 100644 Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/address/accountHistory/AddressAccountHistoryListItem.tsx b/ui/address/accountHistory/AddressAccountHistoryListItem.tsx index 3ea85cd994..01bec19066 100644 --- a/ui/address/accountHistory/AddressAccountHistoryListItem.tsx +++ b/ui/address/accountHistory/AddressAccountHistoryListItem.tsx @@ -3,11 +3,11 @@ import React, { useMemo } from 'react'; import type { NovesResponseData } from 'types/api/noves'; -import dayjs from 'lib/date/dayjs'; import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = { isPlaceholderData: boolean; @@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => { Action - - { dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } - + diff --git a/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx b/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx index 1c47862bd2..6e5ee2505a 100644 --- a/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx +++ b/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx @@ -1,12 +1,12 @@ -import { Td, Tr, Skeleton, Text, Box } from '@chakra-ui/react'; +import { Td, Tr, Skeleton, Box } from '@chakra-ui/react'; import React, { useMemo } from 'react'; import type { NovesResponseData } from 'types/api/noves'; -import dayjs from 'lib/date/dayjs'; import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/links/LinkInternal'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = { isPlaceholderData: boolean; @@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => { return (
- - - { dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } - - + diff --git a/ui/address/blocksValidated/AddressBlocksValidatedListItem.tsx b/ui/address/blocksValidated/AddressBlocksValidatedListItem.tsx index 6627e72d61..81065d7f27 100644 --- a/ui/address/blocksValidated/AddressBlocksValidatedListItem.tsx +++ b/ui/address/blocksValidated/AddressBlocksValidatedListItem.tsx @@ -6,11 +6,11 @@ import type { Block } from 'types/api/block'; import config from 'configs/app'; import getBlockTotalReward from 'lib/block/getBlockTotalReward'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { currencyUnits } from 'lib/units'; +import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; -import Utilization from 'ui/shared/Utilization/Utilization'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = Block & { page: number; @@ -18,7 +18,6 @@ type Props = Block & { }; const AddressBlocksValidatedListItem = (props: Props) => { - const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1); const totalReward = getBlockTotalReward(props); return ( @@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => { noIcon fontWeight={ 700 } /> - -
{ timeAgo } - + Txn @@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => { Gas used { BigNumber(props.gas_used || 0).toFormat() } - { props.gas_used && props.gas_used !== '0' && ( - - ) } + { !config.UI.views.block.hiddenFields?.total_reward && ( diff --git a/ui/address/blocksValidated/AddressBlocksValidatedTableItem.tsx b/ui/address/blocksValidated/AddressBlocksValidatedTableItem.tsx index d6e68dfded..e08dbc7db7 100644 --- a/ui/address/blocksValidated/AddressBlocksValidatedTableItem.tsx +++ b/ui/address/blocksValidated/AddressBlocksValidatedTableItem.tsx @@ -6,9 +6,9 @@ import type { Block } from 'types/api/block'; import config from 'configs/app'; import getBlockTotalReward from 'lib/block/getBlockTotalReward'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; +import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; -import Utilization from 'ui/shared/Utilization/Utilization'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = Block & { page: number; @@ -16,7 +16,6 @@ type Props = Block & { }; const AddressBlocksValidatedTableItem = (props: Props) => { - const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1); const totalReward = getBlockTotalReward(props); return ( @@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => { /> - - { timeAgo } - + @@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => { { BigNumber(props.gas_used || 0).toFormat() } - { props.gas_used && props.gas_used !== '0' && ( - - ) } + { !config.UI.views.block.hiddenFields?.total_reward && ( diff --git a/ui/address/coinBalance/AddressCoinBalanceListItem.tsx b/ui/address/coinBalance/AddressCoinBalanceListItem.tsx index 19ebf6f420..4f9a21dc74 100644 --- a/ui/address/coinBalance/AddressCoinBalanceListItem.tsx +++ b/ui/address/coinBalance/AddressCoinBalanceListItem.tsx @@ -5,11 +5,11 @@ import React from 'react'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import { WEI, ZERO } from 'lib/consts'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { currencyUnits } from 'lib/units'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = AddressCoinBalanceHistoryItem & { page: number; @@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & { const AddressCoinBalanceListItem = (props: Props) => { const deltaBn = BigNumber(props.delta).div(WEI); const isPositiveDelta = deltaBn.gte(ZERO); - const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1); return ( @@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => { ) } Age - { timeAgo } + ); diff --git a/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx b/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx index 0e87a4d7fa..eff93d79a2 100644 --- a/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx +++ b/ui/address/coinBalance/AddressCoinBalanceTableItem.tsx @@ -5,9 +5,9 @@ import React from 'react'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import { WEI, ZERO } from 'lib/consts'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = AddressCoinBalanceHistoryItem & { page: number; @@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & { const AddressCoinBalanceTableItem = (props: Props) => { const deltaBn = BigNumber(props.delta).div(WEI); const isPositiveDelta = deltaBn.gte(ZERO); - const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1); return ( @@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => { ) } - - { timeAgo } - + diff --git a/ui/address/internals/AddressIntTxsListItem.tsx b/ui/address/internals/AddressIntTxsListItem.tsx index 4b0861e706..2e38defecb 100644 --- a/ui/address/internals/AddressIntTxsListItem.tsx +++ b/ui/address/internals/AddressIntTxsListItem.tsx @@ -5,7 +5,6 @@ import React from 'react'; import type { InternalTransaction } from 'types/api/internalTransaction'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import { currencyUnits } from 'lib/units'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import Tag from 'ui/shared/chakra/Tag'; @@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import TxStatus from 'ui/shared/statusTag/TxStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }; @@ -47,9 +47,13 @@ const TxInternalsListItem = ({ fontWeight={ 700 } truncation="constant_long" /> - - { dayjs(timestamp).fromNow() } - + Block diff --git a/ui/address/internals/AddressIntTxsTableItem.tsx b/ui/address/internals/AddressIntTxsTableItem.tsx index 49f83d5100..d19730773d 100644 --- a/ui/address/internals/AddressIntTxsTableItem.tsx +++ b/ui/address/internals/AddressIntTxsTableItem.tsx @@ -5,12 +5,12 @@ import React from 'react'; import type { InternalTransaction } from 'types/api/internalTransaction'; import config from 'configs/app'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import Tag from 'ui/shared/chakra/Tag'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxStatus from 'ui/shared/statusTag/TxStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean } @@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({ const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title; const toData = to ? to : createdContract; - const timeAgo = useTimeAgoIncrement(timestamp, true); - return ( @@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({ noIcon truncation="constant_long" /> - { timestamp && ( - - { timeAgo } - - ) } + diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index 656fe2878f..68b1f5bd6a 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -1,4 +1,4 @@ -import { Grid, GridItem, Text, Link, Box, Tooltip, useColorModeValue, Skeleton } from '@chakra-ui/react'; +import { Grid, GridItem, Text, Link, Box, Tooltip, Skeleton } from '@chakra-ui/react'; import BigNumber from 'bignumber.js'; import capitalize from 'lodash/capitalize'; import { useRouter } from 'next/router'; @@ -18,6 +18,7 @@ import { space } from 'lib/html-entities'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getQueryParamString from 'lib/router/getQueryParamString'; import { currencyUnits } from 'lib/units'; +import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; @@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; -import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/links/LinkInternal'; import PrevNext from 'ui/shared/PrevNext'; import RawDataSnippet from 'ui/shared/RawDataSnippet'; import StatusTag from 'ui/shared/statusTag/StatusTag'; -import TextSeparator from 'ui/shared/TextSeparator'; import Utilization from 'ui/shared/Utilization/Utilization'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo'; @@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => { const router = useRouter(); const heightOrHash = getQueryParamString(router.query.height_or_hash); - const separatorColor = useColorModeValue('gray.200', 'gray.700'); - const { data, isPlaceholderData } = query; const handleCutClick = React.useCallback(() => { @@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => { { BigNumber(data.gas_used || 0).toFormat() } - - { data.gas_target_percentage && ( - <> - - - - ) } { - const timeAgo = useTimeAgoIncrement(ts, isEnabled); - - return ( - - { timeAgo } - - ); -}; - -export default React.memo(chakra(BlockTimestamp)); diff --git a/ui/blocks/BlocksListItem.tsx b/ui/blocks/BlocksListItem.tsx index ea12fd5a1f..0d6d8ce58f 100644 --- a/ui/blocks/BlocksListItem.tsx +++ b/ui/blocks/BlocksListItem.tsx @@ -1,4 +1,4 @@ -import { Flex, Skeleton, Text, Box, useColorModeValue } from '@chakra-ui/react'; +import { Flex, Skeleton, Text, Box } from '@chakra-ui/react'; import BigNumber from 'bignumber.js'; import capitalize from 'lodash/capitalize'; import React from 'react'; @@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import { WEI } from 'lib/consts'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import { currencyUnits } from 'lib/units'; -import BlockTimestamp from 'ui/blocks/BlockTimestamp'; +import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; -import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; -import TextSeparator from 'ui/shared/TextSeparator'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import Utilization from 'ui/shared/Utilization/Utilization'; interface Props { @@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { const burntFees = BigNumber(data.burnt_fees || 0); const txFees = BigNumber(data.tx_fees || 0); - const separatorColor = useColorModeValue('gray.200', 'gray.700'); - return ( @@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { fontWeight={ 600 } /> - + Size @@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { { BigNumber(data.gas_used || 0).toFormat() } - - { data.gas_target_percentage && ( - <> - - - - ) } + { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( diff --git a/ui/blocks/BlocksTableItem.tsx b/ui/blocks/BlocksTableItem.tsx index 1e230e4788..8ded36820f 100644 --- a/ui/blocks/BlocksTableItem.tsx +++ b/ui/blocks/BlocksTableItem.tsx @@ -10,13 +10,12 @@ import { route } from 'nextjs-routes'; import config from 'configs/app'; import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import { WEI } from 'lib/consts'; -import BlockTimestamp from 'ui/blocks/BlockTimestamp'; +import BlockGasUsed from 'ui/shared/block/BlockGasUsed'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; -import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/links/LinkInternal'; -import TextSeparator from 'ui/shared/TextSeparator'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import Utilization from 'ui/shared/Utilization/Utilization'; interface Props { @@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { const burntFees = BigNumber(data.burnt_fees || 0); const txFees = BigNumber(data.tx_fees || 0); - const separatorColor = useColorModeValue('gray.200', 'gray.700'); const burntFeesIconColor = useColorModeValue('gray.500', 'inherit'); return ( @@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { /> - + @@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { { BigNumber(data.gas_used || 0).toFormat() } - - - - - - { data.gas_target_percentage && ( - <> - - - - ) } + { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( diff --git a/ui/deposits/optimisticL2/OptimisticDepositsListItem.tsx b/ui/deposits/optimisticL2/OptimisticDepositsListItem.tsx index fe8abd7a0a..94cdd9e2f9 100644 --- a/ui/deposits/optimisticL2/OptimisticDepositsListItem.tsx +++ b/ui/deposits/optimisticL2/OptimisticDepositsListItem.tsx @@ -5,20 +5,18 @@ import React from 'react'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.l1_block_timestamp).fromNow(); - if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; } @@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + L1 txn hash diff --git a/ui/deposits/optimisticL2/OptimisticDepositsTableItem.tsx b/ui/deposits/optimisticL2/OptimisticDepositsTableItem.tsx index af231b159f..2284839a18 100644 --- a/ui/deposits/optimisticL2/OptimisticDepositsTableItem.tsx +++ b/ui/deposits/optimisticL2/OptimisticDepositsTableItem.tsx @@ -5,18 +5,17 @@ import React from 'react'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.l1_block_timestamp).fromNow(); if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; @@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { /> - { timeAgo } + { - const timeAgo = dayjs(item.timestamp).fromNow(); - if (!(feature.isEnabled && feature.type === 'shibarium')) { return null; } @@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + diff --git a/ui/deposits/shibarium/DepositsTableItem.tsx b/ui/deposits/shibarium/DepositsTableItem.tsx index 7c38f52dd5..da23f68ecc 100644 --- a/ui/deposits/shibarium/DepositsTableItem.tsx +++ b/ui/deposits/shibarium/DepositsTableItem.tsx @@ -1,21 +1,20 @@ -import { Td, Tr, Skeleton } from '@chakra-ui/react'; +import { Td, Tr } from '@chakra-ui/react'; import React from 'react'; import type { ShibariumDepositsItem } from 'types/api/shibarium'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const feature = config.features.rollup; type Props = { item: ShibariumDepositsItem; isLoading?: boolean }; const DepositsTableItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.timestamp).fromNow(); if (!(feature.isEnabled && feature.type === 'shibarium')) { return null; @@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => { /> - { timeAgo } + ); diff --git a/ui/deposits/zkEvmL2/ZkEvmL2DepositsListItem.tsx b/ui/deposits/zkEvmL2/ZkEvmL2DepositsListItem.tsx index 578d04a628..4ec6b525f2 100644 --- a/ui/deposits/zkEvmL2/ZkEvmL2DepositsListItem.tsx +++ b/ui/deposits/zkEvmL2/ZkEvmL2DepositsListItem.tsx @@ -5,11 +5,11 @@ import React from 'react'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; @@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { return null; } - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( @@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + L2 txn hash diff --git a/ui/deposits/zkEvmL2/ZkEvmL2DepositsTableItem.tsx b/ui/deposits/zkEvmL2/ZkEvmL2DepositsTableItem.tsx index 37f57d9d66..962135741b 100644 --- a/ui/deposits/zkEvmL2/ZkEvmL2DepositsTableItem.tsx +++ b/ui/deposits/zkEvmL2/ZkEvmL2DepositsTableItem.tsx @@ -5,10 +5,10 @@ import React from 'react'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; @@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { return null; } - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( @@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { /> - - { timeAgo } - + { item.l2_transaction_hash ? ( diff --git a/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesListItem.tsx b/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesListItem.tsx index 3c6546de3b..27d83573c5 100644 --- a/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesListItem.tsx +++ b/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesListItem.tsx @@ -4,11 +4,11 @@ import React from 'react'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import HashStringShorten from 'ui/shared/HashStringShorten'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; @@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { Age - { dayjs(item.created_at).fromNow() } + Status @@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { { item.resolved_at && ( <> Resolution age - { dayjs(item.resolved_at).fromNow() } + ) } diff --git a/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesTableItem.tsx b/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesTableItem.tsx index 0de2ced695..dd55542b5d 100644 --- a/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesTableItem.tsx +++ b/ui/disputeGames/optimisticL2/OptimisticL2DisputeGamesTableItem.tsx @@ -4,10 +4,10 @@ import React from 'react'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import HashStringShorten from 'ui/shared/HashStringShorten'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const faultProofSystemFeature = config.features.faultProofSystem; @@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => { /> - { dayjs(item.created_at).fromNow() } + { item.status } - - { item.resolved_at ? dayjs(item.resolved_at).fromNow() : 'N/A' } - + ); diff --git a/ui/home/LatestBlocksItem.tsx b/ui/home/LatestBlocksItem.tsx index 3f2a1fb31c..72b428d93c 100644 --- a/ui/home/LatestBlocksItem.tsx +++ b/ui/home/LatestBlocksItem.tsx @@ -12,9 +12,9 @@ import type { Block } from 'types/api/block'; import config from 'configs/app'; import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; -import BlockTimestamp from 'ui/blocks/BlockTimestamp'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = { block: Block; @@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => { fontWeight={ 500 } mr="auto" /> - { - const timeAgo = dayjs(item.l1_block_timestamp).fromNow(); const isMobile = useIsMobile(); if (!feature.isEnabled || feature.type !== 'optimistic') { @@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { <> { l1BlockLink } - - { timeAgo } - + @@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { L1 txn { l1TxLink } - - { timeAgo } - + L2 txn diff --git a/ui/home/LatestTxsItem.tsx b/ui/home/LatestTxsItem.tsx index c556b24f22..7fcc367f8f 100644 --- a/ui/home/LatestTxsItem.tsx +++ b/ui/home/LatestTxsItem.tsx @@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction'; import config from 'configs/app'; import getValueWithUnit from 'lib/getValueWithUnit'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { currencyUnits } from 'lib/units'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxStatus from 'ui/shared/statusTag/TxStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TxFee from 'ui/shared/tx/TxFee'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; @@ -29,7 +29,6 @@ type Props = { const LatestTxsItem = ({ tx, isLoading }: Props) => { const dataTo = tx.to ? tx.to : tx.created_contract; - const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true); const columnNum = config.UI.views.tx.hiddenFields?.value && config.UI.views.tx.hiddenFields?.tx_fee ? 2 : 3; return ( @@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { hash={ tx.hash } fontWeight="700" /> - { tx.timestamp && ( - - { timeAgo } - - ) } + diff --git a/ui/home/LatestTxsItemMobile.tsx b/ui/home/LatestTxsItemMobile.tsx index 323ccafa48..e3d88bd9d0 100644 --- a/ui/home/LatestTxsItemMobile.tsx +++ b/ui/home/LatestTxsItemMobile.tsx @@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction'; import config from 'configs/app'; import getValueWithUnit from 'lib/getValueWithUnit'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { currencyUnits } from 'lib/units'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxStatus from 'ui/shared/statusTag/TxStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TxFee from 'ui/shared/tx/TxFee'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; @@ -28,7 +28,6 @@ type Props = { const LatestTxsItem = ({ tx, isLoading }: Props) => { const dataTo = tx.to ? tx.to : tx.created_contract; - const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true); return ( { fontWeight="700" truncation="constant_long" /> - { tx.timestamp && ( - - { timeAgo } - - ) } + { fontWeight={ 500 } mr="auto" /> - { await mockApiResponse('stats', statsMock.noChartData); await mockApiResponse('stats_charts_txs', dailyTxsMock.noData); - await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); + await mockAssetResponse(statsMock.noChartData.coin_image as string, './playwright/mocks/image_s.jpg'); const component = await render(); await expect(component).toHaveScreenshot(); diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png index 5d23631570..d6c68c11aa 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png differ diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png index 587d663315..afb024abc0 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png differ diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png index fba7f1d6e5..bbd552cddf 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png differ diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png index dabebb47a0..4fd9099e3e 100644 Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png differ diff --git a/ui/marketplace/AppSecurityReport.tsx b/ui/marketplace/AppSecurityReport.tsx index badbf181a3..740a7f09ae 100644 --- a/ui/marketplace/AppSecurityReport.tsx +++ b/ui/marketplace/AppSecurityReport.tsx @@ -72,7 +72,7 @@ const AppSecurityReport = ({ className={ className } /> - + Smart contracts info diff --git a/ui/marketplace/Banner/FeaturedApp.tsx b/ui/marketplace/Banner/FeaturedApp.tsx index 02913e4806..72dd6f49cd 100644 --- a/ui/marketplace/Banner/FeaturedApp.tsx +++ b/ui/marketplace/Banner/FeaturedApp.tsx @@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace'; import useIsMobile from 'lib/hooks/useIsMobile'; import * as mixpanel from 'lib/mixpanel/index'; -import IconSvg from 'ui/shared/IconSvg'; +import FavoriteIcon from '../FavoriteIcon'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; import FeaturedAppMobile from './FeaturedAppMobile'; @@ -136,10 +136,7 @@ const FeaturedApp = ({ w={ 9 } h={ 8 } onClick={ handleFavoriteClick } - icon={ isFavorite ? - : - - } + icon={ } /> ) } diff --git a/ui/marketplace/Banner/FeaturedAppMobile.tsx b/ui/marketplace/Banner/FeaturedAppMobile.tsx index f4fb2f31d5..5f7e8c7b4b 100644 --- a/ui/marketplace/Banner/FeaturedAppMobile.tsx +++ b/ui/marketplace/Banner/FeaturedAppMobile.tsx @@ -4,8 +4,7 @@ import React from 'react'; import type { MarketplaceAppPreview } from 'types/client/marketplace'; -import IconSvg from 'ui/shared/IconSvg'; - +import FavoriteIcon from '../FavoriteIcon'; import MarketplaceAppCardLink from '../MarketplaceAppCardLink'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; @@ -144,10 +143,7 @@ const FeaturedAppMobile = ({ w={ 9 } h={ 8 } onClick={ onFavoriteClick } - icon={ isFavorite ? - : - - } + icon={ } /> ) } diff --git a/ui/marketplace/EmptySearchResult.tsx b/ui/marketplace/EmptySearchResult.tsx index 7d3185019d..171a68cc78 100644 --- a/ui/marketplace/EmptySearchResult.tsx +++ b/ui/marketplace/EmptySearchResult.tsx @@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( <> You don{ apos }t have any favorite apps.
- Click on the icon on the app{ apos }s card to add it to Favorites. + Click on the icon on the app{ apos }s card to add it to Favorites. ) : ( <> diff --git a/ui/marketplace/FavoriteIcon.tsx b/ui/marketplace/FavoriteIcon.tsx new file mode 100644 index 0000000000..e2b589b98d --- /dev/null +++ b/ui/marketplace/FavoriteIcon.tsx @@ -0,0 +1,24 @@ +import { useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import IconSvg from 'ui/shared/IconSvg'; + +type Props = { + isFavorite: boolean; + color?: string; +} + +const FavoriteIcon = ({ isFavorite, color }: Props) => { + const heartFilledColor = useColorModeValue('blue.700', 'gray.400'); + const defaultColor = isFavorite ? heartFilledColor : 'gray.400'; + + return ( + + ); +}; + +export default FavoriteIcon; diff --git a/ui/marketplace/MarketplaceAppCard.tsx b/ui/marketplace/MarketplaceAppCard.tsx index 7db60d3ec4..834255eff6 100644 --- a/ui/marketplace/MarketplaceAppCard.tsx +++ b/ui/marketplace/MarketplaceAppCard.tsx @@ -1,15 +1,17 @@ -import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react'; +import { IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react'; import type { MouseEvent } from 'react'; import React, { useCallback } from 'react'; -import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace'; +import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace'; import useIsMobile from 'lib/hooks/useIsMobile'; -import IconSvg from 'ui/shared/IconSvg'; import AppSecurityReport from './AppSecurityReport'; +import FavoriteIcon from './FavoriteIcon'; import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon'; +import Rating from './Rating/Rating'; +import type { RateFunction } from './Rating/useRatings'; interface Props extends MarketplaceAppWithSecurityReport { onInfoClick: (id: string) => void; @@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport { onAppClick: (event: MouseEvent, id: string) => void; className?: string; showContractList: (id: string, type: ContractListTypes) => void; + userRating?: AppRating; + rateApp: RateFunction; + isRatingSending: boolean; + isRatingLoading: boolean; + canRate: boolean | undefined; } const MarketplaceAppCard = ({ @@ -39,6 +46,12 @@ const MarketplaceAppCard = ({ securityReport, className, showContractList, + rating, + userRating, + rateApp, + isRatingSending, + isRatingLoading, + canRate, }: Props) => { const isMobile = useIsMobile(); const categoriesLabel = categories.join(', '); @@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
{ !isLoading && ( - More info - : - - } - /> - + + + } + /> + + ) } { securityReport && ( diff --git a/ui/marketplace/MarketplaceAppIntegrationIcon.tsx b/ui/marketplace/MarketplaceAppIntegrationIcon.tsx index 07d2dd14ff..378c7c0c95 100644 --- a/ui/marketplace/MarketplaceAppIntegrationIcon.tsx +++ b/ui/marketplace/MarketplaceAppIntegrationIcon.tsx @@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => { textAlign="center" padding={ 2 } openDelay={ 300 } - maxW={ 400 } + maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }} > {}, + isRatingSending: false, + isRatingLoading: false, + canRate: undefined, }; -const testFn: Parameters[1] = async({ render, page, mockAssetResponse }) => { +const testFn: Parameters[1] = async({ render, page, mockAssetResponse, mockEnvs }) => { + await mockEnvs([ + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ], + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ], + ]); await mockAssetResponse(appsMock[0].logo, './playwright/mocks/image_s.jpg'); await render(); + await page.getByText('Launch app').focus(); await expect(page).toHaveScreenshot(); }; diff --git a/ui/marketplace/MarketplaceAppModal.tsx b/ui/marketplace/MarketplaceAppModal.tsx index 03e3885f45..0eb9906480 100644 --- a/ui/marketplace/MarketplaceAppModal.tsx +++ b/ui/marketplace/MarketplaceAppModal.tsx @@ -4,9 +4,10 @@ import { } from '@chakra-ui/react'; import React, { useCallback } from 'react'; -import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; +import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace'; import { ContractListTypes } from 'types/client/marketplace'; +import config from 'configs/app'; import useIsMobile from 'lib/hooks/useIsMobile'; import { nbsp } from 'lib/html-entities'; import * as mixpanel from 'lib/mixpanel/index'; @@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg'; import AppSecurityReport from './AppSecurityReport'; +import FavoriteIcon from './FavoriteIcon'; import MarketplaceAppModalLink from './MarketplaceAppModalLink'; +import Rating from './Rating/Rating'; +import type { RateFunction } from './Rating/useRatings'; + +const feature = config.features.marketplace; +const isRatingEnabled = feature.isEnabled && feature.rating; type Props = { onClose: () => void; @@ -22,6 +29,11 @@ type Props = { onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void; data: MarketplaceAppWithSecurityReport; showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void; + userRating?: AppRating; + rateApp: RateFunction; + isRatingSending: boolean; + isRatingLoading: boolean; + canRate: boolean | undefined; } const MarketplaceAppModal = ({ @@ -30,9 +42,12 @@ const MarketplaceAppModal = ({ onFavoriteClick, data, showContractList: showContractListProp, + userRating, + rateApp, + isRatingSending, + isRatingLoading, + canRate, }: Props) => { - const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300'); - const { id, title, @@ -49,6 +64,7 @@ const MarketplaceAppModal = ({ logoDarkMode, categories, securityReport, + rating, } = data; const socialLinks = [ @@ -119,7 +135,7 @@ const MarketplaceAppModal = ({ w={{ base: '72px', md: '144px' }} h={{ base: '72px', md: '144px' }} marginRight={{ base: 6, md: 8 }} - gridRow={{ base: '1 / 3', md: '1 / 4' }} + gridRow={{ base: '1 / 3', md: '1 / 5' }} > { title } @@ -142,16 +158,37 @@ const MarketplaceAppModal = ({ By{ nbsp }{ author } + { isRatingEnabled && ( + + + + ) } + @@ -170,9 +207,7 @@ const MarketplaceAppModal = ({ w={ 9 } h={ 8 } onClick={ handleFavoriteClick } - icon={ isFavorite ? - : - } + icon={ } /> diff --git a/ui/marketplace/MarketplaceAppTopBar.tsx b/ui/marketplace/MarketplaceAppTopBar.tsx index f7e5235f2d..5cfc939208 100644 --- a/ui/marketplace/MarketplaceAppTopBar.tsx +++ b/ui/marketplace/MarketplaceAppTopBar.tsx @@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop'; import AppSecurityReport from './AppSecurityReport'; import ContractListModal from './ContractListModal'; import MarketplaceAppInfo from './MarketplaceAppInfo'; +import Rating from './Rating/Rating'; +import useRatings from './Rating/useRatings'; type Props = { + appId: string; data: MarketplaceAppOverview | undefined; isLoading: boolean; securityReport?: MarketplaceAppSecurityReport; } -const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { +const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) => { const [ contractListType, setContractListType ] = React.useState(); const appProps = useAppContext(); const isMobile = useIsMobile(); + const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings(); + const goBackUrl = React.useMemo(() => { if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) { return appProps.referrer; @@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { source="App page" /> ) } + { !isMobile && ( { config.features.account.isEnabled && } diff --git a/ui/marketplace/MarketplaceList.tsx b/ui/marketplace/MarketplaceList.tsx index b64f6c9618..19cf53722b 100644 --- a/ui/marketplace/MarketplaceList.tsx +++ b/ui/marketplace/MarketplaceList.tsx @@ -1,13 +1,15 @@ -import { Grid } from '@chakra-ui/react'; +import { Grid, Box } from '@chakra-ui/react'; import React, { useCallback } from 'react'; import type { MouseEvent } from 'react'; -import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace'; +import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace'; +import useLazyRenderedList from 'lib/hooks/useLazyRenderedList'; import * as mixpanel from 'lib/mixpanel/index'; import EmptySearchResult from './EmptySearchResult'; import MarketplaceAppCard from './MarketplaceAppCard'; +import type { RateFunction } from './Rating/useRatings'; type Props = { apps: Array; @@ -18,9 +20,19 @@ type Props = { selectedCategoryId?: string; onAppClick: (event: MouseEvent, id: string) => void; showContractList: (id: string, type: ContractListTypes) => void; + userRatings: Record; + rateApp: RateFunction; + isRatingSending: boolean; + isRatingLoading: boolean; + canRate: boolean | undefined; } -const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick, showContractList }: Props) => { +const MarketplaceList = ({ + apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, + onAppClick, showContractList, userRatings, rateApp, isRatingSending, isRatingLoading, canRate, +}: Props) => { + const { cutRef, renderedItemsNum } = useLazyRenderedList(apps, !isLoading, 16); + const handleInfoClick = useCallback((id: string) => { mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' }); showAppInfo(id); @@ -31,37 +43,46 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL }, [ onFavoriteClick ]); return apps.length > 0 ? ( - - { apps.map((app, index) => ( - - )) } - + <> + + { apps.slice(0, renderedItemsNum).map((app, index) => ( + + )) } + + + ) : ( ); diff --git a/ui/marketplace/Rating/PopoverContent.tsx b/ui/marketplace/Rating/PopoverContent.tsx new file mode 100644 index 0000000000..d86d31179d --- /dev/null +++ b/ui/marketplace/Rating/PopoverContent.tsx @@ -0,0 +1,81 @@ +import { Text, Flex, Spinner } from '@chakra-ui/react'; +import React from 'react'; + +import type { AppRating } from 'types/client/marketplace'; + +import type { EventTypes, EventPayload } from 'lib/mixpanel/index'; +import IconSvg from 'ui/shared/IconSvg'; + +import Stars from './Stars'; +import type { RateFunction } from './useRatings'; + +const ratingDescriptions = [ 'Very bad', 'Bad', 'Average', 'Good', 'Excellent' ]; + +type Props = { + appId: string; + rating?: AppRating; + userRating?: AppRating; + rate: RateFunction; + isSending?: boolean; + source: EventPayload['Source']; +}; + +const PopoverContent = ({ appId, rating, userRating, rate, isSending, source }: Props) => { + const [ hovered, setHovered ] = React.useState(-1); + + const filledIndex = React.useMemo(() => { + if (hovered >= 0) { + return hovered; + } + return userRating?.value ? userRating?.value - 1 : -1; + }, [ userRating, hovered ]); + + const handleMouseOverFactory = React.useCallback((index: number) => () => { + setHovered(index); + }, []); + + const handleMouseOut = React.useCallback(() => { + setHovered(-1); + }, []); + + const handleRateFactory = React.useCallback((index: number) => () => { + rate(appId, rating?.recordId, userRating?.recordId, index + 1, source); + }, [ appId, rating, rate, userRating, source ]); + + if (isSending) { + return ( + + + Sending your feedback + + ); + } + + return ( + <> + + { userRating && ( + + ) } + + { userRating ? 'App is already rated by you' : 'How was your experience?' } + + + + + { (filledIndex >= 0) && ( + + { ratingDescriptions[filledIndex] } + + ) } + + + ); +}; + +export default PopoverContent; diff --git a/ui/marketplace/Rating/Rating.tsx b/ui/marketplace/Rating/Rating.tsx new file mode 100644 index 0000000000..21d5bcf9b4 --- /dev/null +++ b/ui/marketplace/Rating/Rating.tsx @@ -0,0 +1,85 @@ +import { Text, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Skeleton, useOutsideClick, Box } from '@chakra-ui/react'; +import React from 'react'; + +import type { AppRating } from 'types/client/marketplace'; + +import config from 'configs/app'; +import type { EventTypes, EventPayload } from 'lib/mixpanel/index'; +import Popover from 'ui/shared/chakra/Popover'; + +import Content from './PopoverContent'; +import Stars from './Stars'; +import TriggerButton from './TriggerButton'; +import type { RateFunction } from './useRatings'; + +const feature = config.features.marketplace; +const isEnabled = feature.isEnabled && feature.rating; + +type Props = { + appId: string; + rating?: AppRating; + userRating?: AppRating; + rate: RateFunction; + isSending?: boolean; + isLoading?: boolean; + fullView?: boolean; + canRate: boolean | undefined; + source: EventPayload['Source']; +}; + +const Rating = ({ + appId, rating, userRating, rate, + isSending, isLoading, fullView, canRate, source, +}: Props) => { + const { isOpen, onToggle, onClose } = useDisclosure(); + // have to implement this solution because popover loses focus on button click inside it (issue: https://github.com/chakra-ui/chakra-ui/issues/7359) + const popoverRef = React.useRef(null); + useOutsideClick({ ref: popoverRef, handler: onClose }); + + if (!isEnabled) { + return null; + } + + return ( + + { fullView && ( + <> + + { rating?.value } + + ) } + + + + + + + + + + + + + + ); +}; + +export default Rating; diff --git a/ui/marketplace/Rating/Stars.tsx b/ui/marketplace/Rating/Stars.tsx new file mode 100644 index 0000000000..ca2e8d0be4 --- /dev/null +++ b/ui/marketplace/Rating/Stars.tsx @@ -0,0 +1,38 @@ +import { Flex, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; +import type { MouseEventHandler } from 'react'; + +import IconSvg from 'ui/shared/IconSvg'; + +type Props = { + filledIndex: number; + onMouseOverFactory?: (index: number) => MouseEventHandler; + onMouseOut?: () => void; + onClickFactory?: (index: number) => MouseEventHandler; +}; + +const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: Props) => { + const disabledStarColor = useColorModeValue('gray.200', 'gray.700'); + const outlineStartColor = onMouseOverFactory ? 'gray.400' : disabledStarColor; + return ( + + { Array(5).fill(null).map((_, index) => ( + = index ? 'star_filled' : 'star_outline' } + color={ filledIndex >= index ? 'yellow.400' : outlineStartColor } + w={ 6 } // 5 + 1 padding + h={ 5 } + pr={ 1 } // use padding intead of margin so that there are no empty spaces between stars without hover effect + _last={{ w: 5, pr: 0 }} + cursor={ onMouseOverFactory ? 'pointer' : 'default' } + onMouseOver={ onMouseOverFactory?.(index) } + onMouseOut={ onMouseOut } + onClick={ onClickFactory?.(index) } + /> + )) } + + ); +}; + +export default Stars; diff --git a/ui/marketplace/Rating/TriggerButton.tsx b/ui/marketplace/Rating/TriggerButton.tsx new file mode 100644 index 0000000000..309447a1b8 --- /dev/null +++ b/ui/marketplace/Rating/TriggerButton.tsx @@ -0,0 +1,89 @@ +import { Button, chakra, useColorModeValue, Tooltip, useDisclosure } from '@chakra-ui/react'; +import React from 'react'; + +import useIsMobile from 'lib/hooks/useIsMobile'; +import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing'; +import IconSvg from 'ui/shared/IconSvg'; + +type Props = { + rating?: number; + fullView?: boolean; + isActive: boolean; + onClick: () => void; + canRate: boolean | undefined; +}; + +const getTooltipText = (canRate: boolean | undefined) => { + if (canRate === undefined) { + return <>Please connect your wallet to Blockscout to rate this DApp.
Only wallets with 5+ transactions are eligible; + } + if (!canRate) { + return <>Brand new wallets cannot leave ratings.
Please connect a wallet with 5 or more transactions on this chain; + } + return <>Ratings come from verified users.
Click here to rate!; +}; + +const TriggerButton = ( + { rating, fullView, isActive, onClick, canRate }: Props, + ref: React.ForwardedRef, +) => { + const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800'); + const onFocusCapture = usePreventFocusAfterModalClosing(); + + // have to implement controlled tooltip on mobile because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107 + const { isOpen, onToggle, onClose } = useDisclosure(); + const isMobile = useIsMobile(); + + const handleClick = React.useCallback(() => { + if (canRate) { + onClick(); + } else if (isMobile) { + onToggle(); + } + }, [ canRate, isMobile, onToggle, onClick ]); + + return ( + + + + ); +}; + +export default React.forwardRef(TriggerButton); diff --git a/ui/marketplace/Rating/useRatings.test.tsx b/ui/marketplace/Rating/useRatings.test.tsx new file mode 100644 index 0000000000..ee8d71695d --- /dev/null +++ b/ui/marketplace/Rating/useRatings.test.tsx @@ -0,0 +1,74 @@ +import { renderHook, wrapper } from 'jest/lib'; + +import useRatings from './useRatings'; + +const useAccount = jest.fn(); +const useApiQuery = jest.fn(); + +jest.mock('lib/hooks/useToast', () => jest.fn()); +jest.mock('wagmi', () => ({ useAccount: () => useAccount() })); +jest.mock('lib/api/useApiQuery', () => () => useApiQuery()); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should set canRate to true if address is defined and transactions_count is 5 or more', async() => { + useAccount.mockReturnValue({ address: '0x123' }); + useApiQuery.mockReturnValue({ + isPlaceholderData: false, + data: { transactions_count: 5 }, + }); + const { result } = renderHook(() => useRatings(), { wrapper }); + expect(result.current.canRate).toBe(true); +}); + +it('should set canRate to undefined if address is undefined', async() => { + useAccount.mockReturnValue({ address: undefined }); + useApiQuery.mockReturnValue({ + isPlaceholderData: false, + data: { transactions_count: 5 }, + }); + const { result } = renderHook(() => useRatings(), { wrapper }); + expect(result.current.canRate).toBe(undefined); +}); + +it('should set canRate to false if transactions_count is less than 5', async() => { + useAccount.mockReturnValue({ address: '0x123' }); + useApiQuery.mockReturnValue({ + isPlaceholderData: false, + data: { transactions_count: 4 }, + }); + const { result } = renderHook(() => useRatings(), { wrapper }); + expect(result.current.canRate).toBe(false); +}); + +it('should set canRate to false if isPlaceholderData is true', async() => { + useAccount.mockReturnValue({ address: '0x123' }); + useApiQuery.mockReturnValue({ + isPlaceholderData: true, + data: { transactions_count: 5 }, + }); + const { result } = renderHook(() => useRatings(), { wrapper }); + expect(result.current.canRate).toBe(false); +}); + +it('should set canRate to false if data is undefined', async() => { + useAccount.mockReturnValue({ address: '0x123' }); + useApiQuery.mockReturnValue({ + isPlaceholderData: false, + data: undefined, + }); + const { result } = renderHook(() => useRatings()); + expect(result.current.canRate).toBe(false); +}); + +it('should set canRate to false if transactions_count is undefined', async() => { + useAccount.mockReturnValue({ address: '0x123' }); + useApiQuery.mockReturnValue({ + isPlaceholderData: false, + data: {}, + }); + const { result } = renderHook(() => useRatings()); + expect(result.current.canRate).toBe(false); +}); diff --git a/ui/marketplace/Rating/useRatings.tsx b/ui/marketplace/Rating/useRatings.tsx new file mode 100644 index 0000000000..7c4ff1aa9f --- /dev/null +++ b/ui/marketplace/Rating/useRatings.tsx @@ -0,0 +1,193 @@ +import Airtable from 'airtable'; +import { useEffect, useState, useCallback } from 'react'; +import { useAccount } from 'wagmi'; + +import type { AppRating } from 'types/client/marketplace'; + +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; +import useToast from 'lib/hooks/useToast'; +import type { EventTypes, EventPayload } from 'lib/mixpanel/index'; +import * as mixpanel from 'lib/mixpanel/index'; +import { ADDRESS_COUNTERS } from 'stubs/address'; + +const MIN_TRANSACTION_COUNT = 5; + +const feature = config.features.marketplace; +const airtable = (feature.isEnabled && feature.rating) ? + new Airtable({ apiKey: feature.rating.airtableApiKey }).base(feature.rating.airtableBaseId) : + undefined; + +export type RateFunction = ( + appId: string, + appRecordId: string | undefined, + userRecordId: string | undefined, + rating: number, + source: EventPayload['Source'], +) => void; + +function formatRatings(data: Airtable.Records) { + return data.reduce((acc: Record, record) => { + const fields = record.fields as { appId: string | Array; rating: number | undefined }; + const appId = Array.isArray(fields.appId) ? fields.appId[0] : fields.appId; + acc[appId] = { + recordId: record.id, + value: fields.rating, + }; + return acc; + }, {}); +} + +export default function useRatings() { + const { address } = useAccount(); + const toast = useToast(); + + const addressCountersQuery = useApiQuery<'address_counters', { status: number }>('address_counters', { + pathParams: { hash: address }, + queryOptions: { + enabled: Boolean(address), + placeholderData: ADDRESS_COUNTERS, + refetchOnMount: false, + }, + }); + + const [ ratings, setRatings ] = useState>({}); + const [ userRatings, setUserRatings ] = useState>({}); + const [ isRatingLoading, setIsRatingLoading ] = useState(false); + const [ isUserRatingLoading, setIsUserRatingLoading ] = useState(false); + const [ isSending, setIsSending ] = useState(false); + const [ canRate, setCanRate ] = useState(undefined); + + const fetchRatings = useCallback(async() => { + if (!airtable) { + return; + } + try { + const data = await airtable('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all(); + const ratings = formatRatings(data); + setRatings(ratings); + } catch (error) { + toast({ + status: 'error', + title: 'Error loading ratings', + description: 'Please try again later', + }); + } + }, [ toast ]); + + useEffect(() => { + async function fetch() { + setIsRatingLoading(true); + await fetchRatings(); + setIsRatingLoading(false); + } + fetch(); + }, [ fetchRatings ]); + + useEffect(() => { + async function fetchUserRatings() { + setIsUserRatingLoading(true); + let userRatings = {} as Record; + if (address && airtable) { + try { + const data = await airtable('users_ratings').select({ + filterByFormula: `address = "${ address }"`, + fields: [ 'appId', 'rating' ], + }).all(); + userRatings = formatRatings(data); + } catch (error) { + toast({ + status: 'error', + title: 'Error loading user ratings', + description: 'Please try again later', + }); + } + } + setUserRatings(userRatings); + setIsUserRatingLoading(false); + } + fetchUserRatings(); + }, [ address, toast ]); + + useEffect(() => { + const { isPlaceholderData, data } = addressCountersQuery; + const canRate = address && !isPlaceholderData && Number(data?.transactions_count) >= MIN_TRANSACTION_COUNT; + setCanRate(canRate); + }, [ address, addressCountersQuery ]); + + const rateApp = useCallback(async( + appId: string, + appRecordId: string | undefined, + userRecordId: string | undefined, + rating: number, + source: EventPayload['Source'], + ) => { + setIsSending(true); + + try { + if (!address || !airtable) { + throw new Error('Address is missing'); + } + + if (!appRecordId) { + const records = await airtable('apps_ratings').create([ { fields: { appId } } ]); + appRecordId = records[0].id; + if (!appRecordId) { + throw new Error('Record ID is missing'); + } + } + + if (!userRecordId) { + const userRecords = await airtable('users_ratings').create([ + { + fields: { + address, + appRecordId: [ appRecordId ], + rating, + }, + }, + ]); + userRecordId = userRecords[0].id; + } else { + await airtable('users_ratings').update(userRecordId, { rating }); + } + + setUserRatings({ + ...userRatings, + [appId]: { + recordId: userRecordId, + value: rating, + }, + }); + fetchRatings(); + + toast({ + status: 'success', + title: 'Awesome! Thank you 💜', + description: 'Your rating improves the service', + }); + mixpanel.logEvent( + mixpanel.EventTypes.APP_FEEDBACK, + { Action: 'Rating', Source: source, AppId: appId, Score: rating }, + ); + } catch (error) { + toast({ + status: 'error', + title: 'Ooops! Something went wrong', + description: 'Please try again later', + }); + } + + setIsSending(false); + }, [ address, userRatings, fetchRatings, toast ]); + + return { + ratings, + userRatings, + rateApp, + isRatingSending: isSending, + isRatingLoading, + isUserRatingLoading, + canRate, + }; +} diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png index df220dabd8..e917e49cca 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_dark-color-mode_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png index f2e12cad51..b714324fa3 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_base-view-dark-mode-1.png differ diff --git a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png index 925173b47b..11b310da3c 100644 Binary files a/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png and b/ui/marketplace/__screenshots__/MarketplaceAppModal.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/marketplace/useMarketplace.tsx b/ui/marketplace/useMarketplace.tsx index c7db6567ab..8e4b793aa1 100644 --- a/ui/marketplace/useMarketplace.tsx +++ b/ui/marketplace/useMarketplace.tsx @@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce'; import * as mixpanel from 'lib/mixpanel/index'; import getQueryParamString from 'lib/router/getQueryParamString'; +import useRatings from './Rating/useRatings'; import useMarketplaceApps from './useMarketplaceApps'; import useMarketplaceCategories from './useMarketplaceCategories'; @@ -85,9 +86,10 @@ export default function useMarketplace() { setSelectedCategoryId(newCategory); }, []); + const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings(); const { isPlaceholderData, isError, error, data, displayedApps, setSorting, - } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded); + } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded, ratings); const { isPlaceholderData: isCategoriesPlaceholderData, data: categories, } = useMarketplaceCategories(data, isPlaceholderData); @@ -151,6 +153,11 @@ export default function useMarketplace() { contractListModalType, hasPreviousStep, setSorting, + userRatings, + rateApp, + isRatingSending, + isRatingLoading, + canRate, }), [ selectedCategoryId, categories, @@ -174,5 +181,10 @@ export default function useMarketplace() { contractListModalType, hasPreviousStep, setSorting, + userRatings, + rateApp, + isRatingSending, + isRatingLoading, + canRate, ]); } diff --git a/ui/marketplace/useMarketplaceApps.tsx b/ui/marketplace/useMarketplaceApps.tsx index f3c952b539..7d6730046c 100644 --- a/ui/marketplace/useMarketplaceApps.tsx +++ b/ui/marketplace/useMarketplaceApps.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; +import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace'; import config from 'configs/app'; @@ -55,6 +55,7 @@ export default function useMarketplaceApps( selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array | undefined = undefined, isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types + ratings: Record | undefined = undefined, ) { const fetch = useFetch(); const apiFetch = useApiFetch(); @@ -91,20 +92,27 @@ export default function useMarketplaceApps( const [ sorting, setSorting ] = React.useState(); - const appsWithSecurityReports = React.useMemo(() => - data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })), - [ data, securityReports ]); + const appsWithSecurityReportsAndRating = React.useMemo(() => + data?.map((app) => ({ + ...app, + securityReport: securityReports?.[app.id], + rating: ratings?.[app.id], + })), + [ data, securityReports, ratings ]); const displayedApps = React.useMemo(() => { - return appsWithSecurityReports + return appsWithSecurityReportsAndRating ?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) .sort((a, b) => { if (sorting === 'security_score') { return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0); } + if (sorting === 'rating') { + return (b.rating?.value || 0) - (a.rating?.value || 0); + } return 0; }) || []; - }, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]); + }, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]); return React.useMemo(() => ({ data, diff --git a/ui/marketplace/utils.ts b/ui/marketplace/utils.ts index 09cb0ec28f..3f0fb9538b 100644 --- a/ui/marketplace/utils.ts +++ b/ui/marketplace/utils.ts @@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString'; import removeQueryParam from 'lib/router/removeQueryParam'; import type { TOption } from 'ui/shared/sort/Option'; -export type SortValue = 'security_score'; +export type SortValue = 'rating' | 'security_score'; export const SORT_OPTIONS: Array> = [ { title: 'Default', id: undefined }, + { title: 'Rating', id: 'rating' }, { title: 'Security score', id: 'security_score' }, ]; diff --git a/ui/messages/ArbitrumL2MessagesListItem.tsx b/ui/messages/ArbitrumL2MessagesListItem.tsx index e00543b440..422366dac0 100644 --- a/ui/messages/ArbitrumL2MessagesListItem.tsx +++ b/ui/messages/ArbitrumL2MessagesListItem.tsx @@ -4,13 +4,13 @@ import React from 'react'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import type { MessagesDirection } from './ArbitrumL2Messages'; @@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { return null; } - const timeAgo = dayjs(item.origination_timestamp).fromNow(); - const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash; @@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { Age - { timeAgo } + Status diff --git a/ui/messages/ArbitrumL2MessagesTableItem.tsx b/ui/messages/ArbitrumL2MessagesTableItem.tsx index ed46084461..fbd4f13ef9 100644 --- a/ui/messages/ArbitrumL2MessagesTableItem.tsx +++ b/ui/messages/ArbitrumL2MessagesTableItem.tsx @@ -4,12 +4,12 @@ import React from 'react'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import type { MessagesDirection } from './ArbitrumL2Messages'; @@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { return null; } - const timeAgo = dayjs(item.origination_timestamp).fromNow(); - const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash; @@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ) } - - { timeAgo } - + diff --git a/ui/nameDomain/history/NameDomainHistoryListItem.tsx b/ui/nameDomain/history/NameDomainHistoryListItem.tsx index b14a3b65a3..ebc43e607e 100644 --- a/ui/nameDomain/history/NameDomainHistoryListItem.tsx +++ b/ui/nameDomain/history/NameDomainHistoryListItem.tsx @@ -1,4 +1,3 @@ -import { Skeleton } from '@chakra-ui/react'; import React from 'react'; import type * as bens from '@blockscout/bens-types'; @@ -6,12 +5,12 @@ import type * as bens from '@blockscout/bens-types'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import stripTrailingSlash from 'lib/stripTrailingSlash'; import Tag from 'ui/shared/chakra/Tag'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; interface Props { event: bens.DomainEvent; @@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => { Age - - { dayjs(event.timestamp).fromNow() } - + { event.from_address && ( diff --git a/ui/nameDomain/history/NameDomainHistoryTableItem.tsx b/ui/nameDomain/history/NameDomainHistoryTableItem.tsx index 41e2d76f16..cd287cc98d 100644 --- a/ui/nameDomain/history/NameDomainHistoryTableItem.tsx +++ b/ui/nameDomain/history/NameDomainHistoryTableItem.tsx @@ -1,4 +1,4 @@ -import { Tr, Td, Skeleton } from '@chakra-ui/react'; +import { Tr, Td } from '@chakra-ui/react'; import React from 'react'; import type * as bens from '@blockscout/bens-types'; @@ -6,11 +6,11 @@ import type * as bens from '@blockscout/bens-types'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import stripTrailingSlash from 'lib/stripTrailingSlash'; import Tag from 'ui/shared/chakra/Tag'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; interface Props { event: bens.DomainEvent; @@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => { /> - - { dayjs(event.timestamp).fromNow() } - + { event.from_address && } diff --git a/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsListItem.tsx b/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsListItem.tsx index 2e61672773..76140ce1f6 100644 --- a/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsListItem.tsx +++ b/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsListItem.tsx @@ -4,20 +4,18 @@ import React from 'react'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import HashStringShorten from 'ui/shared/HashStringShorten'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.l1_timestamp).fromNow(); - if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; } @@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { Age - - { timeAgo } - + L2 block # diff --git a/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsTableItem.tsx b/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsTableItem.tsx index 6a2e6d9fb5..25fb2d7da8 100644 --- a/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsTableItem.tsx +++ b/ui/outputRoots/optimisticL2/OptimisticL2OutputRootsTableItem.tsx @@ -4,19 +4,17 @@ import React from 'react'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import HashStringShorten from 'ui/shared/HashStringShorten'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.l1_timestamp).fromNow(); - if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; } @@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { { item.l2_output_index } - { timeAgo } + { +test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page }) => { await mockEnvs([ [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ], + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ], + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ], ]); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg'))); + await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({ + status: 200, + body: JSON.stringify(ratingsMock), + })); }); test('base view +@dark-mode', async({ render }) => { diff --git a/ui/pages/Marketplace.tsx b/ui/pages/Marketplace.tsx index a6e9c6f98d..aeab726593 100644 --- a/ui/pages/Marketplace.tsx +++ b/ui/pages/Marketplace.tsx @@ -71,6 +71,11 @@ const Marketplace = () => { contractListModalType, hasPreviousStep, setSorting, + userRatings, + rateApp, + isRatingSending, + isRatingLoading, + canRate, } = useMarketplace(); const isMobile = useIsMobile(); @@ -92,13 +97,13 @@ const Marketplace = () => { tabs.unshift({ id: MarketplaceCategory.FAVORITES, - title: () => , - count: null, + title: () => , + count: favoriteApps.length, component: null, }); return tabs; - }, [ categories, appsTotal ]); + }, [ categories, appsTotal, favoriteApps.length ]); const selectedCategoryIndex = React.useMemo(() => { const index = categoryTabs.findIndex(c => c.id === selectedCategoryId); @@ -224,6 +229,11 @@ const Marketplace = () => { selectedCategoryId={ selectedCategoryId } onAppClick={ handleAppClick } showContractList={ showContractList } + userRatings={ userRatings } + rateApp={ rateApp } + isRatingSending={ isRatingSending } + isRatingLoading={ isRatingLoading } + canRate={ canRate } /> { (selectedApp && isAppInfoModalOpen) && ( @@ -233,6 +243,11 @@ const Marketplace = () => { onFavoriteClick={ onFavoriteClick } data={ selectedApp } showContractList={ showContractList } + userRating={ userRatings[selectedApp.id] } + rateApp={ rateApp } + isRatingSending={ isRatingSending } + isRatingLoading={ isRatingLoading } + canRate={ canRate } /> ) } diff --git a/ui/pages/MarketplaceApp.pw.tsx b/ui/pages/MarketplaceApp.pw.tsx index 94b0811089..bebab02457 100644 --- a/ui/pages/MarketplaceApp.pw.tsx +++ b/ui/pages/MarketplaceApp.pw.tsx @@ -4,6 +4,8 @@ import { numberToHex } from 'viem'; import config from 'configs/app'; import { apps as appsMock } from 'mocks/apps/apps'; +import { ratings as ratingsMock } from 'mocks/apps/ratings'; +import { securityReports as securityReportsMock } from 'mocks/apps/securityReports'; import { test, expect, devices } from 'playwright/lib'; import MarketplaceApp from './MarketplaceApp'; @@ -16,18 +18,27 @@ const hooksConfig = { }; const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json'; +const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json'; -const testFn: Parameters[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => { +const testFn: Parameters[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => { await mockEnvs([ [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], + [ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ], + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ], + [ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ], ]); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); + await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await mockAssetResponse(appsMock[0].url, './mocks/apps/app.html'); await mockRpcResponse({ Method: 'eth_chainId', ReturnType: numberToHex(Number(config.chain.id)), }); + await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({ + status: 200, + body: JSON.stringify(ratingsMock), + })); const component = await render( diff --git a/ui/pages/MarketplaceApp.tsx b/ui/pages/MarketplaceApp.tsx index b1e1893785..78e14ca4e3 100644 --- a/ui/pages/MarketplaceApp.tsx +++ b/ui/pages/MarketplaceApp.tsx @@ -151,6 +151,7 @@ const MarketplaceApp = () => { return ( address ? [ address ] : [], [ address ]); const { data } = useAddressMetadataInfoQuery(memoizedArray, isEnabled); const metadata = data?.addresses[address?.toLowerCase()]; diff --git a/ui/shared/HashStringShortenDynamic.tsx b/ui/shared/HashStringShortenDynamic.tsx index 43a7bb79f8..cb1130662d 100644 --- a/ui/shared/HashStringShortenDynamic.tsx +++ b/ui/shared/HashStringShortenDynamic.tsx @@ -95,7 +95,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled, if (isTruncated) { return ( - { content } + { content } ); } diff --git a/ui/shared/Hint.tsx b/ui/shared/Hint.tsx index e7fa1f8590..5aac2b52e5 100644 --- a/ui/shared/Hint.tsx +++ b/ui/shared/Hint.tsx @@ -28,7 +28,7 @@ const Hint = ({ label, className, tooltipProps, isLoading }: Props) => { diff --git a/ui/shared/TimeAgoWithTooltip.tsx b/ui/shared/TimeAgoWithTooltip.tsx new file mode 100644 index 0000000000..a8e4c0d6d4 --- /dev/null +++ b/ui/shared/TimeAgoWithTooltip.tsx @@ -0,0 +1,32 @@ +import { Skeleton, Tooltip, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import dayjs from 'lib/date/dayjs'; +import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; + +type Props = { + timestamp?: string | null; + fallbackText?: string; + isLoading?: boolean; + enableIncrement?: boolean; + className?: string; +} + +const TimeAgoWithTooltip = ({ timestamp, fallbackText, isLoading, enableIncrement, className }: Props) => { + const timeAgo = useTimeAgoIncrement(timestamp || '', enableIncrement && !isLoading); + if (!timestamp && !fallbackText) { + return null; + } + + const content = timestamp ? + { timeAgo } : + { fallbackText }; + + return ( + + { content } + + ); +}; + +export default chakra(TimeAgoWithTooltip); diff --git a/ui/shared/TokenTransfer/TokenTransferListItem.tsx b/ui/shared/TokenTransfer/TokenTransferListItem.tsx index 1c0ba064a3..03977f0916 100644 --- a/ui/shared/TokenTransfer/TokenTransferListItem.tsx +++ b/ui/shared/TokenTransfer/TokenTransferListItem.tsx @@ -4,7 +4,6 @@ import React from 'react'; import type { TokenTransfer } from 'types/api/tokenTransfer'; import getCurrencyValue from 'lib/getCurrencyValue'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { getTokenTypeName } from 'lib/token/tokenTypes'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import Tag from 'ui/shared/chakra/Tag'; @@ -15,6 +14,8 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; +import TimeAgoWithTooltip from '../TimeAgoWithTooltip'; + type Props = TokenTransfer & { baseAddress?: string; showTxInfo?: boolean; @@ -35,7 +36,6 @@ const TokenTransferListItem = ({ enableTimeIncrement, isLoading, }: Props) => { - const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement); const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ value: total.value, exchangeRate: token.exchange_rate, @@ -71,11 +71,14 @@ const TokenTransferListItem = ({ truncation="constant_long" fontWeight="700" /> - { timestamp && ( - - { timeAgo } - - ) } + ) } { - const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement); const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ value: total.value, exchangeRate: token.exchange_rate, @@ -78,11 +78,15 @@ const TokenTransferTableItem = ({ mt="7px" truncation="constant_long" /> - { timestamp && ( - - { timeAgo } - - ) } + ) } diff --git a/ui/shared/TruncatedTextTooltip.tsx b/ui/shared/TruncatedTextTooltip.tsx index e1f333d5e1..eaf7dfdd66 100644 --- a/ui/shared/TruncatedTextTooltip.tsx +++ b/ui/shared/TruncatedTextTooltip.tsx @@ -79,7 +79,7 @@ const TruncatedTextTooltip = ({ children, label, placement }: Props) => { return ( diff --git a/ui/shared/block/BlockGasUsed.tsx b/ui/shared/block/BlockGasUsed.tsx new file mode 100644 index 0000000000..0c1ee6f416 --- /dev/null +++ b/ui/shared/block/BlockGasUsed.tsx @@ -0,0 +1,54 @@ +import { chakra, Tooltip, Box, useColorModeValue } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import config from 'configs/app'; + +import GasUsedToTargetRatio from '../GasUsedToTargetRatio'; +import TextSeparator from '../TextSeparator'; +import Utilization from '../Utilization/Utilization'; + +const rollupFeature = config.features.rollup; + +interface Props { + className?: string; + gasUsed?: string; + gasLimit: string; + gasTarget?: number; + isLoading?: boolean; +} + +const BlockGasUsed = ({ className, gasUsed, gasLimit, gasTarget, isLoading }: Props) => { + const hasGasUtilization = + gasUsed && gasUsed !== '0' && + (!rollupFeature.isEnabled || rollupFeature.type === 'optimistic' || rollupFeature.type === 'shibarium'); + + const separatorColor = useColorModeValue('gray.200', 'gray.700'); + + if (!hasGasUtilization) { + return null; + } + + return ( + <> + + + + + + { gasTarget && ( + <> + + + + ) } + + ); +}; + +export default React.memo(chakra(BlockGasUsed)); diff --git a/ui/shared/chart/ChartArea.tsx b/ui/shared/chart/ChartArea.tsx index 479e010508..5bb03281a5 100644 --- a/ui/shared/chart/ChartArea.tsx +++ b/ui/shared/chart/ChartArea.tsx @@ -11,10 +11,10 @@ interface Props extends React.SVGProps { yScale: d3.ScaleTime | d3.ScaleLinear; color?: string; data: Array; - disableAnimation?: boolean; + noAnimation?: boolean; } -const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props }: Props) => { +const ChartArea = ({ id, xScale, yScale, color, data, noAnimation, ...props }: Props) => { const ref = React.useRef(null); const theme = useTheme(); @@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props }; React.useEffect(() => { - if (disableAnimation) { + if (noAnimation) { d3.select(ref.current).attr('opacity', 1); return; } @@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props .duration(750) .ease(d3.easeBackIn) .attr('opacity', 1); - }, [ disableAnimation ]); + }, [ noAnimation ]); const d = React.useMemo(() => { const area = d3.area() + .defined(({ isApproximate }) => !isApproximate) .x(({ date }) => xScale(date)) .y1(({ value }) => yScale(value)) .y0(() => yScale(yScale.domain()[0])) diff --git a/ui/shared/chart/ChartAxis.tsx b/ui/shared/chart/ChartAxis.tsx index 487e3ace61..203ba65ccf 100644 --- a/ui/shared/chart/ChartAxis.tsx +++ b/ui/shared/chart/ChartAxis.tsx @@ -5,13 +5,13 @@ import React from 'react'; interface Props extends Omit, 'scale'> { type: 'left' | 'bottom'; scale: d3.ScaleTime | d3.ScaleLinear; - disableAnimation?: boolean; + noAnimation?: boolean; ticks: number; tickFormatGenerator?: (axis: d3.Axis) => (domainValue: d3.AxisDomain, index: number) => string; anchorEl?: SVGRectElement | null; } -const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, anchorEl, ...props }: Props) => { +const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, noAnimation, anchorEl, ...props }: Props) => { const ref = React.useRef(null); const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500'); @@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, const axisGroup = d3.select(ref.current); - if (disableAnimation) { + if (noAnimation) { axisGroup.call(axis); } else { axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis); @@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, .attr('opacity', 1) .attr('color', textColor) .attr('font-size', '0.75rem'); - }, [ scale, ticks, tickFormatGenerator, disableAnimation, type, textColor ]); + }, [ scale, ticks, tickFormatGenerator, noAnimation, type, textColor ]); React.useEffect(() => { if (!anchorEl) { diff --git a/ui/shared/chart/ChartGridLine.tsx b/ui/shared/chart/ChartGridLine.tsx index 2d140f25c1..3756b9239b 100644 --- a/ui/shared/chart/ChartGridLine.tsx +++ b/ui/shared/chart/ChartGridLine.tsx @@ -5,12 +5,12 @@ import React from 'react'; interface Props extends Omit, 'scale'> { type: 'vertical' | 'horizontal'; scale: d3.ScaleTime | d3.ScaleLinear; - disableAnimation?: boolean; + noAnimation?: boolean; size: number; ticks: number; } -const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => { +const ChartGridLine = ({ type, scale, ticks, size, noAnimation, ...props }: Props) => { const ref = React.useRef(null); const strokeColor = useToken('colors', 'divider'); @@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: const axis = axisGenerator(scale).ticks(ticks).tickSize(-size); const gridGroup = d3.select(ref.current); - if (disableAnimation) { + if (noAnimation) { gridGroup.call(axis); } else { gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis); @@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: gridGroup.select('.domain').remove(); gridGroup.selectAll('text').remove(); gridGroup.selectAll('line').attr('stroke', strokeColor); - }, [ scale, ticks, size, disableAnimation, type, strokeColor ]); + }, [ scale, ticks, size, noAnimation, type, strokeColor ]); return ; }; diff --git a/ui/shared/chart/ChartLine.tsx b/ui/shared/chart/ChartLine.tsx index a96b7e84c5..2d34add516 100644 --- a/ui/shared/chart/ChartLine.tsx +++ b/ui/shared/chart/ChartLine.tsx @@ -3,56 +3,38 @@ import React from 'react'; import type { TimeChartItem } from 'ui/shared/chart/types'; +import type { AnimationType } from './utils/animations'; +import { ANIMATIONS } from './utils/animations'; +import { getIncompleteDataLineSource } from './utils/formatters'; + interface Props extends React.SVGProps { xScale: d3.ScaleTime | d3.ScaleLinear; yScale: d3.ScaleTime | d3.ScaleLinear; data: Array; - animation: 'left' | 'fadeIn' | 'none'; + animation: AnimationType; } const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { - const ref = React.useRef(null); - - // Define different types of animation that we can use - const animateLeft = React.useCallback(() => { - const totalLength = ref.current?.getTotalLength() || 0; - d3.select(ref.current) - .attr('opacity', 1) - .attr('stroke-dasharray', `${ totalLength },${ totalLength }`) - .attr('stroke-dashoffset', totalLength) - .transition() - .duration(750) - .ease(d3.easeLinear) - .attr('stroke-dashoffset', 0); - }, []); - - const animateFadeIn = React.useCallback(() => { - d3.select(ref.current) - .transition() - .duration(750) - .ease(d3.easeLinear) - .attr('opacity', 1); - }, []); - - const noneAnimation = React.useCallback(() => { - d3.select(ref.current).attr('opacity', 1); - }, []); + const dataPathRef = React.useRef(null); + const incompleteDataPathRef = React.useRef(null); React.useEffect(() => { - const ANIMATIONS = { - left: animateLeft, - fadeIn: animateFadeIn, - none: noneAnimation, - }; const animationFn = ANIMATIONS[animation]; - window.setTimeout(animationFn, 100); - }, [ animateLeft, animateFadeIn, noneAnimation, animation ]); + const timeoutId = window.setTimeout(() => { + dataPathRef.current && animationFn(dataPathRef.current); + incompleteDataPathRef.current && animationFn(incompleteDataPathRef.current); + }, 100); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [ animation ]); // Recalculate line length if scale has changed React.useEffect(() => { if (animation === 'left') { - const totalLength = ref.current?.getTotalLength(); - d3.select(ref.current).attr( + const totalLength = dataPathRef.current?.getTotalLength(); + d3.select(dataPathRef.current).attr( 'stroke-dasharray', `${ totalLength },${ totalLength }`, ); @@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { .curve(d3.curveMonotoneX); return ( - + <> + + !isApproximate)) || undefined } + strokeWidth={ 1 } + strokeLinecap="round" + fill="none" + opacity={ 0 } + { ...props } + /> + ); }; diff --git a/ui/shared/chart/ChartTooltip.tsx b/ui/shared/chart/ChartTooltip.tsx index e257b5a519..71e51ab738 100644 --- a/ui/shared/chart/ChartTooltip.tsx +++ b/ui/shared/chart/ChartTooltip.tsx @@ -1,12 +1,16 @@ -import { useToken, useColorModeValue } from '@chakra-ui/react'; import * as d3 from 'd3'; import React from 'react'; -import type { TimeChartItem, TimeChartData } from 'ui/shared/chart/types'; +import type { TimeChartData } from 'ui/shared/chart/types'; -import computeTooltipPosition from 'ui/shared/chart/utils/computeTooltipPosition'; -import type { Pointer } from 'ui/shared/chart/utils/pointerTracker'; -import { trackPointer } from 'ui/shared/chart/utils/pointerTracker'; +import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop'; +import ChartTooltipContent, { useRenderContent } from './tooltip/ChartTooltipContent'; +import ChartTooltipLine, { useRenderLine } from './tooltip/ChartTooltipLine'; +import ChartTooltipPoint, { useRenderPoints } from './tooltip/ChartTooltipPoint'; +import ChartTooltipRow, { useRenderRows } from './tooltip/ChartTooltipRow'; +import ChartTooltipTitle, { useRenderTitle } from './tooltip/ChartTooltipTitle'; +import { trackPointer } from './tooltip/pointerTracker'; +import type { Pointer } from './tooltip/pointerTracker'; interface Props { width?: number; @@ -16,151 +20,62 @@ interface Props { xScale: d3.ScaleTime; yScale: d3.ScaleLinear; anchorEl: SVGRectElement | null; + noAnimation?: boolean; } -const TEXT_LINE_HEIGHT = 12; -const PADDING = 16; -const LINE_SPACE = 10; -const POINT_SIZE = 16; -const LABEL_WIDTH = 80; - -const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, ...props }: Props) => { - const lineColor = useToken('colors', 'gray.400'); - const titleColor = useToken('colors', 'blue.100'); - const textColor = useToken('colors', 'white'); - const markerBgColor = useToken('colors', useColorModeValue('black', 'white')); - const markerBorderColor = useToken('colors', useColorModeValue('white', 'black')); - const bgColor = useToken('colors', 'blackAlpha.900'); - - const ref = React.useRef(null); +const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => { + const ref = React.useRef(null); const trackerId = React.useRef(); const isVisible = React.useRef(false); - const drawLine = React.useCallback( - (x: number) => { - d3.select(ref.current) - .select('.ChartTooltip__line') - .attr('x1', x) - .attr('x2', x) - .attr('y1', 0) - .attr('y2', height || 0); - }, - [ ref, height ], - ); - - const drawContent = React.useCallback( - (x: number, y: number) => { - const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content'); - - tooltipContent.attr('transform', (cur, i, nodes) => { - const node = nodes[i] as SVGGElement | null; - const { width: nodeWidth, height: nodeHeight } = node?.getBoundingClientRect() || { width: 0, height: 0 }; - const [ translateX, translateY ] = computeTooltipPosition({ - canvasWidth: width || 0, - canvasHeight: height || 0, - nodeWidth, - nodeHeight, - pointX: x, - pointY: y, - offset: POINT_SIZE, - }); - return `translate(${ translateX }, ${ translateY })`; - }); - - const date = xScale.invert(x); - const dateLabel = data[0].items.find((item) => item.date.getTime() === date.getTime())?.dateLabel; - - tooltipContent - .select('.ChartTooltip__contentDate') - .text(dateLabel || d3.timeFormat('%e %b %Y')(xScale.invert(x))); - }, - [ xScale, data, width, height ], - ); - - const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => { - const nodes = d3.select(ref.current) - .selectAll('.ChartTooltip__value') - .filter((td, tIndex) => tIndex === i) - .text( - (data[i].valueFormatter?.(d.value) || d.value.toLocaleString(undefined, { minimumSignificantDigits: 1 })) + - (data[i].units ? ` ${ data[i].units }` : ''), - ) - .nodes(); - - const widthLimit = tooltipWidth - 2 * PADDING - LABEL_WIDTH; - const width = nodes.map((node) => node?.getBoundingClientRect?.().width); - const maxNodeWidth = Math.max(...width); - d3.select(ref.current) - .select('.ChartTooltip__contentBg') - .attr('width', tooltipWidth + Math.max(0, (maxNodeWidth - widthLimit))); - - }, [ data, tooltipWidth ]); - - const drawPoints = React.useCallback((x: number) => { - const xDate = xScale.invert(x); - const bisectDate = d3.bisector((d) => d.date).left; - let baseXPos = 0; - let baseYPos = 0; + const transitionDuration = !noAnimation ? 100 : null; - d3.select(ref.current) - .selectAll('.ChartTooltip__point') - .attr('transform', (cur, i) => { - const index = bisectDate(data[i].items, xDate, 1); - const d0 = data[i].items[index - 1] as TimeChartItem | undefined; - const d1 = data[i].items[index] as TimeChartItem | undefined; - const d = (() => { - if (!d0) { - return d1; - } - if (!d1) { - return d0; - } - return xDate.getTime() - d0.date.getTime() > d1.date.getTime() - xDate.getTime() ? d1 : d0; - })(); - - if (d?.date === undefined && d?.value === undefined) { - // move point out of container - return 'translate(-100,-100)'; - } - - const xPos = xScale(d.date); - const yPos = yScale(d.value); - - if (i === 0) { - baseXPos = xPos; - baseYPos = yPos; - } - - updateDisplayedValue(d, i); - - return `translate(${ xPos }, ${ yPos })`; - }); - - return [ baseXPos, baseYPos ]; - }, [ data, updateDisplayedValue, xScale, yScale ]); + const renderLine = useRenderLine(ref, height); + const renderContent = useRenderContent(ref, { chart: { width, height }, transitionDuration }); + const renderPoints = useRenderPoints(ref, { data, xScale, yScale }); + const renderTitle = useRenderTitle(ref); + const renderRows = useRenderRows(ref, { data, xScale, minWidth: tooltipWidth }); + const renderBackdrop = useRenderBackdrop(ref, { seriesNum: data.length, transitionDuration }); const draw = React.useCallback((pointer: Pointer) => { if (pointer.point) { - const [ baseXPos, baseYPos ] = drawPoints(pointer.point[0]); - drawLine(baseXPos); - drawContent(baseXPos, baseYPos); + const { x, y, currentPoints } = renderPoints(pointer.point[0]); + const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate); + renderLine(x); + renderContent(x, y); + renderTitle(isIncompleteData); + const { width } = renderRows(x, currentPoints); + renderBackdrop(width, isIncompleteData); } - }, [ drawPoints, drawLine, drawContent ]); + }, [ renderPoints, renderLine, renderContent, renderTitle, renderRows, renderBackdrop ]); const showContent = React.useCallback(() => { if (!isVisible.current) { - d3.select(ref.current).attr('opacity', 1); - d3.select(ref.current) - .selectAll('.ChartTooltip__point') - .attr('opacity', 1); + if (transitionDuration) { + d3.select(ref.current) + .transition() + .delay(transitionDuration) + .attr('opacity', 1); + } else { + d3.select(ref.current) + .attr('opacity', 1); + } isVisible.current = true; } - }, []); + }, [ transitionDuration ]); const hideContent = React.useCallback(() => { - d3.select(ref.current).attr('opacity', 0); + if (transitionDuration) { + d3.select(ref.current) + .transition() + .delay(transitionDuration) + .attr('opacity', 0); + } else { + d3.select(ref.current) + .attr('opacity', 0); + } isVisible.current = false; - }, []); + }, [ transitionDuration ]); const createPointerTracker = React.useCallback((event: PointerEvent, isSubsequentCall?: boolean) => { let isPressed = event.pointerType === 'mouse' && event.type === 'pointerdown' && !isSubsequentCall; @@ -224,73 +139,21 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, }, [ anchorEl, createPointerTracker, draw, hideContent, showContent ]); return ( - - - { data.map(({ name }) => ( - - )) } - - - - - Date - - - - { data.map(({ name }, index) => ( - - - { name } - - - - )) } - + + + { data.map(({ name }) => ) } + + + + + { data.map(({ name }, index) => ) } + ); }; diff --git a/ui/shared/chart/ChartWidget.pw.tsx b/ui/shared/chart/ChartWidget.pw.tsx index 409cf5bdd6..21fc07272f 100644 --- a/ui/shared/chart/ChartWidget.pw.tsx +++ b/ui/shared/chart/ChartWidget.pw.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import type { TimeChartItem } from './types'; + import { test, expect } from 'playwright/lib'; import type { Props } from './ChartWidget'; @@ -26,6 +28,7 @@ const props: Props = { units: 'ETH', isLoading: false, isError: false, + noAnimation: true, }; test('base view +@dark-mode', async({ render, page }) => { @@ -41,6 +44,7 @@ test('base view +@dark-mode', async({ render, page }) => { await page.mouse.move(0, 0); await page.mouse.click(0, 0); + await page.mouse.move(80, 150); await page.mouse.move(100, 150); await expect(component).toHaveScreenshot(); @@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => { }); await expect(component).toHaveScreenshot(); }); + +test('incomplete day', async({ render, page }) => { + const modifiedProps = { + ...props, + items: [ + ...props.items as Array, + { date: new Date('2023-02-24'), value: 25136740.887217894 / 4, isApproximate: true }, + ], + }; + + const component = await render(); + await page.waitForFunction(() => { + return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-small"]')?.getAttribute('opacity') === '1'; + }); + await expect(component).toHaveScreenshot(); + + await page.hover('.ChartOverlay', { position: { x: 120, y: 120 } }); + await page.hover('.ChartOverlay', { position: { x: 320, y: 120 } }); + await expect(page.getByText('Incomplete day')).toBeVisible(); + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/chart/ChartWidget.tsx b/ui/shared/chart/ChartWidget.tsx index 550f912f56..e32a1e29ae 100644 --- a/ui/shared/chart/ChartWidget.tsx +++ b/ui/shared/chart/ChartWidget.tsx @@ -36,11 +36,12 @@ export type Props = { className?: string; isError: boolean; emptyText?: string; + noAnimation?: boolean; } const DOWNLOAD_IMAGE_SCALE = 5; -const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText }: Props) => { +const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText, noAnimation }: Props) => { const ref = useRef(null); const [ isFullscreen, setIsFullscreen ] = useState(false); const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); @@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError, isZoomResetInitial={ isZoomResetInitial } title={ title } units={ units } + noAnimation={ noAnimation } />
); diff --git a/ui/shared/chart/ChartWidgetGraph.tsx b/ui/shared/chart/ChartWidgetGraph.tsx index 7dbce5bb55..ce1ee8e4c9 100644 --- a/ui/shared/chart/ChartWidgetGraph.tsx +++ b/ui/shared/chart/ChartWidgetGraph.tsx @@ -23,13 +23,14 @@ interface Props { onZoom: () => void; isZoomResetInitial: boolean; margin?: ChartMargin; + noAnimation?: boolean; } // temporarily turn off the data aggregation, we need a better algorithm for that const MAX_SHOW_ITEMS = 100_000_000_000; const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 }; -const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units }: Props) => { +const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units, noAnimation }: Props) => { const isMobile = useIsMobile(); const color = useToken('colors', 'blue.200'); const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`; @@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title scale={ axes.y.scale } ticks={ axesConfig.y.ticks } size={ innerWidth } - disableAnimation + noAnimation /> @@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title xScale={ axes.x.scale } yScale={ axes.y.scale } data={ chartData } + noAnimation={ noAnimation } /> { + const bgColor = useToken('colors', 'blackAlpha.900'); + + return ( + + ); +}; + +export default React.memo(ChartTooltipBackdrop); + +interface UseRenderBackdropParams { + seriesNum: number; + transitionDuration: number | null; +} + +export function useRenderBackdrop(ref: React.RefObject, { seriesNum, transitionDuration }: UseRenderBackdropParams) { + return React.useCallback((width: number, isIncompleteData: boolean) => { + const height = calculateContainerHeight(seriesNum, isIncompleteData); + + if (transitionDuration) { + d3.select(ref.current) + .select('.ChartTooltip__backdrop') + .transition() + .duration(transitionDuration) + .ease(d3.easeLinear) + .attr('width', width) + .attr('height', height); + } else { + d3.select(ref.current) + .select('.ChartTooltip__backdrop') + .attr('width', width) + .attr('height', height); + } + }, [ ref, seriesNum, transitionDuration ]); +} diff --git a/ui/shared/chart/tooltip/ChartTooltipContent.tsx b/ui/shared/chart/tooltip/ChartTooltipContent.tsx new file mode 100644 index 0000000000..da1a62a5d6 --- /dev/null +++ b/ui/shared/chart/tooltip/ChartTooltipContent.tsx @@ -0,0 +1,101 @@ +import * as d3 from 'd3'; +import _clamp from 'lodash/clamp'; +import React from 'react'; + +import { POINT_SIZE } from './utils'; + +interface Props { + children: React.ReactNode; +} + +const ChartTooltipContent = ({ children }: Props) => { + return { children }; +}; + +export default React.memo(ChartTooltipContent); + +interface UseRenderContentParams { + chart: { + width?: number; + height?: number; + }; + transitionDuration: number | null; +} + +export function useRenderContent(ref: React.RefObject, { chart, transitionDuration }: UseRenderContentParams) { + return React.useCallback((x: number, y: number) => { + const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content'); + + const transformAttributeFn: d3.ValueFn = (cur, i, nodes) => { + const node = nodes[i] as SVGGElement | null; + const { width: nodeWidth, height: nodeHeight } = node?.getBoundingClientRect() || { width: 0, height: 0 }; + const [ translateX, translateY ] = calculatePosition({ + canvasWidth: chart.width || 0, + canvasHeight: chart.height || 0, + nodeWidth, + nodeHeight, + pointX: x, + pointY: y, + offset: POINT_SIZE, + }); + return `translate(${ translateX }, ${ translateY })`; + }; + + if (transitionDuration) { + tooltipContent + .transition() + .duration(transitionDuration) + .ease(d3.easeLinear) + .attr('transform', transformAttributeFn); + } else { + tooltipContent + .attr('transform', transformAttributeFn); + } + + }, [ chart.height, chart.width, ref, transitionDuration ]); +} + +interface CalculatePositionParams { + pointX: number; + pointY: number; + offset: number; + nodeWidth: number; + nodeHeight: number; + canvasWidth: number; + canvasHeight: number; +} + +function calculatePosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: CalculatePositionParams): [ number, number ] { + // right + if (pointX + offset + nodeWidth <= canvasWidth) { + const x = pointX + offset; + const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight); + return [ x, y ]; + } + + // left + if (nodeWidth + offset <= pointX) { + const x = pointX - offset - nodeWidth; + const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight); + return [ x, y ]; + } + + // top + if (nodeHeight + offset <= pointY) { + const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth); + const y = pointY - offset - nodeHeight; + return [ x, y ]; + } + + // bottom + if (pointY + offset + nodeHeight <= canvasHeight) { + const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth); + const y = pointY + offset; + return [ x, y ]; + } + + const x = _clamp(pointX / 2, 0, canvasWidth - nodeWidth); + const y = _clamp(pointY / 2, 0, canvasHeight - nodeHeight); + + return [ x, y ]; +} diff --git a/ui/shared/chart/tooltip/ChartTooltipLine.tsx b/ui/shared/chart/tooltip/ChartTooltipLine.tsx new file mode 100644 index 0000000000..7397365945 --- /dev/null +++ b/ui/shared/chart/tooltip/ChartTooltipLine.tsx @@ -0,0 +1,21 @@ +import { useToken } from '@chakra-ui/react'; +import * as d3 from 'd3'; +import React from 'react'; + +const ChartTooltipLine = () => { + const lineColor = useToken('colors', 'gray.400'); + return ; +}; + +export default React.memo(ChartTooltipLine); + +export function useRenderLine(ref: React.RefObject, chartHeight: number | undefined) { + return React.useCallback((x: number) => { + d3.select(ref.current) + .select('.ChartTooltip__line') + .attr('x1', x) + .attr('x2', x) + .attr('y1', 0) + .attr('y2', chartHeight || 0); + }, [ ref, chartHeight ]); +} diff --git a/ui/shared/chart/tooltip/ChartTooltipPoint.tsx b/ui/shared/chart/tooltip/ChartTooltipPoint.tsx new file mode 100644 index 0000000000..c6dd1052f4 --- /dev/null +++ b/ui/shared/chart/tooltip/ChartTooltipPoint.tsx @@ -0,0 +1,93 @@ +import { useColorModeValue, useToken } from '@chakra-ui/react'; +import * as d3 from 'd3'; +import React from 'react'; + +import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types'; + +import { POINT_SIZE } from './utils'; + +const ChartTooltipPoint = () => { + const bgColor = useToken('colors', useColorModeValue('black', 'white')); + const borderColor = useToken('colors', useColorModeValue('white', 'black')); + + return ( + + ); +}; + +export default React.memo(ChartTooltipPoint); + +interface UseRenderPointsParams { + data: TimeChartData; + xScale: d3.ScaleTime; + yScale: d3.ScaleLinear; +} + +export interface CurrentPoint { + datumIndex: number; + item: TimeChartItem; +} + +interface RenderPointsReturnType{ + x: number; + y: number; + currentPoints: Array; +} + +export function useRenderPoints(ref: React.RefObject, params: UseRenderPointsParams) { + return React.useCallback((x: number): RenderPointsReturnType => { + const xDate = params.xScale.invert(x); + const bisectDate = d3.bisector((d) => d.date).left; + let baseXPos = 0; + let baseYPos = 0; + const currentPoints: Array = []; + + d3.select(ref.current) + .selectAll('.ChartTooltip__point') + .attr('transform', (cur, elementIndex) => { + const datum = params.data[elementIndex]; + const index = bisectDate(datum.items, xDate, 1); + const d0 = datum.items[index - 1] as TimeChartItem | undefined; + const d1 = datum.items[index] as TimeChartItem | undefined; + const d = (() => { + if (!d0) { + return d1; + } + if (!d1) { + return d0; + } + return xDate.getTime() - d0.date.getTime() > d1.date.getTime() - xDate.getTime() ? d1 : d0; + })(); + + if (d?.date === undefined && d?.value === undefined) { + // move point out of container + return 'translate(-100,-100)'; + } + + const xPos = params.xScale(d.date); + const yPos = params.yScale(d.value); + + if (elementIndex === 0) { + baseXPos = xPos; + baseYPos = yPos; + } + + currentPoints.push({ item: d, datumIndex: elementIndex }); + + return `translate(${ xPos }, ${ yPos })`; + }); + + return { + x: baseXPos, + y: baseYPos, + currentPoints, + }; + }, [ ref, params ]); +} diff --git a/ui/shared/chart/tooltip/ChartTooltipRow.tsx b/ui/shared/chart/tooltip/ChartTooltipRow.tsx new file mode 100644 index 0000000000..a35dcb25f1 --- /dev/null +++ b/ui/shared/chart/tooltip/ChartTooltipRow.tsx @@ -0,0 +1,96 @@ +import { useToken } from '@chakra-ui/react'; +import * as d3 from 'd3'; +import React from 'react'; + +import type { TimeChartData } from '../types'; + +import type { CurrentPoint } from './ChartTooltipPoint'; +import { calculateRowTransformValue, LABEL_WIDTH, PADDING } from './utils'; + +type Props = { + lineNum: number; +} & ({ label: string; children?: never } | { children: React.ReactNode; label?: never }) + +const ChartTooltipRow = ({ label, lineNum, children }: Props) => { + const labelColor = useToken('colors', 'blue.100'); + const textColor = useToken('colors', 'white'); + + return ( + + { children || ( + <> + + { label } + + + + ) } + + ); +}; + +export default React.memo(ChartTooltipRow); + +interface UseRenderRowsParams { + data: TimeChartData; + xScale: d3.ScaleTime; + minWidth: number; +} + +interface UseRenderRowsReturnType { + width: number; +} + +export function useRenderRows(ref: React.RefObject, { data, xScale, minWidth }: UseRenderRowsParams) { + return React.useCallback((x: number, currentPoints: Array): UseRenderRowsReturnType => { + + // update "transform" prop of all rows + const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate); + d3.select(ref.current) + .selectAll('.ChartTooltip__row') + .attr('transform', (datum, index) => { + return calculateRowTransformValue(index - (isIncompleteData ? 0 : 1)); + }); + + // update date and indicators value + // here we assume that the first value element contains the date + const valueNodes = d3.select(ref.current) + .selectAll('.ChartTooltip__value') + .text((_, index) => { + if (index === 0) { + const date = xScale.invert(x); + const dateValue = data[0].items.find((item) => item.date.getTime() === date.getTime())?.dateLabel; + const dateValueFallback = d3.timeFormat('%e %b %Y')(xScale.invert(x)); + return dateValue || dateValueFallback; + } + + const { datumIndex, item } = currentPoints.find(({ datumIndex }) => datumIndex === index - 1) || {}; + if (datumIndex === undefined || !item) { + return null; + } + + const value = data[datumIndex]?.valueFormatter?.(item.value) ?? item.value.toLocaleString(undefined, { minimumSignificantDigits: 1 }); + const units = data[datumIndex]?.units ? ` ${ data[datumIndex]?.units }` : ''; + + return value + units; + }) + .nodes(); + + const valueWidths = valueNodes.map((node) => node?.getBoundingClientRect?.().width); + const maxValueWidth = Math.max(...valueWidths); + const maxRowWidth = Math.max(minWidth, 2 * PADDING + LABEL_WIDTH + maxValueWidth); + + return { width: maxRowWidth }; + + }, [ data, minWidth, ref, xScale ]); +} diff --git a/ui/shared/chart/tooltip/ChartTooltipTitle.tsx b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx new file mode 100644 index 0000000000..93ab2e9943 --- /dev/null +++ b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx @@ -0,0 +1,33 @@ +import { useToken } from '@chakra-ui/react'; +import * as d3 from 'd3'; +import React from 'react'; + +import ChartTooltipRow from './ChartTooltipRow'; + +const ChartTooltipTitle = () => { + const titleColor = useToken('colors', 'yellow.300'); + + return ( + + + Incomplete day + + + ); +}; + +export default React.memo(ChartTooltipTitle); + +export function useRenderTitle(ref: React.RefObject) { + return React.useCallback((isVisible: boolean) => { + d3.select(ref.current) + .select('.ChartTooltip__title') + .attr('opacity', isVisible ? 1 : 0); + }, [ ref ]); +} diff --git a/ui/shared/chart/utils/pointerTracker.tsx b/ui/shared/chart/tooltip/pointerTracker.ts similarity index 100% rename from ui/shared/chart/utils/pointerTracker.tsx rename to ui/shared/chart/tooltip/pointerTracker.ts diff --git a/ui/shared/chart/tooltip/utils.ts b/ui/shared/chart/tooltip/utils.ts new file mode 100644 index 0000000000..8b11b366a9 --- /dev/null +++ b/ui/shared/chart/tooltip/utils.ts @@ -0,0 +1,16 @@ +export const TEXT_LINE_HEIGHT = 12; +export const PADDING = 16; +export const LINE_SPACE = 10; +export const POINT_SIZE = 16; +export const LABEL_WIDTH = 80; + +export const calculateContainerHeight = (seriesNum: number, isIncomplete?: boolean) => { + const linesNum = isIncomplete ? seriesNum + 2 : seriesNum + 1; + + return 2 * PADDING + linesNum * TEXT_LINE_HEIGHT + (linesNum - 1) * LINE_SPACE; +}; + +export const calculateRowTransformValue = (rowNum: number) => { + const top = Math.max(0, PADDING + rowNum * (LINE_SPACE + TEXT_LINE_HEIGHT)); + return `translate(${ PADDING },${ top })`; +}; diff --git a/ui/shared/chart/types.tsx b/ui/shared/chart/types.tsx index 5810fe2fb5..c1c02b1b12 100644 --- a/ui/shared/chart/types.tsx +++ b/ui/shared/chart/types.tsx @@ -8,6 +8,7 @@ export interface TimeChartItem { date: Date; dateLabel?: string; value: number; + isApproximate?: boolean; } export interface ChartMargin { diff --git a/ui/shared/chart/utils/animations.ts b/ui/shared/chart/utils/animations.ts new file mode 100644 index 0000000000..2f873f4a69 --- /dev/null +++ b/ui/shared/chart/utils/animations.ts @@ -0,0 +1,33 @@ +import * as d3 from 'd3'; + +export type AnimationType = 'left' | 'fadeIn' | 'none'; + +export const animateLeft = (path: SVGPathElement) => { + const totalLength = path.getTotalLength() || 0; + d3.select(path) + .attr('opacity', 1) + .attr('stroke-dasharray', `${ totalLength },${ totalLength }`) + .attr('stroke-dashoffset', totalLength) + .transition() + .duration(750) + .ease(d3.easeLinear) + .attr('stroke-dashoffset', 0); +}; + +export const animateFadeIn = (path: SVGPathElement) => { + d3.select(path) + .transition() + .duration(750) + .ease(d3.easeLinear) + .attr('opacity', 1); +}; + +export const noneAnimation = (path: SVGPathElement) => { + d3.select(path).attr('opacity', 1); +}; + +export const ANIMATIONS: Record void> = { + left: animateLeft, + fadeIn: animateFadeIn, + none: noneAnimation, +}; diff --git a/ui/shared/chart/utils/computeTooltipPosition.ts b/ui/shared/chart/utils/computeTooltipPosition.ts deleted file mode 100644 index 88356d34cd..0000000000 --- a/ui/shared/chart/utils/computeTooltipPosition.ts +++ /dev/null @@ -1,46 +0,0 @@ -import _clamp from 'lodash/clamp'; - -interface Params { - pointX: number; - pointY: number; - offset: number; - nodeWidth: number; - nodeHeight: number; - canvasWidth: number; - canvasHeight: number; -} - -export default function computeTooltipPosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: Params): [ number, number ] { - // right - if (pointX + offset + nodeWidth <= canvasWidth) { - const x = pointX + offset; - const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight); - return [ x, y ]; - } - - // left - if (nodeWidth + offset <= pointX) { - const x = pointX - offset - nodeWidth; - const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight); - return [ x, y ]; - } - - // top - if (nodeHeight + offset <= pointY) { - const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth); - const y = pointY - offset - nodeHeight; - return [ x, y ]; - } - - // bottom - if (pointY + offset + nodeHeight <= canvasHeight) { - const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth); - const y = pointY + offset; - return [ x, y ]; - } - - const x = _clamp(pointX / 2, 0, canvasWidth - nodeWidth); - const y = _clamp(pointY / 2, 0, canvasHeight - nodeHeight); - - return [ x, y ]; -} diff --git a/ui/shared/chart/utils/formatters.ts b/ui/shared/chart/utils/formatters.ts new file mode 100644 index 0000000000..9d0d13a7d2 --- /dev/null +++ b/ui/shared/chart/utils/formatters.ts @@ -0,0 +1,19 @@ +import type { TimeChartItem } from '../types'; + +export const getIncompleteDataLineSource = (data: Array): Array => { + const result: Array = []; + + for (let index = 0; index < data.length; index++) { + const current = data[index]; + if (current.isApproximate) { + const prev = data[index - 1]; + const next = data[index + 1]; + + prev && !prev.isApproximate && result.push(prev); + result.push(current); + next && !next.isApproximate && result.push(next); + } + } + + return result; +}; diff --git a/ui/shared/entities/address/AddressEntity.tsx b/ui/shared/entities/address/AddressEntity.tsx index 1519571bb8..704285e0ff 100644 --- a/ui/shared/entities/address/AddressEntity.tsx +++ b/ui/shared/entities/address/AddressEntity.tsx @@ -108,7 +108,7 @@ const Content = chakra((props: ContentProps) => { ); return ( - + { nameText } diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png index 5af8e38ece..a18893d2ea 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_contract-unverified-1.png differ diff --git a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png index 0d77e3eb86..bf2243601a 100644 Binary files a/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png and b/ui/shared/entities/address/__screenshots__/AddressEntity.pw.tsx_default_hover-1.png differ diff --git a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png index 038508436e..5534d9df03 100644 Binary files a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png and b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-1.png differ diff --git a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png index d311239584..9d8d7e86d8 100644 Binary files a/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png and b/ui/shared/entities/token/__screenshots__/TokenEntity.pw.tsx_default_with-logo-long-name-and-symbol-2.png differ diff --git a/ui/shared/forms/FileSnippet.tsx b/ui/shared/forms/FileSnippet.tsx index 172c045ed9..73656e46d8 100644 --- a/ui/shared/forms/FileSnippet.tsx +++ b/ui/shared/forms/FileSnippet.tsx @@ -76,7 +76,7 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Pr diff --git a/ui/snippets/navigation/vertical/NavLink.tsx b/ui/snippets/navigation/vertical/NavLink.tsx index 95b3a3a210..4dfaf67e43 100644 --- a/ui/snippets/navigation/vertical/NavLink.tsx +++ b/ui/snippets/navigation/vertical/NavLink.tsx @@ -64,6 +64,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState variant="nav" gutter={ 20 } color={ isInternalLink && item.isActive ? colors.text.active : colors.text.hover } + margin={ 0 } > diff --git a/ui/stats/ChartWidgetContainer.tsx b/ui/stats/ChartWidgetContainer.tsx index b9e45ebc79..5f41a5c2af 100644 --- a/ui/stats/ChartWidgetContainer.tsx +++ b/ui/stats/ChartWidgetContainer.tsx @@ -42,7 +42,7 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError }); const items = useMemo(() => data?.chart?.map((item) => { - return { date: new Date(item.date), value: Number(item.value) }; + return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate }; }), [ data ]); useEffect(() => { diff --git a/ui/token/TokenDetails.tsx b/ui/token/TokenDetails.tsx index 223f1f974a..e689d8e646 100644 --- a/ui/token/TokenDetails.tsx +++ b/ui/token/TokenDetails.tsx @@ -12,7 +12,6 @@ import type { ResourceError } from 'lib/api/resources'; import useApiQuery from 'lib/api/useApiQuery'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import getCurrencyValue from 'lib/getCurrencyValue'; -import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useIsMounted from 'lib/hooks/useIsMounted'; import { TOKEN_COUNTERS } from 'stubs/token'; import type { TokenTabs } from 'ui/pages/Token'; @@ -31,7 +30,6 @@ interface Props { const TokenDetails = ({ tokenQuery }: Props) => { const router = useRouter(); const isMounted = useIsMounted(); - const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false); const hash = router.query.hash?.toString(); @@ -40,7 +38,7 @@ const TokenDetails = ({ tokenQuery }: Props) => { queryOptions: { enabled: Boolean(router.query.hash), placeholderData: TOKEN_COUNTERS }, }); - const appActionData = useAppActionData(hash, isActionButtonExperiment); + const appActionData = useAppActionData(hash); const changeUrlAndScroll = useCallback((tab: TokenTabs) => () => { router.push( @@ -200,11 +198,10 @@ const TokenDetails = ({ tokenQuery }: Props) => { isLoading={ tokenQuery.isPlaceholderData } appActionData={ appActionData } source="NFT collection" - isActionButtonExperiment={ isActionButtonExperiment } /> ) } - { (type !== 'ERC-20' && config.UI.views.nft.marketplaces.length === 0 && appActionData && isActionButtonExperiment) && ( + { (type !== 'ERC-20' && config.UI.views.nft.marketplaces.length === 0 && appActionData) && ( <> { +const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source }: Props) => { if (!hash || config.UI.views.nft.marketplaces.length === 0) { return null; } @@ -31,7 +30,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc Marketplaces { config.UI.views.nft.marketplaces.map((item) => { @@ -52,7 +51,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc ); }) } - { (appActionData && isActionButtonExperiment) && ( + { appActionData && ( <> diff --git a/ui/token/TokenTransfer/TokenTransferListItem.tsx b/ui/token/TokenTransfer/TokenTransferListItem.tsx index f4c582e039..40c9b70cd2 100644 --- a/ui/token/TokenTransfer/TokenTransferListItem.tsx +++ b/ui/token/TokenTransfer/TokenTransferListItem.tsx @@ -4,13 +4,13 @@ import React from 'react'; import type { TokenTransfer } from 'types/api/tokenTransfer'; import getCurrencyValue from 'lib/getCurrencyValue'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import Tag from 'ui/shared/chakra/Tag'; import NftEntity from 'ui/shared/entities/nft/NftEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TruncatedValue from 'ui/shared/TruncatedValue'; type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean }; @@ -26,7 +26,6 @@ const TokenTransferListItem = ({ tokenId, isLoading, }: Props) => { - const timeAgo = useTimeAgoIncrement(timestamp, true); const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ value: total.value, exchangeRate: token.exchange_rate, @@ -44,13 +43,15 @@ const TokenTransferListItem = ({ truncation="constant_long" fontWeight="700" /> - { timestamp && ( - - - { timeAgo } - - - ) } +
{ method && { method } } { - const timeAgo = useTimeAgoIncrement(timestamp, true); const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ value: total.value, exchangeRate: token.exchange_rate, @@ -44,13 +43,15 @@ const TokenTransferTableItem = ({ noIcon truncation="constant_long" /> - { timestamp && ( - - - { timeAgo } - - - ) } + diff --git a/ui/tokenInstance/TokenInstanceDetails.pw.tsx b/ui/tokenInstance/TokenInstanceDetails.pw.tsx index 17658a89d2..84ea79091e 100644 --- a/ui/tokenInstance/TokenInstanceDetails.pw.tsx +++ b/ui/tokenInstance/TokenInstanceDetails.pw.tsx @@ -55,8 +55,7 @@ test('base view +@dark-mode +@mobile', async({ render, page }) => { }); test.describe('action button', () => { - test.beforeEach(async({ mockFeatures, mockApiResponse, mockAssetResponse }) => { - await mockFeatures([ [ 'action_button_exp', true ] ]); + test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => { const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg'); diff --git a/ui/tokenInstance/TokenInstanceDetails.tsx b/ui/tokenInstance/TokenInstanceDetails.tsx index 68e774fd3e..cac82c8ace 100644 --- a/ui/tokenInstance/TokenInstanceDetails.tsx +++ b/ui/tokenInstance/TokenInstanceDetails.tsx @@ -4,7 +4,6 @@ import React from 'react'; import type { TokenInfo, TokenInstance } from 'types/api/token'; import config from 'configs/app'; -import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useIsMounted from 'lib/hooks/useIsMounted'; import AppActionButton from 'ui/shared/AppActionButton/AppActionButton'; import useAppActionData from 'ui/shared/AppActionButton/useAppActionData'; @@ -29,8 +28,7 @@ interface Props { } const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { - const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false); - const appActionData = useAppActionData(token?.address, isActionButtonExperiment && !isLoading); + const appActionData = useAppActionData(token?.address, !isLoading); const isMounted = useIsMounted(); const handleCounterItemClick = React.useCallback(() => { @@ -96,10 +94,9 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { id={ data.id } appActionData={ appActionData } source="NFT item" - isActionButtonExperiment={ isActionButtonExperiment } /> - { (config.UI.views.nft.marketplaces.length === 0 && appActionData && isActionButtonExperiment) && ( + { (config.UI.views.nft.marketplaces.length === 0 && appActionData) && ( <> { await expect(component).toHaveScreenshot(); }); - test('with interpretation and action button +@mobile +@dark-mode', async({ render, mockApiResponse, mockAssetResponse, mockFeatures }) => { - await mockFeatures([ [ 'action_button_exp', true ] ]); + test('with interpretation and action button +@mobile +@dark-mode', async({ render, mockApiResponse, mockAssetResponse }) => { const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg'); @@ -76,9 +75,8 @@ test.describe('blockscout provider', () => { }); test('with interpretation and view all link, and action button (external link) +@mobile', async({ - render, mockApiResponse, mockAssetResponse, mockFeatures, + render, mockApiResponse, mockAssetResponse, }) => { - await mockFeatures([ [ 'action_button_exp', true ] ]); delete protocolTagWithMeta?.meta?.appID; const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); @@ -92,9 +90,8 @@ test.describe('blockscout provider', () => { await expect(component).toHaveScreenshot(); }); - test('no interpretation, has method called', async({ render, mockApiResponse, mockFeatures }) => { + test('no interpretation, has method called', async({ render, mockApiResponse }) => { // the action button should not render if there is no interpretation - await mockFeatures([ [ 'action_button_exp', true ] ]); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); @@ -103,9 +100,8 @@ test.describe('blockscout provider', () => { await expect(component).toHaveScreenshot(); }); - test('no interpretation', async({ render, mockApiResponse, mockFeatures }) => { + test('no interpretation', async({ render, mockApiResponse }) => { // the action button should not render if there is no interpretation - await mockFeatures([ [ 'action_button_exp', true ] ]); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); diff --git a/ui/tx/TxSubHeading.tsx b/ui/tx/TxSubHeading.tsx index 278bcc137d..dcfcf76837 100644 --- a/ui/tx/TxSubHeading.tsx +++ b/ui/tx/TxSubHeading.tsx @@ -3,7 +3,6 @@ import React from 'react'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; -import useFeatureValue from 'lib/growthbook/useFeatureValue'; import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate'; import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; @@ -29,8 +28,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => { const hasInterpretationFeature = feature.isEnabled; const isNovesInterpretation = hasInterpretationFeature && feature.provider === 'noves'; - const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false); - const appActionData = useAppActionData(txQuery.data?.to?.hash, isActionButtonExperiment && !txQuery.isPlaceholderData); + const appActionData = useAppActionData(txQuery.data?.to?.hash, !txQuery.isPlaceholderData); const txInterpretationQuery = useApiQuery('tx_interpretation', { pathParams: { hash }, @@ -127,7 +125,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => { mt={{ base: 3, lg: 0 }} > { !hasTag && } - { (appActionData && isActionButtonExperiment && hasAnyInterpretation) && ( + { (appActionData && hasAnyInterpretation) && ( ) } diff --git a/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesListItem.tsx b/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesListItem.tsx index 78d8356371..73fbfc78a0 100644 --- a/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesListItem.tsx +++ b/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesListItem.tsx @@ -6,21 +6,19 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean }; const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => { - const timeAgo = item.commitment_transaction.timestamp ? dayjs(item.commitment_transaction.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') { return null; } @@ -76,7 +74,12 @@ const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + Txn count diff --git a/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesTableItem.tsx b/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesTableItem.tsx index 4c59836e7b..94f480b952 100644 --- a/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesTableItem.tsx +++ b/ui/txnBatches/arbitrumL2/ArbitrumL2TxnBatchesTableItem.tsx @@ -6,20 +6,18 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean }; const TxnBatchesTableItem = ({ item, isLoading }: Props) => { - const timeAgo = item.commitment_transaction.timestamp ? dayjs(item.commitment_transaction.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') { return null; } @@ -60,9 +58,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => { /> - - { timeAgo } - + { - const timeAgo = dayjs(item.l1_timestamp).fromNow(); - if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; } @@ -67,7 +65,11 @@ const OptimisticL2TxnBatchesListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + diff --git a/ui/txnBatches/optimisticL2/OptimisticL2TxnBatchesTableItem.tsx b/ui/txnBatches/optimisticL2/OptimisticL2TxnBatchesTableItem.tsx index fff8a75b38..02d0964073 100644 --- a/ui/txnBatches/optimisticL2/OptimisticL2TxnBatchesTableItem.tsx +++ b/ui/txnBatches/optimisticL2/OptimisticL2TxnBatchesTableItem.tsx @@ -6,18 +6,16 @@ import type { OptimisticL2TxnBatchesItem } from 'types/api/optimisticL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2TxnBatchesItem; isLoading?: boolean }; const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.l1_timestamp).fromNow(); - if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { return null; } @@ -60,9 +58,13 @@ const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => { - - { timeAgo } - + ); diff --git a/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesListItem.tsx b/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesListItem.tsx index 454c4e887c..43be4e0fd7 100644 --- a/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesListItem.tsx +++ b/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesListItem.tsx @@ -6,20 +6,18 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { - const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') { return null; } @@ -45,7 +43,12 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + Txn count diff --git a/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesTableItem.tsx b/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesTableItem.tsx index f3742611f3..85bfabb461 100644 --- a/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesTableItem.tsx +++ b/ui/txnBatches/zkEvmL2/ZkEvmTxnBatchesTableItem.tsx @@ -6,19 +6,17 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; const TxnBatchesTableItem = ({ item, isLoading }: Props) => { - const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') { return null; } @@ -39,9 +37,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => { - - { timeAgo } - + { - const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') { return null; } @@ -45,7 +43,12 @@ const ZkSyncTxnBatchesListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + Txn count diff --git a/ui/txnBatches/zkSyncL2/ZkSyncTxnBatchesTableItem.tsx b/ui/txnBatches/zkSyncL2/ZkSyncTxnBatchesTableItem.tsx index 674d53d41a..282dd598c7 100644 --- a/ui/txnBatches/zkSyncL2/ZkSyncTxnBatchesTableItem.tsx +++ b/ui/txnBatches/zkSyncL2/ZkSyncTxnBatchesTableItem.tsx @@ -6,19 +6,17 @@ import type { ZkSyncBatchesItem } from 'types/api/zkSyncL2'; import { route } from 'nextjs-routes'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkInternal from 'ui/shared/links/LinkInternal'; import ZkSyncL2TxnBatchStatus from 'ui/shared/statusTag/ZkSyncL2TxnBatchStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: ZkSyncBatchesItem; isLoading?: boolean }; const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => { - const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined'; - if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') { return null; } @@ -39,9 +37,12 @@ const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => { - - { timeAgo } - + { const dataTo = tx.to ? tx.to : tx.created_contract; - const timeAgo = useTimeAgoIncrement(tx.timestamp, enableTimeIncrement); - return ( @@ -58,11 +56,14 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI fontWeight="700" iconName={ tx.tx_types.includes('blob_transaction') ? 'blob' : undefined } /> - { tx.timestamp && ( - - { timeAgo } - - ) } + { tx.method && ( diff --git a/ui/txs/TxsTableItem.tsx b/ui/txs/TxsTableItem.tsx index 6b75d88f09..c47d7a2395 100644 --- a/ui/txs/TxsTableItem.tsx +++ b/ui/txs/TxsTableItem.tsx @@ -2,7 +2,6 @@ import { Tr, Td, VStack, - Skeleton, } from '@chakra-ui/react'; import { motion } from 'framer-motion'; import React from 'react'; @@ -10,13 +9,13 @@ import React from 'react'; import type { Transaction } from 'types/api/transaction'; import config from 'configs/app'; -import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import AddressFromTo from 'ui/shared/address/AddressFromTo'; import Tag from 'ui/shared/chakra/Tag'; import CurrencyValue from 'ui/shared/CurrencyValue'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxStatus from 'ui/shared/statusTag/TxStatus'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import TxFee from 'ui/shared/tx/TxFee'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; @@ -34,7 +33,6 @@ type Props = { const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, isLoading }: Props) => { const dataTo = tx.to ? tx.to : tx.created_contract; - const timeAgo = useTimeAgoIncrement(tx.timestamp, enableTimeIncrement); return ( - { tx.timestamp && { timeAgo } } + diff --git a/ui/userOps/UserOpsListItem.tsx b/ui/userOps/UserOpsListItem.tsx index e43fcbea7a..40139b1de7 100644 --- a/ui/userOps/UserOpsListItem.tsx +++ b/ui/userOps/UserOpsListItem.tsx @@ -4,13 +4,13 @@ import React from 'react'; import type { UserOpsItem } from 'types/api/userOps'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CurrencyValue from 'ui/shared/CurrencyValue'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; type Props = { @@ -21,8 +21,6 @@ type Props = { }; const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => { - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( @@ -33,7 +31,12 @@ const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => { Age - { timeAgo } + Status diff --git a/ui/userOps/UserOpsTableItem.tsx b/ui/userOps/UserOpsTableItem.tsx index 476e931fc2..7c57b7d84b 100644 --- a/ui/userOps/UserOpsTableItem.tsx +++ b/ui/userOps/UserOpsTableItem.tsx @@ -1,15 +1,15 @@ -import { Td, Tr, Skeleton } from '@chakra-ui/react'; +import { Td, Tr } from '@chakra-ui/react'; import React from 'react'; import type { UserOpsItem } from 'types/api/userOps'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import CurrencyValue from 'ui/shared/CurrencyValue'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; type Props = { @@ -20,15 +20,18 @@ import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; }; const UserOpsTableItem = ({ item, isLoading, showTx, showSender }: Props) => { - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( - { timeAgo } + diff --git a/ui/verifiedContracts/VerifiedContractsListItem.tsx b/ui/verifiedContracts/VerifiedContractsListItem.tsx index c0830fd8db..6cb4284b96 100644 --- a/ui/verifiedContracts/VerifiedContractsListItem.tsx +++ b/ui/verifiedContracts/VerifiedContractsListItem.tsx @@ -6,7 +6,6 @@ import type { VerifiedContract } from 'types/api/contracts'; import config from 'configs/app'; import { CONTRACT_LICENSES } from 'lib/contracts/licenses'; -import dayjs from 'lib/date/dayjs'; import { currencyUnits } from 'lib/units'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; @@ -14,6 +13,7 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import HashStringShorten from 'ui/shared/HashStringShorten'; import IconSvg from 'ui/shared/IconSvg'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; interface Props { data: VerifiedContract; @@ -86,9 +86,11 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => { Verified - - { dayjs(data.verified_at).fromNow() } - + diff --git a/ui/verifiedContracts/VerifiedContractsTableItem.tsx b/ui/verifiedContracts/VerifiedContractsTableItem.tsx index 5ba5053b5a..37932f80f0 100644 --- a/ui/verifiedContracts/VerifiedContractsTableItem.tsx +++ b/ui/verifiedContracts/VerifiedContractsTableItem.tsx @@ -6,12 +6,12 @@ import type { VerifiedContract } from 'types/api/contracts'; import config from 'configs/app'; import { CONTRACT_LICENSES } from 'lib/contracts/licenses'; -import dayjs from 'lib/date/dayjs'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import HashStringShorten from 'ui/shared/HashStringShorten'; import IconSvg from 'ui/shared/IconSvg'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; interface Props { data: VerifiedContract; @@ -90,9 +90,11 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => { - - { dayjs(data.verified_at).fromNow() } - + diff --git a/ui/withdrawals/beaconChain/BeaconChainWithdrawalsListItem.tsx b/ui/withdrawals/beaconChain/BeaconChainWithdrawalsListItem.tsx index 9b97b53e75..0d1c362b97 100644 --- a/ui/withdrawals/beaconChain/BeaconChainWithdrawalsListItem.tsx +++ b/ui/withdrawals/beaconChain/BeaconChainWithdrawalsListItem.tsx @@ -6,12 +6,12 @@ import type { BlockWithdrawalsItem } from 'types/api/block'; import type { WithdrawalsItem } from 'types/api/withdrawals'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import { currencyUnits } from 'lib/units'; import CurrencyValue from 'ui/shared/CurrencyValue'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const feature = config.features.beaconChain; @@ -74,7 +74,11 @@ const BeaconChainWithdrawalsListItem = ({ item, isLoading, view }: Props) => { <> Age - { dayjs(item.timestamp).fromNow() } + Value diff --git a/ui/withdrawals/beaconChain/BeaconChainWithdrawalsTableItem.tsx b/ui/withdrawals/beaconChain/BeaconChainWithdrawalsTableItem.tsx index f7c509e555..a4e4b37a82 100644 --- a/ui/withdrawals/beaconChain/BeaconChainWithdrawalsTableItem.tsx +++ b/ui/withdrawals/beaconChain/BeaconChainWithdrawalsTableItem.tsx @@ -5,10 +5,10 @@ import type { AddressWithdrawalsItem } from 'types/api/address'; import type { BlockWithdrawalsItem } from 'types/api/block'; import type { WithdrawalsItem } from 'types/api/withdrawals'; -import dayjs from 'lib/date/dayjs'; import CurrencyValue from 'ui/shared/CurrencyValue'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; type Props = ({ item: WithdrawalsItem; @@ -52,9 +52,12 @@ const BeaconChainWithdrawalsTableItem = ({ item, view, isLoading }: Props) => { ) } { view !== 'block' && ( - - { dayjs(item.timestamp).fromNow() } - + ) } diff --git a/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsListItem.tsx b/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsListItem.tsx index 4aa040f0f2..098d0ef848 100644 --- a/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsListItem.tsx +++ b/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsListItem.tsx @@ -10,13 +10,13 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkExternal from 'ui/shared/links/LinkExternal'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean }; const OptimisticL2WithdrawalsListItem = ({ item, isLoading }: Props) => { - const timeAgo = item.l2_timestamp ? dayjs(item.l2_timestamp).fromNow() : null; const timeToEnd = item.challenge_period_end ? dayjs(item.challenge_period_end).fromNow(true) + ' left' : null; if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { @@ -57,13 +57,15 @@ const OptimisticL2WithdrawalsListItem = ({ item, isLoading }: Props) => { /> - { timeAgo && ( + { item.l2_timestamp && ( <> Age - - { timeAgo } - + ) } diff --git a/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsTableItem.tsx b/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsTableItem.tsx index 8f8d4ee583..569729902d 100644 --- a/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsTableItem.tsx +++ b/ui/withdrawals/optimisticL2/OptimisticL2WithdrawalsTableItem.tsx @@ -9,13 +9,13 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import LinkExternal from 'ui/shared/links/LinkExternal'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean }; const OptimisticL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { - const timeAgo = item.l2_timestamp ? dayjs(item.l2_timestamp).fromNow() : 'N/A'; const timeToEnd = item.challenge_period_end ? dayjs(item.challenge_period_end).fromNow(true) + ' left' : ''; if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { @@ -47,9 +47,13 @@ const OptimisticL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { /> - - { timeAgo } - + { item.status === 'Ready for relay' && rollupFeature.L2WithdrawalUrl ? diff --git a/ui/withdrawals/shibarium/WithdrawalsListItem.tsx b/ui/withdrawals/shibarium/WithdrawalsListItem.tsx index bf34e0365c..fe1f456e56 100644 --- a/ui/withdrawals/shibarium/WithdrawalsListItem.tsx +++ b/ui/withdrawals/shibarium/WithdrawalsListItem.tsx @@ -1,23 +1,20 @@ -import { Skeleton } from '@chakra-ui/react'; import React from 'react'; import type { ShibariumWithdrawalsItem } from 'types/api/shibarium'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const feature = config.features.rollup; type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean }; const WithdrawalsListItem = ({ item, isLoading }: Props) => { - const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : null; - if (!(feature.isEnabled && feature.type === 'shibarium')) { return null; } @@ -69,7 +66,11 @@ const WithdrawalsListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + diff --git a/ui/withdrawals/shibarium/WithdrawalsTableItem.tsx b/ui/withdrawals/shibarium/WithdrawalsTableItem.tsx index 13d564b061..479dcfec05 100644 --- a/ui/withdrawals/shibarium/WithdrawalsTableItem.tsx +++ b/ui/withdrawals/shibarium/WithdrawalsTableItem.tsx @@ -1,22 +1,20 @@ -import { Td, Tr, Skeleton } from '@chakra-ui/react'; +import { Td, Tr } from '@chakra-ui/react'; import React from 'react'; import type { ShibariumWithdrawalsItem } from 'types/api/shibarium'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const feature = config.features.rollup; type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean }; const WithdrawalsTableItem = ({ item, isLoading }: Props) => { - const timeAgo = dayjs(item.timestamp).fromNow(); - if (!(feature.isEnabled && feature.type === 'shibarium')) { return null; } @@ -59,7 +57,12 @@ const WithdrawalsTableItem = ({ item, isLoading }: Props) => { /> - { timeAgo } + ); diff --git a/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsListItem.tsx b/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsListItem.tsx index ffa9253b1f..7ff895f10e 100644 --- a/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsListItem.tsx +++ b/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsListItem.tsx @@ -5,11 +5,11 @@ import React from 'react'; import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; @@ -20,8 +20,6 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => { return null; } - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( @@ -56,7 +54,11 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => { Age - { timeAgo } + L1 txn hash diff --git a/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsTableItem.tsx b/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsTableItem.tsx index 97e4405432..698d54be1b 100644 --- a/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsTableItem.tsx +++ b/ui/withdrawals/zkEvmL2/ZkEvmL2WithdrawalsTableItem.tsx @@ -5,10 +5,10 @@ import React from 'react'; import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2'; import config from 'configs/app'; -import dayjs from 'lib/date/dayjs'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; +import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip'; const rollupFeature = config.features.rollup; @@ -19,8 +19,6 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { return null; } - const timeAgo = dayjs(item.timestamp).fromNow(); - return ( @@ -49,9 +47,11 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { /> - - { timeAgo } - + { item.l1_transaction_hash ? ( diff --git a/yarn.lock b/yarn.lock index c21c0d2cbf..448bdcc3b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6229,6 +6229,11 @@ dependencies: undici-types "~5.26.4" +"@types/node@>=8.0.0 <15": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== + "@types/papaparse@^5.3.5": version "5.3.5" resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.5.tgz#e5ad94b1fe98e2a8ea0b03284b83d2cb252bbf39" @@ -7116,6 +7121,11 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +abortcontroller-polyfill@^1.4.0: + version "1.7.5" + resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed" + integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ== + acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -7179,6 +7189,17 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +airtable@^0.12.2: + version "0.12.2" + resolved "https://registry.yarnpkg.com/airtable/-/airtable-0.12.2.tgz#e53e66db86744f9bc684faa58881d6c9c12f0e6f" + integrity sha512-HS3VytUBTKj8A0vPl7DDr5p/w3IOGv6RXL0fv7eczOWAtj9Xe8ri4TAiZRXoOyo+Z/COADCj+oARFenbxhmkIg== + dependencies: + "@types/node" ">=8.0.0 <15" + abort-controller "^3.0.0" + abortcontroller-polyfill "^1.4.0" + lodash "^4.17.21" + node-fetch "^2.6.7" + ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"