From ce0aa2456569353f022c3caadf6e356d98276cc8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 25 Oct 2025 13:09:42 +0800 Subject: [PATCH 1/6] feat: update AssetsTableWithSameLoan component with search and settings --- CLAUDE.md | 1 + .../components/MarketSettingsModal.tsx | 2 +- app/positions/components/RebalanceModal.tsx | 3 +- .../common/MarketSelectionModal.tsx | 191 ++++++++---------- .../common/MarketsTableWithSameLoanAsset.tsx | 117 ++++++++++- src/components/providers/TokenProvider.tsx | 10 +- src/constants/markets.ts | 1 + src/utils/tokens.ts | 4 + 8 files changed, 209 insertions(+), 120 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/constants/markets.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3992c0e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +- Dot not run pnpm commands, I'll run it in my terminal myself, you can access info on localhost:3000 for details \ No newline at end of file diff --git a/app/markets/components/MarketSettingsModal.tsx b/app/markets/components/MarketSettingsModal.tsx index addd7888..386b7222 100644 --- a/app/markets/components/MarketSettingsModal.tsx +++ b/app/markets/components/MarketSettingsModal.tsx @@ -101,7 +101,7 @@ export default function MarketSettingsModal({ }; return ( - + {(onClose) => ( <> diff --git a/app/positions/components/RebalanceModal.tsx b/app/positions/components/RebalanceModal.tsx index a81010b7..dd99297b 100644 --- a/app/positions/components/RebalanceModal.tsx +++ b/app/positions/components/RebalanceModal.tsx @@ -16,6 +16,7 @@ import { FromMarketsTable } from './FromMarketsTable'; import { RebalanceActionInput } from './RebalanceActionInput'; import { RebalanceCart } from './RebalanceCart'; import { RebalanceProcessModal } from './RebalanceProcessModal'; + type RebalanceModalProps = { groupedPosition: GroupedPosition; isOpen: boolean; @@ -24,8 +25,6 @@ type RebalanceModalProps = { isRefetching: boolean; }; -export const PER_PAGE = 5; - export function RebalanceModal({ groupedPosition, isOpen, diff --git a/src/components/common/MarketSelectionModal.tsx b/src/components/common/MarketSelectionModal.tsx index c6d8f6cb..56f54997 100644 --- a/src/components/common/MarketSelectionModal.tsx +++ b/src/components/common/MarketSelectionModal.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/react'; import { Address } from 'viem'; import { Button } from '@/components/common/Button'; import { MarketsTableWithSameLoanAsset } from '@/components/common/MarketsTableWithSameLoanAsset'; @@ -14,9 +15,12 @@ type MarketSelectionModalProps = { chainId: SupportedNetworks; excludeMarketIds?: Set; multiSelect?: boolean; + isOpen?: boolean; onClose: () => void; onSelect: (markets: Market[]) => void; confirmButtonText?: string; + // Optional: Storage key for settings (allows different modals to have different settings) + settingsStorageKey?: string; }; /** @@ -30,9 +34,11 @@ export function MarketSelectionModal({ chainId, excludeMarketIds, multiSelect = true, + isOpen = true, onClose, onSelect, confirmButtonText, + settingsStorageKey = 'marketSelection', }: MarketSelectionModalProps) { const [selectedMarkets, setSelectedMarkets] = useState>(new Set()); const { markets, loading: marketsLoading } = useMarkets(); @@ -62,14 +68,10 @@ export function MarketSelectionModal({ const handleToggleMarket = (marketId: string) => { if (!multiSelect) { - // Single select mode - immediately select and close const market = availableMarkets.find((m) => m.uniqueKey === marketId); if (market) { - // Use setTimeout to ensure state updates happen in correct order onSelect([market]); - setTimeout(() => { - onClose(); - }, 0); + onClose(); } return; } @@ -94,40 +96,6 @@ export function MarketSelectionModal({ onClose(); }; - const handleBackdropClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (e.target === e.currentTarget) { - onClose(); - } - }; - - const handleBackdropKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onClose(); - } - }; - - if (marketsLoading) { - return ( -
-
-
- -
-
-
- ); - } - const selectedCount = selectedMarkets.size; const buttonText = confirmButtonText ?? ( multiSelect @@ -136,76 +104,83 @@ export function MarketSelectionModal({ ); return ( -
-
-
-
+ + <> +

{title}

-

{description}

-
-
- - {availableMarkets.length === 0 ? ( -
-

- {excludeMarketIds && excludeMarketIds.size > 0 - ? 'No more markets available to select.' - : 'No markets found matching the criteria.'} -

-
- ) : ( -
- ({ - market: m, - isSelected: selectedMarkets.has(m.uniqueKey), - }))} - - onToggleMarket={handleToggleMarket} - disabled={false} - uniqueCollateralTokens={undefined} - showSelectColumn={multiSelect} - itemsPerPage={7} - /> -
- )} - - {multiSelect && ( -
-

- {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected -

-
- - -
-
- )} - - {!multiSelect && ( -
- -
- )} -
-
+

{description}

+ + + + {marketsLoading ? ( +
+ +
+ ) : availableMarkets.length === 0 ? ( +
+ {excludeMarketIds && excludeMarketIds.size > 0 + ? 'No more markets available to select.' + : 'No markets found matching the criteria.'} +
+ ) : ( + ({ + market: m, + isSelected: selectedMarkets.has(m.uniqueKey), + }))} + onToggleMarket={handleToggleMarket} + disabled={false} + uniqueCollateralTokens={undefined} + showSelectColumn={multiSelect} + settingsStorageKey={settingsStorageKey} + /> + )} +
+ + + {multiSelect ? ( + <> +

+ {selectedCount} market{selectedCount !== 1 ? 's' : ''} selected +

+
+ + +
+ + ) : ( +
+ +
+ )} +
+ +
+
); } diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index bcb7e00a..fe97ea29 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -1,15 +1,20 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { Checkbox } from '@heroui/react'; -import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, TrashIcon } from '@radix-ui/react-icons'; +import { Checkbox, Input } from '@heroui/react'; +import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, TrashIcon, GearIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; +import { FaSearch } from 'react-icons/fa'; import { IoHelpCircleOutline } from 'react-icons/io5'; import { LuX } from 'react-icons/lu'; +import { Button } from '@/components/common'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useMarkets } from '@/hooks/useMarkets'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getViemChain } from '@/utils/networks'; import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tokens'; import { Market } from '@/utils/types'; +import MarketSettingsModal from 'app/markets/components/MarketSettingsModal'; import { Pagination } from '../../../app/markets/components/Pagination'; import { MarketIdBadge } from '../MarketIdBadge'; import { MarketIdentity, MarketIdentityMode, MarketIdentityFocus } from '../MarketIdentity'; @@ -32,8 +37,10 @@ type MarketsTableWithSameLoanAssetProps = { showSelectColumn?: boolean; // Optional: Hide the cart/staging area showing selected markets showCart?: boolean; - // Optional: entry per page - itemsPerPage?: number + // Optional: Storage key prefix for settings (allows different instances to have different settings) + settingsStorageKey?: string; + // Optional: Show the settings button (default: true) + showSettings?: boolean; }; enum SortColumn { @@ -439,13 +446,32 @@ export function MarketsTableWithSameLoanAsset({ uniqueCollateralTokens, showSelectColumn = true, showCart = true, - itemsPerPage = 8 + settingsStorageKey = 'marketsTable', + showSettings = true, }: MarketsTableWithSameLoanAssetProps): JSX.Element { + // Get global market settings + const { showUnwhitelistedMarkets } = useMarkets(); + + // Settings modal state + const [showSettingsModal, setShowSettingsModal] = useState(false); + + // Table state const [currentPage, setCurrentPage] = useState(1); const [sortColumn, setSortColumn] = useState(SortColumn.Supply); const [sortDirection, setSortDirection] = useState<1 | -1>(-1); // -1 = desc, 1 = asc const [collateralFilter, setCollateralFilter] = useState([]); const [oracleFilter, setOracleFilter] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + + // Settings state (persisted with storage key namespace) + const [hideSmallMarkets, setHideSmallMarkets] = useLocalStorage(`${settingsStorageKey}_hideSmallMarkets`, true); + const [entriesPerPage, setEntriesPerPage] = useLocalStorage(`${settingsStorageKey}_entriesPerPage`, 8); + const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage(`${settingsStorageKey}_includeUnknownTokens`, false); + const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(`${settingsStorageKey}_showUnknownOracle`, false); + const [usdFilters, setUsdFilters] = useLocalStorage(`${settingsStorageKey}_usdFilters`, { + minSupply: '', + minBorrow: '', + }); const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -516,6 +542,30 @@ export function MarketsTableWithSameLoanAsset({ const processedMarkets = useMemo(() => { let filtered = [...markets]; + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + filtered = filtered.filter((m) => { + const collateralSymbol = m.market?.collateralAsset?.symbol?.toLowerCase() ?? ''; + const marketId = m.market?.uniqueKey?.toLowerCase() ?? ''; + return collateralSymbol.includes(query) || marketId.includes(query); + }); + } + + // Apply whitelist filter + if (!showUnwhitelistedMarkets) { + filtered = filtered.filter((m) => m.market?.whitelisted ?? false); + } + + // Apply small markets filter + if (hideSmallMarkets) { + const minSupplyUsd = usdFilters.minSupply ? parseFloat(usdFilters.minSupply) : 1000; // Default to 1000 USD if not set + filtered = filtered.filter((m) => { + const supplyUsd = Number(m.market?.state?.supplyAssetsUsd ?? 0); + return minSupplyUsd === 0 || supplyUsd >= minSupplyUsd; + }); + } + // Apply collateral filter if (collateralFilter.length > 0) { filtered = filtered.filter((m) => { @@ -570,7 +620,7 @@ export function MarketsTableWithSameLoanAsset({ }); return filtered; - }, [markets, collateralFilter, oracleFilter, sortColumn, sortDirection]); + }, [markets, collateralFilter, oracleFilter, sortColumn, sortDirection, searchQuery, showUnwhitelistedMarkets, hideSmallMarkets, usdFilters.minSupply]); // Get selected markets const selectedMarkets = useMemo(() => { @@ -578,7 +628,7 @@ export function MarketsTableWithSameLoanAsset({ }, [markets]); // Pagination with guards to prevent invalid states - const safePerPage = Math.max(1, Math.floor(itemsPerPage ?? 8)); + const safePerPage = Math.max(1, Math.floor(entriesPerPage)); const totalPages = Math.max(1, Math.ceil(processedMarkets.length / safePerPage)); const safePage = Math.min(Math.max(1, currentPage), totalPages); const startIndex = (safePage - 1) * safePerPage; @@ -634,6 +684,43 @@ export function MarketsTableWithSameLoanAsset({ )} + {/* Search and Controls */} +
+
+ setSearchQuery(event.target.value)} + endContent={} + classNames={{ + inputWrapper: 'bg-surface rounded-sm focus-within:outline-none', + input: 'bg-surface rounded-sm text-xs focus:outline-none', + }} + size="sm" + /> +
+
+
+ + Hide small markets +
+ {showSettings && ( + + )} +
+
+ {/* Filters */}
+ + {/* Settings Modal */} + {showSettingsModal && ( + setShowSettingsModal(false)} + includeUnknownTokens={includeUnknownTokens} + setIncludeUnknownTokens={setIncludeUnknownTokens} + showUnknownOracle={showUnknownOracle} + setShowUnknownOracle={setShowUnknownOracle} + usdFilters={usdFilters} + setUsdFilters={setUsdFilters} + entriesPerPage={entriesPerPage} + onEntriesPerPageChange={setEntriesPerPage} + /> + )}
); } diff --git a/src/components/providers/TokenProvider.tsx b/src/components/providers/TokenProvider.tsx index d74ad01c..14bfd034 100644 --- a/src/components/providers/TokenProvider.tsx +++ b/src/components/providers/TokenProvider.tsx @@ -23,6 +23,11 @@ type TokenContextType = { const TokenContext = createContext(null); +const localTokensWithSource: ERC20Token[] = supportedTokens.map((token) => ({ + ...token, + source: 'local', +})); + async function fetchPendleAssets(chainId: number): Promise { try { const response = await fetch(`https://api-v2.pendle.finance/core/v1/${chainId}/assets/all`); @@ -50,11 +55,12 @@ function convertPendleAssetToToken(asset: PendleAsset, chainId: SupportedNetwork protocol: { name: 'Pendle', }, + source: 'external', }; } export function TokenProvider({ children }: { children: React.ReactNode }) { - const [allTokens, setAllTokens] = useState(supportedTokens); + const [allTokens, setAllTokens] = useState(localTokensWithSource); useEffect(() => { async function fetchAllAssets() { @@ -85,7 +91,7 @@ export function TokenProvider({ children }: { children: React.ReactNode }) { ); }); - setAllTokens([...supportedTokens, ...filteredPendleTokens]); + setAllTokens([...localTokensWithSource, ...filteredPendleTokens]); } catch (err) { console.error('Error fetching Pendle assets:', err); } diff --git a/src/constants/markets.ts b/src/constants/markets.ts new file mode 100644 index 00000000..d1d1e3bc --- /dev/null +++ b/src/constants/markets.ts @@ -0,0 +1 @@ +export const DEFAULT_MIN_SUPPLY_USD = 1000; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 0fd2db82..31f25437 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -1,6 +1,8 @@ import { Chain, base, mainnet, polygon, unichain, arbitrum } from 'viem/chains'; import { getWrappedNativeToken, hyperevm } from './networks'; +export type TokenSource = 'local' | 'external' | 'unknown'; + export type SingleChainERC20Basic = { symbol: string; decimals: number; @@ -23,6 +25,7 @@ export type ERC20Token = { name: string; }; isFactoryToken?: boolean; + source?: TokenSource; // this is not a "hard peg", instead only used for market supply / borrow USD value estimation peg?: TokenPeg; @@ -34,6 +37,7 @@ export type UnknownERC20Token = { decimals: number; networks: { chain: Chain; address: string }[]; isUnknown?: boolean; + source?: TokenSource; }; const MORPHO_TOKEN_BASE = '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842'; From 0fc36ecc9d28ed3b3adc38c0990df1faa0c86e3b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 25 Oct 2025 14:45:37 +0800 Subject: [PATCH 2/6] fix: modal index --- .../components/MarketSettingsModal.tsx | 11 +++- app/markets/components/markets.tsx | 7 ++- .../common/MarketsTableWithSameLoanAsset.tsx | 59 ++++++++++++++++--- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/app/markets/components/MarketSettingsModal.tsx b/app/markets/components/MarketSettingsModal.tsx index 386b7222..c9788705 100644 --- a/app/markets/components/MarketSettingsModal.tsx +++ b/app/markets/components/MarketSettingsModal.tsx @@ -101,7 +101,16 @@ export default function MarketSettingsModal({ }; return ( - + {(onClose) => ( <> diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index fe1c6707..f393c495 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -19,6 +19,7 @@ import { useStyledToast } from '@/hooks/useStyledToast'; import { SupportedNetworks } from '@/utils/networks'; import { PriceFeedVendors, parsePriceFeedVendors } from '@/utils/oracle'; import * as keys from '@/utils/storageKeys'; +import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; import { ERC20Token, UnknownERC20Token } from '@/utils/tokens'; import { Market } from '@/utils/types'; @@ -88,7 +89,10 @@ export default function Markets({ const { allTokens, findToken } = useTokens(); - const [usdMinSupply, setUsdMinSupply] = useLocalStorage(keys.MarketsUsdMinSupplyKey, ''); + const [usdMinSupply, setUsdMinSupply] = useLocalStorage( + keys.MarketsUsdMinSupplyKey, + DEFAULT_MIN_SUPPLY_USD.toString(), + ); const [usdMinBorrow, setUsdMinBorrow] = useLocalStorage(keys.MarketsUsdMinBorrowKey, ''); // Create memoized usdFilters object from individual localStorage values to prevent re-renders @@ -136,6 +140,7 @@ export default function Markets({ decimals: token.decimals, networks: [], isUnknown: true, + source: 'unknown', }; } acc[token.symbol].networks.push({ diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index fe97ea29..56c9a9ad 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -7,12 +7,14 @@ import { FaSearch } from 'react-icons/fa'; import { IoHelpCircleOutline } from 'react-icons/io5'; import { LuX } from 'react-icons/lu'; import { Button } from '@/components/common'; +import { useTokens } from '@/components/providers/TokenProvider'; +import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getViemChain } from '@/utils/networks'; import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; -import { ERC20Token, UnknownERC20Token, infoToKey, findToken } from '@/utils/tokens'; +import { ERC20Token, UnknownERC20Token, infoToKey } from '@/utils/tokens'; import { Market } from '@/utils/types'; import MarketSettingsModal from 'app/markets/components/MarketSettingsModal'; import { Pagination } from '../../../app/markets/components/Pagination'; @@ -51,6 +53,19 @@ enum SortColumn { Risk = 4, } +const getMinSupplyThreshold = (rawValue: string): number => { + if (rawValue === undefined || rawValue === null || rawValue === '') { + return DEFAULT_MIN_SUPPLY_USD; + } + + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) { + return DEFAULT_MIN_SUPPLY_USD; + } + + return Math.max(parsed, 0); +}; + function HTSortable({ label, column, @@ -451,6 +466,7 @@ export function MarketsTableWithSameLoanAsset({ }: MarketsTableWithSameLoanAssetProps): JSX.Element { // Get global market settings const { showUnwhitelistedMarkets } = useMarkets(); + const { findToken } = useTokens(); // Settings modal state const [showSettingsModal, setShowSettingsModal] = useState(false); @@ -469,10 +485,12 @@ export function MarketsTableWithSameLoanAsset({ const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage(`${settingsStorageKey}_includeUnknownTokens`, false); const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(`${settingsStorageKey}_showUnknownOracle`, false); const [usdFilters, setUsdFilters] = useLocalStorage(`${settingsStorageKey}_usdFilters`, { - minSupply: '', + minSupply: DEFAULT_MIN_SUPPLY_USD.toString(), minBorrow: '', }); + const effectiveMinSupply = getMinSupplyThreshold(usdFilters.minSupply); + const handleSort = (column: SortColumn) => { if (sortColumn === column) { setSortDirection((prev) => (prev === 1 ? -1 : 1)); @@ -485,7 +503,11 @@ export function MarketsTableWithSameLoanAsset({ // Get unique collaterals with full token data const availableCollaterals = useMemo(() => { if (uniqueCollateralTokens) { - return uniqueCollateralTokens; + return [...uniqueCollateralTokens].sort( + (a, b) => + (a.source === 'local' ? 0 : 1) - (b.source === 'local' ? 0 : 1) || + a.symbol.localeCompare(b.symbol), + ); } // Fallback: build tokens manually from markets @@ -514,14 +536,22 @@ export function MarketsTableWithSameLoanAsset({ address: m.market.collateralAsset.address, chain: getViemChain(m.market.morphoBlue.chain.id), }], + isUnknown: true, + source: 'unknown', }; tokenMap.set(key, token); } } }); - return Array.from(tokenMap.values()).sort((a, b) => a.symbol.localeCompare(b.symbol)); - }, [markets, uniqueCollateralTokens]); + return Array.from(tokenMap.values()).sort( + (a, b) => + (a.source === 'local' ? 0 : 1) - (b.source === 'local' ? 0 : 1) || + a.symbol.localeCompare(b.symbol), + ); + }, [markets, uniqueCollateralTokens, findToken]); + + console.log(availableCollaterals) // Get unique oracles from current markets const availableOracles = useMemo(() => { @@ -559,10 +589,9 @@ export function MarketsTableWithSameLoanAsset({ // Apply small markets filter if (hideSmallMarkets) { - const minSupplyUsd = usdFilters.minSupply ? parseFloat(usdFilters.minSupply) : 1000; // Default to 1000 USD if not set filtered = filtered.filter((m) => { const supplyUsd = Number(m.market?.state?.supplyAssetsUsd ?? 0); - return minSupplyUsd === 0 || supplyUsd >= minSupplyUsd; + return effectiveMinSupply === 0 || supplyUsd >= effectiveMinSupply; }); } @@ -620,7 +649,17 @@ export function MarketsTableWithSameLoanAsset({ }); return filtered; - }, [markets, collateralFilter, oracleFilter, sortColumn, sortDirection, searchQuery, showUnwhitelistedMarkets, hideSmallMarkets, usdFilters.minSupply]); + }, [ + markets, + collateralFilter, + oracleFilter, + sortColumn, + sortDirection, + searchQuery, + showUnwhitelistedMarkets, + hideSmallMarkets, + effectiveMinSupply, + ]); // Get selected markets const selectedMarkets = useMemo(() => { @@ -706,7 +745,9 @@ export function MarketsTableWithSameLoanAsset({ isSelected={hideSmallMarkets} onValueChange={setHideSmallMarkets} /> - Hide small markets + + Hide markets below ${effectiveMinSupply.toLocaleString()} + {showSettings && (