diff --git a/.gitignore b/.gitignore index 23407396ef9..b4d14549b04 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ yarn-error.log* # translation benchmark data scripts/translations/benchmark/ +.gemini/ diff --git a/e2e/fixtures/chainflip-lending-revamp-ui.yaml b/e2e/fixtures/chainflip-lending-revamp-ui.yaml index 29409da8389..0da1ee74806 100644 --- a/e2e/fixtures/chainflip-lending-revamp-ui.yaml +++ b/e2e/fixtures/chainflip-lending-revamp-ui.yaml @@ -1,32 +1,101 @@ name: Chainflip Lending Revamp UI -description: Validates the revamped Chainflip lending surfaces for USDC pool and key action modals. +description: Validates the revamped Chainflip lending dashboard, init view, funded view with sections, and key action modals. route: /chainflip-lending steps: - - name: Chainflip lending dashboard - instruction: Open the chainflip lending dashboard and ensure the refreshed layout is visible. - expected: Chainflip lending dashboard cards and market tables are visible. + # === No-wallet state === + - name: Landing page without wallet + instruction: Open the chainflip lending page without a wallet connected. + expected: > + Header shows Total Supplied, Available Liquidity, Total Borrowed stat cards. + "All Markets" section with "Lending Markets" heading, description text, and 6-column table + (Pool, Supply APY, Total Supplied, Borrow APR, Total Borrowed, Utilisation). + USDC row shows non-zero APY. Connect Wallet button is visible. screenshot: true - - name: Open usdc pool - instruction: Navigate into the USDC pool from the All Markets table. - expected: USDC pool page is visible with action tabs. + + # === Init view (fresh wallet, no positions) === + - name: Init view for fresh wallet + instruction: Connect a wallet with no chainflip lending positions. Observe the My Dashboard tab. + expected: > + Hero card with "Get Started" badge, "Deposit your first asset to get started" heading, + blue "+ Deposit" button, "Requires 2 FLIP" note, and asset constellation art (ETH, BTC, USDC, USDT, SOL icons with orbital rings). + Lending Markets table with 6 columns below. + Two info cards at bottom: "Earn Yield" (green sparkle art) and "Borrow Against Collateral" (purple refresh art). screenshot: true - - name: Supply modal - instruction: Open Supply action and verify supply input controls. - expected: Supply modal is open with amount input, max and submit controls. + + # === Funded dashboard === + - name: Funded dashboard overview + instruction: Connect a wallet with free balance on chainflip (e.g. import TorSwapKeyStore4). Navigate to My Dashboard tab. + expected: > + Header stat cards: Free Balance (with value), Supplied $0.00, Collateral $0.00, Borrowed $0.00. + My Dashboard / Markets tabs visible, My Dashboard selected. + Free Balance section with Asset/Balance columns, listing assets with balances. + Blue filled "+ Deposit" button and ghost "Withdraw" button in Free Balance header. + Next Steps sidebar card with green sparkle art, "Supply" and "Add Collateral" buttons. screenshot: true - - name: Deposit and egress modal - instruction: Open Deposit to Chainflip tab and open Withdraw modal. - expected: Egress modal is open with destination toggle and amount controls. + + - name: Empty sections display + instruction: Scroll down on the funded dashboard to see Supplied, Collateral, and Borrowed sections. + expected: > + Supplied section: "$0.00", "No earning positions yet" empty state with blue "+ Deposit" button. + Collateral section: "$0.00", "Provide collateral to start borrowing" with blue "+ Add Collateral" button. + Borrowed section: "$0.00", "No Active Loans" with blue "+ Add Collateral" button. screenshot: true - - name: Collateral modal ltv gauge - instruction: Open Collateral tab and open Add Collateral modal. - expected: LTV gauge shows target, soft liquidation and hard liquidation labels without overlap. + + # === Action modals from dashboard === + - name: Deposit modal from dashboard + instruction: Click the blue "+ Deposit" button in the Free Balance section header. + expected: > + Deposit modal opens. Asset selector card with icon, name, balance visible. + Amount input field with asset icon. Info rows below divider. + Blue "Next" button at bottom. screenshot: true - - name: Borrow modal - instruction: Open Manage Loan tab and open Borrow modal. - expected: Borrow modal is open with amount input and target LTV section. + + - name: Supply modal from Next Steps + instruction: Close deposit modal. Click "Supply" button in the Next Steps sidebar card. + expected: > + Supply modal opens with "Asset to supply" selector card. + Pool APY and Current Position stat boxes side-by-side. + Amount input with asset icon. Info rows: Estimated yearly earnings, Pool Share, Auto-compounding, Risk band. + Blue "Next" button at bottom. screenshot: true - - name: Repay modal - instruction: Open Repay modal from Manage Loan tab. - expected: Repay modal is open with full repayment toggle and amount controls. + + - name: Add Collateral modal + instruction: Close supply modal. Click "+ Add Collateral" button from the Collateral section empty state. + expected: > + Add Collateral modal opens with asset selection, amount input, and LTV gauge preview. + screenshot: true + + # === Loan Health / LTV bar === + - name: Loan Health bar (requires active loan) + instruction: > + If the wallet has an active loan (collateral posted + borrowed amount > 0), + verify the Loan Health card appears above the section cards on My Dashboard. + It should show: heart rate monitor icon, "Loan Health" label, Current LTV percentage + (color-coded green/yellow/red), Liquidation Distance fiat value on the right, + and a horizontal multi-segment bar (Safe green / Risky yellow / Liquidation red) + with a skull icon at the hard liquidation boundary. + If no active loan exists, the Loan Health card should NOT render (this is correct). + expected: > + Loan Health card renders when loans exist with LTV gauge bar, + or correctly hidden when no loans. + data-testid: chainflip-lending-loan-health + screenshot: true + + # === Visual polish checks === + - name: Empty section dashed borders + instruction: > + On the funded dashboard with no supply/collateral/borrowed positions, + verify that the Supplied, Collateral, and Borrowed section cards have + dashed borders (not solid) to visually indicate empty state. + expected: > + Empty section cards have dashed border style. Free Balance card (with data) has solid border. + screenshot: true + + # === Markets tab === + - name: Markets tab + instruction: Close any open modal. Switch to the "Markets" tab. + expected: > + Lending Markets heading with description. + 6-column table (Pool, Supply APY, Total Supplied, Borrow APR, Total Borrowed, Utilisation). + USDC and Tether rows show non-zero APY and utilisation. screenshot: true diff --git a/src/assets/chainflip-lending/borrow-glow.svg b/src/assets/chainflip-lending/borrow-glow.svg new file mode 100644 index 00000000000..6c76440904b --- /dev/null +++ b/src/assets/chainflip-lending/borrow-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-1.svg b/src/assets/chainflip-lending/borrow-ring-1.svg new file mode 100644 index 00000000000..a92b59e8483 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-2.svg b/src/assets/chainflip-lending/borrow-ring-2.svg new file mode 100644 index 00000000000..d1d3ce292b5 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-3.svg b/src/assets/chainflip-lending/borrow-ring-3.svg new file mode 100644 index 00000000000..9548e3cdd19 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-3.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-inner.svg b/src/assets/chainflip-lending/borrow-ring-inner.svg new file mode 100644 index 00000000000..4fcf8325f28 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-inner.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-glow.svg b/src/assets/chainflip-lending/earn-glow.svg new file mode 100644 index 00000000000..dd85a9c9cb6 --- /dev/null +++ b/src/assets/chainflip-lending/earn-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-inner.svg b/src/assets/chainflip-lending/earn-ring-inner.svg new file mode 100644 index 00000000000..522748941c5 --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-inner.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-middle.svg b/src/assets/chainflip-lending/earn-ring-middle.svg new file mode 100644 index 00000000000..13ca13d1cde --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-middle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-outer.svg b/src/assets/chainflip-lending/earn-ring-outer.svg new file mode 100644 index 00000000000..e7615115bd3 --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-outer.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/glow-btc.svg b/src/assets/chainflip-lending/glow-btc.svg new file mode 100644 index 00000000000..9aa0db74b36 Binary files /dev/null and b/src/assets/chainflip-lending/glow-btc.svg differ diff --git a/src/assets/chainflip-lending/glow-eth.svg b/src/assets/chainflip-lending/glow-eth.svg new file mode 100644 index 00000000000..2a33fe95f74 Binary files /dev/null and b/src/assets/chainflip-lending/glow-eth.svg differ diff --git a/src/assets/chainflip-lending/orbital-btc.svg b/src/assets/chainflip-lending/orbital-btc.svg new file mode 100644 index 00000000000..d9e469a2789 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-btc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-eth.svg b/src/assets/chainflip-lending/orbital-eth.svg new file mode 100644 index 00000000000..73ae0542394 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-eth.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-sol.svg b/src/assets/chainflip-lending/orbital-sol.svg new file mode 100644 index 00000000000..1ad6262f3ba --- /dev/null +++ b/src/assets/chainflip-lending/orbital-sol.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-tether.svg b/src/assets/chainflip-lending/orbital-tether.svg new file mode 100644 index 00000000000..528ecd9a152 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-tether.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-usdc.svg b/src/assets/chainflip-lending/orbital-usdc.svg new file mode 100644 index 00000000000..db36016ed6c --- /dev/null +++ b/src/assets/chainflip-lending/orbital-usdc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/refresh-icon.svg b/src/assets/chainflip-lending/refresh-icon.svg new file mode 100644 index 00000000000..0679a629a48 --- /dev/null +++ b/src/assets/chainflip-lending/refresh-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/chainflip-lending/sparkles-icon.svg b/src/assets/chainflip-lending/sparkles-icon.svg new file mode 100644 index 00000000000..8d12cd182b5 --- /dev/null +++ b/src/assets/chainflip-lending/sparkles-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 45bd0ec9ad3..40cef1a3edd 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2456,6 +2456,8 @@ "manageLoan": "Manage Loan", "supply": { "title": "Supply", + "subtitle": "Allocate your free balance into lending pools to earn yield", + "asset": "Asset", "amount": "Amount", "available": "Available to supply", "availableTooltip": "Your free balance on Chainflip State Chain available for lending", @@ -2463,6 +2465,14 @@ "confirmTitle": "Confirm Supply", "confirmDescription": "Supply %{amount} %{asset} to the lending pool", "confirmAndSupply": "Confirm & Supply", + "poolApy": "Pool APY", + "currentPosition": "Current Position", + "autoCompounding": "Auto-compounding", + "enabled": "Enabled", + "destination": "Destination", + "lendingPool": "%{asset} Lending Pool", + "yearlyEarningsSuffix": "%{amount} / year", + "year": "year", "executingTitle": "Supplying...", "executingDescription": "Your supply is being processed", "successTitle": "Supply Successful", @@ -2475,10 +2485,16 @@ "signing": "Signing supply transaction", "confirming": "Confirming supply" }, - "noFreeBalance": "No free balance. Deposit to Chainflip first." + "noFreeBalance": "No free balance. Deposit to Chainflip first.", + "assetToSupply": "Asset to supply", + "poolShare": "Pool Share", + "riskBand": "Risk band", + "conservativeStablecoin": "Conservative stablecoin pool", + "volatileAsset": "Volatile asset pool" }, "borrow": { "title": "Borrow", + "subtitle": "Borrow assets against your collateral", "amount": "Amount", "available": "Available to borrow", "maxLtv": "Max LTV (80%)", @@ -2487,6 +2503,7 @@ "confirmTitle": "Confirm Borrow", "confirmDescription": "Borrow %{amount} %{asset} against your collateral", "confirmAndBorrow": "Confirm & Borrow", + "asset": "Asset", "executingTitle": "Borrowing...", "executingDescription": "Your loan is being processed", "successTitle": "Borrow Successful", @@ -2529,6 +2546,8 @@ }, "supplyApy": "Supply APY", "supplyApyTooltip": "Annual percentage yield earned by supplying assets to this pool.", + "borrowApr": "Borrow APR", + "borrowAprTooltip": "Annual percentage rate charged for borrowing from this pool.", "borrowRate": "Borrow Rate", "utilisation": "Utilisation", "utilisationTooltip": "Percentage of supplied assets currently being borrowed. Higher utilisation means higher yields but less available liquidity.", @@ -2543,6 +2562,8 @@ "add": "Add Collateral", "remove": "Remove Collateral", "amount": "Amount", + "asset": "Asset", + "action": "Action", "availableToAdd": "Available to add", "availableToRemove": "Available to remove", "confirmAddTitle": "Confirm Add Collateral", @@ -2582,9 +2603,12 @@ "connectWalletDescription": "Connect your wallet to view your lending positions.", "protocolStats": "Protocol Stats", "deposit": { + "subtitle": "Move assets from your wallet into your free balance", "amount": "Amount", "available": "Available", "explainer": "Assets will be deposited to your Chainflip State Chain account. Once deposited, you can supply to lending pools to earn yield.", + "asset": "Asset", + "recoveryAddress": "Recovery Address", "openChannel": "Open Deposit Channel", "confirmTitle": "Confirm Deposit", "confirmDescription": "Deposit %{amount} %{asset} to your Chainflip State Chain account. You will be guided through each required step and prompted to sign transactions as needed.", @@ -2632,8 +2656,10 @@ "myBalancesTitle": "Chainflip Lending - My Balances", "withdraw": { "title": "Withdraw", + "subtitle": "Withdraw assets from the lending pool back to your free balance", "amount": "Amount", "available": "Available to withdraw", + "destination": "Destination", "availableTooltip": "Your supply position in the lending pool", "confirmTitle": "Confirm Withdrawal", "confirmDescription": "Withdraw %{amount} %{asset} from the lending pool", @@ -2680,8 +2706,12 @@ }, "repay": { "title": "Repay", + "subtitle": "Repay outstanding loan balance", "amount": "Amount", + "asset": "Asset", "outstanding": "Outstanding debt", + "repaymentType": "Repayment Type", + "full": "Full", "fullRepayment": "Full repayment", "partialRepayment": "Partial repayment", "confirmTitle": "Confirm Repayment", @@ -2712,12 +2742,16 @@ "softLiquidation": "Soft Liquidation", "hardLiquidation": "Hard Liquidation", "safe": "Safe", + "risky": "Risky", + "liquidation": "Liquidation", "warning": "Warning", "danger": "Danger" }, "egress": { "title": "Withdraw from Chainflip", + "subtitle": "Withdraw from your free balance to your wallet", "amount": "Amount", + "asset": "Asset", "destination": "Destination Address", "destinationPlaceholder": "Enter destination address", "available": "Available Free Balance", @@ -2766,6 +2800,57 @@ "signing": "Sign transaction", "confirming": "Confirming on-chain" } + }, + "dashboard": { + "freeBalance": "Free Balance", + "supplied": "Supplied", + "collateral": "Collateral", + "borrowed": "Borrowed", + "loanHealth": "Loan Health", + "currentLtv": "Current LTV %{ltv}", + "liquidationDistance": "Liquidation Distance", + "borrowingPower": "Borrowing Power", + "available": "Available", + "yourNextSteps": "Your Next Steps", + "nextStepsSupplyOrCollateral": "Choose whether you want to start earning yield or provide collateral for borrowing.", + "nextStepsCollateral": "Provide collateral to start borrowing.", + "nextStepsBorrow": "You can now borrow against your collateral.", + "noEarningPositions": "No earning positions yet", + "noEarningPositionsDescription": "Supply from your free balance to start earning yield from borrower demand.", + "provideCollateral": "Provide collateral to start borrowing", + "provideCollateralDescription": "Move assets from your free balance into collateral. Your collateral determines how much you can borrow.", + "noActiveLoans": "No Active Loans", + "noActiveLoansDescription": "Post collateral first, then open borrow against it.", + "cantRepay": "Can't Repay?", + "startLiquidation": "Start Liquidation", + "topupAsset": "Top-up Asset", + "getStarted": "Get Started", + "depositFirstAsset": "Deposit your first asset to get started", + "depositFirstAssetDescription": "Your first deposit handles everything - account creation, FLIP funding for State Chain gas, and a recovery address for the chain you deposit from. Future deposits skip all of this.", + "requiresFlip": "Requires 2 FLIP for one-time account funding", + "earnYield": "Earn Yield", + "earnYieldDescription": "Supply assets into a lending pool and earn variable APY. Yields auto-compound, no action needed after supplying.", + "borrowAgainstCollateral": "Borrow Against Collateral", + "borrowAgainstCollateralDescription": "Post BTC, ETH, or SOL as collateral and borrow stablecoins up to the target LTV.", + "estimatedYearlyEarnings": "Est. Yearly Earnings", + "freeBalanceTooltip": "Your available balance on Chainflip State Chain. Deposit from your wallet, or withdraw back.", + "suppliedTooltip": "Assets supplied to lending pools earning yield.", + "collateralTooltip": "Assets posted as collateral for borrowing.", + "borrowedTooltip": "Outstanding loan balances.", + "loanHealthTooltip": "Your loan-to-value ratio. Keep it below the target LTV to avoid liquidation.", + "borrowingPowerTooltip": "How much more you can borrow based on your collateral.", + "deposit": "Deposit", + "withdraw": "Withdraw", + "supply": "Supply", + "addCollateral": "Add Collateral", + "borrow": "Borrow", + "repay": "Repay", + "apy": "APY", + "borrowRate": "Borrow Rate", + "viewDashboard": "View Dashboard", + "lendingMarkets": "Lending Markets", + "asset": "Asset", + "lendingMarketsDescription": "Each pool is isolated per asset. Pick a market to see what you could earn or borrow before you deposit." } }, "chart": { diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx index 974b7a5f61c..fa2d259d877 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { BorrowMachineCtx } from './BorrowMachineContext' import { BorrowStepper } from './BorrowStepper' @@ -30,6 +31,7 @@ type BorrowConfirmProps = { export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() @@ -89,6 +91,11 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -135,17 +142,29 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { px={6} py={4} > - + + + + ) @@ -258,24 +277,42 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.borrow.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.borrow.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.borrow.amount')} + - - {translate('chainflipLending.borrow.projectedLtv')}: {projectedLtvPercent}% + + + + {translate('chainflipLending.borrow.projectedLtv')} + + + {projectedLtvPercent}% @@ -283,25 +320,32 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx index 9fe127d95d1..61080ec5d75 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx @@ -33,6 +33,8 @@ type BorrowInputProps = { onAssetChange: (assetId: AssetId) => void } +const DEFAULT_RISKY_LTV = 0.8 + export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const translate = useTranslate() const { @@ -100,6 +102,12 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const projectedLtvDecimal = useMemo(() => projectedLtvBps / 10000, [projectedLtvBps]) + const riskyLtv = thresholds?.target ?? DEFAULT_RISKY_LTV + const projectedLtvColor = useMemo( + () => (projectedLtvDecimal > riskyLtv ? 'red.500' : 'text.base'), + [projectedLtvDecimal, riskyLtv], + ) + const assetIds = useMemo(() => Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[], []) const assets = useAppSelector(selectAssets) @@ -256,7 +264,12 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { fontWeight='medium' /> - + )} diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx index 3723cbdd72d..b6e6ef10b37 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { CollateralMachineCtx } from './CollateralMachineContext' import { CollateralStepper } from './CollateralStepper' @@ -29,6 +30,7 @@ type CollateralConfirmProps = { export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -73,6 +75,11 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -129,17 +136,29 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { px={6} py={4} > - + + + + ) @@ -252,56 +271,74 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { return ( - - - - - - - - - {translate( - isAddMode - ? 'chainflipLending.collateral.confirmAddTitle' - : 'chainflipLending.collateral.confirmRemoveTitle', - )} + + + + + + + + + {translate('chainflipLending.collateral.asset')} - - {translate( - isAddMode - ? 'chainflipLending.collateral.confirmAddDescription' - : 'chainflipLending.collateral.confirmRemoveDescription', - { - amount: collateralAmountCryptoPrecision, - asset: asset.symbol, - }, - )} + + {asset.name} + + + + + {translate('chainflipLending.collateral.amount')} - - + + + {translate('chainflipLending.collateral.action')} + + + {translate( + isAddMode + ? 'chainflipLending.collateral.add' + : 'chainflipLending.collateral.remove', + )} + + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx index 518e3e7e9e9..1ab09a02ebc 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx @@ -1,5 +1,6 @@ -import { Box, Flex, Text, VStack } from '@chakra-ui/react' +import { Box, Circle, Flex, Icon, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' +import { TbSkull } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' @@ -9,161 +10,169 @@ type LtvGaugeProps = { projectedLtv?: number } -const GAUGE_HEIGHT = '12px' -const MARKER_HEIGHT = '24px' -const ltvToPercent = (ltv: number): string => `${Math.min(Math.max(ltv * 100, 0), 100)}%` +const GAUGE_HEIGHT = '10px' +const THUMB_SIZE = '20px' -const ltvToDisplayPercent = (ltv: number): string => - `${(Math.min(Math.max(ltv, 0), 1) * 100).toFixed(1)}%` +const DEFAULT_HARD_LIQUIDATION_LTV = 0.93 -const DEFAULT_SOFT_LIQUIDATION_LTV = 0.8 -const DEFAULT_HARD_LIQUIDATION_LTV = 0.9 +// Risky threshold sits between soft and hard liquidation - use topup or midpoint +const DEFAULT_RISKY_LTV = 0.8 -const getStatusColor = ( - ltv: number, - softLiquidationLtv: number, - hardLiquidationLtv: number, -): string => { +const ltvToPercent = (ltv: number): number => Math.min(Math.max(ltv * 100, 0), 100) + +const getStatusColor = (ltv: number, riskyLtv: number, hardLiquidationLtv: number): string => { if (ltv >= hardLiquidationLtv) return 'red.500' - if (ltv >= softLiquidationLtv) return 'yellow.500' + if (ltv >= riskyLtv) return 'yellow.500' return 'green.500' } -const getStatusKey = ( - ltv: number, - softLiquidationLtv: number, - hardLiquidationLtv: number, -): string => { - if (ltv >= hardLiquidationLtv) return 'chainflipLending.ltv.danger' - if (ltv >= softLiquidationLtv) return 'chainflipLending.ltv.warning' - return 'chainflipLending.ltv.safe' +type LegendItem = { + labelKey: string + color: string } +const legendItems: LegendItem[] = [ + { labelKey: 'chainflipLending.ltv.safe', color: 'green.500' }, + { labelKey: 'chainflipLending.ltv.risky', color: 'yellow.500' }, + { labelKey: 'chainflipLending.ltv.liquidation', color: 'red.500' }, +] + export const LtvGauge = memo(({ currentLtv, projectedLtv }: LtvGaugeProps) => { const translate = useTranslate() const { thresholds } = useChainflipLtvThresholds() - const softLiquidationLtv = thresholds?.softLiquidation ?? DEFAULT_SOFT_LIQUIDATION_LTV + // Zone boundaries: safe (0 -> risky), risky (risky -> hard liq), liquidation (hard liq -> 100%) + const riskyLtv = thresholds?.target ?? DEFAULT_RISKY_LTV const hardLiquidationLtv = thresholds?.hardLiquidation ?? DEFAULT_HARD_LIQUIDATION_LTV - const statusColor = useMemo( - () => getStatusColor(currentLtv, softLiquidationLtv, hardLiquidationLtv), - [currentLtv, softLiquidationLtv, hardLiquidationLtv], - ) - const statusKey = useMemo( - () => getStatusKey(currentLtv, softLiquidationLtv, hardLiquidationLtv), - [currentLtv, softLiquidationLtv, hardLiquidationLtv], + const thumbPosition = useMemo(() => ltvToPercent(currentLtv), [currentLtv]) + + const projectedThumbPosition = useMemo( + () => (projectedLtv !== undefined ? ltvToPercent(projectedLtv) : undefined), + [projectedLtv], ) - const gaugeGradient = useMemo( - () => - `linear(to-r, green.500 0%, green.500 ${softLiquidationLtv * 100}%, yellow.500 ${ - softLiquidationLtv * 100 - }%, yellow.500 ${hardLiquidationLtv * 100}%, red.500 ${ - hardLiquidationLtv * 100 - }%, red.800 100%)`, - [softLiquidationLtv, hardLiquidationLtv], + // Segment widths as percentages + const safeWidth = useMemo(() => riskyLtv * 100, [riskyLtv]) + const riskyWidth = useMemo( + () => (hardLiquidationLtv - riskyLtv) * 100, + [hardLiquidationLtv, riskyLtv], ) + const liquidationWidth = useMemo(() => (1 - hardLiquidationLtv) * 100, [hardLiquidationLtv]) - const thresholdMarkers = useMemo(() => { - if (!thresholds) return [] - return [ - { - value: thresholds.target, - labelKey: 'chainflipLending.ltv.target', - color: 'green.500', - }, - { - value: thresholds.softLiquidation, - labelKey: 'chainflipLending.ltv.softLiquidation', - color: 'yellow.500', - }, - { - value: thresholds.hardLiquidation, - labelKey: 'chainflipLending.ltv.hardLiquidation', - color: 'red.500', - }, - ] - }, [thresholds]) + // Position of skull icon at the hard liquidation boundary + const skullPosition = useMemo(() => hardLiquidationLtv * 100, [hardLiquidationLtv]) return ( - - + {/* Bar track */} + - + {/* Safe segment (green) */} + + {/* Risky segment (yellow) */} + + {/* Liquidation segment (red) */} + + + + {/* Filled portion up to current LTV */} + = 100 ? 'full' : '0'} + overflow='hidden' + transition='width 0.3s ease' + > + 0 ? (100 / thumbPosition) * 100 : 100}%`}> + + + + + {/* Skull icon at hard liquidation boundary */} + > + + + + - {projectedLtv !== undefined && ( + {/* Projected LTV thumb (dashed circle) */} + {projectedThumbPosition !== undefined && ( + top='50%' + left={`${projectedThumbPosition}%`} + transform='translate(-50%, -50%)' + zIndex={3} + transition='left 0.3s ease' + > + + )} - {thresholdMarkers.map(marker => ( - - ))} + {/* Current LTV thumb */} + + + - - - - {ltvToDisplayPercent(currentLtv)} - - - {translate(statusKey)} - - - - {thresholdMarkers.map(marker => ( - - - - {translate(marker.labelKey)} - - - ))} - - + {/* Percentage labels under the bar */} + + + 0% + + + 100% + + + + {/* Legend */} + + {legendItems.map(item => ( + + + + {translate(item.labelKey)} + + + ))} + ) }) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx index 59762893b31..ce18f0dcc78 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx @@ -1,10 +1,20 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Badge, Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { + Badge, + Button, + CardBody, + CardFooter, + Divider, + Flex, + HStack, + VStack, +} from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useRepayActionCenter } from './hooks/useRepayActionCenter' import { useRepayConfirmation } from './hooks/useRepayConfirmation' @@ -29,6 +39,7 @@ type RepayConfirmProps = { export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() @@ -73,6 +84,11 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -126,17 +142,29 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { px={6} py={4} > - + + + + ) @@ -249,50 +277,86 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.repay.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.repay.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.repay.amount')} + - {isFullRepayment && ( - {translate('chainflipLending.repay.fullRepayment')} - )} + + + + {translate('chainflipLending.repay.repaymentType')} + + + + {translate( + isFullRepayment + ? 'chainflipLending.repay.fullRepayment' + : 'chainflipLending.repay.partialRepayment', + )} + + {isFullRepayment && ( + + {translate('chainflipLending.repay.full')} + + )} + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx index 600103d31b8..4f681128d72 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx @@ -1,4 +1,4 @@ -import { Button, CardBody, CardFooter, Flex, Stack, Switch, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Flex, Stack, Switch, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' @@ -189,12 +189,19 @@ export const RepayInput = ({ assetId }: RepayInputProps) => { {translate('chainflipLending.repay.outstanding')} - + + + + @@ -271,18 +278,20 @@ export const RepayInput = ({ assetId }: RepayInputProps) => { color='text.subtle' /> - {!isFullRepayment && ( - - )} + + {!isFullRepayment && ( + + )} + diff --git a/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx index 00e9f2828c5..22411043698 100644 --- a/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Deposit/DepositConfirm.tsx @@ -6,6 +6,7 @@ import { BigAmount } from '@shapeshiftoss/utils' import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { DepositMachineCtx } from './DepositMachineContext' import { DepositStepper } from './DepositStepper' @@ -47,6 +48,7 @@ type DepositConfirmProps = { export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { accountNumber, scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -167,6 +169,11 @@ export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { closeModal() }, [scAccount, queryClient, actorRef, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -231,17 +238,29 @@ export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { px={6} py={4} > - + + + + ) @@ -354,54 +373,92 @@ export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.deposit.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.deposit.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.deposit.amount')} + + + + + {translate('chainflipLending.deposit.freeBalance')} + + {effectiveRefundAddress && ( - <> - - - - {translate('chainflipLending.deposit.refundAddress.label')} + + + {translate('chainflipLending.deposit.recoveryAddress')} + + + + - - - - - - - + + )} + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx index b3cd5205d8f..4a734e4512f 100644 --- a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx @@ -5,6 +5,7 @@ import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { EgressMachineCtx } from './EgressMachineContext' import { EgressStepper } from './EgressStepper' @@ -31,6 +32,7 @@ type EgressConfirmProps = { export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -80,6 +82,11 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -144,17 +151,29 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { px={6} py={4} > - + + + + ) @@ -267,60 +286,79 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.egress.confirmTitle')} - + + + + + + + + {translate('chainflipLending.egress.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.egress.amount')} + + + {destinationAddress && ( - <> - - - - {translate('chainflipLending.egress.receiveAddress')} + + + {translate('chainflipLending.egress.receiveAddress')} + + + + - - - - - - - + + )} + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx index 18a18f7d673..5c5f458cf7f 100644 --- a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' -import { memo, useCallback } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useSupplyActionCenter } from './hooks/useSupplyActionCenter' import { useSupplyConfirmation } from './hooks/useSupplyConfirmation' @@ -18,7 +19,9 @@ import { CircularProgress } from '@/components/CircularProgress/CircularProgress import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { reactQueries } from '@/react-queries' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -29,6 +32,7 @@ type SupplyConfirmProps = { export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -47,6 +51,13 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { const stepConfirmed = SupplyMachineCtx.useSelector(s => s.context.stepConfirmed) const isConfirming = SupplyMachineCtx.useSelector(s => s.matches('confirming')) + const { pools } = useChainflipLendingPools() + + const supplyApyPercent = useMemo(() => { + const pool = pools.find(p => p.assetId === assetId) + return pool ? bnOrZero(pool.supplyApy).times(100).toFixed(2) : null + }, [pools, assetId]) + useSupplySign() useSupplyConfirmation() useSupplyActionCenter() @@ -72,6 +83,11 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { closeModal() }, [scAccount, queryClient, actorRef, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -117,17 +133,29 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { px={6} py={4} > - + + + + ) @@ -240,47 +268,101 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.supply.confirmTitle')} - - + + + + + + + + + + {translate('chainflipLending.supply.asset')} + + + + + {asset.symbol} + + + + + + {translate('chainflipLending.supply.amount')} + + + + {supplyApyPercent !== null && ( + + + {translate('chainflipLending.supply.poolApy')} + + + {supplyApyPercent}% + + + )} + + + {translate('chainflipLending.supply.destination')} + + + {translate('chainflipLending.supply.lendingPool', { asset: asset.symbol })} + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx b/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx index 2d5ca4de85d..f0f54c52f25 100644 --- a/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx @@ -1,5 +1,6 @@ -import { Button, CardBody, CardFooter, Flex, Stack, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Divider, Flex, Stack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import { usdcAssetId, usdtAssetId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' @@ -10,8 +11,7 @@ import { useTranslate } from 'react-polyglot' import { SupplyMachineCtx } from './SupplyMachineContext' import { Amount } from '@/components/Amount/Amount' -import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' -import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { AssetIcon } from '@/components/AssetIcon' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' @@ -19,12 +19,16 @@ import { useModal } from '@/hooks/useModal/useModal' import { useToggle } from '@/hooks/useToggle/useToggle' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipMinimumSupply } from '@/pages/ChainflipLending/hooks/useChainflipMinimumSupply' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/marketDataSlice/selectors' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAssetById, selectAssets } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const STABLECOIN_ASSET_IDS: AssetId[] = [usdcAssetId, usdtAssetId] + type SupplyInputProps = { assetId: AssetId onAssetChange: (assetId: AssetId) => void @@ -79,6 +83,46 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { const cryptoValue = isFiat ? cryptoFromFiat : inputValue const fiatValue = isFiat ? inputValue : fiatFromCrypto + const { pools } = useChainflipLendingPools() + const { supplyPositions } = useChainflipSupplyPositions() + + const poolForAsset = useMemo(() => pools.find(p => p.assetId === assetId), [pools, assetId]) + + const currentPositionCrypto = useMemo(() => { + const position = supplyPositions.find(p => p.assetId === assetId) + return position?.totalAmountCryptoPrecision ?? '0' + }, [supplyPositions, assetId]) + + const currentPositionFiat = useMemo(() => { + const position = supplyPositions.find(p => p.assetId === assetId) + return position?.totalAmountFiat ?? '0' + }, [supplyPositions, assetId]) + + const supplyApyPercent = useMemo( + () => (poolForAsset ? bnOrZero(poolForAsset.supplyApy).times(100).toFixed(2) : null), + [poolForAsset], + ) + + const poolSharePercent = useMemo(() => { + if (!poolForAsset) return '0.00' + const totalPoolCrypto = bnOrZero(poolForAsset.totalAmountCryptoPrecision) + if (totalPoolCrypto.isZero()) return '0.00' + const userCrypto = bnOrZero(currentPositionCrypto).plus(bnOrZero(cryptoValue)) + return userCrypto + .div(totalPoolCrypto.plus(bnOrZero(cryptoValue))) + .times(100) + .toFixed(2) + }, [poolForAsset, currentPositionCrypto, cryptoValue]) + + const isStablecoin = useMemo(() => STABLECOIN_ASSET_IDS.includes(assetId), [assetId]) + + const estYearlyEarningsFiat = useMemo(() => { + if (!poolForAsset || !marketData?.price) return null + const apyDecimal = bnOrZero(poolForAsset.supplyApy) + if (apyDecimal.isZero()) return null + return bnOrZero(cryptoValue).times(marketData.price).times(apyDecimal).toFixed(2) + }, [poolForAsset, cryptoValue, marketData?.price]) + const { minSupply, isLoading: isMinSupplyLoading } = useChainflipMinimumSupply(assetId) const isBelowMinimum = useMemo(() => { @@ -119,11 +163,6 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { }) }, [buyAssetSearch, onAssetChange, lendingAssets]) - const handleAssetChange = useCallback( - (asset: Asset) => onAssetChange(asset.assetId), - [onAssetChange], - ) - const handleInputChange = useCallback((values: NumberFormatValues) => { setInputValue(values.value) }, []) @@ -188,7 +227,7 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { return translate('chainflipLending.supply.minimumSupply', { amount: `${bnOrZero(minSupply).decimalPlaces(2).toFixed()} ${asset?.symbol}`, }) - return translate('chainflipLending.supply.title') + return translate('common.next') }, [exceedsBalance, isBelowMinimum, minSupply, asset?.symbol, translate]) if (!asset) return null @@ -197,16 +236,79 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { - + {/* Asset to supply label */} + + {translate('chainflipLending.supply.assetToSupply')} + + + {/* Asset card */} + + + + + + + {asset.name} + + + + + {availableFiat !== undefined && ( + + )} + + + + {/* Stats row - Pool APY and Current Position in bordered boxes */} + + {supplyApyPercent !== null && ( + + + + {translate('chainflipLending.supply.poolApy')} + + + {supplyApyPercent}% + + + + )} + + + + {translate('chainflipLending.supply.currentPosition')} + + {bnOrZero(currentPositionCrypto).gt(0) ? ( + + ) : ( + + )} + + + + {/* Amount label */} {translate('chainflipLending.supply.amount')} @@ -229,7 +331,7 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { onValueChange={handleInputChange} style={{ flex: 1, - fontSize: '1.25rem', + fontSize: '1.5rem', fontWeight: 'bold', background: 'transparent', border: 'none', @@ -238,60 +340,109 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { }} /> {!isFiat && ( - - {asset.symbol} - + + + + {asset.symbol} + + )} - {marketData?.price && ( - - )} - - - - - {translate('chainflipLending.supply.available')} - - - - - {availableFiat !== undefined && ( - - )} + {/* Fiat estimate + Balance/Max row */} + + {marketData?.price ? ( + + ) : ( + + )} + + + {translate('common.balance')}: + - - + + - + + + + + {/* Info rows */} + + {estYearlyEarningsFiat !== null && !bnOrZero(cryptoValue).isZero() && ( + + + {translate('chainflipLending.dashboard.estimatedYearlyEarnings')} + + + + )} + + + + {translate('chainflipLending.supply.poolShare')} + + + {poolSharePercent}% + + + + + + {translate('chainflipLending.supply.autoCompounding')} + + + {translate('chainflipLending.supply.enabled')} + + + + + + {translate('chainflipLending.supply.riskBand')} + + + {isStablecoin + ? translate('chainflipLending.supply.conservativeStablecoin') + : translate('chainflipLending.supply.volatileAsset')} + + + {!hasFreeBalance && ( diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx index 1034d8f6fa6..9c8c740eb53 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useWithdrawActionCenter } from './hooks/useWithdrawActionCenter' import { useWithdrawConfirmation } from './hooks/useWithdrawConfirmation' @@ -29,6 +30,7 @@ type WithdrawConfirmProps = { export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -71,6 +73,11 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -116,17 +123,29 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { px={6} py={4} > - + + + + ) @@ -239,47 +258,67 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.withdraw.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.withdraw.amount')} + + + + {translate('chainflipLending.withdraw.destination')} + + + {translate('chainflipLending.freeBalance')} + + + - ) diff --git a/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx b/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx index d1d16a66570..37bbe96a308 100644 --- a/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx +++ b/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx @@ -1,6 +1,6 @@ import { Button, Card, CardBody, Container, Flex, Heading, Skeleton, Stack } from '@chakra-ui/react' import { ethAssetId } from '@shapeshiftoss/caip' -import { useCallback } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -12,13 +12,59 @@ import { PageBackButton, PageHeader } from '@/components/Layout/Header/PageHeade import { Text } from '@/components/Text' import { WalletActions } from '@/context/WalletProvider/actions' import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' const responsiveFlex = { base: 'auto', lg: 1 } const containerPaddingTop = { base: 0, md: 8 } -export const ChainflipLendingHeader = () => { +type SummaryCardProps = { + value: string + labelKey: string + tooltipKey: string + isLoading: boolean + labelColor?: string + 'data-testid'?: string +} + +const SummaryCard = ({ + value, + labelKey, + tooltipKey, + isLoading, + labelColor, + 'data-testid': testId, +}: SummaryCardProps) => { + const translate = useTranslate() + + return ( + + + + + + + + + + + ) +} + +type ChainflipLendingHeaderProps = { + tabIndex?: number +} + +export const ChainflipLendingHeader = memo(({ tabIndex }: ChainflipLendingHeaderProps) => { const translate = useTranslate() const navigate = useNavigate() const { dispatch: walletDispatch } = useWallet() @@ -33,8 +79,30 @@ export const ChainflipLendingHeader = () => { [walletDispatch], ) - const { totalSuppliedFiat, availableLiquidityFiat, totalBorrowedFiat, isLoading } = - useChainflipLendingPools() + const { + totalSuppliedFiat, + availableLiquidityFiat, + totalBorrowedFiat, + isLoading: isPoolsLoading, + } = useChainflipLendingPools() + + const { totalFiat: freeBalanceTotalFiat, isLoading: isFreeBalancesLoading } = + useChainflipFreeBalances() + + const { supplyPositions, isLoading: isPositionsLoading } = useChainflipSupplyPositions() + + const suppliedTotalFiat = useMemo( + () => supplyPositions.reduce((sum, p) => sum.plus(p.totalAmountFiat), bnOrZero(0)).toFixed(2), + [supplyPositions], + ) + + const { + totalCollateralFiat, + totalBorrowedFiat: userBorrowedFiat, + isLoading: isLoanLoading, + } = useChainflipLoanAccount() + + const isUserDataLoading = isFreeBalancesLoading || isPositionsLoading || isLoanLoading return ( <> @@ -49,7 +117,7 @@ export const ChainflipLendingHeader = () => { - + @@ -65,58 +133,82 @@ export const ChainflipLendingHeader = () => { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {accountId && tabIndex === 0 ? ( + <> + + + + + + ) : ( + <> + + + + {!accountId && ( + + + + )} + + )} - {!accountId && ( - - - - )} ) -} +}) diff --git a/src/pages/ChainflipLending/components/Dashboard.tsx b/src/pages/ChainflipLending/components/Dashboard.tsx new file mode 100644 index 00000000000..4a30ca5daf5 --- /dev/null +++ b/src/pages/ChainflipLending/components/Dashboard.tsx @@ -0,0 +1,66 @@ +import { Flex, Stack } from '@chakra-ui/react' +import { memo, useMemo } from 'react' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + BorrowedSection, + CollateralSection, + FreeBalanceSection, + SuppliedSection, +} from '@/pages/ChainflipLending/components/DashboardSections' +import { DashboardSidebar } from '@/pages/ChainflipLending/components/DashboardSidebar' +import { InitView } from '@/pages/ChainflipLending/components/InitView' +import { LoanHealth } from '@/pages/ChainflipLending/components/LoanHealth' +import { useChainflipAccount } from '@/pages/ChainflipLending/hooks/useChainflipAccount' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' + +export const Dashboard = memo(() => { + const { isFunded, isLpRegistered } = useChainflipAccount() + const { freeBalances } = useChainflipFreeBalances() + const { supplyPositions } = useChainflipSupplyPositions() + const { collateralWithFiat, loansWithFiat } = useChainflipLoanAccount() + + const hasFreeBalance = useMemo( + () => freeBalances.some(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const hasAnyPosition = useMemo( + () => + hasFreeBalance || + supplyPositions.length > 0 || + collateralWithFiat.length > 0 || + loansWithFiat.length > 0, + [hasFreeBalance, supplyPositions, collateralWithFiat, loansWithFiat], + ) + + // Show init view for users with no positions (wallet is connected since Markets.tsx handles no-wallet) + const showInitView = useMemo(() => { + if (!isFunded && !isLpRegistered) return true + return !hasAnyPosition + }, [isFunded, isLpRegistered, hasAnyPosition]) + + if (showInitView) { + return + } + + return ( + + + + + + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/DashboardSections.tsx b/src/pages/ChainflipLending/components/DashboardSections.tsx new file mode 100644 index 00000000000..8cfe95e3cbc --- /dev/null +++ b/src/pages/ChainflipLending/components/DashboardSections.tsx @@ -0,0 +1,730 @@ +import { Badge, Button, Card, CardBody, Flex, HStack, Skeleton, Stack } from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { RawText, Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + CHAINFLIP_LENDING_ASSET_BY_ASSET_ID, + CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET, +} from '@/lib/chainflip/constants' +import type { ChainflipFreeBalanceWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import type { + CollateralWithFiat, + LoanWithFiat, +} from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import type { ChainflipSupplyPositionWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +// Shared grid for consistent alignment across dashboard sections +const dashboardRowGrid = '1fr 100px 120px' + +type AssetRowProps = { + assetId: AssetId + children: React.ReactNode + badge?: React.ReactNode + onClick?: () => void +} + +const AssetRow = ({ assetId, children, badge, onClick }: AssetRowProps) => { + const asset = useAppSelector(state => selectAssetById(state, assetId)) + if (!asset) return null + + return ( + + ) +} + +type SectionHeaderProps = { + titleKey: string + tooltipKey: string + totalFiat: string + isLoading: boolean + primaryAction?: { labelKey: string; handleClick: () => void; testId?: string } + secondaryAction?: { labelKey: string; handleClick: () => void; prefix?: string } +} + +const SectionHeader = ({ + titleKey, + tooltipKey, + totalFiat, + isLoading, + primaryAction, + secondaryAction, +}: SectionHeaderProps) => { + const translate = useTranslate() + + return ( + + + + + + + + + + + {primaryAction && ( + + )} + {secondaryAction && ( + + )} + + + ) +} + +type EmptyStateProps = { + titleKey: string + descriptionKey: string + actionLabelKey: string + onAction: () => void + actionTestId?: string +} + +const EmptyState = ({ + titleKey, + descriptionKey, + actionLabelKey, + onAction, + actionTestId, +}: EmptyStateProps) => { + const translate = useTranslate() + + return ( + + + + + + ) +} + +// Free Balance Section +const FreeBalanceRow = ({ balance }: { balance: ChainflipFreeBalanceWithFiat }) => { + const assetId = balance.assetId + const asset = useAppSelector(state => (assetId ? selectAssetById(state, assetId) : undefined)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + if (!assetId) return + navigate(`/chainflip-lending/pool/${assetId}`) + }, [navigate, assetId]) + + if (!assetId || bnOrZero(balance.balanceCryptoPrecision).isZero()) return null + + return ( + + + + + + + + ) +} + +export const FreeBalanceSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { freeBalances, isLoading, totalFiat } = useChainflipFreeBalances() + + const nonZeroBalances = useMemo( + () => freeBalances.filter(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const handleDeposit = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'deposit', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleWithdraw = useCallback(() => { + const firstWithBalance = nonZeroBalances[0]?.assetId ?? LENDING_ASSET_IDS[0] + if (firstWithBalance) { + chainflipLendingModal.open({ mode: 'withdrawFromChainflip', assetId: firstWithBalance }) + } + }, [chainflipLendingModal, nonZeroBalances]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleWithdraw, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : nonZeroBalances.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.balance')} + + }> + {nonZeroBalances.map(balance => + balance.assetId ? ( + + ) : null, + )} + + + ) : null} + + + + ) +}) + +// Supplied Section +const SuppliedRow = ({ + position, + apy, +}: { + position: ChainflipSupplyPositionWithFiat + apy: string +}) => { + const asset = useAppSelector(state => selectAssetById(state, position.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${position.assetId}`) + }, [navigate, position.assetId]) + + return ( + + + + + + + + ) +} + +export const SuppliedSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { supplyPositions, isLoading } = useChainflipSupplyPositions() + const { pools } = useChainflipLendingPools() + + const totalSuppliedFiat = useMemo( + () => supplyPositions.reduce((sum, p) => sum.plus(p.totalAmountFiat), bnOrZero(0)).toFixed(2), + [supplyPositions], + ) + + const poolsByAssetId = useMemo( + () => + pools.reduce>>((acc, pool) => { + if (pool.assetId) acc[pool.assetId] = pool + return acc + }, {}), + [pools], + ) + + const handleSupply = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleWithdraw = useCallback(() => { + const firstPosition = supplyPositions[0] + if (firstPosition) { + chainflipLendingModal.open({ mode: 'withdrawSupply', assetId: firstPosition.assetId }) + } + }, [chainflipLendingModal, supplyPositions]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.deposit', + handleClick: handleSupply, + testId: 'chainflip-lending-supply-action', + } + : undefined + } + secondaryAction={ + supplyPositions.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleWithdraw, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : supplyPositions.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + + {translate('chainflipLending.dashboard.supplied')} + + + }> + {supplyPositions.map(position => ( + + ))} + + + ) : ( + + )} + + + + ) +}) + +// Collateral Section +const CollateralRow = ({ + collateral, + isTopupAsset, +}: { + collateral: CollateralWithFiat + isTopupAsset: boolean +}) => { + const translate = useTranslate() + const asset = useAppSelector(state => selectAssetById(state, collateral.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${collateral.assetId}`) + }, [navigate, collateral.assetId]) + + return ( + + {translate('chainflipLending.dashboard.topupAsset')} + + ) + } + onClick={handleRowClick} + > + + + + + + + ) +} + +export const CollateralSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { collateralWithFiat, totalCollateralFiat, loanAccount, isLoading } = + useChainflipLoanAccount() + + const topupAssetId = useMemo(() => { + if (!loanAccount?.collateral_topup_asset) return undefined + return CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET[loanAccount.collateral_topup_asset.asset] + }, [loanAccount?.collateral_topup_asset]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleRemoveCollateral = useCallback(() => { + const firstCollateral = collateralWithFiat[0] + if (firstCollateral) { + chainflipLendingModal.open({ mode: 'removeCollateral', assetId: firstCollateral.assetId }) + } + }, [chainflipLendingModal, collateralWithFiat]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.deposit', + handleClick: handleAddCollateral, + testId: 'chainflip-lending-collateral-action', + } + : undefined + } + secondaryAction={ + collateralWithFiat.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleRemoveCollateral, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : collateralWithFiat.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.amount')} + + }> + {collateralWithFiat.map(collateral => ( + + ))} + + + ) : ( + + )} + + + + ) +}) + +// Borrowed Section +const BorrowedRow = ({ loan, borrowRate }: { loan: LoanWithFiat; borrowRate: string }) => { + const asset = useAppSelector(state => selectAssetById(state, loan.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${loan.assetId}`) + }, [navigate, loan.assetId]) + + return ( + + + + + + + + ) +} + +export const BorrowedSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { loansWithFiat, totalBorrowedFiat, isLoading } = useChainflipLoanAccount() + const { pools } = useChainflipLendingPools() + + const poolsByAssetId = useMemo( + () => + pools.reduce>>((acc, pool) => { + if (pool.assetId) acc[pool.assetId] = pool + return acc + }, {}), + [pools], + ) + + const handleBorrow = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleRepay = useCallback(() => { + const firstLoan = loansWithFiat[0] + if (firstLoan) { + chainflipLendingModal.open({ + mode: 'repay', + assetId: firstLoan.assetId, + loanId: firstLoan.loanId, + }) + } + }, [chainflipLendingModal, loansWithFiat]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleVoluntaryLiquidation = useCallback(() => { + const firstLoan = loansWithFiat[0] + if (firstLoan) { + chainflipLendingModal.open({ + mode: 'voluntaryLiquidation', + assetId: firstLoan.assetId, + liquidationAction: 'initiate', + }) + } + }, [chainflipLendingModal, loansWithFiat]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.borrow', + handleClick: handleBorrow, + testId: 'chainflip-lending-borrow-action', + } + : undefined + } + secondaryAction={ + loansWithFiat.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.repay', + handleClick: handleRepay, + prefix: '⟲', + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : loansWithFiat.length > 0 ? ( + <> + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.amount')} + + } + > + {loansWithFiat.map(loan => ( + + ))} + + + + + ) : ( + + )} + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/DashboardSidebar.tsx b/src/pages/ChainflipLending/components/DashboardSidebar.tsx new file mode 100644 index 00000000000..9d8e7f64e24 --- /dev/null +++ b/src/pages/ChainflipLending/components/DashboardSidebar.tsx @@ -0,0 +1,322 @@ +import { + Box, + Button, + Card, + CardBody, + Center, + CircularProgress, + CircularProgressLabel, + Flex, + Stack, +} from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import borrowGlow from '@/assets/chainflip-lending/borrow-glow.svg' +import borrowRing1 from '@/assets/chainflip-lending/borrow-ring-1.svg' +import borrowRingInner from '@/assets/chainflip-lending/borrow-ring-inner.svg' +import earnGlow from '@/assets/chainflip-lending/earn-glow.svg' +import earnRingInner from '@/assets/chainflip-lending/earn-ring-inner.svg' +import earnRingMiddle from '@/assets/chainflip-lending/earn-ring-middle.svg' +import earnRingOuter from '@/assets/chainflip-lending/earn-ring-outer.svg' +import refreshIcon from '@/assets/chainflip-lending/refresh-icon.svg' +import sparklesIcon from '@/assets/chainflip-lending/sparkles-icon.svg' +import { Amount } from '@/components/Amount/Amount' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +const sidebarPosition = { base: 'relative' as const, lg: 'sticky' as const } +const sidebarTop = { base: 0, lg: 4 } + +export const BorrowingPowerCard = memo(() => { + const translate = useTranslate() + const { totalCollateralFiat, totalBorrowedFiat } = useChainflipLoanAccount() + const { thresholds } = useChainflipLtvThresholds() + + const hasCollateral = useMemo(() => bnOrZero(totalCollateralFiat).gt(0), [totalCollateralFiat]) + + const targetLtv = thresholds?.target ?? 0.5 + + const maxBorrow = useMemo( + () => bnOrZero(totalCollateralFiat).times(targetLtv).toFixed(2), + [totalCollateralFiat, targetLtv], + ) + + const available = useMemo( + () => bnOrZero(maxBorrow).minus(totalBorrowedFiat).toFixed(2), + [maxBorrow, totalBorrowedFiat], + ) + + const percentUsed = useMemo(() => { + if (bnOrZero(maxBorrow).isZero()) return 0 + return bnOrZero(totalBorrowedFiat).div(maxBorrow).times(100).toNumber() + }, [totalBorrowedFiat, maxBorrow]) + + const gaugeColor = useMemo(() => (percentUsed > 80 ? 'red.500' : 'blue.500'), [percentUsed]) + + if (!hasCollateral) return null + + return ( + + + + + + + + + {Math.round(percentUsed)}% + + + + + + + + + + ) +}) + +const NextStepsArt = memo(({ colorScheme }: { colorScheme: 'green' | 'purple' }) => { + const isGreen = colorScheme === 'green' + + return ( +
+ {isGreen ? ( + <> + + + + +
+ +
+ + ) : ( + <> + + + +
+ +
+ + )} +
+ ) +}) + +export const NextStepsCard = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { freeBalances } = useChainflipFreeBalances() + const { supplyPositions } = useChainflipSupplyPositions() + const { collateralWithFiat, loansWithFiat } = useChainflipLoanAccount() + + const hasFreeBalance = useMemo( + () => freeBalances.some(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const hasSupply = supplyPositions.length > 0 + const hasCollateral = collateralWithFiat.length > 0 + const hasLoans = loansWithFiat.length > 0 + + const handleSupply = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleBorrow = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + // Hide when user has completed all steps + if (hasSupply && hasCollateral && hasLoans) return null + + const getContent = () => { + if (hasFreeBalance && !hasSupply && !hasCollateral) { + return { + colorScheme: 'green' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsSupplyOrCollateral', + actions: ( + + + + + ), + } + } + + if (hasSupply && !hasCollateral) { + return { + colorScheme: 'green' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsCollateral', + actions: ( + + ), + } + } + + if (hasCollateral && !hasLoans) { + return { + colorScheme: 'purple' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsBorrow', + actions: ( + + ), + } + } + + return null + } + + const content = getContent() + if (!content) return null + + return ( + + + + + + + {content.actions} + + + + ) +}) + +export const DashboardSidebar = memo(() => { + return ( + + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/InitView.tsx b/src/pages/ChainflipLending/components/InitView.tsx new file mode 100644 index 00000000000..dc255b827da --- /dev/null +++ b/src/pages/ChainflipLending/components/InitView.tsx @@ -0,0 +1,573 @@ +import { + Badge, + Box, + Button, + Card, + CardBody, + Center, + CircularProgress, + Flex, + Heading, + HStack, + SimpleGrid, + Skeleton, + Stack, +} from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { btcAssetId, ethAssetId, solAssetId, usdcAssetId, usdtAssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import borrowGlow from '@/assets/chainflip-lending/borrow-glow.svg' +import borrowRing1 from '@/assets/chainflip-lending/borrow-ring-1.svg' +import borrowRing2 from '@/assets/chainflip-lending/borrow-ring-2.svg' +import borrowRing3 from '@/assets/chainflip-lending/borrow-ring-3.svg' +import borrowRingInner from '@/assets/chainflip-lending/borrow-ring-inner.svg' +import earnGlow from '@/assets/chainflip-lending/earn-glow.svg' +import earnRingInner from '@/assets/chainflip-lending/earn-ring-inner.svg' +import earnRingMiddle from '@/assets/chainflip-lending/earn-ring-middle.svg' +import earnRingOuter from '@/assets/chainflip-lending/earn-ring-outer.svg' +import orbitalBtc from '@/assets/chainflip-lending/orbital-btc.svg' +import orbitalEth from '@/assets/chainflip-lending/orbital-eth.svg' +import orbitalSol from '@/assets/chainflip-lending/orbital-sol.svg' +import orbitalTether from '@/assets/chainflip-lending/orbital-tether.svg' +import orbitalUsdc from '@/assets/chainflip-lending/orbital-usdc.svg' +import refreshIcon from '@/assets/chainflip-lending/refresh-icon.svg' +import sparklesIcon from '@/assets/chainflip-lending/sparkles-icon.svg' +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { AssetCell } from '@/components/StakingVaults/Cells' +import { Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { permillToDecimal } from '@/lib/chainflip/utils' +import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +const marketRowGrid = { + base: 'minmax(150px, 1fr) repeat(1, minmax(40px, max-content))', + md: '200px repeat(5, 1fr)', +} + +const mobileDisplay = { base: 'none', md: 'flex' } + +type MarketRowProps = { + pool: ChainflipLendingPoolWithFiat + onViewMarket: (assetId: AssetId) => void +} + +const MarketRow = memo(({ pool, onViewMarket }: MarketRowProps) => { + const asset = useAppSelector(state => + pool.assetId ? selectAssetById(state, pool.assetId) : undefined, + ) + const handleClick = useCallback(() => { + if (pool.assetId) onViewMarket(pool.assetId) + }, [pool.assetId, onViewMarket]) + + const utilisationPercent = useMemo( + () => permillToDecimal(pool.pool.utilisation_rate), + [pool.pool.utilisation_rate], + ) + + const utilisationNumber = useMemo( + () => bnOrZero(utilisationPercent).times(100).toNumber(), + [utilisationPercent], + ) + + const utilisationColor = useMemo(() => { + if (utilisationNumber >= 90) return 'red.400' + if (utilisationNumber >= 70) return 'orange.400' + return 'blue.400' + }, [utilisationNumber]) + + if (!pool.assetId) return null + + return ( + + ) +}) + +const AssetConstellation = memo(() => { + // Exact positions from Figma design context (2918:4222) + // Container is positioned absolutely from left=590px in the original ~1200px wide card + // We scale proportionally to fit our responsive layout + return ( + + {/* Orbital ring SVGs from Figma - ETH blue arc */} + + {/* BTC orange arc */} + + {/* USDC arc */} + + {/* Tether arc */} + + {/* SOL arc */} + + {/* Asset icons - positioned per Figma */} + {/* USDC - top left */} + + + + {/* ETH - center, largest */} + + + + {/* USDT - top right */} + + + + {/* BTC - bottom center-left */} + + + + {/* SOL - bottom right */} + + + + + ) +}) + +const MarketsTable = memo(() => { + const translate = useTranslate() + const navigate = useNavigate() + const { pools, isLoading } = useChainflipLendingPools() + + const handleViewMarket = useCallback( + (assetId: AssetId) => { + navigate(`/chainflip-lending/pool/${assetId}`) + }, + [navigate], + ) + + const sortedPools = useMemo( + () => [...pools].sort((a, b) => bnOrZero(b.supplyApy).minus(a.supplyApy).toNumber()), + [pools], + ) + + const marketRows = useMemo(() => { + if (isLoading) { + return Array.from({ length: 5 }).map((_, i) => ) + } + + return sortedPools.map(pool => + pool.assetId ? ( + + ) : null, + ) + }, [isLoading, sortedPools, handleViewMarket]) + + return ( + + + {translate('chainflipLending.dashboard.lendingMarkets')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {marketRows} + + + ) +}) + +type InfoCardProps = { + titleKey: string + descriptionKey: string + accentColor: string + 'data-testid'?: string +} + +const InfoCard = memo( + ({ titleKey, descriptionKey, accentColor, 'data-testid': testId }: InfoCardProps) => { + const isGreen = accentColor === 'green' + + return ( + + + + + + + + {/* Art from Figma SVGs */} + + {isGreen ? ( + <> + {/* Earn Yield art - concentric green arcs */} + + + + +
+ +
+ + ) : ( + <> + {/* Borrow art - purple rings with radiating lines */} + + + + + +
+ +
+ + )} +
+
+
+
+ ) + }, +) + +export const InitView = memo(({ 'data-testid': testId }: { 'data-testid'?: string }) => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + + const handleDeposit = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'deposit', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + return ( + + {/* Hero Card */} + + + + + + {translate('chainflipLending.dashboard.getStarted')} + + + {translate('chainflipLending.dashboard.depositFirstAsset')} + + + + + + + + + + + + + {/* Lending Markets Table */} + + + {/* Info Cards */} + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/LoanHealth.tsx b/src/pages/ChainflipLending/components/LoanHealth.tsx new file mode 100644 index 00000000000..a6a0f31a62b --- /dev/null +++ b/src/pages/ChainflipLending/components/LoanHealth.tsx @@ -0,0 +1,113 @@ +import { Card, CardBody, Flex, Icon, Stack, Text as CText } from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { TbHeartRateMonitor } from 'react-icons/tb' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { Text } from '@/components/Text' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' +import { LtvGauge } from '@/pages/ChainflipLending/Pool/components/Borrow/LtvGauge' + +const DEFAULT_HARD_LIQUIDATION_LTV = 0.93 + +const getLtvColor = (ltv: number, riskyThreshold: number, hardLiqThreshold: number): string => { + if (ltv >= hardLiqThreshold) return 'red.500' + if (ltv >= riskyThreshold) return 'yellow.500' + return 'green.500' +} + +export const LoanHealth = memo(() => { + const translate = useTranslate() + const { totalCollateralFiat, totalBorrowedFiat } = useChainflipLoanAccount() + const { thresholds } = useChainflipLtvThresholds() + + const hasLoans = useMemo(() => bnOrZero(totalBorrowedFiat).gt(0), [totalBorrowedFiat]) + + const currentLtv = useMemo(() => { + if (bnOrZero(totalCollateralFiat).isZero()) return 0 + return bnOrZero(totalBorrowedFiat).div(totalCollateralFiat).toNumber() + }, [totalBorrowedFiat, totalCollateralFiat]) + + const hardLiquidationLtv = thresholds?.hardLiquidation ?? DEFAULT_HARD_LIQUIDATION_LTV + const riskyLtv = thresholds?.target ?? 0.8 + + const liquidationDistance = useMemo(() => { + if (hardLiquidationLtv === 0) return '0' + const liquidationCollateral = bnOrZero(totalBorrowedFiat).div(hardLiquidationLtv) + return bnOrZero(totalCollateralFiat).minus(liquidationCollateral).toFixed(2) + }, [totalCollateralFiat, totalBorrowedFiat, hardLiquidationLtv]) + + const liquidationDistancePercent = useMemo(() => { + if (bnOrZero(totalCollateralFiat).isZero()) return 0 + return bnOrZero(liquidationDistance).div(totalCollateralFiat).times(100).toNumber() + }, [liquidationDistance, totalCollateralFiat]) + + const liquidationDistanceColor = useMemo(() => { + if (liquidationDistancePercent > 10) return 'green.500' + if (liquidationDistancePercent >= 5) return 'yellow.500' + return 'red.500' + }, [liquidationDistancePercent]) + + const ltvColor = useMemo( + () => getLtvColor(currentLtv, riskyLtv, hardLiquidationLtv), + [currentLtv, riskyLtv, hardLiquidationLtv], + ) + + const ltvDisplayPercent = useMemo( + () => (Math.min(Math.max(currentLtv, 0), 1) * 100).toFixed(1), + [currentLtv], + ) + + if (!hasLoans) return null + + return ( + + + + {/* Header row: left = icon + label + current LTV, right = liquidation distance */} + + + + + + + + {translate('chainflipLending.dashboard.currentLtv', { + ltv: `${ltvDisplayPercent}%`, + })} + + + + + + + + + {/* Multi-segment LTV gauge bar */} + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/Markets.tsx b/src/pages/ChainflipLending/components/Markets.tsx index 7e559556420..7aca2a3effd 100644 --- a/src/pages/ChainflipLending/components/Markets.tsx +++ b/src/pages/ChainflipLending/components/Markets.tsx @@ -7,9 +7,14 @@ import { SimpleGrid, Skeleton, Stack, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -21,14 +26,17 @@ import { AssetCell } from '@/components/StakingVaults/Cells' import { Text } from '@/components/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import { permillToDecimal } from '@/lib/chainflip/utils' +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' import { ChainflipLendingHeader } from '@/pages/ChainflipLending/components/ChainflipLendingHeader' -import { MyBalancesList } from '@/pages/ChainflipLending/components/MyBalances' +import { Dashboard } from '@/pages/ChainflipLending/components/Dashboard' import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' const marketRowGrid: GridProps['gridTemplateColumns'] = { base: 'minmax(150px, 1fr) repeat(1, minmax(40px, max-content))', - md: '200px repeat(4, 1fr)', + md: '200px repeat(5, 1fr)', } const mobileDisplay = { base: 'none', md: 'flex' } @@ -41,6 +49,9 @@ type MarketRowProps = { } const MarketRow = ({ pool, onViewMarket }: MarketRowProps) => { + const asset = useAppSelector(state => + pool.assetId ? selectAssetById(state, pool.assetId) : undefined, + ) const handleClick = useCallback(() => { if (pool.assetId) onViewMarket(pool.assetId) }, [pool.assetId, onViewMarket]) @@ -76,13 +87,17 @@ const MarketRow = ({ pool, onViewMarket }: MarketRowProps) => { height='auto' color='text.base' onClick={handleClick} + data-testid={`chainflip-lending-market-row-${asset?.symbol?.toLowerCase() ?? 'unknown'}`} > + + + - + { ) } -export const Markets = () => { +const MarketsTable = () => { const translate = useTranslate() const navigate = useNavigate() const { pools, isLoading } = useChainflipLendingPools() @@ -115,8 +130,6 @@ export const Markets = () => { [navigate], ) - const headerComponent = useMemo(() => , []) - const sortedPools = useMemo( () => [...pools].sort((a, b) => bnOrZero(b.supplyApy).minus(a.supplyApy).toNumber()), [pools], @@ -134,59 +147,121 @@ export const Markets = () => { ) }, [isLoading, sortedPools, handleViewMarket]) + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {marketRows} + + + ) +} + +export const Markets = () => { + const translate = useTranslate() + const { accountId } = useChainflipLendingAccount() + const [tabIndex, setTabIndex] = useState(0) + + const headerComponent = useMemo(() => , [tabIndex]) + return (
- - - - - - - - - - + {accountId ? ( + + + + {translate('chainflipLending.myDashboard')} + + + {translate('chainflipLending.markets')} + + + + + + + + + + + + ) : ( + + - - - - - - - - - - - - - - - - - - - - - {marketRows} + /> + - + )}
) diff --git a/src/pages/ChainflipLending/components/MyBalances.tsx b/src/pages/ChainflipLending/components/MyBalances.tsx deleted file mode 100644 index c5238a2eaf8..00000000000 --- a/src/pages/ChainflipLending/components/MyBalances.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import type { GridProps } from '@chakra-ui/react' -import { Button, Flex, SimpleGrid, Skeleton, Stack } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' - -import { Amount } from '@/components/Amount/Amount' -import { AssetCell } from '@/components/StakingVaults/Cells' -import { Text } from '@/components/Text' -import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' -import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' -import type { ChainflipFreeBalanceWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' -import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' -import type { - CollateralWithFiat, - LoanWithFiat, -} from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' -import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' -import type { ChainflipSupplyPositionWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' -import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' -import { selectAssetById } from '@/state/slices/assetsSlice/selectors' -import { selectPortfolioCryptoBalanceByFilter } from '@/state/slices/common-selectors' -import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/portfolioSlice/selectors' -import { useAppSelector } from '@/state/store' - -const balanceRowGrid: GridProps['gridTemplateColumns'] = { - base: '1fr', - md: '200px 1fr 1fr 1fr 1fr 1fr', -} - -const mobileDisplay = { base: 'none', md: 'flex' } -const mobilePadding = { base: 4, lg: 4, xl: 0 } -const listMargin = { base: 0, lg: 0, xl: -4 } - -const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] - -type BalanceRowProps = { - assetId: AssetId - accountNumber: number - freeBalance: ChainflipFreeBalanceWithFiat | undefined - supplyPosition: ChainflipSupplyPositionWithFiat | undefined - collateral: CollateralWithFiat | undefined - loan: LoanWithFiat | undefined - onDeposit: (assetId: AssetId) => void -} - -const BalanceRow = ({ - assetId, - accountNumber, - freeBalance, - supplyPosition, - collateral, - loan, - onDeposit, -}: BalanceRowProps) => { - const asset = useAppSelector(state => selectAssetById(state, assetId)) - const accountIdsByAccountNumberAndChainId = useAppSelector( - selectAccountIdsByAccountNumberAndChainId, - ) - - const chainId = useMemo(() => fromAssetId(assetId).chainId, [assetId]) - - const poolChainAccountId = useMemo(() => { - const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] - return byChainId?.[chainId]?.[0] - }, [accountIdsByAccountNumberAndChainId, accountNumber, chainId]) - - const balanceFilter = useMemo( - () => ({ assetId, accountId: poolChainAccountId ?? '' }), - [assetId, poolChainAccountId], - ) - - const walletBalance = useAppSelector(state => - selectPortfolioCryptoBalanceByFilter(state, balanceFilter), - ) - - const walletBalancePrecision = useMemo( - () => (poolChainAccountId ? walletBalance.toPrecision() : '0'), - [walletBalance, poolChainAccountId], - ) - - const scBalancePrecision = useMemo( - () => freeBalance?.balanceCryptoPrecision ?? '0', - [freeBalance?.balanceCryptoPrecision], - ) - - const suppliedPrecision = useMemo( - () => supplyPosition?.totalAmountCryptoPrecision ?? '0', - [supplyPosition?.totalAmountCryptoPrecision], - ) - - const collateralPrecision = useMemo( - () => collateral?.amountCryptoPrecision ?? '0', - [collateral?.amountCryptoPrecision], - ) - - const borrowedPrecision = useMemo( - () => loan?.principalAmountCryptoPrecision ?? '0', - [loan?.principalAmountCryptoPrecision], - ) - - const handleClick = useCallback(() => { - onDeposit(assetId) - }, [assetId, onDeposit]) - - const symbol = useMemo(() => asset?.symbol ?? '', [asset?.symbol]) - - if (!asset) return null - - return ( - - ) -} - -export const MyBalancesList = () => { - const navigate = useNavigate() - const { accountId, accountNumber } = useChainflipLendingAccount() - const { freeBalances, isLoading } = useChainflipFreeBalances() - const { supplyPositions, isLoading: isPositionsLoading } = useChainflipSupplyPositions() - const { collateralWithFiat, loansWithFiat, isLoading: isLoanLoading } = useChainflipLoanAccount() - - const handleDeposit = useCallback( - (assetId: AssetId) => { - navigate(`/chainflip-lending/pool/${assetId}`) - }, - [navigate], - ) - - const freeBalancesByAssetId = useMemo( - () => - freeBalances.reduce>>( - (acc, balance) => { - if (balance.assetId) acc[balance.assetId] = balance - return acc - }, - {}, - ), - [freeBalances], - ) - - const supplyPositionsByAssetId = useMemo( - () => - supplyPositions.reduce>>( - (acc, position) => { - acc[position.assetId] = position - return acc - }, - {}, - ), - [supplyPositions], - ) - - const collateralByAssetId = useMemo( - () => - collateralWithFiat.reduce>>((acc, c) => { - acc[c.assetId] = c - return acc - }, {}), - [collateralWithFiat], - ) - - const loansByAssetId = useMemo( - () => - loansWithFiat.reduce>>((acc, l) => { - acc[l.assetId] = l - return acc - }, {}), - [loansWithFiat], - ) - - const balanceRows = useMemo(() => { - if (isLoading || isPositionsLoading || isLoanLoading) { - return Array.from({ length: 5 }).map((_, i) => ) - } - - return LENDING_ASSET_IDS.map(assetId => ( - - )) - }, [ - accountNumber, - isLoading, - isPositionsLoading, - isLoanLoading, - freeBalancesByAssetId, - supplyPositionsByAssetId, - collateralByAssetId, - loansByAssetId, - handleDeposit, - ]) - - if (!accountId) return null - - return ( - - - - - - - - - - - - - - - - - - - - {balanceRows} - - ) -}