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,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',
];
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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