Commit 65d9671a authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into lock-marketplace-action-bar

parents ad7d8d11 035256aa
...@@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC ...@@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC
const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP'); const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL'); const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_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'; const title = 'Marketplace';
...@@ -27,6 +29,7 @@ const config: Feature<( ...@@ -27,6 +29,7 @@ const config: Feature<(
securityReportsUrl: string | undefined; securityReportsUrl: string | undefined;
featuredApp: string | undefined; featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined; banner: { contentUrl: string; linkUrl: string } | undefined;
rating: { airtableApiKey: string; airtableBaseId: string } | undefined;
}> = (() => { }> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) { if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = { const props = {
...@@ -39,6 +42,10 @@ const config: Feature<( ...@@ -39,6 +42,10 @@ const config: Feature<(
contentUrl: bannerContentUrl, contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl, linkUrl: bannerLinkUrl,
} : undefined, } : undefined,
rating: ratingAirtableApiKey && ratingAirtableBaseId ? {
airtableApiKey: ratingAirtableApiKey,
airtableBaseId: ratingAirtableBaseId,
} : undefined,
}; };
if (configUrl) { if (configUrl) {
......
...@@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol ...@@ -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_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_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form 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_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true 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'} 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 ...@@ -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_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
...@@ -223,6 +223,22 @@ const marketplaceSchema = yup ...@@ -223,6 +223,22 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len // 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'), 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 const beaconChainSchema = yup
......
...@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com ...@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave 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_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_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=test
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=test
...@@ -86,3 +86,4 @@ frontend: ...@@ -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 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 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_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
...@@ -96,3 +96,4 @@ frontend: ...@@ -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 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_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_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
...@@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin ...@@ -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_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_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_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 #### Marketplace app configuration properties
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="m10 3.557-.555-.577c-.947-.946-2.205-.989-3.508-.97a4.876 4.876 0 0 0-3.469 1.547A5.443 5.443 0 0 0 1.001 7.22a5.46 5.46 0 0 0 1.363 3.709L10 18.5l7.636-7.58a5.46 5.46 0 0 0 1.363-3.709 5.443 5.443 0 0 0-1.467-3.664 4.876 4.876 0 0 0-3.47-1.546c-1.302-.02-2.56.023-3.507.969L10 3.557Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor" stroke="currentColor" stroke-width=".4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3.615.615 0 0 1 .315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/> <path d="M15.713 20a.724.724 0 0 1-.354-.09L10 16.956 4.64 19.91a.728.728 0 0 1-.796-.061.788.788 0 0 1-.256-.342.827.827 0 0 1-.045-.432l1.024-6.252L.229 8.394a.802.802 0 0 1-.207-.377.829.829 0 0 1 .015-.435.795.795 0 0 1 .232-.361.741.741 0 0 1 .379-.179L6.64 6.13 9.321.442a.78.78 0 0 1 .28-.323.731.731 0 0 1 .798 0 .78.78 0 0 1 .28.323l2.68 5.688 5.993.912a.74.74 0 0 1 .379.178.8.8 0 0 1 .232.361.828.828 0 0 1-.192.813l-4.338 4.428 1.024 6.252a.83.83 0 0 1-.167.644.762.762 0 0 1-.26.208.728.728 0 0 1-.319.074h.002Z" fill="currentColor"/>
</svg> </svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0a.6.6 0 0 0 .262-.061c.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052L9.9 15.852a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M15.711 20a.724.724 0 0 1-.352-.09Zm.005 0a.73.73 0 0 0 .314-.074.77.77 0 0 0 .26-.208.806.806 0 0 0 .167-.644l-1.024-6.252 4.338-4.428a.828.828 0 0 0 .192-.813.796.796 0 0 0-.232-.36.74.74 0 0 0-.38-.179l-5.86-.892a.25.25 0 0 1-.19-.14L10.679.442A.78.78 0 0 0 10.4.119a.732.732 0 0 0-.798 0 .78.78 0 0 0-.28.323L6.699 6.01a.25.25 0 0 1-.188.14l-5.862.892a.741.741 0 0 0-.38.179.795.795 0 0 0-.231.36.829.829 0 0 0-.015.436.802.802 0 0 0 .207.377l4.25 4.338a.25.25 0 0 1 .068.215l-1.004 6.127a.827.827 0 0 0 .045.432.788.788 0 0 0 .256.342.728.728 0 0 0 .796.061l5.24-2.886a.25.25 0 0 1 .24 0l5.24 2.886m-9.354-3.497a.25.25 0 0 0 .367.26L9.88 14.74a.25.25 0 0 1 .24 0l3.507 1.932a.25.25 0 0 0 .367-.26l-.678-4.141a.25.25 0 0 1 .068-.216l2.982-3.043a.25.25 0 0 0-.14-.423l-4.09-.622a.25.25 0 0 1-.189-.14l-1.72-3.653a.25.25 0 0 0-.453 0L8.053 7.826a.25.25 0 0 1-.189.141l-4.09.622a.25.25 0 0 0-.14.423l2.982 3.043a.25.25 0 0 1 .068.216l-.678 4.14ZM15.716 20h-.003Z" fill="currentColor"/>
</svg> </svg>
...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; ...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures { export interface GrowthBookFeatures {
test_value: string; test_value: string;
action_button_exp: boolean;
} }
export const growthBook = (() => { export const growthBook = (() => {
......
...@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer'; ...@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer';
const STEP = 10; const STEP = 10;
const MIN_ITEMS_NUM = 50; const MIN_ITEMS_NUM = 50;
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean) { export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean, minItemsNum: number = MIN_ITEMS_NUM) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM); const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(minItemsNum);
const { ref, inView } = useInView({ const { ref, inView } = useInView({
rootMargin: '200px', rootMargin: '200px',
triggerOnce: false, triggerOnce: false,
skip: !isEnabled || list.length <= MIN_ITEMS_NUM, skip: !isEnabled || list.length <= minItemsNum,
}); });
React.useEffect(() => { React.useEffect(() => {
......
...@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps> ...@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps>
position: 'top-right', position: 'top-right',
isClosable: true, isClosable: true,
containerStyle: { containerStyle: {
margin: 8, margin: 3,
marginBottom: 0,
}, },
variant: 'subtle', variant: 'subtle',
}; };
......
...@@ -20,6 +20,7 @@ export enum EventTypes { ...@@ -20,6 +20,7 @@ export enum EventTypes {
FILTERS = 'Filters', FILTERS = 'Filters',
BUTTON_CLICK = 'Button click', BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner', PROMO_BANNER = 'Promo banner',
APP_FEEDBACK = 'App feedback',
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? { ...@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace'; 'Source': 'Marketplace';
'Link': string; 'Link': string;
} : } :
Type extends EventTypes.APP_FEEDBACK ? {
'Action': 'Rating';
'Source': 'Discovery' | 'App modal' | 'App page';
'AppId': string;
'Score': number;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
import { apps } from './apps';
export const ratings = {
records: [
{
fields: {
appId: apps[0].id,
rating: 4.3,
},
},
],
};
import { apps } from './apps';
export const securityReports = [ export const securityReports = [
{ {
appName: 'token-approval-tracker', appName: apps[0].id,
doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet', doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet',
chainsData: { chainsData: {
'1': { '1': {
......
...@@ -10,6 +10,7 @@ function generateCspPolicy() { ...@@ -10,6 +10,7 @@ function generateCspPolicy() {
descriptors.googleFonts(), descriptors.googleFonts(),
descriptors.googleReCaptcha(), descriptors.googleReCaptcha(),
descriptors.growthBook(), descriptors.growthBook(),
descriptors.marketplace(),
descriptors.mixpanel(), descriptors.mixpanel(),
descriptors.monaco(), descriptors.monaco(),
descriptors.safe(), descriptors.safe(),
......
...@@ -31,8 +31,6 @@ const getCspReportUrl = () => { ...@@ -31,8 +31,6 @@ const getCspReportUrl = () => {
}; };
export function app(): CspDev.DirectiveDescriptor { export function app(): CspDev.DirectiveDescriptor {
const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace);
return { return {
'default-src': [ 'default-src': [
// KEY_WORDS.NONE, // KEY_WORDS.NONE,
...@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.addressVerification)?.api.endpoint, getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint, getFeaturePayload(config.features.nameService)?.api.endpoint,
getFeaturePayload(config.features.addressMetadata)?.api.endpoint, getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '',
// chain RPC server // chain RPC server
config.chain.rpcUrl, config.chain.rpcUrl,
......
...@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics'; ...@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts'; export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha'; export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook'; export { growthBook } from './growthBook';
export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel'; export { mixpanel } from './mixpanel';
export { monaco } from './monaco'; export { monaco } from './monaco';
export { safe } from './safe'; export { safe } from './safe';
......
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': [
'*',
],
};
}
...@@ -71,6 +71,8 @@ ...@@ -71,6 +71,8 @@
| "globe-b" | "globe-b"
| "globe" | "globe"
| "graphQL" | "graphQL"
| "heart_filled"
| "heart_outline"
| "hourglass" | "hourglass"
| "info" | "info"
| "integration/full" | "integration/full"
......
...@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => { ...@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => {
[$bg.variable]: `colors.${ bg }`, [$bg.variable]: `colors.${ bg }`,
[$fg.variable]: `colors.${ fg }`, [$fg.variable]: `colors.${ fg }`,
[$arrowBg.variable]: $bg.reference, [$arrowBg.variable]: $bg.reference,
maxWidth: props.maxWidth || props.maxW || 'unset', maxWidth: props.maxWidth || props.maxW || 'calc(100vw - 8px)',
marginX: '4px',
}; };
}); });
......
...@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia ...@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string; site?: string;
} }
export type AppRating = {
recordId: string;
value: number | undefined;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & { export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport; securityReport?: MarketplaceAppSecurityReport;
rating?: AppRating;
} }
export enum MarketplaceCategory { export enum MarketplaceCategory {
......
...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) ...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.waitForFunction(() => { await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1'; 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 page.mouse.move(240, 100);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -3,11 +3,11 @@ import React, { useMemo } from 'react'; ...@@ -3,11 +3,11 @@ import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves'; import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
isPlaceholderData: boolean; isPlaceholderData: boolean;
...@@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => { ...@@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => {
Action Action
</Text> </Text>
</Flex> </Flex>
<Text color="text_secondary" fontSize="sm" fontWeight={ 500 }> <TimeAgoWithTooltip
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
</Text> color="text_secondary"
borderRadius="sm"
fontWeight={ 500 }
/>
</Flex> </Flex>
</Skeleton> </Skeleton>
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }> <Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
......
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 React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves'; import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
isPlaceholderData: boolean; isPlaceholderData: boolean;
...@@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => { ...@@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => {
return ( return (
<Tr> <Tr>
<Td px={ 3 } py="18px" fontSize="sm" > <Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" flexShrink={ 0 } isLoaded={ !props.isPlaceholderData }> <TimeAgoWithTooltip
<Text as="span" color="text_secondary"> timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } isLoading={ props.isPlaceholderData }
</Text> color="text_secondary"
</Skeleton> borderRadius="sm"
flexShrink={ 0 }
/>
</Td> </Td>
<Td px={ 3 } py="18px" fontSize="sm" > <Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }> <Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
......
...@@ -6,11 +6,11 @@ import type { Block } from 'types/api/block'; ...@@ -6,11 +6,11 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & { type Props = Block & {
page: number; page: number;
...@@ -18,7 +18,6 @@ type Props = Block & { ...@@ -18,7 +18,6 @@ type Props = Block & {
}; };
const AddressBlocksValidatedListItem = (props: Props) => { const AddressBlocksValidatedListItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props); const totalReward = getBlockTotalReward(props);
return ( return (
...@@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => { ...@@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => {
noIcon noIcon
fontWeight={ 700 } fontWeight={ 700 }
/> />
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Flex> </Flex>
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Txn</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Txn</Skeleton>
...@@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => { ...@@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => {
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Gas used</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Gas used</Skeleton>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ BigNumber(props.gas_used || 0).toFormat() }</Skeleton> <Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ BigNumber(props.gas_used || 0).toFormat() }</Skeleton>
{ props.gas_used && props.gas_used !== '0' && ( <BlockGasUsed
<Utilization gasUsed={ props.gas_used }
colorScheme="gray" gasLimit={ props.gas_limit }
value={ BigNumber(props.gas_used || 0).dividedBy(BigNumber(props.gas_limit)).toNumber() } isLoading={ props.isLoading }
isLoading={ props.isLoading } />
/>
) }
</Flex> </Flex>
{ !config.UI.views.block.hiddenFields?.total_reward && ( { !config.UI.views.block.hiddenFields?.total_reward && (
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
......
...@@ -6,9 +6,9 @@ import type { Block } from 'types/api/block'; ...@@ -6,9 +6,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; 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 BlockEntity from 'ui/shared/entities/block/BlockEntity';
import Utilization from 'ui/shared/Utilization/Utilization'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & { type Props = Block & {
page: number; page: number;
...@@ -16,7 +16,6 @@ type Props = Block & { ...@@ -16,7 +16,6 @@ type Props = Block & {
}; };
const AddressBlocksValidatedTableItem = (props: Props) => { const AddressBlocksValidatedTableItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props); const totalReward = getBlockTotalReward(props);
return ( return (
...@@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => { ...@@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
/> />
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } display="inline-block" fontWeight="500"> <Skeleton isLoaded={ !props.isLoading } display="inline-block" fontWeight="500">
...@@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => { ...@@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
<Skeleton isLoaded={ !props.isLoading } flexBasis="80px"> <Skeleton isLoaded={ !props.isLoading } flexBasis="80px">
{ BigNumber(props.gas_used || 0).toFormat() } { BigNumber(props.gas_used || 0).toFormat() }
</Skeleton> </Skeleton>
{ props.gas_used && props.gas_used !== '0' && ( <BlockGasUsed
<Utilization gasUsed={ props.gas_used }
colorScheme="gray" gasLimit={ props.gas_limit }
value={ BigNumber(props.gas_used).dividedBy(BigNumber(props.gas_limit)).toNumber() } isLoading={ props.isLoading }
isLoading={ props.isLoading } />
/>
) }
</Flex> </Flex>
</Td> </Td>
{ !config.UI.views.block.hiddenFields?.total_reward && ( { !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -5,11 +5,11 @@ import React from 'react'; ...@@ -5,11 +5,11 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts'; import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & { type Props = AddressCoinBalanceHistoryItem & {
page: number; page: number;
...@@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & { ...@@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceListItem = (props: Props) => { const AddressCoinBalanceListItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI); const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO); const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return ( return (
<ListItemMobile rowGap={ 2 } isAnimated> <ListItemMobile rowGap={ 2 } isAnimated>
...@@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => { ...@@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => {
) } ) }
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Age</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Age</Skeleton>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ props.block_timestamp }
enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
/>
</Flex> </Flex>
</ListItemMobile> </ListItemMobile>
); );
......
...@@ -5,9 +5,9 @@ import React from 'react'; ...@@ -5,9 +5,9 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts'; import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & { type Props = AddressCoinBalanceHistoryItem & {
page: number; page: number;
...@@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & { ...@@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceTableItem = (props: Props) => { const AddressCoinBalanceTableItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI); const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO); const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return ( return (
<Tr> <Tr>
...@@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => { ...@@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => {
) } ) }
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.block_timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td isNumeric pr={ 1 }> <Td isNumeric pr={ 1 }>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
......
...@@ -5,7 +5,6 @@ import React from 'react'; ...@@ -5,7 +5,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity'; ...@@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }; type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean };
...@@ -47,9 +47,13 @@ const TxInternalsListItem = ({ ...@@ -47,9 +47,13 @@ const TxInternalsListItem = ({
fontWeight={ 700 } fontWeight={ 700 }
truncation="constant_long" truncation="constant_long"
/> />
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> <TimeAgoWithTooltip
<span>{ dayjs(timestamp).fromNow() }</span> timestamp={ timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
<HStack spacing={ 1 }> <HStack spacing={ 1 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Block</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Block</Skeleton>
......
...@@ -5,12 +5,12 @@ import React from 'react'; ...@@ -5,12 +5,12 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean } type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }
...@@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({ ...@@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title; const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const toData = to ? to : createdContract; const toData = to ? to : createdContract;
const timeAgo = useTimeAgoIncrement(timestamp, true);
return ( return (
<Tr alignItems="top"> <Tr alignItems="top">
<Td verticalAlign="middle"> <Td verticalAlign="middle">
...@@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({ ...@@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({
noIcon noIcon
truncation="constant_long" truncation="constant_long"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> timestamp={ timestamp }
<span>{ timeAgo }</span> enableIncrement
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
......
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 BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
...@@ -18,6 +18,7 @@ import { space } from 'lib/html-entities'; ...@@ -18,6 +18,7 @@ import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
...@@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import PrevNext from 'ui/shared/PrevNext'; import PrevNext from 'ui/shared/PrevNext';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
import StatusTag from 'ui/shared/statusTag/StatusTag'; import StatusTag from 'ui/shared/statusTag/StatusTag';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo'; import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
...@@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => { ...@@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => {
const router = useRouter(); const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash); const heightOrHash = getQueryParamString(router.query.height_or_hash);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isPlaceholderData } = query; const { data, isPlaceholderData } = query;
const handleCutClick = React.useCallback(() => { const handleCutClick = React.useCallback(() => {
...@@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => { ...@@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => {
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas_used || 0).toFormat() } { BigNumber(data.gas_used || 0).toFormat() }
</Skeleton> </Skeleton>
<Utilization <BlockGasUsed
ml={ 4 } gasUsed={ data.gas_used }
colorScheme="gray" gasLimit={ data.gas_limit }
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
ml={ 4 }
gasTarget={ data.gas_target_percentage }
/> />
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isPlaceholderData }/>
</>
) }
</DetailsInfoItem.Value> </DetailsInfoItem.Value>
<DetailsInfoItem.Label <DetailsInfoItem.Label
......
import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
interface Props {
ts: string;
isEnabled?: boolean;
isLoading?: boolean;
className?: string;
}
const BlockTimestamp = ({ ts, isEnabled, isLoading, className }: Props) => {
const timeAgo = useTimeAgoIncrement(ts, isEnabled);
return (
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight={ 400 } className={ className } display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
);
};
export default React.memo(chakra(BlockTimestamp));
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 BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import React from 'react'; import React from 'react';
...@@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward'; ...@@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units'; 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 AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; 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'; import Utilization from 'ui/shared/Utilization/Utilization';
interface Props { interface Props {
...@@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0); const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0); const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
return ( return (
<ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated> <ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated>
<Flex justifyContent="space-between" w="100%"> <Flex justifyContent="space-between" w="100%">
...@@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
fontWeight={ 600 } fontWeight={ 600 }
/> />
</Flex> </Flex>
<BlockTimestamp ts={ data.timestamp } isEnabled={ enableTimeIncrement } isLoading={ isLoading }/> <TimeAgoWithTooltip
timestamp={ data.timestamp }
enableIncrement={ enableTimeIncrement }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
/>
</Flex> </Flex>
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Size</Text> <Text fontWeight={ 500 }>Size</Text>
...@@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary" mr={ 4 }> <Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary" mr={ 4 }>
<span>{ BigNumber(data.gas_used || 0).toFormat() }</span> <span>{ BigNumber(data.gas_used || 0).toFormat() }</span>
</Skeleton> </Skeleton>
<Utilization colorScheme="gray" value={ BigNumber(data.gas_used || 0).div(BigNumber(data.gas_limit)).toNumber() } isLoading={ isLoading }/> <BlockGasUsed
{ data.gas_target_percentage && ( gasUsed={ data.gas_used }
<> gasLimit={ data.gas_limit }
<TextSeparator color={ separatorColor } mx={ 1 }/> isLoading={ isLoading }
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/> gasTarget={ data.gas_target_percentage }
</> />
) }
</Flex> </Flex>
</Box> </Box>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -10,13 +10,12 @@ import { route } from 'nextjs-routes'; ...@@ -10,13 +10,12 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts'; 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 AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; 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'; import Utilization from 'ui/shared/Utilization/Utilization';
interface Props { interface Props {
...@@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0); const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0); const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const burntFeesIconColor = useColorModeValue('gray.500', 'inherit'); const burntFeesIconColor = useColorModeValue('gray.500', 'inherit');
return ( return (
...@@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
/> />
</Tooltip> </Tooltip>
</Flex> </Flex>
<BlockTimestamp ts={ data.timestamp } isEnabled={ enableTimeIncrement } isLoading={ isLoading }/> <TimeAgoWithTooltip
timestamp={ data.timestamp }
enableIncrement={ enableTimeIncrement }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
/>
</Td> </Td>
<Td fontSize="sm"> <Td fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block"> <Skeleton isLoaded={ !isLoading } display="inline-block">
...@@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Td fontSize="sm"> <Td fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<Flex mt={ 2 }> <Flex mt={ 2 }>
<Tooltip label={ isLoading ? undefined : 'Gas Used %' }> <BlockGasUsed
<Box> gasUsed={ data.gas_used }
<Utilization gasLimit={ data.gas_limit }
colorScheme="gray" isLoading={ isLoading }
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } gasTarget={ data.gas_target_percentage }
isLoading={ isLoading } />
/>
</Box>
</Tooltip>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/>
</>
) }
</Flex> </Flex>
</Td> </Td>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -5,20 +5,18 @@ import React from 'react'; ...@@ -5,20 +5,18 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { ...@@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
......
...@@ -5,18 +5,17 @@ import React from 'react'; ...@@ -5,18 +5,17 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
...@@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<TxEntityL1 <TxEntityL1
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium'; import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean }; type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsListItem = ({ item, isLoading }: Props) => { const DepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
} }
...@@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</ListItemMobileGrid.Container> </ListItemMobileGrid.Container>
......
import { Td, Tr, Skeleton } from '@chakra-ui/react'; import { Td, Tr } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium'; import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean }; type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsTableItem = ({ item, isLoading }: Props) => { const DepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
...@@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -5,11 +5,11 @@ import React from 'react'; ...@@ -5,11 +5,11 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<ListItemMobileGrid.Container> <ListItemMobileGrid.Container>
...@@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label>
......
...@@ -5,10 +5,10 @@ import React from 'react'; ...@@ -5,10 +5,10 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
...@@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ item.l2_transaction_hash ? ( { item.l2_transaction_hash ? (
......
...@@ -4,11 +4,11 @@ import React from 'react'; ...@@ -4,11 +4,11 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { ...@@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.created_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.created_at }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
...@@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { ...@@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
{ item.resolved_at && ( { item.resolved_at && (
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>Resolution age</ListItemMobileGrid.Label><ListItemMobileGrid.Value> <ListItemMobileGrid.Label isLoading={ isLoading }>Resolution age</ListItemMobileGrid.Label><ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.resolved_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.resolved_at }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
......
...@@ -4,10 +4,10 @@ import React from 'react'; ...@@ -4,10 +4,10 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const faultProofSystemFeature = config.features.faultProofSystem; const faultProofSystemFeature = config.features.faultProofSystem;
...@@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => { ...@@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.created_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.created_at }
isLoading={ isLoading }
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.status }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ item.status }</Skeleton>
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !isLoading } display="inline-block"> <TimeAgoWithTooltip
{ item.resolved_at ? dayjs(item.resolved_at).fromNow() : 'N/A' } timestamp={ item.resolved_at }
</Skeleton> fallbackText="N/A"
isLoading={ isLoading }
display="inline-block"
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -12,9 +12,9 @@ import type { Block } from 'types/api/block'; ...@@ -12,9 +12,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
block: Block; block: Block;
...@@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => { ...@@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => {
fontWeight={ 500 } fontWeight={ 500 }
mr="auto" mr="auto"
/> />
<BlockTimestamp <TimeAgoWithTooltip
ts={ block.timestamp } timestamp={ block.timestamp }
isEnabled={ !isLoading } enableIncrement={ !isLoading }
isLoading={ isLoading } isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm" fontSize="sm"
flexShrink={ 0 } flexShrink={ 0 }
ml={ 2 } ml={ 2 }
......
...@@ -9,11 +9,11 @@ import React from 'react'; ...@@ -9,11 +9,11 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
...@@ -23,7 +23,6 @@ type Props = { ...@@ -23,7 +23,6 @@ type Props = {
} }
const LatestDepositsItem = ({ item, isLoading }: Props) => { const LatestDepositsItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (!feature.isEnabled || feature.type !== 'optimistic') { if (!feature.isEnabled || feature.type !== 'optimistic') {
...@@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { ...@@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
<> <>
<Flex justifyContent="space-between" alignItems="center" mb={ 1 }> <Flex justifyContent="space-between" alignItems="center" mb={ 1 }>
{ l1BlockLink } { l1BlockLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_block_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Flex> </Flex>
<Grid gridTemplateColumns="56px auto"> <Grid gridTemplateColumns="56px auto">
<Skeleton isLoaded={ !isLoading } my="5px" w="fit-content"> <Skeleton isLoaded={ !isLoading } my="5px" w="fit-content">
...@@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { ...@@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
L1 txn L1 txn
</Skeleton> </Skeleton>
{ l1TxLink } { l1TxLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary" w="fit-content" h="fit-content" my="2px"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_block_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
w="fit-content"
h="fit-content"
my="2px"
/>
<Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px"> <Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px">
L2 txn L2 txn
</Skeleton> </Skeleton>
......
...@@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -29,7 +29,6 @@ type Props = { ...@@ -29,7 +29,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => { const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; 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; const columnNum = config.UI.views.tx.hiddenFields?.value && config.UI.views.tx.hiddenFields?.tx_fee ? 2 : 3;
return ( return (
...@@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
hash={ tx.hash } hash={ tx.hash }
fontWeight="700" fontWeight="700"
/> />
{ tx.timestamp && ( <TimeAgoWithTooltip
<Skeleton timestamp={ tx.timestamp }
isLoaded={ !isLoading } enableIncrement
color="text_secondary" isLoading={ isLoading }
fontWeight="400" color="text_secondary"
fontSize="sm" fontWeight="400"
flexShrink={ 0 } fontSize="sm"
ml={ 2 } flexShrink={ 0 }
> ml={ 2 }
<span>{ timeAgo }</span> />
</Skeleton>
) }
</Flex> </Flex>
</Box> </Box>
</Flex> </Flex>
......
...@@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -28,7 +28,6 @@ type Props = { ...@@ -28,7 +28,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => { const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
return ( return (
<Box <Box
...@@ -60,11 +59,15 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -60,11 +59,15 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
fontWeight="700" fontWeight="700"
truncation="constant_long" truncation="constant_long"
/> />
{ tx.timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm" ml={ 3 }> timestamp={ tx.timestamp }
<span>{ timeAgo }</span> enableIncrement
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
ml={ 3 }
/>
</Flex> </Flex>
<AddressFromTo <AddressFromTo
from={ tx.from } from={ tx.from }
......
...@@ -10,10 +10,10 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; ...@@ -10,10 +10,10 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
batch: ZkEvmL2TxnBatchesItem; batch: ZkEvmL2TxnBatchesItem;
...@@ -44,10 +44,13 @@ const LatestZkevmL2BatchItem = ({ batch, isLoading }: Props) => { ...@@ -44,10 +44,13 @@ const LatestZkevmL2BatchItem = ({ batch, isLoading }: Props) => {
fontWeight={ 500 } fontWeight={ 500 }
mr="auto" mr="auto"
/> />
<BlockTimestamp <TimeAgoWithTooltip
ts={ batch.timestamp } timestamp={ batch.timestamp }
isEnabled={ !isLoading } enableIncrement={ !isLoading }
isLoading={ isLoading } isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm" fontSize="sm"
flexShrink={ 0 } flexShrink={ 0 }
ml={ 2 } ml={ 2 }
......
...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render }) ...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
test('no data', async({ mockApiResponse, mockAssetResponse, render }) => { test('no data', async({ mockApiResponse, mockAssetResponse, render }) => {
await mockApiResponse('stats', statsMock.noChartData); await mockApiResponse('stats', statsMock.noChartData);
await mockApiResponse('stats_charts_txs', dailyTxsMock.noData); 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(<ChainIndicators/>); const component = await render(<ChainIndicators/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
...@@ -72,7 +72,7 @@ const AppSecurityReport = ({ ...@@ -72,7 +72,7 @@ const AppSecurityReport = ({
className={ className } className={ className }
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}> <PopoverContent w={{ base: 'calc(100vw - 24px)', lg: '328px' }} mx={{ base: 3, lg: 0 }}>
<PopoverBody px="26px" py="20px" fontSize="sm"> <PopoverBody px="26px" py="20px" fontSize="sm">
<Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text> <Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text>
<Flex alignItems="center" justifyContent="space-between" py={ 1.5 }> <Flex alignItems="center" justifyContent="space-between" py={ 1.5 }>
......
...@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace'; ...@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
import FeaturedAppMobile from './FeaturedAppMobile'; import FeaturedAppMobile from './FeaturedAppMobile';
...@@ -136,10 +136,7 @@ const FeaturedApp = ({ ...@@ -136,10 +136,7 @@ const FeaturedApp = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/> />
) } ) }
</Flex> </Flex>
......
...@@ -4,8 +4,7 @@ import React from 'react'; ...@@ -4,8 +4,7 @@ import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import IconSvg from 'ui/shared/IconSvg'; import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink'; import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
...@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({ ...@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ onFavoriteClick } onClick={ onFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/> />
) } ) }
</Flex> </Flex>
......
...@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( ...@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<> <>
You don{ apos }t have any favorite apps.<br/> You don{ apos }t have any favorite apps.<br/>
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites. Click on the <IconSvg name="heart_outline" boxSize={ 5 } mb={ -1 } color="gray.400"/> icon on the app{ apos }s card to add it to Favorites.
</> </>
) : ( ) : (
<> <>
......
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 (
<IconSvg
name={ isFavorite ? 'heart_filled' : 'heart_outline' }
color={ color || defaultColor }
boxSize={ 5 }
/>
);
};
export default FavoriteIcon;
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 type { MouseEvent } from 'react';
import React, { useCallback } 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 useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
interface Props extends MarketplaceAppWithSecurityReport { interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void; onInfoClick: (id: string) => void;
...@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport { ...@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport {
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
className?: string; className?: string;
showContractList: (id: string, type: ContractListTypes) => void; showContractList: (id: string, type: ContractListTypes) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
} }
const MarketplaceAppCard = ({ const MarketplaceAppCard = ({
...@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({ ...@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({
securityReport, securityReport,
className, className,
showContractList, showContractList,
rating,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => { }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const categoriesLabel = categories.join(', '); const categoriesLabel = categories.join(', ');
...@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({ ...@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
</Skeleton> </Skeleton>
{ !isLoading && ( { !isLoading && (
<Box <Flex
display="flex"
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
marginTop="auto" marginTop="auto"
...@@ -156,20 +168,29 @@ const MarketplaceAppCard = ({ ...@@ -156,20 +168,29 @@ const MarketplaceAppCard = ({
> >
More info More info
</Link> </Link>
<IconButton <Flex alignItems="center" gap={ 3 }>
aria-label="Mark as favorite" <Rating
title="Mark as favorite" appId={ id }
variant="ghost" rating={ rating }
colorScheme="gray" userRating={ userRating }
w={{ base: 6, md: '30px' }} rate={ rateApp }
h={{ base: 6, md: '30px' }} isSending={ isRatingSending }
onClick={ handleFavoriteClick } isLoading={ isRatingLoading }
icon={ isFavorite ? canRate={ canRate }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> : source="Discovery"
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/> />
} <IconButton
/> aria-label="Mark as favorite"
</Box> title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={{ base: 6, md: '30px' }}
h={{ base: 6, md: '30px' }}
onClick={ handleFavoriteClick }
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
</Flex>
</Flex>
) } ) }
{ securityReport && ( { securityReport && (
......
...@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => { ...@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
textAlign="center" textAlign="center"
padding={ 2 } padding={ 2 }
openDelay={ 300 } openDelay={ 300 }
maxW={ 400 } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
> >
<IconSvg <IconSvg
name={ icon } name={ icon }
......
...@@ -15,13 +15,27 @@ const props = { ...@@ -15,13 +15,27 @@ const props = {
data: { data: {
...appsMock[0], ...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'], securityReport: securityReportsMock[0].chainsData['1'],
rating: {
recordId: 'test',
value: 4.3,
},
} as MarketplaceAppWithSecurityReport, } as MarketplaceAppWithSecurityReport,
isFavorite: false, isFavorite: false,
userRating: undefined,
rateApp: () => {},
isRatingSending: false,
isRatingLoading: false,
canRate: undefined,
}; };
const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse }) => { const testFn: Parameters<typeof test>[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 mockAssetResponse(appsMock[0].logo, './playwright/mocks/image_s.jpg');
await render(<MarketplaceAppModal { ...props }/>); await render(<MarketplaceAppModal { ...props }/>);
await page.getByText('Launch app').focus();
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}; };
......
...@@ -4,9 +4,10 @@ import { ...@@ -4,9 +4,10 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from '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 { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg'; ...@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppModalLink from './MarketplaceAppModalLink'; 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 = { type Props = {
onClose: () => void; onClose: () => void;
...@@ -22,6 +29,11 @@ type Props = { ...@@ -22,6 +29,11 @@ type Props = {
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void; onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport; data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void; showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
} }
const MarketplaceAppModal = ({ const MarketplaceAppModal = ({
...@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({ ...@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({
onFavoriteClick, onFavoriteClick,
data, data,
showContractList: showContractListProp, showContractList: showContractListProp,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => { }: Props) => {
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const { const {
id, id,
title, title,
...@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({ ...@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({
logoDarkMode, logoDarkMode,
categories, categories,
securityReport, securityReport,
rating,
} = data; } = data;
const socialLinks = [ const socialLinks = [
...@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({ ...@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({
w={{ base: '72px', md: '144px' }} w={{ base: '72px', md: '144px' }}
h={{ base: '72px', md: '144px' }} h={{ base: '72px', md: '144px' }}
marginRight={{ base: 6, md: 8 }} marginRight={{ base: 6, md: 8 }}
gridRow={{ base: '1 / 3', md: '1 / 4' }} gridRow={{ base: '1 / 3', md: '1 / 5' }}
> >
<Image <Image
src={ logoUrl } src={ logoUrl }
...@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({ ...@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({
<Heading <Heading
as="h2" as="h2"
gridColumn={ 2 } gridColumn={ 2 }
fontSize={{ base: '2xl', md: '3xl' }} fontSize={{ base: '2xl', md: '32px' }}
fontWeight="medium" fontWeight="medium"
lineHeight={ 1 } lineHeight={{ md: 10 }}
color="blue.600" mb={{ md: 2 }}
> >
{ title } { title }
</Heading> </Heading>
...@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({ ...@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({
<Text <Text
variant="secondary" variant="secondary"
gridColumn={ 2 } gridColumn={ 2 }
fontSize="sm" fontSize={{ base: 'sm', md: 'md' }}
fontWeight="normal" fontWeight="normal"
lineHeight={ 1 } lineHeight={{ md: 6 }}
> >
By{ nbsp }{ author } By{ nbsp }{ author }
</Text> </Text>
{ isRatingEnabled && (
<Box
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 3 }}
py={{ base: 0, md: 1.5 }}
width="fit-content"
>
<Rating
appId={ id }
rating={ rating }
userRating={ userRating }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
fullView
canRate={ canRate }
source="App modal"
/>
</Box>
) }
<Box <Box
gridColumn={{ base: '1 / 3', md: 2 }} gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 0 }} marginTop={{ base: 6, md: 3 }}
> >
<Flex flexWrap="wrap" gap={ 6 }> <Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', md: 'auto' }}> <Flex width={{ base: '100%', md: 'auto' }}>
...@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({ ...@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite } color={ useColorModeValue('blue.700', 'gray.400') }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color={ starOutlineIconColor }/> }
/> />
</Flex> </Flex>
</Flex> </Flex>
......
...@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop'; ...@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal'; import ContractListModal from './ContractListModal';
import MarketplaceAppInfo from './MarketplaceAppInfo'; import MarketplaceAppInfo from './MarketplaceAppInfo';
import Rating from './Rating/Rating';
import useRatings from './Rating/useRatings';
type Props = { type Props = {
appId: string;
data: MarketplaceAppOverview | undefined; data: MarketplaceAppOverview | undefined;
isLoading: boolean; isLoading: boolean;
securityReport?: MarketplaceAppSecurityReport; securityReport?: MarketplaceAppSecurityReport;
} }
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) => {
const [ contractListType, setContractListType ] = React.useState<ContractListTypes>(); const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext(); const appProps = useAppContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const goBackUrl = React.useMemo(() => { const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) { if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
return appProps.referrer; return appProps.referrer;
...@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
source="App page" source="App page"
/> />
) } ) }
<Rating
appId={ appId }
rating={ ratings[appId] }
userRating={ userRatings[appId] }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="App page"
/>
{ !isMobile && ( { !isMobile && (
<Flex flex="1" justifyContent="flex-end"> <Flex flex="1" justifyContent="flex-end">
{ config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> } { config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> }
......
import { Grid } from '@chakra-ui/react'; import { Grid, Box } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MouseEvent } 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 * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult'; import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard'; import MarketplaceAppCard from './MarketplaceAppCard';
import type { RateFunction } from './Rating/useRatings';
type Props = { type Props = {
apps: Array<MarketplaceAppWithSecurityReport>; apps: Array<MarketplaceAppWithSecurityReport>;
...@@ -18,9 +20,19 @@ type Props = { ...@@ -18,9 +20,19 @@ type Props = {
selectedCategoryId?: string; selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void; showContractList: (id: string, type: ContractListTypes) => void;
userRatings: Record<string, AppRating>;
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) => { const handleInfoClick = useCallback((id: string) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id); showAppInfo(id);
...@@ -31,37 +43,46 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -31,37 +43,46 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
}, [ onFavoriteClick ]); }, [ onFavoriteClick ]);
return apps.length > 0 ? ( return apps.length > 0 ? (
<Grid <>
templateColumns={{ <Grid
md: 'repeat(auto-fill, minmax(230px, 1fr))', templateColumns={{
lg: 'repeat(auto-fill, minmax(260px, 1fr))', md: 'repeat(auto-fill, minmax(230px, 1fr))',
}} lg: 'repeat(auto-fill, minmax(260px, 1fr))',
autoRows="1fr" }}
gap={{ base: '16px', md: '24px' }} autoRows="1fr"
marginTop={{ base: 0, lg: 3 }} gap={{ base: '16px', md: '24px' }}
> marginTop={{ base: 0, lg: 3 }}
{ apps.map((app, index) => ( >
<MarketplaceAppCard { apps.slice(0, renderedItemsNum).map((app, index) => (
key={ app.id + (isLoading ? index : '') } <MarketplaceAppCard
onInfoClick={ handleInfoClick } key={ app.id + (isLoading ? index : '') }
id={ app.id } onInfoClick={ handleInfoClick }
external={ app.external } id={ app.id }
url={ app.url } external={ app.external }
title={ app.title } url={ app.url }
logo={ app.logo } title={ app.title }
logoDarkMode={ app.logoDarkMode } logo={ app.logo }
shortDescription={ app.shortDescription } logoDarkMode={ app.logoDarkMode }
categories={ app.categories } shortDescription={ app.shortDescription }
isFavorite={ favoriteApps.includes(app.id) } categories={ app.categories }
onFavoriteClick={ handleFavoriteClick } isFavorite={ favoriteApps.includes(app.id) }
isLoading={ isLoading } onFavoriteClick={ handleFavoriteClick }
internalWallet={ app.internalWallet } isLoading={ isLoading }
onAppClick={ onAppClick } internalWallet={ app.internalWallet }
securityReport={ app.securityReport } onAppClick={ onAppClick }
showContractList={ showContractList } securityReport={ app.securityReport }
/> showContractList={ showContractList }
)) } rating={ app.rating }
</Grid> userRating={ userRatings[app.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
)) }
</Grid>
<Box ref={ cutRef } h={ 0 }/>
</>
) : ( ) : (
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/> <EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
); );
......
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<EventTypes.APP_FEEDBACK>['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 (
<Flex alignItems="center">
<Spinner size="md"/>
<Text fontSize="md" ml={ 3 }>Sending your feedback</Text>
</Flex>
);
}
return (
<>
<Flex alignItems="center">
{ userRating && (
<IconSvg name="verified" color="green.400" boxSize="30px" mr={ 1 } ml="-5px"/>
) }
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
{ userRating ? 'App is already rated by you' : 'How was your experience?' }
</Text>
</Flex>
<Flex alignItems="center" h="32px">
<Stars
filledIndex={ filledIndex }
onMouseOverFactory={ handleMouseOverFactory }
onMouseOut={ handleMouseOut }
onClickFactory={ handleRateFactory }
/>
{ (filledIndex >= 0) && (
<Text fontSize="md" ml={ 3 }>
{ ratingDescriptions[filledIndex] }
</Text>
) }
</Flex>
</>
);
};
export default PopoverContent;
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<EventTypes.APP_FEEDBACK>['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 (
<Skeleton
display="flex"
alignItems="center"
isLoaded={ !isLoading }
w={ (isLoading && !fullView) ? '40px' : 'auto' }
>
{ fullView && (
<>
<Stars filledIndex={ (rating?.value || 0) - 1 }/>
<Text fontSize="md" ml={ 2 }>{ rating?.value }</Text>
</>
) }
<Box ref={ popoverRef }>
<Popover isOpen={ isOpen } placement="bottom" isLazy>
<PopoverTrigger>
<TriggerButton
rating={ rating?.value }
fullView={ fullView }
isActive={ isOpen }
onClick={ onToggle }
canRate={ canRate }
/>
</PopoverTrigger>
<PopoverContent w="250px" mx={ 3 }>
<PopoverBody p={ 4 }>
<Content
appId={ appId }
rating={ rating }
userRating={ userRating }
rate={ rate }
isSending={ isSending }
source={ source }
/>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
</Skeleton>
);
};
export default Rating;
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<HTMLDivElement>;
onMouseOut?: () => void;
onClickFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
};
const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: Props) => {
const disabledStarColor = useColorModeValue('gray.200', 'gray.700');
const outlineStartColor = onMouseOverFactory ? 'gray.400' : disabledStarColor;
return (
<Flex>
{ Array(5).fill(null).map((_, index) => (
<IconSvg
key={ index }
name={ filledIndex >= 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) }
/>
)) }
</Flex>
);
};
export default Stars;
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.<br/>Only wallets with 5+ transactions are eligible</>;
}
if (!canRate) {
return <>Brand new wallets cannot leave ratings.<br/>Please connect a wallet with 5 or more transactions on this chain</>;
}
return <>Ratings come from verified users.<br/>Click here to rate!</>;
};
const TriggerButton = (
{ rating, fullView, isActive, onClick, canRate }: Props,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
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 (
<Tooltip
label={ getTooltipText(canRate) }
openDelay={ 100 }
textAlign="center"
closeOnClick={ Boolean(canRate) || isMobile }
isOpen={ isMobile ? isOpen : undefined }
>
<Button
ref={ ref }
size="xs"
variant="outline"
border={ 0 }
p={ 0 }
onClick={ handleClick }
fontSize={ fullView ? 'md' : 'sm' }
fontWeight={ fullView ? '400' : '500' }
lineHeight="21px"
ml={ fullView ? 3 : 0 }
isActive={ isActive }
onFocusCapture={ onFocusCapture }
cursor={ canRate ? 'pointer' : 'default' }
onMouseLeave={ isMobile ? onClose : undefined }
>
{ !fullView && (
<IconSvg
name={ rating ? 'star_filled' : 'star_outline' }
color={ rating ? 'yellow.400' : 'gray.400' }
boxSize={ 5 }
mr={ 1 }
/>
) }
{ (rating && !fullView) ? (
<chakra.span color={ textColor } transition="inherit">
{ rating }
</chakra.span>
) : (
'Rate it!'
) }
</Button>
</Tooltip>
);
};
export default React.forwardRef(TriggerButton);
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);
});
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<EventTypes.APP_FEEDBACK>['Source'],
) => void;
function formatRatings(data: Airtable.Records<Airtable.FieldSet>) {
return data.reduce((acc: Record<string, AppRating>, record) => {
const fields = record.fields as { appId: string | Array<string>; 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<Record<string, AppRating>>({});
const [ userRatings, setUserRatings ] = useState<Record<string, AppRating>>({});
const [ isRatingLoading, setIsRatingLoading ] = useState<boolean>(false);
const [ isUserRatingLoading, setIsUserRatingLoading ] = useState<boolean>(false);
const [ isSending, setIsSending ] = useState<boolean>(false);
const [ canRate, setCanRate ] = useState<boolean | undefined>(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<string, AppRating>;
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<EventTypes.APP_FEEDBACK>['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,
};
}
...@@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce'; ...@@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useRatings from './Rating/useRatings';
import useMarketplaceApps from './useMarketplaceApps'; import useMarketplaceApps from './useMarketplaceApps';
import useMarketplaceCategories from './useMarketplaceCategories'; import useMarketplaceCategories from './useMarketplaceCategories';
...@@ -85,9 +86,10 @@ export default function useMarketplace() { ...@@ -85,9 +86,10 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const { const {
isPlaceholderData, isError, error, data, displayedApps, setSorting, isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded); } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded, ratings);
const { const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories, isPlaceholderData: isCategoriesPlaceholderData, data: categories,
} = useMarketplaceCategories(data, isPlaceholderData); } = useMarketplaceCategories(data, isPlaceholderData);
...@@ -151,6 +153,11 @@ export default function useMarketplace() { ...@@ -151,6 +153,11 @@ export default function useMarketplace() {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}), [ }), [
selectedCategoryId, selectedCategoryId,
categories, categories,
...@@ -174,5 +181,10 @@ export default function useMarketplace() { ...@@ -174,5 +181,10 @@ export default function useMarketplace() {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
]); ]);
} }
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; 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 { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
...@@ -55,6 +55,7 @@ export default function useMarketplaceApps( ...@@ -55,6 +55,7 @@ export default function useMarketplaceApps(
selectedCategoryId: string = MarketplaceCategory.ALL, selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> | undefined = undefined, favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
ratings: Record<string, AppRating> | undefined = undefined,
) { ) {
const fetch = useFetch(); const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
...@@ -91,20 +92,27 @@ export default function useMarketplaceApps( ...@@ -91,20 +92,27 @@ export default function useMarketplaceApps(
const [ sorting, setSorting ] = React.useState<SortValue>(); const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() => const appsWithSecurityReportsAndRating = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })), data?.map((app) => ({
[ data, securityReports ]); ...app,
securityReport: securityReports?.[app.id],
rating: ratings?.[app.id],
})),
[ data, securityReports, ratings ]);
const displayedApps = React.useMemo(() => { const displayedApps = React.useMemo(() => {
return appsWithSecurityReports return appsWithSecurityReportsAndRating
?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) ?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps))
.sort((a, b) => { .sort((a, b) => {
if (sorting === 'security_score') { if (sorting === 'security_score') {
return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0); 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; return 0;
}) || []; }) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]); }, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
data, data,
......
...@@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam'; import removeQueryParam from 'lib/router/removeQueryParam';
import type { TOption } from 'ui/shared/sort/Option'; import type { TOption } from 'ui/shared/sort/Option';
export type SortValue = 'security_score'; export type SortValue = 'rating' | 'security_score';
export const SORT_OPTIONS: Array<TOption<SortValue>> = [ export const SORT_OPTIONS: Array<TOption<SortValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Rating', id: 'rating' },
{ title: 'Security score', id: 'security_score' }, { title: 'Security score', id: 'security_score' },
]; ];
......
...@@ -4,13 +4,13 @@ import React from 'react'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages'; import type { MessagesDirection } from './ArbitrumL2Messages';
...@@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; 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; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
...@@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.origination_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
......
...@@ -4,12 +4,12 @@ import React from 'react'; ...@@ -4,12 +4,12 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages'; import type { MessagesDirection } from './ArbitrumL2Messages';
...@@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ...@@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; 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; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
...@@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ...@@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
) } ) }
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.origination_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<ArbitrumL2MessageStatus status={ item.status } isLoading={ isLoading }/> <ArbitrumL2MessageStatus status={ item.status } isLoading={ isLoading }/>
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type * as bens from '@blockscout/bens-types'; import type * as bens from '@blockscout/bens-types';
...@@ -6,12 +5,12 @@ 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 { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash'; import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
event: bens.DomainEvent; event: bens.DomainEvent;
...@@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => { ...@@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ dayjs(event.timestamp).fromNow() }</span> timestamp={ event.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
{ event.from_address && ( { event.from_address && (
......
import { Tr, Td, Skeleton } from '@chakra-ui/react'; import { Tr, Td } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type * as bens from '@blockscout/bens-types'; import type * as bens from '@blockscout/bens-types';
...@@ -6,11 +6,11 @@ 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 { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash'; import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
event: bens.DomainEvent; event: bens.DomainEvent;
...@@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => { ...@@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => {
/> />
</Td> </Td>
<Td pl={ 9 } verticalAlign="middle"> <Td pl={ 9 } verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ dayjs(event.timestamp).fromNow() }</span> timestamp={ event.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> } { event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> }
......
...@@ -4,20 +4,18 @@ import React from 'react'; ...@@ -4,20 +4,18 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { ...@@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_timestamp }
</Skeleton> isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 block #</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L2 block #</ListItemMobileGrid.Label>
......
...@@ -4,19 +4,17 @@ import React from 'react'; ...@@ -4,19 +4,17 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { ...@@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.l2_output_index }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ item.l2_output_index }</Skeleton>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_timestamp }
isLoading={ isLoading }
display="inline-block"
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<BlockEntityL2 <BlockEntityL2
......
...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { apps as appsMock } from 'mocks/apps/apps'; 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 { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
...@@ -10,15 +11,21 @@ import Marketplace from './Marketplace'; ...@@ -10,15 +11,21 @@ import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json'; const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse }) => { test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_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_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); 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 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 }) => { test('base view +@dark-mode', async({ render }) => {
......
...@@ -71,6 +71,11 @@ const Marketplace = () => { ...@@ -71,6 +71,11 @@ const Marketplace = () => {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
} = useMarketplace(); } = useMarketplace();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -92,13 +97,13 @@ const Marketplace = () => { ...@@ -92,13 +97,13 @@ const Marketplace = () => {
tabs.unshift({ tabs.unshift({
id: MarketplaceCategory.FAVORITES, id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 } display="flex"/>, title: () => <IconSvg name="heart_filled" boxSize={ 5 } verticalAlign="middle" mt={ -1 }/>,
count: null, count: favoriteApps.length,
component: null, component: null,
}); });
return tabs; return tabs;
}, [ categories, appsTotal ]); }, [ categories, appsTotal, favoriteApps.length ]);
const selectedCategoryIndex = React.useMemo(() => { const selectedCategoryIndex = React.useMemo(() => {
const index = categoryTabs.findIndex(c => c.id === selectedCategoryId); const index = categoryTabs.findIndex(c => c.id === selectedCategoryId);
...@@ -224,6 +229,11 @@ const Marketplace = () => { ...@@ -224,6 +229,11 @@ const Marketplace = () => {
selectedCategoryId={ selectedCategoryId } selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick } onAppClick={ handleAppClick }
showContractList={ showContractList } showContractList={ showContractList }
userRatings={ userRatings }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/> />
{ (selectedApp && isAppInfoModalOpen) && ( { (selectedApp && isAppInfoModalOpen) && (
...@@ -233,6 +243,11 @@ const Marketplace = () => { ...@@ -233,6 +243,11 @@ const Marketplace = () => {
onFavoriteClick={ onFavoriteClick } onFavoriteClick={ onFavoriteClick }
data={ selectedApp } data={ selectedApp }
showContractList={ showContractList } showContractList={ showContractList }
userRating={ userRatings[selectedApp.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/> />
) } ) }
......
...@@ -4,6 +4,8 @@ import { numberToHex } from 'viem'; ...@@ -4,6 +4,8 @@ import { numberToHex } from 'viem';
import config from 'configs/app'; import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps'; 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 { test, expect, devices } from 'playwright/lib';
import MarketplaceApp from './MarketplaceApp'; import MarketplaceApp from './MarketplaceApp';
...@@ -16,18 +18,27 @@ const hooksConfig = { ...@@ -16,18 +18,27 @@ const hooksConfig = {
}; };
const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => { const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ '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_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 mockAssetResponse(appsMock[0].url, './mocks/apps/app.html');
await mockRpcResponse({ await mockRpcResponse({
Method: 'eth_chainId', Method: 'eth_chainId',
ReturnType: numberToHex(Number(config.chain.id)), 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( const component = await render(
<Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh"> <Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh">
......
...@@ -151,6 +151,7 @@ const MarketplaceApp = () => { ...@@ -151,6 +151,7 @@ const MarketplaceApp = () => {
return ( return (
<Flex flexDirection="column" h="100%"> <Flex flexDirection="column" h="100%">
<MarketplaceAppTopBar <MarketplaceAppTopBar
appId={ id }
data={ data } data={ data }
isLoading={ isPending || isSecurityReportsLoading } isLoading={ isPending || isSecurityReportsLoading }
securityReport={ securityReports?.[id] } securityReport={ securityReports?.[id] }
......
...@@ -2,7 +2,7 @@ import { useMemo } from 'react'; ...@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery'; import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
export default function useAppActionData(address: string | undefined = '', isEnabled = false) { export default function useAppActionData(address: string | undefined = '', isEnabled = true) {
const memoizedArray = useMemo(() => address ? [ address ] : [], [ address ]); const memoizedArray = useMemo(() => address ? [ address ] : [], [ address ]);
const { data } = useAddressMetadataInfoQuery(memoizedArray, isEnabled); const { data } = useAddressMetadataInfoQuery(memoizedArray, isEnabled);
const metadata = data?.addresses[address?.toLowerCase()]; const metadata = data?.addresses[address?.toLowerCase()];
......
...@@ -95,7 +95,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled, ...@@ -95,7 +95,7 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', isTooltipDisabled,
if (isTruncated) { if (isTruncated) {
return ( return (
<Tooltip label={ hash } isDisabled={ isTooltipDisabled } maxW={{ base: '100vw', lg: '400px' }}>{ content }</Tooltip> <Tooltip label={ hash } isDisabled={ isTooltipDisabled } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}>{ content }</Tooltip>
); );
} }
......
...@@ -28,7 +28,7 @@ const Hint = ({ label, className, tooltipProps, isLoading }: Props) => { ...@@ -28,7 +28,7 @@ const Hint = ({ label, className, tooltipProps, isLoading }: Props) => {
<Tooltip <Tooltip
label={ label } label={ label }
placement="top" placement="top"
maxW="320px" maxW={{ base: 'calc(100vw - 8px)', lg: '320px' }}
isOpen={ isOpen } isOpen={ isOpen }
{ ...tooltipProps } { ...tooltipProps }
> >
......
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 ?
<Tooltip label={ dayjs(timestamp).format('llll') }><span>{ timeAgo }</span></Tooltip> :
<span>{ fallbackText }</span>;
return (
<Skeleton isLoaded={ !isLoading } className={ className }>
{ content }
</Skeleton>
);
};
export default chakra(TimeAgoWithTooltip);
...@@ -4,7 +4,6 @@ import React from 'react'; ...@@ -4,7 +4,6 @@ import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { getTokenTypeName } from 'lib/token/tokenTypes'; import { getTokenTypeName } from 'lib/token/tokenTypes';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -15,6 +14,8 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; ...@@ -15,6 +14,8 @@ import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers'; import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TimeAgoWithTooltip from '../TimeAgoWithTooltip';
type Props = TokenTransfer & { type Props = TokenTransfer & {
baseAddress?: string; baseAddress?: string;
showTxInfo?: boolean; showTxInfo?: boolean;
...@@ -35,7 +36,6 @@ const TokenTransferListItem = ({ ...@@ -35,7 +36,6 @@ const TokenTransferListItem = ({
enableTimeIncrement, enableTimeIncrement,
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement);
const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
exchangeRate: token.exchange_rate, exchangeRate: token.exchange_rate,
...@@ -71,11 +71,14 @@ const TokenTransferListItem = ({ ...@@ -71,11 +71,14 @@ const TokenTransferListItem = ({
truncation="constant_long" truncation="constant_long"
fontWeight="700" fontWeight="700"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> timestamp={ timestamp }
<span>{ timeAgo }</span> enableIncrement={ enableTimeIncrement }
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
) } ) }
<AddressFromTo <AddressFromTo
......
...@@ -4,7 +4,6 @@ import React from 'react'; ...@@ -4,7 +4,6 @@ import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { getTokenTypeName } from 'lib/token/tokenTypes'; import { getTokenTypeName } from 'lib/token/tokenTypes';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -14,6 +13,8 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity'; ...@@ -14,6 +13,8 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity';
import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers'; import { getTokenTransferTypeText } from 'ui/shared/TokenTransfer/helpers';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TimeAgoWithTooltip from '../TimeAgoWithTooltip';
type Props = TokenTransfer & { type Props = TokenTransfer & {
baseAddress?: string; baseAddress?: string;
showTxInfo?: boolean; showTxInfo?: boolean;
...@@ -34,7 +35,6 @@ const TokenTransferTableItem = ({ ...@@ -34,7 +35,6 @@ const TokenTransferTableItem = ({
enableTimeIncrement, enableTimeIncrement,
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement);
const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
exchangeRate: token.exchange_rate, exchangeRate: token.exchange_rate,
...@@ -78,11 +78,15 @@ const TokenTransferTableItem = ({ ...@@ -78,11 +78,15 @@ const TokenTransferTableItem = ({
mt="7px" mt="7px"
truncation="constant_long" truncation="constant_long"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" mt="10px" display="inline-block"> timestamp={ timestamp }
<span>{ timeAgo }</span> enableIncrement={ enableTimeIncrement }
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
mt="10px"
display="inline-block"
/>
</Td> </Td>
) } ) }
<Td> <Td>
......
...@@ -79,7 +79,7 @@ const TruncatedTextTooltip = ({ children, label, placement }: Props) => { ...@@ -79,7 +79,7 @@ const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
return ( return (
<Tooltip <Tooltip
label={ label } label={ label }
maxW={{ base: '100vw', lg: '400px' }} maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
placement={ placement } placement={ placement }
isOpen={ isOpen } isOpen={ isOpen }
> >
......
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 (
<>
<Tooltip label={ isLoading ? undefined : 'Gas Used %' }>
<Box>
<Utilization
colorScheme="gray"
value={ BigNumber(gasUsed).dividedBy(BigNumber(gasLimit)).toNumber() }
isLoading={ isLoading }
className={ className }
/>
</Box>
</Tooltip>
{ gasTarget && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ gasTarget } isLoading={ isLoading }/>
</>
) }
</>
);
};
export default React.memo(chakra(BlockGasUsed));
...@@ -11,10 +11,10 @@ interface Props extends React.SVGProps<SVGPathElement> { ...@@ -11,10 +11,10 @@ interface Props extends React.SVGProps<SVGPathElement> {
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
color?: string; color?: string;
data: Array<TimeChartItem>; data: Array<TimeChartItem>;
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 ref = React.useRef(null);
const theme = useTheme(); const theme = useTheme();
...@@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props ...@@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
}; };
React.useEffect(() => { React.useEffect(() => {
if (disableAnimation) { if (noAnimation) {
d3.select(ref.current).attr('opacity', 1); d3.select(ref.current).attr('opacity', 1);
return; return;
} }
...@@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props ...@@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
.duration(750) .duration(750)
.ease(d3.easeBackIn) .ease(d3.easeBackIn)
.attr('opacity', 1); .attr('opacity', 1);
}, [ disableAnimation ]); }, [ noAnimation ]);
const d = React.useMemo(() => { const d = React.useMemo(() => {
const area = d3.area<TimeChartItem>() const area = d3.area<TimeChartItem>()
.defined(({ isApproximate }) => !isApproximate)
.x(({ date }) => xScale(date)) .x(({ date }) => xScale(date))
.y1(({ value }) => yScale(value)) .y1(({ value }) => yScale(value))
.y0(() => yScale(yScale.domain()[0])) .y0(() => yScale(yScale.domain()[0]))
......
...@@ -5,13 +5,13 @@ import React from 'react'; ...@@ -5,13 +5,13 @@ import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> { interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'left' | 'bottom'; type: 'left' | 'bottom';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean; noAnimation?: boolean;
ticks: number; ticks: number;
tickFormatGenerator?: (axis: d3.Axis<d3.NumberValue>) => (domainValue: d3.AxisDomain, index: number) => string; tickFormatGenerator?: (axis: d3.Axis<d3.NumberValue>) => (domainValue: d3.AxisDomain, index: number) => string;
anchorEl?: SVGRectElement | null; 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<SVGGElement>(null); const ref = React.useRef<SVGGElement>(null);
const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500'); const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500');
...@@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, ...@@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
const axisGroup = d3.select(ref.current); const axisGroup = d3.select(ref.current);
if (disableAnimation) { if (noAnimation) {
axisGroup.call(axis); axisGroup.call(axis);
} else { } else {
axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis); axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
...@@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, ...@@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
.attr('opacity', 1) .attr('opacity', 1)
.attr('color', textColor) .attr('color', textColor)
.attr('font-size', '0.75rem'); .attr('font-size', '0.75rem');
}, [ scale, ticks, tickFormatGenerator, disableAnimation, type, textColor ]); }, [ scale, ticks, tickFormatGenerator, noAnimation, type, textColor ]);
React.useEffect(() => { React.useEffect(() => {
if (!anchorEl) { if (!anchorEl) {
......
...@@ -5,12 +5,12 @@ import React from 'react'; ...@@ -5,12 +5,12 @@ import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> { interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'vertical' | 'horizontal'; type: 'vertical' | 'horizontal';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean; noAnimation?: boolean;
size: number; size: number;
ticks: 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<SVGGElement>(null); const ref = React.useRef<SVGGElement>(null);
const strokeColor = useToken('colors', 'divider'); const strokeColor = useToken('colors', 'divider');
...@@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: ...@@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
const axis = axisGenerator(scale).ticks(ticks).tickSize(-size); const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
const gridGroup = d3.select(ref.current); const gridGroup = d3.select(ref.current);
if (disableAnimation) { if (noAnimation) {
gridGroup.call(axis); gridGroup.call(axis);
} else { } else {
gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis); gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
...@@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: ...@@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
gridGroup.select('.domain').remove(); gridGroup.select('.domain').remove();
gridGroup.selectAll('text').remove(); gridGroup.selectAll('text').remove();
gridGroup.selectAll('line').attr('stroke', strokeColor); gridGroup.selectAll('line').attr('stroke', strokeColor);
}, [ scale, ticks, size, disableAnimation, type, strokeColor ]); }, [ scale, ticks, size, noAnimation, type, strokeColor ]);
return <g ref={ ref } { ...props }/>; return <g ref={ ref } { ...props }/>;
}; };
......
...@@ -3,56 +3,38 @@ import React from 'react'; ...@@ -3,56 +3,38 @@ import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types'; 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<SVGPathElement> { interface Props extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
data: Array<TimeChartItem>; data: Array<TimeChartItem>;
animation: 'left' | 'fadeIn' | 'none'; animation: AnimationType;
} }
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
const ref = React.useRef<SVGPathElement>(null); const dataPathRef = React.useRef<SVGPathElement>(null);
const incompleteDataPathRef = React.useRef<SVGPathElement>(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);
}, []);
React.useEffect(() => { React.useEffect(() => {
const ANIMATIONS = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
const animationFn = ANIMATIONS[animation]; const animationFn = ANIMATIONS[animation];
window.setTimeout(animationFn, 100); const timeoutId = window.setTimeout(() => {
}, [ animateLeft, animateFadeIn, noneAnimation, animation ]); dataPathRef.current && animationFn(dataPathRef.current);
incompleteDataPathRef.current && animationFn(incompleteDataPathRef.current);
}, 100);
return () => {
window.clearTimeout(timeoutId);
};
}, [ animation ]);
// Recalculate line length if scale has changed // Recalculate line length if scale has changed
React.useEffect(() => { React.useEffect(() => {
if (animation === 'left') { if (animation === 'left') {
const totalLength = ref.current?.getTotalLength(); const totalLength = dataPathRef.current?.getTotalLength();
d3.select(ref.current).attr( d3.select(dataPathRef.current).attr(
'stroke-dasharray', 'stroke-dasharray',
`${ totalLength },${ totalLength }`, `${ totalLength },${ totalLength }`,
); );
...@@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { ...@@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
.curve(d3.curveMonotoneX); .curve(d3.curveMonotoneX);
return ( return (
<path <>
ref={ ref } <path
d={ line(data) || undefined } ref={ incompleteDataPathRef }
strokeWidth={ 1 } d={ line(getIncompleteDataLineSource(data)) || undefined }
strokeLinecap="round" strokeWidth={ 1 }
fill="none" strokeLinecap="round"
opacity={ 0 } fill="none"
{ ...props } strokeDasharray="6 6"
/> opacity={ 0 }
{ ...props }
/>
<path
ref={ dataPathRef }
d={ line(data.filter(({ isApproximate }) => !isApproximate)) || undefined }
strokeWidth={ 1 }
strokeLinecap="round"
fill="none"
opacity={ 0 }
{ ...props }
/>
</>
); );
}; };
......
import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import React from 'react'; 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 ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop';
import type { Pointer } from 'ui/shared/chart/utils/pointerTracker'; import ChartTooltipContent, { useRenderContent } from './tooltip/ChartTooltipContent';
import { trackPointer } from 'ui/shared/chart/utils/pointerTracker'; 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 { interface Props {
width?: number; width?: number;
...@@ -16,151 +20,62 @@ interface Props { ...@@ -16,151 +20,62 @@ interface Props {
xScale: d3.ScaleTime<number, number>; xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>; yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null; anchorEl: SVGRectElement | null;
noAnimation?: boolean;
} }
const TEXT_LINE_HEIGHT = 12; const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => {
const PADDING = 16; const ref = React.useRef<SVGGElement>(null);
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 trackerId = React.useRef<number>(); const trackerId = React.useRef<number>();
const isVisible = React.useRef(false); const isVisible = React.useRef(false);
const drawLine = React.useCallback( const transitionDuration = !noAnimation ? 100 : null;
(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<Element, TimeChartData>('.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<TimeChartItem, unknown>((d) => d.date).left;
let baseXPos = 0;
let baseYPos = 0;
d3.select(ref.current) const renderLine = useRenderLine(ref, height);
.selectAll('.ChartTooltip__point') const renderContent = useRenderContent(ref, { chart: { width, height }, transitionDuration });
.attr('transform', (cur, i) => { const renderPoints = useRenderPoints(ref, { data, xScale, yScale });
const index = bisectDate(data[i].items, xDate, 1); const renderTitle = useRenderTitle(ref);
const d0 = data[i].items[index - 1] as TimeChartItem | undefined; const renderRows = useRenderRows(ref, { data, xScale, minWidth: tooltipWidth });
const d1 = data[i].items[index] as TimeChartItem | undefined; const renderBackdrop = useRenderBackdrop(ref, { seriesNum: data.length, transitionDuration });
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 draw = React.useCallback((pointer: Pointer) => { const draw = React.useCallback((pointer: Pointer) => {
if (pointer.point) { if (pointer.point) {
const [ baseXPos, baseYPos ] = drawPoints(pointer.point[0]); const { x, y, currentPoints } = renderPoints(pointer.point[0]);
drawLine(baseXPos); const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
drawContent(baseXPos, baseYPos); 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(() => { const showContent = React.useCallback(() => {
if (!isVisible.current) { if (!isVisible.current) {
d3.select(ref.current).attr('opacity', 1); if (transitionDuration) {
d3.select(ref.current) d3.select(ref.current)
.selectAll('.ChartTooltip__point') .transition()
.attr('opacity', 1); .delay(transitionDuration)
.attr('opacity', 1);
} else {
d3.select(ref.current)
.attr('opacity', 1);
}
isVisible.current = true; isVisible.current = true;
} }
}, []); }, [ transitionDuration ]);
const hideContent = React.useCallback(() => { 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; isVisible.current = false;
}, []); }, [ transitionDuration ]);
const createPointerTracker = React.useCallback((event: PointerEvent, isSubsequentCall?: boolean) => { const createPointerTracker = React.useCallback((event: PointerEvent, isSubsequentCall?: boolean) => {
let isPressed = event.pointerType === 'mouse' && event.type === 'pointerdown' && !isSubsequentCall; let isPressed = event.pointerType === 'mouse' && event.type === 'pointerdown' && !isSubsequentCall;
...@@ -224,73 +139,21 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, ...@@ -224,73 +139,21 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
}, [ anchorEl, createPointerTracker, draw, hideContent, showContent ]); }, [ anchorEl, createPointerTracker, draw, hideContent, showContent ]);
return ( return (
<g ref={ ref } opacity={ 0 } { ...props }> <g
<line className="ChartTooltip__line" stroke={ lineColor } strokeDasharray="3"/> ref={ ref }
{ data.map(({ name }) => ( opacity={ 0 }
<circle fontSize="12px"
key={ name } fontWeight="500"
className="ChartTooltip__point" { ...props }
r={ POINT_SIZE / 2 } >
opacity={ 0 } <ChartTooltipLine/>
fill={ markerBgColor } { data.map(({ name }) => <ChartTooltipPoint key={ name }/>) }
stroke={ markerBorderColor } <ChartTooltipContent>
strokeWidth={ 4 } <ChartTooltipBackdrop/>
/> <ChartTooltipTitle/>
)) } <ChartTooltipRow label="Date" lineNum={ 1 }/>
<g className="ChartTooltip__content"> { data.map(({ name }, index) => <ChartTooltipRow key={ name } label={ name } lineNum={ index + 1 }/>) }
<rect </ChartTooltipContent>
className="ChartTooltip__contentBg"
rx={ 12 }
ry={ 12 }
fill={ bgColor }
width={ tooltipWidth }
height={ 2 * PADDING + (data.length + 1) * TEXT_LINE_HEIGHT + data.length * LINE_SPACE }
/>
<g transform={ `translate(${ PADDING },${ PADDING })` }>
<text
className="ChartTooltip__contentTitle"
transform="translate(0,0)"
fontSize="12px"
fontWeight="500"
fill={ titleColor }
dominantBaseline="hanging"
>
Date
</text>
<text
className="ChartTooltip__contentDate"
transform={ `translate(${ LABEL_WIDTH },0)` }
fontSize="12px"
fontWeight="500"
fill={ textColor }
dominantBaseline="hanging"
/>
</g>
{ data.map(({ name }, index) => (
<g
key={ name }
transform={ `translate(${ PADDING },${ PADDING + (index + 1) * (LINE_SPACE + TEXT_LINE_HEIGHT) })` }
>
<text
className="ChartTooltip__contentTitle"
transform="translate(0,0)"
fontSize="12px"
fontWeight="500"
fill={ titleColor }
dominantBaseline="hanging"
>
{ name }
</text>
<text
transform={ `translate(${ LABEL_WIDTH },0)` }
className="ChartTooltip__value"
fontSize="12px"
fill={ textColor }
dominantBaseline="hanging"
/>
</g>
)) }
</g>
</g> </g>
); );
}; };
......
import React from 'react'; import React from 'react';
import type { TimeChartItem } from './types';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import type { Props } from './ChartWidget'; import type { Props } from './ChartWidget';
...@@ -26,6 +28,7 @@ const props: Props = { ...@@ -26,6 +28,7 @@ const props: Props = {
units: 'ETH', units: 'ETH',
isLoading: false, isLoading: false,
isError: false, isError: false,
noAnimation: true,
}; };
test('base view +@dark-mode', async({ render, page }) => { test('base view +@dark-mode', async({ render, page }) => {
...@@ -41,6 +44,7 @@ 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.move(0, 0);
await page.mouse.click(0, 0); await page.mouse.click(0, 0);
await page.mouse.move(80, 150);
await page.mouse.move(100, 150); await page.mouse.move(100, 150);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
...@@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => { ...@@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => {
}); });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('incomplete day', async({ render, page }) => {
const modifiedProps = {
...props,
items: [
...props.items as Array<TimeChartItem>,
{ date: new Date('2023-02-24'), value: 25136740.887217894 / 4, isApproximate: true },
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
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();
});
...@@ -36,11 +36,12 @@ export type Props = { ...@@ -36,11 +36,12 @@ export type Props = {
className?: string; className?: string;
isError: boolean; isError: boolean;
emptyText?: string; emptyText?: string;
noAnimation?: boolean;
} }
const DOWNLOAD_IMAGE_SCALE = 5; 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<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [ isFullscreen, setIsFullscreen ] = useState(false); const [ isFullscreen, setIsFullscreen ] = useState(false);
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
...@@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError, ...@@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError,
isZoomResetInitial={ isZoomResetInitial } isZoomResetInitial={ isZoomResetInitial }
title={ title } title={ title }
units={ units } units={ units }
noAnimation={ noAnimation }
/> />
</Box> </Box>
); );
......
...@@ -23,13 +23,14 @@ interface Props { ...@@ -23,13 +23,14 @@ interface Props {
onZoom: () => void; onZoom: () => void;
isZoomResetInitial: boolean; isZoomResetInitial: boolean;
margin?: ChartMargin; margin?: ChartMargin;
noAnimation?: boolean;
} }
// temporarily turn off the data aggregation, we need a better algorithm for that // temporarily turn off the data aggregation, we need a better algorithm for that
const MAX_SHOW_ITEMS = 100_000_000_000; const MAX_SHOW_ITEMS = 100_000_000_000;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 }; 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 isMobile = useIsMobile();
const color = useToken('colors', 'blue.200'); const color = useToken('colors', 'blue.200');
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`; const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
...@@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
scale={ axes.y.scale } scale={ axes.y.scale }
ticks={ axesConfig.y.ticks } ticks={ axesConfig.y.ticks }
size={ innerWidth } size={ innerWidth }
disableAnimation noAnimation
/> />
<ChartArea <ChartArea
...@@ -108,6 +109,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -108,6 +109,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
color={ color } color={ color }
xScale={ axes.x.scale } xScale={ axes.x.scale }
yScale={ axes.y.scale } yScale={ axes.y.scale }
noAnimation={ noAnimation }
/> />
<ChartLine <ChartLine
...@@ -124,7 +126,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -124,7 +126,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
scale={ axes.y.scale } scale={ axes.y.scale }
ticks={ axesConfig.y.ticks } ticks={ axesConfig.y.ticks }
tickFormatGenerator={ axes.y.tickFormatter } tickFormatGenerator={ axes.y.tickFormatter }
disableAnimation noAnimation
/> />
<ChartAxis <ChartAxis
...@@ -134,7 +136,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -134,7 +136,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
ticks={ axesConfig.x.ticks } ticks={ axesConfig.x.ticks }
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
tickFormatGenerator={ axes.x.tickFormatter } tickFormatGenerator={ axes.x.tickFormatter }
disableAnimation noAnimation
/> />
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }> <ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
...@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
xScale={ axes.x.scale } xScale={ axes.x.scale }
yScale={ axes.y.scale } yScale={ axes.y.scale }
data={ chartData } data={ chartData }
noAnimation={ noAnimation }
/> />
<ChartSelectionX <ChartSelectionX
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { calculateContainerHeight } from './utils';
const ChartTooltipBackdrop = () => {
const bgColor = useToken('colors', 'blackAlpha.900');
return (
<rect
className="ChartTooltip__backdrop"
rx={ 12 }
ry={ 12 }
fill={ bgColor }
/>
);
};
export default React.memo(ChartTooltipBackdrop);
interface UseRenderBackdropParams {
seriesNum: number;
transitionDuration: number | null;
}
export function useRenderBackdrop(ref: React.RefObject<SVGGElement>, { 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 ]);
}
import * as d3 from 'd3';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
import React from 'react';
interface Params { import { POINT_SIZE } from './utils';
interface Props {
children: React.ReactNode;
}
const ChartTooltipContent = ({ children }: Props) => {
return <g className="ChartTooltip__content">{ children }</g>;
};
export default React.memo(ChartTooltipContent);
interface UseRenderContentParams {
chart: {
width?: number;
height?: number;
};
transitionDuration: number | null;
}
export function useRenderContent(ref: React.RefObject<SVGGElement>, { chart, transitionDuration }: UseRenderContentParams) {
return React.useCallback((x: number, y: number) => {
const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
const transformAttributeFn: d3.ValueFn<d3.BaseType, unknown, string> = (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; pointX: number;
pointY: number; pointY: number;
offset: number; offset: number;
...@@ -10,7 +65,7 @@ interface Params { ...@@ -10,7 +65,7 @@ interface Params {
canvasHeight: number; canvasHeight: number;
} }
export default function computeTooltipPosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: Params): [ number, number ] { function calculatePosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: CalculatePositionParams): [ number, number ] {
// right // right
if (pointX + offset + nodeWidth <= canvasWidth) { if (pointX + offset + nodeWidth <= canvasWidth) {
const x = pointX + offset; const x = pointX + offset;
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
const ChartTooltipLine = () => {
const lineColor = useToken('colors', 'gray.400');
return <line className="ChartTooltip__line" stroke={ lineColor } strokeDasharray="3"/>;
};
export default React.memo(ChartTooltipLine);
export function useRenderLine(ref: React.RefObject<SVGGElement>, 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 ]);
}
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 (
<circle
className="ChartTooltip__point"
r={ POINT_SIZE / 2 }
opacity={ 1 }
fill={ bgColor }
stroke={ borderColor }
strokeWidth={ 4 }
/>
);
};
export default React.memo(ChartTooltipPoint);
interface UseRenderPointsParams {
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>;
}
export interface CurrentPoint {
datumIndex: number;
item: TimeChartItem;
}
interface RenderPointsReturnType{
x: number;
y: number;
currentPoints: Array<CurrentPoint>;
}
export function useRenderPoints(ref: React.RefObject<SVGGElement>, params: UseRenderPointsParams) {
return React.useCallback((x: number): RenderPointsReturnType => {
const xDate = params.xScale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
let baseXPos = 0;
let baseYPos = 0;
const currentPoints: Array<CurrentPoint> = [];
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 ]);
}
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 (
<g className="ChartTooltip__row" transform={ calculateRowTransformValue(lineNum) }>
{ children || (
<>
<text
className="ChartTooltip__label"
transform="translate(0,0)"
dominantBaseline="hanging"
fill={ labelColor }
>
{ label }
</text>
<text
className="ChartTooltip__value"
transform={ `translate(${ LABEL_WIDTH },0)` }
dominantBaseline="hanging"
fill={ textColor }
/>
</>
) }
</g>
);
};
export default React.memo(ChartTooltipRow);
interface UseRenderRowsParams {
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
minWidth: number;
}
interface UseRenderRowsReturnType {
width: number;
}
export function useRenderRows(ref: React.RefObject<SVGGElement>, { data, xScale, minWidth }: UseRenderRowsParams) {
return React.useCallback((x: number, currentPoints: Array<CurrentPoint>): UseRenderRowsReturnType => {
// update "transform" prop of all rows
const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
d3.select(ref.current)
.selectAll<Element, TimeChartData>('.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<Element, TimeChartData>('.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 ]);
}
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 (
<ChartTooltipRow lineNum={ 0 }>
<text
className="ChartTooltip__title"
transform="translate(0,0)"
fill={ titleColor }
opacity={ 0 }
dominantBaseline="hanging"
>
Incomplete day
</text>
</ChartTooltipRow>
);
};
export default React.memo(ChartTooltipTitle);
export function useRenderTitle(ref: React.RefObject<SVGGElement>) {
return React.useCallback((isVisible: boolean) => {
d3.select(ref.current)
.select('.ChartTooltip__title')
.attr('opacity', isVisible ? 1 : 0);
}, [ ref ]);
}
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 })`;
};
...@@ -8,6 +8,7 @@ export interface TimeChartItem { ...@@ -8,6 +8,7 @@ export interface TimeChartItem {
date: Date; date: Date;
dateLabel?: string; dateLabel?: string;
value: number; value: number;
isApproximate?: boolean;
} }
export interface ChartMargin { export interface ChartMargin {
......
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<AnimationType, (path: SVGPathElement) => void> = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
import type { TimeChartItem } from '../types';
export const getIncompleteDataLineSource = (data: Array<TimeChartItem>): Array<TimeChartItem> => {
const result: Array<TimeChartItem> = [];
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;
};
...@@ -108,7 +108,7 @@ const Content = chakra((props: ContentProps) => { ...@@ -108,7 +108,7 @@ const Content = chakra((props: ContentProps) => {
); );
return ( return (
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}> <Tooltip label={ label } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}>
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span"> <Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ nameText } { nameText }
</Skeleton> </Skeleton>
......
...@@ -76,7 +76,7 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Pr ...@@ -76,7 +76,7 @@ const FileSnippet = ({ file, className, index, onRemove, isDisabled, error }: Pr
<Tooltip <Tooltip
label={ error } label={ error }
placement="top" placement="top"
maxW="320px" maxW={{ base: 'calc(100vw - 8px)', lg: '320px' }}
> >
<Box cursor="pointer" display="inherit" onClick={ handleErrorHintIconClick } ml={ 1 }> <Box cursor="pointer" display="inherit" onClick={ handleErrorHintIconClick } ml={ 1 }>
<IconSvg name="info" boxSize={ 5 } color="error"/> <IconSvg name="info" boxSize={ 5 } color="error"/>
......
...@@ -64,6 +64,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState ...@@ -64,6 +64,7 @@ const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState
variant="nav" variant="nav"
gutter={ 20 } gutter={ 20 }
color={ isInternalLink && item.isActive ? colors.text.active : colors.text.hover } color={ isInternalLink && item.isActive ? colors.text.active : colors.text.hover }
margin={ 0 }
> >
<HStack spacing={ 0 } overflow="hidden"> <HStack spacing={ 0 } overflow="hidden">
<NavLinkIcon item={ item }/> <NavLinkIcon item={ item }/>
......
...@@ -42,7 +42,7 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError ...@@ -42,7 +42,7 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError
}); });
const items = useMemo(() => data?.chart?.map((item) => { 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 ]); }), [ data ]);
useEffect(() => { useEffect(() => {
......
...@@ -12,7 +12,6 @@ import type { ResourceError } from 'lib/api/resources'; ...@@ -12,7 +12,6 @@ import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMounted from 'lib/hooks/useIsMounted'; import useIsMounted from 'lib/hooks/useIsMounted';
import { TOKEN_COUNTERS } from 'stubs/token'; import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token'; import type { TokenTabs } from 'ui/pages/Token';
...@@ -31,7 +30,6 @@ interface Props { ...@@ -31,7 +30,6 @@ interface Props {
const TokenDetails = ({ tokenQuery }: Props) => { const TokenDetails = ({ tokenQuery }: Props) => {
const router = useRouter(); const router = useRouter();
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false);
const hash = router.query.hash?.toString(); const hash = router.query.hash?.toString();
...@@ -40,7 +38,7 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -40,7 +38,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
queryOptions: { enabled: Boolean(router.query.hash), placeholderData: TOKEN_COUNTERS }, queryOptions: { enabled: Boolean(router.query.hash), placeholderData: TOKEN_COUNTERS },
}); });
const appActionData = useAppActionData(hash, isActionButtonExperiment); const appActionData = useAppActionData(hash);
const changeUrlAndScroll = useCallback((tab: TokenTabs) => () => { const changeUrlAndScroll = useCallback((tab: TokenTabs) => () => {
router.push( router.push(
...@@ -200,11 +198,10 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -200,11 +198,10 @@ const TokenDetails = ({ tokenQuery }: Props) => {
isLoading={ tokenQuery.isPlaceholderData } isLoading={ tokenQuery.isPlaceholderData }
appActionData={ appActionData } appActionData={ appActionData }
source="NFT collection" 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) && (
<> <>
<DetailsInfoItem.Label <DetailsInfoItem.Label
hint="Link to the dapp" hint="Link to the dapp"
......
...@@ -14,10 +14,9 @@ interface Props { ...@@ -14,10 +14,9 @@ interface Props {
isLoading?: boolean; isLoading?: boolean;
appActionData?: AddressMetadataTagFormatted['meta']; appActionData?: AddressMetadataTagFormatted['meta'];
source: 'NFT collection' | 'NFT item'; source: 'NFT collection' | 'NFT item';
isActionButtonExperiment?: boolean;
} }
const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isActionButtonExperiment }: Props) => { const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source }: Props) => {
if (!hash || config.UI.views.nft.marketplaces.length === 0) { if (!hash || config.UI.views.nft.marketplaces.length === 0) {
return null; return null;
} }
...@@ -31,7 +30,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc ...@@ -31,7 +30,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc
Marketplaces Marketplaces
</DetailsInfoItem.Label> </DetailsInfoItem.Label>
<DetailsInfoItem.Value <DetailsInfoItem.Value
py={ (appActionData && isActionButtonExperiment) ? '1px' : '6px' } py={ appActionData ? '1px' : '6px' }
> >
<Skeleton isLoaded={ !isLoading } display="flex" columnGap={ 3 } flexWrap="wrap" alignItems="center"> <Skeleton isLoaded={ !isLoading } display="flex" columnGap={ 3 } flexWrap="wrap" alignItems="center">
{ config.UI.views.nft.marketplaces.map((item) => { { config.UI.views.nft.marketplaces.map((item) => {
...@@ -52,7 +51,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc ...@@ -52,7 +51,7 @@ const TokenNftMarketplaces = ({ hash, id, isLoading, appActionData, source, isAc
</Tooltip> </Tooltip>
); );
}) } }) }
{ (appActionData && isActionButtonExperiment) && ( { appActionData && (
<> <>
<TextSeparator color="gray.500" margin={ 0 }/> <TextSeparator color="gray.500" margin={ 0 }/>
<AppActionButton data={ appActionData } height="30px" source={ source }/> <AppActionButton data={ appActionData } height="30px" source={ source }/>
......
...@@ -4,13 +4,13 @@ import React from 'react'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import NftEntity from 'ui/shared/entities/nft/NftEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean }; type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean };
...@@ -26,7 +26,6 @@ const TokenTransferListItem = ({ ...@@ -26,7 +26,6 @@ const TokenTransferListItem = ({
tokenId, tokenId,
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, true);
const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
exchangeRate: token.exchange_rate, exchangeRate: token.exchange_rate,
...@@ -44,13 +43,15 @@ const TokenTransferListItem = ({ ...@@ -44,13 +43,15 @@ const TokenTransferListItem = ({
truncation="constant_long" truncation="constant_long"
fontWeight="700" fontWeight="700"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } display="inline-block" fontWeight="400" fontSize="sm" color="text_secondary"> timestamp={ timestamp }
<span> enableIncrement
{ timeAgo } isLoading={ isLoading }
</span> color="text_secondary"
</Skeleton> fontWeight="400"
) } fontSize="sm"
display="inline-block"
/>
</Flex> </Flex>
{ method && <Tag isLoading={ isLoading }>{ method }</Tag> } { method && <Tag isLoading={ isLoading }>{ method }</Tag> }
<AddressFromTo <AddressFromTo
......
...@@ -4,12 +4,12 @@ import React from 'react'; ...@@ -4,12 +4,12 @@ import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer'; import type { TokenTransfer } from 'types/api/tokenTransfer';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; import { NFT_TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import NftEntity from 'ui/shared/entities/nft/NftEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean } type Props = TokenTransfer & { tokenId?: string; isLoading?: boolean }
...@@ -24,7 +24,6 @@ const TokenTransferTableItem = ({ ...@@ -24,7 +24,6 @@ const TokenTransferTableItem = ({
tokenId, tokenId,
isLoading, isLoading,
}: Props) => { }: Props) => {
const timeAgo = useTimeAgoIncrement(timestamp, true);
const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({ const { usd, valueStr } = 'value' in total && total.value !== null ? getCurrencyValue({
value: total.value, value: total.value,
exchangeRate: token.exchange_rate, exchangeRate: token.exchange_rate,
...@@ -44,13 +43,15 @@ const TokenTransferTableItem = ({ ...@@ -44,13 +43,15 @@ const TokenTransferTableItem = ({
noIcon noIcon
truncation="constant_long" truncation="constant_long"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } display="inline-block" color="gray.500" fontWeight="400" ml="10px"> timestamp={ timestamp }
<span> enableIncrement
{ timeAgo } isLoading={ isLoading }
</span> display="inline-block"
</Skeleton> color="gray.500"
) } fontWeight="400"
ml="10px"
/>
</Flex> </Flex>
</Td> </Td>
<Td> <Td>
......
...@@ -55,8 +55,7 @@ test('base view +@dark-mode +@mobile', async({ render, page }) => { ...@@ -55,8 +55,7 @@ test('base view +@dark-mode +@mobile', async({ render, page }) => {
}); });
test.describe('action button', () => { test.describe('action button', () => {
test.beforeEach(async({ mockFeatures, mockApiResponse, mockAssetResponse }) => { test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => {
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg');
......
...@@ -4,7 +4,6 @@ import React from 'react'; ...@@ -4,7 +4,6 @@ import React from 'react';
import type { TokenInfo, TokenInstance } from 'types/api/token'; import type { TokenInfo, TokenInstance } from 'types/api/token';
import config from 'configs/app'; import config from 'configs/app';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMounted from 'lib/hooks/useIsMounted'; import useIsMounted from 'lib/hooks/useIsMounted';
import AppActionButton from 'ui/shared/AppActionButton/AppActionButton'; import AppActionButton from 'ui/shared/AppActionButton/AppActionButton';
import useAppActionData from 'ui/shared/AppActionButton/useAppActionData'; import useAppActionData from 'ui/shared/AppActionButton/useAppActionData';
...@@ -29,8 +28,7 @@ interface Props { ...@@ -29,8 +28,7 @@ interface Props {
} }
const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false); const appActionData = useAppActionData(token?.address, !isLoading);
const appActionData = useAppActionData(token?.address, isActionButtonExperiment && !isLoading);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const handleCounterItemClick = React.useCallback(() => { const handleCounterItemClick = React.useCallback(() => {
...@@ -96,10 +94,9 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { ...@@ -96,10 +94,9 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
id={ data.id } id={ data.id }
appActionData={ appActionData } appActionData={ appActionData }
source="NFT item" source="NFT item"
isActionButtonExperiment={ isActionButtonExperiment }
/> />
{ (config.UI.views.nft.marketplaces.length === 0 && appActionData && isActionButtonExperiment) && ( { (config.UI.views.nft.marketplaces.length === 0 && appActionData) && (
<> <>
<DetailsInfoItem.Label <DetailsInfoItem.Label
hint="Link to the dapp" hint="Link to the dapp"
......
...@@ -55,8 +55,7 @@ test.describe('blockscout provider', () => { ...@@ -55,8 +55,7 @@ test.describe('blockscout provider', () => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with interpretation and action button +@mobile +@dark-mode', async({ render, mockApiResponse, mockAssetResponse, mockFeatures }) => { test('with interpretation and action button +@mobile +@dark-mode', async({ render, mockApiResponse, mockAssetResponse }) => {
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(protocolTagWithMeta?.meta?.appLogoURL as string, './playwright/mocks/image_s.jpg');
...@@ -76,9 +75,8 @@ test.describe('blockscout provider', () => { ...@@ -76,9 +75,8 @@ test.describe('blockscout provider', () => {
}); });
test('with interpretation and view all link, and action button (external link) +@mobile', async({ 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; delete protocolTagWithMeta?.meta?.appID;
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
...@@ -92,9 +90,8 @@ test.describe('blockscout provider', () => { ...@@ -92,9 +90,8 @@ test.describe('blockscout provider', () => {
await expect(component).toHaveScreenshot(); 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 // the action button should not render if there is no interpretation
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
...@@ -103,9 +100,8 @@ test.describe('blockscout provider', () => { ...@@ -103,9 +100,8 @@ test.describe('blockscout provider', () => {
await expect(component).toHaveScreenshot(); 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 // the action button should not render if there is no interpretation
await mockFeatures([ [ 'action_button_exp', true ] ]);
const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta); const metadataResponse = generateAddressMetadataResponse(protocolTagWithMeta);
await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams }); await mockApiResponse('address_metadata_info', metadataResponse, { queryParams: addressMetadataQueryParams });
......
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate'; import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
...@@ -29,8 +28,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => { ...@@ -29,8 +28,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
const hasInterpretationFeature = feature.isEnabled; const hasInterpretationFeature = feature.isEnabled;
const isNovesInterpretation = hasInterpretationFeature && feature.provider === 'noves'; const isNovesInterpretation = hasInterpretationFeature && feature.provider === 'noves';
const { value: isActionButtonExperiment } = useFeatureValue('action_button_exp', false); const appActionData = useAppActionData(txQuery.data?.to?.hash, !txQuery.isPlaceholderData);
const appActionData = useAppActionData(txQuery.data?.to?.hash, isActionButtonExperiment && !txQuery.isPlaceholderData);
const txInterpretationQuery = useApiQuery('tx_interpretation', { const txInterpretationQuery = useApiQuery('tx_interpretation', {
pathParams: { hash }, pathParams: { hash },
...@@ -127,7 +125,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => { ...@@ -127,7 +125,7 @@ const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
mt={{ base: 3, lg: 0 }} mt={{ base: 3, lg: 0 }}
> >
{ !hasTag && <AccountActionsMenu/> } { !hasTag && <AccountActionsMenu/> }
{ (appActionData && isActionButtonExperiment && hasAnyInterpretation) && ( { (appActionData && hasAnyInterpretation) && (
<AppActionButton data={ appActionData } txHash={ hash } source="Txn"/> <AppActionButton data={ appActionData } txHash={ hash } source="Txn"/>
) } ) }
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 0, lg: 'auto' }}/> <NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 0, lg: 'auto' }}/>
......
...@@ -6,21 +6,19 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2'; ...@@ -6,21 +6,19 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus'; import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean };
const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => { const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => {
const timeAgo = item.commitment_transaction.timestamp ? dayjs(item.commitment_transaction.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') {
return null; return null;
} }
...@@ -76,7 +74,12 @@ const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => { ...@@ -76,7 +74,12 @@ const ArbitrumL2TxnBatchesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.commitment_transaction.timestamp }
fallbackText="Undefined"
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label>
......
...@@ -6,20 +6,18 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2'; ...@@ -6,20 +6,18 @@ import type { ArbitrumL2TxnBatchesItem } from 'types/api/arbitrumL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus'; import ArbitrumL2TxnBatchStatus from 'ui/shared/statusTag/ArbitrumL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ArbitrumL2TxnBatchesItem; isLoading?: boolean };
const TxnBatchesTableItem = ({ item, isLoading }: Props) => { const TxnBatchesTableItem = ({ item, isLoading }: Props) => {
const timeAgo = item.commitment_transaction.timestamp ? dayjs(item.commitment_transaction.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'arbitrum') {
return null; return null;
} }
...@@ -60,9 +58,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => { ...@@ -60,9 +58,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.commitment_transaction.timestamp }
</Skeleton> fallbackText="Undefined"
isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<LinkInternal <LinkInternal
......
...@@ -6,19 +6,17 @@ import type { OptimisticL2TxnBatchesItem } from 'types/api/optimisticL2'; ...@@ -6,19 +6,17 @@ import type { OptimisticL2TxnBatchesItem } from 'types/api/optimisticL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: OptimisticL2TxnBatchesItem; isLoading?: boolean };
const OptimisticL2TxnBatchesListItem = ({ item, isLoading }: Props) => { const OptimisticL2TxnBatchesListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -67,7 +65,11 @@ const OptimisticL2TxnBatchesListItem = ({ item, isLoading }: Props) => { ...@@ -67,7 +65,11 @@ const OptimisticL2TxnBatchesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</ListItemMobileGrid.Container> </ListItemMobileGrid.Container>
......
...@@ -6,18 +6,16 @@ import type { OptimisticL2TxnBatchesItem } from 'types/api/optimisticL2'; ...@@ -6,18 +6,16 @@ import type { OptimisticL2TxnBatchesItem } from 'types/api/optimisticL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: OptimisticL2TxnBatchesItem; isLoading?: boolean };
const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => { const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -60,9 +58,13 @@ const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => { ...@@ -60,9 +58,13 @@ const OptimisticL2TxnBatchesTableItem = ({ item, isLoading }: Props) => {
</VStack> </VStack>
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !isLoading } color="text_secondary" my={ 1 } display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_timestamp }
</Skeleton> isLoading={ isLoading }
display="inline-block"
color="text_secondary"
my={ 1 }
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -6,20 +6,18 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; ...@@ -6,20 +6,18 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => {
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null; return null;
} }
...@@ -45,7 +43,12 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { ...@@ -45,7 +43,12 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
fallbackText="Undefined"
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label>
......
...@@ -6,19 +6,17 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; ...@@ -6,19 +6,17 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const TxnBatchesTableItem = ({ item, isLoading }: Props) => { const TxnBatchesTableItem = ({ item, isLoading }: Props) => {
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null; return null;
} }
...@@ -39,9 +37,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => { ...@@ -39,9 +37,12 @@ const TxnBatchesTableItem = ({ item, isLoading }: Props) => {
<ZkEvmL2TxnBatchStatus status={ item.status } isLoading={ isLoading }/> <ZkEvmL2TxnBatchStatus status={ item.status } isLoading={ isLoading }/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.timestamp }
</Skeleton> fallbackText="Undefined"
isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<LinkInternal <LinkInternal
......
...@@ -6,20 +6,18 @@ import type { ZkSyncBatchesItem } from 'types/api/zkSyncL2'; ...@@ -6,20 +6,18 @@ import type { ZkSyncBatchesItem } from 'types/api/zkSyncL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ZkSyncL2TxnBatchStatus from 'ui/shared/statusTag/ZkSyncL2TxnBatchStatus'; import ZkSyncL2TxnBatchStatus from 'ui/shared/statusTag/ZkSyncL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ZkSyncBatchesItem; isLoading?: boolean }; type Props = { item: ZkSyncBatchesItem; isLoading?: boolean };
const ZkSyncTxnBatchesListItem = ({ item, isLoading }: Props) => { const ZkSyncTxnBatchesListItem = ({ item, isLoading }: Props) => {
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') {
return null; return null;
} }
...@@ -45,7 +43,12 @@ const ZkSyncTxnBatchesListItem = ({ item, isLoading }: Props) => { ...@@ -45,7 +43,12 @@ const ZkSyncTxnBatchesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
fallbackText="Undefined"
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Txn count</ListItemMobileGrid.Label>
......
...@@ -6,19 +6,17 @@ import type { ZkSyncBatchesItem } from 'types/api/zkSyncL2'; ...@@ -6,19 +6,17 @@ import type { ZkSyncBatchesItem } from 'types/api/zkSyncL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ZkSyncL2TxnBatchStatus from 'ui/shared/statusTag/ZkSyncL2TxnBatchStatus'; import ZkSyncL2TxnBatchStatus from 'ui/shared/statusTag/ZkSyncL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: ZkSyncBatchesItem; isLoading?: boolean }; type Props = { item: ZkSyncBatchesItem; isLoading?: boolean };
const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => { const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => {
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkSync') {
return null; return null;
} }
...@@ -39,9 +37,12 @@ const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => { ...@@ -39,9 +37,12 @@ const ZkSyncTxnBatchesTableItem = ({ item, isLoading }: Props) => {
<ZkSyncL2TxnBatchStatus status={ item.status } isLoading={ isLoading }/> <ZkSyncL2TxnBatchStatus status={ item.status } isLoading={ isLoading }/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.timestamp }
</Skeleton> fallbackText="Undefined"
isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<LinkInternal <LinkInternal
......
...@@ -9,7 +9,6 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -9,7 +9,6 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
...@@ -17,6 +16,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity'; ...@@ -17,6 +16,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -35,8 +35,6 @@ type Props = { ...@@ -35,8 +35,6 @@ type Props = {
const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeIncrement }: Props) => { const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeIncrement }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp, enableTimeIncrement);
return ( return (
<ListItemMobile display="block" width="100%" isAnimated key={ tx.hash }> <ListItemMobile display="block" width="100%" isAnimated key={ tx.hash }>
<Flex justifyContent="space-between" mt={ 4 }> <Flex justifyContent="space-between" mt={ 4 }>
...@@ -58,11 +56,14 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI ...@@ -58,11 +56,14 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
fontWeight="700" fontWeight="700"
iconName={ tx.tx_types.includes('blob_transaction') ? 'blob' : undefined } iconName={ tx.tx_types.includes('blob_transaction') ? 'blob' : undefined }
/> />
{ tx.timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> timestamp={ tx.timestamp }
<span>{ timeAgo }</span> enableIncrement={ enableTimeIncrement }
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
{ tx.method && ( { tx.method && (
<Flex mt={ 3 }> <Flex mt={ 3 }>
......
...@@ -2,7 +2,6 @@ import { ...@@ -2,7 +2,6 @@ import {
Tr, Tr,
Td, Td,
VStack, VStack,
Skeleton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import React from 'react'; import React from 'react';
...@@ -10,13 +9,13 @@ import React from 'react'; ...@@ -10,13 +9,13 @@ import React from 'react';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -34,7 +33,6 @@ type Props = { ...@@ -34,7 +33,6 @@ type Props = {
const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, isLoading }: Props) => { const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp, enableTimeIncrement);
return ( return (
<Tr <Tr
...@@ -58,7 +56,13 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, ...@@ -58,7 +56,13 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement,
maxW="100%" maxW="100%"
truncation="constant_long" truncation="constant_long"
/> />
{ tx.timestamp && <Skeleton color="text_secondary" fontWeight="400" isLoaded={ !isLoading }><span>{ timeAgo }</span></Skeleton> } <TimeAgoWithTooltip
timestamp={ tx.timestamp }
enableIncrement={ enableTimeIncrement }
isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
/>
</VStack> </VStack>
</Td> </Td>
<Td> <Td>
......
...@@ -4,13 +4,13 @@ import React from 'react'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { UserOpsItem } from 'types/api/userOps'; import type { UserOpsItem } from 'types/api/userOps';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
type Props = { type Props = {
...@@ -21,8 +21,6 @@ type Props = { ...@@ -21,8 +21,6 @@ type Props = {
}; };
const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => { const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<ListItemMobileGrid.Container gridTemplateColumns="100px auto"> <ListItemMobileGrid.Container gridTemplateColumns="100px auto">
...@@ -33,7 +31,12 @@ const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => { ...@@ -33,7 +31,12 @@ const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
......
import { Td, Tr, Skeleton } from '@chakra-ui/react'; import { Td, Tr } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { UserOpsItem } from 'types/api/userOps'; import type { UserOpsItem } from 'types/api/userOps';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
type Props = { type Props = {
...@@ -20,15 +20,18 @@ import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; ...@@ -20,15 +20,18 @@ import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
}; };
const UserOpsTableItem = ({ item, isLoading, showTx, showSender }: Props) => { const UserOpsTableItem = ({ item, isLoading, showTx, showSender }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<UserOpEntity hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 } truncation="constant_long"/> <UserOpEntity hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 } truncation="constant_long"/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<UserOpStatus status={ item.status } isLoading={ isLoading }/> <UserOpStatus status={ item.status } isLoading={ isLoading }/>
......
...@@ -6,7 +6,6 @@ import type { VerifiedContract } from 'types/api/contracts'; ...@@ -6,7 +6,6 @@ import type { VerifiedContract } from 'types/api/contracts';
import config from 'configs/app'; import config from 'configs/app';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses'; import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
...@@ -14,6 +13,7 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -14,6 +13,7 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
data: VerifiedContract; data: VerifiedContract;
...@@ -86,9 +86,11 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => { ...@@ -86,9 +86,11 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>Verified</Skeleton> <Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>Verified</Skeleton>
<Flex alignItems="center" columnGap={ 2 }> <Flex alignItems="center" columnGap={ 2 }>
<IconSvg name="status/success" boxSize={ 4 } color="green.500" isLoading={ isLoading }/> <IconSvg name="status/success" boxSize={ 4 } color="green.500" isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ dayjs(data.verified_at).fromNow() }</span> timestamp={ data.verified_at }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Flex> </Flex>
</Flex> </Flex>
<Flex columnGap={ 3 }> <Flex columnGap={ 3 }>
......
...@@ -6,12 +6,12 @@ import type { VerifiedContract } from 'types/api/contracts'; ...@@ -6,12 +6,12 @@ import type { VerifiedContract } from 'types/api/contracts';
import config from 'configs/app'; import config from 'configs/app';
import { CONTRACT_LICENSES } from 'lib/contracts/licenses'; import { CONTRACT_LICENSES } from 'lib/contracts/licenses';
import dayjs from 'lib/date/dayjs';
import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel'; import ContractCertifiedLabel from 'ui/shared/ContractCertifiedLabel';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
data: VerifiedContract; data: VerifiedContract;
...@@ -90,9 +90,11 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => { ...@@ -90,9 +90,11 @@ const VerifiedContractsTableItem = ({ data, isLoading }: Props) => {
<Td> <Td>
<Flex alignItems="center" columnGap={ 2 } my={ 1 }> <Flex alignItems="center" columnGap={ 2 } my={ 1 }>
<IconSvg name="status/success" boxSize={ 4 } color="green.500" isLoading={ isLoading }/> <IconSvg name="status/success" boxSize={ 4 } color="green.500" isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ dayjs(data.verified_at).fromNow() }</span> timestamp={ data.verified_at }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Flex> </Flex>
</Td> </Td>
<Td> <Td>
......
...@@ -6,12 +6,12 @@ import type { BlockWithdrawalsItem } from 'types/api/block'; ...@@ -6,12 +6,12 @@ import type { BlockWithdrawalsItem } from 'types/api/block';
import type { WithdrawalsItem } from 'types/api/withdrawals'; import type { WithdrawalsItem } from 'types/api/withdrawals';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.beaconChain; const feature = config.features.beaconChain;
...@@ -74,7 +74,11 @@ const BeaconChainWithdrawalsListItem = ({ item, isLoading, view }: Props) => { ...@@ -74,7 +74,11 @@ const BeaconChainWithdrawalsListItem = ({ item, isLoading, view }: Props) => {
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.timestamp).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
......
...@@ -5,10 +5,10 @@ import type { AddressWithdrawalsItem } from 'types/api/address'; ...@@ -5,10 +5,10 @@ import type { AddressWithdrawalsItem } from 'types/api/address';
import type { BlockWithdrawalsItem } from 'types/api/block'; import type { BlockWithdrawalsItem } from 'types/api/block';
import type { WithdrawalsItem } from 'types/api/withdrawals'; import type { WithdrawalsItem } from 'types/api/withdrawals';
import dayjs from 'lib/date/dayjs';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = ({ type Props = ({
item: WithdrawalsItem; item: WithdrawalsItem;
...@@ -52,9 +52,12 @@ const BeaconChainWithdrawalsTableItem = ({ item, view, isLoading }: Props) => { ...@@ -52,9 +52,12 @@ const BeaconChainWithdrawalsTableItem = ({ item, view, isLoading }: Props) => {
) } ) }
{ view !== 'block' && ( { view !== 'block' && (
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary"> <TimeAgoWithTooltip
<span>{ dayjs(item.timestamp).fromNow() }</span> timestamp={ item.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
) } ) }
<Td verticalAlign="middle"> <Td verticalAlign="middle">
......
...@@ -10,13 +10,13 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity'; ...@@ -10,13 +10,13 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean }; type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean };
const OptimisticL2WithdrawalsListItem = ({ item, isLoading }: Props) => { 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; const timeToEnd = item.challenge_period_end ? dayjs(item.challenge_period_end).fromNow(true) + ' left' : null;
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
...@@ -57,13 +57,15 @@ const OptimisticL2WithdrawalsListItem = ({ item, isLoading }: Props) => { ...@@ -57,13 +57,15 @@ const OptimisticL2WithdrawalsListItem = ({ item, isLoading }: Props) => {
/> />
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
{ timeAgo && ( { item.l2_timestamp && (
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block"> <TimeAgoWithTooltip
{ timeAgo } timestamp={ item.l2_timestamp }
</Skeleton> isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
......
...@@ -9,13 +9,13 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -9,13 +9,13 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import LinkExternal from 'ui/shared/links/LinkExternal'; import LinkExternal from 'ui/shared/links/LinkExternal';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean }; type Props = { item: OptimisticL2WithdrawalsItem; isLoading?: boolean };
const OptimisticL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { 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' : ''; const timeToEnd = item.challenge_period_end ? dayjs(item.challenge_period_end).fromNow(true) + ' left' : '';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
...@@ -47,9 +47,13 @@ const OptimisticL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { ...@@ -47,9 +47,13 @@ const OptimisticL2WithdrawalsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span> { timeAgo }</span> timestamp={ item.l2_timestamp }
</Skeleton> fallbackText="N/A"
isLoading={ isLoading }
display="inline-block"
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ item.status === 'Ready for relay' && rollupFeature.L2WithdrawalUrl ? { item.status === 'Ready for relay' && rollupFeature.L2WithdrawalUrl ?
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumWithdrawalsItem } from 'types/api/shibarium'; import type { ShibariumWithdrawalsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean }; type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean };
const WithdrawalsListItem = ({ item, isLoading }: Props) => { const WithdrawalsListItem = ({ item, isLoading }: Props) => {
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : null;
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
} }
...@@ -69,7 +66,11 @@ const WithdrawalsListItem = ({ item, isLoading }: Props) => { ...@@ -69,7 +66,11 @@ const WithdrawalsListItem = ({ item, isLoading }: Props) => {
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</ListItemMobileGrid.Container> </ListItemMobileGrid.Container>
......
import { Td, Tr, Skeleton } from '@chakra-ui/react'; import { Td, Tr } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumWithdrawalsItem } from 'types/api/shibarium'; import type { ShibariumWithdrawalsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean }; type Props = { item: ShibariumWithdrawalsItem; isLoading?: boolean };
const WithdrawalsTableItem = ({ item, isLoading }: Props) => { const WithdrawalsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
} }
...@@ -59,7 +57,12 @@ const WithdrawalsTableItem = ({ item, isLoading }: Props) => { ...@@ -59,7 +57,12 @@ const WithdrawalsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
color="text_secondary"
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -5,11 +5,11 @@ import React from 'react'; ...@@ -5,11 +5,11 @@ import React from 'react';
import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -20,8 +20,6 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => { ...@@ -20,8 +20,6 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<ListItemMobileGrid.Container> <ListItemMobileGrid.Container>
...@@ -56,7 +54,11 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => { ...@@ -56,7 +54,11 @@ const ZkEvmL2WithdrawalsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
......
...@@ -5,10 +5,10 @@ import React from 'react'; ...@@ -5,10 +5,10 @@ import React from 'react';
import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2WithdrawalsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -19,8 +19,6 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { ...@@ -19,8 +19,6 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
...@@ -49,9 +47,11 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => { ...@@ -49,9 +47,11 @@ const ZkEvmL2WithdrawalsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ item.l1_transaction_hash ? ( { item.l1_transaction_hash ? (
......
...@@ -6229,6 +6229,11 @@ ...@@ -6229,6 +6229,11 @@
dependencies: dependencies:
undici-types "~5.26.4" 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": "@types/papaparse@^5.3.5":
version "5.3.5" version "5.3.5"
resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.5.tgz#e5ad94b1fe98e2a8ea0b03284b83d2cb252bbf39" resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.5.tgz#e5ad94b1fe98e2a8ea0b03284b83d2cb252bbf39"
...@@ -7116,6 +7121,11 @@ abort-controller@^3.0.0: ...@@ -7116,6 +7121,11 @@ abort-controller@^3.0.0:
dependencies: dependencies:
event-target-shim "^5.0.0" 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: acorn-globals@^7.0.0:
version "7.0.1" version "7.0.1"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
...@@ -7179,6 +7189,17 @@ aggregate-error@^3.0.0: ...@@ -7179,6 +7189,17 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0" clean-stack "^2.0.0"
indent-string "^4.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: ajv@^6.12.4:
version "6.12.6" version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment