From d9cf6c135a849caa8b94ff7d531946cbf53fc30e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 27 Apr 2026 14:04:20 +0800 Subject: [PATCH 1/4] feat: etherlink ref link --- app/etherlink/page.tsx | 5 + app/markets/page.tsx | 10 +- app/page.tsx | 18 +- .../components/filters/asset-filter.tsx | 13 +- .../markets/market-filter-selection.ts | 46 ++++ .../markets/market-filter-url-state.test.ts | 121 +++++++++++ .../markets/market-filter-url-state.ts | 203 ++++++++++++++++++ src/features/markets/markets-view.tsx | 125 +++++++++-- src/hooks/useFilteredMarkets.ts | 9 +- src/stores/useMarketFilterPreferences.ts | 58 +++++ src/stores/useMarketsFilters.ts | 22 +- src/utils/chain-asset-key.ts | 3 + src/utils/marketFilters.ts | 9 +- src/utils/networks.ts | 66 ++---- src/utils/search-params.ts | 22 ++ src/utils/supported-networks.ts | 27 +++ src/utils/tokens.ts | 5 +- 17 files changed, 668 insertions(+), 94 deletions(-) create mode 100644 app/etherlink/page.tsx create mode 100644 src/features/markets/market-filter-selection.ts create mode 100644 src/features/markets/market-filter-url-state.test.ts create mode 100644 src/features/markets/market-filter-url-state.ts create mode 100644 src/stores/useMarketFilterPreferences.ts create mode 100644 src/utils/chain-asset-key.ts create mode 100644 src/utils/search-params.ts create mode 100644 src/utils/supported-networks.ts diff --git a/app/etherlink/page.tsx b/app/etherlink/page.tsx new file mode 100644 index 00000000..bc287b47 --- /dev/null +++ b/app/etherlink/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function EtherlinkPage() { + redirect('/markets?network=etherlink&ref=etherlink'); +} diff --git a/app/markets/page.tsx b/app/markets/page.tsx index b3d979d0..8b03984b 100644 --- a/app/markets/page.tsx +++ b/app/markets/page.tsx @@ -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', @@ -8,6 +9,11 @@ export const metadata = generateMetadata({ pathname: '', }); -export default function MarketPage() { - return ; +type MarketPageProps = { + searchParams: Promise; +}; + +export default async function MarketPage({ searchParams }: MarketPageProps) { + const resolvedSearchParams = await searchParams; + return ; } diff --git a/app/page.tsx b/app/page.tsx index 80c7feae..8409848a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,6 @@ import { generateMetadata } from '@/utils/generateMetadata'; +import { serializeSearchParamsRecord, type SearchParamsRecord } from '@/utils/search-params'; +import { redirect } from 'next/navigation'; import HomePage from '../src/features/home/home-view'; export const metadata = generateMetadata({ @@ -14,6 +16,20 @@ export const metadata = generateMetadata({ * https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#step-4-migrating-pages * https://nextjs.org/docs/app/building-your-application/rendering/client-components */ -export default function Page() { +const MARKET_INTENT_QUERY_KEYS = new Set(['network', 'chain', 'ref', 'loan', 'collateral']); + +type HomePageProps = { + searchParams: Promise; +}; + +export default async function Page({ searchParams }: HomePageProps) { + const resolvedSearchParams = await searchParams; + const hasMarketIntent = Object.keys(resolvedSearchParams).some((key) => MARKET_INTENT_QUERY_KEYS.has(key)); + + if (hasMarketIntent) { + const nextSearchParams = serializeSearchParamsRecord(resolvedSearchParams); + redirect(nextSearchParams ? `/markets?${nextSearchParams}` : '/markets'); + } + return ; } diff --git a/src/features/markets/components/filters/asset-filter.tsx b/src/features/markets/components/filters/asset-filter.tsx index ec819782..5462d35b 100644 --- a/src/features/markets/components/filters/asset-filter.tsx +++ b/src/features/markets/components/filters/asset-filter.tsx @@ -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; @@ -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 { @@ -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]); @@ -115,7 +114,7 @@ export default function AssetFilter({ ) : selectedAssets.length > 0 ? (
{selectedAssets.slice(0, 3).map((asset) => { - const token = items.find((item) => getTokenKey(item) === asset); + const token = items.find((item) => getMarketFilterAssetSelectionKey(item) === asset); return token ? {renderTokenIcon(token, 14)} : null; })} {selectedAssets.length > 3 && +{selectedAssets.length - 3}} @@ -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 (
  • { + 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(); + + for (const selector of selectors) { + for (const item of items) { + if (matchesTokenSelector(item, selector)) { + selectedKeys.add(getMarketFilterAssetSelectionKey(item)); + } + } + } + + return [...selectedKeys]; +}; diff --git a/src/features/markets/market-filter-url-state.test.ts b/src/features/markets/market-filter-url-state.test.ts new file mode 100644 index 00000000..c394d32c --- /dev/null +++ b/src/features/markets/market-filter-url-state.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Chain } from 'viem'; +import { getMarketFilterAssetSelectionKey } from '@/features/markets/market-filter-selection'; +import { + parseMarketFilterTokenSelector, + parseMarketFilterUrlState, + resolveMarketFilterSelectionsFromUrlState, + resolveSupportedNetworkPreference, +} from '@/features/markets/market-filter-url-state'; +import { SupportedNetworks } from '@/utils/supported-networks'; + +const mainnet = { id: SupportedNetworks.Mainnet } as Chain; +const base = { id: SupportedNetworks.Base } as Chain; +const etherlink = { id: SupportedNetworks.Etherlink } as Chain; + +const tokenItems = [ + { + symbol: 'USDC', + img: undefined, + decimals: 6, + networks: [ + { chain: mainnet, address: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, + { chain: base, address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' }, + ], + }, + { + symbol: 'XTZ', + img: undefined, + decimals: 18, + networks: [{ chain: etherlink, address: '0xfc24f770F94edBca6D6f885E12d4317320BcB401' }], + }, +]; + +test('resolveSupportedNetworkPreference handles aliases, chain ids, and clear values', () => { + assert.equal(resolveSupportedNetworkPreference('etherlink'), SupportedNetworks.Etherlink); + assert.equal(resolveSupportedNetworkPreference('42793'), SupportedNetworks.Etherlink); + assert.equal(resolveSupportedNetworkPreference('all'), null); + assert.equal(resolveSupportedNetworkPreference('unknown-chain'), undefined); +}); + +test('parseMarketFilterTokenSelector supports canonical chain-address and symbol selectors', () => { + assert.deepEqual(parseMarketFilterTokenSelector('8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'), { + kind: 'chain-address', + chainId: SupportedNetworks.Base, + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + }); + + assert.deepEqual(parseMarketFilterTokenSelector('symbol:usdc'), { + kind: 'symbol', + symbol: 'usdc', + }); + + assert.deepEqual(parseMarketFilterTokenSelector('xtz'), { + kind: 'symbol', + symbol: 'xtz', + }); +}); + +test('parseMarketFilterUrlState prefers explicit network params and falls back to ref aliases', () => { + const explicitUrlState = parseMarketFilterUrlState( + new URLSearchParams([ + ['network', 'base'], + ['ref', 'etherlink'], + ['loan', '8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'], + ['collateral', 'symbol:xtz'], + ]), + ); + + assert.equal(explicitUrlState.selectedNetwork, SupportedNetworks.Base); + assert.deepEqual(explicitUrlState.selectedLoanSelectors, [ + { + kind: 'chain-address', + chainId: SupportedNetworks.Base, + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + }, + ]); + assert.deepEqual(explicitUrlState.selectedCollateralSelectors, [ + { + kind: 'symbol', + symbol: 'xtz', + }, + ]); + assert.equal(explicitUrlState.signature, 'network:8453|loan:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913|collateral:symbol:xtz'); + + const referralUrlState = parseMarketFilterUrlState(new URLSearchParams([['ref', 'etherlink']])); + assert.equal(referralUrlState.selectedNetwork, SupportedNetworks.Etherlink); + assert.equal(referralUrlState.signature, 'network:42793'); +}); + +test('parseMarketFilterUrlState treats empty or clear token params as explicit resets', () => { + const urlState = parseMarketFilterUrlState( + new URLSearchParams([ + ['loan', 'all'], + ['collateral', ''], + ]), + ); + + assert.deepEqual(urlState.selectedLoanSelectors, []); + assert.deepEqual(urlState.selectedCollateralSelectors, []); + assert.equal(urlState.signature, 'loan:all|collateral:all'); +}); + +test('resolveMarketFilterSelectionsFromUrlState maps selectors onto persisted asset keys', () => { + const selections = resolveMarketFilterSelectionsFromUrlState( + [ + { + kind: 'chain-address', + chainId: SupportedNetworks.Base, + address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + }, + { + kind: 'symbol', + symbol: 'xtz', + }, + ], + tokenItems, + ); + + assert.deepEqual(selections, [getMarketFilterAssetSelectionKey(tokenItems[0]), getMarketFilterAssetSelectionKey(tokenItems[1])]); +}); diff --git a/src/features/markets/market-filter-url-state.ts b/src/features/markets/market-filter-url-state.ts new file mode 100644 index 00000000..a9c736f1 --- /dev/null +++ b/src/features/markets/market-filter-url-state.ts @@ -0,0 +1,203 @@ +import { type MarketFilterTokenSelector, type MarketFilterAsset, resolveMarketFilterTokenSelectors } from './market-filter-selection'; +import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; + +type SearchParamsLike = Pick; + +export type MarketFilterUrlState = { + selectedCollateralSelectors?: MarketFilterTokenSelector[]; + selectedLoanSelectors?: MarketFilterTokenSelector[]; + selectedNetwork?: SupportedNetworks | null; + signature: string | null; +}; + +const CLEAR_VALUES = new Set(['all', 'clear', 'none']); + +const NETWORK_ALIASES: Record = { + 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 = { + etherlink: SupportedNetworks.Etherlink, +}; + +const normalizeParamValue = (value: string): string => value.trim().toLowerCase(); + +const splitSearchParamValues = (searchParams: SearchParamsLike, key: string): string[] => { + return searchParams + .getAll(key) + .flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter(Boolean); +}; + +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]; +}; + +export const parseMarketFilterTokenSelector = (rawValue: string): MarketFilterTokenSelector | null => { + const trimmedValue = rawValue.trim(); + if (!trimmedValue) { + return null; + } + + const normalizedValue = normalizeParamValue(trimmedValue); + if (CLEAR_VALUES.has(normalizedValue)) { + return null; + } + + if (normalizedValue.startsWith('symbol:')) { + const symbol = normalizeParamValue(trimmedValue.slice(trimmedValue.indexOf(':') + 1)); + return symbol ? { kind: 'symbol', symbol } : null; + } + + const [networkOrChainId, address] = trimmedValue.split(':', 2); + if (address) { + const chainId = resolveSupportedNetworkPreference(networkOrChainId); + if (chainId === null || chainId === undefined || !/^0x[a-fA-F0-9]{40}$/.test(address)) { + return null; + } + + return { + kind: 'chain-address', + chainId, + address: address.toLowerCase(), + }; + } + + return { + kind: 'symbol', + symbol: normalizedValue, + }; +}; + +const parseTokenSelectorsParam = (searchParams: SearchParamsLike, key: string): MarketFilterTokenSelector[] | undefined => { + if (!searchParams.has(key)) { + return undefined; + } + + const rawValues = splitSearchParamValues(searchParams, key); + if (rawValues.length === 0 || rawValues.some((value) => CLEAR_VALUES.has(normalizeParamValue(value)))) { + return []; + } + + const selectors = rawValues + .map((value) => parseMarketFilterTokenSelector(value)) + .filter((value): value is MarketFilterTokenSelector => value !== null); + + return selectors.length > 0 ? selectors : undefined; +}; + +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 serializeTokenSelector = (selector: MarketFilterTokenSelector): string => { + if (selector.kind === 'symbol') { + return `symbol:${selector.symbol}`; + } + + return `${selector.chainId}:${selector.address}`; +}; + +const createMarketFilterUrlStateSignature = (state: Omit): string | null => { + const signatureParts: string[] = []; + + if (state.selectedNetwork !== undefined) { + signatureParts.push(`network:${state.selectedNetwork ?? 'all'}`); + } + + if (state.selectedLoanSelectors !== undefined) { + const serializedLoanSelectors = state.selectedLoanSelectors.map(serializeTokenSelector).sort().join(','); + signatureParts.push(`loan:${serializedLoanSelectors || 'all'}`); + } + + if (state.selectedCollateralSelectors !== undefined) { + const serializedCollateralSelectors = state.selectedCollateralSelectors.map(serializeTokenSelector).sort().join(','); + signatureParts.push(`collateral:${serializedCollateralSelectors || 'all'}`); + } + + return signatureParts.length > 0 ? signatureParts.join('|') : null; +}; + +export const parseMarketFilterUrlState = (searchParams: SearchParamsLike): MarketFilterUrlState => { + const explicitNetworkPreference = parseExplicitNetworkPreference(searchParams); + + const nextState: Omit = { + selectedNetwork: explicitNetworkPreference ?? resolveReferralNetworkPreference(searchParams), + selectedLoanSelectors: parseTokenSelectorsParam(searchParams, 'loan'), + selectedCollateralSelectors: parseTokenSelectorsParam(searchParams, 'collateral'), + }; + + return { + ...nextState, + signature: createMarketFilterUrlStateSignature(nextState), + }; +}; + +export const resolveMarketFilterSelectionsFromUrlState = ( + selectors: MarketFilterTokenSelector[] | undefined, + items: MarketFilterAsset[], +): string[] | undefined => { + if (selectors === undefined) { + return undefined; + } + + if (selectors.length === 0) { + return []; + } + + return resolveMarketFilterTokenSelectors(selectors, items); +}; diff --git a/src/features/markets/markets-view.tsx b/src/features/markets/markets-view.tsx index 70c95fdd..5bf12303 100644 --- a/src/features/markets/markets-view.tsx +++ b/src/features/markets/markets-view.tsx @@ -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, resolveMarketFilterSelectionsFromUrlState } 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'; @@ -16,14 +18,29 @@ 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() { +const haveSameSelections = (left: string[], right: string[]): boolean => { + if (left.length !== right.length) { + return false; + } + + return left.every((value) => right.includes(value)); +}; + +type MarketsProps = { + initialSearchParams: string; +}; + +export default function Markets({ initialSearchParams }: MarketsProps) { const toast = useStyledToast(); + const appliedUrlSignatureRef = useRef(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)[]>([]); @@ -34,6 +51,37 @@ export default function Markets() { const { currentPage, setCurrentPage, resetPage } = usePagination(); const { allTokens } = useTokensQuery(); const { tableViewMode, includeUnknownTokens } = useMarketPreferences(); + const resolvedUrlLoanSelections = useMemo(() => { + if (urlFilterState.selectedLoanSelectors === undefined) { + return undefined; + } + + if (urlFilterState.selectedLoanSelectors.length === 0) { + return []; + } + + if (loading) { + return null; + } + + return resolveMarketFilterSelectionsFromUrlState(urlFilterState.selectedLoanSelectors, uniqueLoanAssets); + }, [loading, uniqueLoanAssets, urlFilterState.selectedLoanSelectors]); + + const resolvedUrlCollateralSelections = useMemo(() => { + if (urlFilterState.selectedCollateralSelectors === undefined) { + return undefined; + } + + if (urlFilterState.selectedCollateralSelectors.length === 0) { + return []; + } + + if (loading) { + return null; + } + + return resolveMarketFilterSelectionsFromUrlState(urlFilterState.selectedCollateralSelectors, uniqueCollaterals); + }, [loading, uniqueCollaterals, urlFilterState.selectedCollateralSelectors]); // Force compact mode on mobile useEffect(() => { @@ -115,13 +163,61 @@ 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 (resolvedUrlLoanSelections === null || resolvedUrlCollateralSelections === null) { + return; + } + + const nextSelections: { + selectedCollaterals?: string[]; + selectedLoanAssets?: string[]; + selectedNetwork?: typeof persistedFilters.selectedNetwork; + } = {}; + + if (urlFilterState.selectedNetwork !== undefined && urlFilterState.selectedNetwork !== persistedFilters.selectedNetwork) { + nextSelections.selectedNetwork = urlFilterState.selectedNetwork; + } + + if (resolvedUrlLoanSelections !== undefined && !haveSameSelections(resolvedUrlLoanSelections, persistedFilters.selectedLoanAssets)) { + nextSelections.selectedLoanAssets = resolvedUrlLoanSelections; + } + + if ( + resolvedUrlCollateralSelections !== undefined && + !haveSameSelections(resolvedUrlCollateralSelections, persistedFilters.selectedCollaterals) + ) { + nextSelections.selectedCollaterals = resolvedUrlCollateralSelections; + } + + if (Object.keys(nextSelections).length > 0) { + persistedFilters.applySelections(nextSelections); + } + + appliedUrlSignatureRef.current = urlFilterState.signature; + }, [ + persistedFilters, + resolvedUrlCollateralSelections, + resolvedUrlLoanSelections, + 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, @@ -149,6 +245,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 ( <>
    @@ -168,18 +269,18 @@ export default function Markets() {
  • diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index b825c905..5b76c0ff 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -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'; @@ -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(); @@ -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: { @@ -154,6 +156,7 @@ export const useFilteredMarkets = (): UseFilteredMarketsResult => { shouldBlockWhitelistedFiltering, showUnwhitelistedMarkets, filters, + persistedFilters, preferences, trustedVaults, findToken, diff --git a/src/stores/useMarketFilterPreferences.ts b/src/stores/useMarketFilterPreferences.ts new file mode 100644 index 00000000..8d2de3d4 --- /dev/null +++ b/src/stores/useMarketFilterPreferences.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { SupportedNetworks } from '@/utils/supported-networks'; + +export type MarketFilterPreferencesState = { + selectedCollaterals: string[]; + selectedLoanAssets: string[]; + selectedNetwork: SupportedNetworks | null; +}; + +type MarketFilterPreferencesActions = { + applySelections: (state: Partial) => void; + reset: () => void; + setSelectedCollaterals: (collaterals: string[]) => void; + setSelectedLoanAssets: (assets: string[]) => void; + setSelectedNetwork: (network: SupportedNetworks | null) => void; +}; + +type MarketFilterPreferencesStore = MarketFilterPreferencesState & MarketFilterPreferencesActions; + +const dedupeSelections = (values: string[]) => [...new Set(values)]; + +const DEFAULT_STATE: MarketFilterPreferencesState = { + selectedCollaterals: [], + selectedLoanAssets: [], + selectedNetwork: null, +}; + +export const useMarketFilterPreferences = create()( + persist( + (set) => ({ + ...DEFAULT_STATE, + + setSelectedCollaterals: (collaterals) => set({ selectedCollaterals: dedupeSelections(collaterals) }), + setSelectedLoanAssets: (assets) => set({ selectedLoanAssets: dedupeSelections(assets) }), + setSelectedNetwork: (network) => set({ selectedNetwork: network }), + applySelections: (state) => + set((currentState) => ({ + selectedNetwork: 'selectedNetwork' in state ? (state.selectedNetwork ?? null) : currentState.selectedNetwork, + selectedLoanAssets: + 'selectedLoanAssets' in state ? dedupeSelections(state.selectedLoanAssets ?? []) : currentState.selectedLoanAssets, + selectedCollaterals: + 'selectedCollaterals' in state ? dedupeSelections(state.selectedCollaterals ?? []) : currentState.selectedCollaterals, + })), + reset: () => set(DEFAULT_STATE), + }), + { + name: 'monarch_store_marketFilterPreferences', + version: 1, + migrate: (state) => { + if (!state || typeof state !== 'object') { + return DEFAULT_STATE; + } + return { ...DEFAULT_STATE, ...(state as Partial) }; + }, + }, + ), +); diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index dfb23fa6..667fc2a8 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -1,18 +1,14 @@ import { create } from 'zustand'; -import type { SupportedNetworks } from '@/utils/networks'; import type { PriceFeedVendors } from '@/utils/oracle'; /** - * Temporary filter state for markets page (resets on refresh for lightning-fast UX). + * Session-only filter state for the markets page. * - * Separation from useMarketPreferences: - * - useMarketPreferences: UI preferences (sort, view mode, USD thresholds, column visibility) - persisted - * - useMarketsFilters: Data filters (which assets/network to view, search query) - temporary + * Persisted market selections (network, loan asset, collateral) live in + * useMarketFilterPreferences. This store holds the transient filters that + * should reset naturally between sessions or when the user clears the page. */ type MarketsFiltersState = { - selectedCollaterals: string[]; - selectedLoanAssets: string[]; - selectedNetwork: SupportedNetworks | null; selectedOracles: PriceFeedVendors[]; searchQuery: string; trendingMode: boolean; // Official trending filter (backend-computed) @@ -21,9 +17,6 @@ type MarketsFiltersState = { }; type MarketsFiltersActions = { - setSelectedCollaterals: (collaterals: string[]) => void; - setSelectedLoanAssets: (assets: string[]) => void; - setSelectedNetwork: (network: SupportedNetworks | null) => void; setSelectedOracles: (oracles: PriceFeedVendors[]) => void; setSearchQuery: (query: string) => void; toggleTrendingMode: () => void; @@ -35,9 +28,6 @@ type MarketsFiltersActions = { type MarketsFiltersStore = MarketsFiltersState & MarketsFiltersActions; const DEFAULT_STATE: MarketsFiltersState = { - selectedCollaterals: [], - selectedLoanAssets: [], - selectedNetwork: null, selectedOracles: [], searchQuery: '', trendingMode: false, @@ -50,16 +40,12 @@ const DEFAULT_STATE: MarketsFiltersState = { * * @example * ```tsx - * const { selectedCollaterals, setSelectedCollaterals } = useMarketsFilters(); * const { searchQuery, setSearchQuery, resetFilters } = useMarketsFilters(); * ``` */ export const useMarketsFilters = create()((set) => ({ ...DEFAULT_STATE, - setSelectedCollaterals: (collaterals) => set({ selectedCollaterals: [...new Set(collaterals)] }), - setSelectedLoanAssets: (assets) => set({ selectedLoanAssets: [...new Set(assets)] }), - setSelectedNetwork: (network) => set({ selectedNetwork: network }), setSelectedOracles: (oracles) => set({ selectedOracles: oracles }), setSearchQuery: (query) => set({ searchQuery: query }), toggleTrendingMode: () => set((state) => ({ trendingMode: !state.trendingMode })), diff --git a/src/utils/chain-asset-key.ts b/src/utils/chain-asset-key.ts new file mode 100644 index 00000000..aff23359 --- /dev/null +++ b/src/utils/chain-asset-key.ts @@ -0,0 +1,3 @@ +export const toChainAssetKey = (address: string, chainId: number): string => { + return `${address.toLowerCase()}-${chainId}`; +}; diff --git a/src/utils/marketFilters.ts b/src/utils/marketFilters.ts index edf3a50f..df02b551 100644 --- a/src/utils/marketFilters.ts +++ b/src/utils/marketFilters.ts @@ -7,6 +7,7 @@ import { LOCKED_MARKET_APY_THRESHOLD } from '@/constants/markets'; import type { OracleMetadataRecord } from '@/hooks/useOracleMetadata'; +import { marketFilterSelectionIncludesAsset } from '@/features/markets/market-filter-selection'; import { parseNumericThreshold } from '@/utils/markets'; import type { SupportedNetworks } from '@/utils/networks'; import { type PriceFeedVendors, getOracleType, getOracleVendorInfo, OracleType } from '@/utils/oracle'; @@ -135,8 +136,8 @@ export const createCollateralFilter = (selectedCollaterals: string[]): MarketFil return () => true; } return (market) => { - return selectedCollaterals.some((combinedKey) => - combinedKey.split('|').includes(`${market.collateralAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`), + return selectedCollaterals.some((selectionKey) => + marketFilterSelectionIncludesAsset(selectionKey, market.collateralAsset.address, market.morphoBlue.chain.id), ); }; }; @@ -149,8 +150,8 @@ export const createLoanAssetFilter = (selectedLoanAssets: string[]): MarketFilte return () => true; } return (market) => { - return selectedLoanAssets.some((combinedKey) => - combinedKey.split('|').includes(`${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`), + return selectedLoanAssets.some((selectionKey) => + marketFilterSelectionIncludesAsset(selectionKey, market.loanAsset.address, market.morphoBlue.chain.id), ); }; }; diff --git a/src/utils/networks.ts b/src/utils/networks.ts index a9e0bb3f..246d4892 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -10,8 +10,10 @@ import { unichain, hyperEvm as hyperEvmOld, } from 'viem/chains'; +import { isSupportedNetwork as isSupportedNetworkValue, SupportedNetworks as SupportedNetworkId } from './supported-networks'; import { v2AgentsBase } from './monarch-agent'; import type { AgentMetadata } from './types'; +export { ALL_SUPPORTED_NETWORKS, SupportedNetworks, isSupportedNetwork } from './supported-networks'; const alchemyKey = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY; const rpcPriority = process.env.NEXT_PUBLIC_RPC_PRIORITY; @@ -37,30 +39,6 @@ const getRpcUrl = (specificRpcUrl: string | undefined, alchemySubdomain?: string return targetRpc ?? alchemyUrl ?? ''; }; -export enum SupportedNetworks { - Mainnet = 1, - Optimism = 10, - Base = 8453, - Polygon = 137, - Unichain = 130, - Arbitrum = 42_161, - Etherlink = 42_793, - HyperEVM = 999, - Monad = 143, -} - -export const ALL_SUPPORTED_NETWORKS = [ - SupportedNetworks.Mainnet, - SupportedNetworks.Optimism, - SupportedNetworks.Base, - SupportedNetworks.Polygon, - SupportedNetworks.Unichain, - SupportedNetworks.Arbitrum, - SupportedNetworks.Etherlink, - SupportedNetworks.HyperEVM, - SupportedNetworks.Monad, -]; - // use hyperevm as custom chain export const hyperEvm = defineChain({ ...hyperEvmOld, @@ -80,7 +58,7 @@ type VaultAgentConfig = { }; type NetworkConfig = { - network: SupportedNetworks; + network: SupportedNetworkId; logo: string; name: string; chain: Chain; @@ -101,7 +79,7 @@ type NetworkConfig = { export const networks: NetworkConfig[] = [ { - network: SupportedNetworks.Mainnet, + network: SupportedNetworkId.Mainnet, logo: require('../imgs/chains/eth.svg') as string, name: 'Mainnet', chain: mainnet, @@ -112,7 +90,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', }, { - network: SupportedNetworks.Optimism, + network: SupportedNetworkId.Optimism, logo: require('../imgs/chains/op.svg') as string, name: 'Optimism', chain: optimism, @@ -123,7 +101,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0x4200000000000000000000000000000000000006', }, { - network: SupportedNetworks.Base, + network: SupportedNetworkId.Base, logo: require('../imgs/chains/base.webp') as string, name: 'Base', chain: base, @@ -140,7 +118,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0x4200000000000000000000000000000000000006', }, { - network: SupportedNetworks.Polygon, + network: SupportedNetworkId.Polygon, chain: polygon, logo: require('../imgs/chains/polygon.png') as string, name: 'Polygon', @@ -152,7 +130,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', }, { - network: SupportedNetworks.Unichain, + network: SupportedNetworkId.Unichain, chain: unichain, logo: require('../imgs/chains/unichain.svg') as string, defaultRPC: getRpcUrl(process.env.NEXT_PUBLIC_UNICHAIN_RPC, 'unichain-mainnet'), @@ -163,7 +141,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0x4200000000000000000000000000000000000006', }, { - network: SupportedNetworks.Arbitrum, + network: SupportedNetworkId.Arbitrum, chain: arbitrum, logo: require('../imgs/chains/arbitrum.png') as string, name: 'Arbitrum', @@ -174,7 +152,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', }, { - network: SupportedNetworks.Etherlink, + network: SupportedNetworkId.Etherlink, chain: etherlinkChain, logo: require('../imgs/chains/etherlink.svg') as string, name: 'Etherlink', @@ -186,7 +164,7 @@ export const networks: NetworkConfig[] = [ wrappedNativeToken: '0xc9B53AB2679f573e480d01e0f49e2B5CFB7a3EAb', }, { - network: SupportedNetworks.HyperEVM, + network: SupportedNetworkId.HyperEVM, chain: hyperEvm, logo: require('../imgs/chains/hyperevm.png') as string, name: 'HyperEVM', @@ -198,7 +176,7 @@ export const networks: NetworkConfig[] = [ explorerUrl: 'https://hyperevmscan.io', }, { - network: SupportedNetworks.Monad, + network: SupportedNetworkId.Monad, chain: monad, logo: require('../imgs/chains/monad.svg') as string, name: 'Monad', @@ -212,26 +190,26 @@ export const networks: NetworkConfig[] = [ ]; export const isSupportedChain = (chainId: number) => { - return Object.values(SupportedNetworks).includes(chainId); + return isSupportedNetworkValue(chainId); }; -export const getNetworkConfig = (chainId: SupportedNetworks): NetworkConfig => { +export const getNetworkConfig = (chainId: SupportedNetworkId): NetworkConfig => { return networks.find((network) => network.network === chainId) as NetworkConfig; }; -export const getViemChain = (chainId: SupportedNetworks): Chain => { +export const getViemChain = (chainId: SupportedNetworkId): Chain => { return getNetworkConfig(chainId).chain; }; -export const getDefaultRPC = (chainId: SupportedNetworks): string => { +export const getDefaultRPC = (chainId: SupportedNetworkId): string => { return getNetworkConfig(chainId).defaultRPC; }; -export const getBlocktime = (chainId: SupportedNetworks): number => { +export const getBlocktime = (chainId: SupportedNetworkId): number => { return getNetworkConfig(chainId).blocktime; }; -export const getMaxBlockDelay = (chainId: SupportedNetworks): number => { +export const getMaxBlockDelay = (chainId: SupportedNetworkId): number => { return getNetworkConfig(chainId).maxBlockDelay || 0; }; @@ -242,7 +220,7 @@ export const isAgentAvailable = (chainId: number): boolean => { return true; }; -export const getAgentConfig = (chainId: SupportedNetworks): VaultAgentConfig | undefined => { +export const getAgentConfig = (chainId: SupportedNetworkId): VaultAgentConfig | undefined => { const network = getNetworkConfig(chainId); return network?.vaultConfig; }; @@ -257,15 +235,15 @@ export const getNetworkName = (chainId: number) => { return target?.name; }; -export const getExplorerUrl = (chainId: SupportedNetworks): string => { +export const getExplorerUrl = (chainId: SupportedNetworkId): string => { return getNetworkConfig(chainId).explorerUrl ?? 'https://etherscan.io'; }; -export const getNativeTokenSymbol = (chainId: SupportedNetworks): string => { +export const getNativeTokenSymbol = (chainId: SupportedNetworkId): string => { return getNetworkConfig(chainId).nativeTokenSymbol ?? 'ETH'; }; -export const getWrappedNativeToken = (chainId: SupportedNetworks): Address | undefined => { +export const getWrappedNativeToken = (chainId: SupportedNetworkId): Address | undefined => { return getNetworkConfig(chainId).wrappedNativeToken; }; diff --git a/src/utils/search-params.ts b/src/utils/search-params.ts new file mode 100644 index 00000000..814bf789 --- /dev/null +++ b/src/utils/search-params.ts @@ -0,0 +1,22 @@ +export type SearchParamsRecord = Record; + +export const serializeSearchParamsRecord = (searchParams: SearchParamsRecord): string => { + const nextSearchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(searchParams)) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + for (const entry of value) { + nextSearchParams.append(key, entry); + } + continue; + } + + nextSearchParams.set(key, value); + } + + return nextSearchParams.toString(); +}; diff --git a/src/utils/supported-networks.ts b/src/utils/supported-networks.ts new file mode 100644 index 00000000..8d765caa --- /dev/null +++ b/src/utils/supported-networks.ts @@ -0,0 +1,27 @@ +export enum SupportedNetworks { + Mainnet = 1, + Optimism = 10, + Base = 8453, + Polygon = 137, + Unichain = 130, + Arbitrum = 42_161, + Etherlink = 42_793, + HyperEVM = 999, + Monad = 143, +} + +export const ALL_SUPPORTED_NETWORKS = [ + SupportedNetworks.Mainnet, + SupportedNetworks.Optimism, + SupportedNetworks.Base, + SupportedNetworks.Polygon, + SupportedNetworks.Unichain, + SupportedNetworks.Arbitrum, + SupportedNetworks.Etherlink, + SupportedNetworks.HyperEVM, + SupportedNetworks.Monad, +]; + +export const isSupportedNetwork = (chainId: number): chainId is SupportedNetworks => { + return ALL_SUPPORTED_NETWORKS.includes(chainId as SupportedNetworks); +}; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 7bac3b1f..9399c807 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -1,4 +1,5 @@ import { type Chain, arbitrum, base, etherlink, mainnet, monad, optimism, polygon, unichain } from 'viem/chains'; +import { toChainAssetKey } from './chain-asset-key'; import { getWrappedNativeToken, hyperEvm } from './networks'; export type TokenSource = 'local' | 'external' | 'unknown'; @@ -1004,9 +1005,7 @@ const findToken = (address: string, chainId: number) => { ); }; -const infoToKey = (address: string, chainId: number) => { - return `${address.toLowerCase()}-${chainId}`; -}; +const infoToKey = toChainAssetKey; const findTokenWithKey = (key: string) => { // key: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48-1|0x833589fcd6edb6e08f4c7c32d4f71b54bda02913-8453' From d4346e15c38f7586103ee715e60aef611b27dce7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 27 Apr 2026 14:12:37 +0800 Subject: [PATCH 2/4] chore: remove red --- app/etherlink/page.tsx | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 app/etherlink/page.tsx diff --git a/app/etherlink/page.tsx b/app/etherlink/page.tsx deleted file mode 100644 index bc287b47..00000000 --- a/app/etherlink/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function EtherlinkPage() { - redirect('/markets?network=etherlink&ref=etherlink'); -} From e8a5300ef9a923278716dfbd04eeb71ea9a49b66 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 27 Apr 2026 14:21:24 +0800 Subject: [PATCH 3/4] chore: remove tests --- .../markets/market-filter-url-state.test.ts | 121 ------------------ 1 file changed, 121 deletions(-) delete mode 100644 src/features/markets/market-filter-url-state.test.ts diff --git a/src/features/markets/market-filter-url-state.test.ts b/src/features/markets/market-filter-url-state.test.ts deleted file mode 100644 index c394d32c..00000000 --- a/src/features/markets/market-filter-url-state.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { Chain } from 'viem'; -import { getMarketFilterAssetSelectionKey } from '@/features/markets/market-filter-selection'; -import { - parseMarketFilterTokenSelector, - parseMarketFilterUrlState, - resolveMarketFilterSelectionsFromUrlState, - resolveSupportedNetworkPreference, -} from '@/features/markets/market-filter-url-state'; -import { SupportedNetworks } from '@/utils/supported-networks'; - -const mainnet = { id: SupportedNetworks.Mainnet } as Chain; -const base = { id: SupportedNetworks.Base } as Chain; -const etherlink = { id: SupportedNetworks.Etherlink } as Chain; - -const tokenItems = [ - { - symbol: 'USDC', - img: undefined, - decimals: 6, - networks: [ - { chain: mainnet, address: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, - { chain: base, address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' }, - ], - }, - { - symbol: 'XTZ', - img: undefined, - decimals: 18, - networks: [{ chain: etherlink, address: '0xfc24f770F94edBca6D6f885E12d4317320BcB401' }], - }, -]; - -test('resolveSupportedNetworkPreference handles aliases, chain ids, and clear values', () => { - assert.equal(resolveSupportedNetworkPreference('etherlink'), SupportedNetworks.Etherlink); - assert.equal(resolveSupportedNetworkPreference('42793'), SupportedNetworks.Etherlink); - assert.equal(resolveSupportedNetworkPreference('all'), null); - assert.equal(resolveSupportedNetworkPreference('unknown-chain'), undefined); -}); - -test('parseMarketFilterTokenSelector supports canonical chain-address and symbol selectors', () => { - assert.deepEqual(parseMarketFilterTokenSelector('8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'), { - kind: 'chain-address', - chainId: SupportedNetworks.Base, - address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - }); - - assert.deepEqual(parseMarketFilterTokenSelector('symbol:usdc'), { - kind: 'symbol', - symbol: 'usdc', - }); - - assert.deepEqual(parseMarketFilterTokenSelector('xtz'), { - kind: 'symbol', - symbol: 'xtz', - }); -}); - -test('parseMarketFilterUrlState prefers explicit network params and falls back to ref aliases', () => { - const explicitUrlState = parseMarketFilterUrlState( - new URLSearchParams([ - ['network', 'base'], - ['ref', 'etherlink'], - ['loan', '8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'], - ['collateral', 'symbol:xtz'], - ]), - ); - - assert.equal(explicitUrlState.selectedNetwork, SupportedNetworks.Base); - assert.deepEqual(explicitUrlState.selectedLoanSelectors, [ - { - kind: 'chain-address', - chainId: SupportedNetworks.Base, - address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - }, - ]); - assert.deepEqual(explicitUrlState.selectedCollateralSelectors, [ - { - kind: 'symbol', - symbol: 'xtz', - }, - ]); - assert.equal(explicitUrlState.signature, 'network:8453|loan:8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913|collateral:symbol:xtz'); - - const referralUrlState = parseMarketFilterUrlState(new URLSearchParams([['ref', 'etherlink']])); - assert.equal(referralUrlState.selectedNetwork, SupportedNetworks.Etherlink); - assert.equal(referralUrlState.signature, 'network:42793'); -}); - -test('parseMarketFilterUrlState treats empty or clear token params as explicit resets', () => { - const urlState = parseMarketFilterUrlState( - new URLSearchParams([ - ['loan', 'all'], - ['collateral', ''], - ]), - ); - - assert.deepEqual(urlState.selectedLoanSelectors, []); - assert.deepEqual(urlState.selectedCollateralSelectors, []); - assert.equal(urlState.signature, 'loan:all|collateral:all'); -}); - -test('resolveMarketFilterSelectionsFromUrlState maps selectors onto persisted asset keys', () => { - const selections = resolveMarketFilterSelectionsFromUrlState( - [ - { - kind: 'chain-address', - chainId: SupportedNetworks.Base, - address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - }, - { - kind: 'symbol', - symbol: 'xtz', - }, - ], - tokenItems, - ); - - assert.deepEqual(selections, [getMarketFilterAssetSelectionKey(tokenItems[0]), getMarketFilterAssetSelectionKey(tokenItems[1])]); -}); From 6b1a146bfd3d44472ce7d647df8e47520859d845 Mon Sep 17 00:00:00 2001 From: Stark Sama Date: Mon, 27 Apr 2026 15:16:22 +0800 Subject: [PATCH 4/4] refactor: remove market filter URL forwarding --- app/page.tsx | 18 +-- .../markets/market-filter-url-state.ts | 113 +----------------- src/features/markets/markets-view.tsx | 76 +----------- 3 files changed, 9 insertions(+), 198 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 8409848a..80c7feae 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,4 @@ import { generateMetadata } from '@/utils/generateMetadata'; -import { serializeSearchParamsRecord, type SearchParamsRecord } from '@/utils/search-params'; -import { redirect } from 'next/navigation'; import HomePage from '../src/features/home/home-view'; export const metadata = generateMetadata({ @@ -16,20 +14,6 @@ export const metadata = generateMetadata({ * https://nextjs.org/docs/pages/building-your-application/upgrading/app-router-migration#step-4-migrating-pages * https://nextjs.org/docs/app/building-your-application/rendering/client-components */ -const MARKET_INTENT_QUERY_KEYS = new Set(['network', 'chain', 'ref', 'loan', 'collateral']); - -type HomePageProps = { - searchParams: Promise; -}; - -export default async function Page({ searchParams }: HomePageProps) { - const resolvedSearchParams = await searchParams; - const hasMarketIntent = Object.keys(resolvedSearchParams).some((key) => MARKET_INTENT_QUERY_KEYS.has(key)); - - if (hasMarketIntent) { - const nextSearchParams = serializeSearchParamsRecord(resolvedSearchParams); - redirect(nextSearchParams ? `/markets?${nextSearchParams}` : '/markets'); - } - +export default function Page() { return ; } diff --git a/src/features/markets/market-filter-url-state.ts b/src/features/markets/market-filter-url-state.ts index a9c736f1..64e4dad2 100644 --- a/src/features/markets/market-filter-url-state.ts +++ b/src/features/markets/market-filter-url-state.ts @@ -1,11 +1,8 @@ -import { type MarketFilterTokenSelector, type MarketFilterAsset, resolveMarketFilterTokenSelectors } from './market-filter-selection'; import { SupportedNetworks, isSupportedNetwork } from '@/utils/supported-networks'; -type SearchParamsLike = Pick; +type SearchParamsLike = Pick; export type MarketFilterUrlState = { - selectedCollateralSelectors?: MarketFilterTokenSelector[]; - selectedLoanSelectors?: MarketFilterTokenSelector[]; selectedNetwork?: SupportedNetworks | null; signature: string | null; }; @@ -35,14 +32,6 @@ const REF_NETWORK_ALIASES: Record = { const normalizeParamValue = (value: string): string => value.trim().toLowerCase(); -const splitSearchParamValues = (searchParams: SearchParamsLike, key: string): string[] => { - return searchParams - .getAll(key) - .flatMap((value) => value.split(',')) - .map((value) => value.trim()) - .filter(Boolean); -}; - export const resolveSupportedNetworkPreference = (rawValue: string): SupportedNetworks | null | undefined => { const normalizedValue = normalizeParamValue(rawValue); @@ -62,59 +51,6 @@ export const resolveSupportedNetworkPreference = (rawValue: string): SupportedNe return NETWORK_ALIASES[normalizedValue]; }; -export const parseMarketFilterTokenSelector = (rawValue: string): MarketFilterTokenSelector | null => { - const trimmedValue = rawValue.trim(); - if (!trimmedValue) { - return null; - } - - const normalizedValue = normalizeParamValue(trimmedValue); - if (CLEAR_VALUES.has(normalizedValue)) { - return null; - } - - if (normalizedValue.startsWith('symbol:')) { - const symbol = normalizeParamValue(trimmedValue.slice(trimmedValue.indexOf(':') + 1)); - return symbol ? { kind: 'symbol', symbol } : null; - } - - const [networkOrChainId, address] = trimmedValue.split(':', 2); - if (address) { - const chainId = resolveSupportedNetworkPreference(networkOrChainId); - if (chainId === null || chainId === undefined || !/^0x[a-fA-F0-9]{40}$/.test(address)) { - return null; - } - - return { - kind: 'chain-address', - chainId, - address: address.toLowerCase(), - }; - } - - return { - kind: 'symbol', - symbol: normalizedValue, - }; -}; - -const parseTokenSelectorsParam = (searchParams: SearchParamsLike, key: string): MarketFilterTokenSelector[] | undefined => { - if (!searchParams.has(key)) { - return undefined; - } - - const rawValues = splitSearchParamValues(searchParams, key); - if (rawValues.length === 0 || rawValues.some((value) => CLEAR_VALUES.has(normalizeParamValue(value)))) { - return []; - } - - const selectors = rawValues - .map((value) => parseMarketFilterTokenSelector(value)) - .filter((value): value is MarketFilterTokenSelector => value !== null); - - return selectors.length > 0 ? selectors : undefined; -}; - const parseExplicitNetworkPreference = (searchParams: SearchParamsLike): SupportedNetworks | null | undefined => { for (const key of ['network', 'chain']) { if (!searchParams.has(key)) { @@ -144,41 +80,17 @@ const resolveReferralNetworkPreference = (searchParams: SearchParamsLike): Suppo return REF_NETWORK_ALIASES[normalizeParamValue(refValue)]; }; -const serializeTokenSelector = (selector: MarketFilterTokenSelector): string => { - if (selector.kind === 'symbol') { - return `symbol:${selector.symbol}`; - } - - return `${selector.chainId}:${selector.address}`; -}; - const createMarketFilterUrlStateSignature = (state: Omit): string | null => { - const signatureParts: string[] = []; - - if (state.selectedNetwork !== undefined) { - signatureParts.push(`network:${state.selectedNetwork ?? 'all'}`); - } - - if (state.selectedLoanSelectors !== undefined) { - const serializedLoanSelectors = state.selectedLoanSelectors.map(serializeTokenSelector).sort().join(','); - signatureParts.push(`loan:${serializedLoanSelectors || 'all'}`); - } - - if (state.selectedCollateralSelectors !== undefined) { - const serializedCollateralSelectors = state.selectedCollateralSelectors.map(serializeTokenSelector).sort().join(','); - signatureParts.push(`collateral:${serializedCollateralSelectors || 'all'}`); + if (state.selectedNetwork === undefined) { + return null; } - return signatureParts.length > 0 ? signatureParts.join('|') : null; + return `network:${state.selectedNetwork ?? 'all'}`; }; export const parseMarketFilterUrlState = (searchParams: SearchParamsLike): MarketFilterUrlState => { - const explicitNetworkPreference = parseExplicitNetworkPreference(searchParams); - const nextState: Omit = { - selectedNetwork: explicitNetworkPreference ?? resolveReferralNetworkPreference(searchParams), - selectedLoanSelectors: parseTokenSelectorsParam(searchParams, 'loan'), - selectedCollateralSelectors: parseTokenSelectorsParam(searchParams, 'collateral'), + selectedNetwork: parseExplicitNetworkPreference(searchParams) ?? resolveReferralNetworkPreference(searchParams), }; return { @@ -186,18 +98,3 @@ export const parseMarketFilterUrlState = (searchParams: SearchParamsLike): Marke signature: createMarketFilterUrlStateSignature(nextState), }; }; - -export const resolveMarketFilterSelectionsFromUrlState = ( - selectors: MarketFilterTokenSelector[] | undefined, - items: MarketFilterAsset[], -): string[] | undefined => { - if (selectors === undefined) { - return undefined; - } - - if (selectors.length === 0) { - return []; - } - - return resolveMarketFilterTokenSelectors(selectors, items); -}; diff --git a/src/features/markets/markets-view.tsx b/src/features/markets/markets-view.tsx index 5bf12303..643b1c94 100644 --- a/src/features/markets/markets-view.tsx +++ b/src/features/markets/markets-view.tsx @@ -4,7 +4,7 @@ import type { Chain } from 'viem'; import Header from '@/components/layout/header/Header'; import { Breadcrumbs } from '@/components/shared/breadcrumbs'; -import { parseMarketFilterUrlState, resolveMarketFilterSelectionsFromUrlState } from '@/features/markets/market-filter-url-state'; +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'; @@ -18,14 +18,6 @@ import type { ERC20Token, UnknownERC20Token } from '@/utils/tokens'; import { CompactFilterBar } from './components/filters/compact-filter-bar'; import MarketsTable from './components/table/markets-table'; -const haveSameSelections = (left: string[], right: string[]): boolean => { - if (left.length !== right.length) { - return false; - } - - return left.every((value) => right.includes(value)); -}; - type MarketsProps = { initialSearchParams: string; }; @@ -51,37 +43,6 @@ export default function Markets({ initialSearchParams }: MarketsProps) { const { currentPage, setCurrentPage, resetPage } = usePagination(); const { allTokens } = useTokensQuery(); const { tableViewMode, includeUnknownTokens } = useMarketPreferences(); - const resolvedUrlLoanSelections = useMemo(() => { - if (urlFilterState.selectedLoanSelectors === undefined) { - return undefined; - } - - if (urlFilterState.selectedLoanSelectors.length === 0) { - return []; - } - - if (loading) { - return null; - } - - return resolveMarketFilterSelectionsFromUrlState(urlFilterState.selectedLoanSelectors, uniqueLoanAssets); - }, [loading, uniqueLoanAssets, urlFilterState.selectedLoanSelectors]); - - const resolvedUrlCollateralSelections = useMemo(() => { - if (urlFilterState.selectedCollateralSelectors === undefined) { - return undefined; - } - - if (urlFilterState.selectedCollateralSelectors.length === 0) { - return []; - } - - if (loading) { - return null; - } - - return resolveMarketFilterSelectionsFromUrlState(urlFilterState.selectedCollateralSelectors, uniqueCollaterals); - }, [loading, uniqueCollaterals, urlFilterState.selectedCollateralSelectors]); // Force compact mode on mobile useEffect(() => { @@ -173,43 +134,12 @@ export default function Markets({ initialSearchParams }: MarketsProps) { return; } - if (resolvedUrlLoanSelections === null || resolvedUrlCollateralSelections === null) { - return; - } - - const nextSelections: { - selectedCollaterals?: string[]; - selectedLoanAssets?: string[]; - selectedNetwork?: typeof persistedFilters.selectedNetwork; - } = {}; - if (urlFilterState.selectedNetwork !== undefined && urlFilterState.selectedNetwork !== persistedFilters.selectedNetwork) { - nextSelections.selectedNetwork = urlFilterState.selectedNetwork; - } - - if (resolvedUrlLoanSelections !== undefined && !haveSameSelections(resolvedUrlLoanSelections, persistedFilters.selectedLoanAssets)) { - nextSelections.selectedLoanAssets = resolvedUrlLoanSelections; - } - - if ( - resolvedUrlCollateralSelections !== undefined && - !haveSameSelections(resolvedUrlCollateralSelections, persistedFilters.selectedCollaterals) - ) { - nextSelections.selectedCollaterals = resolvedUrlCollateralSelections; - } - - if (Object.keys(nextSelections).length > 0) { - persistedFilters.applySelections(nextSelections); + persistedFilters.applySelections({ selectedNetwork: urlFilterState.selectedNetwork }); } appliedUrlSignatureRef.current = urlFilterState.signature; - }, [ - persistedFilters, - resolvedUrlCollateralSelections, - resolvedUrlLoanSelections, - urlFilterState.selectedNetwork, - urlFilterState.signature, - ]); + }, [persistedFilters, urlFilterState.selectedNetwork, urlFilterState.signature]); // Reset page when filters change useEffect(() => {