Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/markets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { generateMetadata } from '@/utils/generateMetadata';
import MarketContent from '@/features/markets/markets-view';
import { serializeSearchParamsRecord, type SearchParamsRecord } from '@/utils/search-params';

export const metadata = generateMetadata({
title: 'Markets | Monarch',
Expand All @@ -8,6 +9,11 @@ export const metadata = generateMetadata({
pathname: '',
});

export default function MarketPage() {
return <MarketContent />;
type MarketPageProps = {
searchParams: Promise<SearchParamsRecord>;
};

export default async function MarketPage({ searchParams }: MarketPageProps) {
const resolvedSearchParams = await searchParams;
return <MarketContent initialSearchParams={serializeSearchParamsRecord(resolvedSearchParams)} />;
}
13 changes: 6 additions & 7 deletions src/features/markets/components/filters/asset-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons';
import Image from 'next/image';
import { getMarketFilterAssetSelectionKey } from '@/features/markets/market-filter-selection';
import { cn } from '@/utils/components';
import { type ERC20Token, type UnknownERC20Token, infoToKey } from '@/utils/tokens';
import type { ERC20Token, UnknownERC20Token } from '@/utils/tokens';

type AssetFilterProps = {
label: string;
Expand Down Expand Up @@ -40,12 +41,10 @@ export default function AssetFilter({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

const getTokenKey = (token: ERC20Token | UnknownERC20Token) => token.networks.map((n) => infoToKey(n.address, n.chain.id)).join('|');

const toggleDropdown = () => setIsOpen(!isOpen);

const selectOption = (token: ERC20Token | UnknownERC20Token) => {
const tokenKey = getTokenKey(token);
const tokenKey = getMarketFilterAssetSelectionKey(token);
if (selectedAssets.includes(tokenKey)) {
setSelectedAssets(selectedAssets.filter((asset) => asset !== tokenKey));
} else {
Expand All @@ -67,7 +66,7 @@ export default function AssetFilter({
const newSelection = updateFromSearch
.map((symbol) => items.find((item) => item.symbol.toLowerCase() === symbol.toLowerCase()))
.filter(Boolean)
.map((token) => getTokenKey(token!));
.map((token) => getMarketFilterAssetSelectionKey(token!));
setSelectedAssets(newSelection);
}
}, [updateFromSearch, items, setSelectedAssets]);
Expand Down Expand Up @@ -115,7 +114,7 @@ export default function AssetFilter({
) : selectedAssets.length > 0 ? (
<div className="flex items-center gap-1">
{selectedAssets.slice(0, 3).map((asset) => {
const token = items.find((item) => getTokenKey(item) === asset);
const token = items.find((item) => getMarketFilterAssetSelectionKey(item) === asset);
return token ? <span key={asset}>{renderTokenIcon(token, 14)}</span> : null;
})}
{selectedAssets.length > 3 && <span className="text-xs text-secondary">+{selectedAssets.length - 3}</span>}
Expand Down Expand Up @@ -143,7 +142,7 @@ export default function AssetFilter({
role="listbox"
>
{filteredItems.map((token) => {
const tokenKey = getTokenKey(token);
const tokenKey = getMarketFilterAssetSelectionKey(token);
const isSelected = selectedAssets.includes(tokenKey);
return (
<li
Expand Down
46 changes: 46 additions & 0 deletions src/features/markets/market-filter-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { toChainAssetKey } from '@/utils/chain-asset-key';
import type { SupportedNetworks } from '@/utils/supported-networks';
import type { ERC20Token, UnknownERC20Token } from '@/utils/tokens';

export type MarketFilterAsset = ERC20Token | UnknownERC20Token;

export type MarketFilterTokenSelector =
| {
address: string;
chainId: SupportedNetworks;
kind: 'chain-address';
}
| {
kind: 'symbol';
symbol: string;
};

export const getMarketFilterAssetSelectionKey = (token: MarketFilterAsset): string => {
return token.networks.map((network) => toChainAssetKey(network.address, network.chain.id)).join('|');
};

export const marketFilterSelectionIncludesAsset = (selectionKey: string, address: string, chainId: number): boolean => {
return selectionKey.split('|').includes(toChainAssetKey(address, chainId));
};

const matchesTokenSelector = (token: MarketFilterAsset, selector: MarketFilterTokenSelector): boolean => {
if (selector.kind === 'symbol') {
return token.symbol.toLowerCase() === selector.symbol;
}

return token.networks.some((network) => network.chain.id === selector.chainId && network.address.toLowerCase() === selector.address);
};

export const resolveMarketFilterTokenSelectors = (selectors: MarketFilterTokenSelector[], items: MarketFilterAsset[]): string[] => {
const selectedKeys = new Set<string>();

for (const selector of selectors) {
for (const item of items) {
if (matchesTokenSelector(item, selector)) {
selectedKeys.add(getMarketFilterAssetSelectionKey(item));
}
}
}

return [...selectedKeys];
};
100 changes: 100 additions & 0 deletions src/features/markets/market-filter-url-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks';

type SearchParamsLike = Pick<URLSearchParams, 'get' | 'has'>;

export type MarketFilterUrlState = {
selectedNetwork?: SupportedNetworks | null;
signature: string | null;
};

const CLEAR_VALUES = new Set(['all', 'clear', 'none']);

const NETWORK_ALIASES: Record<string, SupportedNetworks> = {
arb: SupportedNetworks.Arbitrum,
arbitrum: SupportedNetworks.Arbitrum,
base: SupportedNetworks.Base,
etherlink: SupportedNetworks.Etherlink,
ethereum: SupportedNetworks.Mainnet,
eth: SupportedNetworks.Mainnet,
'hyper-evm': SupportedNetworks.HyperEVM,
hyperevm: SupportedNetworks.HyperEVM,
mainnet: SupportedNetworks.Mainnet,
monad: SupportedNetworks.Monad,
op: SupportedNetworks.Optimism,
optimism: SupportedNetworks.Optimism,
polygon: SupportedNetworks.Polygon,
unichain: SupportedNetworks.Unichain,
};

const REF_NETWORK_ALIASES: Record<string, SupportedNetworks> = {
etherlink: SupportedNetworks.Etherlink,
};

const normalizeParamValue = (value: string): string => value.trim().toLowerCase();

export const resolveSupportedNetworkPreference = (rawValue: string): SupportedNetworks | null | undefined => {
const normalizedValue = normalizeParamValue(rawValue);

if (!normalizedValue) {
return undefined;
}

if (CLEAR_VALUES.has(normalizedValue)) {
return null;
}

if (/^\d+$/.test(normalizedValue)) {
const chainId = Number(normalizedValue);
return isSupportedNetwork(chainId) ? chainId : undefined;
}

return NETWORK_ALIASES[normalizedValue];
};

const parseExplicitNetworkPreference = (searchParams: SearchParamsLike): SupportedNetworks | null | undefined => {
for (const key of ['network', 'chain']) {
if (!searchParams.has(key)) {
continue;
}

const value = searchParams.get(key);
if (!value) {
continue;
}

const resolvedNetwork = resolveSupportedNetworkPreference(value);
if (resolvedNetwork !== undefined) {
return resolvedNetwork;
}
}

return undefined;
};

const resolveReferralNetworkPreference = (searchParams: SearchParamsLike): SupportedNetworks | undefined => {
const refValue = searchParams.get('ref');
if (!refValue) {
return undefined;
}

return REF_NETWORK_ALIASES[normalizeParamValue(refValue)];
};

const createMarketFilterUrlStateSignature = (state: Omit<MarketFilterUrlState, 'signature'>): string | null => {
if (state.selectedNetwork === undefined) {
return null;
}

return `network:${state.selectedNetwork ?? 'all'}`;
};

export const parseMarketFilterUrlState = (searchParams: SearchParamsLike): MarketFilterUrlState => {
const nextState: Omit<MarketFilterUrlState, 'signature'> = {
selectedNetwork: parseExplicitNetworkPreference(searchParams) ?? resolveReferralNetworkPreference(searchParams),
};

return {
...nextState,
signature: createMarketFilterUrlStateSignature(nextState),
};
};
55 changes: 43 additions & 12 deletions src/features/markets/markets-view.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Chain } from 'viem';

import Header from '@/components/layout/header/Header';
import { Breadcrumbs } from '@/components/shared/breadcrumbs';
import { parseMarketFilterUrlState } from '@/features/markets/market-filter-url-state';
import { useFilteredMarkets } from '@/hooks/useFilteredMarkets';
import { useTokensQuery } from '@/hooks/queries/useTokensQuery';
import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery';
import { useMarketFilterPreferences } from '@/stores/useMarketFilterPreferences';
import { useMarketsFilters } from '@/stores/useMarketsFilters';
import { usePagination } from '@/hooks/usePagination';
import { useStyledToast } from '@/hooks/useStyledToast';
Expand All @@ -16,14 +18,21 @@ import type { ERC20Token, UnknownERC20Token } from '@/utils/tokens';
import { CompactFilterBar } from './components/filters/compact-filter-bar';
import MarketsTable from './components/table/markets-table';

export default function Markets() {
type MarketsProps = {
initialSearchParams: string;
};

export default function Markets({ initialSearchParams }: MarketsProps) {
const toast = useStyledToast();
const appliedUrlSignatureRef = useRef<string | null>(null);

// Data fetching with React Query
const { data: rawMarkets, isLoading: loading, refetch } = useMarketsQuery();
const { markets, isLoading: filteredMarketsLoading, isWhitelistUnavailable } = useFilteredMarkets();

const filters = useMarketsFilters();
const persistedFilters = useMarketFilterPreferences();
const urlFilterState = useMemo(() => parseMarketFilterUrlState(new URLSearchParams(initialSearchParams)), [initialSearchParams]);

// UI state
const [uniqueCollaterals, setUniqueCollaterals] = useState<(ERC20Token | UnknownERC20Token)[]>([]);
Expand Down Expand Up @@ -115,13 +124,30 @@ export default function Markets() {
setUniqueLoanAssets(processTokens(loanList));
}, [rawMarkets, includeUnknownTokens, allTokens]);

useLayoutEffect(() => {
if (!urlFilterState.signature) {
appliedUrlSignatureRef.current = null;
return;
}

if (appliedUrlSignatureRef.current === urlFilterState.signature) {
return;
}

if (urlFilterState.selectedNetwork !== undefined && urlFilterState.selectedNetwork !== persistedFilters.selectedNetwork) {
persistedFilters.applySelections({ selectedNetwork: urlFilterState.selectedNetwork });
}

appliedUrlSignatureRef.current = urlFilterState.signature;
}, [persistedFilters, urlFilterState.selectedNetwork, urlFilterState.signature]);

// Reset page when filters change
useEffect(() => {
resetPage();
}, [
filters.selectedNetwork,
filters.selectedCollaterals,
filters.selectedLoanAssets,
persistedFilters.selectedNetwork,
persistedFilters.selectedCollaterals,
persistedFilters.selectedLoanAssets,
filters.selectedOracles,
filters.searchQuery,
resetPage,
Expand Down Expand Up @@ -149,6 +175,11 @@ export default function Markets() {
refetch().then(() => toast.success('Markets refreshed', 'Markets refreshed successfully'));
}, [refetch, toast]);

const handleClearAll = useCallback(() => {
persistedFilters.reset();
filters.resetFilters();
}, [filters, persistedFilters]);

return (
<>
<div className="flex w-full flex-col justify-between font-zen">
Expand All @@ -168,18 +199,18 @@ export default function Markets() {
<CompactFilterBar
searchQuery={filters.searchQuery}
onSearch={filters.setSearchQuery}
selectedNetwork={filters.selectedNetwork}
setSelectedNetwork={filters.setSelectedNetwork}
selectedLoanAssets={filters.selectedLoanAssets}
setSelectedLoanAssets={filters.setSelectedLoanAssets}
selectedNetwork={persistedFilters.selectedNetwork}
setSelectedNetwork={persistedFilters.setSelectedNetwork}
selectedLoanAssets={persistedFilters.selectedLoanAssets}
setSelectedLoanAssets={persistedFilters.setSelectedLoanAssets}
loanAssetItems={uniqueLoanAssets}
selectedCollaterals={filters.selectedCollaterals}
setSelectedCollaterals={filters.setSelectedCollaterals}
selectedCollaterals={persistedFilters.selectedCollaterals}
setSelectedCollaterals={persistedFilters.setSelectedCollaterals}
collateralItems={uniqueCollaterals}
selectedOracles={filters.selectedOracles}
setSelectedOracles={filters.setSelectedOracles}
loading={loading}
onClearAll={filters.resetFilters}
onClearAll={handleClearAll}
/>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions src/hooks/useFilteredMarkets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useProcessedMarkets } from '@/hooks/useProcessedMarkets';
import { useMarketFilterPreferences } from '@/stores/useMarketFilterPreferences';
import { useMorphoWhitelistStatusQuery } from '@/hooks/queries/useMorphoWhitelistStatusQuery';
import { useAllOracleMetadata } from '@/hooks/useOracleMetadata';
import { useMarketsFilters } from '@/stores/useMarketsFilters';
Expand All @@ -21,6 +22,7 @@ type UseFilteredMarketsResult = {

export const useFilteredMarkets = (): UseFilteredMarketsResult => {
const preferences = useMarketPreferences();
const persistedFilters = useMarketFilterPreferences();
const { allMarkets, whitelistedMarkets } = useProcessedMarkets();
const { whitelistLookup, isLoading: whitelistLoading, isFetching: whitelistFetching } = useMorphoWhitelistStatusQuery();
const { data: oracleMetadataMap } = useAllOracleMetadata();
Expand All @@ -41,12 +43,12 @@ export const useFilteredMarkets = (): UseFilteredMarketsResult => {
if (filteredMarkets.length === 0) return [];

filteredMarkets = filterMarkets(filteredMarkets, {
selectedNetwork: filters.selectedNetwork,
selectedNetwork: persistedFilters.selectedNetwork,
showUnknownTokens: preferences.includeUnknownTokens,
showUnknownOracle: preferences.showUnknownOracle,
showLockedMarkets: preferences.showLockedMarkets,
selectedCollaterals: filters.selectedCollaterals,
selectedLoanAssets: filters.selectedLoanAssets,
selectedCollaterals: persistedFilters.selectedCollaterals,
selectedLoanAssets: persistedFilters.selectedLoanAssets,
selectedOracles: filters.selectedOracles,
usdFilters: {
minSupply: {
Expand Down Expand Up @@ -154,6 +156,7 @@ export const useFilteredMarkets = (): UseFilteredMarketsResult => {
shouldBlockWhitelistedFiltering,
showUnwhitelistedMarkets,
filters,
persistedFilters,
preferences,
trustedVaults,
findToken,
Expand Down
Loading