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 ? ( + + + + ) : ( + + ) +} 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 + + + + + +) 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' },