Commit d1aef092 authored by Max Alekseenko's avatar Max Alekseenko Committed by GitHub

Merge pull request #1581 from blockscout/marketplace-admin-api

Fetch dapps from admin service
parents a5682107 9538a8f5
......@@ -7,22 +7,36 @@ import { getEnvValue, getExternalAssetFilePath } from '../utils';
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 adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const title = 'Marketplace';
const config: Feature<{ configUrl: string; submitFormUrl: string; categoriesUrl: string | undefined }> = (() => {
if (
chain.rpcUrl &&
configUrl &&
submitFormUrl
) {
return Object.freeze({
title,
isEnabled: true,
configUrl,
submitFormUrl,
categoriesUrl,
});
const config: Feature<(
{ configUrl: string } |
{ api: { endpoint: string; basePath: string } }
) & { submitFormUrl: string; categoriesUrl: string | undefined }
> = (() => {
if (chain.rpcUrl && submitFormUrl) {
if (configUrl) {
return Object.freeze({
title,
isEnabled: true,
configUrl,
submitFormUrl,
categoriesUrl,
});
} else if (adminServiceApiHost) {
return Object.freeze({
title,
isEnabled: true,
submitFormUrl,
categoriesUrl,
api: {
endpoint: adminServiceApiHost,
basePath: '',
},
});
}
}
return Object.freeze({
......
......@@ -89,10 +89,11 @@ const marketplaceSchema = yup
.of(yup.string()),
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: yup
.string()
.when('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', {
is: (value: Array<unknown>) => value.length > 0,
.when([ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST' ], {
is: (config: Array<unknown>, apiHost: string) => config.length > 0 || Boolean(apiHost),
then: (schema) => schema.test(urlTest).required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM cannot not be used without NEXT_PUBLIC_MARKETPLACE_CONFIG_URL'),
// eslint-disable-next-line max-len
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM cannot not be used without NEXT_PUBLIC_MARKETPLACE_CONFIG_URL or NEXT_PUBLIC_ADMIN_SERVICE_API_HOST'),
}),
});
......
......@@ -63,7 +63,6 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
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','logo':'https://github.com/blockscout/frontend-configs/blob/main/configs/explorer-logos/etherscan.png?raw=true','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']"
......
......@@ -429,7 +429,8 @@ This feature is **always enabled**, but you can configure its behavior by passin
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| 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_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. Can be replaced with NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | Required | - | `https://example.com/marketplace_config.json` |
| NEXT_PUBLIC_ADMIN_SERVICE_API_HOST | `string` | Admin Service API endpoint url. Can be used instead of NEXT_PUBLIC_MARKETPLACE_CONFIG_URL | - | - | `https://admin-rs.services.blockscout.com` |
| 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` |
......
......@@ -85,10 +85,14 @@ import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem, ZkEvmL2TxnBatchesResponse, ZkEvmL2TxnBatchTxs } from 'types/api/zkEvmL2';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { ArrayElement } from 'types/utils';
import config from 'configs/app';
const marketplaceFeature = getFeaturePayload(config.features.marketplace);
const marketplaceApi = marketplaceFeature && 'api' in marketplaceFeature ? marketplaceFeature.api : undefined;
export interface ApiResource {
path: ResourcePath;
endpoint?: string;
......@@ -224,6 +228,20 @@ export const RESOURCES = {
basePath: getFeaturePayload(config.features.sol2uml)?.api.basePath,
},
// MARKETPLACE
marketplace_dapps: {
path: '/api/v1/chains/:chainId/marketplace/dapps',
pathParams: [ 'chainId' as const ],
endpoint: marketplaceApi?.endpoint,
basePath: marketplaceApi?.basePath,
},
marketplace_dapp: {
path: '/api/v1/chains/:chainId/marketplace/dapps/:dappId',
pathParams: [ 'chainId' as const, 'dappId' as const ],
endpoint: marketplaceApi?.endpoint,
basePath: marketplaceApi?.basePath,
},
// BLOCKS, TXS
blocks: {
path: '/api/v2/blocks',
......@@ -674,7 +692,10 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
/* eslint-disable @typescript-eslint/indent */
export type ResourcePayload<Q extends ResourceName> =
// !!! IMPORTANT !!!
// Don't add any new types here because TypeScript cannot handle it properly
// use ResourcePayloadB instead
export type ResourcePayloadA<Q extends ResourceName> =
Q extends 'user_info' ? UserInfo :
Q extends 'custom_abi' ? CustomAbis :
Q extends 'public_tags' ? PublicTags :
......@@ -776,8 +797,24 @@ Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never;
// !!! IMPORTANT !!!
// See comment above
/* eslint-enable @typescript-eslint/indent */
/* eslint-disable @typescript-eslint/indent */
export type ResourcePayloadB<Q extends ResourceName> =
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
never;
/* eslint-enable @typescript-eslint/indent */
export type ResourcePayload<Q extends ResourceName> = ResourcePayloadA<Q> | ResourcePayloadB<Q>;
// Right now there is no paginated resources in B-part
// Add "| ResourcePayloadB<Q>[...]" if it is not true anymore
export type PaginatedResponseItems<Q extends ResourceName> = Q extends PaginatedResources ? ResourcePayloadA<Q>['items'] : never;
export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends PaginatedResources ? ResourcePayloadA<Q>['next_page_params'] : never;
/* eslint-disable @typescript-eslint/indent */
export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters :
......
import type { ArrayElement } from 'types/utils';
import type { PaginatedResources, PaginatedResponse } from 'lib/api/resources';
import type { PaginatedResources, PaginatedResponse, PaginatedResponseItems } from 'lib/api/resources';
export function generateListStub<Resource extends PaginatedResources>(
stub: ArrayElement<PaginatedResponse<Resource>['items']>,
stub: ArrayElement<PaginatedResponseItems<Resource>>,
num = 50,
rest: Omit<PaginatedResponse<Resource>, 'items'>,
) {
......
......@@ -6,12 +6,12 @@ import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useApiFetch from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace';
const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : '';
function isAppNameMatches(q: string, app: MarketplaceAppOverview) {
return app.title.toLowerCase().includes(q.toLowerCase());
......@@ -48,12 +48,21 @@ function sortApps(apps: Array<MarketplaceAppOverview>, isExperiment: boolean) {
}
export default function useMarketplaceApps(filter: string, selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array<string> = []) {
const fetch = useFetch();
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' }),
queryKey: [ 'marketplace-dapps' ],
queryFn: async() => {
if (!feature.isEnabled) {
return [];
} else if ('configUrl' in feature) {
return fetch<Array<MarketplaceAppOverview>, unknown>(feature.configUrl, undefined, { resource: 'marketplace-dapps' });
} else {
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } });
}
},
select: (data) => sortApps(data as Array<MarketplaceAppOverview>, isExperiment),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity,
......
......@@ -10,8 +10,9 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useApiFetch from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
......@@ -20,7 +21,6 @@ import useAutoConnectWallet from '../marketplace/useAutoConnectWallet';
import useMarketplaceWallet from '../marketplace/useMarketplaceWallet';
const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : '';
const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' +
'allow-pointer-lock allow-popups-to-escape-sandbox ' +
......@@ -99,23 +99,29 @@ const MarketplaceApp = () => {
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet();
useAutoConnectWallet();
const fetch = useFetch();
const apiFetch = useApiFetch();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ],
queryKey: [ 'marketplace-dapps', id ],
queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
if (!Array.isArray(result)) {
throw result;
if (!feature.isEnabled) {
return null;
} else if ('configUrl' in feature) {
const result = await fetch<Array<MarketplaceAppOverview>, unknown>(feature.configUrl, undefined, { resource: 'marketplace-dapps' });
if (!Array.isArray(result)) {
throw result;
}
const item = result.find((app: MarketplaceAppOverview) => app.id === id);
if (!item) {
throw { status: 404 };
}
return item;
} else {
return apiFetch('marketplace_dapp', { pathParams: { chainId: config.chain.id, dappId: id } });
}
const item = result.find((app: MarketplaceAppOverview) => app.id === id);
if (!item) {
throw { status: 404 };
}
return item;
},
enabled: feature.isEnabled,
});
......
......@@ -34,6 +34,14 @@ function getPaginationParamsFromQuery(queryString: string | Array<string> | unde
return {};
}
function getNextPageParams<R extends PaginatedResources>(data: ResourcePayload<R> | undefined) {
if (!data || typeof data !== 'object' || !('next_page_params' in data)) {
return;
}
return data.next_page_params;
}
export type QueryWithPagesResult<Resource extends PaginatedResources> =
UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> &
{
......@@ -76,29 +84,30 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
},
});
const { data } = queryResult;
const nextPageParams = getNextPageParams(data);
const onNextPageClick = useCallback(() => {
if (!data?.next_page_params) {
if (!nextPageParams) {
// we hide next page button if no next_page_params
return;
}
setPageParams((prev) => ({
...prev,
[page + 1]: data.next_page_params as NextPageParams,
[page + 1]: nextPageParams as NextPageParams,
}));
setPage(prev => prev + 1);
const nextPageQuery = {
...router.query,
page: String(page + 1),
next_page_params: encodeURIComponent(JSON.stringify(data.next_page_params)),
next_page_params: encodeURIComponent(JSON.stringify(nextPageParams)),
};
setHasPages(true);
scrollToTop();
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true });
}, [ data?.next_page_params, page, router, scrollToTop ]);
}, [ nextPageParams, page, router, scrollToTop ]);
const onPrevPageClick = useCallback(() => {
// returning to the first page
......@@ -181,7 +190,6 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
});
}, [ router, scrollToTop ]);
const nextPageParams = data?.next_page_params;
const hasNextPage = nextPageParams ? Object.keys(nextPageParams).length > 0 : false;
const pagination = {
......
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