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'; ...@@ -7,22 +7,36 @@ import { getEnvValue, getExternalAssetFilePath } from '../utils';
const configUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL'); const configUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL');
const submitFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM'); const submitFormUrl = getEnvValue('NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM');
const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL'); const categoriesUrl = getExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL');
const adminServiceApiHost = getEnvValue('NEXT_PUBLIC_ADMIN_SERVICE_API_HOST');
const title = 'Marketplace'; const title = 'Marketplace';
const config: Feature<{ configUrl: string; submitFormUrl: string; categoriesUrl: string | undefined }> = (() => { const config: Feature<(
if ( { configUrl: string } |
chain.rpcUrl && { api: { endpoint: string; basePath: string } }
configUrl && ) & { submitFormUrl: string; categoriesUrl: string | undefined }
submitFormUrl > = (() => {
) { if (chain.rpcUrl && submitFormUrl) {
return Object.freeze({ if (configUrl) {
title, return Object.freeze({
isEnabled: true, title,
configUrl, isEnabled: true,
submitFormUrl, configUrl,
categoriesUrl, submitFormUrl,
}); categoriesUrl,
});
} else if (adminServiceApiHost) {
return Object.freeze({
title,
isEnabled: true,
submitFormUrl,
categoriesUrl,
api: {
endpoint: adminServiceApiHost,
basePath: '',
},
});
}
} }
return Object.freeze({ return Object.freeze({
......
...@@ -89,10 +89,11 @@ const marketplaceSchema = yup ...@@ -89,10 +89,11 @@ const marketplaceSchema = yup
.of(yup.string()), .of(yup.string()),
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: yup NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM: yup
.string() .string()
.when('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', { .when([ 'NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST' ], {
is: (value: Array<unknown>) => value.length > 0, is: (config: Array<unknown>, apiHost: string) => config.length > 0 || Boolean(apiHost),
then: (schema) => schema.test(urlTest).required(), 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: ...@@ -63,7 +63,6 @@ frontend:
NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']" NEXT_PUBLIC_HOMEPAGE_CHARTS: "['daily_txs','coin_price','market_cap']"
NEXT_PUBLIC_NETWORK_RPC_URL: https://rpc.ankr.com/eth_goerli 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_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_MARKETPLACE_CATEGORIES_URL: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/marketplace-categories/default.json
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']" 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 ...@@ -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 | | 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_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_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` | | 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'; ...@@ -85,10 +85,14 @@ import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { VisualizedContract } from 'types/api/visualization'; import type { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals'; import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem, ZkEvmL2TxnBatchesResponse, ZkEvmL2TxnBatchTxs } from 'types/api/zkEvmL2'; import type { ZkEvmL2TxnBatch, ZkEvmL2TxnBatchesItem, ZkEvmL2TxnBatchesResponse, ZkEvmL2TxnBatchTxs } from 'types/api/zkEvmL2';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import type { ArrayElement } from 'types/utils'; import type { ArrayElement } from 'types/utils';
import config from 'configs/app'; import config from 'configs/app';
const marketplaceFeature = getFeaturePayload(config.features.marketplace);
const marketplaceApi = marketplaceFeature && 'api' in marketplaceFeature ? marketplaceFeature.api : undefined;
export interface ApiResource { export interface ApiResource {
path: ResourcePath; path: ResourcePath;
endpoint?: string; endpoint?: string;
...@@ -224,6 +228,20 @@ export const RESOURCES = { ...@@ -224,6 +228,20 @@ export const RESOURCES = {
basePath: getFeaturePayload(config.features.sol2uml)?.api.basePath, 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, TXS
blocks: { blocks: {
path: '/api/v2/blocks', path: '/api/v2/blocks',
...@@ -674,7 +692,10 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -674,7 +692,10 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
/* eslint-disable @typescript-eslint/indent */ /* 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 'user_info' ? UserInfo :
Q extends 'custom_abi' ? CustomAbis : Q extends 'custom_abi' ? CustomAbis :
Q extends 'public_tags' ? PublicTags : Q extends 'public_tags' ? PublicTags :
...@@ -776,8 +797,24 @@ Q extends 'user_ops' ? UserOpsResponse : ...@@ -776,8 +797,24 @@ Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp : Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount : Q extends 'user_ops_account' ? UserOpsAccount :
never; never;
// !!! IMPORTANT !!!
// See comment above
/* eslint-enable @typescript-eslint/indent */ /* 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 */ /* eslint-disable @typescript-eslint/indent */
export type PaginationFilters<Q extends PaginatedResources> = export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters : Q extends 'blocks' ? BlockFilters :
......
import type { ArrayElement } from 'types/utils'; 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>( export function generateListStub<Resource extends PaginatedResources>(
stub: ArrayElement<PaginatedResponse<Resource>['items']>, stub: ArrayElement<PaginatedResponseItems<Resource>>,
num = 50, num = 50,
rest: Omit<PaginatedResponse<Resource>, 'items'>, rest: Omit<PaginatedResponse<Resource>, 'items'>,
) { ) {
......
...@@ -6,12 +6,12 @@ import { MarketplaceCategory } from 'types/client/marketplace'; ...@@ -6,12 +6,12 @@ import { MarketplaceCategory } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useApiFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import { MARKETPLACE_APP } from 'stubs/marketplace'; import { MARKETPLACE_APP } from 'stubs/marketplace';
const feature = config.features.marketplace; const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : '';
function isAppNameMatches(q: string, app: MarketplaceAppOverview) { function isAppNameMatches(q: string, app: MarketplaceAppOverview) {
return app.title.toLowerCase().includes(q.toLowerCase()); return app.title.toLowerCase().includes(q.toLowerCase());
...@@ -48,12 +48,21 @@ function sortApps(apps: Array<MarketplaceAppOverview>, isExperiment: boolean) { ...@@ -48,12 +48,21 @@ function sortApps(apps: Array<MarketplaceAppOverview>, isExperiment: boolean) {
} }
export default function useMarketplaceApps(filter: string, selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array<string> = []) { export default function useMarketplaceApps(filter: string, selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array<string> = []) {
const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const { value: isExperiment } = useFeatureValue('marketplace_exp', false); const { value: isExperiment } = useFeatureValue('marketplace_exp', false);
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({ const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({
queryKey: [ 'marketplace-apps' ], queryKey: [ 'marketplace-dapps' ],
queryFn: async() => apiFetch(configUrl, undefined, { resource: 'marketplace-apps' }), 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), select: (data) => sortApps(data as Array<MarketplaceAppOverview>, isExperiment),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined, placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity, staleTime: Infinity,
......
...@@ -10,8 +10,9 @@ import { route } from 'nextjs-routes'; ...@@ -10,8 +10,9 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; 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 * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
...@@ -20,7 +21,6 @@ import useAutoConnectWallet from '../marketplace/useAutoConnectWallet'; ...@@ -20,7 +21,6 @@ import useAutoConnectWallet from '../marketplace/useAutoConnectWallet';
import useMarketplaceWallet from '../marketplace/useMarketplaceWallet'; import useMarketplaceWallet from '../marketplace/useMarketplaceWallet';
const feature = config.features.marketplace; const feature = config.features.marketplace;
const configUrl = feature.isEnabled ? feature.configUrl : '';
const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' + const IFRAME_SANDBOX_ATTRIBUTE = 'allow-forms allow-orientation-lock ' +
'allow-pointer-lock allow-popups-to-escape-sandbox ' + 'allow-pointer-lock allow-popups-to-escape-sandbox ' +
...@@ -99,23 +99,29 @@ const MarketplaceApp = () => { ...@@ -99,23 +99,29 @@ const MarketplaceApp = () => {
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet(); const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet();
useAutoConnectWallet(); useAutoConnectWallet();
const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const router = useRouter(); const router = useRouter();
const id = getQueryParamString(router.query.id); const id = getQueryParamString(router.query.id);
const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({ const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ], queryKey: [ 'marketplace-dapps', id ],
queryFn: async() => { queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' }); if (!feature.isEnabled) {
if (!Array.isArray(result)) { return null;
throw result; } 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, enabled: feature.isEnabled,
}); });
......
...@@ -34,6 +34,14 @@ function getPaginationParamsFromQuery(queryString: string | Array<string> | unde ...@@ -34,6 +34,14 @@ function getPaginationParamsFromQuery(queryString: string | Array<string> | unde
return {}; 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> = export type QueryWithPagesResult<Resource extends PaginatedResources> =
UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> & UseQueryResult<ResourcePayload<Resource>, ResourceError<unknown>> &
{ {
...@@ -76,29 +84,30 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -76,29 +84,30 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}, },
}); });
const { data } = queryResult; const { data } = queryResult;
const nextPageParams = getNextPageParams(data);
const onNextPageClick = useCallback(() => { const onNextPageClick = useCallback(() => {
if (!data?.next_page_params) { if (!nextPageParams) {
// we hide next page button if no next_page_params // we hide next page button if no next_page_params
return; return;
} }
setPageParams((prev) => ({ setPageParams((prev) => ({
...prev, ...prev,
[page + 1]: data.next_page_params as NextPageParams, [page + 1]: nextPageParams as NextPageParams,
})); }));
setPage(prev => prev + 1); setPage(prev => prev + 1);
const nextPageQuery = { const nextPageQuery = {
...router.query, ...router.query,
page: String(page + 1), page: String(page + 1),
next_page_params: encodeURIComponent(JSON.stringify(data.next_page_params)), next_page_params: encodeURIComponent(JSON.stringify(nextPageParams)),
}; };
setHasPages(true); setHasPages(true);
scrollToTop(); scrollToTop();
router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true }); router.push({ pathname: router.pathname, query: nextPageQuery }, undefined, { shallow: true });
}, [ data?.next_page_params, page, router, scrollToTop ]); }, [ nextPageParams, page, router, scrollToTop ]);
const onPrevPageClick = useCallback(() => { const onPrevPageClick = useCallback(() => {
// returning to the first page // returning to the first page
...@@ -181,7 +190,6 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -181,7 +190,6 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}); });
}, [ router, scrollToTop ]); }, [ router, scrollToTop ]);
const nextPageParams = data?.next_page_params;
const hasNextPage = nextPageParams ? Object.keys(nextPageParams).length > 0 : false; const hasNextPage = nextPageParams ? Object.keys(nextPageParams).length > 0 : false;
const pagination = { 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