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
const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL');
const ratingAirtableApiKey = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY');
const ratingAirtableBaseId = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID');
const title = 'Marketplace';
......@@ -27,6 +29,7 @@ const config: Feature<(
securityReportsUrl: string | undefined;
featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined;
rating: { airtableApiKey: string; airtableBaseId: string } | undefined;
}> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = {
......@@ -39,6 +42,10 @@ const config: Feature<(
contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl,
} : undefined,
rating: ratingAirtableApiKey && ratingAirtableBaseId ? {
airtableApiKey: ratingAirtableApiKey,
airtableBaseId: ratingAirtableBaseId,
} : undefined,
};
if (configUrl) {
......
......@@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}
......
......@@ -223,6 +223,22 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
});
const beaconChainSchema = yup
......
......@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=test
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=test
......@@ -86,3 +86,4 @@ frontend:
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
......@@ -96,3 +96,4 @@ frontend:
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
......@@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY | `string` | Airtable API key | - | - | - | v1.33.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID | `string` | Airtable base ID with dapp ratings | - | - | - | v1.33.0+ |
#### Marketplace app configuration properties
......
<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">
<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"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<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 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>
......@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
action_button_exp: boolean;
}
export const growthBook = (() => {
......
......@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer';
const STEP = 10;
const MIN_ITEMS_NUM = 50;
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM);
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean, minItemsNum: number = MIN_ITEMS_NUM) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(minItemsNum);
const { ref, inView } = useInView({
rootMargin: '200px',
triggerOnce: false,
skip: !isEnabled || list.length <= MIN_ITEMS_NUM,
skip: !isEnabled || list.length <= minItemsNum,
});
React.useEffect(() => {
......
......@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps>
position: 'top-right',
isClosable: true,
containerStyle: {
margin: 8,
margin: 3,
marginBottom: 0,
},
variant: 'subtle',
};
......
......@@ -20,6 +20,7 @@ export enum EventTypes {
FILTERS = 'Filters',
BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner',
APP_FEEDBACK = 'App feedback',
}
/* eslint-disable @typescript-eslint/indent */
......@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace';
'Link': string;
} :
Type extends EventTypes.APP_FEEDBACK ? {
'Action': 'Rating';
'Source': 'Discovery' | 'App modal' | 'App page';
'AppId': string;
'Score': number;
} :
undefined;
/* eslint-enable @typescript-eslint/indent */
import { apps } from './apps';
export const ratings = {
records: [
{
fields: {
appId: apps[0].id,
rating: 4.3,
},
},
],
};
import { apps } from './apps';
export const securityReports = [
{
appName: 'token-approval-tracker',
appName: apps[0].id,
doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet',
chainsData: {
'1': {
......
......@@ -10,6 +10,7 @@ function generateCspPolicy() {
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.growthBook(),
descriptors.marketplace(),
descriptors.mixpanel(),
descriptors.monaco(),
descriptors.safe(),
......
......@@ -31,8 +31,6 @@ const getCspReportUrl = () => {
};
export function app(): CspDev.DirectiveDescriptor {
const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace);
return {
'default-src': [
// KEY_WORDS.NONE,
......@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint,
getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '',
// chain RPC server
config.chain.rpcUrl,
......
......@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook';
export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel';
export { monaco } from './monaco';
export { safe } from './safe';
......
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 @@
| "globe-b"
| "globe"
| "graphQL"
| "heart_filled"
| "heart_outline"
| "hourglass"
| "info"
| "integration/full"
......
......@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => {
[$bg.variable]: `colors.${ bg }`,
[$fg.variable]: `colors.${ fg }`,
[$arrowBg.variable]: $bg.reference,
maxWidth: props.maxWidth || props.maxW || 'unset',
maxWidth: props.maxWidth || props.maxW || 'calc(100vw - 8px)',
marginX: '4px',
};
});
......
......@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string;
}
export type AppRating = {
recordId: string;
value: number | undefined;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport;
rating?: AppRating;
}
export enum MarketplaceCategory {
......
......@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
});
await page.mouse.move(100, 100);
await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot();
});
......@@ -3,11 +3,11 @@ import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = {
isPlaceholderData: boolean;
......@@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => {
Action
</Text>
</Flex>
<Text color="text_secondary" fontSize="sm" fontWeight={ 500 }>
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() }
</Text>
<TimeAgoWithTooltip
timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
color="text_secondary"
borderRadius="sm"
fontWeight={ 500 }
/>
</Flex>
</Skeleton>
<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 type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = {
isPlaceholderData: boolean;
......@@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => {
return (
<Tr>
<Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" flexShrink={ 0 } isLoaded={ !props.isPlaceholderData }>
<Text as="span" color="text_secondary">
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() }
</Text>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
isLoading={ props.isPlaceholderData }
color="text_secondary"
borderRadius="sm"
flexShrink={ 0 }
/>
</Td>
<Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
......
......@@ -6,11 +6,11 @@ import type { Block } from 'types/api/block';
import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & {
page: number;
......@@ -18,7 +18,6 @@ type Props = Block & {
};
const AddressBlocksValidatedListItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props);
return (
......@@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => {
noIcon
fontWeight={ 700 }
/>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ props.timestamp }
enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Flex>
<Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Txn</Skeleton>
......@@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => {
<Flex columnGap={ 2 } w="100%">
<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>
{ props.gas_used && props.gas_used !== '0' && (
<Utilization
colorScheme="gray"
value={ BigNumber(props.gas_used || 0).dividedBy(BigNumber(props.gas_limit)).toNumber() }
<BlockGasUsed
gasUsed={ props.gas_used }
gasLimit={ props.gas_limit }
isLoading={ props.isLoading }
/>
) }
</Flex>
{ !config.UI.views.block.hiddenFields?.total_reward && (
<Flex columnGap={ 2 } w="100%">
......
......@@ -6,9 +6,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import Utilization from 'ui/shared/Utilization/Utilization';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & {
page: number;
......@@ -16,7 +16,6 @@ type Props = Block & {
};
const AddressBlocksValidatedTableItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props);
return (
......@@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
/>
</Td>
<Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ props.timestamp }
enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td>
<Td>
<Skeleton isLoaded={ !props.isLoading } display="inline-block" fontWeight="500">
......@@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
<Skeleton isLoaded={ !props.isLoading } flexBasis="80px">
{ BigNumber(props.gas_used || 0).toFormat() }
</Skeleton>
{ props.gas_used && props.gas_used !== '0' && (
<Utilization
colorScheme="gray"
value={ BigNumber(props.gas_used).dividedBy(BigNumber(props.gas_limit)).toNumber() }
<BlockGasUsed
gasUsed={ props.gas_used }
gasLimit={ props.gas_limit }
isLoading={ props.isLoading }
/>
) }
</Flex>
</Td>
{ !config.UI.views.block.hiddenFields?.total_reward && (
......
......@@ -5,11 +5,11 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & {
page: number;
......@@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceListItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return (
<ListItemMobile rowGap={ 2 } isAnimated>
......@@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => {
) }
<Flex columnGap={ 2 } w="100%">
<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>
</ListItemMobile>
);
......
......@@ -5,9 +5,9 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & {
page: number;
......@@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceTableItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return (
<Tr>
......@@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => {
) }
</Td>
<Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ props.block_timestamp }
enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td>
<Td isNumeric pr={ 1 }>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
......
......@@ -5,7 +5,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag';
......@@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean };
......@@ -47,9 +47,13 @@ const TxInternalsListItem = ({
fontWeight={ 700 }
truncation="constant_long"
/>
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm">
<span>{ dayjs(timestamp).fromNow() }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ timestamp }
isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex>
<HStack spacing={ 1 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Block</Skeleton>
......
......@@ -5,12 +5,12 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }
......@@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const toData = to ? to : createdContract;
const timeAgo = useTimeAgoIncrement(timestamp, true);
return (
<Tr alignItems="top">
<Td verticalAlign="middle">
......@@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({
noIcon
truncation="constant_long"
/>
{ timestamp && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm">
<span>{ timeAgo }</span>
</Skeleton>
) }
<TimeAgoWithTooltip
timestamp={ timestamp }
enableIncrement
isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex>
</Td>
<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 capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
......@@ -18,6 +18,7 @@ import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString';
import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
......@@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import PrevNext from 'ui/shared/PrevNext';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import StatusTag from 'ui/shared/statusTag/StatusTag';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
......@@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => {
const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isPlaceholderData } = query;
const handleCutClick = React.useCallback(() => {
......@@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => {
<Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas_used || 0).toFormat() }
</Skeleton>
<Utilization
ml={ 4 }
colorScheme="gray"
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }
<BlockGasUsed
gasUsed={ data.gas_used }
gasLimit={ data.gas_limit }
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.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 capitalize from 'lodash/capitalize';
import React from 'react';
......@@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TextSeparator from 'ui/shared/TextSeparator';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
......@@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
return (
<ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated>
<Flex justifyContent="space-between" w="100%">
......@@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
fontWeight={ 600 }
/>
</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 columnGap={ 2 }>
<Text fontWeight={ 500 }>Size</Text>
......@@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary" mr={ 4 }>
<span>{ BigNumber(data.gas_used || 0).toFormat() }</span>
</Skeleton>
<Utilization colorScheme="gray" value={ BigNumber(data.gas_used || 0).div(BigNumber(data.gas_limit)).toNumber() } isLoading={ isLoading }/>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/>
</>
) }
<BlockGasUsed
gasUsed={ data.gas_used }
gasLimit={ data.gas_limit }
isLoading={ isLoading }
gasTarget={ data.gas_target_percentage }
/>
</Flex>
</Box>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
......@@ -10,13 +10,12 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
......@@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const burntFeesIconColor = useColorModeValue('gray.500', 'inherit');
return (
......@@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
/>
</Tooltip>
</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 fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">
......@@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Td fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<Flex mt={ 2 }>
<Tooltip label={ isLoading ? undefined : 'Gas Used %' }>
<Box>
<Utilization
colorScheme="gray"
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }
<BlockGasUsed
gasUsed={ data.gas_used }
gasLimit={ data.gas_limit }
isLoading={ isLoading }
gasTarget={ data.gas_target_percentage }
/>
</Box>
</Tooltip>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/>
</>
) }
</Flex>
</Td>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
......@@ -5,20 +5,18 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null;
}
......@@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
......
......@@ -5,18 +5,17 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null;
......@@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
/>
</Td>
<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 verticalAlign="middle">
<TxEntityL1
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null;
}
......@@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
......
import { Td, Tr, Skeleton } from '@chakra-ui/react';
import { Td, Tr } from '@chakra-ui/react';
import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null;
......@@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => {
/>
</Td>
<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>
</Tr>
);
......
......@@ -5,11 +5,11 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
......@@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<ListItemMobileGrid.Container>
......@@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label>
......
......@@ -5,10 +5,10 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
......@@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
return null;
}
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<Tr>
<Td verticalAlign="middle">
......@@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
/>
</Td>
<Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
/>
</Td>
<Td verticalAlign="middle">
{ item.l2_transaction_hash ? (
......
......@@ -4,11 +4,11 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
......@@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<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.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
......@@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
{ item.resolved_at && (
<>
<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>
</>
) }
......
......@@ -4,10 +4,10 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const faultProofSystemFeature = config.features.faultProofSystem;
......@@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => {
/>
</Td>
<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 verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.status }</Skeleton>
</Td>
<Td>
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ item.resolved_at ? dayjs(item.resolved_at).fromNow() : 'N/A' }
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.resolved_at }
fallbackText="N/A"
isLoading={ isLoading }
display="inline-block"
/>
</Td>
</Tr>
);
......
......@@ -12,9 +12,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = {
block: Block;
......@@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => {
fontWeight={ 500 }
mr="auto"
/>
<BlockTimestamp
ts={ block.timestamp }
isEnabled={ !isLoading }
<TimeAgoWithTooltip
timestamp={ block.timestamp }
enableIncrement={ !isLoading }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm"
flexShrink={ 0 }
ml={ 2 }
......
......@@ -9,11 +9,11 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup;
......@@ -23,7 +23,6 @@ type Props = {
}
const LatestDepositsItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
const isMobile = useIsMobile();
if (!feature.isEnabled || feature.type !== 'optimistic') {
......@@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
<>
<Flex justifyContent="space-between" alignItems="center" mb={ 1 }>
{ l1BlockLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
color="text_secondary"
/>
</Flex>
<Grid gridTemplateColumns="56px auto">
<Skeleton isLoaded={ !isLoading } my="5px" w="fit-content">
......@@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
L1 txn
</Skeleton>
{ l1TxLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary" w="fit-content" h="fit-content" my="2px">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
color="text_secondary"
w="fit-content"
h="fit-content"
my="2px"
/>
<Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px">
L2 txn
</Skeleton>
......
......@@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
......@@ -29,7 +29,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
const columnNum = config.UI.views.tx.hiddenFields?.value && config.UI.views.tx.hiddenFields?.tx_fee ? 2 : 3;
return (
......@@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
hash={ tx.hash }
fontWeight="700"
/>
{ tx.timestamp && (
<Skeleton
isLoaded={ !isLoading }
<TimeAgoWithTooltip
timestamp={ tx.timestamp }
enableIncrement
isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
flexShrink={ 0 }
ml={ 2 }
>
<span>{ timeAgo }</span>
</Skeleton>
) }
/>
</Flex>
</Box>
</Flex>
......
......@@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
......@@ -28,7 +28,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
return (
<Box
......@@ -60,11 +59,15 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
fontWeight="700"
truncation="constant_long"
/>
{ tx.timestamp && (
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm" ml={ 3 }>
<span>{ timeAgo }</span>
</Skeleton>
) }
<TimeAgoWithTooltip
timestamp={ tx.timestamp }
enableIncrement
isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
ml={ 3 }
/>
</Flex>
<AddressFromTo
from={ tx.from }
......
......@@ -10,10 +10,10 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import { route } from 'nextjs-routes';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import LinkInternal from 'ui/shared/links/LinkInternal';
import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = {
batch: ZkEvmL2TxnBatchesItem;
......@@ -44,10 +44,13 @@ const LatestZkevmL2BatchItem = ({ batch, isLoading }: Props) => {
fontWeight={ 500 }
mr="auto"
/>
<BlockTimestamp
ts={ batch.timestamp }
isEnabled={ !isLoading }
<TimeAgoWithTooltip
timestamp={ batch.timestamp }
enableIncrement={ !isLoading }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm"
flexShrink={ 0 }
ml={ 2 }
......
......@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
test('no data', async({ mockApiResponse, mockAssetResponse, render }) => {
await mockApiResponse('stats', statsMock.noChartData);
await mockApiResponse('stats_charts_txs', dailyTxsMock.noData);
await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg');
await mockAssetResponse(statsMock.noChartData.coin_image as string, './playwright/mocks/image_s.jpg');
const component = await render(<ChainIndicators/>);
await expect(component).toHaveScreenshot();
......
......@@ -72,7 +72,7 @@ const AppSecurityReport = ({
className={ className }
/>
</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">
<Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text>
<Flex alignItems="center" justifyContent="space-between" py={ 1.5 }>
......
......@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
import FeaturedAppMobile from './FeaturedAppMobile';
......@@ -136,10 +136,7 @@ const FeaturedApp = ({
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
) }
</Flex>
......
......@@ -4,8 +4,7 @@ import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
......@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({
w={ 9 }
h={ 8 }
onClick={ onFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
) }
</Flex>
......
......@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<>
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 React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void;
......@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport {
onAppClick: (event: MouseEvent, id: string) => void;
className?: string;
showContractList: (id: string, type: ContractListTypes) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
}
const MarketplaceAppCard = ({
......@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({
securityReport,
className,
showContractList,
rating,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => {
const isMobile = useIsMobile();
const categoriesLabel = categories.join(', ');
......@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
</Skeleton>
{ !isLoading && (
<Box
display="flex"
<Flex
alignItems="center"
justifyContent="space-between"
marginTop="auto"
......@@ -156,6 +168,17 @@ const MarketplaceAppCard = ({
>
More info
</Link>
<Flex alignItems="center" gap={ 3 }>
<Rating
appId={ id }
rating={ rating }
userRating={ userRating }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="Discovery"
/>
<IconButton
aria-label="Mark as favorite"
title="Mark as favorite"
......@@ -164,12 +187,10 @@ const MarketplaceAppCard = ({
w={{ base: 6, md: '30px' }}
h={{ base: 6, md: '30px' }}
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
</Box>
</Flex>
</Flex>
) }
{ securityReport && (
......
......@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
textAlign="center"
padding={ 2 }
openDelay={ 300 }
maxW={ 400 }
maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
>
<IconSvg
name={ icon }
......
......@@ -15,13 +15,27 @@ const props = {
data: {
...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'],
rating: {
recordId: 'test',
value: 4.3,
},
} as MarketplaceAppWithSecurityReport,
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 render(<MarketplaceAppModal { ...props }/>);
await page.getByText('Launch app').focus();
await expect(page).toHaveScreenshot();
};
......
......@@ -4,9 +4,10 @@ import {
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
const feature = config.features.marketplace;
const isRatingEnabled = feature.isEnabled && feature.rating;
type Props = {
onClose: () => void;
......@@ -22,6 +29,11 @@ type Props = {
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
}
const MarketplaceAppModal = ({
......@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({
onFavoriteClick,
data,
showContractList: showContractListProp,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => {
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const {
id,
title,
......@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({
logoDarkMode,
categories,
securityReport,
rating,
} = data;
const socialLinks = [
......@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({
w={{ base: '72px', md: '144px' }}
h={{ base: '72px', md: '144px' }}
marginRight={{ base: 6, md: 8 }}
gridRow={{ base: '1 / 3', md: '1 / 4' }}
gridRow={{ base: '1 / 3', md: '1 / 5' }}
>
<Image
src={ logoUrl }
......@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({
<Heading
as="h2"
gridColumn={ 2 }
fontSize={{ base: '2xl', md: '3xl' }}
fontSize={{ base: '2xl', md: '32px' }}
fontWeight="medium"
lineHeight={ 1 }
color="blue.600"
lineHeight={{ md: 10 }}
mb={{ md: 2 }}
>
{ title }
</Heading>
......@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({
<Text
variant="secondary"
gridColumn={ 2 }
fontSize="sm"
fontSize={{ base: 'sm', md: 'md' }}
fontWeight="normal"
lineHeight={ 1 }
lineHeight={{ md: 6 }}
>
By{ nbsp }{ author }
</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
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 0 }}
marginTop={{ base: 6, md: 3 }}
>
<Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', md: 'auto' }}>
......@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({
w={ 9 }
h={ 8 }
onClick={ handleFavoriteClick }
icon={ isFavorite ?
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color={ starOutlineIconColor }/> }
icon={ <FavoriteIcon isFavorite={ isFavorite } color={ useColorModeValue('blue.700', 'gray.400') }/> }
/>
</Flex>
</Flex>
......
......@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal';
import MarketplaceAppInfo from './MarketplaceAppInfo';
import Rating from './Rating/Rating';
import useRatings from './Rating/useRatings';
type Props = {
appId: string;
data: MarketplaceAppOverview | undefined;
isLoading: boolean;
securityReport?: MarketplaceAppSecurityReport;
}
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) => {
const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext();
const isMobile = useIsMobile();
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
return appProps.referrer;
......@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
source="App page"
/>
) }
<Rating
appId={ appId }
rating={ ratings[appId] }
userRating={ userRatings[appId] }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="App page"
/>
{ !isMobile && (
<Flex flex="1" justifyContent="flex-end">
{ 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 type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
import type { RateFunction } from './Rating/useRatings';
type Props = {
apps: Array<MarketplaceAppWithSecurityReport>;
......@@ -18,9 +20,19 @@ type Props = {
selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => 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) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id);
......@@ -31,6 +43,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
}, [ onFavoriteClick ]);
return apps.length > 0 ? (
<>
<Grid
templateColumns={{
md: 'repeat(auto-fill, minmax(230px, 1fr))',
......@@ -40,7 +53,7 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
gap={{ base: '16px', md: '24px' }}
marginTop={{ base: 0, lg: 3 }}
>
{ apps.map((app, index) => (
{ apps.slice(0, renderedItemsNum).map((app, index) => (
<MarketplaceAppCard
key={ app.id + (isLoading ? index : '') }
onInfoClick={ handleInfoClick }
......@@ -59,9 +72,17 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
onAppClick={ onAppClick }
securityReport={ app.securityReport }
showContractList={ showContractList }
rating={ app.rating }
userRating={ userRatings[app.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
)) }
</Grid>
<Box ref={ cutRef } h={ 0 }/>
</>
) : (
<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';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import useRatings from './Rating/useRatings';
import useMarketplaceApps from './useMarketplaceApps';
import useMarketplaceCategories from './useMarketplaceCategories';
......@@ -85,9 +86,10 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory);
}, []);
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const {
isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded, ratings);
const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories,
} = useMarketplaceCategories(data, isPlaceholderData);
......@@ -151,6 +153,11 @@ export default function useMarketplace() {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}), [
selectedCategoryId,
categories,
......@@ -174,5 +181,10 @@ export default function useMarketplace() {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
]);
}
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
......@@ -55,6 +55,7 @@ export default function useMarketplaceApps(
selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
ratings: Record<string, AppRating> | undefined = undefined,
) {
const fetch = useFetch();
const apiFetch = useApiFetch();
......@@ -91,20 +92,27 @@ export default function useMarketplaceApps(
const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })),
[ data, securityReports ]);
const appsWithSecurityReportsAndRating = React.useMemo(() =>
data?.map((app) => ({
...app,
securityReport: securityReports?.[app.id],
rating: ratings?.[app.id],
})),
[ data, securityReports, ratings ]);
const displayedApps = React.useMemo(() => {
return appsWithSecurityReports
return appsWithSecurityReportsAndRating
?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps))
.sort((a, b) => {
if (sorting === 'security_score') {
return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0);
}
if (sorting === 'rating') {
return (b.rating?.value || 0) - (a.rating?.value || 0);
}
return 0;
}) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]);
}, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]);
return React.useMemo(() => ({
data,
......
......@@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam';
import type { TOption } from 'ui/shared/sort/Option';
export type SortValue = 'security_score';
export type SortValue = 'rating' | 'security_score';
export const SORT_OPTIONS: Array<TOption<SortValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Rating', id: 'rating' },
{ title: 'Security score', id: 'security_score' },
];
......
......@@ -4,13 +4,13 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages';
......@@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
return null;
}
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash;
const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
......@@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.origination_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
......
......@@ -4,12 +4,12 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages';
......@@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
return null;
}
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash;
const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
......@@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
) }
</Td>
<Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.origination_timestamp }
isLoading={ isLoading }
color="text_secondary"
/>
</Td>
<Td verticalAlign="middle">
<ArbitrumL2MessageStatus status={ item.status } isLoading={ isLoading }/>
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type * as bens from '@blockscout/bens-types';
......@@ -6,12 +5,12 @@ import type * as bens from '@blockscout/bens-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props {
event: bens.DomainEvent;
......@@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(event.timestamp).fromNow() }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ event.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</ListItemMobileGrid.Value>
{ event.from_address && (
......
import { Tr, Td, Skeleton } from '@chakra-ui/react';
import { Tr, Td } from '@chakra-ui/react';
import React from 'react';
import type * as bens from '@blockscout/bens-types';
......@@ -6,11 +6,11 @@ import type * as bens from '@blockscout/bens-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props {
event: bens.DomainEvent;
......@@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => {
/>
</Td>
<Td pl={ 9 } verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block">
<span>{ dayjs(event.timestamp).fromNow() }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ event.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td>
<Td verticalAlign="middle">
{ event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> }
......
......@@ -4,20 +4,18 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null;
}
......@@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
<TimeAgoWithTooltip
timestamp={ item.l1_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 block #</ListItemMobileGrid.Label>
......
......@@ -4,19 +4,17 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null;
}
......@@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.l2_output_index }</Skeleton>
</Td>
<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 verticalAlign="middle">
<BlockEntityL2
......
......@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react';
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';
......@@ -10,15 +11,21 @@ import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.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([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg')));
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
});
test('base view +@dark-mode', async({ render }) => {
......
......@@ -71,6 +71,11 @@ const Marketplace = () => {
contractListModalType,
hasPreviousStep,
setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
} = useMarketplace();
const isMobile = useIsMobile();
......@@ -92,13 +97,13 @@ const Marketplace = () => {
tabs.unshift({
id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 } display="flex"/>,
count: null,
title: () => <IconSvg name="heart_filled" boxSize={ 5 } verticalAlign="middle" mt={ -1 }/>,
count: favoriteApps.length,
component: null,
});
return tabs;
}, [ categories, appsTotal ]);
}, [ categories, appsTotal, favoriteApps.length ]);
const selectedCategoryIndex = React.useMemo(() => {
const index = categoryTabs.findIndex(c => c.id === selectedCategoryId);
......@@ -224,6 +229,11 @@ const Marketplace = () => {
selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick }
showContractList={ showContractList }
userRatings={ userRatings }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
{ (selectedApp && isAppInfoModalOpen) && (
......@@ -233,6 +243,11 @@ const Marketplace = () => {
onFavoriteClick={ onFavoriteClick }
data={ selectedApp }
showContractList={ showContractList }
userRating={ userRatings[selectedApp.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
) }
......
......@@ -4,6 +4,8 @@ import { numberToHex } from 'viem';
import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib';
import MarketplaceApp from './MarketplaceApp';
......@@ -16,18 +18,27 @@ const hooksConfig = {
};
const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => {
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await mockAssetResponse(appsMock[0].url, './mocks/apps/app.html');
await mockRpcResponse({
Method: 'eth_chainId',
ReturnType: numberToHex(Number(config.chain.id)),
});
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
const component = await render(
<Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh">
......
......@@ -151,6 +151,7 @@ const MarketplaceApp = () => {
return (
<Flex flexDirection="column" h="100%">
<MarketplaceAppTopBar
appId={ id }
data={ data }
isLoading={ isPending || isSecurityReportsLoading }
securityReport={ securityReports?.[id] }
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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