Commit 4374a6f3 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #1545 from blockscout/improve-marketplace-sorting

Load categories from config and improve app sorting
parents 63c6107b 8fb0a6a6
......@@ -6,10 +6,11 @@ import { getEnvValue, getExternalAssetFilePath } from '../utils';
// config file will be downloaded at run-time and saved in the public folder
const configUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL');
const submitFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM');
const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL');
const title = 'Marketplace';
const config: Feature<{ configUrl: string; submitFormUrl: string }> = (() => {
const config: Feature<{ configUrl: string; submitFormUrl: string; categoriesUrl: string | undefined }> = (() => {
if (
chain.rpcUrl &&
configUrl &&
......@@ -20,6 +21,7 @@ const config: Feature<{ configUrl: string; submitFormUrl: string }> = (() => {
isEnabled: true,
configUrl,
submitFormUrl,
categoriesUrl,
});
}
......
......@@ -46,6 +46,7 @@ NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_LOGOUT_URL=https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_STATS_API_HOST=https://stats-goerli.k8s-dev.blockscout.com
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.k8s-dev.blockscout.com
......
......@@ -15,6 +15,7 @@ ASSETS_DIR="$1"
# Define a list of environment variables containing URLs of external assets
ASSETS_ENVS=(
"NEXT_PUBLIC_MARKETPLACE_CONFIG_URL"
"NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL"
"NEXT_PUBLIC_FEATURED_NETWORKS"
"NEXT_PUBLIC_FOOTER_LINKS"
"NEXT_PUBLIC_NETWORK_LOGO"
......@@ -36,7 +37,7 @@ get_target_filename() {
local name_prefix="${env_var#NEXT_PUBLIC_}"
local name_suffix="${name_prefix%_URL}"
local name_lc="$(echo "$name_suffix" | tr '[:upper:]' '[:lower:]')"
# Check if the URL starts with "file://"
if [[ "$url" == file://* ]]; then
# Extract the local file path
......@@ -54,7 +55,7 @@ get_target_filename() {
# Convert the extension to lowercase
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
# Construct the custom file name
echo "$name_lc.$extension"
}
......
......@@ -36,6 +36,7 @@ async function validateEnvs(appEnvs: Record<string, string>) {
const envsWithJsonConfig = [
'NEXT_PUBLIC_FEATURED_NETWORKS',
'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL',
'NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL',
'NEXT_PUBLIC_FOOTER_LINKS',
];
......@@ -99,7 +100,7 @@ async function checkPlaceholdersCongruity(envsMap: Record<string, string>) {
inconsistencies.forEach((env) => {
console.log(` ${ env }`);
});
console.log(` They are either deprecated or running the app with them may lead to unexpected behavior.
console.log(` They are either deprecated or running the app with them may lead to unexpected behavior.
Please check the documentation for more details - https://github.com/blockscout/frontend/blob/main/docs/ENVS.md
`);
throw new Error();
......
......@@ -72,6 +72,7 @@ const marketplaceAppSchema: yup.ObjectSchema<MarketplaceAppOverview> = yup
telegram: yup.string().test(urlTest),
github: yup.string().test(urlTest),
internalWallet: yup.boolean(),
priority: yup.number(),
});
const marketplaceSchema = yup
......@@ -81,6 +82,10 @@ const marketplaceSchema = yup
.array()
.json()
.of(marketplaceAppSchema),
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: yup
.array()
.json()
.of(yup.string()),
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', {
......
......@@ -23,6 +23,7 @@ NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER=true
NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME=true
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL=https://example.com
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://example.com
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://example.com
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='<a href="#">Hello</a>'
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
......@@ -51,4 +52,4 @@ NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','tx_fee','gas_fees','burnt_fees']
NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
\ No newline at end of file
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
......@@ -182,6 +182,7 @@ frontend:
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: https://airtable.com/shrqUAcjgGJ4jU88C
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: "linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)"
NEXT_PUBLIC_NETWORK_RPC_URL: https://goerli.optimism.io
NEXT_PUBLIC_WEB3_WALLETS: "['coinbase']"
......
......@@ -158,6 +158,7 @@ frontend:
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
......
......@@ -56,6 +56,7 @@ frontend:
NEXT_PUBLIC_LOGOUT_URL: https://blockscoutcom.us.auth0.com/v2/logout
NEXT_PUBLIC_STATS_API_HOST: https://stats-optimism-goerli.k8s-dev.blockscout.com
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/base-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND: "linear-gradient(136.9deg,rgb(107 94 236) 1.5%,rgb(0 82 255) 56.84%,rgb(82 62 231) 98.54%)"
NEXT_PUBLIC_NETWORK_RPC_URL: https://goerli.optimism.io
NEXT_PUBLIC_WEB3_WALLETS: "['coinbase']"
......
......@@ -64,6 +64,7 @@ frontend:
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli
NEXT_PUBLIC_NETWORK_EXPLORERS: "[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]"
NEXT_PUBLIC_MARKETPLACE_CONFIG_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace/eth-goerli.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_ADDRESS_IDENTICON_TYPE: gradient_avatar
......@@ -84,4 +85,4 @@ frontend:
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID
FAVICON_GENERATOR_API_KEY: ref+vault://deployment-values/blockscout/common?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_FAVICON_GENERATOR_API_KEY
NEXT_PUBLIC_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
\ No newline at end of file
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
......@@ -440,6 +440,7 @@ This feature is **always enabled**, but you can configure its behavior by passin
| NEXT_PUBLIC_MARKETPLACE_CONFIG_URL | `string` | URL of configuration file (`.json` format only) which contains list of apps that will be shown on the marketplace page. See [below](#marketplace-app-configuration-properties) list of available properties for an app | Required | - | `https://example.com/marketplace_config.json` |
| NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM | `string` | Link to form where authors can submit their dapps to the marketplace | Required | - | `https://airtable.com/shrqUAcjgGJ4jU88C` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | See in [Blockchain parameters](ENVS.md#blockchain-parameters) section | Required | - | `https://core.poa.network` |
| NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL | `string` | URL of configuration file (`.json` format only) which contains the list of categories to be displayed on the markeplace page in the specified order. If no URL is provided, then the list of categories will be compiled based on the `categories` fields from the marketplace (apps) configuration file | - | - | `https://example.com/marketplace_categories.json` |
#### Marketplace app configuration properties
......@@ -458,6 +459,8 @@ This feature is **always enabled**, but you can configure its behavior by passin
| twitter | `string` | Displayed twitter link | - | `'https://twitter.com/blockscoutcom'` |
| telegram | `string` | Displayed telegram link | - | `'https://t.me/poa_network'` |
| github | `string` | Displayed github link | - | `'https://github.com/blockscout'` |
| internalWallet | `boolean` | `true` means that the application can automatically connect to the Blockscout wallet. | - | `true` |
| priority | `number` | The higher the priority, the higher the app will appear in the list on the Marketplace page. | - | `7` |
#### Marketplace categories ids
......@@ -561,7 +564,7 @@ This feature allows users to view tokens that have been bridged from other EVM c
### Safe{Core} address tags
For the smart contract addresses which are [Safe{Core} accounts](https://safe.global/) public tag "Multisig: Safe" will be displayed in the address page header alongside to Safe logo. The Safe service is available only for certain networks, see full list [here](https://docs.safe.global/safe-core-api/available-services). Based on provided value of `NEXT_PUBLIC_NETWORK_ID`, the feature will be enabled or disabled.
For the smart contract addresses which are [Safe{Core} accounts](https://safe.global/) public tag "Multisig: Safe" will be displayed in the address page header alongside to Safe logo. The Safe service is available only for certain networks, see full list [here](https://docs.safe.global/safe-core-api/available-services). Based on provided value of `NEXT_PUBLIC_NETWORK_ID`, the feature will be enabled or disabled.
&nbsp;
......
......@@ -15,3 +15,5 @@ export const MARKETPLACE_APP: MarketplaceAppOverview = {
external: true,
url: 'https://example.com',
};
export const CATEGORIES: Array<string> = Array(9).fill('Bridge').map((c, i) => c + i);
......@@ -8,6 +8,7 @@ export type MarketplaceAppPreview = {
categories: Array<string>;
url: string;
internalWallet?: boolean;
priority?: number;
}
export type MarketplaceAppOverview = MarketplaceAppPreview & {
......
import _groudBy from 'lodash/groupBy';
import _pickBy from 'lodash/pickBy';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -10,6 +9,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
import useMarketplaceApps from './useMarketplaceApps';
import useMarketplaceCategories from './useMarketplaceCategories';
const favoriteAppsLocalStorageKey = 'favoriteApps';
......@@ -72,15 +72,7 @@ export default function useMarketplace() {
}, []);
const { isPlaceholderData, isError, error, data, displayedApps } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps);
const categories = React.useMemo(() => {
const grouped = _groudBy(data, app => app.categories);
return Object.keys(grouped).map(category => ({
name: category,
count: grouped[category].length,
}));
// return _unique(data?.map(app => app.categories).flat()) || [];
}, [ data ]);
const { isPlaceholderData: isCategoriesPlaceholderData, data: categories } = useMarketplaceCategories(data, isPlaceholderData);
React.useEffect(() => {
setFavoriteApps(getFavoriteApps());
......@@ -134,6 +126,7 @@ export default function useMarketplace() {
isDisclaimerModalOpen,
showDisclaimer,
appsTotal: data?.length || 0,
isCategoriesPlaceholderData,
}), [
selectedCategoryId,
categories,
......@@ -152,5 +145,6 @@ export default function useMarketplace() {
isDisclaimerModalOpen,
showDisclaimer,
data?.length,
isCategoriesPlaceholderData,
]);
}
......@@ -6,6 +6,7 @@ import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useApiFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace';
......@@ -22,12 +23,38 @@ function isAppCategoryMatches(category: string, app: MarketplaceAppOverview, fav
app.categories.includes(category);
}
function sortApps(apps: Array<MarketplaceAppOverview>, isExperiment: boolean) {
if (!isExperiment) {
return apps.sort((a, b) => a.title.localeCompare(b.title));
}
return apps.sort((a, b) => {
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
// First, sort by priority (descending)
if (priorityB !== priorityA) {
return priorityB - priorityA;
}
// If priority is the same, sort by internalWallet (true first)
if (a.internalWallet !== b.internalWallet) {
return a.internalWallet ? -1 : 1;
}
// If internalWallet is also the same, sort by external (false first)
if (a.external !== b.external) {
return a.external ? 1 : -1;
}
// If all criteria are the same, keep original order (stable sort)
return 0;
});
}
export default function useMarketplaceApps(filter: string, selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array<string> = []) {
const apiFetch = useApiFetch();
const { value: isExperiment } = useFeatureValue('marketplace_exp', false);
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({
queryKey: [ 'marketplace-apps' ],
queryFn: async() => apiFetch(configUrl, undefined, { resource: 'marketplace-apps' }),
select: (data) => (data as Array<MarketplaceAppOverview>).sort((a, b) => a.title.localeCompare(b.title)),
select: (data) => sortApps(data as Array<MarketplaceAppOverview>, isExperiment),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity,
enabled: feature.isEnabled,
......
import { useQuery } from '@tanstack/react-query';
import _groudBy from 'lodash/groupBy';
import React from 'react';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useApiFetch from 'lib/hooks/useFetch';
import { CATEGORIES } from 'stubs/marketplace';
const feature = config.features.marketplace;
const categoriesUrl = (feature.isEnabled && feature.categoriesUrl) || '';
export default function useMarketplaceCategories(apps: Array<MarketplaceAppOverview> | undefined, isAppsPlaceholderData: boolean) {
const apiFetch = useApiFetch();
const { value: isExperiment } = useFeatureValue('marketplace_exp', false);
const { isPlaceholderData, data } = useQuery<unknown, ResourceError<unknown>, Array<string>>({
queryKey: [ 'marketplace-categories' ],
queryFn: async() => apiFetch(categoriesUrl, undefined, { resource: 'marketplace-categories' }),
placeholderData: categoriesUrl ? CATEGORIES : undefined,
staleTime: Infinity,
enabled: Boolean(categoriesUrl),
});
const categories = React.useMemo(() => {
if (isAppsPlaceholderData || isPlaceholderData) {
return CATEGORIES.map(category => ({ name: category, count: 0 }));
}
let categoryNames: Array<string> = [];
const grouped = _groudBy(apps, app => app.categories);
if (data?.length && !isPlaceholderData && isExperiment) {
categoryNames = data;
} else {
categoryNames = Object.keys(grouped);
}
return categoryNames
.map(category => ({ name: category, count: grouped[category]?.length || 0 }))
.filter(c => c.count > 0);
}, [ apps, isAppsPlaceholderData, data, isPlaceholderData, isExperiment ]);
return React.useMemo(() => ({
isPlaceholderData: isAppsPlaceholderData || isPlaceholderData,
data: categories,
}), [ isPlaceholderData, isAppsPlaceholderData, categories ]);
}
......@@ -39,6 +39,7 @@ const Marketplace = () => {
isDisclaimerModalOpen,
showDisclaimer,
appsTotal,
isCategoriesPlaceholderData,
} = useMarketplace();
const { value: isExperiment } = useFeatureValue('marketplace_exp', false);
......@@ -89,7 +90,7 @@ const Marketplace = () => {
<>
{ isExperiment && (
<Box marginTop={{ base: 0, lg: 8 }}>
{ isPlaceholderData ? (
{ (isCategoriesPlaceholderData) ? (
<TabsSkeleton tabs={ categoryTabs }/>
) : (
<TabsWithScroll
......
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