From d702f24b1f523f51070d1b0725271f6d3e000938 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 5 Dec 2025 11:51:01 +0800 Subject: [PATCH 1/8] feat: table filtering --- .claude/settings.local.json | 3 +- app/global.css | 52 +++++ .../[marketid]/components/BorrowsTable.tsx | 210 ++++++++++------- .../components/LiquidationsTable.tsx | 56 +++-- .../[marketid]/components/SuppliesTable.tsx | 118 +++++++--- .../[marketid]/components/TablePagination.tsx | 220 ++++++++++++++++++ .../components/TransactionFiltersModal.tsx | 128 ++++++++++ app/market/[chainId]/[marketid]/content.tsx | 68 +++++- components.json | 20 ++ package.json | 2 + pnpm-lock.yaml | 29 +++ src/components/ui/button.tsx | 57 +++++ src/components/ui/pagination.tsx | 116 +++++++++ src/contexts/MarketsContext.tsx | 1 - src/data-sources/morpho-api/market-borrows.ts | 31 ++- .../morpho-api/market-supplies.ts | 31 ++- src/data-sources/subgraph/market-borrows.ts | 35 ++- src/data-sources/subgraph/market-supplies.ts | 37 ++- src/graphql/morpho-api-queries.ts | 6 +- src/graphql/morpho-subgraph-queries.ts | 24 +- src/hooks/useMarketBorrows.ts | 110 ++++++--- src/hooks/useMarketSupplies.ts | 110 ++++++--- src/hooks/useTransactionFilters.ts | 50 ++++ src/lib/utils.ts | 6 + src/utils/types.ts | 6 + tailwind.config.ts | 38 +++ 26 files changed, 1311 insertions(+), 253 deletions(-) create mode 100644 app/market/[chainId]/[marketid]/components/TablePagination.tsx create mode 100644 app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx create mode 100644 components.json create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/hooks/useTransactionFilters.ts create mode 100644 src/lib/utils.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4388ebb4..0041bd4a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -27,7 +27,8 @@ "Bash(timeout 30 pnpm exec tsc --noEmit --pretty)", "Bash(identify:*)", "Bash(sips:*)", - "Bash(awk:*)" + "Bash(awk:*)", + "Bash(pnpm dlx:*)" ], "deny": [] } diff --git a/app/global.css b/app/global.css index 1fbb0b3b..1144cfa7 100644 --- a/app/global.css +++ b/app/global.css @@ -5,6 +5,15 @@ @tailwind components; @tailwind utilities; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + :root { --quick-nav-display: none; --component-highlights-item-width: calc(100vw - 100px); @@ -23,6 +32,28 @@ --color-text: #16181a; --color-text-secondary: #8e8e8e; + /* shadcn variables */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 14 100% 57%; /* Morpho orange #f45f2d */ + --primary-foreground: 0 0% 100%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 100%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 14 100% 57%; + --radius: 0.5rem; + color-scheme: light; } @@ -43,6 +74,27 @@ --color-text: #fff; --color-text-secondary: #8e8e8e; + /* shadcn dark variables */ + --background: 222.2 84% 4.9%; + --foreground: 0 0% 100%; + --card: 222.2 84% 4.9%; + --card-foreground: 0 0% 100%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 0 0% 100%; + --primary: 14 100% 57%; /* Morpho orange #f45f2d */ + --primary-foreground: 0 0% 100%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 0 0% 100%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 0 0% 100%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 100%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 14 100% 57%; + color-scheme: dark; } diff --git a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx index 19f7c37f..090c203d 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx @@ -1,49 +1,56 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { - Pagination, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, + Tooltip, } from '@heroui/react'; +import { FiFilter } from 'react-icons/fi'; import moment from 'moment'; import { Address } from 'viem'; import { formatUnits } from 'viem'; import { AccountIdentity } from '@/components/common/AccountIdentity'; import { Badge } from '@/components/common/Badge'; +import { Button } from '@/components/common'; +import { Spinner } from '@/components/common/Spinner'; +import { TooltipContent } from '@/components/TooltipContent'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketBorrows } from '@/hooks/useMarketBorrows'; import { Market } from '@/utils/types'; +import { TablePagination } from './TablePagination'; type BorrowsTableProps = { chainId: number; market: Market; + minAssets: string; + onOpenFiltersModal: () => void; }; -export function BorrowsTable({ chainId, market }: BorrowsTableProps) { +export function BorrowsTable({ chainId, market, minAssets, onOpenFiltersModal }: BorrowsTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; const { - data: borrows, + data: paginatedData, isLoading, + isFetching, error, - } = useMarketBorrows(market?.uniqueKey, market.loanAsset.id, chainId); + } = useMarketBorrows(market?.uniqueKey, market.loanAsset.id, chainId, minAssets, currentPage, pageSize); - const totalPages = Math.ceil((borrows ?? []).length / pageSize); + const borrows = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; - const paginatedBorrows = useMemo(() => { - const sliced = (borrows ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); - return sliced; - }, [currentPage, borrows, pageSize]); - + const hasActiveFilter = minAssets !== '0'; const tableKey = `borrows-table-${currentPage}`; if (error) { @@ -56,80 +63,117 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { return (
-

Borrow & Repay

- - 1 ? ( -
- +

Borrow & Repay

+
+ } + /> + } + > +
- ) : null - } - > - - USER - TYPE - AMOUNT - TIME - - TRANSACTION - - - + +
+ + +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + +
- {paginatedBorrows.map((borrow) => ( - - - - - - - {borrow.type === 'MarketBorrow' ? 'Borrow' : 'Repay'} - - - - {formatUnits(BigInt(borrow.amount), market.loanAsset.decimals)} - {market?.loanAsset?.symbol && ( - - - - )} - - {moment.unix(borrow.timestamp).fromNow()} - - - - - ))} - -
+ + ACCOUNT + TYPE + AMOUNT + TIME + + TRANSACTION + + + + {borrows.map((borrow) => ( + + + + + + + {borrow.type === 'MarketBorrow' ? 'Borrow' : 'Repay'} + + + + {formatUnits(BigInt(borrow.amount), market.loanAsset.decimals)} + {market?.loanAsset?.symbol && ( + + + + )} + + {moment.unix(borrow.timestamp).fromNow()} + + + + + ))} + + +
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index e6a6097a..8bb50817 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from 'react'; import { - Pagination, Table, TableHeader, TableBody, @@ -11,10 +10,12 @@ import { import moment from 'moment'; import { Address, formatUnits } from 'viem'; import { AccountIdentity } from '@/components/common/AccountIdentity'; +import { Spinner } from '@/components/common/Spinner'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; import { useMarketLiquidations } from '@/hooks/useMarketLiquidations'; import { Market, MarketLiquidationTransaction } from '@/utils/types'; +import { TablePagination } from './TablePagination'; type LiquidationsTableProps = { chainId: number; @@ -31,7 +32,8 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { error, } = useMarketLiquidations(market?.uniqueKey, chainId); - const totalPages = Math.ceil((liquidations ?? []).length / pageSize); + const totalCount = (liquidations ?? []).length; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); @@ -56,28 +58,22 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) {

Liquidations

- 1 ? ( -
- -
- ) : null - } - > +
+ {/* Loading overlay */} + {isLoading && ( +
+ +
+ )} + +
LIQUIDATOR REPAID ({market?.loanAsset?.symbol ?? 'Loan'}) @@ -169,6 +165,18 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { })}
+
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx index f8dbd675..5dc11265 100644 --- a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx +++ b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx @@ -1,79 +1,113 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { - Pagination, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, + Tooltip, } from '@heroui/react'; +import { FiFilter } from 'react-icons/fi'; import moment from 'moment'; import { Address } from 'viem'; import { formatUnits } from 'viem'; import { AccountIdentity } from '@/components/common/AccountIdentity'; import { Badge } from '@/components/common/Badge'; +import { Button } from '@/components/common'; +import { Spinner } from '@/components/common/Spinner'; +import { TooltipContent } from '@/components/TooltipContent'; import { TransactionIdentity } from '@/components/common/TransactionIdentity'; import { TokenIcon } from '@/components/TokenIcon'; +import { MONARCH_PRIMARY } from '@/constants/chartColors'; import useMarketSupplies from '@/hooks/useMarketSupplies'; import { Market } from '@/utils/types'; +import { TablePagination } from './TablePagination'; type SuppliesTableProps = { chainId: number; market: Market; + minAssets: string; + onOpenFiltersModal: () => void; }; -export function SuppliesTable({ chainId, market }: SuppliesTableProps) { +export function SuppliesTable({ chainId, market, minAssets, onOpenFiltersModal }: SuppliesTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; - const { data: supplies, isLoading } = useMarketSupplies( + const { data: paginatedData, isLoading, isFetching } = useMarketSupplies( market?.uniqueKey, market.loanAsset.id, chainId, + minAssets, + currentPage, + pageSize, ); - const totalPages = Math.ceil((supplies ?? []).length / pageSize); + const supplies = paginatedData?.items ?? []; + const totalCount = paginatedData?.totalCount ?? 0; + const totalPages = Math.ceil(totalCount / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; - const paginatedSupplies = useMemo(() => { - const sliced = (supplies ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); - return sliced; - }, [currentPage, supplies, pageSize]); - + const hasActiveFilter = minAssets !== '0'; const tableKey = `supplies-table-${currentPage}`; return (
-

Supply & Withdraw

- - 1 ? ( -
- +

Supply & Withdraw

+
+ } /> -
- ) : null - } - > + } + > + + +
+ + +
+ {/* Loading overlay */} + {isFetching && ( +
+ +
+ )} + +
- USER + ACCOUNT TYPE AMOUNT TIME @@ -86,8 +120,8 @@ export function SuppliesTable({ chainId, market }: SuppliesTableProps) { emptyContent={isLoading ? 'Loading...' : 'No supply activities found for this market'} isLoading={isLoading} > - {paginatedSupplies.map((supply) => ( - + {supplies.map((supply) => ( +
+
+ + {totalCount > 0 && ( + + )} ); } diff --git a/app/market/[chainId]/[marketid]/components/TablePagination.tsx b/app/market/[chainId]/[marketid]/components/TablePagination.tsx new file mode 100644 index 00000000..af94884d --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/TablePagination.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react'; +import { Input, Popover, PopoverTrigger, PopoverContent, Tooltip } from '@heroui/react'; +import { ChevronLeftIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { Button } from '@/components/ui/button'; +import { TooltipContent } from '@/components/TooltipContent'; +import { cn } from '@/lib/utils'; + +type TablePaginationProps = { + currentPage: number; + totalPages: number; + totalEntries: number; + pageSize: number; + onPageChange: (page: number) => void; + isLoading?: boolean; + showEntryCount?: boolean; +}; + +export function TablePagination({ + currentPage, + totalPages, + totalEntries, + pageSize, + onPageChange, + isLoading = false, + showEntryCount = true, +}: TablePaginationProps) { + const [jumpPage, setJumpPage] = useState(''); + const [isJumpOpen, setIsJumpOpen] = useState(false); + + if (totalPages === 0) { + return null; + } + + const startEntry = (currentPage - 1) * pageSize + 1; + const endEntry = Math.min(currentPage * pageSize, totalEntries); + + const handleJumpToPage = () => { + const page = parseInt(jumpPage, 10); + if (page >= 1 && page <= totalPages) { + onPageChange(page); + setJumpPage(''); + setIsJumpOpen(false); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleJumpToPage(); + } + }; + + // Generate smart page numbers with ellipsis + const getPageNumbers = () => { + const pages: (number | 'ellipsis')[] = []; + const delta = 1; // Number of pages to show on each side of current + + if (totalPages <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + // Always show first page + pages.push(1); + + // Calculate range around current page + const start = Math.max(2, currentPage - delta); + const end = Math.min(totalPages - 1, currentPage + delta); + + // Add ellipsis after first page if needed + if (start > 2) { + pages.push('ellipsis'); + } + + // Add pages around current + for (let i = start; i <= end; i++) { + pages.push(i); + } + + // Add ellipsis before last page if needed + if (end < totalPages - 1) { + pages.push('ellipsis'); + } + + // Always show last page + pages.push(totalPages); + } + + return pages; + }; + + const pageNumbers = getPageNumbers(); + + return ( +
+ {/* Page controls */} +
+ {/* Previous button */} + + + {/* Page numbers */} +
+ {pageNumbers.map((page, idx) => + page === 'ellipsis' ? ( + 1000 ? 'w-10 text-xs' : 'w-8 text-sm' + )}> + ... + + ) : ( + + ), + )} +
+ + {/* Next button */} + + + {/* Jump to page - only show if more than 10 pages */} + {totalPages > 10 && ( +
+ + } + /> + } + > + + + + + +
+

Jump to page

+
+ setJumpPage(e.target.value)} + onKeyPress={handleKeyPress} + className="w-24" + classNames={{ + input: 'text-center rounded-sm px-3', + inputWrapper: 'rounded-sm', + }} + /> + +
+
+
+
+
+ )} +
+ + {/* Entry count - below controls */} + {showEntryCount && ( +
+ Showing {startEntry}-{endEntry} of {totalEntries} entries +
+ )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx b/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx new file mode 100644 index 00000000..c33847f0 --- /dev/null +++ b/app/market/[chainId]/[marketid]/components/TransactionFiltersModal.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { Input, Divider } from '@heroui/react'; +import { FiSliders } from 'react-icons/fi'; +import { Button } from '@/components/common'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; + +type TransactionFiltersModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + minSupplyAmount: string; + minBorrowAmount: string; + onMinSupplyChange: (value: string) => void; + onMinBorrowChange: (value: string) => void; + loanAssetSymbol: string; +}; + +function SettingItem({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +export default function TransactionFiltersModal({ + isOpen, + onOpenChange, + minSupplyAmount, + minBorrowAmount, + onMinSupplyChange, + onMinBorrowChange, + loanAssetSymbol, +}: TransactionFiltersModalProps) { + const handleSupplyChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Allow decimals and empty string + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onMinSupplyChange(value); + } + }; + + const handleBorrowChange = (e: React.ChangeEvent) => { + const { value } = e.target; + // Allow decimals and empty string + if (value === '' || /^\d*\.?\d*$/.test(value)) { + onMinBorrowChange(value); + } + }; + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Minimum Amounts

+

+ Filter transactions to show only those above the specified minimum amount. +

+ + + + + + + +
+
+ + + + + )} +
+ ); +} diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index 502f0a85..5d50b451 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -8,7 +8,7 @@ import { ExternalLinkIcon, ChevronLeftIcon } from '@radix-ui/react-icons'; import Image from 'next/image'; import Link from 'next/link'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { formatUnits } from 'viem'; +import { formatUnits, parseUnits } from 'viem'; import { useAccount } from 'wagmi'; import { BorrowModal } from '@/components/BorrowModal'; import { Button } from '@/components/common'; @@ -20,6 +20,7 @@ import { TokenIcon } from '@/components/TokenIcon'; import { useMarketData } from '@/hooks/useMarketData'; import { useMarketHistoricalData } from '@/hooks/useMarketHistoricalData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; +import { useTransactionFilters } from '@/hooks/useTransactionFilters'; import useUserPositions from '@/hooks/useUserPosition'; import MORPHO_LOGO from '@/imgs/tokens/morpho.svg'; import { getExplorerURL, getMarketURL } from '@/utils/external'; @@ -32,6 +33,7 @@ import { CampaignBadge } from './components/CampaignBadge'; import { LiquidationsTable } from './components/LiquidationsTable'; import { PositionStats } from './components/PositionStats'; import { SuppliesTable } from './components/SuppliesTable'; +import TransactionFiltersModal from './components/TransactionFiltersModal'; import RateChart from './RateChart'; import VolumeChart from './VolumeChart'; @@ -80,6 +82,7 @@ function MarketContent() { ); const [volumeView, setVolumeView] = useState<'USD' | 'Asset'>('Asset'); const [isRefreshing, setIsRefreshing] = useState(false); + const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); // 4. Data fetching hooks - use unified time range const { @@ -89,6 +92,14 @@ function MarketContent() { refetch: refetchMarket, } = useMarketData(marketid as string, network); + // Transaction filters with localStorage persistence (per symbol) + const { + minSupplyAmount, + minBorrowAmount, + setMinSupplyAmount, + setMinBorrowAmount, + } = useTransactionFilters(market?.loanAsset?.symbol ?? ''); + const { data: historicalData, isLoading: isHistoricalLoading, @@ -118,6 +129,29 @@ function MarketContent() { return formatUnits(adjusted, 36); }, [oraclePrice, market]); + // convert to token amounts + const scaledMinSupplyAmount = useMemo(() => { + if (!market || !minSupplyAmount || minSupplyAmount === '0' || minSupplyAmount === '') { + return '0'; + } + try { + return parseUnits(minSupplyAmount, market.loanAsset.decimals).toString(); + } catch { + return '0'; + } + }, [minSupplyAmount, market]); + + const scaledMinBorrowAmount = useMemo(() => { + if (!market || !minBorrowAmount || minBorrowAmount === '0' || minBorrowAmount === '') { + return '0'; + } + try { + return parseUnits(minBorrowAmount, market.loanAsset.decimals).toString(); + } catch { + return '0'; + } + }, [minBorrowAmount, market]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -233,6 +267,18 @@ function MarketContent() { /> )} + {showTransactionFiltersModal && ( + + )} +

{market.loanAsset.symbol}/{market.collateralAsset.symbol} Market @@ -372,7 +418,7 @@ function MarketContent() { />

-

Volume

+

Volume

-

Rates

+

Rates

-

Activities

+

Activities

{/* divider */}
- - + setShowTransactionFiltersModal(true)} + /> + setShowTransactionFiltersModal(true)} + />
diff --git a/components.json b/components.json new file mode 100644 index 00000000..d3d7e306 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/global.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/package.json b/package.json index fe7e69eb..10532995 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-slot": "^1.2.4", "@rainbow-me/rainbowkit": "2", "@react-aria/i18n": "^3.12.11", "@react-spring/web": "^9.7.3", @@ -45,6 +46,7 @@ "@uniswap/permit2-sdk": "^1.2.1", "abitype": "^0.10.3", "animejs": "^4.2.2", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "downshift": "^9.0.8", "framer-motion": "^11.2.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41402378..23e288d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@radix-ui/react-navigation-menu': specifier: ^1.1.4 version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@18.3.23)(react@18.3.1) '@rainbow-me/rainbowkit': specifier: '2' version: 2.2.8(@tanstack/react-query@5.85.3(react@18.3.1))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3)(viem@2.40.2(bufferutil@4.0.9)(typescript@5.6.3)(utf-8-validate@5.0.10)(zod@3.25.76))(wagmi@2.16.4(@tanstack/query-core@5.85.3)(@tanstack/react-query@5.85.3(react@18.3.1))(@types/react@18.3.23)(bufferutil@4.0.9)(react@18.3.1)(typescript@5.6.3)(utf-8-validate@5.0.10)(viem@2.40.2(bufferutil@4.0.9)(typescript@5.6.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76)) @@ -83,6 +86,9 @@ importers: animejs: specifier: ^4.2.2 version: 4.2.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 clsx: specifier: ^2.1.0 version: 2.1.1 @@ -2707,6 +2713,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -4248,6 +4263,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -11194,6 +11212,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.23 + '@radix-ui/react-slot@1.2.4(@types/react@18.3.23)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.23 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.23)(react@18.3.1)': dependencies: react: 18.3.1 @@ -13842,6 +13867,10 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + client-only@0.0.1: {} cliui@6.0.0: diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 00000000..65d4fcd9 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 00000000..484e5ce7 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" +import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +