From 62ea26d8ffa79e73a180df82776bb38f92ea4e1e Mon Sep 17 00:00:00 2001 From: JustJousting Date: Mon, 9 Jun 2025 13:58:46 +0300 Subject: [PATCH 01/41] refactor: integrate i18n for PegKeeperStatistics labels --- .../PagePegKeepers/components/PegKeeperStatistics.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx b/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx index 1f41878b2..bd6473f66 100644 --- a/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx +++ b/apps/main/src/loan/components/PagePegKeepers/components/PegKeeperStatistics.tsx @@ -2,6 +2,7 @@ import { useAppStatsTotalCrvusdSupply } from '@/loan/entities/appstats-total-crv import { LlamaApi } from '@/loan/types/loan.types' import { CardHeader, Box } from '@mui/material' import { useConnection } from '@ui-kit/features/connect-wallet' +import { t } from '@ui-kit/lib/i18n' import { Metric } from '@ui-kit/shared/ui/Metric' import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' @@ -18,17 +19,17 @@ export const PegKeeperStatistics = () => { return ( - + From a7efca029b00e2bfc9c0e121062274bd5357765a Mon Sep 17 00:00:00 2001 From: JustJousting Date: Wed, 11 Jun 2025 17:04:17 +0300 Subject: [PATCH 02/41] feat: init position details --- .../loan/components/LoanInfoUser/index.tsx | 67 ++++++++++++- apps/main/src/loan/store/createLoansSlice.ts | 13 ++- apps/main/src/loan/types/loan.types.ts | 1 + .../ui/PositionDetails/BorrowInformation.tsx | 96 +++++++++++++++++++ .../shared/ui/PositionDetails/HealthBar.tsx | 57 +++++++++++ .../ui/PositionDetails/HealthDetails.tsx | 48 ++++++++++ .../src/shared/ui/PositionDetails/index.tsx | 57 +++++++++++ 7 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx create mode 100644 packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx create mode 100644 packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx create mode 100644 packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx diff --git a/apps/main/src/loan/components/LoanInfoUser/index.tsx b/apps/main/src/loan/components/LoanInfoUser/index.tsx index 09f6c856d..b7e81fc4b 100644 --- a/apps/main/src/loan/components/LoanInfoUser/index.tsx +++ b/apps/main/src/loan/components/LoanInfoUser/index.tsx @@ -1,5 +1,6 @@ import isUndefined from 'lodash/isUndefined' -import { useEffect, useState } from 'react' +import meanBy from 'lodash/meanBy' +import { useEffect, useMemo, useState } from 'react' import styled from 'styled-components' import PoolInfoData from '@/loan/components/ChartOhlcWrapper' import { getHealthMode } from '@/loan/components/DetailInfoHealth' @@ -9,12 +10,16 @@ import ChartUserLiquidationRange from '@/loan/components/LoanInfoUser/components import UserInfos from '@/loan/components/LoanInfoUser/components/UserInfos' import type { PageLoanManageProps } from '@/loan/components/PageLoanManage/types' import { DEFAULT_HEALTH_MODE } from '@/loan/components/PageLoanManage/utils' +import { useCrvUsdSnapshots } from '@/loan/entities/crvusd-snapshots' +import networks from '@/loan/networks' import useStore from '@/loan/store/useStore' import { ChainId } from '@/loan/types/loan.types' +import type { Address } from '@curvefi/prices-api' import Box from '@ui/Box' import { breakpoints } from '@ui/utils/responsive' import { useUserProfileStore } from '@ui-kit/features/user-profile' import { t } from '@ui-kit/lib/i18n' +import { PositionDetails, type PositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails' interface Props extends Pick { rChainId: ChainId @@ -24,8 +29,14 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const loanDetails = useStore((state) => state.loans.detailsMapper[llammaId]) const userLoanDetails = useStore((state) => state.loans.userDetailsMapper[llammaId]) const { chartExpanded } = useStore((state) => state.ohlcCharts) + const usdRatesLoading = useStore((state) => state.usdRates.loading) + const collateralUsdRate = useStore((state) => state.usdRates.tokens[llamma?.collateral ?? '']) const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) + const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useCrvUsdSnapshots({ + blockchainId: networks[rChainId].id, + contractAddress: llamma?.controller as Address, + }) const { userBands, healthFull, healthNotFull, userStatus } = userLoanDetails ?? {} const { oraclePriceBand } = loanDetails ?? {} @@ -33,6 +44,27 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const [healthMode, setHealthMode] = useState(DEFAULT_HEALTH_MODE) + const sevenDayAvgRate = useMemo(() => { + if (!crvUsdSnapshots) return null + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > sevenDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ rate }) => rate) * 100 + }, [crvUsdSnapshots]) + + const collateralValue = useMemo(() => { + if (!collateralUsdRate || !userLoanDetails?.userState?.collateral) return null + return ( + Number(userLoanDetails?.userState?.collateral) * Number(collateralUsdRate) + + Number(userLoanDetails?.userState?.stablecoin) + ) + }, [userLoanDetails?.userState?.collateral, userLoanDetails?.userState?.stablecoin, collateralUsdRate]) + useEffect(() => { if (!isUndefined(oraclePriceBand) && healthFull && healthNotFull && userBands) { const fetchedHealthMode = getHealthMode( @@ -52,8 +84,41 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { } }, [oraclePriceBand, healthFull, healthNotFull, userBands]) + const positionDetailsProps: PositionDetailsProps = { + app: 'crvusd', + health: { + value: healthMode.percent, + loading: userLoanDetails?.loading ?? true, + }, + borrowRate: { + value: sevenDayAvgRate?.toString(), + loading: isSnapshotsLoading, + }, + accruedInterest: { + value: '1', + loading: userLoanDetails?.loading ?? true, + }, + liquidationRange: { + value: userLoanDetails?.userPrices, + loading: userLoanDetails?.loading ?? true, + }, + collateralValue: { + value: collateralValue?.toString(), + loading: (userLoanDetails?.loading ?? true) || usdRatesLoading, + }, + ltv: { + value: collateralValue ? ((Number(userLoanDetails?.userState?.debt) / collateralValue) * 100).toString() : null, + loading: userLoanDetails?.loading ?? true, + }, + totalDebt: { + value: userLoanDetails?.userState?.debt, + loading: userLoanDetails?.loading ?? true, + }, + } + return ( + , get: GetState) => ({ fetchUserLoanDetails: async (curve: LlamaApi, llamma: Llamma) => { const chainId = curve.chainId const userLoanDetailsFn = networks[chainId].api.detailInfo.userLoanInfo + + get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, { + ...get()[sliceKey].userDetailsMapper[llamma.id], + loading: true, + }) + const resp = await userLoanDetailsFn(llamma, curve.signerAddress) - get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, resp) - return resp + get()[sliceKey].setStateByActiveKey('userDetailsMapper', llamma.id, { + ...resp, + loading: false, + }) + return { ...resp, loading: false } }, fetchUserLoanPartialDetails: async (curve: LlamaApi, llamma: Llamma) => { const chainId = curve.chainId diff --git a/apps/main/src/loan/types/loan.types.ts b/apps/main/src/loan/types/loan.types.ts index 91bfe623a..01c147550 100644 --- a/apps/main/src/loan/types/loan.types.ts +++ b/apps/main/src/loan/types/loan.types.ts @@ -116,6 +116,7 @@ export type BandsBalancesData = { collateralStablecoinUsd: number } export type UserLoanDetails = { + loading: boolean healthFull: string healthNotFull: string userBands: number[] diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx new file mode 100644 index 000000000..cba29fdfd --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx @@ -0,0 +1,96 @@ +import { CardHeader, Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { Metric } from '@ui-kit/shared/ui/Metric' +import type { + Pnl, + BorrowRate, + AccruedInterest, + LiquidationRange, + Leverage, + CollateralValue, + Ltv, + TotalDebt, +} from '@ui-kit/shared/ui/PositionDetails' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +type BorrowInformationProps = { + app: 'crvusd' | 'lend' + rate: BorrowRate | undefined | null + pnl: Pnl | undefined | null + collateralValue: CollateralValue | undefined | null + ltv: Ltv | undefined | null + leverage: Leverage | undefined | null + liquidationRange: LiquidationRange | undefined | null + liquidationThreshold: number | undefined | null + totalDebt: TotalDebt | undefined | null + accruedInterest: AccruedInterest | undefined | null +} + +export const BorrowInformation = ({ + app, + rate, + pnl, + collateralValue, + ltv, + leverage, + liquidationRange, + liquidationThreshold, + totalDebt, + accruedInterest, +}: BorrowInformationProps) => ( + + + + + {app === 'lend' && } + + + {app === 'lend' && ( + + )} + {/* */} + + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx new file mode 100644 index 000000000..0789960d5 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthBar.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material' +import { Reds, Blues } from '@ui-kit/themes/design/0_primitives' + +type HealthBarProps = { + health: number | undefined | null +} + +const BAR_HEIGHT = '1.75rem' +const LINE_WIDTH = '0.25rem' + +const Line = ({ position, color }: { position: string; color: 'red' | 'orange' | 'green' }) => ( + t.design.Color.Tertiary[600] + : color === 'orange' + ? (t) => t.design.Color.Tertiary[400] + : (t) => t.design.Color.Secondary[500], + }} + /> +) + +export const HealthBar = ({ health }: HealthBarProps) => { + const healthPercentage = health ?? 0 + const trackColor = health != null && health < 5 ? Reds[500] : Blues[500] + + return ( + t.design.Color.Neutral[300], + }} + > + + + + + + ) +} diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx new file mode 100644 index 000000000..d3a58ca33 --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx @@ -0,0 +1,48 @@ +import { Box, Typography } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { Metric } from '@ui-kit/shared/ui/Metric' +import type { Health } from '@ui-kit/shared/ui/PositionDetails' +import { HealthBar } from '@ui-kit/shared/ui/PositionDetails/HealthBar' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' + +const { Spacing } = SizesAndSpaces + +export const HealthDetails = ({ health }: { health: Health }) => ( + + t.design.Layer.Highlight.Fill, + }} + > + + + + + + + {t`Health determines a position liquidation. It is not directly correlated to the price of the collateral. `} + + t.typography.fontWeightBold }}> + {t`Liquidations occur when health reaches 0.`} + + + + +) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx new file mode 100644 index 000000000..f95465cdf --- /dev/null +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx @@ -0,0 +1,57 @@ +import { CardHeader, Box } from '@mui/material' +import { t } from '@ui-kit/lib/i18n' +import { BorrowInformation } from '@ui-kit/shared/ui/PositionDetails/BorrowInformation' +import { HealthDetails } from '@ui-kit/shared/ui/PositionDetails/HealthDetails' + +export type Pnl = { value: string | undefined | null; percentageChange: string | undefined | null; loading: boolean } +export type Health = { value: string | undefined | null; loading: boolean } +export type BorrowRate = { value: string | undefined | null; loading: boolean } +export type AccruedInterest = { value: string | undefined | null; loading: boolean } +export type LiquidationRange = { value: string[] | undefined | null; loading: boolean } +export type Leverage = { value: string | undefined | null; loading: boolean } +export type CollateralValue = { value: string | undefined | null; loading: boolean } +export type Ltv = { value: string | undefined | null; loading: boolean } +export type TotalDebt = { value: string | undefined | null; loading: boolean } + +export type PositionDetailsProps = { + app: 'crvusd' | 'lend' + health: Health + borrowRate: BorrowRate + accruedInterest: AccruedInterest + pnl?: Pnl // doesn't exist yet for crvusd + liquidationRange: LiquidationRange + leverage?: Leverage // doesn't exist yet for crvusd + collateralValue: CollateralValue + ltv: Ltv + totalDebt: TotalDebt +} + +export const PositionDetails = ({ + app, + health, + borrowRate, + accruedInterest, + pnl, + liquidationRange, + leverage, + collateralValue, + ltv, + totalDebt, +}: PositionDetailsProps) => ( + + + + + +) From eb972d78846a686739bcec586fcacbe378cece39 Mon Sep 17 00:00:00 2001 From: JustJousting Date: Thu, 12 Jun 2025 03:10:11 +0300 Subject: [PATCH 03/41] feat: add support for number[] in Metric --- .../curve-ui-kit/src/shared/ui/Metric.tsx | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/curve-ui-kit/src/shared/ui/Metric.tsx b/packages/curve-ui-kit/src/shared/ui/Metric.tsx index 6ca93d87d..c95693999 100644 --- a/packages/curve-ui-kit/src/shared/ui/Metric.tsx +++ b/packages/curve-ui-kit/src/shared/ui/Metric.tsx @@ -98,10 +98,21 @@ type MetricValueProps = Required void + copyValue: (valueToCopy: number | number[]) => void } -function runFormatter(value: number, formatter: (value: number) => string, abbreviate: boolean, symbol?: string) { +function runFormatter( + value: number | number[], + formatter: (value: number) => string, + abbreviate: boolean, + symbol?: string, +): string { + if (Array.isArray(value)) { + const from = runFormatter(value[0], formatter, abbreviate, symbol) + const to = runFormatter(value[1], formatter, abbreviate, symbol) + return `${from} - ${to}` + } + if (symbol === '$' && value > MAX_USD_VALUE) { console.warn(`USD value is too large: ${value}`) return `?` @@ -119,10 +130,15 @@ const MetricValue = ({ fontVariantUnit, copyValue, }: MetricValueProps) => { - const numberValue: number | null = useMemo(() => { + const numberValue: number | number[] | null = useMemo(() => { if (typeof value === 'number' && isFinite(value)) { return value } + + if (Array.isArray(value) && value.every((v) => typeof v === 'number' && isFinite(v))) { + return value + } + return null }, [value]) @@ -131,8 +147,14 @@ const MetricValue = ({ numberValue && copyValue(numberValue)} sx={{ cursor: 'pointer' }} > @@ -149,7 +171,7 @@ const MetricValue = ({ )} - {numberValue !== null && abbreviate && ( + {typeof numberValue === 'number' && abbreviate && ( {scaleSuffix(numberValue)} @@ -174,7 +196,7 @@ const MetricValue = ({ type Props = { /** The actual metric value to display */ - value: number | '' | false | undefined | null + value: number | number[] | '' | false | undefined | null /** A unit can be a currency symbol or percentage, prefix or suffix */ unit?: Unit | undefined /** The number of decimals the value should contain */ @@ -237,9 +259,9 @@ export const Metric = ({ const [openCopyAlert, setOpenCopyAlert] = useState(false) - const copyValue = () => { - if (value) { - void copyToClipboard(value.toString()) + const copyValue = (valueToCopy: number | number[]) => { + if (valueToCopy) { + void copyToClipboard(Array.isArray(valueToCopy) ? valueToCopy.join(' - ') : valueToCopy.toString()) } setOpenCopyAlert(true) } From cf8bed1cf6666b42d50f0ac363ddaf0f35f8b41b Mon Sep 17 00:00:00 2001 From: JustJousting Date: Thu, 12 Jun 2025 03:10:41 +0300 Subject: [PATCH 04/41] feat: set up borrow information for crvUSD --- .../loan/components/LoanInfoUser/index.tsx | 20 ++++--- apps/main/src/loan/lib/apiCrvusd.ts | 1 + apps/main/src/utils/parseLocaleNumber.ts | 7 +++ .../ui/PositionDetails/BorrowInformation.tsx | 52 ++++++++++++------- .../src/shared/ui/PositionDetails/index.tsx | 25 +++++---- 5 files changed, 68 insertions(+), 37 deletions(-) create mode 100644 apps/main/src/utils/parseLocaleNumber.ts diff --git a/apps/main/src/loan/components/LoanInfoUser/index.tsx b/apps/main/src/loan/components/LoanInfoUser/index.tsx index b7e81fc4b..70764c8e0 100644 --- a/apps/main/src/loan/components/LoanInfoUser/index.tsx +++ b/apps/main/src/loan/components/LoanInfoUser/index.tsx @@ -87,31 +87,35 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const positionDetailsProps: PositionDetailsProps = { app: 'crvusd', health: { - value: healthMode.percent, + value: Number(healthMode.percent), loading: userLoanDetails?.loading ?? true, }, borrowRate: { - value: sevenDayAvgRate?.toString(), - loading: isSnapshotsLoading, + value: sevenDayAvgRate, + loading: isSnapshotsLoading || !llamma?.controller, }, accruedInterest: { - value: '1', + value: null, // this data point doesn't yet exist on API loading: userLoanDetails?.loading ?? true, }, liquidationRange: { - value: userLoanDetails?.userPrices, + value: userLoanDetails?.userPrices?.map(Number) ?? null, + loading: userLoanDetails?.loading ?? true, + }, + liquidationThreshold: { + value: userLoanDetails?.userPrices ? Number(userLoanDetails.userPrices[1]) : null, loading: userLoanDetails?.loading ?? true, }, collateralValue: { - value: collateralValue?.toString(), + value: collateralValue, loading: (userLoanDetails?.loading ?? true) || usdRatesLoading, }, ltv: { - value: collateralValue ? ((Number(userLoanDetails?.userState?.debt) / collateralValue) * 100).toString() : null, + value: collateralValue ? (Number(userLoanDetails?.userState?.debt) / collateralValue) * 100 : null, loading: userLoanDetails?.loading ?? true, }, totalDebt: { - value: userLoanDetails?.userState?.debt, + value: userLoanDetails?.userState?.debt ? Number(userLoanDetails.userState.debt) : null, loading: userLoanDetails?.loading ?? true, }, } diff --git a/apps/main/src/loan/lib/apiCrvusd.ts b/apps/main/src/loan/lib/apiCrvusd.ts index fef141587..27f36da87 100644 --- a/apps/main/src/loan/lib/apiCrvusd.ts +++ b/apps/main/src/loan/lib/apiCrvusd.ts @@ -204,6 +204,7 @@ const detailInfo = { ) const fetchedUserDetails: UserLoanDetails = { + loading: false, ...fetchedPartialUserLoanInfo, userBandsBalances: parsedBandsBalances, userBandsRange, diff --git a/apps/main/src/utils/parseLocaleNumber.ts b/apps/main/src/utils/parseLocaleNumber.ts new file mode 100644 index 000000000..4064d24d6 --- /dev/null +++ b/apps/main/src/utils/parseLocaleNumber.ts @@ -0,0 +1,7 @@ +export function parseLocaleNumber(value: string | number | undefined | null): number | null { + if (value === null || typeof value === 'undefined' || value === '') { + return null + } + const numberValue = Number(value) + return !isNaN(numberValue) && isFinite(numberValue) ? numberValue : null +} diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx index cba29fdfd..4e3688d72 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx @@ -10,6 +10,7 @@ import type { CollateralValue, Ltv, TotalDebt, + LiquidationThreshold, } from '@ui-kit/shared/ui/PositionDetails' import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' @@ -23,7 +24,7 @@ type BorrowInformationProps = { ltv: Ltv | undefined | null leverage: Leverage | undefined | null liquidationRange: LiquidationRange | undefined | null - liquidationThreshold: number | undefined | null + liquidationThreshold: LiquidationThreshold | undefined | null totalDebt: TotalDebt | undefined | null accruedInterest: AccruedInterest | undefined | null } @@ -46,51 +47,66 @@ export const BorrowInformation = ({ - {app === 'lend' && } + {app === 'lend' && ( + + )} - + {app === 'lend' && ( )} - {/* */} + - + /> */} ) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx index f95465cdf..a05017f08 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/index.tsx @@ -3,23 +3,25 @@ import { t } from '@ui-kit/lib/i18n' import { BorrowInformation } from '@ui-kit/shared/ui/PositionDetails/BorrowInformation' import { HealthDetails } from '@ui-kit/shared/ui/PositionDetails/HealthDetails' -export type Pnl = { value: string | undefined | null; percentageChange: string | undefined | null; loading: boolean } -export type Health = { value: string | undefined | null; loading: boolean } -export type BorrowRate = { value: string | undefined | null; loading: boolean } -export type AccruedInterest = { value: string | undefined | null; loading: boolean } -export type LiquidationRange = { value: string[] | undefined | null; loading: boolean } -export type Leverage = { value: string | undefined | null; loading: boolean } -export type CollateralValue = { value: string | undefined | null; loading: boolean } -export type Ltv = { value: string | undefined | null; loading: boolean } -export type TotalDebt = { value: string | undefined | null; loading: boolean } +export type Pnl = { value: number | undefined | null; percentageChange: number | undefined | null; loading: boolean } +export type Health = { value: number | undefined | null; loading: boolean } +export type BorrowRate = { value: number | undefined | null; loading: boolean } +export type AccruedInterest = { value: number | undefined | null; loading: boolean } +export type LiquidationRange = { value: number[] | undefined | null; loading: boolean } +export type LiquidationThreshold = { value: number | undefined | null; loading: boolean } +export type Leverage = { value: number | undefined | null; loading: boolean } +export type CollateralValue = { value: number | undefined | null; loading: boolean } +export type Ltv = { value: number | undefined | null; loading: boolean } +export type TotalDebt = { value: number | undefined | null; loading: boolean } export type PositionDetailsProps = { app: 'crvusd' | 'lend' health: Health borrowRate: BorrowRate - accruedInterest: AccruedInterest + accruedInterest?: AccruedInterest // doesn't yet exist on API for any app pnl?: Pnl // doesn't exist yet for crvusd liquidationRange: LiquidationRange + liquidationThreshold: LiquidationThreshold leverage?: Leverage // doesn't exist yet for crvusd collateralValue: CollateralValue ltv: Ltv @@ -33,6 +35,7 @@ export const PositionDetails = ({ accruedInterest, pnl, liquidationRange, + liquidationThreshold, leverage, collateralValue, ltv, @@ -49,7 +52,7 @@ export const PositionDetails = ({ ltv={ltv} leverage={leverage} liquidationRange={liquidationRange} - liquidationThreshold={Number(liquidationRange.value?.[1])} + liquidationThreshold={liquidationThreshold} totalDebt={totalDebt} accruedInterest={accruedInterest} /> From b9055d02979ec28ed5686441ca17d8b00275fa62 Mon Sep 17 00:00:00 2001 From: JustJousting Date: Thu, 12 Jun 2025 14:55:18 +0300 Subject: [PATCH 05/41] refactor: assemble position details in own wrapper --- .../components/PositionDetailsWrapper.tsx | 85 +++++++++++++++++++ .../loan/components/LoanInfoUser/index.tsx | 73 +--------------- 2 files changed, 88 insertions(+), 70 deletions(-) create mode 100644 apps/main/src/loan/components/LoanInfoUser/components/PositionDetailsWrapper.tsx diff --git a/apps/main/src/loan/components/LoanInfoUser/components/PositionDetailsWrapper.tsx b/apps/main/src/loan/components/LoanInfoUser/components/PositionDetailsWrapper.tsx new file mode 100644 index 000000000..c785b3c21 --- /dev/null +++ b/apps/main/src/loan/components/LoanInfoUser/components/PositionDetailsWrapper.tsx @@ -0,0 +1,85 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { useCrvUsdSnapshots } from '@/loan/entities/crvusd-snapshots' +import networks from '@/loan/networks' +import useStore from '@/loan/store/useStore' +import { ChainId, Llamma } from '@/loan/types/loan.types' +import type { Address } from '@curvefi/prices-api' +import { PositionDetails, type PositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails' + +type PositionDetailsWrapperProps = { + rChainId: ChainId + llamma: Llamma | null + llammaId: string + health: string +} + +export const PositionDetailsWrapper = ({ rChainId, llamma, llammaId, health }: PositionDetailsWrapperProps) => { + const userLoanDetails = useStore((state) => state.loans.userDetailsMapper[llammaId]) + const usdRatesLoading = useStore((state) => state.usdRates.loading) + const collateralUsdRate = useStore((state) => state.usdRates.tokens[llamma?.collateral ?? '']) + + const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useCrvUsdSnapshots({ + blockchainId: networks[rChainId].id, + contractAddress: llamma?.controller as Address, + }) + + const sevenDayAvgRate = useMemo(() => { + if (!crvUsdSnapshots) return null + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > sevenDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ rate }) => rate) * 100 + }, [crvUsdSnapshots]) + + const collateralValue = useMemo(() => { + if (!collateralUsdRate || !userLoanDetails?.userState?.collateral) return null + return ( + Number(userLoanDetails?.userState?.collateral) * Number(collateralUsdRate) + + Number(userLoanDetails?.userState?.stablecoin) + ) + }, [userLoanDetails?.userState?.collateral, userLoanDetails?.userState?.stablecoin, collateralUsdRate]) + + const positionDetailsProps: PositionDetailsProps = { + app: 'crvusd', + health: { + value: Number(health), + loading: userLoanDetails?.loading ?? true, + }, + borrowRate: { + value: sevenDayAvgRate, + loading: isSnapshotsLoading || !llamma?.controller, + }, + accruedInterest: { + value: null, // this data point doesn't yet exist on API + loading: userLoanDetails?.loading ?? true, + }, + liquidationRange: { + value: userLoanDetails?.userPrices?.map(Number) ?? null, + loading: userLoanDetails?.loading ?? true, + }, + liquidationThreshold: { + value: userLoanDetails?.userPrices ? Number(userLoanDetails.userPrices[1]) : null, + loading: userLoanDetails?.loading ?? true, + }, + collateralValue: { + value: collateralValue, + loading: (userLoanDetails?.loading ?? true) || usdRatesLoading, + }, + ltv: { + value: collateralValue ? (Number(userLoanDetails?.userState?.debt) / collateralValue) * 100 : null, + loading: userLoanDetails?.loading ?? true, + }, + totalDebt: { + value: userLoanDetails?.userState?.debt ? Number(userLoanDetails.userState.debt) : null, + loading: userLoanDetails?.loading ?? true, + }, + } + + return +} diff --git a/apps/main/src/loan/components/LoanInfoUser/index.tsx b/apps/main/src/loan/components/LoanInfoUser/index.tsx index 70764c8e0..5c5b4dd58 100644 --- a/apps/main/src/loan/components/LoanInfoUser/index.tsx +++ b/apps/main/src/loan/components/LoanInfoUser/index.tsx @@ -1,6 +1,5 @@ import isUndefined from 'lodash/isUndefined' -import meanBy from 'lodash/meanBy' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import styled from 'styled-components' import PoolInfoData from '@/loan/components/ChartOhlcWrapper' import { getHealthMode } from '@/loan/components/DetailInfoHealth' @@ -10,16 +9,13 @@ import ChartUserLiquidationRange from '@/loan/components/LoanInfoUser/components import UserInfos from '@/loan/components/LoanInfoUser/components/UserInfos' import type { PageLoanManageProps } from '@/loan/components/PageLoanManage/types' import { DEFAULT_HEALTH_MODE } from '@/loan/components/PageLoanManage/utils' -import { useCrvUsdSnapshots } from '@/loan/entities/crvusd-snapshots' -import networks from '@/loan/networks' import useStore from '@/loan/store/useStore' import { ChainId } from '@/loan/types/loan.types' -import type { Address } from '@curvefi/prices-api' import Box from '@ui/Box' import { breakpoints } from '@ui/utils/responsive' import { useUserProfileStore } from '@ui-kit/features/user-profile' import { t } from '@ui-kit/lib/i18n' -import { PositionDetails, type PositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails' +import { PositionDetailsWrapper } from './components/PositionDetailsWrapper' interface Props extends Pick { rChainId: ChainId @@ -29,14 +25,8 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const loanDetails = useStore((state) => state.loans.detailsMapper[llammaId]) const userLoanDetails = useStore((state) => state.loans.userDetailsMapper[llammaId]) const { chartExpanded } = useStore((state) => state.ohlcCharts) - const usdRatesLoading = useStore((state) => state.usdRates.loading) - const collateralUsdRate = useStore((state) => state.usdRates.tokens[llamma?.collateral ?? '']) const isAdvancedMode = useUserProfileStore((state) => state.isAdvancedMode) - const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useCrvUsdSnapshots({ - blockchainId: networks[rChainId].id, - contractAddress: llamma?.controller as Address, - }) const { userBands, healthFull, healthNotFull, userStatus } = userLoanDetails ?? {} const { oraclePriceBand } = loanDetails ?? {} @@ -44,27 +34,6 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { const [healthMode, setHealthMode] = useState(DEFAULT_HEALTH_MODE) - const sevenDayAvgRate = useMemo(() => { - if (!crvUsdSnapshots) return null - - const sevenDaysAgo = new Date() - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) - - const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > sevenDaysAgo) - - if (recentSnapshots.length === 0) return null - - return meanBy(recentSnapshots, ({ rate }) => rate) * 100 - }, [crvUsdSnapshots]) - - const collateralValue = useMemo(() => { - if (!collateralUsdRate || !userLoanDetails?.userState?.collateral) return null - return ( - Number(userLoanDetails?.userState?.collateral) * Number(collateralUsdRate) + - Number(userLoanDetails?.userState?.stablecoin) - ) - }, [userLoanDetails?.userState?.collateral, userLoanDetails?.userState?.stablecoin, collateralUsdRate]) - useEffect(() => { if (!isUndefined(oraclePriceBand) && healthFull && healthNotFull && userBands) { const fetchedHealthMode = getHealthMode( @@ -84,45 +53,9 @@ const LoanInfoUser = ({ llamma, llammaId, rChainId, titleMapper }: Props) => { } }, [oraclePriceBand, healthFull, healthNotFull, userBands]) - const positionDetailsProps: PositionDetailsProps = { - app: 'crvusd', - health: { - value: Number(healthMode.percent), - loading: userLoanDetails?.loading ?? true, - }, - borrowRate: { - value: sevenDayAvgRate, - loading: isSnapshotsLoading || !llamma?.controller, - }, - accruedInterest: { - value: null, // this data point doesn't yet exist on API - loading: userLoanDetails?.loading ?? true, - }, - liquidationRange: { - value: userLoanDetails?.userPrices?.map(Number) ?? null, - loading: userLoanDetails?.loading ?? true, - }, - liquidationThreshold: { - value: userLoanDetails?.userPrices ? Number(userLoanDetails.userPrices[1]) : null, - loading: userLoanDetails?.loading ?? true, - }, - collateralValue: { - value: collateralValue, - loading: (userLoanDetails?.loading ?? true) || usdRatesLoading, - }, - ltv: { - value: collateralValue ? (Number(userLoanDetails?.userState?.debt) / collateralValue) * 100 : null, - loading: userLoanDetails?.loading ?? true, - }, - totalDebt: { - value: userLoanDetails?.userState?.debt ? Number(userLoanDetails.userState.debt) : null, - loading: userLoanDetails?.loading ?? true, - }, - } - return ( - + Date: Mon, 16 Jun 2025 15:24:26 +0300 Subject: [PATCH 06/41] feat: init position details on lend --- .../components/DetailsUserLoan.tsx | 3 + .../components/PositionDetailsWrapper.tsx | 99 +++++++++++++++++++ apps/main/src/lend/entities/chains.ts | 18 ++++ .../src/lend/entities/lending-snapshots.ts | 23 +++++ .../main/src/lend/store/createMarketsSlice.ts | 5 + .../ui/PositionDetails/BorrowInformation.tsx | 11 ++- .../shared/ui/PositionDetails/HealthBar.tsx | 64 ++++++++---- .../ui/PositionDetails/HealthDetails.tsx | 3 +- 8 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 apps/main/src/lend/components/DetailsUser/components/PositionDetailsWrapper.tsx create mode 100644 apps/main/src/lend/entities/chains.ts create mode 100644 apps/main/src/lend/entities/lending-snapshots.ts diff --git a/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx b/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx index e69959196..15abad77e 100644 --- a/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx +++ b/apps/main/src/lend/components/DetailsUser/components/DetailsUserLoan.tsx @@ -24,6 +24,7 @@ import Box from '@ui/Box' import ListInfoItem, { ListInfoItems, ListInfoItemsWrapper } from '@ui/ListInfo' import { breakpoints } from '@ui/utils' import { useUserProfileStore } from '@ui-kit/features/user-profile' +import { PositionDetailsWrapper } from './PositionDetailsWrapper' const DetailsUserLoan = (pageProps: PageContentProps) => { const { rChainId, rOwmId, api, market, titleMapper, userActiveKey } = pageProps @@ -91,6 +92,8 @@ const DetailsUserLoan = (pageProps: PageContentProps) => { )} + + diff --git a/apps/main/src/lend/components/DetailsUser/components/PositionDetailsWrapper.tsx b/apps/main/src/lend/components/DetailsUser/components/PositionDetailsWrapper.tsx new file mode 100644 index 000000000..fdcd51620 --- /dev/null +++ b/apps/main/src/lend/components/DetailsUser/components/PositionDetailsWrapper.tsx @@ -0,0 +1,99 @@ +import meanBy from 'lodash/meanBy' +import { useMemo } from 'react' +import { useLendingSnapshots } from '@/lend/entities/lending-snapshots' +import { useTokenUsdRate } from '@/lend/entities/token' +import networks from '@/lend/networks' +import useStore from '@/lend/store/useStore' +import { ChainId, OneWayMarketTemplate } from '@/lend/types/lend.types' +import type { Address, Chain } from '@curvefi/prices-api' +import { PositionDetails, type PositionDetailsProps } from '@ui-kit/shared/ui/PositionDetails' + +type PositionDetailsWrapperProps = { + rChainId: ChainId + market: OneWayMarketTemplate | undefined + userActiveKey: string +} + +export const PositionDetailsWrapper = ({ rChainId, market, userActiveKey }: PositionDetailsWrapperProps) => { + const userLoanDetailsResp = useStore((state) => state.user.loansDetailsMapper[userActiveKey]) + const isFetchingAll = useStore((state) => state.markets.isFetchingAll) + + const { data: collateralUsdRate, isLoading: collateralUsdRateLoading } = useTokenUsdRate({ + chainId: rChainId, + tokenAddress: market?.addresses?.collateral_token, + }) + + const { details: userLoanDetails } = userLoanDetailsResp ?? {} + + const { data: crvUsdSnapshots, isLoading: isSnapshotsLoading } = useLendingSnapshots({ + blockchainId: networks[rChainId].id as Chain, + contractAddress: market?.addresses?.controller as Address, + }) + + const sevenDayAvgRate = useMemo(() => { + if (!crvUsdSnapshots) return null + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + const recentSnapshots = crvUsdSnapshots.filter((snapshot) => new Date(snapshot.timestamp) > sevenDaysAgo) + + if (recentSnapshots.length === 0) return null + + return meanBy(recentSnapshots, ({ rate }) => rate) * 100 + }, [crvUsdSnapshots]) + + const collateralValue = useMemo(() => { + if (!collateralUsdRate || !userLoanDetails?.state?.collateral) return null + return ( + Number(userLoanDetails?.state?.collateral) * Number(collateralUsdRate) + Number(userLoanDetails?.state?.borrowed) // assuming crvusd is borrowed + ) + }, [userLoanDetails?.state?.collateral, userLoanDetails?.state?.borrowed, collateralUsdRate]) + + const positionDetailsProps: PositionDetailsProps = { + app: 'lend', + health: { + value: Number(userLoanDetails?.healthFull), + loading: isFetchingAll ?? true, + }, + borrowRate: { + value: sevenDayAvgRate, + loading: isSnapshotsLoading || !market?.addresses.controller, + }, + accruedInterest: { + value: null, // this data point doesn't yet exist on API + loading: isFetchingAll ?? true, + }, + liquidationRange: { + value: userLoanDetails?.prices?.map(Number) ?? null, + loading: isFetchingAll ?? true, + }, + liquidationThreshold: { + value: userLoanDetails?.prices ? Number(userLoanDetails.prices[1]) : null, + loading: isFetchingAll ?? true, + }, + collateralValue: { + value: collateralValue, + loading: isFetchingAll || collateralUsdRateLoading, + }, + ltv: { + value: collateralValue ? (Number(userLoanDetails?.state?.debt) / collateralValue) * 100 : null, + loading: isFetchingAll ?? true, + }, + pnl: { + value: userLoanDetails?.pnl?.currentProfit ? Number(userLoanDetails.pnl.currentProfit) : null, + percentageChange: userLoanDetails?.pnl?.percentage ? Number(userLoanDetails.pnl.percentage) : null, + loading: isFetchingAll ?? true, + }, + leverage: { + value: userLoanDetails?.leverage ? Number(userLoanDetails.leverage) : null, + loading: isFetchingAll ?? true, + }, + totalDebt: { + value: userLoanDetails?.state?.debt ? Number(userLoanDetails.state.debt) : null, + loading: isFetchingAll ?? true, + }, + } + + return +} diff --git a/apps/main/src/lend/entities/chains.ts b/apps/main/src/lend/entities/chains.ts new file mode 100644 index 000000000..36c7d298f --- /dev/null +++ b/apps/main/src/lend/entities/chains.ts @@ -0,0 +1,18 @@ +import { getSupportedChains as getSupportedChainsFromApi } from '@curvefi/prices-api/chains' +import { getChains } from '@curvefi/prices-api/llamalend' +import { EmptyValidationSuite } from '@ui-kit/lib' +import { queryFactory } from '@ui-kit/lib/model' + +export const { fetchQuery: fetchSupportedChains, setQueryData: setSupportedChains } = queryFactory({ + queryKey: () => ['prices-api', 'supported-chains'] as const, + queryFn: getSupportedChainsFromApi, + staleTime: '1d', + validationSuite: EmptyValidationSuite, +}) + +export const { fetchQuery: fetchSupportedLendingChains, setQueryData: setSupportedLendingChains } = queryFactory({ + queryKey: () => ['prices-api', 'supported-lending-chains'] as const, + queryFn: getChains, + staleTime: '1d', + validationSuite: EmptyValidationSuite, +}) diff --git a/apps/main/src/lend/entities/lending-snapshots.ts b/apps/main/src/lend/entities/lending-snapshots.ts new file mode 100644 index 000000000..5f06f7486 --- /dev/null +++ b/apps/main/src/lend/entities/lending-snapshots.ts @@ -0,0 +1,23 @@ +import { fetchSupportedLendingChains } from '@/lend/entities/chains' +import { Chain } from '@curvefi/prices-api' +import { getSnapshots, Snapshot } from '@curvefi/prices-api/llamalend' +import { ContractParams, ContractQuery, queryFactory, rootKeys } from '@ui-kit/lib/model/query' +import { contractValidationSuite } from '@ui-kit/lib/model/query/contract-validation' + +type LendingSnapshotFromApi = Snapshot +export type LendingSnapshot = LendingSnapshotFromApi + +export const { useQuery: useLendingSnapshots } = queryFactory({ + queryKey: (params: ContractParams) => [...rootKeys.contract(params), 'lendingSnapshots', 'v1'] as const, + queryFn: async ({ blockchainId, contractAddress }: ContractQuery): Promise => { + const chains = await fetchSupportedLendingChains({}) + const chain = blockchainId as Chain + if (!chains.includes(chain)) return [] // backend gives 404 for optimism + + // todo: pass {sort_by: 'DATE_ASC, start: now-week} and remove reverse (backend is timing out) + const response = await getSnapshots(chain, contractAddress, { agg: 'none', fetch_on_chain: false }) + return response.reverse() + }, + staleTime: '1h', + validationSuite: contractValidationSuite, +}) diff --git a/apps/main/src/lend/store/createMarketsSlice.ts b/apps/main/src/lend/store/createMarketsSlice.ts index 32c9c2fe4..94615cf98 100644 --- a/apps/main/src/lend/store/createMarketsSlice.ts +++ b/apps/main/src/lend/store/createMarketsSlice.ts @@ -25,6 +25,7 @@ import { getLib } from '@ui-kit/features/connect-wallet' type StateKey = keyof typeof DEFAULT_STATE type SliceState = { + isFetchingAll: boolean statsParametersMapper: { [chainId: string]: MarketsStatsParametersMapper } statsBandsMapper: { [chainId: string]: MarketsStatsBandsMapper } statsTotalsMapper: { [chainId: string]: MarketsStatsTotalsMapper } @@ -62,6 +63,7 @@ export type MarketsSlice = { } const DEFAULT_STATE: SliceState = { + isFetchingAll: true, statsParametersMapper: {}, statsBandsMapper: {}, statsTotalsMapper: {}, @@ -86,6 +88,8 @@ const createMarketsSlice = (set: SetState, get: GetState): Markets const { chainId } = api + sliceState.setStateByKey('isFetchingAll', true) + const fnMapper = { statsParametersMapper: apiLending.market.fetchStatsParameters, statsBandsMapper: apiLending.market.fetchStatsBands, @@ -116,6 +120,7 @@ const createMarketsSlice = (set: SetState, get: GetState): Markets cMapper[owmId] = resp[owmId] }) sliceState.setStateByActiveKey(k, chainId.toString(), cMapper) + sliceState.setStateByKey('isFetchingAll', false) }, fetchAll: async (api, OneWayMarketTemplate, shouldRefetch) => { diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx index 4e3688d72..785b263ef 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx @@ -52,7 +52,14 @@ export const BorrowInformation = ({ unit="percentage" /> {app === 'lend' && ( - + )} - {app === 'lend' && ( + {app === 'lend' && leverage?.value && leverage?.value > 1 && ( ( ) +const Label = ({ position, text }: { position: string; text: string }) => ( + + + {text} + + +) + export const HealthBar = ({ health }: HealthBarProps) => { - const healthPercentage = health ?? 0 + const healthPercentage = Math.min(health ?? 0, 100) const trackColor = health != null && health < 5 ? Reds[500] : Blues[500] return ( - t.design.Color.Neutral[300], - }} - > + + + t.design.Color.Neutral[300], + transition: 'background-color 0.3s ease-in-out', }} - /> - - - + > + + + + + ) } diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx index d3a58ca33..cc70fd1f2 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx @@ -25,13 +25,14 @@ export const HealthDetails = ({ health }: { health: Health }) => ( borderColor: (t) => t.design.Layer.Highlight.Fill, }} > - + From 965d509719e08f0dd850145f934c91ffdc9467fd Mon Sep 17 00:00:00 2001 From: JustJousting Date: Tue, 17 Jun 2025 18:38:32 +0300 Subject: [PATCH 07/41] styling: tweak healthbar --- .../ui/PositionDetails/BorrowInformation.tsx | 2 +- .../shared/ui/PositionDetails/HealthBar.tsx | 75 ++++++++++++------- .../ui/PositionDetails/HealthDetails.tsx | 3 +- 3 files changed, 52 insertions(+), 28 deletions(-) diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx index 785b263ef..4e9817f30 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/BorrowInformation.tsx @@ -54,7 +54,7 @@ export const BorrowInformation = ({ {app === 'lend' && ( ( +type LineColor = 'red' | 'orange' | 'green' | 'dark-green' + +const getLineColor = (color: LineColor) => (t: Theme) => { + switch (color) { + case 'red': + return t.design.Color.Tertiary[600] + case 'orange': + return t.design.Color.Tertiary[400] + case 'dark-green': + return t.design.Color.Secondary[600] + case 'green': + return t.design.Color.Secondary[500] + } +} + +const Line = ({ first, position, color }: { first?: boolean; position: string; color: LineColor }) => ( t.design.Color.Tertiary[600] - : color === 'orange' - ? (t) => t.design.Color.Tertiary[400] - : (t) => t.design.Color.Secondary[500], + backgroundColor: getLineColor(color), }} /> ) -const Label = ({ position, text }: { position: string; text: string }) => ( +const Label = ({ + first, + last, + position, + text, +}: { + first?: boolean + last?: boolean + position: string + text: string +}) => ( - + {text} ) export const HealthBar = ({ health }: HealthBarProps) => { - const healthPercentage = Math.min(health ?? 0, 100) + const healthPercentage = Math.max(0, Math.min(health ?? 0, 100)) const trackColor = health != null && health < 5 ? Reds[500] : Blues[500] return ( - - - diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/CollateralMetric/index.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/CollateralMetric/index.tsx index a5da06a2f..3ad9d1343 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/CollateralMetric/index.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/CollateralMetric/index.tsx @@ -20,7 +20,7 @@ export const CollateralMetric = ({ collateralValue }: CollateralMetricProps) => label={t`Collateral Value`} value={collateralValue?.totalValue} loading={collateralValue?.totalValue == null && collateralValue?.loading} - unit="dollar" + valueOptions={{ unit: 'dollar' }} hideTooltip /> diff --git a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx index 0dcfd40a8..6afadced3 100644 --- a/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx +++ b/packages/curve-ui-kit/src/shared/ui/PositionDetails/HealthDetails.tsx @@ -29,10 +29,8 @@ export const HealthDetails = ({ health }: { health: Health }) => ( From d402ab063be9d858e32fcd5c9d415407f97ebf05 Mon Sep 17 00:00:00 2001 From: JustJousting Date: Mon, 23 Jun 2025 14:47:47 +0300 Subject: [PATCH 16/41] fix: enable copy on click when hideTooltip is true --- packages/curve-ui-kit/src/shared/ui/Metric.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/curve-ui-kit/src/shared/ui/Metric.tsx b/packages/curve-ui-kit/src/shared/ui/Metric.tsx index 8c0d8c074..d280c9a02 100644 --- a/packages/curve-ui-kit/src/shared/ui/Metric.tsx +++ b/packages/curve-ui-kit/src/shared/ui/Metric.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' +import { Box } from '@mui/material' import Alert from '@mui/material/Alert' import AlertTitle from '@mui/material/AlertTitle' import Snackbar from '@mui/material/Snackbar' @@ -196,7 +197,9 @@ const MetricValue = ({ {content} ) : ( - content + + {content} + )} {(change || change === 0) && ( @@ -222,7 +225,7 @@ type Props = { label: string /** Optional tooltip content shown next to the label */ tooltip?: string - /** If the tooltip should be hidden */ + /** If the component tooltip should be hidden in order to be able to wrap the component with a custom tooltip */ hideTooltip?: boolean /** The text to display when the value is copied to the clipboard */ copyText?: string @@ -252,6 +255,7 @@ export const Metric = ({ alignment = 'start', loading = false, testId, + ...props }: Props) => { const { decimals = 1, formatter = (value: number) => formatValue(value, decimals) } = valueOptions const unit = typeof valueOptions.unit === 'string' ? UNIT_MAP[valueOptions.unit] : valueOptions.unit @@ -286,7 +290,7 @@ export const Metric = ({ } return ( - + {label} {tooltip && ( From 1ac700e0df87a16b2f2a2466a7cbe2dd70211a16 Mon Sep 17 00:00:00 2001 From: JustJousting Date: Mon, 23 Jun 2025 18:31:13 +0300 Subject: [PATCH 17/41] refactor: handle custom metric tooltips --- .../curve-ui-kit/src/shared/ui/Metric.tsx | 112 +++++++++--------- .../ui/PositionDetails/BorrowInformation.tsx | 16 ++- .../CollateralMetric/index.tsx | 27 ----- .../CollateralMetricTooltip.tsx | 0 4 files changed, 70 insertions(+), 85 deletions(-) delete mode 100644 packages/curve-ui-kit/src/shared/ui/PositionDetails/CollateralMetric/index.tsx rename packages/curve-ui-kit/src/shared/ui/PositionDetails/{CollateralMetric => tooltips}/CollateralMetricTooltip.tsx (100%) diff --git a/packages/curve-ui-kit/src/shared/ui/Metric.tsx b/packages/curve-ui-kit/src/shared/ui/Metric.tsx index d280c9a02..f4474aeaf 100644 --- a/packages/curve-ui-kit/src/shared/ui/Metric.tsx +++ b/packages/curve-ui-kit/src/shared/ui/Metric.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from 'react' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' -import { Box } from '@mui/material' import Alert from '@mui/material/Alert' import AlertTitle from '@mui/material/AlertTitle' +import type { PopperProps } from '@mui/material/Popper' import Snackbar from '@mui/material/Snackbar' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' @@ -78,6 +78,15 @@ const UNIT_MAP = { type Unit = keyof typeof UNIT_MAP | UnitOptions export const UNITS = Object.keys(UNIT_MAP) as unknown as keyof typeof UNIT_MAP +type Placement = PopperProps['placement'] +/** Optional tooltip to replace the default tooltip shown when hovering over the value */ +type ValueTooltipOptions = { + title?: string + body?: React.ReactNode + placement?: Placement + arrow?: boolean +} + /** Options for any being used, whether it's the main value or a notional it doesn't matter */ type ValueOptions = { /** A unit can be a currency symbol or percentage, prefix or suffix */ @@ -132,7 +141,7 @@ type MetricValueProps = Pick & fontVariant: TypographyVariantKey fontVariantUnit: TypographyVariantKey copyValue: () => void - hideTooltip?: boolean + valueTooltip?: ValueTooltipOptions } const MetricValue = ({ @@ -144,7 +153,7 @@ const MetricValue = ({ fontVariant, fontVariantUnit, copyValue, - hideTooltip, + valueTooltip, }: MetricValueProps) => { const numberValue: number | null = useMemo(() => { if (typeof value === 'number' && isFinite(value)) { @@ -155,52 +164,43 @@ const MetricValue = ({ const { symbol, position, abbreviate = false } = unit ?? {} - const content = ( - - {position === 'prefix' && numberValue !== null && ( - - {symbol} - - )} - - - {useMemo( - () => (numberValue === null ? t`N/A` : runFormatter(numberValue, formatter, abbreviate, symbol)), - [numberValue, formatter, abbreviate, symbol], - )} - - - {numberValue !== null && abbreviate && ( - - {scaleSuffix(numberValue)} - - )} - - {position === 'suffix' && numberValue !== null && ( - - {symbol} - - )} - - ) - return ( - {!hideTooltip ? ( - - {content} - - ) : ( - - {content} - - )} + + + {position === 'prefix' && numberValue !== null && ( + + {symbol} + + )} + + + {useMemo( + () => (numberValue === null ? t`N/A` : runFormatter(numberValue, formatter, abbreviate, symbol)), + [numberValue, formatter, abbreviate, symbol], + )} + + + {numberValue !== null && abbreviate && ( + + {scaleSuffix(numberValue)} + + )} + + {position === 'suffix' && numberValue !== null && ( + + {symbol} + + )} + + {(change || change === 0) && (