diff --git a/apps/main/src/llamalend/constants.ts b/apps/main/src/llamalend/constants.ts
new file mode 100644
index 0000000000..0ab2b5b47c
--- /dev/null
+++ b/apps/main/src/llamalend/constants.ts
@@ -0,0 +1,8 @@
+export const LLAMA_MONITOR_BOT_URL = 'https://t.me/LlamalendMonitorBot'
+
+// Enum for the empty state of the user positions table
+export enum PositionsEmptyState {
+ Error = 'error',
+ NoPositions = 'no-positions',
+ Filtered = 'filtered',
+}
diff --git a/apps/main/src/llamalend/features/bands-chart/TooltipContent.tsx b/apps/main/src/llamalend/features/bands-chart/TooltipContent.tsx
index e934629b8d..1245ec34a7 100644
--- a/apps/main/src/llamalend/features/bands-chart/TooltipContent.tsx
+++ b/apps/main/src/llamalend/features/bands-chart/TooltipContent.tsx
@@ -1,4 +1,3 @@
-import { Token } from '@/llamalend/features/borrow/types'
import { TooltipItem, TooltipItems, TooltipWrapper } from '@/llamalend/widgets/tooltips/TooltipComponents'
import { Box, Typography } from '@mui/material'
import { t } from '@ui-kit/lib/i18n'
diff --git a/apps/main/src/llamalend/features/market-list/LlamaMarketsList.tsx b/apps/main/src/llamalend/features/market-list/LlamaMarketsList.tsx
index f2c84e6592..63cb5b3cf5 100644
--- a/apps/main/src/llamalend/features/market-list/LlamaMarketsList.tsx
+++ b/apps/main/src/llamalend/features/market-list/LlamaMarketsList.tsx
@@ -52,7 +52,9 @@ export const LlamaMarketsList = () => {
const loading = isReloading || (!data && (!isError || isLoading)) // on initial render isLoading is still false
return (
}>
- {data?.userHasPositions && }
+ {(data?.userHasPositions || !address) && (
+
+ )}
)
diff --git a/apps/main/src/llamalend/features/market-list/LlamaMonitorBotButton.tsx b/apps/main/src/llamalend/features/market-list/LlamaMonitorBotButton.tsx
new file mode 100644
index 0000000000..d17d814743
--- /dev/null
+++ b/apps/main/src/llamalend/features/market-list/LlamaMonitorBotButton.tsx
@@ -0,0 +1,41 @@
+import { LLAMA_MONITOR_BOT_URL } from '@/llamalend/constants'
+import Button from '@mui/material/Button'
+import IconButton from '@mui/material/IconButton'
+import Link from '@mui/material/Link'
+import { useIsMobile } from '@ui-kit/hooks/useBreakpoints'
+import { t } from '@ui-kit/lib/i18n'
+import { ArrowTopRightIcon } from '@ui-kit/shared/icons/ArrowTopRightIcon'
+import { BellRingingIcon } from '@ui-kit/shared/icons/BellIcon'
+import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
+
+const { Spacing } = SizesAndSpaces
+
+export const LlamaMonitorBotButton = () => {
+ const isMobile = useIsMobile()
+ return isMobile ? (
+
+
+
+ ) : (
+ }
+ endIcon={}
+ href={LLAMA_MONITOR_BOT_URL}
+ target="_blank"
+ rel="noreferrer noopener"
+ >
+ {t`Get notified with LlamaMonitor bot`}
+
+ )
+}
diff --git a/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx b/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx
index 7fd998e642..f593cf413a 100644
--- a/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx
+++ b/apps/main/src/llamalend/features/market-list/UserPositionTabs.tsx
@@ -1,27 +1,125 @@
-import { useState } from 'react'
-import type { LlamaMarketsResult } from '@/llamalend/entities/llama-markets'
+import { useEffect, useMemo, useState } from 'react'
+import { useAccount } from 'wagmi'
+import { fromEntries } from '@curvefi/prices-api/objects.util'
+import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
+import Typography from '@mui/material/Typography'
+import { useWallet } from '@ui-kit/features/connect-wallet'
import { t } from '@ui-kit/lib/i18n'
+import { EmptyStateCard } from '@ui-kit/shared/ui/EmptyStateCard'
import { TabsSwitcher, type TabOption } from '@ui-kit/shared/ui/TabsSwitcher'
+import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import { MarketRateType } from '@ui-kit/types/market'
+import { LlamaMonitorBotButton } from './LlamaMonitorBotButton'
import { UserPositionsTable, type UserPositionsTableProps } from './UserPositionsTable'
-const tabs: TabOption[] = [
- { value: MarketRateType.Borrow, label: t`Borrow` },
- { value: MarketRateType.Supply, label: t`Supply` },
-]
+const { Spacing, Height } = SizesAndSpaces
-/** Show the first tab that has user positions by default, or the first tab if none are found. */
-const getDefault = (userHasPositions: LlamaMarketsResult['userHasPositions'] | undefined) =>
- tabs.find(({ value }) => userHasPositions?.Lend[value] || userHasPositions?.Mint[value]) ?? tabs[0]
+export const UserPositionsTabs = (props: Omit) => {
+ const { connect } = useWallet()
+ const { address } = useAccount()
+ const { markets } = props.result ?? {}
+
+ // Calculate total positions number across all markets (independent of filters)
+ const openPositionsCount = useMemo(
+ (): Record =>
+ fromEntries(
+ Object.values(MarketRateType).map((type) => [
+ type,
+ markets && `${markets.filter((market) => market.userHasPositions?.[type]).length}`,
+ ]),
+ ),
+ [markets],
+ )
+
+ // Define tabs with position counts
+ const tabs: TabOption[] = useMemo(
+ () => [
+ {
+ value: MarketRateType.Borrow,
+ label: t`Borrowing`,
+ endAdornment: openPositionsCount[MarketRateType.Borrow],
+ },
+ {
+ value: MarketRateType.Supply,
+ label: t`Lending`,
+ endAdornment: openPositionsCount[MarketRateType.Supply],
+ },
+ ],
+ [openPositionsCount],
+ )
+
+ // Show the first tab that has user positions by default, or the first tab if none are found
+ const defaultTab = useMemo(() => {
+ const userHasPositions = props.result?.userHasPositions
+ return tabs.find(({ value }) => userHasPositions?.Lend[value] || userHasPositions?.Mint[value]) ?? tabs[0]
+ }, [props.result?.userHasPositions, tabs])
+
+ const [tab, setTab] = useState(defaultTab.value)
+
+ // Update tab when defaultTab changes (e.g., when user positions data loads)
+ useEffect(() => {
+ setTab(defaultTab.value)
+ }, [defaultTab.value])
-export const UserPositionsTabs = (props: Omit) => {
- const defaultTab = getDefault(props.result?.userHasPositions).value
- const [tab, setTab] = useState(defaultTab)
return (
-
-
+ `1px solid ${t.design.Tabs.UnderLined.Default.Outline}`,
+ backgroundColor: (t) => t.design.Layer[1].Fill,
+ }}
+ >
+ Your Positions
+
+ {address ? (
+ <>
+ t.design.Layer[1].Fill,
+ }}
+ >
+
+ `1px solid ${t.design.Tabs.UnderLined.Default.Outline}` }}
+ >
+
+
+
+ {/* the key is needed to force a re-render when the tab changes, otherwise filters have stale state for few milliseconds */}
+
+ >
+ ) : (
+ t.design.Layer[1].Fill,
+ }}
+ >
+ connect()}>
+ {t`Connect to view positions`}
+
+ }
+ />
+
+ )}
)
}
diff --git a/apps/main/src/llamalend/features/market-list/UserPositionsEmptyState.tsx b/apps/main/src/llamalend/features/market-list/UserPositionsEmptyState.tsx
new file mode 100644
index 0000000000..2847af4a67
--- /dev/null
+++ b/apps/main/src/llamalend/features/market-list/UserPositionsEmptyState.tsx
@@ -0,0 +1,76 @@
+import { PositionsEmptyState } from '@/llamalend/constants'
+import Button from '@mui/material/Button'
+import type { Table } from '@tanstack/react-table'
+import { t } from '@ui-kit/lib/i18n'
+import { EmptyStateRow } from '@ui-kit/shared/ui/DataTable/EmptyStateRow'
+import { EmptyStateCard } from '@ui-kit/shared/ui/EmptyStateCard'
+import { MarketRateType } from '@ui-kit/types/market'
+
+type EmptyStateConfig = {
+ title: string
+ subtitle?: string
+ buttonLabel?: string
+ onButtonClick?: () => void
+}
+
+type UserPositionsEmptyStateProps = {
+ table: Table
+ state: PositionsEmptyState
+ tab: MarketRateType
+ onReload: () => void
+ resetFilters: () => void
+}
+
+const emptyStateConfigs = (
+ tab: MarketRateType,
+ onReload: () => void,
+ resetFilters: () => void,
+): Record => ({
+ [PositionsEmptyState.Error]: {
+ title: t`Could not load positions`,
+ buttonLabel: t`Reload`,
+ onButtonClick: onReload,
+ },
+ [PositionsEmptyState.NoPositions]: {
+ title: t`No active positions`,
+ subtitle: noPositionsSubTitle[tab],
+ },
+ [PositionsEmptyState.Filtered]: {
+ title: t`No positions found`,
+ subtitle: t`Try adjusting your filters or search query`,
+ buttonLabel: t`Show All Positions`,
+ onButtonClick: resetFilters,
+ },
+})
+
+const noPositionsSubTitle: Record = {
+ [MarketRateType.Borrow]: t`Borrow with LLAMMA to stay exposed, reduce liquidation risk, and access liquidity without selling.`,
+ [MarketRateType.Supply]: t`Lend assets to earn yield and support deep liquidity across Curve.`,
+}
+
+export const UserPositionsEmptyState = ({
+ table,
+ state,
+ tab,
+ onReload,
+ resetFilters,
+}: UserPositionsEmptyStateProps) => {
+ const configs = emptyStateConfigs(tab, onReload, resetFilters)
+ const { title, subtitle, buttonLabel, onButtonClick } = configs[state]
+
+ return (
+
+
+ {buttonLabel}
+
+ )
+ }
+ />
+
+ )
+}
diff --git a/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx b/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx
index a542ed72fc..36142201b0 100644
--- a/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx
+++ b/apps/main/src/llamalend/features/market-list/UserPositionsTable.tsx
@@ -1,26 +1,26 @@
+import lodash from 'lodash'
import { useMemo, useState } from 'react'
-import { LlamaListMarketChips } from '@/llamalend/features/market-list/chips/LlamaListMarketChips'
-import Grid from '@mui/material/Grid'
+import { PositionsEmptyState } from '@/llamalend/constants'
import { ExpandedState, useReactTable } from '@tanstack/react-table'
import { useIsTablet } from '@ui-kit/hooks/useBreakpoints'
import { useSortFromQueryString } from '@ui-kit/hooks/useSortFromQueryString'
-import { t } from '@ui-kit/lib/i18n'
import { getTableOptions } from '@ui-kit/shared/ui/DataTable/data-table.utils'
import { DataTable } from '@ui-kit/shared/ui/DataTable/DataTable'
-import { EmptyStateRow } from '@ui-kit/shared/ui/DataTable/EmptyStateRow'
import { useColumnFilters } from '@ui-kit/shared/ui/DataTable/hooks/useColumnFilters'
import { TableFilters } from '@ui-kit/shared/ui/DataTable/TableFilters'
-import { TableFiltersTitles } from '@ui-kit/shared/ui/DataTable/TableFiltersTitles'
-import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
+import { TableSearchField } from '@ui-kit/shared/ui/DataTable/TableSearchField'
import { MarketRateType } from '@ui-kit/types/market'
import { type LlamaMarketsResult } from '../../entities/llama-markets'
+import { ChainFilterChip } from './chips/ChainFilterChip'
+import { LlamaListChips } from './chips/LlamaListChips'
import { DEFAULT_SORT_BORROW, DEFAULT_SORT_SUPPLY, LLAMA_MARKET_COLUMNS } from './columns'
import { LlamaMarketColumnId } from './columns.enum'
import { useLlamaTableVisibility } from './hooks/useLlamaTableVisibility'
import { useSearch } from './hooks/useSearch'
+import { LendingMarketsFilters } from './LendingMarketsFilters'
import { LlamaMarketExpandedPanel } from './LlamaMarketExpandedPanel'
-
-const { Spacing } = SizesAndSpaces
+import { UserPositionsEmptyState } from './UserPositionsEmptyState'
+const { isEqual } = lodash
const LOCAL_STORAGE_KEYS = {
// not using the t`` here as the value is used as a key in the local storage
@@ -33,19 +33,32 @@ const DEFAULT_SORT = {
[MarketRateType.Supply]: DEFAULT_SORT_SUPPLY,
}
+const SORT_QUERY_FIELD = {
+ [MarketRateType.Borrow]: 'userSortBorrow',
+ [MarketRateType.Supply]: 'userSortSupply',
+}
+
+const getEmptyState = (isError: boolean, hasPositions: boolean): PositionsEmptyState =>
+ isError ? PositionsEmptyState.Error : hasPositions ? PositionsEmptyState.Filtered : PositionsEmptyState.NoPositions
+
const useDefaultUserFilter = (type: MarketRateType) =>
useMemo(() => [{ id: LlamaMarketColumnId.UserHasPositions, value: type }], [type])
export type UserPositionsTableProps = {
+ onReload: () => void
result: LlamaMarketsResult | undefined
+ isError: boolean
loading: boolean
tab: MarketRateType
}
const pagination = { pageIndex: 0, pageSize: 50 }
+const DEFAULT_VISIBLE_ROWS = 3
-export const UserPositionsTable = ({ result, loading, tab }: UserPositionsTableProps) => {
+export const UserPositionsTable = ({ onReload, result, loading, isError, tab }: UserPositionsTableProps) => {
const { markets: data = [], userHasPositions } = result ?? {}
+ const userData = useMemo(() => data.filter((market) => market.userHasPositions?.[tab]), [data, tab])
+
const defaultFilters = useDefaultUserFilter(tab)
const title = LOCAL_STORAGE_KEYS[tab]
const { columnFilters, columnFiltersById, setColumnFilter, resetFilters } = useColumnFilters({
@@ -54,13 +67,15 @@ export const UserPositionsTable = ({ result, loading, tab }: UserPositionsTableP
defaultFilters,
scope: tab.toLowerCase(),
})
- const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT[tab], 'userSort')
- const { columnSettings, columnVisibility, sortField } = useLlamaTableVisibility(title, sorting, tab)
+ const [sorting, onSortingChange] = useSortFromQueryString(DEFAULT_SORT[tab], SORT_QUERY_FIELD[tab])
+ const { columnSettings, columnVisibility, sortField, toggleVisibility } = useLlamaTableVisibility(title, sorting, tab)
const [expanded, onExpandedChange] = useState({})
const [searchText, onSearch] = useSearch(columnFiltersById, setColumnFilter)
+ const filterProps = { columnFiltersById, setColumnFilter }
+
const table = useReactTable({
columns: LLAMA_MARKET_COLUMNS,
- data,
+ data: userData,
state: { expanded, sorting, columnVisibility, columnFilters },
initialState: { pagination },
onSortingChange,
@@ -68,29 +83,55 @@ export const UserPositionsTable = ({ result, loading, tab }: UserPositionsTableP
...getTableOptions(result),
})
- const showChips = userHasPositions?.Lend[tab] && userHasPositions?.Mint[tab]
return (
{t`No positions found`}}
+ rowLimit={DEFAULT_VISIBLE_ROWS}
+ viewAllLabel="View all positions"
+ emptyState={
+ 0)}
+ table={table}
+ tab={tab}
+ onReload={onReload}
+ resetFilters={resetFilters}
+ />
+ }
expandedPanel={LlamaMarketExpandedPanel}
shouldStickFirstColumn={Boolean(useIsTablet() && userHasPositions)}
loading={loading}
>
filterExpandedKey={title}
- leftChildren={}
+ leftChildren={}
loading={loading}
- hasSearchBar
+ onReload={onReload}
visibilityGroups={columnSettings}
+ toggleVisibility={toggleVisibility}
searchText={searchText}
onSearch={onSearch}
+ collapsible={
+
+ }
chips={
- showChips && (
-
-
-
- )
+ <>
+
+ 0 && !isEqual(columnFilters, defaultFilters)}
+ resetFilters={resetFilters}
+ userHasPositions={userHasPositions}
+ onSortingChange={onSortingChange}
+ sortField={sortField}
+ data={userData}
+ userPositionsTab={tab}
+ {...filterProps}
+ />
+ >
}
/>
diff --git a/apps/main/src/llamalend/features/market-list/chips/LlamaListChips.tsx b/apps/main/src/llamalend/features/market-list/chips/LlamaListChips.tsx
index 4507037519..46a551a972 100644
--- a/apps/main/src/llamalend/features/market-list/chips/LlamaListChips.tsx
+++ b/apps/main/src/llamalend/features/market-list/chips/LlamaListChips.tsx
@@ -6,6 +6,7 @@ import { useIsMobile } from '@ui-kit/hooks/useBreakpoints'
import type { FilterProps } from '@ui-kit/shared/ui/DataTable/data-table.utils'
import { HiddenMarketsResetFilters } from '@ui-kit/shared/ui/DataTable/HiddenMarketsResetFilters'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
+import { MarketRateType } from '@ui-kit/types/market'
import { LlamaMarketColumnId } from '../columns.enum'
import { MarketListFilterDrawer } from '../drawers/MarketListFilterDrawer'
import { MarketSortDrawer } from '../drawers/MarketSortDrawer'
@@ -20,11 +21,12 @@ type LlamaListChipsProps = {
hasFilters: boolean
children?: ReactNode
userHasPositions: LlamaMarketsResult['userHasPositions'] | undefined
- hasFavorites: boolean | undefined
+ hasFavorites?: boolean
onSortingChange: OnChangeFn
sortField: LlamaMarketColumnId
data: LlamaMarket[]
- minLiquidity: number
+ minLiquidity?: number
+ userPositionsTab?: MarketRateType
} & FilterProps
export const LlamaListChips = ({
@@ -36,10 +38,12 @@ export const LlamaListChips = ({
onSortingChange,
sortField,
data,
- minLiquidity,
+ minLiquidity = 0,
+ userPositionsTab,
...filterProps
}: LlamaListChipsProps) => {
const isMobile = useIsMobile()
+ const hasPopularFilters = userPositionsTab === MarketRateType.Borrow || !userPositionsTab
return (
{isMobile ? (
@@ -58,18 +62,23 @@ export const LlamaListChips = ({
hiddenMarketCount={hiddenMarketCount}
resetFilters={resetFilters}
hasFilters={hasFilters}
+ userPositionsTab={userPositionsTab}
{...filterProps}
/>
) : (
<>
-
-
-
-
-
-
+ {hasPopularFilters && (
+
+
+
+ )}
+ {!userPositionsTab && (
+
+
+
+ )}
>
)}
{hiddenMarketCount != null && !isMobile && (
diff --git a/apps/main/src/llamalend/features/market-list/drawers/MarketListFilterDrawer.tsx b/apps/main/src/llamalend/features/market-list/drawers/MarketListFilterDrawer.tsx
index fb902ebedf..48c2d10b45 100644
--- a/apps/main/src/llamalend/features/market-list/drawers/MarketListFilterDrawer.tsx
+++ b/apps/main/src/llamalend/features/market-list/drawers/MarketListFilterDrawer.tsx
@@ -11,6 +11,7 @@ import { DrawerHeader } from '@ui-kit/shared/ui/SwipeableDrawer/DrawerHeader'
import { DrawerItems } from '@ui-kit/shared/ui/SwipeableDrawer/DrawerItems'
import { SwipeableDrawer } from '@ui-kit/shared/ui/SwipeableDrawer/SwipeableDrawer'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
+import { MarketRateType } from '@ui-kit/types/market'
import { LlamaListMarketChips } from '../chips/LlamaListMarketChips'
import { LlamaListUserChips } from '../chips/LlamaListUserChips'
import { LendingMarketsFilters } from '../LendingMarketsFilters'
@@ -25,6 +26,7 @@ type Props = {
hiddenMarketCount?: number
resetFilters: () => void
hasFilters: boolean
+ userPositionsTab?: MarketRateType
} & FilterProps
export const MarketListFilterDrawer = ({
@@ -35,9 +37,12 @@ export const MarketListFilterDrawer = ({
hiddenMarketCount,
resetFilters,
hasFilters,
+ userPositionsTab,
...filterProps
}: Props) => {
const [open, openDrawer, closeDrawer] = useSwitch(false)
+ const hasPopularFilters = userPositionsTab === MarketRateType.Borrow || !userPositionsTab
+ const showUserChips = !userPositionsTab
return (
-
+ {hasPopularFilters && }
-
-
+ {hasPopularFilters && }
+ {showUserChips && (
+
+ )}
diff --git a/packages/curve-ui-kit/src/hooks/useSortFromQueryString.ts b/packages/curve-ui-kit/src/hooks/useSortFromQueryString.ts
index c27b1c0de2..434e9516bf 100644
--- a/packages/curve-ui-kit/src/hooks/useSortFromQueryString.ts
+++ b/packages/curve-ui-kit/src/hooks/useSortFromQueryString.ts
@@ -26,6 +26,6 @@ function parseSort(search: URLSearchParams, defaultSort: SortingState, fieldName
function updateSort(search: URLSearchParams, state: SortingState, fieldName: string): string {
const params = new URLSearchParams(search)
params.delete(fieldName)
- state.forEach(({ id, desc }) => params.append('sort', `${desc ? '-' : ''}${id}`))
+ state.forEach(({ id, desc }) => params.append(fieldName, `${desc ? '-' : ''}${id}`))
return `?${params.toString()}`
}
diff --git a/packages/curve-ui-kit/src/shared/icons/BellIcon.stories.tsx b/packages/curve-ui-kit/src/shared/icons/BellIcon.stories.tsx
new file mode 100644
index 0000000000..11b593f4b3
--- /dev/null
+++ b/packages/curve-ui-kit/src/shared/icons/BellIcon.stories.tsx
@@ -0,0 +1,173 @@
+import Box from '@mui/material/Box'
+import Stack from '@mui/material/Stack'
+import Typography from '@mui/material/Typography'
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { BellIcon, BellRingingIcon } from './BellIcon'
+
+const meta: Meta = {
+ title: 'UI Kit/Icons/BellRingingIcon',
+ component: BellRingingIcon,
+ argTypes: {
+ fontSize: {
+ control: 'select',
+ options: ['small', 'medium', 'large', 'inherit'],
+ description: 'The size of the icon',
+ },
+ color: {
+ control: 'select',
+ options: ['inherit', 'primary', 'secondary', 'error', 'warning', 'info', 'success'],
+ description: 'The color of the icon',
+ },
+ },
+ args: {
+ fontSize: 'medium',
+ color: 'inherit',
+ },
+}
+
+type Story = StoryObj
+
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ component: 'BellRingingIcon is an animated version of BellIcon that rings when hovered.',
+ story: 'Default BellRingingIcon - hover to see the ringing animation',
+ },
+ },
+ },
+}
+
+export const Comparison: Story = {
+ render: () => (
+
+
+
+
+ Regular Bell (no animation)
+
+
+
+
+
+ Ringing Bell (hover me!)
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Comparison between regular BellIcon and BellRingingIcon with hover animation',
+ },
+ },
+ },
+}
+
+export const DifferentSizes: Story = {
+ render: () => (
+
+
+
+
+ Small
+
+
+
+
+
+ Medium
+
+
+
+
+
+ Large
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'BellRingingIcon in different sizes - hover over any bell to see the animation',
+ },
+ },
+ },
+}
+
+export const DifferentColors: Story = {
+ render: () => (
+
+
+
+
+ Primary
+
+
+
+
+
+ Secondary
+
+
+
+
+
+ Error
+
+
+
+
+
+ Warning
+
+
+
+
+
+ Success
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'BellRingingIcon in different colors - hover over any bell to see the animation',
+ },
+ },
+ },
+}
+
+export const AnimationDetails: Story = {
+ render: () => (
+
+
+
+ Hover over the bell to see the ringing animation
+
+
+
+ Animation Details
+
+
+ • Duration: 0.6 seconds
+ • Easing: ease-in-out
+ • Transform origin: top center
+ • Max rotation: ±14 degrees
+ • Effect: Smooth damped oscillation
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Technical details of the bell ringing animation',
+ },
+ },
+ },
+}
+
+export default meta
diff --git a/packages/curve-ui-kit/src/shared/icons/BellIcon.tsx b/packages/curve-ui-kit/src/shared/icons/BellIcon.tsx
new file mode 100644
index 0000000000..273e76bdf4
--- /dev/null
+++ b/packages/curve-ui-kit/src/shared/icons/BellIcon.tsx
@@ -0,0 +1,39 @@
+import { styled } from '@mui/material/styles'
+import { keyframes } from '@mui/material/styles'
+import type { SvgIconProps } from '@mui/material/SvgIcon'
+import { createSvgIcon } from '@mui/material/utils'
+
+export const BellIcon = createSvgIcon(
+ ,
+ 'Bell',
+)
+
+// Bell ringing animation - smooth shake with rotation
+const bellRing = keyframes`
+ 0% { transform: rotate(0deg); }
+ 10% { transform: rotate(14deg); }
+ 20% { transform: rotate(-12deg); }
+ 30% { transform: rotate(10deg); }
+ 40% { transform: rotate(-8deg); }
+ 50% { transform: rotate(6deg); }
+ 60% { transform: rotate(-4deg); }
+ 70% { transform: rotate(2deg); }
+ 80% { transform: rotate(-1deg); }
+ 90% { transform: rotate(0.5deg); }
+ 100% { transform: rotate(0deg); }
+`
+
+// BellRingingIcon component with hover animation
+export const BellRingingIcon = styled(BellIcon)(({ theme }) => ({
+ '&:hover': {
+ animation: `${bellRing} 0.6s ease-in-out`,
+ transformOrigin: 'top center',
+ },
+}))
diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/DataTable.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/DataTable.tsx
index 6827bce996..25282538d9 100644
--- a/packages/curve-ui-kit/src/shared/ui/DataTable/DataTable.tsx
+++ b/packages/curve-ui-kit/src/shared/ui/DataTable/DataTable.tsx
@@ -8,6 +8,7 @@ import TableFooter from '@mui/material/TableFooter'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import { useLayoutStore } from '@ui-kit/features/layout'
+import { t } from '@ui-kit/lib/i18n'
import { TablePagination } from '@ui-kit/shared/ui/DataTable/TablePagination'
import { WithWrapper } from '@ui-kit/shared/ui/WithWrapper'
import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
@@ -16,6 +17,8 @@ import { DataRow, type DataRowProps } from './DataRow'
import { FilterRow } from './FilterRow'
import { HeaderCell } from './HeaderCell'
import { SkeletonRows } from './SkeletonRows'
+import { TableViewAllCell } from './TableViewAllCell'
+import { useTableRowLimit } from './useTableRowLimit'
/**
* Scrolls to the top of the window whenever the column filters change.
@@ -53,6 +56,8 @@ export const DataTable = ({
children,
loading,
maxHeight,
+ rowLimit,
+ viewAllLabel,
...rowProps
}: {
table: TanstackTable
@@ -60,9 +65,18 @@ export const DataTable = ({
children?: ReactNode // passed to
loading: boolean
maxHeight?: `${number}rem` // also sets overflowY to 'auto'
+ rowLimit?: number
+ viewAllLabel?: string
} & Omit, 'row' | 'isLast'>) => {
const { table, shouldStickFirstColumn } = rowProps
const { rows } = table.getRowModel()
+ const { isLimited, isLoading: isLoadingViewAll, handleShowAll } = useTableRowLimit(rowLimit)
+ // When number of rows are limited, show only rowLimit rows
+ const visibleRows = isLimited && rowLimit ? rows.slice(0, rowLimit) : rows
+ const showViewAllButton = isLimited && rows.length > rowLimit!
+ // pagination should bw shown if no rows limit and if needed
+ const showPagination = !isLimited && table.getPageCount() > 1
+
const headerGroups = table.getHeaderGroups()
const columnCount = useMemo(() => headerGroups.reduce((acc, group) => acc + group.headers.length, 0), [headerGroups])
const top = useLayoutStore((state) => state.navHeight)
@@ -115,17 +129,24 @@ export const DataTable = ({
) : rows.length === 0 ? (
emptyState
) : (
- rows.map((row, index) => (
- key={row.id} row={row} isLast={index === rows.length - 1} {...rowProps} />
+ visibleRows.map((row, index) => (
+ key={row.id} row={row} isLast={index === visibleRows.length - 1} {...rowProps} />
))
)}
- {table.getPageCount() > 1 && (
+ {(showPagination || showViewAllButton) && (
-
-
-
+ {showViewAllButton && (
+
+ {viewAllLabel || t`View all`}
+
+ )}
+ {showPagination && (
+
+
+
+ )}
)}
diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/TableFilters.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/TableFilters.tsx
index b27daff90b..b887cc51ee 100644
--- a/packages/curve-ui-kit/src/shared/ui/DataTable/TableFilters.tsx
+++ b/packages/curve-ui-kit/src/shared/ui/DataTable/TableFilters.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useMemo, useRef } from 'react'
+import { ReactNode, useRef } from 'react'
import Collapse from '@mui/material/Collapse'
import Fade from '@mui/material/Fade'
import Grid from '@mui/material/Grid'
@@ -53,13 +53,13 @@ export const TableFilters = ({
const [isSearchExpanded, , , toggleSearchExpanded] = useSwitch(false)
const isMobile = useIsMobile()
const isCollapsible = collapsible || (isMobile && chips)
- const isExpandedOrValue = useMemo(() => isSearchExpanded || !!searchText, [isSearchExpanded, searchText])
- const hideTitle = isExpandedOrValue && isMobile
+ const isExpandedOrValue = Boolean(isSearchExpanded || searchText)
+ const hideTitle = hasSearchBar && isExpandedOrValue && isMobile
return (
-
+
{leftChildren}
diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/TableSearchField.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/TableSearchField.tsx
index 19db9d363b..41fc649332 100644
--- a/packages/curve-ui-kit/src/shared/ui/DataTable/TableSearchField.tsx
+++ b/packages/curve-ui-kit/src/shared/ui/DataTable/TableSearchField.tsx
@@ -90,7 +90,7 @@ export const TableSearchField = ({ value, onChange, testId, toggleExpanded, isEx
) : (
diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/TableViewAllCell.tsx b/packages/curve-ui-kit/src/shared/ui/DataTable/TableViewAllCell.tsx
new file mode 100644
index 0000000000..95e144623e
--- /dev/null
+++ b/packages/curve-ui-kit/src/shared/ui/DataTable/TableViewAllCell.tsx
@@ -0,0 +1,32 @@
+import { ReactNode } from 'react'
+import Button from '@mui/material/Button'
+import Stack from '@mui/material/Stack'
+import TableCell, { TableCellProps } from '@mui/material/TableCell'
+import { ArrowDownIcon } from '@ui-kit/shared/icons/ArrowDownIcon'
+import { Spacing } from '@ui-kit/themes/design/0_primitives'
+
+export const TableViewAllCell = ({
+ children,
+ onClick,
+ isLoading = false,
+ ...tableCellProps
+}: {
+ children: ReactNode
+ onClick: () => void
+ isLoading?: boolean
+} & TableCellProps) => (
+ // constant padding block accross all breakpoints
+
+
+ }
+ >
+ {children}
+
+
+
+)
diff --git a/packages/curve-ui-kit/src/shared/ui/DataTable/useTableRowLimit.ts b/packages/curve-ui-kit/src/shared/ui/DataTable/useTableRowLimit.ts
new file mode 100644
index 0000000000..9d30e36e0a
--- /dev/null
+++ b/packages/curve-ui-kit/src/shared/ui/DataTable/useTableRowLimit.ts
@@ -0,0 +1,31 @@
+import { useState, useTransition } from 'react'
+
+/**
+ * Hook to manage table row limiting functionality.
+ * When a rowLimit is provided, the table will initially show only that many rows
+ * with a "show all" button. Once clicked, all rows are shown with pagination enabled.
+ */
+export function useTableRowLimit(rowLimit?: number) {
+ const [showAllRows, setShowAllRows] = useState(false)
+ const [isPending, startTransition] = useTransition()
+
+ const isLimited = rowLimit != null && !showAllRows
+
+ const handleShowAll = () => {
+ // when toggling the show all rows, we want to use a transition to avoid blocking the UI
+ startTransition(() => {
+ setShowAllRows(true)
+ })
+ }
+
+ const reset = () => {
+ setShowAllRows(false)
+ }
+
+ return {
+ isLimited,
+ isLoading: isPending,
+ handleShowAll,
+ reset,
+ }
+}
diff --git a/packages/curve-ui-kit/src/shared/ui/EmptyStateCard.tsx b/packages/curve-ui-kit/src/shared/ui/EmptyStateCard.tsx
index 63292380f4..3dfc76fe9b 100644
--- a/packages/curve-ui-kit/src/shared/ui/EmptyStateCard.tsx
+++ b/packages/curve-ui-kit/src/shared/ui/EmptyStateCard.tsx
@@ -11,7 +11,7 @@ export const EmptyStateCard = ({
subtitle,
action,
}: {
- title: string
+ title?: string
subtitle?: string
action?: ReactNode
}) => (
@@ -25,11 +25,13 @@ export const EmptyStateCard = ({
{/* Needed because no gap between the title and subtitle */}
-
- {title}
-
+ {title && (
+
+ {title}
+
+ )}
{subtitle && (
-
+
{subtitle}
)}
diff --git a/packages/curve-ui-kit/src/shared/ui/TabsSwitcher.tsx b/packages/curve-ui-kit/src/shared/ui/TabsSwitcher.tsx
index 0bd5f33960..a9f743f132 100644
--- a/packages/curve-ui-kit/src/shared/ui/TabsSwitcher.tsx
+++ b/packages/curve-ui-kit/src/shared/ui/TabsSwitcher.tsx
@@ -1,8 +1,10 @@
import type { UrlObject } from 'url'
+import Stack from '@mui/material/Stack'
import Tab, { type TabProps } from '@mui/material/Tab'
import Tabs, { type TabsProps } from '@mui/material/Tabs'
import Typography, { type TypographyProps } from '@mui/material/Typography'
import { RouterLink as Link } from '@ui-kit/shared/ui/RouterLink'
+import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces'
import type { TypographyVariantKey } from '@ui-kit/themes/typography'
import {
TABS_HEIGHT_CLASSES,
@@ -11,6 +13,8 @@ import {
TabSwitcherVariants,
} from '../../themes/components/tabs'
+const { Spacing } = SizesAndSpaces
+
const defaultTextVariants = {
small: 'buttonS',
medium: 'buttonM',
@@ -20,6 +24,7 @@ const defaultTextVariants = {
export type TabOption = Pick & {
value: T
href?: string | UrlObject
+ endAdornment?: string
}
export type TabsSwitcherProps = Pick & {
@@ -57,12 +62,21 @@ export const TabsSwitcher = ({
sx={{ ...sx, ...(fullWidth && { '& .MuiTab-root': { flexGrow: 1 } }) }}
{...props}
>
- {options.map(({ value, label, href, ...props }) => (
+ {options.map(({ value, label, href, endAdornment, ...props }) => (
{label}}
+ label={
+
+ {label}
+ {endAdornment != null && (
+
+ {endAdornment}
+
+ )}
+
+ }
{...(href && { href, component: Link })}
{...props}
/>
diff --git a/packages/curve-ui-kit/src/themes/components/tabs/mui-tabs.ts b/packages/curve-ui-kit/src/themes/components/tabs/mui-tabs.ts
index 4690557459..c7f4caaa73 100644
--- a/packages/curve-ui-kit/src/themes/components/tabs/mui-tabs.ts
+++ b/packages/curve-ui-kit/src/themes/components/tabs/mui-tabs.ts
@@ -44,7 +44,7 @@ const BORDER_SIZE_LARGE = '4px' as const
* keeping color changes instantenously.
*/
-export const defineMuiTab = ({ Tabs: { Transition } }: DesignSystem): Components['MuiTab'] => ({
+export const defineMuiTab = ({ Tabs: { Transition }, Text }: DesignSystem): Components['MuiTab'] => ({
styleOverrides: {
root: {
transition: Transition,
@@ -61,6 +61,16 @@ export const defineMuiTab = ({ Tabs: { Transition } }: DesignSystem): Components
position: 'absolute',
height: BORDER_SIZE,
},
+ // Count typography color states
+ '& .tab-end-adornment': {
+ color: Text.TextColors.Tertiary,
+ },
+ '&:hover .tab-end-adornment': {
+ color: 'inherit',
+ },
+ '&.Mui-selected .tab-end-adornment': {
+ color: Text.TextColors.Secondary,
+ },
},
},
})
diff --git a/packages/curve-ui-kit/src/themes/design/1_sizes_spaces.ts b/packages/curve-ui-kit/src/themes/design/1_sizes_spaces.ts
index 72a8c5e9f4..40db14732f 100644
--- a/packages/curve-ui-kit/src/themes/design/1_sizes_spaces.ts
+++ b/packages/curve-ui-kit/src/themes/design/1_sizes_spaces.ts
@@ -323,6 +323,7 @@ export const SizesAndSpaces = {
Height: {
modal: MappedModalHeight,
row: Sizing[700],
+ userPositionsTitle: Sizing[500],
},
MinHeight: {
tableNoResults: { sm: '15vh', lg: '35vh' },