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

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

parents ad7d8d11 035256aa
...@@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC ...@@ -14,6 +14,8 @@ const securityReportsUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_SEC
const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP'); const featuredApp = getEnvValue('NEXT_PUBLIC_MARKETPLACE_FEATURED_APP');
const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL'); const bannerContentUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL');
const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL'); const bannerLinkUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL');
const ratingAirtableApiKey = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY');
const ratingAirtableBaseId = getEnvValue('NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID');
const title = 'Marketplace'; const title = 'Marketplace';
...@@ -27,6 +29,7 @@ const config: Feature<( ...@@ -27,6 +29,7 @@ const config: Feature<(
securityReportsUrl: string | undefined; securityReportsUrl: string | undefined;
featuredApp: string | undefined; featuredApp: string | undefined;
banner: { contentUrl: string; linkUrl: string } | undefined; banner: { contentUrl: string; linkUrl: string } | undefined;
rating: { airtableApiKey: string; airtableBaseId: string } | undefined;
}> = (() => { }> = (() => {
if (enabled === 'true' && chain.rpcUrl && submitFormUrl) { if (enabled === 'true' && chain.rpcUrl && submitFormUrl) {
const props = { const props = {
...@@ -39,6 +42,10 @@ const config: Feature<( ...@@ -39,6 +42,10 @@ const config: Feature<(
contentUrl: bannerContentUrl, contentUrl: bannerContentUrl,
linkUrl: bannerLinkUrl, linkUrl: bannerLinkUrl,
} : undefined, } : undefined,
rating: ratingAirtableApiKey && ratingAirtableBaseId ? {
airtableApiKey: ratingAirtableApiKey,
airtableBaseId: ratingAirtableBaseId,
} : undefined,
}; };
if (configUrl) { if (configUrl) {
......
...@@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol ...@@ -36,6 +36,7 @@ NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=gearbox-protocol
NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-security-reports/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=appGkvtmKI7fXE4Vs
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_METASUITES_ENABLED=true NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'} NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG={'name': 'zerion', 'dapp_id': 'zerion', 'url_template': 'https://app.zerion.io/{address}/overview?utm_source=blockscout&utm_medium=address', 'logo': 'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-logos/zerion.svg'}
...@@ -59,4 +60,4 @@ NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true ...@@ -59,4 +60,4 @@ NEXT_PUBLIC_SEO_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s-prod-1.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
...@@ -223,6 +223,22 @@ const marketplaceSchema = yup ...@@ -223,6 +223,22 @@ const marketplaceSchema = yup
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'), otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}), }),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_ENABLED', {
is: true,
then: (schema) => schema,
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID cannot not be used without NEXT_PUBLIC_MARKETPLACE_ENABLED'),
}),
}); });
const beaconChainSchema = yup const beaconChainSchema = yup
......
...@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com ...@@ -8,3 +8,5 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave NEXT_PUBLIC_MARKETPLACE_FEATURED_APP=aave
NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL=https://gist.githubusercontent.com/maxaleks/36f779fd7d74877b57ec7a25a9a3a6c9/raw/746a8a59454c0537235ee44616c4690ce3bbf3c8/banner.html
NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL=https://www.basename.app
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY=test
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID=test
...@@ -86,3 +86,4 @@ frontend: ...@@ -86,3 +86,4 @@ frontend:
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review-l2?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png NEXT_PUBLIC_OG_IMAGE_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/base-mainnet.png
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
...@@ -96,3 +96,4 @@ frontend: ...@@ -96,3 +96,4 @@ frontend:
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN
NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY: ref+vault://deployment-values/blockscout/dev/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY
...@@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin ...@@ -474,6 +474,8 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` | v1.29.0+ | | NEXT_PUBLIC_MARKETPLACE_FEATURED_APP | `string` | ID of the featured application to be displayed on the banner on the Marketplace page | - | - | `uniswap` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ | | NEXT_PUBLIC_MARKETPLACE_BANNER_CONTENT_URL | `string` | URL of the banner HTML content | - | - | `https://example.com/banner` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ | | NEXT_PUBLIC_MARKETPLACE_BANNER_LINK_URL | `string` | URL of the page the banner leads to | - | - | `https://example.com` | v1.29.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY | `string` | Airtable API key | - | - | - | v1.33.0+ |
| NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID | `string` | Airtable base ID with dapp ratings | - | - | - | v1.33.0+ |
#### Marketplace app configuration properties #### Marketplace app configuration properties
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="m10 3.557-.555-.577c-.947-.946-2.205-.989-3.508-.97a4.876 4.876 0 0 0-3.469 1.547A5.443 5.443 0 0 0 1.001 7.22a5.46 5.46 0 0 0 1.363 3.709L10 18.5l7.636-7.58a5.46 5.46 0 0 0 1.363-3.709 5.443 5.443 0 0 0-1.467-3.664 4.876 4.876 0 0 0-3.47-1.546c-1.302-.02-2.56.023-3.507.969L10 3.557Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 20" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor" stroke="currentColor" stroke-width=".4"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.671 3.458a5.332 5.332 0 0 1 7.542 7.532l-.007.008-7.54 7.548-7.539-7.54-.007-.008a5.332 5.332 0 0 1 7.542-7.532l.002-.001.008-.007Zm1.017 1.06-1.017 1.018L9.647 4.53A3.862 3.862 0 0 0 4.18 9.983l6.485 6.484 6.485-6.493a3.863 3.863 0 0 0-5.463-5.455Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<path d="M14.76 18.333a.603.603 0 0 1-.294-.075L10 15.798l-4.467 2.46a.607.607 0 0 1-.663-.052.657.657 0 0 1-.213-.285.69.69 0 0 1-.038-.36l.853-5.21-3.615-3.69a.69.69 0 0 1-.16-.677.663.663 0 0 1 .194-.3.615.615 0 0 1 .315-.149l4.995-.76 2.233-4.74a.65.65 0 0 1 .233-.269.61.61 0 0 1 .666 0c.1.065.18.158.232.269l2.234 4.74 4.994.76c.116.018.226.07.316.149.09.079.157.183.193.3a.69.69 0 0 1-.16.678l-3.615 3.69.854 5.21a.692.692 0 0 1-.14.537.636.636 0 0 1-.216.173.607.607 0 0 1-.266.061h.001Z" fill="currentColor"/> <path d="M15.713 20a.724.724 0 0 1-.354-.09L10 16.956 4.64 19.91a.728.728 0 0 1-.796-.061.788.788 0 0 1-.256-.342.827.827 0 0 1-.045-.432l1.024-6.252L.229 8.394a.802.802 0 0 1-.207-.377.829.829 0 0 1 .015-.435.795.795 0 0 1 .232-.361.741.741 0 0 1 .379-.179L6.64 6.13 9.321.442a.78.78 0 0 1 .28-.323.731.731 0 0 1 .798 0 .78.78 0 0 1 .28.323l2.68 5.688 5.993.912a.74.74 0 0 1 .379.178.8.8 0 0 1 .232.361.828.828 0 0 1-.192.813l-4.338 4.428 1.024 6.252a.83.83 0 0 1-.167.644.762.762 0 0 1-.26.208.728.728 0 0 1-.319.074h.002Z" fill="currentColor"/>
</svg> </svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.76 18.333a.603.603 0 0 1-.294-.075l.293.075Zm.003 0a.6.6 0 0 0 .262-.061c.083-.04.157-.1.216-.173a.674.674 0 0 0 .14-.538l-.854-5.21 3.616-3.69a.69.69 0 0 0 .16-.677.662.662 0 0 0-.194-.3.617.617 0 0 0-.316-.149l-4.884-.743a.208.208 0 0 1-.158-.117l-2.186-4.64a.651.651 0 0 0-.232-.269.61.61 0 0 0-.666 0 .65.65 0 0 0-.233.269l-2.186 4.64a.208.208 0 0 1-.157.117l-4.885.743a.617.617 0 0 0-.315.149.663.663 0 0 0-.194.3.69.69 0 0 0 .16.678L5.4 12.276a.208.208 0 0 1 .056.18L4.62 17.56a.69.69 0 0 0 .038.36.657.657 0 0 0 .213.285.613.613 0 0 0 .663.052L9.9 15.852a.208.208 0 0 1 .201 0l4.366 2.405m-7.795-2.915c-.028.172.154.3.306.216L9.9 13.95a.208.208 0 0 1 .201 0l2.922 1.61a.208.208 0 0 0 .306-.216l-.565-3.452a.208.208 0 0 1 .057-.18l2.485-2.536a.208.208 0 0 0-.117-.351l-3.409-.52a.208.208 0 0 1-.157-.116l-1.434-3.044a.208.208 0 0 0-.377 0L8.377 8.189a.208.208 0 0 1-.157.117l-3.408.518a.208.208 0 0 0-.118.352l2.486 2.537a.208.208 0 0 1 .057.18l-.566 3.45Zm8.092 2.99h-.003.003Z" fill="currentColor"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M15.711 20a.724.724 0 0 1-.352-.09Zm.005 0a.73.73 0 0 0 .314-.074.77.77 0 0 0 .26-.208.806.806 0 0 0 .167-.644l-1.024-6.252 4.338-4.428a.828.828 0 0 0 .192-.813.796.796 0 0 0-.232-.36.74.74 0 0 0-.38-.179l-5.86-.892a.25.25 0 0 1-.19-.14L10.679.442A.78.78 0 0 0 10.4.119a.732.732 0 0 0-.798 0 .78.78 0 0 0-.28.323L6.699 6.01a.25.25 0 0 1-.188.14l-5.862.892a.741.741 0 0 0-.38.179.795.795 0 0 0-.231.36.829.829 0 0 0-.015.436.802.802 0 0 0 .207.377l4.25 4.338a.25.25 0 0 1 .068.215l-1.004 6.127a.827.827 0 0 0 .045.432.788.788 0 0 0 .256.342.728.728 0 0 0 .796.061l5.24-2.886a.25.25 0 0 1 .24 0l5.24 2.886m-9.354-3.497a.25.25 0 0 0 .367.26L9.88 14.74a.25.25 0 0 1 .24 0l3.507 1.932a.25.25 0 0 0 .367-.26l-.678-4.141a.25.25 0 0 1 .068-.216l2.982-3.043a.25.25 0 0 0-.14-.423l-4.09-.622a.25.25 0 0 1-.189-.14l-1.72-3.653a.25.25 0 0 0-.453 0L8.053 7.826a.25.25 0 0 1-.189.141l-4.09.622a.25.25 0 0 0-.14.423l2.982 3.043a.25.25 0 0 1 .068.216l-.678 4.14ZM15.716 20h-.003Z" fill="currentColor"/>
</svg> </svg>
...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; ...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures { export interface GrowthBookFeatures {
test_value: string; test_value: string;
action_button_exp: boolean;
} }
export const growthBook = (() => { export const growthBook = (() => {
......
...@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer'; ...@@ -5,12 +5,12 @@ import { useInView } from 'react-intersection-observer';
const STEP = 10; const STEP = 10;
const MIN_ITEMS_NUM = 50; const MIN_ITEMS_NUM = 50;
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean) { export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean, minItemsNum: number = MIN_ITEMS_NUM) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM); const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(minItemsNum);
const { ref, inView } = useInView({ const { ref, inView } = useInView({
rootMargin: '200px', rootMargin: '200px',
triggerOnce: false, triggerOnce: false,
skip: !isEnabled || list.length <= MIN_ITEMS_NUM, skip: !isEnabled || list.length <= minItemsNum,
}); });
React.useEffect(() => { React.useEffect(() => {
......
...@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps> ...@@ -12,7 +12,8 @@ const defaultOptions: UseToastOptions & { toastComponent?: React.FC<ToastProps>
position: 'top-right', position: 'top-right',
isClosable: true, isClosable: true,
containerStyle: { containerStyle: {
margin: 8, margin: 3,
marginBottom: 0,
}, },
variant: 'subtle', variant: 'subtle',
}; };
......
...@@ -20,6 +20,7 @@ export enum EventTypes { ...@@ -20,6 +20,7 @@ export enum EventTypes {
FILTERS = 'Filters', FILTERS = 'Filters',
BUTTON_CLICK = 'Button click', BUTTON_CLICK = 'Button click',
PROMO_BANNER = 'Promo banner', PROMO_BANNER = 'Promo banner',
APP_FEEDBACK = 'App feedback',
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? { ...@@ -135,5 +136,11 @@ Type extends EventTypes.PROMO_BANNER ? {
'Source': 'Marketplace'; 'Source': 'Marketplace';
'Link': string; 'Link': string;
} : } :
Type extends EventTypes.APP_FEEDBACK ? {
'Action': 'Rating';
'Source': 'Discovery' | 'App modal' | 'App page';
'AppId': string;
'Score': number;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
import { apps } from './apps';
export const ratings = {
records: [
{
fields: {
appId: apps[0].id,
rating: 4.3,
},
},
],
};
import { apps } from './apps';
export const securityReports = [ export const securityReports = [
{ {
appName: 'token-approval-tracker', appName: apps[0].id,
doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet', doc: 'http://docs.li.fi/smart-contracts/deployments#mainnet',
chainsData: { chainsData: {
'1': { '1': {
......
...@@ -10,6 +10,7 @@ function generateCspPolicy() { ...@@ -10,6 +10,7 @@ function generateCspPolicy() {
descriptors.googleFonts(), descriptors.googleFonts(),
descriptors.googleReCaptcha(), descriptors.googleReCaptcha(),
descriptors.growthBook(), descriptors.growthBook(),
descriptors.marketplace(),
descriptors.mixpanel(), descriptors.mixpanel(),
descriptors.monaco(), descriptors.monaco(),
descriptors.safe(), descriptors.safe(),
......
...@@ -31,8 +31,6 @@ const getCspReportUrl = () => { ...@@ -31,8 +31,6 @@ const getCspReportUrl = () => {
}; };
export function app(): CspDev.DirectiveDescriptor { export function app(): CspDev.DirectiveDescriptor {
const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace);
return { return {
'default-src': [ 'default-src': [
// KEY_WORDS.NONE, // KEY_WORDS.NONE,
...@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -57,7 +55,6 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.addressVerification)?.api.endpoint, getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint, getFeaturePayload(config.features.nameService)?.api.endpoint,
getFeaturePayload(config.features.addressMetadata)?.api.endpoint, getFeaturePayload(config.features.addressMetadata)?.api.endpoint,
marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '',
// chain RPC server // chain RPC server
config.chain.rpcUrl, config.chain.rpcUrl,
......
...@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics'; ...@@ -5,6 +5,7 @@ export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts'; export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha'; export { googleReCaptcha } from './googleReCaptcha';
export { growthBook } from './growthBook'; export { growthBook } from './growthBook';
export { marketplace } from './marketplace';
export { mixpanel } from './mixpanel'; export { mixpanel } from './mixpanel';
export { monaco } from './monaco'; export { monaco } from './monaco';
export { safe } from './safe'; export { safe } from './safe';
......
import type CspDev from 'csp-dev';
import config from 'configs/app';
const feature = config.features.marketplace;
export function marketplace(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}
return {
'connect-src': [
'api' in feature ? feature.api.endpoint : '',
feature.rating ? 'https://api.airtable.com' : '',
],
'frame-src': [
'*',
],
};
}
...@@ -71,6 +71,8 @@ ...@@ -71,6 +71,8 @@
| "globe-b" | "globe-b"
| "globe" | "globe"
| "graphQL" | "graphQL"
| "heart_filled"
| "heart_outline"
| "hourglass" | "hourglass"
| "info" | "info"
| "integration/full" | "integration/full"
......
...@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => { ...@@ -35,7 +35,8 @@ const baseStyle = defineStyle((props) => {
[$bg.variable]: `colors.${ bg }`, [$bg.variable]: `colors.${ bg }`,
[$fg.variable]: `colors.${ fg }`, [$fg.variable]: `colors.${ fg }`,
[$arrowBg.variable]: $bg.reference, [$arrowBg.variable]: $bg.reference,
maxWidth: props.maxWidth || props.maxW || 'unset', maxWidth: props.maxWidth || props.maxW || 'calc(100vw - 8px)',
marginX: '4px',
}; };
}); });
......
...@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia ...@@ -26,8 +26,14 @@ export type MarketplaceAppOverview = MarketplaceAppPreview & MarketplaceAppSocia
site?: string; site?: string;
} }
export type AppRating = {
recordId: string;
value: number | undefined;
}
export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & { export type MarketplaceAppWithSecurityReport = MarketplaceAppOverview & {
securityReport?: MarketplaceAppSecurityReport; securityReport?: MarketplaceAppSecurityReport;
rating?: AppRating;
} }
export enum MarketplaceCategory { export enum MarketplaceCategory {
......
...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) ...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.waitForFunction(() => { await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1'; return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
}); });
await page.mouse.move(100, 100);
await page.mouse.move(240, 100); await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -3,11 +3,11 @@ import React, { useMemo } from 'react'; ...@@ -3,11 +3,11 @@ import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves'; import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
isPlaceholderData: boolean; isPlaceholderData: boolean;
...@@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => { ...@@ -40,9 +40,12 @@ const AddressAccountHistoryListItem = (props: Props) => {
Action Action
</Text> </Text>
</Flex> </Flex>
<Text color="text_secondary" fontSize="sm" fontWeight={ 500 }> <TimeAgoWithTooltip
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
</Text> color="text_secondary"
borderRadius="sm"
fontWeight={ 500 }
/>
</Flex> </Flex>
</Skeleton> </Skeleton>
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }> <Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
......
import { Td, Tr, Skeleton, Text, Box } from '@chakra-ui/react'; import { Td, Tr, Skeleton, Box } from '@chakra-ui/react';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves'; import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo'; import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
isPlaceholderData: boolean; isPlaceholderData: boolean;
...@@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => { ...@@ -25,11 +25,13 @@ const AddressAccountHistoryTableItem = (props: Props) => {
return ( return (
<Tr> <Tr>
<Td px={ 3 } py="18px" fontSize="sm" > <Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" flexShrink={ 0 } isLoaded={ !props.isPlaceholderData }> <TimeAgoWithTooltip
<Text as="span" color="text_secondary"> timestamp={ (props.tx.rawTransactionData.timestamp * 1000).toString() }
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } isLoading={ props.isPlaceholderData }
</Text> color="text_secondary"
</Skeleton> borderRadius="sm"
flexShrink={ 0 }
/>
</Td> </Td>
<Td px={ 3 } py="18px" fontSize="sm" > <Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }> <Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
......
...@@ -6,11 +6,11 @@ import type { Block } from 'types/api/block'; ...@@ -6,11 +6,11 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & { type Props = Block & {
page: number; page: number;
...@@ -18,7 +18,6 @@ type Props = Block & { ...@@ -18,7 +18,6 @@ type Props = Block & {
}; };
const AddressBlocksValidatedListItem = (props: Props) => { const AddressBlocksValidatedListItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props); const totalReward = getBlockTotalReward(props);
return ( return (
...@@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => { ...@@ -30,9 +29,13 @@ const AddressBlocksValidatedListItem = (props: Props) => {
noIcon noIcon
fontWeight={ 700 } fontWeight={ 700 }
/> />
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Flex> </Flex>
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Txn</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Txn</Skeleton>
...@@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => { ...@@ -43,13 +46,11 @@ const AddressBlocksValidatedListItem = (props: Props) => {
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Gas used</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Gas used</Skeleton>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ BigNumber(props.gas_used || 0).toFormat() }</Skeleton> <Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ BigNumber(props.gas_used || 0).toFormat() }</Skeleton>
{ props.gas_used && props.gas_used !== '0' && ( <BlockGasUsed
<Utilization gasUsed={ props.gas_used }
colorScheme="gray" gasLimit={ props.gas_limit }
value={ BigNumber(props.gas_used || 0).dividedBy(BigNumber(props.gas_limit)).toNumber() } isLoading={ props.isLoading }
isLoading={ props.isLoading } />
/>
) }
</Flex> </Flex>
{ !config.UI.views.block.hiddenFields?.total_reward && ( { !config.UI.views.block.hiddenFields?.total_reward && (
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
......
...@@ -6,9 +6,9 @@ import type { Block } from 'types/api/block'; ...@@ -6,9 +6,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import Utilization from 'ui/shared/Utilization/Utilization'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = Block & { type Props = Block & {
page: number; page: number;
...@@ -16,7 +16,6 @@ type Props = Block & { ...@@ -16,7 +16,6 @@ type Props = Block & {
}; };
const AddressBlocksValidatedTableItem = (props: Props) => { const AddressBlocksValidatedTableItem = (props: Props) => {
const timeAgo = useTimeAgoIncrement(props.timestamp, props.page === 1);
const totalReward = getBlockTotalReward(props); const totalReward = getBlockTotalReward(props);
return ( return (
...@@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => { ...@@ -32,9 +31,13 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
/> />
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } display="inline-block" fontWeight="500"> <Skeleton isLoaded={ !props.isLoading } display="inline-block" fontWeight="500">
...@@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => { ...@@ -46,13 +49,11 @@ const AddressBlocksValidatedTableItem = (props: Props) => {
<Skeleton isLoaded={ !props.isLoading } flexBasis="80px"> <Skeleton isLoaded={ !props.isLoading } flexBasis="80px">
{ BigNumber(props.gas_used || 0).toFormat() } { BigNumber(props.gas_used || 0).toFormat() }
</Skeleton> </Skeleton>
{ props.gas_used && props.gas_used !== '0' && ( <BlockGasUsed
<Utilization gasUsed={ props.gas_used }
colorScheme="gray" gasLimit={ props.gas_limit }
value={ BigNumber(props.gas_used).dividedBy(BigNumber(props.gas_limit)).toNumber() } isLoading={ props.isLoading }
isLoading={ props.isLoading } />
/>
) }
</Flex> </Flex>
</Td> </Td>
{ !config.UI.views.block.hiddenFields?.total_reward && ( { !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -5,11 +5,11 @@ import React from 'react'; ...@@ -5,11 +5,11 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts'; import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & { type Props = AddressCoinBalanceHistoryItem & {
page: number; page: number;
...@@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & { ...@@ -19,7 +19,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceListItem = (props: Props) => { const AddressCoinBalanceListItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI); const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO); const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return ( return (
<ListItemMobile rowGap={ 2 } isAnimated> <ListItemMobile rowGap={ 2 } isAnimated>
...@@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => { ...@@ -61,7 +60,12 @@ const AddressCoinBalanceListItem = (props: Props) => {
) } ) }
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Age</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Age</Skeleton>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ props.block_timestamp }
enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
/>
</Flex> </Flex>
</ListItemMobile> </ListItemMobile>
); );
......
...@@ -5,9 +5,9 @@ import React from 'react'; ...@@ -5,9 +5,9 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import { WEI, ZERO } from 'lib/consts'; import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = AddressCoinBalanceHistoryItem & { type Props = AddressCoinBalanceHistoryItem & {
page: number; page: number;
...@@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & { ...@@ -17,7 +17,6 @@ type Props = AddressCoinBalanceHistoryItem & {
const AddressCoinBalanceTableItem = (props: Props) => { const AddressCoinBalanceTableItem = (props: Props) => {
const deltaBn = BigNumber(props.delta).div(WEI); const deltaBn = BigNumber(props.delta).div(WEI);
const isPositiveDelta = deltaBn.gte(ZERO); const isPositiveDelta = deltaBn.gte(ZERO);
const timeAgo = useTimeAgoIncrement(props.block_timestamp, props.page === 1);
return ( return (
<Tr> <Tr>
...@@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => { ...@@ -43,9 +42,13 @@ const AddressCoinBalanceTableItem = (props: Props) => {
) } ) }
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ props.block_timestamp }
</Skeleton> enableIncrement={ props.page === 1 }
isLoading={ props.isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td isNumeric pr={ 1 }> <Td isNumeric pr={ 1 }>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block"> <Skeleton isLoaded={ !props.isLoading } color="text_secondary" display="inline-block">
......
...@@ -5,7 +5,6 @@ import React from 'react'; ...@@ -5,7 +5,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity'; ...@@ -13,6 +12,7 @@ import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }; type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean };
...@@ -47,9 +47,13 @@ const TxInternalsListItem = ({ ...@@ -47,9 +47,13 @@ const TxInternalsListItem = ({
fontWeight={ 700 } fontWeight={ 700 }
truncation="constant_long" truncation="constant_long"
/> />
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> <TimeAgoWithTooltip
<span>{ dayjs(timestamp).fromNow() }</span> timestamp={ timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
<HStack spacing={ 1 }> <HStack spacing={ 1 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Block</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Block</Skeleton>
......
...@@ -5,12 +5,12 @@ import React from 'react'; ...@@ -5,12 +5,12 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils'; import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean } type Props = InternalTransaction & { currentAddress: string; isLoading?: boolean }
...@@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({ ...@@ -32,8 +32,6 @@ const AddressIntTxsTableItem = ({
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title; const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
const toData = to ? to : createdContract; const toData = to ? to : createdContract;
const timeAgo = useTimeAgoIncrement(timestamp, true);
return ( return (
<Tr alignItems="top"> <Tr alignItems="top">
<Td verticalAlign="middle"> <Td verticalAlign="middle">
...@@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({ ...@@ -45,11 +43,14 @@ const AddressIntTxsTableItem = ({
noIcon noIcon
truncation="constant_long" truncation="constant_long"
/> />
{ timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm"> timestamp={ timestamp }
<span>{ timeAgo }</span> enableIncrement
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
/>
</Flex> </Flex>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
......
import { Grid, GridItem, Text, Link, Box, Tooltip, useColorModeValue, Skeleton } from '@chakra-ui/react'; import { Grid, GridItem, Text, Link, Box, Tooltip, Skeleton } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
...@@ -18,6 +18,7 @@ import { space } from 'lib/html-entities'; ...@@ -18,6 +18,7 @@ import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
...@@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -26,14 +27,12 @@ import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import PrevNext from 'ui/shared/PrevNext'; import PrevNext from 'ui/shared/PrevNext';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
import StatusTag from 'ui/shared/statusTag/StatusTag'; import StatusTag from 'ui/shared/statusTag/StatusTag';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo'; import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
...@@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => { ...@@ -52,8 +51,6 @@ const BlockDetails = ({ query }: Props) => {
const router = useRouter(); const router = useRouter();
const heightOrHash = getQueryParamString(router.query.height_or_hash); const heightOrHash = getQueryParamString(router.query.height_or_hash);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isPlaceholderData } = query; const { data, isPlaceholderData } = query;
const handleCutClick = React.useCallback(() => { const handleCutClick = React.useCallback(() => {
...@@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => { ...@@ -412,18 +409,13 @@ const BlockDetails = ({ query }: Props) => {
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas_used || 0).toFormat() } { BigNumber(data.gas_used || 0).toFormat() }
</Skeleton> </Skeleton>
<Utilization <BlockGasUsed
ml={ 4 } gasUsed={ data.gas_used }
colorScheme="gray" gasLimit={ data.gas_limit }
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
ml={ 4 }
gasTarget={ data.gas_target_percentage }
/> />
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isPlaceholderData }/>
</>
) }
</DetailsInfoItem.Value> </DetailsInfoItem.Value>
<DetailsInfoItem.Label <DetailsInfoItem.Label
......
import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
interface Props {
ts: string;
isEnabled?: boolean;
isLoading?: boolean;
className?: string;
}
const BlockTimestamp = ({ ts, isEnabled, isLoading, className }: Props) => {
const timeAgo = useTimeAgoIncrement(ts, isEnabled);
return (
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight={ 400 } className={ className } display="inline-block">
<span>{ timeAgo }</span>
</Skeleton>
);
};
export default React.memo(chakra(BlockTimestamp));
import { Flex, Skeleton, Text, Box, useColorModeValue } from '@chakra-ui/react'; import { Flex, Skeleton, Text, Box } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import React from 'react'; import React from 'react';
...@@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward'; ...@@ -12,14 +12,13 @@ import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import BlockTimestamp from 'ui/blocks/BlockTimestamp'; import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import TextSeparator from 'ui/shared/TextSeparator'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
interface Props { interface Props {
...@@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -35,8 +34,6 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0); const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0); const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
return ( return (
<ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated> <ListItemMobile rowGap={ 3 } key={ String(data.height) } isAnimated>
<Flex justifyContent="space-between" w="100%"> <Flex justifyContent="space-between" w="100%">
...@@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -49,7 +46,14 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
fontWeight={ 600 } fontWeight={ 600 }
/> />
</Flex> </Flex>
<BlockTimestamp ts={ data.timestamp } isEnabled={ enableTimeIncrement } isLoading={ isLoading }/> <TimeAgoWithTooltip
timestamp={ data.timestamp }
enableIncrement={ enableTimeIncrement }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
/>
</Flex> </Flex>
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Size</Text> <Text fontWeight={ 500 }>Size</Text>
...@@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -85,13 +89,12 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary" mr={ 4 }> <Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary" mr={ 4 }>
<span>{ BigNumber(data.gas_used || 0).toFormat() }</span> <span>{ BigNumber(data.gas_used || 0).toFormat() }</span>
</Skeleton> </Skeleton>
<Utilization colorScheme="gray" value={ BigNumber(data.gas_used || 0).div(BigNumber(data.gas_limit)).toNumber() } isLoading={ isLoading }/> <BlockGasUsed
{ data.gas_target_percentage && ( gasUsed={ data.gas_used }
<> gasLimit={ data.gas_limit }
<TextSeparator color={ separatorColor } mx={ 1 }/> isLoading={ isLoading }
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/> gasTarget={ data.gas_target_percentage }
</> />
) }
</Flex> </Flex>
</Box> </Box>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -10,13 +10,12 @@ import { route } from 'nextjs-routes'; ...@@ -10,13 +10,12 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import BlockTimestamp from 'ui/blocks/BlockTimestamp'; import BlockGasUsed from 'ui/shared/block/BlockGasUsed';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator'; import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
interface Props { interface Props {
...@@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -32,7 +31,6 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
const burntFees = BigNumber(data.burnt_fees || 0); const burntFees = BigNumber(data.burnt_fees || 0);
const txFees = BigNumber(data.tx_fees || 0); const txFees = BigNumber(data.tx_fees || 0);
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const burntFeesIconColor = useColorModeValue('gray.500', 'inherit'); const burntFeesIconColor = useColorModeValue('gray.500', 'inherit');
return ( return (
...@@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -58,7 +56,14 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
/> />
</Tooltip> </Tooltip>
</Flex> </Flex>
<BlockTimestamp ts={ data.timestamp } isEnabled={ enableTimeIncrement } isLoading={ isLoading }/> <TimeAgoWithTooltip
timestamp={ data.timestamp }
enableIncrement={ enableTimeIncrement }
isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
/>
</Td> </Td>
<Td fontSize="sm"> <Td fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block"> <Skeleton isLoaded={ !isLoading } display="inline-block">
...@@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -89,21 +94,12 @@ const BlocksTableItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
<Td fontSize="sm"> <Td fontSize="sm">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<Flex mt={ 2 }> <Flex mt={ 2 }>
<Tooltip label={ isLoading ? undefined : 'Gas Used %' }> <BlockGasUsed
<Box> gasUsed={ data.gas_used }
<Utilization gasLimit={ data.gas_limit }
colorScheme="gray" isLoading={ isLoading }
value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } gasTarget={ data.gas_target_percentage }
isLoading={ isLoading } />
/>
</Box>
</Tooltip>
{ data.gas_target_percentage && (
<>
<TextSeparator color={ separatorColor } mx={ 1 }/>
<GasUsedToTargetRatio value={ data.gas_target_percentage } isLoading={ isLoading }/>
</>
) }
</Flex> </Flex>
</Td> </Td>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
......
...@@ -5,20 +5,18 @@ import React from 'react'; ...@@ -5,20 +5,18 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => { ...@@ -50,7 +48,11 @@ const OptimisticDepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L1 txn hash</ListItemMobileGrid.Label>
......
...@@ -5,18 +5,17 @@ import React from 'react'; ...@@ -5,18 +5,17 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1'; import AddressEntityL1 from 'ui/shared/entities/address/AddressEntityL1';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean }; type Props = { item: OptimisticL2DepositsItem; isLoading?: boolean };
const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
...@@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -45,7 +44,12 @@ const OptimisticDepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_block_timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<TxEntityL1 <TxEntityL1
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium'; import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean }; type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsListItem = ({ item, isLoading }: Props) => { const DepositsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
} }
...@@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -70,7 +67,11 @@ const DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</ListItemMobileGrid.Container> </ListItemMobileGrid.Container>
......
import { Td, Tr, Skeleton } from '@chakra-ui/react'; import { Td, Tr } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ShibariumDepositsItem } from 'types/api/shibarium'; import type { ShibariumDepositsItem } from 'types/api/shibarium';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam'; import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrParam';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
type Props = { item: ShibariumDepositsItem; isLoading?: boolean }; type Props = { item: ShibariumDepositsItem; isLoading?: boolean };
const DepositsTableItem = ({ item, isLoading }: Props) => { const DepositsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
if (!(feature.isEnabled && feature.type === 'shibarium')) { if (!(feature.isEnabled && feature.type === 'shibarium')) {
return null; return null;
...@@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -59,7 +58,12 @@ const DepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -5,11 +5,11 @@ import React from 'react'; ...@@ -5,11 +5,11 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -20,8 +20,6 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<ListItemMobileGrid.Container> <ListItemMobileGrid.Container>
...@@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => { ...@@ -56,7 +54,11 @@ const ZkEvmL2DepositsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L2 txn hash</ListItemMobileGrid.Label>
......
...@@ -5,10 +5,10 @@ import React from 'react'; ...@@ -5,10 +5,10 @@ import React from 'react';
import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2'; import type { ZkEvmL2DepositsItem } from 'types/api/zkEvmL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -19,8 +19,6 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.timestamp).fromNow();
return ( return (
<Tr> <Tr>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
...@@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => { ...@@ -49,9 +47,11 @@ const ZkEvmL2DepositsTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ item.l2_transaction_hash ? ( { item.l2_transaction_hash ? (
......
...@@ -4,11 +4,11 @@ import React from 'react'; ...@@ -4,11 +4,11 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
...@@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { ...@@ -53,7 +53,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.created_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.created_at }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
...@@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => { ...@@ -64,7 +68,11 @@ const OptimisticL2DisputeGamesListItem = ({ item, isLoading }: Props) => {
{ item.resolved_at && ( { item.resolved_at && (
<> <>
<ListItemMobileGrid.Label isLoading={ isLoading }>Resolution age</ListItemMobileGrid.Label><ListItemMobileGrid.Value> <ListItemMobileGrid.Label isLoading={ isLoading }>Resolution age</ListItemMobileGrid.Label><ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.resolved_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.resolved_at }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
......
...@@ -4,10 +4,10 @@ import React from 'react'; ...@@ -4,10 +4,10 @@ import React from 'react';
import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2'; import type { OptimisticL2DisputeGamesItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const faultProofSystemFeature = config.features.faultProofSystem; const faultProofSystemFeature = config.features.faultProofSystem;
...@@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => { ...@@ -44,15 +44,22 @@ const OptimisticL2DisputeGamesTableItem = ({ item, isLoading }: Props) => {
/> />
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ dayjs(item.created_at).fromNow() }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.created_at }
isLoading={ isLoading }
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.status }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ item.status }</Skeleton>
</Td> </Td>
<Td> <Td>
<Skeleton isLoaded={ !isLoading } display="inline-block"> <TimeAgoWithTooltip
{ item.resolved_at ? dayjs(item.resolved_at).fromNow() : 'N/A' } timestamp={ item.resolved_at }
</Skeleton> fallbackText="N/A"
isLoading={ isLoading }
display="inline-block"
/>
</Td> </Td>
</Tr> </Tr>
); );
......
...@@ -12,9 +12,9 @@ import type { Block } from 'types/api/block'; ...@@ -12,9 +12,9 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
block: Block; block: Block;
...@@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => { ...@@ -46,10 +46,13 @@ const LatestBlocksItem = ({ block, isLoading }: Props) => {
fontWeight={ 500 } fontWeight={ 500 }
mr="auto" mr="auto"
/> />
<BlockTimestamp <TimeAgoWithTooltip
ts={ block.timestamp } timestamp={ block.timestamp }
isEnabled={ !isLoading } enableIncrement={ !isLoading }
isLoading={ isLoading } isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm" fontSize="sm"
flexShrink={ 0 } flexShrink={ 0 }
ml={ 2 } ml={ 2 }
......
...@@ -9,11 +9,11 @@ import React from 'react'; ...@@ -9,11 +9,11 @@ import React from 'react';
import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2'; import type { OptimisticL2DepositsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const feature = config.features.rollup; const feature = config.features.rollup;
...@@ -23,7 +23,6 @@ type Props = { ...@@ -23,7 +23,6 @@ type Props = {
} }
const LatestDepositsItem = ({ item, isLoading }: Props) => { const LatestDepositsItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_block_timestamp).fromNow();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (!feature.isEnabled || feature.type !== 'optimistic') { if (!feature.isEnabled || feature.type !== 'optimistic') {
...@@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { ...@@ -66,9 +65,11 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
<> <>
<Flex justifyContent="space-between" alignItems="center" mb={ 1 }> <Flex justifyContent="space-between" alignItems="center" mb={ 1 }>
{ l1BlockLink } { l1BlockLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_block_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Flex> </Flex>
<Grid gridTemplateColumns="56px auto"> <Grid gridTemplateColumns="56px auto">
<Skeleton isLoaded={ !isLoading } my="5px" w="fit-content"> <Skeleton isLoaded={ !isLoading } my="5px" w="fit-content">
...@@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => { ...@@ -91,9 +92,14 @@ const LatestDepositsItem = ({ item, isLoading }: Props) => {
L1 txn L1 txn
</Skeleton> </Skeleton>
{ l1TxLink } { l1TxLink }
<Skeleton isLoaded={ !isLoading } color="text_secondary" w="fit-content" h="fit-content" my="2px"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_block_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
w="fit-content"
h="fit-content"
my="2px"
/>
<Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px"> <Skeleton isLoaded={ !isLoading } w="fit-content" h="fit-content" my="2px">
L2 txn L2 txn
</Skeleton> </Skeleton>
......
...@@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -12,11 +12,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -29,7 +29,6 @@ type Props = { ...@@ -29,7 +29,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => { const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
const columnNum = config.UI.views.tx.hiddenFields?.value && config.UI.views.tx.hiddenFields?.tx_fee ? 2 : 3; const columnNum = config.UI.views.tx.hiddenFields?.value && config.UI.views.tx.hiddenFields?.tx_fee ? 2 : 3;
return ( return (
...@@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -65,18 +64,16 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
hash={ tx.hash } hash={ tx.hash }
fontWeight="700" fontWeight="700"
/> />
{ tx.timestamp && ( <TimeAgoWithTooltip
<Skeleton timestamp={ tx.timestamp }
isLoaded={ !isLoading } enableIncrement
color="text_secondary" isLoading={ isLoading }
fontWeight="400" color="text_secondary"
fontSize="sm" fontWeight="400"
flexShrink={ 0 } fontSize="sm"
ml={ 2 } flexShrink={ 0 }
> ml={ 2 }
<span>{ timeAgo }</span> />
</Skeleton>
) }
</Flex> </Flex>
</Box> </Box>
</Flex> </Flex>
......
...@@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -11,11 +11,11 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import TxFee from 'ui/shared/tx/TxFee'; import TxFee from 'ui/shared/tx/TxFee';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
...@@ -28,7 +28,6 @@ type Props = { ...@@ -28,7 +28,6 @@ type Props = {
const LatestTxsItem = ({ tx, isLoading }: Props) => { const LatestTxsItem = ({ tx, isLoading }: Props) => {
const dataTo = tx.to ? tx.to : tx.created_contract; const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
return ( return (
<Box <Box
...@@ -60,11 +59,15 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -60,11 +59,15 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
fontWeight="700" fontWeight="700"
truncation="constant_long" truncation="constant_long"
/> />
{ tx.timestamp && ( <TimeAgoWithTooltip
<Skeleton isLoaded={ !isLoading } color="text_secondary" fontWeight="400" fontSize="sm" ml={ 3 }> timestamp={ tx.timestamp }
<span>{ timeAgo }</span> enableIncrement
</Skeleton> isLoading={ isLoading }
) } color="text_secondary"
fontWeight="400"
fontSize="sm"
ml={ 3 }
/>
</Flex> </Flex>
<AddressFromTo <AddressFromTo
from={ tx.from } from={ tx.from }
......
...@@ -10,10 +10,10 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2'; ...@@ -10,10 +10,10 @@ import type { ZkEvmL2TxnBatchesItem } from 'types/api/zkEvmL2';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import LinkInternal from 'ui/shared/links/LinkInternal'; import LinkInternal from 'ui/shared/links/LinkInternal';
import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus'; import ZkEvmL2TxnBatchStatus from 'ui/shared/statusTag/ZkEvmL2TxnBatchStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
type Props = { type Props = {
batch: ZkEvmL2TxnBatchesItem; batch: ZkEvmL2TxnBatchesItem;
...@@ -44,10 +44,13 @@ const LatestZkevmL2BatchItem = ({ batch, isLoading }: Props) => { ...@@ -44,10 +44,13 @@ const LatestZkevmL2BatchItem = ({ batch, isLoading }: Props) => {
fontWeight={ 500 } fontWeight={ 500 }
mr="auto" mr="auto"
/> />
<BlockTimestamp <TimeAgoWithTooltip
ts={ batch.timestamp } timestamp={ batch.timestamp }
isEnabled={ !isLoading } enableIncrement={ !isLoading }
isLoading={ isLoading } isLoading={ isLoading }
color="text_secondary"
fontWeight={ 400 }
display="inline-block"
fontSize="sm" fontSize="sm"
flexShrink={ 0 } flexShrink={ 0 }
ml={ 2 } ml={ 2 }
......
...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render }) ...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
test('no data', async({ mockApiResponse, mockAssetResponse, render }) => { test('no data', async({ mockApiResponse, mockAssetResponse, render }) => {
await mockApiResponse('stats', statsMock.noChartData); await mockApiResponse('stats', statsMock.noChartData);
await mockApiResponse('stats_charts_txs', dailyTxsMock.noData); await mockApiResponse('stats_charts_txs', dailyTxsMock.noData);
await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(statsMock.noChartData.coin_image as string, './playwright/mocks/image_s.jpg');
const component = await render(<ChainIndicators/>); const component = await render(<ChainIndicators/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
...@@ -72,7 +72,7 @@ const AppSecurityReport = ({ ...@@ -72,7 +72,7 @@ const AppSecurityReport = ({
className={ className } className={ className }
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '328px' }}> <PopoverContent w={{ base: 'calc(100vw - 24px)', lg: '328px' }} mx={{ base: 3, lg: 0 }}>
<PopoverBody px="26px" py="20px" fontSize="sm"> <PopoverBody px="26px" py="20px" fontSize="sm">
<Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text> <Text fontWeight="500" fontSize="xs" mb={ 2 } variant="secondary">Smart contracts info</Text>
<Flex alignItems="center" justifyContent="space-between" py={ 1.5 }> <Flex alignItems="center" justifyContent="space-between" py={ 1.5 }>
......
...@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace'; ...@@ -7,8 +7,8 @@ import type { MarketplaceAppPreview } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
import FeaturedAppMobile from './FeaturedAppMobile'; import FeaturedAppMobile from './FeaturedAppMobile';
...@@ -136,10 +136,7 @@ const FeaturedApp = ({ ...@@ -136,10 +136,7 @@ const FeaturedApp = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/> />
) } ) }
</Flex> </Flex>
......
...@@ -4,8 +4,7 @@ import React from 'react'; ...@@ -4,8 +4,7 @@ import React from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import IconSvg from 'ui/shared/IconSvg'; import FavoriteIcon from '../FavoriteIcon';
import MarketplaceAppCardLink from '../MarketplaceAppCardLink'; import MarketplaceAppCardLink from '../MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from '../MarketplaceAppIntegrationIcon';
...@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({ ...@@ -144,10 +143,7 @@ const FeaturedAppMobile = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ onFavoriteClick } onClick={ onFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/>
}
/> />
) } ) }
</Flex> </Flex>
......
...@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => ( ...@@ -21,7 +21,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? ( (selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<> <>
You don{ apos }t have any favorite apps.<br/> You don{ apos }t have any favorite apps.<br/>
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites. Click on the <IconSvg name="heart_outline" boxSize={ 5 } mb={ -1 } color="gray.400"/> icon on the app{ apos }s card to add it to Favorites.
</> </>
) : ( ) : (
<> <>
......
import { useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
isFavorite: boolean;
color?: string;
}
const FavoriteIcon = ({ isFavorite, color }: Props) => {
const heartFilledColor = useColorModeValue('blue.700', 'gray.400');
const defaultColor = isFavorite ? heartFilledColor : 'gray.400';
return (
<IconSvg
name={ isFavorite ? 'heart_filled' : 'heart_outline' }
color={ color || defaultColor }
boxSize={ 5 }
/>
);
};
export default FavoriteIcon;
import { Box, IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react'; import { IconButton, Image, Link, LinkBox, Skeleton, useColorModeValue, chakra, Flex } from '@chakra-ui/react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon'; import MarketplaceAppIntegrationIcon from './MarketplaceAppIntegrationIcon';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
interface Props extends MarketplaceAppWithSecurityReport { interface Props extends MarketplaceAppWithSecurityReport {
onInfoClick: (id: string) => void; onInfoClick: (id: string) => void;
...@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport { ...@@ -19,6 +21,11 @@ interface Props extends MarketplaceAppWithSecurityReport {
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
className?: string; className?: string;
showContractList: (id: string, type: ContractListTypes) => void; showContractList: (id: string, type: ContractListTypes) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
} }
const MarketplaceAppCard = ({ const MarketplaceAppCard = ({
...@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({ ...@@ -39,6 +46,12 @@ const MarketplaceAppCard = ({
securityReport, securityReport,
className, className,
showContractList, showContractList,
rating,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => { }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const categoriesLabel = categories.join(', '); const categoriesLabel = categories.join(', ');
...@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({ ...@@ -141,8 +154,7 @@ const MarketplaceAppCard = ({
</Skeleton> </Skeleton>
{ !isLoading && ( { !isLoading && (
<Box <Flex
display="flex"
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
marginTop="auto" marginTop="auto"
...@@ -156,20 +168,29 @@ const MarketplaceAppCard = ({ ...@@ -156,20 +168,29 @@ const MarketplaceAppCard = ({
> >
More info More info
</Link> </Link>
<IconButton <Flex alignItems="center" gap={ 3 }>
aria-label="Mark as favorite" <Rating
title="Mark as favorite" appId={ id }
variant="ghost" rating={ rating }
colorScheme="gray" userRating={ userRating }
w={{ base: 6, md: '30px' }} rate={ rateApp }
h={{ base: 6, md: '30px' }} isSending={ isRatingSending }
onClick={ handleFavoriteClick } isLoading={ isRatingLoading }
icon={ isFavorite ? canRate={ canRate }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> : source="Discovery"
<IconSvg name="star_outline" w={ 5 } h={ 5 } color="gray.400"/> />
} <IconButton
/> aria-label="Mark as favorite"
</Box> title="Mark as favorite"
variant="ghost"
colorScheme="gray"
w={{ base: 6, md: '30px' }}
h={{ base: 6, md: '30px' }}
onClick={ handleFavoriteClick }
icon={ <FavoriteIcon isFavorite={ isFavorite }/> }
/>
</Flex>
</Flex>
) } ) }
{ securityReport && ( { securityReport && (
......
...@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => { ...@@ -36,7 +36,7 @@ const MarketplaceAppIntegrationIcon = ({ external, internalWallet }: Props) => {
textAlign="center" textAlign="center"
padding={ 2 } padding={ 2 }
openDelay={ 300 } openDelay={ 300 }
maxW={ 400 } maxW={{ base: 'calc(100vw - 8px)', lg: '400px' }}
> >
<IconSvg <IconSvg
name={ icon } name={ icon }
......
...@@ -15,13 +15,27 @@ const props = { ...@@ -15,13 +15,27 @@ const props = {
data: { data: {
...appsMock[0], ...appsMock[0],
securityReport: securityReportsMock[0].chainsData['1'], securityReport: securityReportsMock[0].chainsData['1'],
rating: {
recordId: 'test',
value: 4.3,
},
} as MarketplaceAppWithSecurityReport, } as MarketplaceAppWithSecurityReport,
isFavorite: false, isFavorite: false,
userRating: undefined,
rateApp: () => {},
isRatingSending: false,
isRatingLoading: false,
canRate: undefined,
}; };
const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse }) => { const testFn: Parameters<typeof test>[1] = async({ render, page, mockAssetResponse, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]);
await mockAssetResponse(appsMock[0].logo, './playwright/mocks/image_s.jpg'); await mockAssetResponse(appsMock[0].logo, './playwright/mocks/image_s.jpg');
await render(<MarketplaceAppModal { ...props }/>); await render(<MarketplaceAppModal { ...props }/>);
await page.getByText('Launch app').focus();
await expect(page).toHaveScreenshot(); await expect(page).toHaveScreenshot();
}; };
......
...@@ -4,9 +4,10 @@ import { ...@@ -4,9 +4,10 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace'; import { ContractListTypes } from 'types/client/marketplace';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
...@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg'; ...@@ -14,7 +15,13 @@ import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import FavoriteIcon from './FavoriteIcon';
import MarketplaceAppModalLink from './MarketplaceAppModalLink'; import MarketplaceAppModalLink from './MarketplaceAppModalLink';
import Rating from './Rating/Rating';
import type { RateFunction } from './Rating/useRatings';
const feature = config.features.marketplace;
const isRatingEnabled = feature.isEnabled && feature.rating;
type Props = { type Props = {
onClose: () => void; onClose: () => void;
...@@ -22,6 +29,11 @@ type Props = { ...@@ -22,6 +29,11 @@ type Props = {
onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void; onFavoriteClick: (id: string, isFavorite: boolean, source: 'App modal') => void;
data: MarketplaceAppWithSecurityReport; data: MarketplaceAppWithSecurityReport;
showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void; showContractList: (id: string, type: ContractListTypes, hasPreviousStep: boolean) => void;
userRating?: AppRating;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
} }
const MarketplaceAppModal = ({ const MarketplaceAppModal = ({
...@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({ ...@@ -30,9 +42,12 @@ const MarketplaceAppModal = ({
onFavoriteClick, onFavoriteClick,
data, data,
showContractList: showContractListProp, showContractList: showContractListProp,
userRating,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}: Props) => { }: Props) => {
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const { const {
id, id,
title, title,
...@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({ ...@@ -49,6 +64,7 @@ const MarketplaceAppModal = ({
logoDarkMode, logoDarkMode,
categories, categories,
securityReport, securityReport,
rating,
} = data; } = data;
const socialLinks = [ const socialLinks = [
...@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({ ...@@ -119,7 +135,7 @@ const MarketplaceAppModal = ({
w={{ base: '72px', md: '144px' }} w={{ base: '72px', md: '144px' }}
h={{ base: '72px', md: '144px' }} h={{ base: '72px', md: '144px' }}
marginRight={{ base: 6, md: 8 }} marginRight={{ base: 6, md: 8 }}
gridRow={{ base: '1 / 3', md: '1 / 4' }} gridRow={{ base: '1 / 3', md: '1 / 5' }}
> >
<Image <Image
src={ logoUrl } src={ logoUrl }
...@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({ ...@@ -131,10 +147,10 @@ const MarketplaceAppModal = ({
<Heading <Heading
as="h2" as="h2"
gridColumn={ 2 } gridColumn={ 2 }
fontSize={{ base: '2xl', md: '3xl' }} fontSize={{ base: '2xl', md: '32px' }}
fontWeight="medium" fontWeight="medium"
lineHeight={ 1 } lineHeight={{ md: 10 }}
color="blue.600" mb={{ md: 2 }}
> >
{ title } { title }
</Heading> </Heading>
...@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({ ...@@ -142,16 +158,37 @@ const MarketplaceAppModal = ({
<Text <Text
variant="secondary" variant="secondary"
gridColumn={ 2 } gridColumn={ 2 }
fontSize="sm" fontSize={{ base: 'sm', md: 'md' }}
fontWeight="normal" fontWeight="normal"
lineHeight={ 1 } lineHeight={{ md: 6 }}
> >
By{ nbsp }{ author } By{ nbsp }{ author }
</Text> </Text>
{ isRatingEnabled && (
<Box
gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 3 }}
py={{ base: 0, md: 1.5 }}
width="fit-content"
>
<Rating
appId={ id }
rating={ rating }
userRating={ userRating }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
fullView
canRate={ canRate }
source="App modal"
/>
</Box>
) }
<Box <Box
gridColumn={{ base: '1 / 3', md: 2 }} gridColumn={{ base: '1 / 3', md: 2 }}
marginTop={{ base: 6, md: 0 }} marginTop={{ base: 6, md: 3 }}
> >
<Flex flexWrap="wrap" gap={ 6 }> <Flex flexWrap="wrap" gap={ 6 }>
<Flex width={{ base: '100%', md: 'auto' }}> <Flex width={{ base: '100%', md: 'auto' }}>
...@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({ ...@@ -170,9 +207,7 @@ const MarketplaceAppModal = ({
w={ 9 } w={ 9 }
h={ 8 } h={ 8 }
onClick={ handleFavoriteClick } onClick={ handleFavoriteClick }
icon={ isFavorite ? icon={ <FavoriteIcon isFavorite={ isFavorite } color={ useColorModeValue('blue.700', 'gray.400') }/> }
<IconSvg name="star_filled" w={ 5 } h={ 5 } color="yellow.400"/> :
<IconSvg name="star_outline" w={ 5 } h={ 5 } color={ starOutlineIconColor }/> }
/> />
</Flex> </Flex>
</Flex> </Flex>
......
...@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop'; ...@@ -18,18 +18,23 @@ import WalletMenuDesktop from 'ui/snippets/walletMenu/WalletMenuDesktop';
import AppSecurityReport from './AppSecurityReport'; import AppSecurityReport from './AppSecurityReport';
import ContractListModal from './ContractListModal'; import ContractListModal from './ContractListModal';
import MarketplaceAppInfo from './MarketplaceAppInfo'; import MarketplaceAppInfo from './MarketplaceAppInfo';
import Rating from './Rating/Rating';
import useRatings from './Rating/useRatings';
type Props = { type Props = {
appId: string;
data: MarketplaceAppOverview | undefined; data: MarketplaceAppOverview | undefined;
isLoading: boolean; isLoading: boolean;
securityReport?: MarketplaceAppSecurityReport; securityReport?: MarketplaceAppSecurityReport;
} }
const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { const MarketplaceAppTopBar = ({ appId, data, isLoading, securityReport }: Props) => {
const [ contractListType, setContractListType ] = React.useState<ContractListTypes>(); const [ contractListType, setContractListType ] = React.useState<ContractListTypes>();
const appProps = useAppContext(); const appProps = useAppContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const goBackUrl = React.useMemo(() => { const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) { if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
return appProps.referrer; return appProps.referrer;
...@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => { ...@@ -82,6 +87,16 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
source="App page" source="App page"
/> />
) } ) }
<Rating
appId={ appId }
rating={ ratings[appId] }
userRating={ userRatings[appId] }
rate={ rateApp }
isSending={ isRatingSending }
isLoading={ isRatingLoading }
canRate={ canRate }
source="App page"
/>
{ !isMobile && ( { !isMobile && (
<Flex flex="1" justifyContent="flex-end"> <Flex flex="1" justifyContent="flex-end">
{ config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> } { config.features.account.isEnabled && <ProfileMenuDesktop boxSize="32px" fallbackIconSize={ 16 }/> }
......
import { Grid } from '@chakra-ui/react'; import { Grid, Box } from '@chakra-ui/react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MouseEvent } from 'react'; import type { MouseEvent } from 'react';
import type { MarketplaceAppWithSecurityReport, ContractListTypes } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport, ContractListTypes, AppRating } from 'types/client/marketplace';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import EmptySearchResult from './EmptySearchResult'; import EmptySearchResult from './EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard'; import MarketplaceAppCard from './MarketplaceAppCard';
import type { RateFunction } from './Rating/useRatings';
type Props = { type Props = {
apps: Array<MarketplaceAppWithSecurityReport>; apps: Array<MarketplaceAppWithSecurityReport>;
...@@ -18,9 +20,19 @@ type Props = { ...@@ -18,9 +20,19 @@ type Props = {
selectedCategoryId?: string; selectedCategoryId?: string;
onAppClick: (event: MouseEvent, id: string) => void; onAppClick: (event: MouseEvent, id: string) => void;
showContractList: (id: string, type: ContractListTypes) => void; showContractList: (id: string, type: ContractListTypes) => void;
userRatings: Record<string, AppRating>;
rateApp: RateFunction;
isRatingSending: boolean;
isRatingLoading: boolean;
canRate: boolean | undefined;
} }
const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId, onAppClick, showContractList }: Props) => { const MarketplaceList = ({
apps, showAppInfo, favoriteApps, onFavoriteClick, isLoading, selectedCategoryId,
onAppClick, showContractList, userRatings, rateApp, isRatingSending, isRatingLoading, canRate,
}: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(apps, !isLoading, 16);
const handleInfoClick = useCallback((id: string) => { const handleInfoClick = useCallback((id: string) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id, Source: 'Discovery view' });
showAppInfo(id); showAppInfo(id);
...@@ -31,37 +43,46 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL ...@@ -31,37 +43,46 @@ const MarketplaceList = ({ apps, showAppInfo, favoriteApps, onFavoriteClick, isL
}, [ onFavoriteClick ]); }, [ onFavoriteClick ]);
return apps.length > 0 ? ( return apps.length > 0 ? (
<Grid <>
templateColumns={{ <Grid
md: 'repeat(auto-fill, minmax(230px, 1fr))', templateColumns={{
lg: 'repeat(auto-fill, minmax(260px, 1fr))', md: 'repeat(auto-fill, minmax(230px, 1fr))',
}} lg: 'repeat(auto-fill, minmax(260px, 1fr))',
autoRows="1fr" }}
gap={{ base: '16px', md: '24px' }} autoRows="1fr"
marginTop={{ base: 0, lg: 3 }} gap={{ base: '16px', md: '24px' }}
> marginTop={{ base: 0, lg: 3 }}
{ apps.map((app, index) => ( >
<MarketplaceAppCard { apps.slice(0, renderedItemsNum).map((app, index) => (
key={ app.id + (isLoading ? index : '') } <MarketplaceAppCard
onInfoClick={ handleInfoClick } key={ app.id + (isLoading ? index : '') }
id={ app.id } onInfoClick={ handleInfoClick }
external={ app.external } id={ app.id }
url={ app.url } external={ app.external }
title={ app.title } url={ app.url }
logo={ app.logo } title={ app.title }
logoDarkMode={ app.logoDarkMode } logo={ app.logo }
shortDescription={ app.shortDescription } logoDarkMode={ app.logoDarkMode }
categories={ app.categories } shortDescription={ app.shortDescription }
isFavorite={ favoriteApps.includes(app.id) } categories={ app.categories }
onFavoriteClick={ handleFavoriteClick } isFavorite={ favoriteApps.includes(app.id) }
isLoading={ isLoading } onFavoriteClick={ handleFavoriteClick }
internalWallet={ app.internalWallet } isLoading={ isLoading }
onAppClick={ onAppClick } internalWallet={ app.internalWallet }
securityReport={ app.securityReport } onAppClick={ onAppClick }
showContractList={ showContractList } securityReport={ app.securityReport }
/> showContractList={ showContractList }
)) } rating={ app.rating }
</Grid> userRating={ userRatings[app.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/>
)) }
</Grid>
<Box ref={ cutRef } h={ 0 }/>
</>
) : ( ) : (
<EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/> <EmptySearchResult selectedCategoryId={ selectedCategoryId } favoriteApps={ favoriteApps }/>
); );
......
import { Text, Flex, Spinner } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import Stars from './Stars';
import type { RateFunction } from './useRatings';
const ratingDescriptions = [ 'Very bad', 'Bad', 'Average', 'Good', 'Excellent' ];
type Props = {
appId: string;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'];
};
const PopoverContent = ({ appId, rating, userRating, rate, isSending, source }: Props) => {
const [ hovered, setHovered ] = React.useState(-1);
const filledIndex = React.useMemo(() => {
if (hovered >= 0) {
return hovered;
}
return userRating?.value ? userRating?.value - 1 : -1;
}, [ userRating, hovered ]);
const handleMouseOverFactory = React.useCallback((index: number) => () => {
setHovered(index);
}, []);
const handleMouseOut = React.useCallback(() => {
setHovered(-1);
}, []);
const handleRateFactory = React.useCallback((index: number) => () => {
rate(appId, rating?.recordId, userRating?.recordId, index + 1, source);
}, [ appId, rating, rate, userRating, source ]);
if (isSending) {
return (
<Flex alignItems="center">
<Spinner size="md"/>
<Text fontSize="md" ml={ 3 }>Sending your feedback</Text>
</Flex>
);
}
return (
<>
<Flex alignItems="center">
{ userRating && (
<IconSvg name="verified" color="green.400" boxSize="30px" mr={ 1 } ml="-5px"/>
) }
<Text fontWeight="500" fontSize="xs" lineHeight="30px" variant="secondary">
{ userRating ? 'App is already rated by you' : 'How was your experience?' }
</Text>
</Flex>
<Flex alignItems="center" h="32px">
<Stars
filledIndex={ filledIndex }
onMouseOverFactory={ handleMouseOverFactory }
onMouseOut={ handleMouseOut }
onClickFactory={ handleRateFactory }
/>
{ (filledIndex >= 0) && (
<Text fontSize="md" ml={ 3 }>
{ ratingDescriptions[filledIndex] }
</Text>
) }
</Flex>
</>
);
};
export default PopoverContent;
import { Text, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Skeleton, useOutsideClick, Box } from '@chakra-ui/react';
import React from 'react';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import Popover from 'ui/shared/chakra/Popover';
import Content from './PopoverContent';
import Stars from './Stars';
import TriggerButton from './TriggerButton';
import type { RateFunction } from './useRatings';
const feature = config.features.marketplace;
const isEnabled = feature.isEnabled && feature.rating;
type Props = {
appId: string;
rating?: AppRating;
userRating?: AppRating;
rate: RateFunction;
isSending?: boolean;
isLoading?: boolean;
fullView?: boolean;
canRate: boolean | undefined;
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'];
};
const Rating = ({
appId, rating, userRating, rate,
isSending, isLoading, fullView, canRate, source,
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
// have to implement this solution because popover loses focus on button click inside it (issue: https://github.com/chakra-ui/chakra-ui/issues/7359)
const popoverRef = React.useRef(null);
useOutsideClick({ ref: popoverRef, handler: onClose });
if (!isEnabled) {
return null;
}
return (
<Skeleton
display="flex"
alignItems="center"
isLoaded={ !isLoading }
w={ (isLoading && !fullView) ? '40px' : 'auto' }
>
{ fullView && (
<>
<Stars filledIndex={ (rating?.value || 0) - 1 }/>
<Text fontSize="md" ml={ 2 }>{ rating?.value }</Text>
</>
) }
<Box ref={ popoverRef }>
<Popover isOpen={ isOpen } placement="bottom" isLazy>
<PopoverTrigger>
<TriggerButton
rating={ rating?.value }
fullView={ fullView }
isActive={ isOpen }
onClick={ onToggle }
canRate={ canRate }
/>
</PopoverTrigger>
<PopoverContent w="250px" mx={ 3 }>
<PopoverBody p={ 4 }>
<Content
appId={ appId }
rating={ rating }
userRating={ userRating }
rate={ rate }
isSending={ isSending }
source={ source }
/>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
</Skeleton>
);
};
export default Rating;
import { Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { MouseEventHandler } from 'react';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
filledIndex: number;
onMouseOverFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
onMouseOut?: () => void;
onClickFactory?: (index: number) => MouseEventHandler<HTMLDivElement>;
};
const Stars = ({ filledIndex, onMouseOverFactory, onMouseOut, onClickFactory }: Props) => {
const disabledStarColor = useColorModeValue('gray.200', 'gray.700');
const outlineStartColor = onMouseOverFactory ? 'gray.400' : disabledStarColor;
return (
<Flex>
{ Array(5).fill(null).map((_, index) => (
<IconSvg
key={ index }
name={ filledIndex >= index ? 'star_filled' : 'star_outline' }
color={ filledIndex >= index ? 'yellow.400' : outlineStartColor }
w={ 6 } // 5 + 1 padding
h={ 5 }
pr={ 1 } // use padding intead of margin so that there are no empty spaces between stars without hover effect
_last={{ w: 5, pr: 0 }}
cursor={ onMouseOverFactory ? 'pointer' : 'default' }
onMouseOver={ onMouseOverFactory?.(index) }
onMouseOut={ onMouseOut }
onClick={ onClickFactory?.(index) }
/>
)) }
</Flex>
);
};
export default Stars;
import { Button, chakra, useColorModeValue, Tooltip, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing';
import IconSvg from 'ui/shared/IconSvg';
type Props = {
rating?: number;
fullView?: boolean;
isActive: boolean;
onClick: () => void;
canRate: boolean | undefined;
};
const getTooltipText = (canRate: boolean | undefined) => {
if (canRate === undefined) {
return <>Please connect your wallet to Blockscout to rate this DApp.<br/>Only wallets with 5+ transactions are eligible</>;
}
if (!canRate) {
return <>Brand new wallets cannot leave ratings.<br/>Please connect a wallet with 5 or more transactions on this chain</>;
}
return <>Ratings come from verified users.<br/>Click here to rate!</>;
};
const TriggerButton = (
{ rating, fullView, isActive, onClick, canRate }: Props,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const textColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
const onFocusCapture = usePreventFocusAfterModalClosing();
// have to implement controlled tooltip on mobile because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
const { isOpen, onToggle, onClose } = useDisclosure();
const isMobile = useIsMobile();
const handleClick = React.useCallback(() => {
if (canRate) {
onClick();
} else if (isMobile) {
onToggle();
}
}, [ canRate, isMobile, onToggle, onClick ]);
return (
<Tooltip
label={ getTooltipText(canRate) }
openDelay={ 100 }
textAlign="center"
closeOnClick={ Boolean(canRate) || isMobile }
isOpen={ isMobile ? isOpen : undefined }
>
<Button
ref={ ref }
size="xs"
variant="outline"
border={ 0 }
p={ 0 }
onClick={ handleClick }
fontSize={ fullView ? 'md' : 'sm' }
fontWeight={ fullView ? '400' : '500' }
lineHeight="21px"
ml={ fullView ? 3 : 0 }
isActive={ isActive }
onFocusCapture={ onFocusCapture }
cursor={ canRate ? 'pointer' : 'default' }
onMouseLeave={ isMobile ? onClose : undefined }
>
{ !fullView && (
<IconSvg
name={ rating ? 'star_filled' : 'star_outline' }
color={ rating ? 'yellow.400' : 'gray.400' }
boxSize={ 5 }
mr={ 1 }
/>
) }
{ (rating && !fullView) ? (
<chakra.span color={ textColor } transition="inherit">
{ rating }
</chakra.span>
) : (
'Rate it!'
) }
</Button>
</Tooltip>
);
};
export default React.forwardRef(TriggerButton);
import { renderHook, wrapper } from 'jest/lib';
import useRatings from './useRatings';
const useAccount = jest.fn();
const useApiQuery = jest.fn();
jest.mock('lib/hooks/useToast', () => jest.fn());
jest.mock('wagmi', () => ({ useAccount: () => useAccount() }));
jest.mock('lib/api/useApiQuery', () => () => useApiQuery());
beforeEach(() => {
jest.clearAllMocks();
});
it('should set canRate to true if address is defined and transactions_count is 5 or more', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(true);
});
it('should set canRate to undefined if address is undefined', async() => {
useAccount.mockReturnValue({ address: undefined });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(undefined);
});
it('should set canRate to false if transactions_count is less than 5', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: { transactions_count: 4 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if isPlaceholderData is true', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: true,
data: { transactions_count: 5 },
});
const { result } = renderHook(() => useRatings(), { wrapper });
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if data is undefined', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: undefined,
});
const { result } = renderHook(() => useRatings());
expect(result.current.canRate).toBe(false);
});
it('should set canRate to false if transactions_count is undefined', async() => {
useAccount.mockReturnValue({ address: '0x123' });
useApiQuery.mockReturnValue({
isPlaceholderData: false,
data: {},
});
const { result } = renderHook(() => useRatings());
expect(result.current.canRate).toBe(false);
});
import Airtable from 'airtable';
import { useEffect, useState, useCallback } from 'react';
import { useAccount } from 'wagmi';
import type { AppRating } from 'types/client/marketplace';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import useToast from 'lib/hooks/useToast';
import type { EventTypes, EventPayload } from 'lib/mixpanel/index';
import * as mixpanel from 'lib/mixpanel/index';
import { ADDRESS_COUNTERS } from 'stubs/address';
const MIN_TRANSACTION_COUNT = 5;
const feature = config.features.marketplace;
const airtable = (feature.isEnabled && feature.rating) ?
new Airtable({ apiKey: feature.rating.airtableApiKey }).base(feature.rating.airtableBaseId) :
undefined;
export type RateFunction = (
appId: string,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => void;
function formatRatings(data: Airtable.Records<Airtable.FieldSet>) {
return data.reduce((acc: Record<string, AppRating>, record) => {
const fields = record.fields as { appId: string | Array<string>; rating: number | undefined };
const appId = Array.isArray(fields.appId) ? fields.appId[0] : fields.appId;
acc[appId] = {
recordId: record.id,
value: fields.rating,
};
return acc;
}, {});
}
export default function useRatings() {
const { address } = useAccount();
const toast = useToast();
const addressCountersQuery = useApiQuery<'address_counters', { status: number }>('address_counters', {
pathParams: { hash: address },
queryOptions: {
enabled: Boolean(address),
placeholderData: ADDRESS_COUNTERS,
refetchOnMount: false,
},
});
const [ ratings, setRatings ] = useState<Record<string, AppRating>>({});
const [ userRatings, setUserRatings ] = useState<Record<string, AppRating>>({});
const [ isRatingLoading, setIsRatingLoading ] = useState<boolean>(false);
const [ isUserRatingLoading, setIsUserRatingLoading ] = useState<boolean>(false);
const [ isSending, setIsSending ] = useState<boolean>(false);
const [ canRate, setCanRate ] = useState<boolean | undefined>(undefined);
const fetchRatings = useCallback(async() => {
if (!airtable) {
return;
}
try {
const data = await airtable('apps_ratings').select({ fields: [ 'appId', 'rating' ] }).all();
const ratings = formatRatings(data);
setRatings(ratings);
} catch (error) {
toast({
status: 'error',
title: 'Error loading ratings',
description: 'Please try again later',
});
}
}, [ toast ]);
useEffect(() => {
async function fetch() {
setIsRatingLoading(true);
await fetchRatings();
setIsRatingLoading(false);
}
fetch();
}, [ fetchRatings ]);
useEffect(() => {
async function fetchUserRatings() {
setIsUserRatingLoading(true);
let userRatings = {} as Record<string, AppRating>;
if (address && airtable) {
try {
const data = await airtable('users_ratings').select({
filterByFormula: `address = "${ address }"`,
fields: [ 'appId', 'rating' ],
}).all();
userRatings = formatRatings(data);
} catch (error) {
toast({
status: 'error',
title: 'Error loading user ratings',
description: 'Please try again later',
});
}
}
setUserRatings(userRatings);
setIsUserRatingLoading(false);
}
fetchUserRatings();
}, [ address, toast ]);
useEffect(() => {
const { isPlaceholderData, data } = addressCountersQuery;
const canRate = address && !isPlaceholderData && Number(data?.transactions_count) >= MIN_TRANSACTION_COUNT;
setCanRate(canRate);
}, [ address, addressCountersQuery ]);
const rateApp = useCallback(async(
appId: string,
appRecordId: string | undefined,
userRecordId: string | undefined,
rating: number,
source: EventPayload<EventTypes.APP_FEEDBACK>['Source'],
) => {
setIsSending(true);
try {
if (!address || !airtable) {
throw new Error('Address is missing');
}
if (!appRecordId) {
const records = await airtable('apps_ratings').create([ { fields: { appId } } ]);
appRecordId = records[0].id;
if (!appRecordId) {
throw new Error('Record ID is missing');
}
}
if (!userRecordId) {
const userRecords = await airtable('users_ratings').create([
{
fields: {
address,
appRecordId: [ appRecordId ],
rating,
},
},
]);
userRecordId = userRecords[0].id;
} else {
await airtable('users_ratings').update(userRecordId, { rating });
}
setUserRatings({
...userRatings,
[appId]: {
recordId: userRecordId,
value: rating,
},
});
fetchRatings();
toast({
status: 'success',
title: 'Awesome! Thank you 💜',
description: 'Your rating improves the service',
});
mixpanel.logEvent(
mixpanel.EventTypes.APP_FEEDBACK,
{ Action: 'Rating', Source: source, AppId: appId, Score: rating },
);
} catch (error) {
toast({
status: 'error',
title: 'Ooops! Something went wrong',
description: 'Please try again later',
});
}
setIsSending(false);
}, [ address, userRatings, fetchRatings, toast ]);
return {
ratings,
userRatings,
rateApp,
isRatingSending: isSending,
isRatingLoading,
isUserRatingLoading,
canRate,
};
}
...@@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce'; ...@@ -9,6 +9,7 @@ import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useRatings from './Rating/useRatings';
import useMarketplaceApps from './useMarketplaceApps'; import useMarketplaceApps from './useMarketplaceApps';
import useMarketplaceCategories from './useMarketplaceCategories'; import useMarketplaceCategories from './useMarketplaceCategories';
...@@ -85,9 +86,10 @@ export default function useMarketplace() { ...@@ -85,9 +86,10 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
const { ratings, userRatings, rateApp, isRatingSending, isRatingLoading, canRate } = useRatings();
const { const {
isPlaceholderData, isError, error, data, displayedApps, setSorting, isPlaceholderData, isError, error, data, displayedApps, setSorting,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded); } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded, ratings);
const { const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories, isPlaceholderData: isCategoriesPlaceholderData, data: categories,
} = useMarketplaceCategories(data, isPlaceholderData); } = useMarketplaceCategories(data, isPlaceholderData);
...@@ -151,6 +153,11 @@ export default function useMarketplace() { ...@@ -151,6 +153,11 @@ export default function useMarketplace() {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
}), [ }), [
selectedCategoryId, selectedCategoryId,
categories, categories,
...@@ -174,5 +181,10 @@ export default function useMarketplace() { ...@@ -174,5 +181,10 @@ export default function useMarketplace() {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
]); ]);
} }
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace'; import type { MarketplaceAppWithSecurityReport, AppRating } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
...@@ -55,6 +55,7 @@ export default function useMarketplaceApps( ...@@ -55,6 +55,7 @@ export default function useMarketplaceApps(
selectedCategoryId: string = MarketplaceCategory.ALL, selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> | undefined = undefined, favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
ratings: Record<string, AppRating> | undefined = undefined,
) { ) {
const fetch = useFetch(); const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
...@@ -91,20 +92,27 @@ export default function useMarketplaceApps( ...@@ -91,20 +92,27 @@ export default function useMarketplaceApps(
const [ sorting, setSorting ] = React.useState<SortValue>(); const [ sorting, setSorting ] = React.useState<SortValue>();
const appsWithSecurityReports = React.useMemo(() => const appsWithSecurityReportsAndRating = React.useMemo(() =>
data?.map((app) => ({ ...app, securityReport: securityReports?.[app.id] })), data?.map((app) => ({
[ data, securityReports ]); ...app,
securityReport: securityReports?.[app.id],
rating: ratings?.[app.id],
})),
[ data, securityReports, ratings ]);
const displayedApps = React.useMemo(() => { const displayedApps = React.useMemo(() => {
return appsWithSecurityReports return appsWithSecurityReportsAndRating
?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) ?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps))
.sort((a, b) => { .sort((a, b) => {
if (sorting === 'security_score') { if (sorting === 'security_score') {
return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0); return (b.securityReport?.overallInfo.securityScore || 0) - (a.securityReport?.overallInfo.securityScore || 0);
} }
if (sorting === 'rating') {
return (b.rating?.value || 0) - (a.rating?.value || 0);
}
return 0; return 0;
}) || []; }) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps, sorting ]); }, [ selectedCategoryId, appsWithSecurityReportsAndRating, filter, favoriteApps, sorting ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
data, data,
......
...@@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString'; ...@@ -4,10 +4,11 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import removeQueryParam from 'lib/router/removeQueryParam'; import removeQueryParam from 'lib/router/removeQueryParam';
import type { TOption } from 'ui/shared/sort/Option'; import type { TOption } from 'ui/shared/sort/Option';
export type SortValue = 'security_score'; export type SortValue = 'rating' | 'security_score';
export const SORT_OPTIONS: Array<TOption<SortValue>> = [ export const SORT_OPTIONS: Array<TOption<SortValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Rating', id: 'rating' },
{ title: 'Security score', id: 'security_score' }, { title: 'Security score', id: 'security_score' },
]; ];
......
...@@ -4,13 +4,13 @@ import React from 'react'; ...@@ -4,13 +4,13 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages'; import type { MessagesDirection } from './ArbitrumL2Messages';
...@@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -23,8 +23,6 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash;
const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
...@@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => { ...@@ -88,7 +86,11 @@ const ArbitrumL2MessagesListItem = ({ item, isLoading, direction }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block">{ timeAgo }</Skeleton> <TimeAgoWithTooltip
timestamp={ item.origination_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
......
...@@ -4,12 +4,12 @@ import React from 'react'; ...@@ -4,12 +4,12 @@ import React from 'react';
import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2'; import type { ArbitrumL2MessagesItem } from 'types/api/arbitrumL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1'; import BlockEntityL1 from 'ui/shared/entities/block/BlockEntityL1';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus'; import ArbitrumL2MessageStatus from 'ui/shared/statusTag/ArbitrumL2MessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import type { MessagesDirection } from './ArbitrumL2Messages'; import type { MessagesDirection } from './ArbitrumL2Messages';
...@@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ...@@ -22,8 +22,6 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
return null; return null;
} }
const timeAgo = dayjs(item.origination_timestamp).fromNow();
const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash; const l1TxHash = direction === 'from-rollup' ? item.completion_transaction_hash : item.origination_transaction_hash;
const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash; const l2TxHash = direction === 'from-rollup' ? item.origination_transaction_hash : item.completion_transaction_hash;
...@@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => { ...@@ -75,9 +73,11 @@ const ArbitrumL2MessagesTableItem = ({ item, direction, isLoading }: Props) => {
) } ) }
</Td> </Td>
<Td verticalAlign="middle" pr={ 12 }> <Td verticalAlign="middle" pr={ 12 }>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.origination_timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<ArbitrumL2MessageStatus status={ item.status } isLoading={ isLoading }/> <ArbitrumL2MessageStatus status={ item.status } isLoading={ isLoading }/>
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type * as bens from '@blockscout/bens-types'; import type * as bens from '@blockscout/bens-types';
...@@ -6,12 +5,12 @@ import type * as bens from '@blockscout/bens-types'; ...@@ -6,12 +5,12 @@ import type * as bens from '@blockscout/bens-types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash'; import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
event: bens.DomainEvent; event: bens.DomainEvent;
...@@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => { ...@@ -38,9 +37,12 @@ const NameDomainHistoryListItem = ({ isLoading, domain, event }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ dayjs(event.timestamp).fromNow() }</span> timestamp={ event.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
{ event.from_address && ( { event.from_address && (
......
import { Tr, Td, Skeleton } from '@chakra-ui/react'; import { Tr, Td } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type * as bens from '@blockscout/bens-types'; import type * as bens from '@blockscout/bens-types';
...@@ -6,11 +6,11 @@ import type * as bens from '@blockscout/bens-types'; ...@@ -6,11 +6,11 @@ import type * as bens from '@blockscout/bens-types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import stripTrailingSlash from 'lib/stripTrailingSlash'; import stripTrailingSlash from 'lib/stripTrailingSlash';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
interface Props { interface Props {
event: bens.DomainEvent; event: bens.DomainEvent;
...@@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => { ...@@ -41,9 +41,12 @@ const NameDomainHistoryTableItem = ({ isLoading, event, domain }: Props) => {
/> />
</Td> </Td>
<Td pl={ 9 } verticalAlign="middle"> <Td pl={ 9 } verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"> <TimeAgoWithTooltip
<span>{ dayjs(event.timestamp).fromNow() }</span> timestamp={ event.timestamp }
</Skeleton> isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
{ event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> } { event.from_address && <AddressEntity address={ event.from_address } isLoading={ isLoading } truncation="constant"/> }
......
...@@ -4,20 +4,18 @@ import React from 'react'; ...@@ -4,20 +4,18 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid'; import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => { ...@@ -32,9 +30,11 @@ const OptimisticL2OutputRootsListItem = ({ item, isLoading }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="inline-block"> <TimeAgoWithTooltip
<span>{ timeAgo }</span> timestamp={ item.l1_timestamp }
</Skeleton> isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>L2 block #</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>L2 block #</ListItemMobileGrid.Label>
......
...@@ -4,19 +4,17 @@ import React from 'react'; ...@@ -4,19 +4,17 @@ import React from 'react';
import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2'; import type { OptimisticL2OutputRootsItem } from 'types/api/optimisticL2';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2'; import BlockEntityL2 from 'ui/shared/entities/block/BlockEntityL2';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean }; type Props = { item: OptimisticL2OutputRootsItem; isLoading?: boolean };
const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.l1_timestamp).fromNow();
if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'optimistic') {
return null; return null;
} }
...@@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => { ...@@ -27,7 +25,12 @@ const OptimisticL2OutputRootsTableItem = ({ item, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } display="inline-block">{ item.l2_output_index }</Skeleton> <Skeleton isLoaded={ !isLoading } display="inline-block">{ item.l2_output_index }</Skeleton>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton> <TimeAgoWithTooltip
timestamp={ item.l1_timestamp }
isLoading={ isLoading }
display="inline-block"
color="text_secondary"
/>
</Td> </Td>
<Td verticalAlign="middle"> <Td verticalAlign="middle">
<BlockEntityL2 <BlockEntityL2
......
...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react'; ...@@ -2,6 +2,7 @@ import { Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { apps as appsMock } from 'mocks/apps/apps'; import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports'; import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
...@@ -10,15 +11,21 @@ import Marketplace from './Marketplace'; ...@@ -10,15 +11,21 @@ import Marketplace from './Marketplace';
const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'http://localhost/marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json'; const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse }) => { test.beforeEach(async({ mockConfigResponse, mockEnvs, mockAssetResponse, page }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]); ]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg'))); await Promise.all(appsMock.map(app => mockAssetResponse(app.logo, './playwright/mocks/image_s.jpg')));
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
}); });
test('base view +@dark-mode', async({ render }) => { test('base view +@dark-mode', async({ render }) => {
......
...@@ -71,6 +71,11 @@ const Marketplace = () => { ...@@ -71,6 +71,11 @@ const Marketplace = () => {
contractListModalType, contractListModalType,
hasPreviousStep, hasPreviousStep,
setSorting, setSorting,
userRatings,
rateApp,
isRatingSending,
isRatingLoading,
canRate,
} = useMarketplace(); } = useMarketplace();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -92,13 +97,13 @@ const Marketplace = () => { ...@@ -92,13 +97,13 @@ const Marketplace = () => {
tabs.unshift({ tabs.unshift({
id: MarketplaceCategory.FAVORITES, id: MarketplaceCategory.FAVORITES,
title: () => <IconSvg name="star_outline" w={ 5 } h={ 5 } display="flex"/>, title: () => <IconSvg name="heart_filled" boxSize={ 5 } verticalAlign="middle" mt={ -1 }/>,
count: null, count: favoriteApps.length,
component: null, component: null,
}); });
return tabs; return tabs;
}, [ categories, appsTotal ]); }, [ categories, appsTotal, favoriteApps.length ]);
const selectedCategoryIndex = React.useMemo(() => { const selectedCategoryIndex = React.useMemo(() => {
const index = categoryTabs.findIndex(c => c.id === selectedCategoryId); const index = categoryTabs.findIndex(c => c.id === selectedCategoryId);
...@@ -224,6 +229,11 @@ const Marketplace = () => { ...@@ -224,6 +229,11 @@ const Marketplace = () => {
selectedCategoryId={ selectedCategoryId } selectedCategoryId={ selectedCategoryId }
onAppClick={ handleAppClick } onAppClick={ handleAppClick }
showContractList={ showContractList } showContractList={ showContractList }
userRatings={ userRatings }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/> />
{ (selectedApp && isAppInfoModalOpen) && ( { (selectedApp && isAppInfoModalOpen) && (
...@@ -233,6 +243,11 @@ const Marketplace = () => { ...@@ -233,6 +243,11 @@ const Marketplace = () => {
onFavoriteClick={ onFavoriteClick } onFavoriteClick={ onFavoriteClick }
data={ selectedApp } data={ selectedApp }
showContractList={ showContractList } showContractList={ showContractList }
userRating={ userRatings[selectedApp.id] }
rateApp={ rateApp }
isRatingSending={ isRatingSending }
isRatingLoading={ isRatingLoading }
canRate={ canRate }
/> />
) } ) }
......
...@@ -4,6 +4,8 @@ import { numberToHex } from 'viem'; ...@@ -4,6 +4,8 @@ import { numberToHex } from 'viem';
import config from 'configs/app'; import config from 'configs/app';
import { apps as appsMock } from 'mocks/apps/apps'; import { apps as appsMock } from 'mocks/apps/apps';
import { ratings as ratingsMock } from 'mocks/apps/ratings';
import { securityReports as securityReportsMock } from 'mocks/apps/securityReports';
import { test, expect, devices } from 'playwright/lib'; import { test, expect, devices } from 'playwright/lib';
import MarketplaceApp from './MarketplaceApp'; import MarketplaceApp from './MarketplaceApp';
...@@ -16,18 +18,27 @@ const hooksConfig = { ...@@ -16,18 +18,27 @@ const hooksConfig = {
}; };
const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json'; const MARKETPLACE_CONFIG_URL = 'https://marketplace-config.json';
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse }) => { const testFn: Parameters<typeof test>[1] = async({ render, mockConfigResponse, mockAssetResponse, mockEnvs, mockRpcResponse, page }) => {
await mockEnvs([ await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ], [ 'NEXT_PUBLIC_MARKETPLACE_ENABLED', 'true' ],
[ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ], [ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_API_KEY', 'test' ],
[ 'NEXT_PUBLIC_MARKETPLACE_RATING_AIRTABLE_BASE_ID', 'test' ],
]); ]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock)); await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', MARKETPLACE_CONFIG_URL, JSON.stringify(appsMock));
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
await mockAssetResponse(appsMock[0].url, './mocks/apps/app.html'); await mockAssetResponse(appsMock[0].url, './mocks/apps/app.html');
await mockRpcResponse({ await mockRpcResponse({
Method: 'eth_chainId', Method: 'eth_chainId',
ReturnType: numberToHex(Number(config.chain.id)), ReturnType: numberToHex(Number(config.chain.id)),
}); });
await page.route('https://api.airtable.com/v0/test/apps_ratings?fields%5B%5D=appId&fields%5B%5D=rating', (route) => route.fulfill({
status: 200,
body: JSON.stringify(ratingsMock),
}));
const component = await render( const component = await render(
<Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh"> <Flex flexDirection="column" mx={{ base: 4, lg: 6 }} h="100vh">
......
...@@ -151,6 +151,7 @@ const MarketplaceApp = () => { ...@@ -151,6 +151,7 @@ const MarketplaceApp = () => {
return ( return (
<Flex flexDirection="column" h="100%"> <Flex flexDirection="column" h="100%">
<MarketplaceAppTopBar <MarketplaceAppTopBar
appId={ id }
data={ data } data={ data }
isLoading={ isPending || isSecurityReportsLoading } isLoading={ isPending || isSecurityReportsLoading }
securityReport={ securityReports?.[id] } securityReport={ securityReports?.[id] }
......
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