diff --git a/app/HomePage.tsx b/app/HomePage.tsx new file mode 100644 index 00000000..0d345de2 --- /dev/null +++ b/app/HomePage.tsx @@ -0,0 +1,546 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useTheme } from 'next-themes'; +import { RiBookLine, RiDiscordFill, RiGithubFill, RiArrowDownLine, RiExternalLinkLine } from 'react-icons/ri'; +import RebalanceAnimation from '@/components/animations/RebalanceAnimation'; +import { Badge } from '@/components/common/Badge'; +import { Button } from '@/components/common/Button'; +import Header from '@/components/layout/header/Header'; +import { EXTERNAL_LINKS } from '@/utils/external'; +import logo from '../src/components/imgs/logo.png'; +import morphoLogoDark from '../src/imgs/intro/morpho-logo-darkmode.svg'; +import morphoLogoLight from '../src/imgs/intro/morpho-logo-lightmode.svg'; +import vaultImage from '../src/imgs/intro/vault.webp'; + +type Phrase = { + text: string; + highlightWords: { word: string; color: string }[]; +}; + +const phrases: Phrase[] = [ + { + text: 'Customized lending on Morpho Blue', + highlightWords: [{ word: 'Morpho Blue', color: 'rgb(59, 130, 246)' }], + }, + { + text: 'Customized lending with full control', + highlightWords: [], + }, + { + text: 'Customized lending with automation', + highlightWords: [{ word: 'automation', color: 'rgb(251, 146, 60)' }], + }, + { + text: 'Manage your own risk with no fees', + highlightWords: [], + }, + { + text: 'Manage your own yield with automation', + highlightWords: [{ word: 'automation', color: 'rgb(251, 146, 60)' }], + }, + { + text: 'Deploy your own vault with zero fees', + highlightWords: [{ word: 'zero fees', color: 'rgb(251, 146, 60)' }], + }, + { + text: 'Be your own risk curator', + highlightWords: [], + }, +]; + +function CustomTypingAnimation() { + const [displayText, setDisplayText] = useState(''); + const [phraseIndex, setPhraseIndex] = useState(0); + const [isDeleting, setIsDeleting] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [showCursor, setShowCursor] = useState(true); + + useEffect(() => { + // Cursor blink + const cursorInterval = setInterval(() => { + setShowCursor((prev) => !prev); + }, 530); + + return () => clearInterval(cursorInterval); + }, []); + + useEffect(() => { + if (isPaused) { + const pauseTimeout = setTimeout(() => { + setIsPaused(false); + setIsDeleting(true); + }, 2000); + return () => clearTimeout(pauseTimeout); + } + + const currentPhrase = phrases[phraseIndex]; + const targetText = currentPhrase.text; + + // Calculate deletion strategy + const getNextPhraseIndex = (current: number) => (current + 1) % phrases.length; + const nextPhrase = phrases[getNextPhraseIndex(phraseIndex)]; + + // Find common prefix between current and next phrase + let commonPrefixLength = 0; + for (let i = 0; i < Math.min(targetText.length, nextPhrase.text.length); i++) { + if (targetText[i] === nextPhrase.text[i]) { + commonPrefixLength = i + 1; + } else { + break; + } + } + + // Sometimes delete everything, sometimes keep common prefix + const shouldDeleteAll = Math.random() > 0.5; + const deleteToLength = shouldDeleteAll ? 0 : commonPrefixLength; + + const typingSpeed = 30; + const deletingSpeed = 20; + + const timeout = setTimeout(() => { + if (!isDeleting) { + // Typing + if (displayText.length < targetText.length) { + setDisplayText(targetText.slice(0, displayText.length + 1)); + } else { + // Finished typing, pause + setIsPaused(true); + } + } else { + // Deleting + if (displayText.length > deleteToLength) { + setDisplayText(displayText.slice(0, -1)); + } else { + // Finished deleting, move to next phrase + setIsDeleting(false); + setPhraseIndex(getNextPhraseIndex(phraseIndex)); + } + } + }, isDeleting ? deletingSpeed : typingSpeed); + + return () => clearTimeout(timeout); + }, [displayText, phraseIndex, isDeleting, isPaused]); + + // Render text with colored highlights + const renderColoredText = () => { + const currentPhrase = phrases[phraseIndex]; + let remainingText = displayText; + const elements: React.ReactNode[] = []; + let keyIndex = 0; + + currentPhrase.highlightWords.forEach(({ word, color }) => { + const index = remainingText.indexOf(word); + if (index >= 0) { + // Add text before the highlight + if (index > 0) { + elements.push( + {remainingText.slice(0, index)} + ); + } + // Add highlighted text (only if fully typed) + const highlightedPortion = remainingText.slice(index, index + word.length); + elements.push( + + {highlightedPortion} + + ); + remainingText = remainingText.slice(index + word.length); + } + }); + + // Add remaining text + if (remainingText.length > 0) { + elements.push({remainingText}); + } + + return elements.length > 0 ? elements : displayText; + }; + + return ( +
+ {renderColoredText()} + + | + +
+ ); +} + +function HomePage() { + const { theme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const scrollToSection = (sectionId: string) => { + const element = document.getElementById(sectionId); + element?.scrollIntoView({ behavior: 'smooth' }); + }; + + return ( +
+
+
+ {/* Hero Section - Full Screen */} +
+
+
+
+ {/* Logo and Product Title - Horizontal Layout */} +
+

+ Welcome to Monarch +

+ Monarch Logo +
+ + {/* Tagline with typing animation */} +
+
+ +
+
+
+ + {/* CTA Buttons */} +
+ + + + +
+
+
+ + {/* Scroll indicator - centered and subtle */} +
+ + + +
+
+ + {/* Section 1: Introducing Monarch - Full Screen, Left Layout with Image */} +
+
+
+ {/* Text Content */} +
+

+ Introducing Monarch +

+

+ Advanced Interface for Morpho Blue +

+
+

+ Morpho Blue is the core protocol of the Morpho ecosystem—a decentralized, immutable, and neutral lending protocol that enables the creation of lending markets with any assets. +

+

+ Monarch is an advanced interface for Morpho Blue, providing powerful tools to interact directly with the protocol—from simple lending to creating your own automated vaults. +

+
+
+ + + + +
+
+ + {/* Morpho Logo */} +
+ Morpho Logo +
+
+
+
+ + {/* Section 2: Morpho Vaults - Full Screen, Right Layout with Image */} +
+
+
+ {/* Text Content */} +
+

+ Morpho Vaults +

+

+ Curated Risk Management +

+
+

+ Morpho Vaults are intermediate contracts managed by professional risk curators who simplify risk management for suppliers. These vaults provide a simplified user experience with managed risk exposure and ERC4626 token compatibility. +

+

+ However, they come with trade-offs: less control over parameters, limited customization, and potential performance fees charged by curators. +

+
+
+ + + + +
+
+ + {/* Vault Image */} +
+ Morpho Vaults +
+
+
+
+ + {/* Section 3: Direct Market Access - Full Screen, Left Layout with Animation */} +
+
+
+ {/* Text Content - Centered */} +
+

+ Why Monarch? +

+

+ Advanced Tools for DeFi Power Users +

+
+

+ Monarch provides direct access to Morpho Blue markets with no intermediaries and zero fees. Our powerful tools are designed for sophisticated users who want maximum control and capital efficiency: +

+
    +
  • + + Market Discovery: Find the best lending opportunities with the highest APY while understanding the risk trade-offs. +
  • +
  • + + Risk Analysis: Comprehensive risk metrics and analytics on every market, helping you make informed decisions. +
  • +
  • + + Smart Rebalancing: Tools designed to help you identify optimal yield opportunities and easily rebalance your positions across multiple markets. +
  • +
+
+
+ + {/* Rebalance Animation */} +
+ +
+ + {/* CTA Buttons - Centered */} +
+ + + + +
+
+
+
+ + {/* Section 4: Auto Vaults - Full Screen, Center Layout, No Image */} + {/*
+
+
+
+

+ Auto Vaults +

+ + New + +
+

+ Be Your Own Risk Curator +

+ +
+

+ Deploy your own vault. Define your risk parameters. Keep full control. +

+ +
+
+ 1 +
+

Deploy Your Vault

+

+ Launch your own vault contract with just a few clicks. No technical expertise required. +

+
+
+ +
+ 2 +
+

Full Control

+

+ Set your own risk parameters, choose markets, define caps. You're the curator. No performance fees, no middlemen. +

+
+
+ +
+ 3 +
+

Automated Optimization

+

+ Choose automation agents that work within your rules to optimize yields automatically. +

+
+
+
+
+ +
+ + + +
+
+
+
*/} + + {/* Footer CTA - Full Screen */} +
+
+

+ Join the Monarch Community +

+

+ Connect with us and stay updated on the latest features and developments in decentralized lending. +

+ + {/* Social Links - Fixed underscores */} + +
+
+
+
+ ); +} + +export default HomePage; diff --git a/app/autovault/[chainId]/[vaultAddress]/content.tsx b/app/autovault/[chainId]/[vaultAddress]/content.tsx index 39ddb628..3cc7ed1f 100644 --- a/app/autovault/[chainId]/[vaultAddress]/content.tsx +++ b/app/autovault/[chainId]/[vaultAddress]/content.tsx @@ -174,8 +174,8 @@ export default function VaultContent() { - {/* Setup Banners */} - {vault.needsAdapterDeployment && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + {/* Setup Banners - Only show when data is loaded */} + {!vault.vaultDataLoading && vault.needsAdapterDeployment && networkConfig?.vaultConfig?.marketV1AdapterFactory && (

Complete vault initialization

@@ -195,7 +195,7 @@ export default function VaultContent() {
)} - {vault.hasNoAllocators && vault.isOwner && ( + {!vault.vaultDataLoading && vault.hasNoAllocators && vault.isOwner && (

Choose an agent

@@ -215,7 +215,7 @@ export default function VaultContent() {
)} - {vault.capsUninitialized && vault.isOwner && ( + {!vault.vaultDataLoading && vault.capsUninitialized && vault.isOwner && (

Configure allocation caps

diff --git a/app/home/HomePage.tsx b/app/home/HomePage.tsx deleted file mode 100644 index 07b02f67..00000000 --- a/app/home/HomePage.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import Link from 'next/link'; -import { useAccount } from 'wagmi'; -import { Button } from '@/components/common/Button'; -import Header from '@/components/layout/header/Header'; - -export default function HomePage() { - const [showCustomized, setShowCustomized] = useState(true); - const [riskYieldIndex, setRiskYieldIndex] = useState(0); - const [secondCounter, setSecondCounter] = useState(0); - - const riskYieldTerms = ['risk', 'yield']; - const secondPhrases = ['on Morpho Blue', 'with no intermediates']; - - useEffect(() => { - // Toggle between customized and manage your own every 5 seconds - const customizedInterval = setInterval(() => { - setShowCustomized((prev) => !prev); - }, 5000); - - // Change risk/yield every 3 seconds when showing manage your own - const riskYieldInterval = setInterval(() => { - if (!showCustomized) { - setRiskYieldIndex((prev) => (prev + 1) % riskYieldTerms.length); - } - }, 3000); - - // Second segment changes every 4 seconds - const secondInterval = setInterval(() => { - setSecondCounter((prev) => (prev + 1) % secondPhrases.length); - }, 4000); - - return () => { - clearInterval(customizedInterval); - clearInterval(riskYieldInterval); - clearInterval(secondInterval); - }; - }, [showCustomized]); - - const renderFirstPhrase = () => { - if (showCustomized) { - return ( - - Customized lending - - ); - } - return ( - - - Manage your own - - {riskYieldTerms.map((term, index) => ( - - {term} - - ))} - - - - ); - }; - - const { address } = useAccount(); - - return ( -
-
-
-
-
-

-
{renderFirstPhrase()}
-
- {secondPhrases.map((phrase, index) => ( - - {phrase.includes('Morpho Blue') ? ( - - on Morpho Blue - - ) : ( - phrase - )} - - ))} -
-

-
-
- - - - - - -
-
-
-
- ); -} diff --git a/app/info/components/info.tsx b/app/info/components/info.tsx deleted file mode 100644 index 07d9fb2c..00000000 --- a/app/info/components/info.tsx +++ /dev/null @@ -1,178 +0,0 @@ -// eslint-disable jsx-a11y/click-events-have-key-events -// eslint-disable jsx-a11y/no-static-element-interactions -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { Button } from '@heroui/react'; -import Image from 'next/image'; -import { FaChevronLeft, FaChevronRight } from 'react-icons/fa'; -import { Badge } from '@/components/common/Badge'; -import Header from '@/components/layout/header/Header'; -import { sections } from './sectionData'; - -function InfoPage() { - const [currentSection, setCurrentSection] = useState(0); - const [isClient, setIsClient] = useState(false); - const containerRef = useRef(null); - const [isTransitioning, setIsTransitioning] = useState(false); - - useEffect(() => { - setIsClient(true); - }, []); - - const changeSection = (direction: 'next' | 'prev') => { - if (isTransitioning) return; - setIsTransitioning(true); - setCurrentSection((prev) => { - if (direction === 'next') { - return (prev + 1) % sections.length; - } else { - return (prev - 1 + sections.length) % sections.length; - } - }); - setTimeout(() => setIsTransitioning(false), 500); - }; - - const nextSection = () => changeSection('next'); - const prevSection = () => changeSection('prev'); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - let lastWheelTime = 0; - const wheelThreshold = 50; // Minimum time between wheel events in ms - - const handleWheel = (e: WheelEvent) => { - const now = Date.now(); - if (now - lastWheelTime < wheelThreshold) return; - - if (Math.abs(e.deltaX) > Math.abs(e.deltaY) && Math.abs(e.deltaX) > 5) { - e.preventDefault(); - if (e.deltaX > 0) { - nextSection(); - } else { - prevSection(); - } - lastWheelTime = now; - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { - nextSection(); - } else if (e.key === 'ArrowLeft') { - prevSection(); - } - }; - - container.addEventListener('wheel', handleWheel, { passive: false }); - window.addEventListener('keydown', handleKeyDown); - - return () => { - container.removeEventListener('wheel', handleWheel); - window.removeEventListener('keydown', handleKeyDown); - }; - }, [nextSection, prevSection]); - - const renderImage = (section: (typeof sections)[0], index: number) => ( -
- {section.mainTitle} -
- ); - - if (!isClient) { - return null; // or a loading spinner - } - - return ( -
-
-
-
-
- {sections.map((section, index) => ( -
-
-
-

- {section.mainTitle} -

- {section.isNew && ( - - {' '} - New{' '} - - )} -
-

- {section.subTitle} -

-
- {renderImage(section, index)} -
- {section.content} -
-
-
-
- ))} -
-
-
- - -
- -
-
- ); -} - -export default InfoPage; diff --git a/app/info/components/sectionData.tsx b/app/info/components/sectionData.tsx deleted file mode 100644 index 824931bb..00000000 --- a/app/info/components/sectionData.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React from 'react'; -import { EXTERNAL_LINKS } from '@/utils/external'; -import monarchAgentImg from '../../../src/imgs/agent/agent.png'; -import monarchImage from '../../../src/imgs/intro/direct-supply.png'; -import morphoImage from '../../../src/imgs/intro/morpho.png'; -import vaultsImage from '../../../src/imgs/intro/vaults.png'; - -function Card({ title, items }: { title: string; items: string[] }) { - return ( -
-

{title}

-
    - {items.map((item, index) => ( -
  • {item}
  • - ))} -
-
- ); -} - -export const sections = [ - { - mainTitle: 'Introducing Monarch', - subTitle: 'Built on Morpho Blue', - image: morphoImage, - content: ( - <> -

- Morpho Blue is the core protocol of the Morpho - ecosystem. It's a decentralized, immutable, and neutral lending protocol that enables the - creation of lending markets with any assets in a truly decentralized manner. -

-

- Built with a minimalistic approach, Morpho Blue is the foundation of the entire Morpho - ecosystem. Its efficiency and security have made it highly regarded in the DeFi community. -

-

- Monarch serves as an advanced interface for Morpho Blue, providing users with a gateway to - interact with this powerful core protocol. -

- - ), - }, - { - mainTitle: 'Understanding the Ecosystem', - subTitle: 'Morpho Vaults', - image: vaultsImage, - content: ( - <> -

- The Morpho Lab team introduces Morpho Vaults, - intermediate contracts managed by curators to simplify risk management for normal - suppliers. -

-
- - -
- - ), - }, - { - mainTitle: 'Monarch: Empowering Advanced Users', - subTitle: 'Direct Market Access', - image: monarchImage, - content: ( - <> -

- Monarch empowers advanced users by enabling{' '} - direct lending to Morpho Blue markets, bypassing the - need for vaults. -

-
- - -
- - ), - }, - { - mainTitle: 'Monarch Agent', - subTitle: 'Automated Position Management', - image: monarchAgentImg, - isNew: true, - content: ( - <> -

- Introducing Monarch Agent -
- The Monarch Agent is your personal companion that helps optimize your lending strategy - across Morpho Blue markets. -

-
- - -
-

- We Value Your Feedback! -
- Your input is crucial in shaping Monarch's future. Share your thoughts in our{' '} - - Discord - - . -

- - ), - }, -]; diff --git a/app/info/page.tsx b/app/info/page.tsx deleted file mode 100644 index 9f0bf08a..00000000 --- a/app/info/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { generateMetadata } from '@/utils/generateMetadata'; - -import InfoContent from './components/info'; - -export const metadata = generateMetadata({ - title: 'Info | Monarch', - description: 'Permission-less access to morpho blue protocol', - images: 'themes.png', - pathname: '', -}); - -export default function MarketPage() { - return ; -} diff --git a/app/markets/components/MarketSettingsModal.tsx b/app/markets/components/MarketSettingsModal.tsx index c9788705..b2d97e41 100644 --- a/app/markets/components/MarketSettingsModal.tsx +++ b/app/markets/components/MarketSettingsModal.tsx @@ -20,12 +20,20 @@ type MarketSettingsModalProps = { setIncludeUnknownTokens: (value: boolean) => void; showUnknownOracle: boolean; setShowUnknownOracle: (value: boolean) => void; - // USD Filters (Simplified) + // USD Filters (with enabled/disabled states) usdFilters: { minSupply: string; minBorrow: string; + minLiquidity: string; }; setUsdFilters: (filters: MarketSettingsModalProps['usdFilters']) => void; + // USD Filter enabled states + minSupplyEnabled: boolean; + setMinSupplyEnabled: (value: boolean) => void; + minBorrowEnabled: boolean; + setMinBorrowEnabled: (value: boolean) => void; + minLiquidityEnabled: boolean; + setMinLiquidityEnabled: (value: boolean) => void; // Pagination entriesPerPage: number; onEntriesPerPageChange: (value: number) => void; @@ -65,6 +73,12 @@ export default function MarketSettingsModal({ setShowUnknownOracle, usdFilters, setUsdFilters, + minSupplyEnabled, + setMinSupplyEnabled, + minBorrowEnabled, + setMinBorrowEnabled, + minLiquidityEnabled, + setMinLiquidityEnabled, entriesPerPage, onEntriesPerPageChange, }: MarketSettingsModalProps) { @@ -172,70 +186,152 @@ export default function MarketSettingsModal({ Filter by Min USD Value

- {' '} - {/* Fine-tune note position */} Note: USD values are estimates and may not be available or accurate for all - markets. + markets. Toggle the switch to enable/disable each filter.

- - - - $ - -
- } - /> - + + {/* Min Supply Filter */} +
+
+
+ +

+ Min Supply (USD) +

+
+

+ Show markets with total supply >= this value. This filter can also be toggled from the main page. +

+
+
+ + + $ + +
+ } + /> +
+
- - - - $ - -
- } - /> - + + {/* Min Borrow Filter */} +
+
+
+ +

+ Min Borrow (USD) +

+
+

+ Show markets with total borrow >= this value. +

+
+
+ + + $ + +
+ } + /> +
+
+ + + {/* Min Liquidity Filter */} +
+
+
+ +

+ Min Liquidity (USD) +

+
+

+ Show markets with available liquidity >= this value. +

+
+
+ + + $ + +
+ } + /> +
+ {/* --- View Options Section --- */} diff --git a/app/markets/components/markets.tsx b/app/markets/components/markets.tsx index 49bcbdf7..d3498d02 100644 --- a/app/markets/components/markets.tsx +++ b/app/markets/components/markets.tsx @@ -11,15 +11,17 @@ import { useTokens } from '@/components/providers/TokenProvider'; import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModalV2 } from '@/components/SupplyModalV2'; -import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; +import { DEFAULT_MIN_SUPPLY_USD, DEFAULT_MIN_LIQUIDITY_USD } from '@/constants/markets'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { usePagination } from '@/hooks/usePagination'; import { useStaredMarkets } from '@/hooks/useStaredMarkets'; import { useStyledToast } from '@/hooks/useStyledToast'; import { formatReadable } from '@/utils/balance'; +import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters'; +import { parseNumericThreshold } from '@/utils/markets'; import { SupportedNetworks } from '@/utils/networks'; -import { PriceFeedVendors, parsePriceFeedVendors } from '@/utils/oracle'; +import { PriceFeedVendors } from '@/utils/oracle'; import * as keys from '@/utils/storageKeys'; import { ERC20Token, UnknownERC20Token } from '@/utils/tokens'; import { Market } from '@/utils/types'; @@ -31,7 +33,6 @@ import MarketSettingsModal from './MarketSettingsModal'; import MarketsTable from './marketsTable'; import NetworkFilter from './NetworkFilter'; import OracleFilter from './OracleFilter'; -import { applyFilterAndSort } from './utils'; type MarketContentProps = { initialNetwork: SupportedNetworks | null; @@ -39,19 +40,6 @@ type MarketContentProps = { initialLoanAssets: string[]; }; -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); -}; - export default function Markets({ initialNetwork, initialCollaterals, @@ -100,34 +88,54 @@ export default function Markets({ false, ); const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(keys.MarketsShowUnknownOracle, false); - const [hideSmallMarkets, setHideSmallMarkets] = useLocalStorage(keys.MarketsShowSmallMarkets, true); const { allTokens, findToken } = useTokens(); + // USD Filter values const [usdMinSupply, setUsdMinSupply] = useLocalStorage( keys.MarketsUsdMinSupplyKey, DEFAULT_MIN_SUPPLY_USD.toString(), ); const [usdMinBorrow, setUsdMinBorrow] = useLocalStorage(keys.MarketsUsdMinBorrowKey, ''); + const [usdMinLiquidity, setUsdMinLiquidity] = useLocalStorage( + keys.MarketsUsdMinLiquidityKey, + DEFAULT_MIN_LIQUIDITY_USD.toString(), + ); + + // USD Filter enabled states + const [minSupplyEnabled, setMinSupplyEnabled] = useLocalStorage( + keys.MarketsMinSupplyEnabledKey, + true, // Default to enabled for backward compatibility + ); + const [minBorrowEnabled, setMinBorrowEnabled] = useLocalStorage( + keys.MarketsMinBorrowEnabledKey, + false, + ); + const [minLiquidityEnabled, setMinLiquidityEnabled] = useLocalStorage( + keys.MarketsMinLiquidityEnabledKey, + false, + ); // Create memoized usdFilters object from individual localStorage values to prevent re-renders const usdFilters = useMemo( () => ({ minSupply: usdMinSupply, minBorrow: usdMinBorrow, + minLiquidity: usdMinLiquidity, }), - [usdMinSupply, usdMinBorrow], + [usdMinSupply, usdMinBorrow, usdMinLiquidity], ); const setUsdFilters = useCallback( - (filters: { minSupply: string; minBorrow: string }) => { + (filters: { minSupply: string; minBorrow: string; minLiquidity: string }) => { setUsdMinSupply(filters.minSupply); setUsdMinBorrow(filters.minBorrow); + setUsdMinLiquidity(filters.minLiquidity); }, - [setUsdMinSupply, setUsdMinBorrow], + [setUsdMinSupply, setUsdMinBorrow, setUsdMinLiquidity], ); - const effectiveMinSupply = getMinSupplyThreshold(usdFilters.minSupply); + const effectiveMinSupply = parseNumericThreshold(usdFilters.minSupply); useEffect(() => { // return if no markets @@ -214,33 +222,46 @@ export default function Markets({ const applyFiltersAndSort = useCallback(() => { if (!rawMarkets) return; - const filtered = applyFilterAndSort( - rawMarkets, - sortColumn, - sortDirection, + // Apply filters using the new composable filtering system + const filtered = filterMarkets(rawMarkets, { selectedNetwork, - includeUnknownTokens, + showUnknownTokens: includeUnknownTokens, showUnknownOracle, selectedCollaterals, selectedLoanAssets, selectedOracles, - staredIds, + usdFilters: { + minSupply: { enabled: minSupplyEnabled, threshold: usdFilters.minSupply }, + minBorrow: { enabled: minBorrowEnabled, threshold: usdFilters.minBorrow }, + minLiquidity: { enabled: minLiquidityEnabled, threshold: usdFilters.minLiquidity }, + }, findToken, - usdFilters, - hideSmallMarkets, - ).filter((market) => { - if (!searchQuery) return true; // If no search query, show all markets - const lowercaseQuery = searchQuery.toLowerCase(); - const { vendors } = parsePriceFeedVendors(market.oracle?.data, market.morphoBlue.chain.id); - const vendorsName = vendors.join(','); - return ( - market.uniqueKey.toLowerCase().includes(lowercaseQuery) || - market.collateralAsset.symbol.toLowerCase().includes(lowercaseQuery) || - market.loanAsset.symbol.toLowerCase().includes(lowercaseQuery) || - vendorsName.toLowerCase().includes(lowercaseQuery) - ); + searchQuery, }); - setFilteredMarkets(filtered); + + // Apply sorting + let sorted: Market[]; + if (sortColumn === SortColumn.Starred) { + sorted = sortMarkets(filtered, createStarredSort(staredIds), 1); + } else { + const sortPropertyMap: Record = { + [SortColumn.Starred]: 'uniqueKey', + [SortColumn.LoanAsset]: 'loanAsset.name', + [SortColumn.CollateralAsset]: 'collateralAsset.name', + [SortColumn.LLTV]: 'lltv', + [SortColumn.Supply]: 'state.supplyAssetsUsd', + [SortColumn.Borrow]: 'state.borrowAssetsUsd', + [SortColumn.SupplyAPY]: 'state.supplyApy', + }; + const propertyPath = sortPropertyMap[sortColumn]; + if (propertyPath) { + sorted = sortMarkets(filtered, createPropertySort(propertyPath), sortDirection as 1 | -1); + } else { + sorted = filtered; + } + } + + setFilteredMarkets(sorted); resetPage(); }, [ rawMarkets, @@ -255,7 +276,9 @@ export default function Markets({ staredIds, findToken, usdFilters, - hideSmallMarkets, + minSupplyEnabled, + minBorrowEnabled, + minLiquidityEnabled, searchQuery, resetPage, ]); @@ -358,6 +381,12 @@ export default function Markets({ setShowUnknownOracle={setShowUnknownOracle} usdFilters={usdFilters} setUsdFilters={setUsdFilters} + minSupplyEnabled={minSupplyEnabled} + setMinSupplyEnabled={setMinSupplyEnabled} + minBorrowEnabled={minBorrowEnabled} + setMinBorrowEnabled={setMinBorrowEnabled} + minLiquidityEnabled={minLiquidityEnabled} + setMinLiquidityEnabled={setMinLiquidityEnabled} entriesPerPage={entriesPerPage} onEntriesPerPageChange={handleEntriesPerPageChange} /> @@ -423,8 +452,8 @@ export default function Markets({
Hide markets below ${formatReadable(effectiveMinSupply)} @@ -486,6 +515,8 @@ export default function Markets({ ? "Try enabling 'Show Unknown Tokens' in settings, or adjust your current filters." : selectedOracles.length > 0 && !showUnknownOracle ? "Try enabling 'Show Unknown Oracles' in settings, or adjust your oracle filters." + : minSupplyEnabled || minBorrowEnabled || minLiquidityEnabled + ? 'Try disabling USD filters in settings, or adjust your filter thresholds.' : 'Try adjusting your filters or search query to see more results.' } /> diff --git a/app/page.tsx b/app/page.tsx index 8c985418..4353c000 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +1,15 @@ import { generateMetadata } from '@/utils/generateMetadata'; -import HomePage from './home/HomePage'; +import HomePage from './HomePage'; export const metadata = generateMetadata({ title: 'Monarch', - description: 'Customized lending with Morpho Blue', + description: 'Customized lending on Morpho Blue with no intermediaries', images: 'themes.png', pathname: '', }); /** - * Server component, which imports the Home component (client component that has 'use client' in it) + * Server component, which imports the HomePage component (client component that has 'use client' in it) * https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts * 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 diff --git a/app/info/components/risk.tsx b/app/risks/RiskContent.tsx similarity index 95% rename from app/info/components/risk.tsx rename to app/risks/RiskContent.tsx index f0a2a1d0..b083edc7 100644 --- a/app/info/components/risk.tsx +++ b/app/risks/RiskContent.tsx @@ -50,7 +50,7 @@ const riskSections = [ }, ]; -function RiskPage() { +function RiskContent() { return (
@@ -60,8 +60,8 @@ function RiskPage() {

This page covers advanced topics. For a comprehensive overview of Monarch, please visit our{' '} - - introduction page + + home page .

@@ -91,4 +91,4 @@ function RiskPage() { ); } -export default RiskPage; +export default RiskContent; diff --git a/app/info/risks/page.tsx b/app/risks/page.tsx similarity index 86% rename from app/info/risks/page.tsx rename to app/risks/page.tsx index 24da7b4f..6c8574b5 100644 --- a/app/info/risks/page.tsx +++ b/app/risks/page.tsx @@ -1,6 +1,5 @@ import { generateMetadata } from '@/utils/generateMetadata'; - -import RiskContent from '../components/risk'; +import RiskContent from './RiskContent'; export const metadata = generateMetadata({ title: 'Risks | Monarch', diff --git a/package.json b/package.json index ca2c6aba..14a98d58 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@types/react-table": "^7.7.20", "@uniswap/permit2-sdk": "^1.2.1", "abitype": "^0.10.3", + "animejs": "^4.2.2", "clsx": "^2.1.0", "downshift": "^9.0.8", "framer-motion": "^11.2.10", @@ -62,6 +63,7 @@ "react-spinners": "^0.14.1", "react-table": "^7.8.0", "react-toastify": "11.0.2", + "react-type-animation": "^3.2.0", "recharts": "^2.13.0", "rehype-pretty-code": "0.12.3", "rehype-stringify": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ba42bf..0956ecd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: abitype: specifier: ^0.10.3 version: 0.10.3(typescript@5.6.3)(zod@3.25.76) + animejs: + specifier: ^4.2.2 + version: 4.2.2 clsx: specifier: ^2.1.0 version: 2.1.1 @@ -137,6 +140,9 @@ importers: react-toastify: specifier: 11.0.2 version: 11.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-type-animation: + specifier: ^3.2.0 + version: 3.2.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) recharts: specifier: ^2.13.0 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3892,6 +3898,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + animejs@4.2.2: + resolution: {integrity: sha512-Ys3RuvLdAeI14fsdKCQy7ytu4057QX6Bb7m4jwmfd6iKmUmLquTwk1ut0e4NtRQgCeq/s2Lv5+oMBjz6c7ZuIg==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -6739,6 +6748,13 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-type-animation@3.2.0: + resolution: {integrity: sha512-WXTe0i3rRNKjmggPvT5ntye1QBt0ATGbijeW6V3cQe2W0jaMABXXlPPEdtofnS9tM7wSRHchEvI9SUw+0kUohw==} + peerDependencies: + prop-types: ^15.5.4 + react: '>= 15.0.0' + react-dom: '>= 15.0.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -13437,6 +13453,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + animejs@4.2.2: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -16893,6 +16911,12 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-type-animation@3.2.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/src/components/RiskNotificationModal.tsx b/src/components/RiskNotificationModal.tsx index 1a41b940..f71aeccf 100644 --- a/src/components/RiskNotificationModal.tsx +++ b/src/components/RiskNotificationModal.tsx @@ -20,7 +20,7 @@ export default function RiskNotificationModal() { useEffect(() => { const hasReadRisks = localStorage.getItem('hasReadRisks'); - if (hasReadRisks !== 'true' && pathname !== '/info/risks') { + if (hasReadRisks !== 'true' && pathname !== '/risks') { setIsOpen(true); } }, [pathname]); @@ -32,7 +32,7 @@ export default function RiskNotificationModal() { } }; - if (pathname === '/info/risks' || pathname === '/' || pathname === '/info') { + if (pathname === '/risks' || pathname === '/') { return null; } @@ -47,8 +47,8 @@ export default function RiskNotificationModal() { Monarch enables direct lending to the Morpho Blue protocol. Before proceeding, it's important to understand the key aspects of this approach. For a comprehensive overview, please visit our{' '} - - introduction page + + home page .

@@ -65,7 +65,7 @@ export default function RiskNotificationModal() { While this approach offers more control, it also requires a deeper understanding of market dynamics. For a detailed explanation of the risks and considerations, please read our{' '} - + risk assessment page . diff --git a/src/components/animations/RebalanceAnimation.tsx b/src/components/animations/RebalanceAnimation.tsx new file mode 100644 index 00000000..d5825977 --- /dev/null +++ b/src/components/animations/RebalanceAnimation.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Image from 'next/image'; + +// Import token images +import monarchLogo from '../../components/imgs/logo.png'; +import cbBTCImg from '../../imgs/tokens/cbbtc.webp'; +import cbETHImg from '../../imgs/tokens/cbeth.png'; +import usdcImg from '../../imgs/tokens/usdc.webp'; +import wethImg from '../../imgs/tokens/weth.webp'; +import wstETHImg from '../../imgs/tokens/wsteth.webp'; + +type Token = { + name: string; + image: any; +}; + +const tokens: Token[] = [ + { name: 'USDC', image: usdcImg }, + { name: 'cbBTC', image: cbBTCImg }, + { name: 'cbETH', image: cbETHImg }, + { name: 'wstETH', image: wstETHImg }, + { name: 'WETH', image: wethImg }, + { name: 'Monarch', image: monarchLogo }, +]; + +type SlotState = { + currentIndex: number; + isSpinning: boolean; + nextIndex: number; +}; + +function RebalanceAnimation() { + const [slots, setSlots] = useState([ + { currentIndex: 0, isSpinning: false, nextIndex: 0 }, + { currentIndex: 1, isSpinning: false, nextIndex: 1 }, + { currentIndex: 2, isSpinning: false, nextIndex: 2 }, + ]); + + useEffect(() => { + const interval = setInterval(() => { + const SLOT_COUNT = 3 + + // Randomly decide how many slots to change (1-3) + const numSlotsToChange = Math.floor(Math.random() * SLOT_COUNT) + 1; + + // Randomly select which slots to change + const slotsToChange = new Set(); + while (slotsToChange.size < numSlotsToChange) { + slotsToChange.add(Math.floor(Math.random() * SLOT_COUNT)); + } + + setSlots((prevSlots) => + prevSlots.map((slot, index) => { + if (slotsToChange.has(index)) { + // Pick a random next token + const nextIndex = Math.floor(Math.random() * tokens.length); + return { + ...slot, + isSpinning: true, + nextIndex, + }; + } + return slot; + }) + ); + + // After animation duration, update the current index + setTimeout(() => { + setSlots((prevSlots) => + prevSlots.map((slot, index) => { + if (slotsToChange.has(index)) { + return { + currentIndex: slot.nextIndex, + isSpinning: false, + nextIndex: slot.nextIndex, + }; + } + return slot; + }) + ); + }, 500); // Match this with CSS transition duration + }, 3000); // Change every 3 seconds + + return () => clearInterval(interval); + }, []); + + return ( +
+ {slots.map((slot, index) => ( +
+ {/* Current token - slides up when spinning */} +
+ {tokens[slot.currentIndex].name} +
+ {/* Next token - slides up from bottom when spinning */} +
+ {tokens[slot.nextIndex].name} +
+
+ ))} +
+ ); +} + +export default RebalanceAnimation; diff --git a/src/components/common/MarketsTableWithSameLoanAsset.tsx b/src/components/common/MarketsTableWithSameLoanAsset.tsx index fe5aac05..6ad1ff37 100644 --- a/src/components/common/MarketsTableWithSameLoanAsset.tsx +++ b/src/components/common/MarketsTableWithSameLoanAsset.tsx @@ -8,10 +8,12 @@ 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 { DEFAULT_MIN_SUPPLY_USD, DEFAULT_MIN_LIQUIDITY_USD } from '@/constants/markets'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useMarkets } from '@/hooks/useMarkets'; import { formatBalance, formatReadable } from '@/utils/balance'; +import { filterMarkets, sortMarkets, createPropertySort } from '@/utils/marketFilters'; +import { parseNumericThreshold } from '@/utils/markets'; import { getViemChain } from '@/utils/networks'; import { parsePriceFeedVendors, PriceFeedVendors, OracleVendorIcons } from '@/utils/oracle'; import * as keys from "@/utils/storageKeys" @@ -52,19 +54,6 @@ 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, @@ -478,7 +467,6 @@ export function MarketsTableWithSameLoanAsset({ const [searchQuery, setSearchQuery] = useState(''); // Settings state (persisted with storage key namespace) - const [hideSmallMarkets, setHideSmallMarkets] = useLocalStorage(keys.MarketsShowSmallMarkets, true); const [entriesPerPage, setEntriesPerPage] = useLocalStorage(keys.MarketEntriesPerPageKey, 8); const [includeUnknownTokens, setIncludeUnknownTokens] = useLocalStorage(keys.MarketsShowUnknownTokens, false); const [showUnknownOracle, setShowUnknownOracle] = useLocalStorage(keys.MarketsShowUnknownOracle, false); @@ -489,25 +477,45 @@ export function MarketsTableWithSameLoanAsset({ DEFAULT_MIN_SUPPLY_USD.toString(), ); const [usdMinBorrow, setUsdMinBorrow] = useLocalStorage(keys.MarketsUsdMinBorrowKey, ''); + const [usdMinLiquidity, setUsdMinLiquidity] = useLocalStorage( + keys.MarketsUsdMinLiquidityKey, + DEFAULT_MIN_LIQUIDITY_USD.toString(), + ); + + // USD Filter enabled states + const [minSupplyEnabled, setMinSupplyEnabled] = useLocalStorage( + keys.MarketsMinSupplyEnabledKey, + true, // Default to enabled for backward compatibility + ); + const [minBorrowEnabled, setMinBorrowEnabled] = useLocalStorage( + keys.MarketsMinBorrowEnabledKey, + false, + ); + const [minLiquidityEnabled, setMinLiquidityEnabled] = useLocalStorage( + keys.MarketsMinLiquidityEnabledKey, + false, + ); // Create memoized usdFilters object from individual localStorage values const usdFilters = useMemo( () => ({ minSupply: usdMinSupply, minBorrow: usdMinBorrow, + minLiquidity: usdMinLiquidity, }), - [usdMinSupply, usdMinBorrow], + [usdMinSupply, usdMinBorrow, usdMinLiquidity], ); const setUsdFilters = useCallback( - (filters: { minSupply: string; minBorrow: string }) => { + (filters: { minSupply: string; minBorrow: string; minLiquidity: string }) => { setUsdMinSupply(filters.minSupply); setUsdMinBorrow(filters.minBorrow); + setUsdMinLiquidity(filters.minLiquidity); }, - [setUsdMinSupply, setUsdMinBorrow], + [setUsdMinSupply, setUsdMinBorrow, setUsdMinLiquidity], ); - const effectiveMinSupply = getMinSupplyThreshold(usdFilters.minSupply); + const effectiveMinSupply = parseNumericThreshold(usdFilters.minSupply); const handleSort = (column: SortColumn) => { if (sortColumn === column) { @@ -584,87 +592,50 @@ export function MarketsTableWithSameLoanAsset({ return Array.from(oracleSet); }, [markets]); - // Filter and sort markets + // Filter and sort markets using the new shared filtering system 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); - }); - } + // Extract just the markets for filtering + const marketsList = markets.map((m) => m.market); + + // Apply global filters using the shared utility + let filtered = filterMarkets(marketsList, { + showUnknownTokens: includeUnknownTokens, + showUnknownOracle, + selectedCollaterals: collateralFilter, + selectedOracles: oracleFilter, + usdFilters: { + minSupply: { enabled: minSupplyEnabled, threshold: usdFilters.minSupply }, + minBorrow: { enabled: minBorrowEnabled, threshold: usdFilters.minBorrow }, + minLiquidity: { enabled: minLiquidityEnabled, threshold: usdFilters.minLiquidity }, + }, + findToken, + searchQuery, + }); - // Apply whitelist filter + // Apply whitelist filter (not in the shared utility because it uses global state) if (!showUnwhitelistedMarkets) { - filtered = filtered.filter((m) => m.market?.whitelisted ?? false); - } - - // Apply small markets filter - if (hideSmallMarkets) { - filtered = filtered.filter((m) => { - const supplyUsd = Number(m.market?.state?.supplyAssetsUsd ?? 0); - return effectiveMinSupply === 0 || supplyUsd >= effectiveMinSupply; - }); + filtered = filtered.filter((market) => market.whitelisted ?? false); } - // Apply collateral filter - if (collateralFilter.length > 0) { - filtered = filtered.filter((m) => { - // Add null checks - if (!m?.market?.collateralAsset?.address || !m?.market?.morphoBlue?.chain?.id) { - return false; - } - const key = infoToKey(m.market.collateralAsset.address, m.market.morphoBlue.chain.id); - return collateralFilter.some((filterKey) => - filterKey.split('|').includes(key) - ); - }); - } + // Sort using the shared utility + const sortPropertyMap: Record = { + [SortColumn.MarketName]: 'collateralAsset.symbol', + [SortColumn.Supply]: 'state.supplyAssetsUsd', + [SortColumn.APY]: 'state.supplyApy', + [SortColumn.Liquidity]: 'state.liquidityAssets', + [SortColumn.Risk]: '', // No sorting for risk + }; - // Apply oracle filter - if (oracleFilter.length > 0) { - filtered = filtered.filter((m) => { - // Add null checks - if (!m?.market?.morphoBlue?.chain?.id) { - return false; - } - const vendorInfo = parsePriceFeedVendors(m.market.oracle?.data, m.market.morphoBlue.chain.id); - return vendorInfo?.coreVendors?.some((v) => oracleFilter.includes(v)) ?? false; - }); + const propertyPath = sortPropertyMap[sortColumn]; + if (propertyPath && sortColumn !== SortColumn.Risk) { + filtered = sortMarkets(filtered, createPropertySort(propertyPath), sortDirection); } - // Sort - filtered.sort((a, b) => { - let comparison = 0; - switch (sortColumn) { - case SortColumn.MarketName: - comparison = (a.market?.collateralAsset?.symbol ?? '').localeCompare( - b.market?.collateralAsset?.symbol ?? '', - ); - break; - case SortColumn.Supply: - comparison = - Number(a.market?.state?.supplyAssetsUsd ?? 0) - Number(b.market?.state?.supplyAssetsUsd ?? 0); - break; - case SortColumn.APY: - comparison = (a.market?.state?.supplyApy ?? 0) - (b.market?.state?.supplyApy ?? 0); - break; - case SortColumn.Liquidity: - comparison = - Number(a.market?.state?.liquidityAssets ?? 0) - Number(b.market?.state?.liquidityAssets ?? 0); - break; - case SortColumn.Risk: - comparison = 0; - break; - } - return comparison * sortDirection; + // Map back to MarketWithSelection + return filtered.map((market) => { + const original = markets.find((m) => m.market.uniqueKey === market.uniqueKey); + return original ?? { market, isSelected: false }; }); - - return filtered; }, [ markets, collateralFilter, @@ -673,8 +644,13 @@ export function MarketsTableWithSameLoanAsset({ sortDirection, searchQuery, showUnwhitelistedMarkets, - hideSmallMarkets, - effectiveMinSupply, + includeUnknownTokens, + showUnknownOracle, + minSupplyEnabled, + minBorrowEnabled, + minLiquidityEnabled, + usdFilters, + findToken, ]); // Get selected markets @@ -758,11 +734,11 @@ export function MarketsTableWithSameLoanAsset({
- Hide markets below ${effectiveMinSupply.toLocaleString()} + Hide markets below ${formatReadable(effectiveMinSupply)}
{showSettings && ( @@ -873,6 +849,12 @@ export function MarketsTableWithSameLoanAsset({ setShowUnknownOracle={setShowUnknownOracle} usdFilters={usdFilters} setUsdFilters={setUsdFilters} + minSupplyEnabled={minSupplyEnabled} + setMinSupplyEnabled={setMinSupplyEnabled} + minBorrowEnabled={minBorrowEnabled} + setMinBorrowEnabled={setMinBorrowEnabled} + minLiquidityEnabled={minLiquidityEnabled} + setMinLiquidityEnabled={setMinLiquidityEnabled} entriesPerPage={entriesPerPage} onEntriesPerPageChange={setEntriesPerPage} /> diff --git a/src/constants/markets.ts b/src/constants/markets.ts index d1d1e3bc..84f1f936 100644 --- a/src/constants/markets.ts +++ b/src/constants/markets.ts @@ -1 +1,2 @@ export const DEFAULT_MIN_SUPPLY_USD = 1000; +export const DEFAULT_MIN_LIQUIDITY_USD = 10000; diff --git a/src/hooks/useAllocations.ts b/src/hooks/useAllocations.ts index 8b8b30dd..07306895 100644 --- a/src/hooks/useAllocations.ts +++ b/src/hooks/useAllocations.ts @@ -31,7 +31,7 @@ export function useAllocations({ enabled = true, }: UseAllocationsArgs): UseAllocationsReturn { const [allocations, setAllocations] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Create a stable key from capIds to detect actual changes @@ -42,6 +42,7 @@ export function useAllocations({ const load = useCallback(async () => { if (!vaultAddress || !enabled || caps.length === 0) { setAllocations([]); + setLoading(false); return; } diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts index 44a6d953..cdfedd40 100644 --- a/src/hooks/useMorphoMarketV1Adapters.ts +++ b/src/hooks/useMorphoMarketV1Adapters.ts @@ -12,7 +12,7 @@ export function useMorphoMarketV1Adapters({ chainId: SupportedNetworks; }) { const [adapters, setAdapters] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const vaultConfig = useMemo(() => { @@ -30,6 +30,7 @@ export function useMorphoMarketV1Adapters({ if (!vaultAddress || !subgraphUrl) { setAdapters([]); setError(null); + setLoading(false); return; } diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 9ace3915..aba7ad53 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -52,12 +52,13 @@ export function useVaultV2Data({ const { findToken } = useTokens(); const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { if (!vaultAddress) { setData(null); + setLoading(false); return; } diff --git a/src/imgs/intro/morpho-logo-darkmode.svg b/src/imgs/intro/morpho-logo-darkmode.svg new file mode 100644 index 00000000..813a9de5 --- /dev/null +++ b/src/imgs/intro/morpho-logo-darkmode.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/imgs/intro/morpho-logo-lightmode.svg b/src/imgs/intro/morpho-logo-lightmode.svg new file mode 100644 index 00000000..9246b5b4 --- /dev/null +++ b/src/imgs/intro/morpho-logo-lightmode.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/imgs/intro/vault.webp b/src/imgs/intro/vault.webp new file mode 100644 index 00000000..991fac4e Binary files /dev/null and b/src/imgs/intro/vault.webp differ diff --git a/src/imgs/intro/vaults.png b/src/imgs/intro/vaults.png deleted file mode 100644 index b83697f7..00000000 Binary files a/src/imgs/intro/vaults.png and /dev/null differ diff --git a/src/utils/marketFilters.ts b/src/utils/marketFilters.ts new file mode 100644 index 00000000..df31ca74 --- /dev/null +++ b/src/utils/marketFilters.ts @@ -0,0 +1,377 @@ +/** + * Shared, composable filtering utilities for market tables. + * + * This module provides a flexible filtering system that can be used across + * different market views (main markets page, same-loan-asset tables, etc.) + */ + +import { parseNumericThreshold } from '@/utils/markets'; +import { SupportedNetworks } from '@/utils/networks'; +import { parsePriceFeedVendors, PriceFeedVendors, getOracleType, OracleType } from '@/utils/oracle'; +import { ERC20Token } from '@/utils/tokens'; +import { Market } from '@/utils/types'; + +// ============================================================================ +// Types +// ============================================================================ + +export type MarketFilter = (market: Market) => boolean; + +export type UsdFilterConfig = { + enabled: boolean; + threshold: string; // stored as string for input compatibility +}; + +export type UsdFiltersConfig = { + minSupply: UsdFilterConfig; + minBorrow: UsdFilterConfig; + minLiquidity: UsdFilterConfig; +}; + +export type MarketFilterOptions = { + // Network filter + selectedNetwork?: SupportedNetworks | null; + + // Token visibility + showUnknownTokens?: boolean; + showUnknownOracle?: boolean; + + // Asset filters + selectedCollaterals?: string[]; + selectedLoanAssets?: string[]; + + // Oracle filters + selectedOracles?: PriceFeedVendors[]; + + // USD filters with enabled/disabled states + usdFilters?: UsdFiltersConfig; + + // Helper function to find tokens + findToken?: (address: string, chainId: number) => ERC20Token | undefined; + + // Search query + searchQuery?: string; + + // Starred markets + staredIds?: string[]; +}; + +// ============================================================================ +// Individual Filter Functions (Composable) +// ============================================================================ + +/** + * Filter by network/chain + */ +export const createNetworkFilter = (selectedNetwork: SupportedNetworks | null): MarketFilter => { + if (selectedNetwork === null) { + return () => true; + } + return (market) => market.morphoBlue.chain.id === selectedNetwork; +}; + +/** + * Filter by unknown tokens (requires token lookup) + */ +export const createUnknownTokenFilter = ( + showUnknown: boolean, + findToken: (address: string, chainId: number) => ERC20Token | undefined, +): MarketFilter => { + if (showUnknown) { + return () => true; + } + return (market) => { + const collateralToken = findToken(market.collateralAsset.address, market.morphoBlue.chain.id); + const loanToken = findToken(market.loanAsset.address, market.morphoBlue.chain.id); + return !!(collateralToken && loanToken); + }; +}; + +/** + * Filter by unknown oracles + */ +export const createUnknownOracleFilter = (showUnknownOracle: boolean): MarketFilter => { + if (showUnknownOracle) { + return () => true; + } + return (market) => { + if (!market.oracle) return false; + + const info = parsePriceFeedVendors(market.oracle.data, market.morphoBlue.chain.id); + const isCustom = + getOracleType(market.oracle?.data, market.oracleAddress, market.morphoBlue.chain.id) === + OracleType.Custom; + const isUnknown = isCustom || (info?.hasUnknown ?? false); + + return !isUnknown; + }; +}; + +/** + * Filter by selected collateral assets + */ +export const createCollateralFilter = (selectedCollaterals: string[]): MarketFilter => { + if (selectedCollaterals.length === 0) { + return () => true; + } + return (market) => { + return selectedCollaterals.some((combinedKey) => + combinedKey + .split('|') + .includes( + `${market.collateralAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`, + ), + ); + }; +}; + +/** + * Filter by selected loan assets + */ +export const createLoanAssetFilter = (selectedLoanAssets: string[]): MarketFilter => { + if (selectedLoanAssets.length === 0) { + return () => true; + } + return (market) => { + return selectedLoanAssets.some((combinedKey) => + combinedKey + .split('|') + .includes( + `${market.loanAsset.address.toLowerCase()}-${market.morphoBlue.chain.id}`, + ), + ); + }; +}; + +/** + * Filter by selected oracles + */ +export const createOracleFilter = (selectedOracles: PriceFeedVendors[]): MarketFilter => { + if (selectedOracles.length === 0) { + return () => true; + } + return (market) => { + if (!market.oracle) return false; + const marketOracles = parsePriceFeedVendors( + market.oracle.data, + market.morphoBlue.chain.id, + ).vendors; + return marketOracles.some((oracle) => selectedOracles.includes(oracle)); + }; +}; + +/** + * Filter by minimum supply USD (with enabled flag) + */ +export const createMinSupplyFilter = (config: UsdFilterConfig): MarketFilter => { + if (!config.enabled) { + return () => true; + } + const threshold = parseNumericThreshold(config.threshold); + if (threshold === 0) { + return () => true; + } + return (market) => { + const supplyUsd = Number(market.state?.supplyAssetsUsd ?? 0); + return supplyUsd >= threshold; + }; +}; + +/** + * Filter by minimum borrow USD (with enabled flag) + */ +export const createMinBorrowFilter = (config: UsdFilterConfig): MarketFilter => { + if (!config.enabled) { + return () => true; + } + const threshold = parseNumericThreshold(config.threshold); + if (threshold === 0) { + return () => true; + } + return (market) => { + const borrowUsd = Number(market.state?.borrowAssetsUsd ?? 0); + return borrowUsd >= threshold; + }; +}; + +/** + * Filter by minimum liquidity (with enabled flag) + */ +export const createMinLiquidityFilter = (config: UsdFilterConfig): MarketFilter => { + if (!config.enabled) { + return () => true; + } + const threshold = parseNumericThreshold(config.threshold); + if (threshold === 0) { + return () => true; + } + return (market) => { + const liquidityUsd = Number(market.state?.liquidityAssetsUsd ?? 0); + return liquidityUsd >= threshold; + }; +}; + +/** + * Filter by search query (collateral, loan, market ID, oracle vendors) + */ +export const createSearchFilter = (searchQuery: string): MarketFilter => { + if (!searchQuery || searchQuery.trim() === '') { + return () => true; + } + const lowercaseQuery = searchQuery.toLowerCase().trim(); + return (market) => { + const { vendors } = parsePriceFeedVendors(market.oracle?.data, market.morphoBlue.chain.id); + const vendorsName = vendors.join(','); + return ( + market.uniqueKey.toLowerCase().includes(lowercaseQuery) || + market.collateralAsset.symbol.toLowerCase().includes(lowercaseQuery) || + market.loanAsset.symbol.toLowerCase().includes(lowercaseQuery) || + vendorsName.toLowerCase().includes(lowercaseQuery) + ); + }; +}; + +/** + * Filter by whitelisted status (uses global setting) + */ +export const createWhitelistFilter = (showUnwhitelistedMarkets: boolean): MarketFilter => { + if (showUnwhitelistedMarkets) { + return () => true; + } + return (market) => market.whitelisted ?? false; +}; + +// ============================================================================ +// Combined Filtering +// ============================================================================ + +/** + * Apply multiple filters to a list of markets. + * All filters must pass for a market to be included. + */ +export const applyFilters = (markets: Market[], filters: MarketFilter[]): Market[] => { + return markets.filter((market) => filters.every((filter) => filter(market))); +}; + +/** + * Create all filters from options and apply them to markets. + * This is the main entry point for filtering markets. + */ +export const filterMarkets = ( + markets: Market[], + options: MarketFilterOptions, +): Market[] => { + const filters: MarketFilter[] = []; + + // Network filter + if (options.selectedNetwork !== undefined) { + filters.push(createNetworkFilter(options.selectedNetwork)); + } + + // Unknown tokens filter + if (options.showUnknownTokens !== undefined && options.findToken) { + filters.push(createUnknownTokenFilter(options.showUnknownTokens, options.findToken)); + } + + // Unknown oracle filter + if (options.showUnknownOracle !== undefined) { + filters.push(createUnknownOracleFilter(options.showUnknownOracle)); + } + + // Collateral filter + if (options.selectedCollaterals) { + filters.push(createCollateralFilter(options.selectedCollaterals)); + } + + // Loan asset filter + if (options.selectedLoanAssets) { + filters.push(createLoanAssetFilter(options.selectedLoanAssets)); + } + + // Oracle filter + if (options.selectedOracles) { + filters.push(createOracleFilter(options.selectedOracles)); + } + + // USD filters (with enabled flags) + if (options.usdFilters) { + filters.push(createMinSupplyFilter(options.usdFilters.minSupply)); + filters.push(createMinBorrowFilter(options.usdFilters.minBorrow)); + filters.push(createMinLiquidityFilter(options.usdFilters.minLiquidity)); + } + + // Search filter + if (options.searchQuery) { + filters.push(createSearchFilter(options.searchQuery)); + } + + return applyFilters(markets, filters); +}; + +// ============================================================================ +// Sorting +// ============================================================================ + +export type SortDirection = 1 | -1; // 1 = asc, -1 = desc + +export type MarketSortFn = (a: Market, b: Market) => number; + +/** + * Sort markets by a comparison function and direction + */ +export const sortMarkets = ( + markets: Market[], + sortFn: MarketSortFn, + direction: SortDirection = -1, +): Market[] => { + return [...markets].sort((a, b) => sortFn(a, b) * direction); +}; + +/** + * Get nested property from market object (helper for sorting) + */ +export const getNestedProperty = (obj: Market, path: string): unknown => { + if (!path) return undefined; + return path.split('.').reduce((acc: unknown, part: string) => { + return acc && typeof acc === 'object' && part in acc ? (acc as Record)[part] : undefined; + }, obj as unknown); +}; + +/** + * Create a sort function from a property path + */ +export const createPropertySort = (propertyPath: string): MarketSortFn => { + return (a, b) => { + const aValue: unknown = getNestedProperty(a, propertyPath); + const bValue: unknown = getNestedProperty(b, propertyPath); + + // Handle undefined/null cases + if (aValue === bValue) return 0; + if (aValue === undefined || aValue === null) return 1; + if (bValue === undefined || bValue === null) return -1; + + // Type guard for comparable values + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue > bValue ? 1 : -1; + } + if (typeof aValue === 'string' && typeof bValue === 'string') { + return aValue > bValue ? 1 : -1; + } + + // Fallback: convert to string for comparison + return String(aValue) > String(bValue) ? 1 : -1; + }; +}; + +/** + * Sort by starred status (starred markets first) + */ +export const createStarredSort = (staredIds: string[]): MarketSortFn => { + return (a, b) => { + const aStared = staredIds.includes(a.uniqueKey); + const bStared = staredIds.includes(b.uniqueKey); + if (aStared && !bStared) return -1; + if (!aStared && bStared) return 1; + return 0; + }; +}; diff --git a/src/utils/markets.ts b/src/utils/markets.ts index 9053ef85..ca3d484c 100644 --- a/src/utils/markets.ts +++ b/src/utils/markets.ts @@ -1,3 +1,22 @@ +/** + * Parse and normalize a numeric threshold value from user input. + * - Empty string or "0" → 0 (no threshold) + * - Invalid values → 0 + * - Valid positive numbers → parsed value + */ +export const parseNumericThreshold = (rawValue: string | undefined | null): number => { + if (rawValue === undefined || rawValue === null || rawValue === '' || rawValue === '0') { + return 0; + } + + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) { + return 0; + } + + return Math.max(parsed, 0); +}; + // Blacklisted markets by uniqueKey export const blacklistedMarkets = [ '0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9', // PAXG / USDC market with wrong oracle diff --git a/src/utils/storageKeys.ts b/src/utils/storageKeys.ts index 991a7dcc..3a44b067 100644 --- a/src/utils/storageKeys.ts +++ b/src/utils/storageKeys.ts @@ -6,6 +6,12 @@ export const MarketEntriesPerPageKey = 'monarch_marketsEntriesPerPage'; export const MarketsUsdMinSupplyKey = 'monarch_marketsUsdMinSupply_2'; export const MarketsUsdMinBorrowKey = 'monarch_marketsUsdMinBorrow'; +export const MarketsUsdMinLiquidityKey = 'monarch_marketsUsdMinLiquidity'; + +// USD Filter enabled/disabled states +export const MarketsMinSupplyEnabledKey = 'monarch_minSupplyEnabled'; +export const MarketsMinBorrowEnabledKey = 'monarch_minBorrowEnabled'; +export const MarketsMinLiquidityEnabledKey = 'monarch_minLiquidityEnabled'; export const PositionsShowEmptyKey = 'positions:show-empty'; export const PositionsShowCollateralExposureKey = 'positions:show-collateral-exposure'; @@ -14,6 +20,7 @@ export const ThemeKey = 'theme'; export const CacheMarketPositionKeys = 'monarch_cache_market_unique_keys'; +// Deprecated: Use MarketsMinSupplyEnabledKey instead export const MarketsShowSmallMarkets = 'monarch_show_small_markets' export const MarketsShowUnknownTokens = 'includeUnknownTokens'; export const MarketsShowUnknownOracle = 'showUnknownOracle'; \ No newline at end of file