From f8fc2f6de8593ea5fb41c2dc8c5521043993c7b5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:41:37 +0100 Subject: [PATCH 01/25] feat: display strategy names instead of asset symbols in yield cards Remove titleOverride props so single yields show their metadata.name (e.g., "Aave v3 USDC Lending") instead of just the asset symbol. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldsList.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index bda30387abe..2dc4d4ff191 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -677,7 +677,6 @@ export const YieldsList = memo(() => { {availableYields.map(item => { const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) const displayInfo = getYieldDisplayInfo(item.yield) - const inputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol return ( { variant={isMobile ? 'mobile' : 'card'} userBalanceUsd={positionBalanceUsd} availableBalanceUserCurrency={item.balanceFiat} - titleOverride={inputSymbol} onEnter={() => handleYieldClick(item.yield.id)} /> ) @@ -721,7 +719,6 @@ export const YieldsList = memo(() => { variant={isMobile ? 'mobile' : 'card'} userBalanceUsd={group.userGroupBalanceUsd} availableBalanceUserCurrency={availableUsd} - titleOverride={group.assetSymbol} onEnter={() => handleYieldClick(singleYield.id)} /> ) @@ -805,7 +802,6 @@ export const YieldsList = memo(() => { {availableYields.map(item => { const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) const rowDisplayInfo = getYieldDisplayInfo(item.yield) - const rowInputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol return ( { variant='row' userBalanceUsd={positionBalanceUsd} availableBalanceUserCurrency={item.balanceFiat} - titleOverride={rowInputSymbol} onEnter={() => handleYieldClick(item.yield.id)} /> ) @@ -850,7 +845,6 @@ export const YieldsList = memo(() => { variant='row' userBalanceUsd={group.userGroupBalanceUsd} availableBalanceUserCurrency={availableUsd} - titleOverride={group.assetSymbol} onEnter={() => handleYieldClick(singleYield.id)} /> ) @@ -898,7 +892,6 @@ export const YieldsList = memo(() => { {recommendedYields.map(rec => { const recDisplayInfo = getYieldDisplayInfo(rec.yield) - const inputSymbol = rec.yield.inputTokens?.[0]?.symbol ?? rec.yield.token.symbol return ( { }} variant={isMobile ? 'mobile' : 'card'} availableBalanceUserCurrency={rec.balanceFiat} - titleOverride={inputSymbol} onEnter={() => handleYieldClick(rec.yield.id)} /> ) From fca42a03c4abb76f10cfe83be369b69a6b044ef5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:59:08 +0100 Subject: [PATCH 02/25] feat: add maintenance and deprecated warnings for yield opportunities Display warning badges on yield cards and alerts on detail pages when yields are marked as underMaintenance or deprecated in the API. Co-Authored-By: Claude Opus 4.5 --- NEAR_IMPROVEMENTS.md | 165 ++++++++++++++++++++++ src/assets/translations/en/main.json | 6 +- src/pages/Yields/components/YieldHero.tsx | 21 +++ src/pages/Yields/components/YieldItem.tsx | 45 +++++- 4 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 NEAR_IMPROVEMENTS.md diff --git a/NEAR_IMPROVEMENTS.md b/NEAR_IMPROVEMENTS.md new file mode 100644 index 00000000000..6084a042c4a --- /dev/null +++ b/NEAR_IMPROVEMENTS.md @@ -0,0 +1,165 @@ +# Yield Feature Improvements Analysis + +Based on exploration of the codebase, here are categorized improvements for the Yields feature. + +## Quick Wins (High Impact, Low Effort) + +These can be implemented with minimal code changes: + +### 1. Typography Punch - APY Emphasis +**File:** `src/pages/Yields/components/YieldItem.tsx` +- Current: APY uses `GradientApy` but same font weight as other text +- Change: Increase `fontWeight` to `bold` or `semibold`, bump `fontSize` slightly +- Also applies to TVL display in `YieldOpportunityStats.tsx` + +### 2. Copy Updates +**Files:** `src/assets/translations/en/main.json`, various components +- "My Positions" → "Your positions" +- "Available to Earn" → "Opportunities" or "Put your spare crypto to work" +- Update `translate()` keys + +### 3. Strategy Naming - Use `metadata.name` +**File:** `src/pages/Yields/components/YieldItem.tsx` (line ~86, ~156) +- Currently: Card titles show asset name/symbol, subtitle shows provider +- Change: For single yields, show `metadata.name` (e.g., "Aave v3 USDC Lending") as primary title +- Provider becomes secondary label + +### 4. Lightning Icon on Enter/Deposit Buttons +**Files:** `YieldOpportunityCard.tsx`, `YieldHero.tsx`, `YieldEnterModal.tsx` +- Add `FaBolt` icon to "Start Earning", "Deposit", "Enter" buttons +- Pattern: `` + +### 5. Maintenance/Deprecated Warnings +**Files:** `YieldItem.tsx`, `YieldHero.tsx` +- Data exists: `metadata.underMaintenance`, `metadata.deprecated` +- Add: Warning badge/alert when these are `true` +- Use Chakra `` or `` + +--- + +## Medium Effort Improvements + +These require more component work but use existing data: + +### 6. Reward Rate Components Breakdown +**Files:** `YieldHero.tsx`, `YieldStats.tsx`, new tooltip component +- Data: `rewardRate.components[]` has `{ rate, token, yieldSource, description }` +- Display: Tooltip or expandable section showing multi-token yield breakdown +- Example: "8.5% APY = 7% ETH + 1.5% COMP rewards" + +### 7. Fee Structure Display +**File:** `YieldHero.tsx`, `YieldStats.tsx` +- Data: `mechanics.possibleFeeTakingMechanisms` + - `{ depositFee, managementFee, performanceFee, validatorRebates }` +- Display: Small badges/pills showing which fees apply +- Pattern: `{depositFee && Deposit Fee}` + +### 8. Type Pills/Tags +**Files:** `YieldItem.tsx`, `YieldFilters.tsx` +- Data: `mechanics.type` (staking, lending, liquidity_pool) +- Display: Colored pill at top-left of card +- Colors: Green for lending, Blue for staking, Purple for LP + +### 9. Cooldown/Lockup Period Warning +**File:** `YieldHero.tsx`, `YieldEnterModal.tsx` +- Data: `mechanics.cooldownPeriod?.seconds` +- Display: Warning text like "7-day unstaking period" before deposit +- Pattern: `` + +### 10. Entry Limits Display +**File:** `YieldEnterModal.tsx` +- Data: `mechanics.entryLimits` `{ minimum, maximum }` +- Already partially used for validation +- Make more prominent with visible text showing min/max + +### 11. Documentation Links +**File:** `YieldHero.tsx` +- Data: `metadata.documentation` (URL string) +- Add: External link button/icon to strategy docs +- Pattern: `} />` + +### 12. Capacity State Indicator +**Files:** `YieldItem.tsx`, `YieldHero.tsx` +- Data: `state?.capacityState` `{ current, max, remaining }` +- Display: Progress bar or "85% filled" indicator when near capacity +- Alert when `remaining` is low + +--- + +## Larger Efforts (Significant Refactoring) + +### 13. Pill-Style Filters +**File:** `src/pages/Yields/components/YieldFilters.tsx` +- Current: Standard Chakra `MenuButton` dropdowns +- Change: Rounded pill buttons with filled state when selected +- Pattern: Custom Button variant `rounded='full'` `bg={selected ? 'blue.500' : 'gray.700'}` + +### 14. Featured/Handpicked Section +**Files:** `YieldsList.tsx`, new component +- No curation logic exists currently +- Options: + a. Backend flag: Add `featured` field from API + b. Frontend curation: Hardcode list of featured yield IDs + c. Smart curation: Top 3 by user's held assets weighted by APY +- Display: Separate "Handpicked" card row above main list + +### 15. Card Redesign for Grid View +**File:** `YieldItem.tsx` (card variant) +- Current: Flat dark cards with dense info +- Changes: + - Add ambient glow/gradient background for high-APY cards + - Restructure: Type pill top-left, Network badge top-right + - APY as "hero stat" (larger, more prominent) + - Quick action button (lightning icon) floating bottom-right + +### 16. Detail Page 2-Column Layout (Desktop) +**File:** `YieldDetail.tsx`, `YieldHero.tsx` +- Current: Centered single-column layout +- Change: Left column (info/about), Right column (actions/position) +- Mobile: Stacked view (keep current) + +--- + +## Critical Files Reference + +| Component | Path | Purpose | +|-----------|------|---------| +| YieldsList | `src/pages/Yields/components/YieldsList.tsx` | Main list with tabs, filters | +| YieldItem | `src/pages/Yields/components/YieldItem.tsx` | Card/row renderer | +| YieldHero | `src/pages/Yields/components/YieldHero.tsx` | Detail page header | +| YieldStats | `src/pages/Yields/components/YieldStats.tsx` | Stats grid (TVL, type) | +| YieldFilters | `src/pages/Yields/components/YieldFilters.tsx` | Filter dropdowns | +| YieldOpportunityStats | `src/pages/Yields/components/YieldOpportunityStats.tsx` | Top dashboard cards | +| YieldEnterModal | `src/pages/Yields/components/YieldEnterModal.tsx` | Deposit modal | +| GradientApy | `src/pages/Yields/components/GradientApy.tsx` | APY gradient text | +| Types | `src/lib/yieldxyz/types.ts` | All yield data types | +| Translations | `src/assets/translations/en/main.json` | Text strings | + +--- + +## Available but Unused Data Fields + +These fields exist in the API response but are not currently displayed: + +| Field | Type | Potential Use | +|-------|------|---------------| +| `metadata.documentation` | string | External docs link | +| `metadata.underMaintenance` | boolean | Warning banner | +| `metadata.deprecated` | boolean | Hide or warn | +| `rewardRate.components[]` | array | Multi-token yield breakdown | +| `mechanics.possibleFeeTakingMechanisms` | object | Fee badges | +| `mechanics.cooldownPeriod` | object | Lockup warning | +| `mechanics.entryLimits` | object | Min/max display | +| `statistics.uniqueUsers` | number | Social proof | +| `state.capacityState` | object | Capacity indicator | +| `tags[]` | array | Filtering/categorization | + +--- + +## Mobile Considerations + +Per the proposal, mobile should retain dense list layout: +- Quick Wins (#1-5) apply to both mobile and desktop +- Pill filters (#13) can work on mobile +- Grid redesign (#15) is desktop-only +- 2-column detail (#16) is desktop-only; mobile stays stacked diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 7fb7c526a96..1c7dbc31c8c 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2801,7 +2801,11 @@ "initiateFailedDescription": "Failed to initiate transaction sequence.", "quoteFailedTitle": "Quote failed", "quoteFailedDescription": "Unable to get a quote for this transaction. Please try again." - } + }, + "underMaintenance": "Under Maintenance", + "underMaintenanceDescription": "This yield opportunity is currently under maintenance. Deposits may be unavailable.", + "deprecated": "Deprecated", + "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon." }, "earn": { "enterFrom": "Enter from", diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 6d1552c1f96..55d044dbd13 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -1,5 +1,8 @@ import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' import { + Alert, + AlertDescription, + AlertIcon, Avatar, Badge, Box, @@ -163,6 +166,24 @@ export const YieldHero = memo( + {yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.deprecatedDescription')} + + + )} + + {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.underMaintenanceDescription')} + + + )} + {stackedIconElement} diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 22049d633c6..daec07f4287 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -1,6 +1,7 @@ import { Avatar, AvatarGroup, + Badge, Box, Card, CardBody, @@ -13,6 +14,7 @@ import { StatLabel, StatNumber, Text, + Tooltip, } from '@chakra-ui/react' import type BigNumber from 'bignumber.js' import { memo, useCallback, useMemo } from 'react' @@ -166,6 +168,30 @@ export const YieldItem = memo( return data.assetSymbol }, [data, isSingle, titleOverride]) + const statusBadge = useMemo(() => { + if (!isSingle) return null + const { underMaintenance, deprecated } = data.yieldItem.metadata + if (deprecated) { + return ( + + + {translate('yieldXYZ.deprecated')} + + + ) + } + if (underMaintenance) { + return ( + + + {translate('yieldXYZ.underMaintenance')} + + + ) + } + return null + }, [data, isSingle, translate]) + const showAvailable = isSingle && hasAvailable && !hasBalance const cardStatElement = useMemo(() => { @@ -311,9 +337,12 @@ export const YieldItem = memo( {iconElement} - - {title} - + + + {title} + + {statusBadge} + @@ -380,9 +409,12 @@ export const YieldItem = memo( {iconElement} - - {title} - + + + {title} + + {statusBadge} + {subtitle} @@ -475,6 +507,7 @@ export const YieldItem = memo( + {statusBadge} From e7b34e738e4921066a313694ddc66b7fff8d70a1 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:01:07 +0100 Subject: [PATCH 03/25] chore: add temporary monkey patch for testing maintenance/deprecated badges Co-Authored-By: Claude Opus 4.5 --- src/react-queries/queries/yieldxyz/useYields.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/react-queries/queries/yieldxyz/useYields.ts b/src/react-queries/queries/yieldxyz/useYields.ts index bffc8202420..9e82e5aa4a4 100644 --- a/src/react-queries/queries/yieldxyz/useYields.ts +++ b/src/react-queries/queries/yieldxyz/useYields.ts @@ -77,6 +77,16 @@ export const useYields = (params?: UseYieldsParams) => { offset += limit } + // TEMP: Monkey patch for testing maintenance/deprecated badges + for (const item of allItems) { + if (item.id === 'katana-vbusdc-bbqusdc-0x1445a01a57d7b7663cfd7b4ee0a8ec03b379aabd-4626-vault') { + item.metadata.underMaintenance = true + } + if (item.id === 'polygon-usdt-fusdt-0x6f5e34eff43d9ab7c977512509c53840b5efba85-4626-vault') { + item.metadata.deprecated = true + } + } + const qualityYields = allItems.filter(item => !isLowQualityYield(item)) // Sort by TVL descending (Highest TVL first) From ea4338bff08f44ec90de517784c615656642e1a0 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:01:20 +0100 Subject: [PATCH 04/25] chore: remove temporary monkey patch for maintenance/deprecated badges Co-Authored-By: Claude Opus 4.5 --- src/react-queries/queries/yieldxyz/useYields.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/react-queries/queries/yieldxyz/useYields.ts b/src/react-queries/queries/yieldxyz/useYields.ts index 9e82e5aa4a4..bffc8202420 100644 --- a/src/react-queries/queries/yieldxyz/useYields.ts +++ b/src/react-queries/queries/yieldxyz/useYields.ts @@ -77,16 +77,6 @@ export const useYields = (params?: UseYieldsParams) => { offset += limit } - // TEMP: Monkey patch for testing maintenance/deprecated badges - for (const item of allItems) { - if (item.id === 'katana-vbusdc-bbqusdc-0x1445a01a57d7b7663cfd7b4ee0a8ec03b379aabd-4626-vault') { - item.metadata.underMaintenance = true - } - if (item.id === 'polygon-usdt-fusdt-0x6f5e34eff43d9ab7c977512509c53840b5efba85-4626-vault') { - item.metadata.deprecated = true - } - } - const qualityYields = allItems.filter(item => !isLowQualityYield(item)) // Sort by TVL descending (Highest TVL first) From 6ebcbc058163e91a8f5ee5d3225d07566253583c Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:09:44 +0100 Subject: [PATCH 05/25] feat: add YieldExplainers component for consistent staking info Create reusable YieldExplainers component that shows reward schedule, unbonding periods, and other yield-specific info based on mechanics type. Add it to YieldEnterModal and EarnConfirm for consistent user education across all enter flows. Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnConfirm.tsx | 7 ++ .../Yields/components/YieldEnterModal.tsx | 5 + .../Yields/components/YieldExplainers.tsx | 106 ++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/pages/Yields/components/YieldExplainers.tsx diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 65151b1b2ad..e9cfbc29688 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -13,6 +13,7 @@ import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers' import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -310,6 +311,12 @@ export const EarnConfirm = memo(() => { )} + {selectedYield && ( + + + + )} + {stepsToShow.length > 0 && ( diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index e486c288a46..0ace8d89f4a 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -32,6 +32,7 @@ import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers' import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -634,6 +635,10 @@ export const YieldEnterModal = memo( )} {statsContent} + {stepsToShow.length > 0 && } )} diff --git a/src/pages/Yields/components/YieldExplainers.tsx b/src/pages/Yields/components/YieldExplainers.tsx new file mode 100644 index 00000000000..ea4b5c64d6f --- /dev/null +++ b/src/pages/Yields/components/YieldExplainers.tsx @@ -0,0 +1,106 @@ +import { InfoIcon } from '@chakra-ui/icons' +import { Box, HStack, Icon, Text, VStack } from '@chakra-ui/react' +import React, { memo, useMemo } from 'react' +import { FaGift } from 'react-icons/fa' +import { MdSwapHoriz } from 'react-icons/md' +import { useTranslate } from 'react-polyglot' + +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' + +type ExplainerItem = { + icon: React.ReactNode + textKey: string +} + +const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => { + const yieldType = selectedYield.mechanics.type + const outputTokenSymbol = selectedYield.outputToken?.symbol + + switch (yieldType) { + case 'liquid-staking': + return [ + { + icon: , + textKey: outputTokenSymbol + ? 'earn.explainers.liquidStakingReceive' + : 'earn.explainers.liquidStakingTrade', + }, + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { + icon: , + textKey: 'earn.explainers.liquidStakingWithdraw', + }, + ] + case 'native-staking': + case 'pooled-staking': + case 'staking': + return [ + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { icon: , textKey: 'earn.explainers.stakingUnbonding' }, + ] + case 'restaking': + return [ + { + icon: , + textKey: 'earn.explainers.restakingYield', + }, + { icon: , textKey: 'earn.explainers.restakingWithdraw' }, + ] + case 'vault': + return [ + { icon: , textKey: 'earn.explainers.vaultYield' }, + { icon: , textKey: 'earn.explainers.vaultWithdraw' }, + ] + case 'lending': + return [ + { icon: , textKey: 'earn.explainers.lendingYield' }, + { icon: , textKey: 'earn.explainers.lendingWithdraw' }, + ] + default: + return [] + } +} + +type YieldExplainersProps = { + selectedYield: AugmentedYieldDto + sellAssetSymbol?: string +} + +export const YieldExplainers = memo(({ selectedYield, sellAssetSymbol }: YieldExplainersProps) => { + const translate = useTranslate() + + const explainers = useMemo(() => getYieldExplainers(selectedYield), [selectedYield]) + + const rewardSchedule = selectedYield.mechanics.rewardSchedule + + const cooldownDays = useMemo(() => { + const seconds = selectedYield.mechanics.cooldownPeriod?.seconds + if (!seconds) return undefined + return Math.ceil(seconds / 86400) + }, [selectedYield.mechanics.cooldownPeriod?.seconds]) + + if (explainers.length === 0) return null + + return ( + + {explainers.map((explainer, index) => ( + + {explainer.icon} + + {translate(explainer.textKey, { + symbol: selectedYield.outputToken?.symbol ?? sellAssetSymbol ?? '', + schedule: rewardSchedule ?? '', + days: cooldownDays ?? '', + })} + + + ))} + + ) +}) From 1555437690af96bebb40baea8047628cdddaef84 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:12:14 +0100 Subject: [PATCH 06/25] feat: add documentation link to yield detail page Show a "Learn more" link with external icon when a yield has metadata.documentation available. Appears below the description text on the yield detail page. Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 3 ++- src/pages/Yields/components/YieldHero.tsx | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 1c7dbc31c8c..ddd75cd5ff3 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2805,7 +2805,8 @@ "underMaintenance": "Under Maintenance", "underMaintenanceDescription": "This yield opportunity is currently under maintenance. Deposits may be unavailable.", "deprecated": "Deprecated", - "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon." + "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon.", + "learnMore": "Learn more" }, "earn": { "enterFrom": "Enter from", diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 55d044dbd13..fa8f1c59d65 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -1,4 +1,4 @@ -import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' +import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon } from '@chakra-ui/icons' import { Alert, AlertDescription, @@ -10,6 +10,7 @@ import { Flex, HStack, IconButton, + Link, Text, VStack, } from '@chakra-ui/react' @@ -223,6 +224,21 @@ export const YieldHero = memo( )} + {yieldItem.metadata.documentation && ( + + {translate('yieldXYZ.learnMore')} + + + )} + From 37845813c78a94bea4445f55065f6bb8e7b58c35 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:26:25 +0100 Subject: [PATCH 07/25] feat: add yield explainers to YieldForm and improve docs link - Add YieldExplainers component to YieldForm.tsx (yield page enter modal) - Update ProviderDto to include website and references fields - Use provider.references[0] for documentation link (protocol website) - Fix docs link styling: inline icon next to description - Prefer provider documentation over yield metadata documentation Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/types.ts | 2 + src/pages/Yields/YieldDetail.tsx | 9 +++- .../Yields/components/YieldEnterModal.tsx | 10 ++-- src/pages/Yields/components/YieldForm.tsx | 4 ++ src/pages/Yields/components/YieldHero.tsx | 53 ++++++++++--------- 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts index be584174067..d4197dc0627 100644 --- a/src/lib/yieldxyz/types.ts +++ b/src/lib/yieldxyz/types.ts @@ -313,6 +313,8 @@ export type ProviderDto = { logoURI: string description?: string documentation?: string + website?: string + references?: string[] } export type ProvidersResponse = { diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 06bb993ae67..567030531a2 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -115,7 +115,14 @@ export const YieldDetail = memo(() => { } if (!isStaking && yieldItem) { const provider = yieldProviders?.[yieldItem.providerId] - if (provider) return { name: provider.name, logoURI: provider.logoURI } + if (provider) { + return { + name: provider.name, + logoURI: provider.logoURI, + description: provider.description, + documentation: provider.references?.[0] ?? provider.website, + } + } } return null }, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders]) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 0ace8d89f4a..f90a15f1ee9 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -635,10 +635,12 @@ export const YieldEnterModal = memo( )} {statsContent} - + + + {stepsToShow.length > 0 && } )} diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index cfa9923af65..74d4da4fb2f 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -27,6 +27,7 @@ import { YieldBalanceType } from '@/lib/yieldxyz/types' import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers' import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' @@ -749,6 +750,9 @@ export const YieldForm = memo( )} {!isClaimAction && statsContent} + {!isClaimAction && ( + + )} {stepsToShow.length > 0 && } diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index fa8f1c59d65..de6b115cdc8 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -34,6 +34,8 @@ const exitIcon = type ValidatorOrProviderInfo = { name: string logoURI?: string + description?: string + documentation?: string } | null type YieldHeroProps = { @@ -212,32 +214,31 @@ export const YieldHero = memo( {apy}% {translate('common.apy')} - {yieldItem.metadata.description && ( - - {yieldItem.metadata.description} - - )} - - {yieldItem.metadata.documentation && ( - - {translate('yieldXYZ.learnMore')} - - - )} + {(() => { + const docUrl = validatorOrProvider?.documentation ?? yieldItem.metadata.documentation + const description = yieldItem.metadata.description + if (!description && !docUrl) return null + return ( + + {description && ( + + {description} + + )} + {docUrl && ( + + + + )} + + ) + })()} From 9f1e96af2d2e7141ed49cb5fe0ebc6de399d4013 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:49:32 +0100 Subject: [PATCH 08/25] feat: add Available to Earn tab and improve yield page navigation - Add 3-tab structure: All | Available to Earn | My Positions - Auto-navigate to "Available to Earn" tab when wallet connects - Each tab has explicit URL param for proper navigation - Keep recommended strip visible in all tabs - Add YieldTab enum for type safety - Rename Earn button action to navigate to Available tab Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 4 +- .../components/YieldOpportunityStats.tsx | 26 +- src/pages/Yields/components/YieldsList.tsx | 288 ++++++++++-------- 3 files changed, 185 insertions(+), 133 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index ddd75cd5ff3..d0d2141791f 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2806,7 +2806,9 @@ "underMaintenanceDescription": "This yield opportunity is currently under maintenance. Deposits may be unavailable.", "deprecated": "Deprecated", "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon.", - "learnMore": "Learn more" + "learnMore": "Learn more", + "noAvailableYields": "No yield opportunities available for your assets", + "connectWalletAvailable": "Connect a wallet to see yields available for your assets" }, "earn": { "enterFrom": "Enter from", diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx index 04d927d5c6c..aa190cd48a3 100644 --- a/src/pages/Yields/components/YieldOpportunityStats.tsx +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -31,8 +31,8 @@ type YieldOpportunityStatsProps = { positions: AugmentedYieldDto[] balances: Record | undefined allYields: AugmentedYieldDto[] | undefined - isMyOpportunities?: boolean - onToggleMyOpportunities?: () => void + isAvailableToEarnTab?: boolean + onNavigateToAvailableTab?: () => void isConnected: boolean isMobile?: boolean } @@ -41,8 +41,8 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ positions, balances, allYields, - isMyOpportunities, - onToggleMyOpportunities, + isAvailableToEarnTab, + onNavigateToAvailableTab, isConnected, isMobile, }: YieldOpportunityStatsProps) { @@ -131,18 +131,18 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ ) const buttonBg = useMemo( - () => (isMyOpportunities ? 'whiteAlpha.300' : 'blue.500'), - [isMyOpportunities], + () => (isAvailableToEarnTab ? 'whiteAlpha.300' : 'blue.500'), + [isAvailableToEarnTab], ) const buttonHoverBg = useMemo( - () => ({ bg: isMyOpportunities ? 'whiteAlpha.400' : 'blue.400' }), - [isMyOpportunities], + () => ({ bg: isAvailableToEarnTab ? 'whiteAlpha.400' : 'blue.400' }), + [isAvailableToEarnTab], ) const buttonText = useMemo( - () => (isMyOpportunities ? translate('yieldXYZ.showAll') : translate('yieldXYZ.earn')), - [isMyOpportunities, translate], + () => (isAvailableToEarnTab ? translate('yieldXYZ.showAll') : translate('yieldXYZ.earn')), + [isAvailableToEarnTab, translate], ) const activeDepositsCard = useMemo(() => { @@ -180,20 +180,20 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ }, [hasActiveDeposits, activeValueFormatted, positionsCount, translate]) const toggleButton = useMemo(() => { - if (!onToggleMyOpportunities) return null + if (!onNavigateToAvailableTab) return null return ( ) - }, [onToggleMyOpportunities, buttonBg, buttonHoverBg, buttonText]) + }, [onNavigateToAvailableTab, buttonBg, buttonHoverBg, buttonText]) if (isMobile) return null diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 2dc4d4ff191..20ab99f5ec7 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -22,7 +22,7 @@ import { } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' -import { memo, useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -66,6 +66,24 @@ import { useAppSelector } from '@/state/store' const tabSelectedSx = { color: 'white', bg: 'blue.500' } +enum YieldTab { + All = 'all', + AvailableToEarn = 'available', + MyPositions = 'my-positions', +} + +const TAB_PARAM_TO_INDEX: Record = { + [YieldTab.All]: 0, + [YieldTab.AvailableToEarn]: 1, + [YieldTab.MyPositions]: 2, +} + +const TAB_INDEX_TO_PARAM: Record = { + 0: YieldTab.All, + 1: YieldTab.AvailableToEarn, + 2: YieldTab.MyPositions, +} + export const YieldsList = memo(() => { const translate = useTranslate() const navigate = useNavigate() @@ -74,10 +92,19 @@ export const YieldsList = memo(() => { const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() - const tabParam = useMemo(() => searchParams.get('tab'), [searchParams]) - const tabIndex = useMemo(() => (tabParam === 'my-positions' ? 1 : 0), [tabParam]) - const filterOption = useMemo(() => searchParams.get('filter'), [searchParams]) - const isMyOpportunities = useMemo(() => filterOption === 'my-assets', [filterOption]) + const tabParam = useMemo(() => searchParams.get('tab') as YieldTab | null, [searchParams]) + const tabIndex = useMemo(() => (tabParam ? TAB_PARAM_TO_INDEX[tabParam] ?? 0 : 0), [tabParam]) + const isAvailableToEarnTab = useMemo(() => tabParam === YieldTab.AvailableToEarn, [tabParam]) + + useEffect(() => { + if (!tabParam && isConnected) { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + next.set('tab', YieldTab.AvailableToEarn) + return next + }) + } + }, [tabParam, isConnected, setSearchParams]) const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) const viewMode = useMemo<'grid' | 'list'>( () => (viewParam === 'list' ? 'list' : 'grid'), @@ -134,22 +161,20 @@ export const YieldsList = memo(() => { (index: number) => { setSearchParams(prev => { const next = new URLSearchParams(prev) - if (index === 0) next.delete('tab') - else next.set('tab', 'my-positions') + next.set('tab', TAB_INDEX_TO_PARAM[index]) return next }) }, [setSearchParams], ) - const handleToggleMyOpportunities = useCallback(() => { + const handleNavigateToAvailableTab = useCallback(() => { setSearchParams(prev => { const next = new URLSearchParams(prev) - if (isMyOpportunities) next.delete('filter') - else next.set('filter', 'my-assets') + next.set('tab', YieldTab.AvailableToEarn) return next }) - }, [isMyOpportunities, setSearchParams]) + }, [setSearchParams]) const getProviderLogo = useCallback( (providerId: string) => yieldProviders?.[providerId]?.logoURI, @@ -261,16 +286,9 @@ export const YieldsList = memo(() => { const yieldsByAsset = useMemo(() => { if (!yields?.assetGroups) return [] - const hasUserBalance = (y: AugmentedYieldDto) => { - if (y.inputTokens?.some(t => bnOrZero(userCurrencyBalances[t.assetId || '']).gt(0))) - return true - return bnOrZero(userCurrencyBalances[y.token.assetId || '']).gt(0) - } - return yields.assetGroups .map(group => { let filteredYields = group.yields - if (isMyOpportunities) filteredYields = filteredYields.filter(hasUserBalance) if (selectedNetwork) filteredYields = filteredYields.filter(y => y.network === selectedNetwork) if (selectedProvider) @@ -332,14 +350,12 @@ export const YieldsList = memo(() => { }[] }, [ yields?.assetGroups, - isMyOpportunities, selectedNetwork, selectedProvider, selectedType, searchQuery, getYieldPositionBalanceUsd, sortOption, - userCurrencyBalances, ]) const recommendedYields = useMemo(() => { @@ -670,34 +686,8 @@ export const YieldsList = memo(() => { [translate], ) - const allYieldsGridElement = useMemo(() => { - if (isMyOpportunities) { - return ( - - {availableYields.map(item => { - const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) - const displayInfo = getYieldDisplayInfo(item.yield) - return ( - handleYieldClick(item.yield.id)} - /> - ) - })} - - ) - } - - return ( + const allYieldsGridElement = useMemo( + () => ( {yieldsByAsset.map(group => { if (group.yields.length === 1) { @@ -742,21 +732,46 @@ export const YieldsList = memo(() => { ) })} - ) - }, [ - isMyOpportunities, - availableYields, - getYieldPositionBalanceUsd, - filterSearchString, - yieldsByAsset, - isMobile, - getYieldDisplayInfo, - handleYieldClick, - userCurrencyBalances, - ]) + ), + [ + filterSearchString, + yieldsByAsset, + isMobile, + getYieldDisplayInfo, + handleYieldClick, + userCurrencyBalances, + ], + ) + + const availableToEarnGridElement = useMemo( + () => ( + + {availableYields.map(item => { + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const displayInfo = getYieldDisplayInfo(item.yield) + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ), + [availableYields, getYieldPositionBalanceUsd, isMobile, getYieldDisplayInfo, handleYieldClick], + ) - const allYieldsListElement = useMemo(() => { - const listHeader = ( + const listHeader = useMemo( + () => ( { - ) - - if (isMyOpportunities) { - return ( - - {listHeader} - {availableYields.map(item => { - const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) - const rowDisplayInfo = getYieldDisplayInfo(item.yield) - return ( - handleYieldClick(item.yield.id)} - /> - ) - })} - - ) - } + ), + [translate], + ) - return ( + const allYieldsListElement = useMemo( + () => ( {listHeader} {yieldsByAsset.map(group => { @@ -868,21 +859,53 @@ export const YieldsList = memo(() => { ) })} - ) - }, [ - isMyOpportunities, - availableYields, - getYieldPositionBalanceUsd, - filterSearchString, - translate, - yieldsByAsset, - getYieldDisplayInfo, - handleYieldClick, - userCurrencyBalances, - ]) + ), + [ + listHeader, + filterSearchString, + yieldsByAsset, + getYieldDisplayInfo, + handleYieldClick, + userCurrencyBalances, + ], + ) + + const availableToEarnListElement = useMemo( + () => ( + + {listHeader} + {availableYields.map(item => { + const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id) + const rowDisplayInfo = getYieldDisplayInfo(item.yield) + return ( + handleYieldClick(item.yield.id)} + /> + ) + })} + + ), + [ + listHeader, + availableYields, + getYieldPositionBalanceUsd, + getYieldDisplayInfo, + handleYieldClick, + ], + ) const recommendedStripElement = useMemo(() => { - if (!isConnected || recommendedYields.length === 0 || isMyOpportunities) return null + if (!isConnected || recommendedYields.length === 0) return null return ( @@ -910,23 +933,14 @@ export const YieldsList = memo(() => { ) - }, [ - isConnected, - recommendedYields, - isMyOpportunities, - translate, - getYieldDisplayInfo, - handleYieldClick, - isMobile, - ]) + }, [isConnected, recommendedYields, translate, getYieldDisplayInfo, handleYieldClick, isMobile]) const allYieldsContentElement = useMemo(() => { if (isLoading) return viewMode === 'grid' || isMobile ? allYieldsLoadingGridElement : allYieldsLoadingListElement - const isEmpty = isMyOpportunities ? availableYields.length === 0 : yieldsByAsset.length === 0 - if (isEmpty) return allYieldsEmptyElement + if (yieldsByAsset.length === 0) return allYieldsEmptyElement return viewMode === 'grid' || isMobile ? allYieldsGridElement : allYieldsListElement }, [ allYieldsEmptyElement, @@ -935,13 +949,47 @@ export const YieldsList = memo(() => { allYieldsLoadingGridElement, allYieldsLoadingListElement, isLoading, - isMyOpportunities, - availableYields.length, viewMode, yieldsByAsset.length, isMobile, ]) + const availableToEarnEmptyElement = useMemo( + () => ( + + {translate('yieldXYZ.noAvailableYields')} + + ), + [translate], + ) + + const availableToEarnContentElement = useMemo(() => { + if (!isConnected) + return ( + + ) + if (isLoading) + return viewMode === 'grid' || isMobile + ? allYieldsLoadingGridElement + : allYieldsLoadingListElement + if (availableYields.length === 0) return availableToEarnEmptyElement + return viewMode === 'grid' || isMobile ? availableToEarnGridElement : availableToEarnListElement + }, [ + isConnected, + isLoading, + viewMode, + isMobile, + allYieldsLoadingGridElement, + allYieldsLoadingListElement, + availableYields.length, + availableToEarnEmptyElement, + availableToEarnGridElement, + availableToEarnListElement, + ]) + const positionsLoadingElement = useMemo( () => ( @@ -1061,8 +1109,8 @@ export const YieldsList = memo(() => { positions={myPositions} balances={allBalances} allYields={yields?.all} - isMyOpportunities={isMyOpportunities} - onToggleMyOpportunities={handleToggleMyOpportunities} + isAvailableToEarnTab={isAvailableToEarnTab} + onNavigateToAvailableTab={handleNavigateToAvailableTab} isConnected={isConnected} isMobile={isMobile} /> @@ -1077,6 +1125,7 @@ export const YieldsList = memo(() => { > {translate('common.all')} + {translate('yieldXYZ.availableToEarn')} {translate('yieldXYZ.myPositions')} ({myPositions.length}) @@ -1128,6 +1177,7 @@ export const YieldsList = memo(() => { {allYieldsContentElement} + {availableToEarnContentElement} {positionsContentElement} From ec27e2b33546d9605d4c1b86b8b1ebfa2d15eee4 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:39:39 +0100 Subject: [PATCH 09/25] feat: improve yield page UX with filter fixes and code cleanup - Fix filters not working on My Positions tab (use unfiltered data) - Fix filters affecting Recommended For You (should be independent) - Fix "Show all" button navigating to All tab when on Available tab - Show full metadata.name on yield detail page below provider pill - Hide Withdraw button when user has no balance (instead of disabled) - Add YieldProviderInfo component with provider descriptions - Add YieldRelatedMarkets component showing other yields for same token - Make Yields first tab in Earn menu when feature flag enabled - Code simplification: remove unnecessary useMemo wrappers Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 5 +- src/components/Layout/Header/Header.tsx | 38 ++--- src/lib/yieldxyz/providerDescriptions.ts | 47 ++++++ src/pages/Yields/YieldDetail.tsx | 11 ++ src/pages/Yields/components/YieldHero.tsx | 90 +++++------ .../components/YieldOpportunityStats.tsx | 147 +++++++----------- .../Yields/components/YieldProviderInfo.tsx | 84 ++++++++++ .../Yields/components/YieldRelatedMarkets.tsx | 79 ++++++++++ src/pages/Yields/components/YieldsList.tsx | 95 +++++++---- .../queries/yieldxyz/useYields.ts | 1 + 10 files changed, 413 insertions(+), 184 deletions(-) create mode 100644 src/lib/yieldxyz/providerDescriptions.ts create mode 100644 src/pages/Yields/components/YieldProviderInfo.tsx create mode 100644 src/pages/Yields/components/YieldRelatedMarkets.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index d0d2141791f..45daca7dab6 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2808,7 +2808,10 @@ "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon.", "learnMore": "Learn more", "noAvailableYields": "No yield opportunities available for your assets", - "connectWalletAvailable": "Connect a wallet to see yields available for your assets" + "connectWalletAvailable": "Connect a wallet to see yields available for your assets", + "aboutProvider": "About %{provider}", + "visitWebsite": "Visit Website", + "otherYields": "Other %{symbol} Yields" }, "earn": { "enterFrom": "Enter from", diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index c0f10f7a42a..8e733009842 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -83,24 +83,16 @@ export const Header = memo(() => { const height = useMemo(() => ref.current?.getBoundingClientRect()?.height ?? 0, []) const { scrollY } = useScroll() - // Responsive display based on viewport width - const searchBoxDisplay = useMemo( - () => ({ - base: 'none', - '2xl': 'flex', - // Hide at smaller breakpoints where it would get cramped - xl: 'none', - }), - [], - ) + const searchBoxDisplay = { + base: 'none', + '2xl': 'flex', + xl: 'none', + } - const iconButtonDisplay = useMemo( - () => ({ - base: 'flex', - '2xl': 'none', - }), - [], - ) + const iconButtonDisplay = { + base: 'flex', + '2xl': 'none', + } useEffect(() => { return scrollY.onChange(() => setY(scrollY.get())) @@ -124,12 +116,12 @@ export const Header = memo(() => { const hasWallet = Boolean(walletInfo?.deviceId) const earnSubMenuItems = useMemo( () => [ - { label: 'navBar.tcy', path: '/tcy', icon: TCYIcon }, - { label: 'navBar.pools', path: '/pools', icon: TbPool }, - { label: 'navBar.lending', path: '/lending', icon: TbBuildingBank }, ...(isYieldXyzEnabled ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp }] : []), + { label: 'navBar.tcy', path: '/tcy', icon: TCYIcon }, + { label: 'navBar.pools', path: '/pools', icon: TbPool }, + { label: 'navBar.lending', path: '/lending', icon: TbBuildingBank }, ], [isYieldXyzEnabled], ) @@ -188,7 +180,11 @@ export const Header = memo(() => { items={exploreSubMenuItems} defaultPath='/assets' /> - + = { + morpho: { + description: + 'Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.', + }, + 'morpho-aave': { + description: + 'Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.', + }, + 'morpho-compound': { + description: + 'Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.', + }, + lido: { + description: + 'Lido is a liquid staking protocol that lets users stake ETH while keeping liquidity via stETH. Multiply audited by top-tier security firms, live since 2020, with $2M in bug bounty incentives.', + }, + aave: { + description: + 'Aave is a multi-chain lending marketplace enabling users to lend, borrow, and build advanced strategies. Multiply audited by top-tier security firms, live since 2017, with $1M in bug bounty incentives.', + }, + compound: { + description: + 'Compound is a foundational DeFi money market with algorithmic interest rates. Multiply audited by top-tier security firms, live since 2018, with $1M in bug bounty incentives.', + }, + kamino: { + description: + 'Kamino is a Solana DeFi suite unifying lending, liquidity, and leverage into one platform. Runs an Immunefi program with up to $1.5M maximum bounty.', + }, + fluid: { + description: + 'Fluid is a liquidity layer built by the Instadapp team, connecting lending, DEX, borrowing, and stablecoin markets into one efficient system. Multiply audited by top-tier security firms, live since 2024, with $0.5M in bug bounty incentives.', + }, + venus: { + description: + 'Venus is a lending and borrowing protocol focused on BNB Chain. Emphasizes security through third-party audits and an ongoing bug bounty program.', + }, + gearbox: { + description: + 'Gearbox is a composable leverage protocol enabling credit accounts that plug into DeFi strategies. Multiply audited by top-tier security firms, live since 2021, with $0.2M in bug bounty incentives.', + }, +} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 567030531a2..633e786db0f 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -22,6 +22,8 @@ import { YieldBalanceType } from '@/lib/yieldxyz/types' import { YieldHero } from '@/pages/Yields/components/YieldHero' import { YieldManager } from '@/pages/Yields/components/YieldManager' import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard' +import { YieldProviderInfo } from '@/pages/Yields/components/YieldProviderInfo' +import { YieldRelatedMarkets } from '@/pages/Yields/components/YieldRelatedMarkets' import { YieldStats } from '@/pages/Yields/components/YieldStats' import { useYieldAccountSync } from '@/pages/Yields/hooks/useYieldAccountSync' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' @@ -253,6 +255,15 @@ export const YieldDetail = memo(() => { isBalancesLoading={isBalancesLoading} /> + {!isStaking && validatorOrProvider && ( + + )} + diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index de6b115cdc8..9b51a686075 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -59,14 +59,11 @@ export const YieldHero = memo( const { location } = useBrowserRouter() const iconSource = useMemo(() => resolveYieldInputAssetIcon(yieldItem), [yieldItem]) - const apy = useMemo( - () => bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2), - [yieldItem.rewardRate.total], - ) - const hasExitBalance = useMemo(() => bnOrZero(userBalanceCrypto).gt(0), [userBalanceCrypto]) + const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2) + const hasExitBalance = bnOrZero(userBalanceCrypto).gt(0) const [searchParams] = useSearchParams() - const validator = useMemo(() => searchParams.get('validator'), [searchParams]) + const validator = searchParams.get('validator') const handleBack = useCallback(() => navigate('/yields'), [navigate]) @@ -87,21 +84,13 @@ export const YieldHero = memo( const handleEnter = useCallback(() => handleAction('enter'), [handleAction]) const handleExit = useCallback(() => handleAction('exit'), [handleAction]) - const enterLabel = useMemo( - () => - yieldItem.mechanics.type === 'staking' - ? translate('defi.stake') - : translate('common.deposit'), - [yieldItem.mechanics.type, translate], - ) + const enterLabel = + yieldItem.mechanics.type === 'staking' ? translate('defi.stake') : translate('common.deposit') - const exitLabel = useMemo( - () => - yieldItem.mechanics.type === 'staking' - ? translate('defi.unstake') - : translate('common.withdraw'), - [yieldItem.mechanics.type, translate], - ) + const exitLabel = + yieldItem.mechanics.type === 'staking' + ? translate('defi.unstake') + : translate('common.withdraw') const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol @@ -187,20 +176,27 @@ export const YieldHero = memo( )} - - {stackedIconElement} - - {validatorOrProvider?.name ?? yieldItem.token.symbol} - - {yieldItem.chainId && ( - - - - {yieldItem.network} - - + + + {stackedIconElement} + + {validatorOrProvider?.name ?? yieldItem.token.symbol} + + {yieldItem.chainId && ( + + + + {yieldItem.network} + + + )} + + {yieldItem.metadata.name && yieldItem.metadata.name !== yieldItem.token.symbol && ( + + {yieldItem.metadata.name} + )} - + {enterLabel} - + {hasExitBalance && ( + + )} ) diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx index aa190cd48a3..c46f39f8ed5 100644 --- a/src/pages/Yields/components/YieldOpportunityStats.tsx +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -33,6 +33,7 @@ type YieldOpportunityStatsProps = { allYields: AugmentedYieldDto[] | undefined isAvailableToEarnTab?: boolean onNavigateToAvailableTab?: () => void + onNavigateToAllTab?: () => void isConnected: boolean isMobile?: boolean } @@ -43,6 +44,7 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ allYields, isAvailableToEarnTab, onNavigateToAvailableTab, + onNavigateToAllTab, isConnected, isMobile, }: YieldOpportunityStatsProps) { @@ -107,99 +109,57 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ return { weightedApy: avgApy, potentialEarningsValue: totalEarnings } }, [isConnected, yields?.byInputAssetId, portfolioBalances]) - const hasActiveDeposits = useMemo(() => activeValueUsd.gt(0), [activeValueUsd]) + const hasActiveDeposits = activeValueUsd.gt(0) + const activeValueFormatted = activeValueUsd.times(userCurrencyToUsdRate).toFixed() + const idleValueFormatted = idleValueUsd.toFixed() + const potentialEarnings = potentialEarningsValue.toFixed() + const weightedApyFormatted = weightedApy.toFixed(2) + const positionsCount = positions.length + const gridColumn = { md: hasActiveDeposits ? 'span 2' : 'span 3' } + const buttonBg = isAvailableToEarnTab ? 'whiteAlpha.300' : 'blue.500' + const buttonHoverBg = { bg: isAvailableToEarnTab ? 'whiteAlpha.400' : 'blue.400' } + const buttonText = isAvailableToEarnTab + ? translate('yieldXYZ.showAll') + : translate('yieldXYZ.earn') - const activeValueFormatted = useMemo( - () => activeValueUsd.times(userCurrencyToUsdRate).toFixed(), - [activeValueUsd, userCurrencyToUsdRate], - ) - - const idleValueFormatted = useMemo(() => idleValueUsd.toFixed(), [idleValueUsd]) - - const potentialEarnings = useMemo( - () => potentialEarningsValue.toFixed(), - [potentialEarningsValue], - ) - - const weightedApyFormatted = useMemo(() => weightedApy.toFixed(2), [weightedApy]) - - const positionsCount = useMemo(() => positions.length, [positions.length]) - - const gridColumn = useMemo( - () => ({ md: hasActiveDeposits ? 'span 2' : 'span 3' }), - [hasActiveDeposits], - ) - - const buttonBg = useMemo( - () => (isAvailableToEarnTab ? 'whiteAlpha.300' : 'blue.500'), - [isAvailableToEarnTab], - ) - - const buttonHoverBg = useMemo( - () => ({ bg: isAvailableToEarnTab ? 'whiteAlpha.400' : 'blue.400' }), - [isAvailableToEarnTab], - ) - - const buttonText = useMemo( - () => (isAvailableToEarnTab ? translate('yieldXYZ.showAll') : translate('yieldXYZ.earn')), - [isAvailableToEarnTab, translate], - ) - - const activeDepositsCard = useMemo(() => { - if (!hasActiveDeposits) return null - return ( - - - {chartPieIcon} - - - - {translate('yieldXYZ.activePositions')} - - - - - - {translate('yieldXYZ.acrossPositions', { count: positionsCount })} - - - - ) - }, [hasActiveDeposits, activeValueFormatted, positionsCount, translate]) - - const toggleButton = useMemo(() => { - if (!onNavigateToAvailableTab) return null - return ( - - ) - }, [onNavigateToAvailableTab, buttonBg, buttonHoverBg, buttonText]) + const toggleButtonClickHandler = isAvailableToEarnTab + ? onNavigateToAllTab + : onNavigateToAvailableTab if (isMobile) return null return ( - {activeDepositsCard} + {hasActiveDeposits && ( + + + {chartPieIcon} + + + + {translate('yieldXYZ.activePositions')} + + + + + + {translate('yieldXYZ.acrossPositions', { count: positionsCount })} + + + + )} {translate('yieldXYZ.perYear')} - {toggleButton} + {toggleButtonClickHandler && ( + + )} diff --git a/src/pages/Yields/components/YieldProviderInfo.tsx b/src/pages/Yields/components/YieldProviderInfo.tsx new file mode 100644 index 00000000000..97e85016141 --- /dev/null +++ b/src/pages/Yields/components/YieldProviderInfo.tsx @@ -0,0 +1,84 @@ +import { ExternalLinkIcon } from '@chakra-ui/icons' +import { Avatar, Box, Button, Flex, Heading, HStack, Link, Text } from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Display } from '@/components/Display' +import { PROVIDER_DESCRIPTIONS } from '@/lib/yieldxyz/providerDescriptions' + +type YieldProviderInfoProps = { + providerId: string + providerName: string + providerLogoURI?: string + providerWebsite?: string +} + +export const YieldProviderInfo = memo( + ({ providerId, providerName, providerLogoURI, providerWebsite }: YieldProviderInfoProps) => { + const translate = useTranslate() + + const description = useMemo(() => { + return PROVIDER_DESCRIPTIONS[providerId]?.description + }, [providerId]) + + if (!description) return null + + return ( + + + + + + + {providerLogoURI && ( + + )} + + {translate('yieldXYZ.aboutProvider', { provider: providerName })} + + + + {description} + + {providerWebsite && ( + + + + )} + + + + + + + + + {providerLogoURI && } + + {translate('yieldXYZ.aboutProvider', { provider: providerName })} + + + + {description} + + {providerWebsite && ( + + + + )} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx new file mode 100644 index 00000000000..da4fc2d0fb6 --- /dev/null +++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx @@ -0,0 +1,79 @@ +import { Box, Heading, SimpleGrid, useMediaQuery } from '@chakra-ui/react' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { YieldItem } from '@/pages/Yields/components/YieldItem' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' + +type YieldRelatedMarketsProps = { + currentYieldId: string + tokenSymbol: string +} + +export const YieldRelatedMarkets = memo( + ({ currentYieldId, tokenSymbol }: YieldRelatedMarketsProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const [isMobile] = useMediaQuery('(max-width: 768px)') + const { data: yields } = useYields() + const { data: yieldProviders } = useYieldProviders() + + const relatedYields = useMemo(() => { + if (!yields?.all) return [] + + return yields.all + .filter(y => { + if (y.id === currentYieldId) return false + const inputSymbol = y.inputTokens?.[0]?.symbol || y.token.symbol + return inputSymbol === tokenSymbol + }) + .sort((a, b) => b.rewardRate.total - a.rewardRate.total) + .slice(0, 3) + }, [yields?.all, currentYieldId, tokenSymbol]) + + const handleYieldClick = useCallback( + (yieldId: string) => { + navigate(`/yields/${yieldId}`) + }, + [navigate], + ) + + const getProviderInfo = useCallback( + (providerId: string) => { + const provider = yieldProviders?.[providerId] + return { name: provider?.name, logo: provider?.logoURI } + }, + [yieldProviders], + ) + + if (relatedYields.length === 0) return null + + return ( + + + {translate('yieldXYZ.otherYields', { symbol: tokenSymbol })} + + + {relatedYields.map(y => { + const providerInfo = getProviderInfo(y.providerId) + return ( + handleYieldClick(y.id)} + /> + ) + })} + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 20ab99f5ec7..e1f7a307681 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -88,13 +88,13 @@ export const YieldsList = memo(() => { const translate = useTranslate() const navigate = useNavigate() const { state: walletState } = useWallet() - const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) + const isConnected = Boolean(walletState.walletInfo) const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() - const tabParam = useMemo(() => searchParams.get('tab') as YieldTab | null, [searchParams]) - const tabIndex = useMemo(() => (tabParam ? TAB_PARAM_TO_INDEX[tabParam] ?? 0 : 0), [tabParam]) - const isAvailableToEarnTab = useMemo(() => tabParam === YieldTab.AvailableToEarn, [tabParam]) + const tabParam = searchParams.get('tab') as YieldTab | null + const tabIndex = tabParam ? TAB_PARAM_TO_INDEX[tabParam] ?? 0 : 0 + const isAvailableToEarnTab = tabParam === YieldTab.AvailableToEarn useEffect(() => { if (!tabParam && isConnected) { @@ -105,11 +105,8 @@ export const YieldsList = memo(() => { }) } }, [tabParam, isConnected, setSearchParams]) - const viewParam = useMemo(() => searchParams.get('view'), [searchParams]) - const viewMode = useMemo<'grid' | 'list'>( - () => (viewParam === 'list' ? 'list' : 'grid'), - [viewParam], - ) + const viewParam = searchParams.get('view') + const viewMode: 'grid' | 'list' = viewParam === 'list' ? 'list' : 'grid' const setViewMode = useCallback( (mode: 'grid' | 'list') => { setSearchParams(prev => { @@ -122,7 +119,7 @@ export const YieldsList = memo(() => { [setSearchParams], ) const [searchQuery, setSearchQuery] = useState('') - const filterSearchString = useMemo(() => searchParams.toString(), [searchParams]) + const filterSearchString = searchParams.toString() const { selectedNetwork, @@ -176,6 +173,14 @@ export const YieldsList = memo(() => { }) }, [setSearchParams]) + const handleNavigateToAllTab = useCallback(() => { + setSearchParams(prev => { + const next = new URLSearchParams(prev) + next.set('tab', YieldTab.All) + return next + }) + }, [setSearchParams]) + const getProviderLogo = useCallback( (providerId: string) => yieldProviders?.[providerId]?.logoURI, [yieldProviders], @@ -359,9 +364,22 @@ export const YieldsList = memo(() => { ]) const recommendedYields = useMemo(() => { - if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) + if (!isConnected || !yields?.unfiltered || !userCurrencyBalances || !assetBalancesBaseUnit) return [] + // Build unfiltered byInputAssetId lookup so recommendations are independent of filters + const allYieldsByInputAssetId = yields.unfiltered.reduce>( + (acc, item) => { + const assetId = item.inputTokens?.[0]?.assetId + if (assetId) { + if (!acc[assetId]) acc[assetId] = [] + acc[assetId].push(item) + } + return acc + }, + {}, + ) + const recommendations: { yield: AugmentedYieldDto balanceFiat: ReturnType @@ -369,7 +387,7 @@ export const YieldsList = memo(() => { }[] = [] for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { - const yieldsForAsset = yields.byInputAssetId[assetId] + const yieldsForAsset = allYieldsByInputAssetId[assetId] if (!yieldsForAsset?.length) continue const balance = bnOrZero(balanceFiat) @@ -404,7 +422,7 @@ export const YieldsList = memo(() => { return recommendations .sort((a, b) => b.potentialEarnings.minus(a.potentialEarnings).toNumber()) .slice(0, 3) - }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets]) + }, [isConnected, yields?.unfiltered, userCurrencyBalances, assetBalancesBaseUnit, assets]) const availableYields = useMemo(() => { if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) @@ -459,16 +477,17 @@ export const YieldsList = memo(() => { ]) const myPositions = useMemo(() => { - if (!yields?.all || !allBalances) return [] - const positions = yields.all.filter(yieldItem => { + if (!yields?.unfiltered || !allBalances) return [] + const positions = yields.unfiltered.filter(yieldItem => { const balances = allBalances[yieldItem.id] if (!balances) return false return balances.some(b => bnOrZero(b.amount).gt(0)) }) - return positions.filter(y => { + const filtered = positions.filter(y => { if (selectedNetwork && y.network !== selectedNetwork) return false if (selectedProvider && y.providerId !== selectedProvider) return false + if (selectedType && y.mechanics.type !== selectedType) return false if (searchQuery) { const q = searchQuery.toLowerCase() if ( @@ -481,7 +500,26 @@ export const YieldsList = memo(() => { } return true }) - }, [yields, allBalances, selectedNetwork, selectedProvider, searchQuery]) + + return filtered.sort((a, b) => { + const aBalance = allBalances[a.id]?.reduce( + (sum, bal) => sum.plus(bnOrZero(bal.amountUsd)), + bnOrZero(0), + ) + const bBalance = allBalances[b.id]?.reduce( + (sum, bal) => sum.plus(bnOrZero(bal.amountUsd)), + bnOrZero(0), + ) + return bBalance.minus(aBalance).toNumber() + }) + }, [ + yields?.unfiltered, + allBalances, + selectedNetwork, + selectedProvider, + selectedType, + searchQuery, + ]) const handleYieldClick = useCallback( (yieldId: string) => { @@ -1018,22 +1056,22 @@ export const YieldsList = memo(() => { const positionsGridElement = useMemo( () => ( - {positionsTable.getRowModel().rows.map(row => { - const posDisplayInfo = getYieldDisplayInfo(row.original) + {myPositions.map(position => { + const posDisplayInfo = getYieldDisplayInfo(position) return ( handleYieldClick(row.original.id)} + onEnter={() => handleYieldClick(position.id)} userBalanceUsd={ - allBalances?.[row.original.id] - ? allBalances[row.original.id].reduce( + allBalances?.[position.id] + ? allBalances[position.id].reduce( (sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0), ) @@ -1044,21 +1082,23 @@ export const YieldsList = memo(() => { })} ), - [allBalances, getYieldDisplayInfo, handleYieldClick, positionsTable, isMobile], + [allBalances, getYieldDisplayInfo, handleYieldClick, myPositions, isMobile], ) const positionsListElement = useMemo( () => ( `${s.id}-${s.desc}`).join(',')} + key={`${positionsSorting.map(s => `${s.id}-${s.desc}`).join(',')}-${ + myPositions.length + }-${myPositions.map(p => p.id).join(',')}`} table={positionsTable} isLoading={false} onRowClick={handleRowClick} /> ), - [handleRowClick, positionsSorting, positionsTable], + [handleRowClick, positionsSorting, positionsTable, myPositions], ) const positionsContentElement = useMemo(() => { @@ -1111,6 +1151,7 @@ export const YieldsList = memo(() => { allYields={yields?.all} isAvailableToEarnTab={isAvailableToEarnTab} onNavigateToAvailableTab={handleNavigateToAvailableTab} + onNavigateToAllTab={handleNavigateToAllTab} isConnected={isConnected} isMobile={isMobile} /> diff --git a/src/react-queries/queries/yieldxyz/useYields.ts b/src/react-queries/queries/yieldxyz/useYields.ts index bffc8202420..1c6d15c9942 100644 --- a/src/react-queries/queries/yieldxyz/useYields.ts +++ b/src/react-queries/queries/yieldxyz/useYields.ts @@ -174,6 +174,7 @@ export const useYields = (params?: UseYieldsParams) => { return { all: filtered, + unfiltered: allYields, byId, ids, byAssetSymbol, From f90fac7479ccd411cc5e87ee747ec32583ffaaab Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:41:44 +0100 Subject: [PATCH 10/25] feat: swap fiat/crypto display on yield detail page Show fiat value as primary (large text) and crypto as secondary. This matches user expectations where fiat is the more relevant metric. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldHero.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 9b51a686075..156d6313bf0 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -238,13 +238,10 @@ export const YieldHero = memo( - + - {yieldItem.token.symbol} - - - + From 1e247f3e607ca3914939923f0311877cf2947cf0 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:49:52 +0100 Subject: [PATCH 11/25] feat: improve yield detail page desktop layout with two-column design Add a professional two-column layout for desktop screens with info on the left and actions on the right. Create YieldInfoCard and YieldAvailableToDeposit components. Mobile layout remains unchanged. Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 8 +- src/pages/Yields/YieldDetail.tsx | 286 +++++++++++------- .../components/YieldAvailableToDeposit.tsx | 103 +++++++ src/pages/Yields/components/YieldHero.tsx | 103 +++---- src/pages/Yields/components/YieldInfoCard.tsx | 183 +++++++++++ .../Yields/components/YieldPositionCard.tsx | 124 +++++--- .../Yields/components/YieldRelatedMarkets.tsx | 2 +- src/pages/Yields/components/YieldStats.tsx | 59 ++-- 8 files changed, 614 insertions(+), 254 deletions(-) create mode 100644 src/pages/Yields/components/YieldAvailableToDeposit.tsx create mode 100644 src/pages/Yields/components/YieldInfoCard.tsx diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 45daca7dab6..4bf65d7ea90 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2811,7 +2811,13 @@ "connectWalletAvailable": "Connect a wallet to see yields available for your assets", "aboutProvider": "About %{provider}", "visitWebsite": "Visit Website", - "otherYields": "Other %{symbol} Yields" + "otherYields": "Other %{symbol} Yields", + "availableToDeposit": "Available to Deposit", + "availableToDepositTooltip": "This is the amount of %{symbol} in your wallet that you can deposit into this yield opportunity.", + "potentialEarningsAmount": "%{amount}/yr at %{apy}% APY", + "depositNow": "Deposit Now", + "strategyInfo": "Strategy Info", + "overview": "Overview" }, "earn": { "enterFrom": "Enter from", diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 633e786db0f..1cba884284b 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -1,5 +1,8 @@ -import { Box, Button, Container, Flex, Heading, Text } from '@chakra-ui/react' -import { memo, useEffect, useMemo } from 'react' +import { ArrowBackIcon } from '@chakra-ui/icons' +import type { ResponsiveValue } from '@chakra-ui/react' +import { Box, Button, Container, Flex, Heading, IconButton, Stack, Text } from '@chakra-ui/react' +import type { Property } from 'csstype' +import { memo, useCallback, useEffect } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' @@ -19,7 +22,9 @@ import { SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, } from '@/lib/yieldxyz/constants' import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { YieldAvailableToDeposit } from '@/pages/Yields/components/YieldAvailableToDeposit' import { YieldHero } from '@/pages/Yields/components/YieldHero' +import { YieldInfoCard } from '@/pages/Yields/components/YieldInfoCard' import { YieldManager } from '@/pages/Yields/components/YieldManager' import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard' import { YieldProviderInfo } from '@/pages/Yields/components/YieldProviderInfo' @@ -31,11 +36,21 @@ import { useYield } from '@/react-queries/queries/yieldxyz/useYield' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { + selectMarketDataByAssetIdUserCurrency, selectPortfolioAccountIdsByAssetIdFilter, selectUserCurrencyToUsdRate, } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const backIcon = + +const layoutDirection: ResponsiveValue = { + base: 'column', + lg: 'row', +} + +const actionColumnMaxWidth = { base: '100%', lg: '500px' } + export const YieldDetail = memo(() => { const { yieldId } = useParams<{ yieldId: string }>() const [searchParams] = useSearchParams() @@ -46,11 +61,15 @@ export const YieldDetail = memo(() => { const { data: yieldItem, isLoading, error } = useYield(yieldId ?? '') - const selectorAssetId = useMemo(() => { - if (yieldItem?.token.assetId) return yieldItem.token.assetId - if (yieldItem?.inputTokens?.[0]?.assetId) return yieldItem.inputTokens[0].assetId - return undefined - }, [yieldItem?.inputTokens, yieldItem?.token.assetId]) + const selectorAssetId = + yieldItem?.token.assetId ?? yieldItem?.inputTokens?.[0]?.assetId ?? undefined + + const inputTokenAssetId = yieldItem?.inputTokens[0]?.assetId ?? '' + const inputTokenMarketData = useAppSelector(state => + selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId), + ) + + const handleBack = useCallback(() => navigate('/yields'), [navigate]) const availableAccounts = useAppSelector(state => selectorAssetId @@ -66,11 +85,13 @@ export const YieldDetail = memo(() => { const showAccountSelector = isYieldMultiAccountEnabled && availableAccounts.length > 1 - const balanceAccountIds = useMemo(() => { - if (!isYieldMultiAccountEnabled) - return availableAccounts.length > 0 ? availableAccounts : undefined - return selectedAccountId ? [selectedAccountId] : undefined - }, [isYieldMultiAccountEnabled, selectedAccountId, availableAccounts]) + const balanceAccountIds = !isYieldMultiAccountEnabled + ? availableAccounts.length > 0 + ? availableAccounts + : undefined + : selectedAccountId + ? [selectedAccountId] + : undefined const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances({ accountIds: balanceAccountIds, @@ -78,14 +99,12 @@ export const YieldDetail = memo(() => { const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined const isBalancesLoading = !allBalancesData && isBalancesFetching - const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) - const defaultValidator = useMemo( - () => - yieldItem?.chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] : undefined, - [yieldItem?.chainId], - ) - const selectedValidatorAddress = useMemo(() => { - // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + const validatorParam = searchParams.get('validator') + const defaultValidator = yieldItem?.chainId + ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + : undefined + + const selectedValidatorAddress = (() => { if ( yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || @@ -94,17 +113,14 @@ export const YieldDetail = memo(() => { return defaultValidator } return validatorParam || defaultValidator - }, [yieldId, validatorParam, defaultValidator]) + })() const isStaking = yieldItem?.mechanics.type === 'staking' - const shouldFetchValidators = useMemo( - () => isStaking && yieldItem?.mechanics.requiresValidatorSelection, - [isStaking, yieldItem?.mechanics.requiresValidatorSelection], - ) + const shouldFetchValidators = isStaking && yieldItem?.mechanics.requiresValidatorSelection const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators) const { data: yieldProviders } = useYieldProviders() - const validatorOrProvider = useMemo(() => { + const validatorOrProvider = (() => { if (isStaking && selectedValidatorAddress) { const found = validators?.find(v => v.address === selectedValidatorAddress) if (found) return { name: found.name, logoURI: found.logoURI } @@ -127,18 +143,17 @@ export const YieldDetail = memo(() => { } } return null - }, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders]) + })() - const titleOverride = useMemo(() => { + const titleOverride = (() => { if (!yieldItem) return undefined const isNativeStaking = yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection if (isNativeStaking) return translate('yieldXYZ.nativeStaking') - // For non-native staking, use token symbol (consistent with cards) return yieldItem.token.symbol - }, [yieldItem, translate]) + })() - const userBalances = useMemo(() => { + const userBalances = (() => { if (!balances) return { userCurrency: '0', crypto: '0' } const balancesByType = selectedValidatorAddress @@ -166,47 +181,38 @@ export const YieldDetail = memo(() => { userCurrency: totalUsd.times(userCurrencyToUsdRate).toFixed(), crypto: totalCrypto.toFixed(), } - }, [balances, selectedValidatorAddress, userCurrencyToUsdRate]) + })() useEffect(() => { if (!yieldId) navigate('/yields') }, [yieldId, navigate]) - const isModalOpen = useMemo(() => { - const modal = searchParams.get('modal') - return modal === 'yield' - }, [searchParams]) - - const loadingElement = useMemo( - () => ( - - - - {translate('common.loadingText')} - - - - ), - [translate], + const isModalOpen = searchParams.get('modal') === 'yield' + + const loadingElement = ( + + + + {translate('common.loadingText')} + + + ) - const errorElement = useMemo( - () => ( - - - - {translate('common.error')} - - - {error ? String(error) : translate('common.noResultsFound')} - - - - - ), - [error, navigate, translate], + const errorElement = ( + + + + {translate('common.error')} + + + {error ? String(error) : translate('common.noResultsFound')} + + + + ) if (isLoading) return loadingElement @@ -215,56 +221,116 @@ export const YieldDetail = memo(() => { return ( - {showAccountSelector && selectorAssetId && ( - <> - - - - - - - - - - - - )} - - - - + + + {showAccountSelector && selectorAssetId && ( + + )} + + + + + {showAccountSelector && selectorAssetId && ( + + + + )} + - - {!isStaking && validatorOrProvider && ( - + - )} - - + + + {!isStaking && validatorOrProvider && ( + + )} + + + + + + + + + + {!isStaking && validatorOrProvider && ( + + )} + + + + + + + + + {isModalOpen && } diff --git a/src/pages/Yields/components/YieldAvailableToDeposit.tsx b/src/pages/Yields/components/YieldAvailableToDeposit.tsx new file mode 100644 index 00000000000..8bd45c02da3 --- /dev/null +++ b/src/pages/Yields/components/YieldAvailableToDeposit.tsx @@ -0,0 +1,103 @@ +import { InfoOutlineIcon } from '@chakra-ui/icons' +import { Box, Card, CardBody, Flex, Heading, HStack, Text, Tooltip, VStack } from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { selectPortfolioCryptoBalanceBaseUnitByFilter } from '@/state/slices/selectors' +import { useAppSelector } from '@/state/store' + +type YieldAvailableToDepositProps = { + yieldItem: AugmentedYieldDto + inputTokenMarketData: { price?: string } | undefined +} + +export const YieldAvailableToDeposit = memo( + ({ yieldItem, inputTokenMarketData }: YieldAvailableToDepositProps) => { + const translate = useTranslate() + + const inputTokenAssetId = yieldItem.inputTokens[0]?.assetId ?? '' + const inputTokenPrecision = yieldItem.inputTokens[0]?.decimals ?? 18 + + const availableBalanceBaseUnit = useAppSelector(state => + selectPortfolioCryptoBalanceBaseUnitByFilter(state, { assetId: inputTokenAssetId }), + ) + + const availableBalance = useMemo( + () => bnOrZero(availableBalanceBaseUnit).shiftedBy(-inputTokenPrecision), + [availableBalanceBaseUnit, inputTokenPrecision], + ) + + const availableBalanceFiat = useMemo( + () => availableBalance.times(bnOrZero(inputTokenMarketData?.price)), + [availableBalance, inputTokenMarketData?.price], + ) + + const potentialYearlyEarningsFiat = useMemo( + () => availableBalanceFiat.times(yieldItem.rewardRate.total), + [availableBalanceFiat, yieldItem.rewardRate.total], + ) + + const hasAvailableBalance = availableBalance.gt(0) + + const tooltipLabel = translate('yieldXYZ.availableToDepositTooltip', { + symbol: yieldItem.token.symbol, + }) + + if (!hasAvailableBalance) return null + + return ( + + + + + + + {translate('yieldXYZ.availableToDeposit')} + + + + + + + + + + + + + + + + + {potentialYearlyEarningsFiat.gt(0) && ( + + + {translate('yieldXYZ.potentialEarnings')} + + + + )} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 156d6313bf0..6a787bb2669 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -1,4 +1,4 @@ -import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon } from '@chakra-ui/icons' +import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon } from '@chakra-ui/icons' import { Alert, AlertDescription, @@ -7,15 +7,13 @@ import { Badge, Box, Button, - Flex, HStack, - IconButton, Link, Text, VStack, } from '@chakra-ui/react' import qs from 'qs' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -27,7 +25,6 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' -const backIcon = const enterIcon = const exitIcon = @@ -58,15 +55,13 @@ export const YieldHero = memo( const translate = useTranslate() const { location } = useBrowserRouter() - const iconSource = useMemo(() => resolveYieldInputAssetIcon(yieldItem), [yieldItem]) + const iconSource = resolveYieldInputAssetIcon(yieldItem) const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2) const hasExitBalance = bnOrZero(userBalanceCrypto).gt(0) const [searchParams] = useSearchParams() const validator = searchParams.get('validator') - const handleBack = useCallback(() => navigate('/yields'), [navigate]) - const handleAction = useCallback( (action: 'enter' | 'exit') => { navigate({ @@ -94,46 +89,44 @@ export const YieldHero = memo( const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol - const stackedIconElement = useMemo(() => { - const assetIcon = iconSource.assetId ? ( - - ) : ( - - ) - - const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId + const assetIcon = iconSource.assetId ? ( + + ) : ( + + ) - if (!hasOverlay) return assetIcon + const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId - return ( - - {assetIcon} - {validatorOrProvider?.logoURI ? ( - - ) : yieldItem.chainId ? ( - - - - ) : null} - - ) - }, [iconSource, validatorOrProvider, yieldItem.chainId]) + const stackedIconElement = !hasOverlay ? ( + assetIcon + ) : ( + + {assetIcon} + {validatorOrProvider?.logoURI ? ( + + ) : yieldItem.chainId ? ( + + + + ) : null} + + ) return ( - - - - {yieldTitle} - - - + + {yieldTitle} + {yieldItem.metadata.deprecated && ( diff --git a/src/pages/Yields/components/YieldInfoCard.tsx b/src/pages/Yields/components/YieldInfoCard.tsx new file mode 100644 index 00000000000..a316babf348 --- /dev/null +++ b/src/pages/Yields/components/YieldInfoCard.tsx @@ -0,0 +1,183 @@ +import { + Alert, + AlertDescription, + AlertIcon, + Avatar, + Badge, + Box, + Card, + CardBody, + Flex, + Heading, + HStack, + Text, + VStack, +} from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import { AssetIcon } from '@/components/AssetIcon' +import { ChainIcon } from '@/components/ChainMenu' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' + +type ValidatorOrProviderInfo = { + name: string + logoURI?: string + description?: string + documentation?: string +} | null + +type YieldInfoCardProps = { + yieldItem: AugmentedYieldDto + validatorOrProvider: ValidatorOrProviderInfo + titleOverride?: string +} + +export const YieldInfoCard = memo( + ({ yieldItem, validatorOrProvider, titleOverride }: YieldInfoCardProps) => { + const translate = useTranslate() + + const iconSource = resolveYieldInputAssetIcon(yieldItem) + const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2) + const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol + + const assetIcon = useMemo( + () => + iconSource.assetId ? ( + + ) : ( + + ), + [iconSource], + ) + + const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId + + const stackedIconElement = useMemo( + () => + !hasOverlay ? ( + assetIcon + ) : ( + + {assetIcon} + {validatorOrProvider?.logoURI ? ( + + ) : yieldItem.chainId ? ( + + + + ) : null} + + ), + [assetIcon, hasOverlay, validatorOrProvider, yieldItem.chainId], + ) + + const type = yieldItem.mechanics.type + const typeLabel = type.charAt(0).toUpperCase() + type.slice(1) + + const description = yieldItem.metadata.description + + return ( + + + + {yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.deprecatedDescription')} + + + )} + + {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.underMaintenanceDescription')} + + + )} + + + {stackedIconElement} + + + {yieldTitle} + + + {validatorOrProvider?.name && ( + + + {validatorOrProvider.name} + + + )} + {yieldItem.chainId && ( + + + + {yieldItem.network} + + + )} + + + + + + + + {apy}% {translate('common.apy')} + + + + {typeLabel} + + + + {description && ( + + {description} + + )} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index 143b6da39d5..f4565bd7b7d 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -1,3 +1,4 @@ +import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' import { Alert, Badge, @@ -8,6 +9,7 @@ import { Divider, Flex, Heading, + HStack, Skeleton, Text, VStack, @@ -20,6 +22,7 @@ import { useTranslate } from 'react-polyglot' import { useNavigate, useSearchParams } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' +import { Display } from '@/components/Display' import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter' import { bnOrZero } from '@/lib/bignumber/bignumber' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' @@ -37,6 +40,16 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const enterIcon = +const exitIcon = + +const loadingState = ( + + + + +) + type YieldPositionCardProps = { yieldItem: AugmentedYieldDto balances: NormalizedYieldBalances | undefined @@ -103,10 +116,7 @@ export const YieldPositionCard = memo( ) }, []) - const hasEntering = useMemo( - () => enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0), - [enteringBalance], - ) + const hasEntering = Boolean(enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0)) const exitingEntries = useMemo(() => { if (!balances?.raw) return [] @@ -118,12 +128,11 @@ export const YieldPositionCard = memo( }) }, [balances?.raw, selectedValidatorAddress]) - const hasExiting = useMemo(() => exitingEntries.length > 0, [exitingEntries]) - const hasWithdrawable = useMemo( - () => withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0), - [withdrawableBalance], + const hasExiting = exitingEntries.length > 0 + const hasWithdrawable = Boolean( + withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0), ) - const hasClaimable = useMemo(() => Boolean(claimableBalance), [claimableBalance]) + const hasClaimable = Boolean(claimableBalance) const totalValueUsd = useMemo( () => @@ -148,7 +157,7 @@ export const YieldPositionCard = memo( [activeBalance, enteringBalance, exitingBalance, withdrawableBalance], ) - const hasAnyPosition = useMemo(() => totalAmount.gt(0), [totalAmount]) + const hasAnyPosition = totalAmount.gt(0) const { data: validators } = useYieldValidators(yieldItem.id) @@ -162,46 +171,41 @@ export const YieldPositionCard = memo( return foundInBalances?.validator?.name }, [validators, selectedValidatorAddress, balances]) - const headingText = useMemo( - () => - selectedValidatorName - ? translate('yieldXYZ.myValidatorPosition', { validator: selectedValidatorName }) - : translate('yieldXYZ.myPosition'), - [selectedValidatorName, translate], - ) + const headingText = selectedValidatorName + ? translate('yieldXYZ.myValidatorPosition', { validator: selectedValidatorName }) + : translate('yieldXYZ.myPosition') + + const addressBadgeText = address ? `${address.slice(0, 4)}...${address.slice(-4)}` : '' - const addressBadgeText = useMemo( - () => (address ? `${address.slice(0, 4)}...${address.slice(-4)}` : ''), - [address], + const totalAmountFixed = totalAmount.toFixed() + + const navigateToAction = useCallback( + (action: 'claim' | 'enter' | 'exit') => { + navigate({ + pathname: location.pathname, + search: qs.stringify({ + action, + modal: 'yield', + ...(selectedValidatorAddress ? { validator: selectedValidatorAddress } : {}), + }), + }) + }, + [navigate, location.pathname, selectedValidatorAddress], ) - const totalAmountFixed = useMemo(() => totalAmount.toFixed(), [totalAmount]) + const handleClaimClick = useCallback(() => navigateToAction('claim'), [navigateToAction]) + const handleEnter = useCallback(() => navigateToAction('enter'), [navigateToAction]) + const handleExit = useCallback(() => navigateToAction('exit'), [navigateToAction]) - const handleClaimClick = useCallback(() => { - navigate({ - pathname: location.pathname, - search: qs.stringify({ - action: 'claim', - modal: 'yield', - ...(selectedValidatorAddress ? { validator: selectedValidatorAddress } : {}), - }), - }) - }, [navigate, location.pathname, selectedValidatorAddress]) + const enterLabel = + yieldItem.mechanics.type === 'staking' ? translate('defi.stake') : translate('common.deposit') - const showPendingActions = useMemo( - () => hasEntering || hasExiting || hasWithdrawable || hasClaimable, - [hasEntering, hasExiting, hasWithdrawable, hasClaimable], - ) + const exitLabel = + yieldItem.mechanics.type === 'staking' + ? translate('defi.unstake') + : translate('common.withdraw') - const loadingState = useMemo( - () => ( - - - - - ), - [], - ) + const showPendingActions = hasEntering || hasExiting || hasWithdrawable || hasClaimable const enteringSection = useMemo(() => { if (!hasEntering) return null @@ -380,8 +384,6 @@ export const YieldPositionCard = memo( ) } - if (!hasAnyPosition && !showPendingActions) return null - return ( @@ -414,6 +416,36 @@ export const YieldPositionCard = memo( {pendingActionsSection} + + + + {hasAnyPosition && ( + + )} + + diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx index da4fc2d0fb6..a4fa1da5b94 100644 --- a/src/pages/Yields/components/YieldRelatedMarkets.tsx +++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx @@ -30,7 +30,7 @@ export const YieldRelatedMarkets = memo( return inputSymbol === tokenSymbol }) .sort((a, b) => b.rewardRate.total - a.rewardRate.total) - .slice(0, 3) + .slice(0, 6) }, [yields?.all, currentYieldId, tokenSymbol]) const handleYieldClick = useCallback( diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index 981e3ca7375..1740e4a19a9 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -1,5 +1,5 @@ import { Avatar, Box, Flex, SimpleGrid, Text } from '@chakra-ui/react' -import { memo, useMemo } from 'react' +import { memo } from 'react' import { useTranslate } from 'react-polyglot' import { useSearchParams } from 'react-router-dom' @@ -33,22 +33,18 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { ) const [searchParams] = useSearchParams() - const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) + const validatorParam = searchParams.get('validator') - const shouldFetchValidators = useMemo( - () => yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, - [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], - ) + const shouldFetchValidators = + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators) - const defaultValidator = useMemo(() => { - if (yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]) - return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] - return validators?.[0]?.address - }, [yieldItem.chainId, validators]) + const defaultValidator = + yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] + : validators?.[0]?.address - const selectedValidatorAddress = useMemo(() => { - // For native staking with hardcoded defaults, always use the default validator (ignore URL param) + const selectedValidatorAddress = (() => { if ( yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || @@ -57,9 +53,9 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { return defaultValidator } return validatorParam || defaultValidator - }, [yieldItem.id, validatorParam, defaultValidator]) + })() - const selectedValidator = useMemo(() => { + const selectedValidator = (() => { if (!selectedValidatorAddress) return undefined const inList = validators?.find(v => v.address === selectedValidatorAddress) if (inList) return inList @@ -67,29 +63,22 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { ?.validator if (inBalances) return inBalances return undefined - }, [validators, selectedValidatorAddress, balances]) + })() - const tvl = useMemo(() => { - const validatorTvl = - selectedValidator && 'tvl' in selectedValidator ? selectedValidator.tvl : undefined - return bnOrZero(yieldItem.statistics?.tvl ?? validatorTvl).toNumber() - }, [selectedValidator, yieldItem.statistics?.tvl]) + const validatorTvl = + selectedValidator && 'tvl' in selectedValidator ? selectedValidator.tvl : undefined + const tvl = bnOrZero(yieldItem.statistics?.tvl ?? validatorTvl).toNumber() - const tvlUserCurrency = useMemo(() => { - if (yieldItem.statistics?.tvlUsd) { - return bnOrZero(yieldItem.statistics.tvlUsd).times(userCurrencyToUsdRate).toFixed() - } - return bnOrZero(tvl) - .times(bnOrZero(inputTokenMarketData?.price)) - .toFixed() - }, [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price]) + const tvlUserCurrency = yieldItem.statistics?.tvlUsd + ? bnOrZero(yieldItem.statistics.tvlUsd).times(userCurrencyToUsdRate).toFixed() + : bnOrZero(tvl) + .times(bnOrZero(inputTokenMarketData?.price)) + .toFixed() - const validatorMetadata = useMemo(() => { - if (yieldItem.mechanics.type !== 'staking') return null - if (selectedValidator) - return { name: selectedValidator.name, logoURI: selectedValidator.logoURI } - return null - }, [yieldItem.mechanics.type, selectedValidator]) + const validatorMetadata = + yieldItem.mechanics.type === 'staking' && selectedValidator + ? { name: selectedValidator.name, logoURI: selectedValidator.logoURI } + : null return ( From b18f7ab9484a41f2f092d01534318bff23500b6b Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:01:22 +0100 Subject: [PATCH 12/25] feat: fix validator mismatch bug and add New badge to Yields menu - Fix validator mismatch on yield detail page by passing selectedValidatorAddress from YieldDetail to YieldPositionCard (ensures Cosmos uses ShapeShift DAO) - Add "New" badge support to NavigationDropdown component - Mark Yields menu item with New badge - Apply react-best-practices cleanup to yield components Co-Authored-By: Claude Opus 4.5 --- src/components/Layout/Header/Header.tsx | 2 +- .../Header/NavBar/NavigationDropdown.tsx | 18 +- src/pages/Yields/YieldDetail.tsx | 2 + .../Yields/components/YieldExplainers.tsx | 26 +- src/pages/Yields/components/YieldHero.tsx | 388 +++++---- src/pages/Yields/components/YieldItem.tsx | 820 +++++++++--------- .../components/YieldOpportunityStats.tsx | 29 +- .../Yields/components/YieldPositionCard.tsx | 16 +- src/pages/Yields/components/YieldsList.tsx | 19 +- 9 files changed, 658 insertions(+), 662 deletions(-) diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 8e733009842..65b1e048cd1 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -117,7 +117,7 @@ export const Header = memo(() => { const earnSubMenuItems = useMemo( () => [ ...(isYieldXyzEnabled - ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp }] + ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp, isNew: true }] : []), { label: 'navBar.tcy', path: '/tcy', icon: TCYIcon }, { label: 'navBar.pools', path: '/pools', icon: TbPool }, diff --git a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx index 00a393cc2a5..a7ae86b2552 100644 --- a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx +++ b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx @@ -1,6 +1,8 @@ import { + Badge, Box, Button, + HStack, Icon, Menu, MenuButton, @@ -23,6 +25,7 @@ type NavigationDropdownItem = { label: string path: string icon?: React.ComponentType + isNew?: boolean } type NavigationDropdownProps = { @@ -134,7 +137,20 @@ export const NavigationDropdown = ({ label, items, defaultPath }: NavigationDrop _hover={menuItemHoverSx} icon={item.icon && } > - {translate(item.label)} + + {translate(item.label)} + {item.isNew && ( + + {translate('common.new')} + + )} + ) })} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 1cba884284b..2c125218b60 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -267,6 +267,7 @@ export const YieldDetail = memo(() => { yieldItem={yieldItem} balances={balances} isBalancesLoading={isBalancesLoading} + selectedValidatorAddress={selectedValidatorAddress} /> { yieldItem={yieldItem} balances={balances} isBalancesLoading={isBalancesLoading} + selectedValidatorAddress={selectedValidatorAddress} /> +const giftIcon = +const infoIcon = + type ExplainerItem = { icon: React.ReactNode textKey: string @@ -20,17 +24,17 @@ const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] = case 'liquid-staking': return [ { - icon: , + icon: swapIcon, textKey: outputTokenSymbol ? 'earn.explainers.liquidStakingReceive' : 'earn.explainers.liquidStakingTrade', }, { - icon: , + icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule', }, { - icon: , + icon: infoIcon, textKey: 'earn.explainers.liquidStakingWithdraw', }, ] @@ -39,28 +43,28 @@ const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] = case 'staking': return [ { - icon: , + icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule', }, - { icon: , textKey: 'earn.explainers.stakingUnbonding' }, + { icon: infoIcon, textKey: 'earn.explainers.stakingUnbonding' }, ] case 'restaking': return [ { - icon: , + icon: giftIcon, textKey: 'earn.explainers.restakingYield', }, - { icon: , textKey: 'earn.explainers.restakingWithdraw' }, + { icon: infoIcon, textKey: 'earn.explainers.restakingWithdraw' }, ] case 'vault': return [ - { icon: , textKey: 'earn.explainers.vaultYield' }, - { icon: , textKey: 'earn.explainers.vaultWithdraw' }, + { icon: giftIcon, textKey: 'earn.explainers.vaultYield' }, + { icon: infoIcon, textKey: 'earn.explainers.vaultWithdraw' }, ] case 'lending': return [ - { icon: , textKey: 'earn.explainers.lendingYield' }, - { icon: , textKey: 'earn.explainers.lendingWithdraw' }, + { icon: giftIcon, textKey: 'earn.explainers.lendingYield' }, + { icon: infoIcon, textKey: 'earn.explainers.lendingWithdraw' }, ] default: return [] diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 6a787bb2669..30a830a11b5 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -13,7 +13,7 @@ import { VStack, } from '@chakra-ui/react' import qs from 'qs' -import { memo, useCallback } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -43,222 +43,230 @@ type YieldHeroProps = { titleOverride?: string } -export const YieldHero = memo( - ({ - yieldItem, - userBalanceUsd, - userBalanceCrypto, - validatorOrProvider, - titleOverride, - }: YieldHeroProps) => { - const navigate = useNavigate() - const translate = useTranslate() - const { location } = useBrowserRouter() +export const YieldHero = memo(function YieldHero({ + yieldItem, + userBalanceUsd, + userBalanceCrypto, + validatorOrProvider, + titleOverride, +}: YieldHeroProps) { + const navigate = useNavigate() + const translate = useTranslate() + const { location } = useBrowserRouter() - const iconSource = resolveYieldInputAssetIcon(yieldItem) - const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2) - const hasExitBalance = bnOrZero(userBalanceCrypto).gt(0) + const iconSource = resolveYieldInputAssetIcon(yieldItem) + const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2) + const hasExitBalance = bnOrZero(userBalanceCrypto).gt(0) - const [searchParams] = useSearchParams() - const validator = searchParams.get('validator') + const [searchParams] = useSearchParams() + const validator = searchParams.get('validator') - const handleAction = useCallback( - (action: 'enter' | 'exit') => { - navigate({ - pathname: location.pathname, - search: qs.stringify({ - action, - modal: 'yield', - ...(validator ? { validator } : {}), - }), - }) - }, - [navigate, location.pathname, validator], - ) + const handleAction = useCallback( + (action: 'enter' | 'exit') => { + navigate({ + pathname: location.pathname, + search: qs.stringify({ + action, + modal: 'yield', + ...(validator ? { validator } : {}), + }), + }) + }, + [navigate, location.pathname, validator], + ) - const handleEnter = useCallback(() => handleAction('enter'), [handleAction]) - const handleExit = useCallback(() => handleAction('exit'), [handleAction]) + const handleEnter = useCallback(() => handleAction('enter'), [handleAction]) + const handleExit = useCallback(() => handleAction('exit'), [handleAction]) - const enterLabel = - yieldItem.mechanics.type === 'staking' ? translate('defi.stake') : translate('common.deposit') + const isStaking = yieldItem.mechanics.type === 'staking' + const enterLabel = isStaking ? translate('defi.stake') : translate('common.deposit') + const exitLabel = isStaking ? translate('defi.unstake') : translate('common.withdraw') - const exitLabel = - yieldItem.mechanics.type === 'staking' - ? translate('defi.unstake') - : translate('common.withdraw') + const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol - const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol + const assetIcon = iconSource.assetId ? ( + + ) : ( + + ) - const assetIcon = iconSource.assetId ? ( - - ) : ( - - ) + const overlayElement = (() => { + if (validatorOrProvider?.logoURI) { + return ( + + ) + } + if (yieldItem.chainId) { + return ( + + + + ) + } + return null + })() - const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId + const stackedIconElement = overlayElement ? ( + + {assetIcon} + {overlayElement} + + ) : ( + assetIcon + ) - const stackedIconElement = !hasOverlay ? ( - assetIcon - ) : ( - - {assetIcon} - {validatorOrProvider?.logoURI ? ( - - ) : yieldItem.chainId ? ( - { + const docUrl = validatorOrProvider?.documentation ?? yieldItem.metadata.documentation + const description = yieldItem.metadata.description + if (!description && !docUrl) return null + return ( + + {description && ( + + {description} + + )} + {docUrl && ( + - - - ) : null} - + + + )} + ) + }, [ + validatorOrProvider?.documentation, + yieldItem.metadata.documentation, + yieldItem.metadata.description, + ]) - return ( - - - {yieldTitle} - + return ( + + + {yieldTitle} + - {yieldItem.metadata.deprecated && ( - - - - {translate('yieldXYZ.deprecatedDescription')} - - - )} + {yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.deprecatedDescription')} + + + )} - {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && ( - - - - {translate('yieldXYZ.underMaintenanceDescription')} - - - )} + {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && ( + + + + {translate('yieldXYZ.underMaintenanceDescription')} + + + )} - - - {stackedIconElement} - - {validatorOrProvider?.name ?? yieldItem.token.symbol} - - {yieldItem.chainId && ( - - - - {yieldItem.network} - - - )} - - {yieldItem.metadata.name && yieldItem.metadata.name !== yieldItem.token.symbol && ( - - {yieldItem.metadata.name} - + + + {stackedIconElement} + + {validatorOrProvider?.name ?? yieldItem.token.symbol} + + {yieldItem.chainId && ( + + + + {yieldItem.network} + + )} - + + {yieldItem.metadata.name && yieldItem.metadata.name !== yieldItem.token.symbol && ( + + {yieldItem.metadata.name} + + )} + - - {apy}% {translate('common.apy')} - + + {apy}% {translate('common.apy')} + - {(() => { - const docUrl = validatorOrProvider?.documentation ?? yieldItem.metadata.documentation - const description = yieldItem.metadata.description - if (!description && !docUrl) return null - return ( - - {description && ( - - {description} - - )} - {docUrl && ( - - - - )} - - ) - })()} + {descriptionSection} - - - - - - - - + + + + + + + + - + + {hasExitBalance && ( - {hasExitBalance && ( - - )} - - - ) - }, -) + )} + + + ) +}) diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index daec07f4287..fe16af20385 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -57,487 +57,465 @@ type YieldItemProps = { titleOverride?: string } -export const YieldItem = memo( - ({ - data, - variant, - userBalanceUsd, - availableBalanceUserCurrency, - onEnter, - searchString, - titleOverride, - }: YieldItemProps) => { - const navigate = useNavigate() - const translate = useTranslate() - const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const { data: yieldProviders } = useYieldProviders() +export const YieldItem = memo(function YieldItem({ + data, + variant, + userBalanceUsd, + availableBalanceUserCurrency, + onEnter, + searchString, + titleOverride, +}: YieldItemProps) { + const navigate = useNavigate() + const translate = useTranslate() + const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) + const { data: yieldProviders } = useYieldProviders() - const isSingle = data.type === 'single' - const isGroup = data.type === 'group' - - const stats = useMemo(() => { - if (isSingle) { - const y = data.yieldItem - return { - apy: y.rewardRate.total, - apyLabel: y.rewardRate.rateType, - tvlUsd: y.statistics?.tvlUsd ?? '0', - providers: [{ id: y.providerId, logo: data.providerIcon }], - chainIds: y.chainId ? [y.chainId] : [], - count: 1, - name: y.metadata.name, - canEnter: y.status.enter, - } - } - const yields = data.yields - const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) - const totalTvlUsd = yields - .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) - .toFixed() - const providerIds = [...new Set(yields.map(y => y.providerId))] - const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] + const isSingle = data.type === 'single' + const isGroup = data.type === 'group' + const stats = (() => { + if (isSingle) { + const y = data.yieldItem return { - apy: maxApy, - apyLabel: 'APY', - tvlUsd: totalTvlUsd, - providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), - chainIds, - count: yields.length, - name: data.assetName, - canEnter: true, + apy: y.rewardRate.total, + apyLabel: y.rewardRate.rateType, + tvlUsd: y.statistics?.tvlUsd ?? '0', + providers: [{ id: y.providerId, logo: data.providerIcon }], + chainIds: y.chainId ? [y.chainId] : [], + count: 1, + name: y.metadata.name, + canEnter: y.status.enter, } - }, [data, isSingle, yieldProviders]) - - const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy]) + } + const yields = data.yields + const maxApy = Math.max(0, ...yields.map(y => y.rewardRate.total)) + const totalTvlUsd = yields + .reduce((acc, y) => acc.plus(bnOrZero(y.statistics?.tvlUsd)), bnOrZero(0)) + .toFixed() + const providerIds = [...new Set(yields.map(y => y.providerId))] + const chainIds = [...new Set(yields.map(y => y.chainId).filter(Boolean))] as string[] - const tvlUserCurrency = useMemo( - () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(), - [stats.tvlUsd, userCurrencyToUsdRate], - ) + return { + apy: maxApy, + apyLabel: 'APY', + tvlUsd: totalTvlUsd, + providers: providerIds.map(id => ({ id, logo: yieldProviders?.[id]?.logoURI })), + chainIds, + count: yields.length, + name: data.assetName, + canEnter: true, + } + })() - const userBalanceUserCurrency = useMemo( - () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined), - [userBalanceUsd, userCurrencyToUsdRate], - ) + const apyFormatted = `${(stats.apy * 100).toFixed(2)}%` + const tvlUserCurrency = bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed() + const userBalanceUserCurrency = userBalanceUsd + ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() + : undefined - const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) - const hasAvailable = availableBalanceUserCurrency && availableBalanceUserCurrency.gt(0) + const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) + const hasAvailable = availableBalanceUserCurrency && availableBalanceUserCurrency.gt(0) - const handleClick = useCallback(() => { - if (isSingle) { - if (stats.canEnter && onEnter) { - onEnter(data.yieldItem) - } else { - navigate(`/yields/${data.yieldItem.id}`) - } + const handleClick = useCallback(() => { + if (isSingle) { + if (stats.canEnter && onEnter) { + onEnter(data.yieldItem) } else { - const suffix = searchString ? `?${searchString}` : '' - navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}${suffix}`) + navigate(`/yields/${data.yieldItem.id}`) } - }, [data, isSingle, navigate, onEnter, searchString, stats.canEnter]) + } else { + const suffix = searchString ? `?${searchString}` : '' + navigate(`/yields/asset/${encodeURIComponent(data.assetSymbol)}${suffix}`) + } + }, [data, isSingle, navigate, onEnter, searchString, stats.canEnter]) - const iconElement = useMemo(() => { - if (isSingle) { - const iconSource = resolveYieldInputAssetIcon(data.yieldItem) - const size = variant === 'card' ? 'md' : 'sm' - if (iconSource.assetId) { - return - } - return - } + const iconElement = (() => { + if (isSingle) { + const iconSource = resolveYieldInputAssetIcon(data.yieldItem) const size = variant === 'card' ? 'md' : 'sm' - if (data.assetId) { - return + if (iconSource.assetId) { + return } - return - }, [data, isSingle, variant]) + return + } + const size = variant === 'card' ? 'md' : 'sm' + if (data.assetId) { + return + } + return + })() - const subtitle = useMemo(() => { - if (isSingle) { - return data.providerName ?? data.yieldItem.providerId - } - return `${stats.count} ${ + const subtitle = isSingle + ? data.providerName ?? data.yieldItem.providerId + : `${stats.count} ${ stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') }` - }, [data, isSingle, stats.count, translate]) - const title = useMemo(() => { - if (titleOverride) return titleOverride - if (isSingle) return data.yieldItem.metadata.name - return data.assetSymbol - }, [data, isSingle, titleOverride]) + const title = titleOverride ?? (isSingle ? data.yieldItem.metadata.name : data.assetSymbol) - const statusBadge = useMemo(() => { - if (!isSingle) return null - const { underMaintenance, deprecated } = data.yieldItem.metadata - if (deprecated) { - return ( - - - {translate('yieldXYZ.deprecated')} - - - ) - } - if (underMaintenance) { - return ( - - - {translate('yieldXYZ.underMaintenance')} - - - ) - } - return null - }, [data, isSingle, translate]) + const underMaintenance = isSingle ? data.yieldItem.metadata.underMaintenance : undefined + const deprecated = isSingle ? data.yieldItem.metadata.deprecated : undefined - const showAvailable = isSingle && hasAvailable && !hasBalance + const statusBadge = useMemo(() => { + if (!isSingle) return null + if (deprecated) { + return ( + + + {translate('yieldXYZ.deprecated')} + + + ) + } + if (underMaintenance) { + return ( + + + {translate('yieldXYZ.underMaintenance')} + + + ) + } + return null + }, [isSingle, underMaintenance, deprecated, translate]) - const cardStatElement = useMemo(() => { - if (hasBalance) { - return ( - <> - - {translate('yieldXYZ.balance')} - - - - - - ) - } - if (showAvailable) { - return ( - <> - - {translate('common.available')} - - - - - - ) - } + const showAvailable = isSingle && hasAvailable && !hasBalance + + const cardStatElement = (() => { + if (hasBalance) { return ( <> - {translate('yieldXYZ.tvl')} + {translate('yieldXYZ.balance')} - - + + ) - }, [ - hasBalance, - showAvailable, - userBalanceUserCurrency, - availableBalanceUserCurrency, - tvlUserCurrency, - translate, - ]) + } + if (showAvailable) { + return ( + <> + + {translate('common.available')} + + + + + + ) + } + return ( + <> + + {translate('yieldXYZ.tvl')} + + + + + + ) + })() - const showAvailableInRow = isSingle && hasAvailable + const showAvailableInRow = isSingle && hasAvailable - const mobileBalanceLabelKey = useMemo(() => { - if (hasBalance) return 'yieldXYZ.balance' - if (showAvailable) return 'common.available' - return 'yieldXYZ.balance' - }, [hasBalance, showAvailable]) + const mobileBalanceLabelKey = (() => { + if (hasBalance) return 'yieldXYZ.balance' + if (showAvailable) return 'common.available' + return 'yieldXYZ.balance' + })() - const mobileBalanceElement = useMemo(() => { - if (hasBalance) { - return ( - - - - ) - } - if (showAvailable) { - return ( - - - - ) - } + const mobileBalanceElement = (() => { + if (hasBalance) { + return ( + + + + ) + } + if (showAvailable) { return ( - - — + + ) - }, [hasBalance, showAvailable, userBalanceUserCurrency, availableBalanceUserCurrency]) + } + return ( + + — + + ) + })() - const rowBalanceElement = useMemo(() => { - if (hasBalance && showAvailableInRow) { - return ( - - - - - - - {translate('yieldXYZ.balance').toLowerCase()} - - - - - - - - {translate('common.available').toLowerCase()} - - - - ) - } - if (hasBalance) { - return ( - - - - ) - } - if (showAvailableInRow) { - return ( - <> + const rowBalanceElement = (() => { + if (hasBalance && showAvailableInRow) { + return ( + + + + + - {translate('common.available')} + {translate('yieldXYZ.balance').toLowerCase()} - + + + - - ) - } - return ( - - — - + + {translate('common.available').toLowerCase()} + + + ) - }, [ - hasBalance, - showAvailableInRow, - userBalanceUserCurrency, - availableBalanceUserCurrency, - translate, - ]) - - if (variant === 'mobile') { + } + if (hasBalance) { return ( - - - - {iconElement} - - - {title} - - {statusBadge} - - - - - - - {translate(mobileBalanceLabelKey)} - - {mobileBalanceElement} - - - - {translate('yieldXYZ.tvl')} - - - - - - - - {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} - - - {apyFormatted} - - - - - + + + ) } - - if (variant === 'row') { + if (showAvailableInRow) { return ( - - - - {iconElement} - - - - {title} - - {statusBadge} - - - {subtitle} - - - - - - - {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} - - - {apyFormatted} - - - - - {translate('yieldXYZ.tvl')} - - - - - - - {isGroup ? ( - - {stats.providers.map(p => ( - - ))} - - ) : ( - - {stats.providers.slice(0, 1).map(p => ( - - ))} - - )} - - - {rowBalanceElement} - - - - + <> + + {translate('common.available')} + + + + + ) } + return ( + + — + + ) + })() + if (variant === 'mobile') { return ( - - - - {iconElement} - - - {title} - - - {isSingle && data.providerIcon && ( - - )} - - {subtitle} - - - - - {statusBadge} + + + {iconElement} + + + {title} + + {statusBadge} + - - - - {isGroup - ? translate('yieldXYZ.maxApy') - : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} - - + + + {translate(mobileBalanceLabelKey)} + + {mobileBalanceElement} + + + + {translate('yieldXYZ.tvl')} + + + + + + + + {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + + {apyFormatted} - - - - {cardStatElement} - - + + + ) - }, -) + } -export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' | 'mobile' }) => { + if (variant === 'row') { + return ( + + + + {iconElement} + + + + {title} + + {statusBadge} + + + {subtitle} + + + + + + + {isGroup ? translate('yieldXYZ.maxApy') : translate('yieldXYZ.apy')} + + + {apyFormatted} + + + + + {translate('yieldXYZ.tvl')} + + + + + + + {isGroup ? ( + + {stats.providers.map(p => ( + + ))} + + ) : ( + + {stats.providers.slice(0, 1).map(p => ( + + ))} + + )} + + + {rowBalanceElement} + + + + + ) + } + + return ( + + + + + {iconElement} + + + {title} + + + {isSingle && data.providerIcon && ( + + )} + + {subtitle} + + + + + {statusBadge} + + + + + + {isGroup + ? translate('yieldXYZ.maxApy') + : `${translate('yieldXYZ.apy')} (${stats.apyLabel})`} + + + {apyFormatted} + + + + {cardStatElement} + + + + + ) +}) + +export const YieldItemSkeleton = memo(function YieldItemSkeleton({ + variant, +}: { + variant: 'card' | 'row' | 'mobile' +}) { if (variant === 'mobile') { return ( diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx index c46f39f8ed5..dad9f8a48db 100644 --- a/src/pages/Yields/components/YieldOpportunityStats.tsx +++ b/src/pages/Yields/components/YieldOpportunityStats.tsx @@ -12,7 +12,7 @@ import { Text, Tooltip, } from '@chakra-ui/react' -import { memo, useMemo } from 'react' +import { memo } from 'react' import { FaChartPie, FaInfoCircle, FaMoon } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' @@ -53,18 +53,14 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ const portfolioBalances = useAppSelector(selectPortfolioUserCurrencyBalances) const { data: yields } = useYields() - const activeValueUsd = useMemo(() => { - return positions.reduce((acc, position) => { - const positionBalances = balances?.[position.id] - if (!positionBalances) return acc - const activeBalances = positionBalances.filter( - b => b.type === 'active' || b.type === 'locked', - ) - return activeBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc) - }, bnOrZero(0)) - }, [positions, balances]) + const activeValueUsd = positions.reduce((acc, position) => { + const positionBalances = balances?.[position.id] + if (!positionBalances) return acc + const activeBalances = positionBalances.filter(b => b.type === 'active' || b.type === 'locked') + return activeBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc) + }, bnOrZero(0)) - const idleValueUsd = useMemo(() => { + const idleValueUsd = (() => { if (!isConnected || !allYields) return bnOrZero(0) const yieldableAssetIds = new Set( @@ -80,10 +76,9 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ const bal = portfolioBalances[assetId] return bal ? totalIdle.plus(bnOrZero(bal)) : totalIdle }, bnOrZero(0)) - }, [isConnected, allYields, portfolioBalances]) + })() - // Calculate weighted APY and potential earnings based on user's actual held assets - const { weightedApy, potentialEarningsValue } = useMemo(() => { + const { weightedApy, potentialEarningsValue } = (() => { if (!isConnected || !yields?.byInputAssetId || !portfolioBalances) { return { weightedApy: 0, potentialEarningsValue: bnOrZero(0) } } @@ -93,7 +88,7 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ for (const [assetId, balanceFiat] of Object.entries(portfolioBalances)) { const yieldsForAsset = yields.byInputAssetId[assetId] - if (!yieldsForAsset?.length) continue // Early bail - no yield for this asset + if (!yieldsForAsset?.length) continue const balance = bnOrZero(balanceFiat) const bestApy = Math.max(...yieldsForAsset.map(y => y.rewardRate.total)) @@ -107,7 +102,7 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({ : 0 return { weightedApy: avgApy, potentialEarningsValue: totalEarnings } - }, [isConnected, yields?.byInputAssetId, portfolioBalances]) + })() const hasActiveDeposits = activeValueUsd.gt(0) const activeValueFormatted = activeValueUsd.times(userCurrencyToUsdRate).toFixed() diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index f4565bd7b7d..42f4cd0d285 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -19,13 +19,12 @@ import dayjs from 'dayjs' import qs from 'qs' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' import { Display } from '@/components/Display' import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { YieldBalanceType } from '@/lib/yieldxyz/types' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' @@ -54,22 +53,23 @@ type YieldPositionCardProps = { yieldItem: AugmentedYieldDto balances: NormalizedYieldBalances | undefined isBalancesLoading: boolean + selectedValidatorAddress: string | undefined } export const YieldPositionCard = memo( - ({ yieldItem, balances, isBalancesLoading }: YieldPositionCardProps) => { + ({ + yieldItem, + balances, + isBalancesLoading, + selectedValidatorAddress, + }: YieldPositionCardProps) => { const translate = useTranslate() const navigate = useNavigate() const { location } = useBrowserRouter() - const [searchParams] = useSearchParams() - const validatorParam = searchParams.get('validator') const { chainId } = yieldItem const { accountId: contextAccountId, accountNumber } = useYieldAccount() - const defaultValidator = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined - const selectedValidatorAddress = validatorParam || defaultValidator - const accountId = useAppSelector(state => { if (contextAccountId) return contextAccountId if (!chainId) return undefined diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index e1f7a307681..5e4eb7a54f3 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -22,7 +22,7 @@ import { } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -93,18 +93,11 @@ export const YieldsList = memo(() => { const [isMobile] = useMediaQuery('(max-width: 768px)') const [searchParams, setSearchParams] = useSearchParams() const tabParam = searchParams.get('tab') as YieldTab | null - const tabIndex = tabParam ? TAB_PARAM_TO_INDEX[tabParam] ?? 0 : 0 - const isAvailableToEarnTab = tabParam === YieldTab.AvailableToEarn - - useEffect(() => { - if (!tabParam && isConnected) { - setSearchParams(prev => { - const next = new URLSearchParams(prev) - next.set('tab', YieldTab.AvailableToEarn) - return next - }) - } - }, [tabParam, isConnected, setSearchParams]) + const tabIndex = useMemo(() => { + if (tabParam) return TAB_PARAM_TO_INDEX[tabParam] ?? 0 + return isConnected ? TAB_PARAM_TO_INDEX[YieldTab.AvailableToEarn] : 0 + }, [tabParam, isConnected]) + const isAvailableToEarnTab = tabParam === YieldTab.AvailableToEarn || (!tabParam && isConnected) const viewParam = searchParams.get('view') const viewMode: 'grid' | 'list' = viewParam === 'list' ? 'list' : 'grid' const setViewMode = useCallback( From eb59b8dc36c59340b20db4befe79a608b8683f66 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:29:53 +0100 Subject: [PATCH 13/25] feat: add getYieldDisplayName utility for clean yield names - Create getYieldDisplayName that returns curator name for Morpho/Yearn vaults (e.g., "Steakhouse High Yield") and symbol for simple yields - Integrate into YieldItem, YieldDetail, YieldInfoCard, YieldHero - Remove useless secondary row on mobile showing ugly metadata name - Fix Visit Website alignment in YieldProviderInfo - Fix Lending badge from UPPERCASE to Capitalize - Simplify components by removing unnecessary useMemo calls Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/getYieldDisplayName.test.ts | 47 ++++++++++ src/lib/yieldxyz/getYieldDisplayName.ts | 31 +++++++ src/pages/Yields/YieldDetail.tsx | 3 +- src/pages/Yields/components/YieldHero.tsx | 5 -- src/pages/Yields/components/YieldInfoCard.tsx | 87 +++++++++---------- src/pages/Yields/components/YieldItem.tsx | 9 +- .../Yields/components/YieldProviderInfo.tsx | 2 +- 7 files changed, 125 insertions(+), 59 deletions(-) create mode 100644 src/lib/yieldxyz/getYieldDisplayName.test.ts create mode 100644 src/lib/yieldxyz/getYieldDisplayName.ts diff --git a/src/lib/yieldxyz/getYieldDisplayName.test.ts b/src/lib/yieldxyz/getYieldDisplayName.test.ts new file mode 100644 index 00000000000..289704e82a7 --- /dev/null +++ b/src/lib/yieldxyz/getYieldDisplayName.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { getYieldDisplayName } from './getYieldDisplayName' +import type { AugmentedYieldDto } from './types' + +const mockYield = (providerId: string, tokenSymbol: string, metadataName: string) => + ({ + providerId, + token: { symbol: tokenSymbol }, + metadata: { name: metadataName }, + }) as AugmentedYieldDto + +describe('getYieldDisplayName', () => { + describe('returns token symbol for standard yields', () => { + it.each([ + ['aave', 'USDC', 'Aave v3 Lending'], + ['fluid', 'USDT', '(PoS) Tether USD Lending Fluid Vault'], + ['compound', 'WETH', 'Compound v3 Lending'], + ['lido', 'stETH', 'Lido Ethereum Staking'], + ['gearbox', 'USDC', 'USDC Trade USDC v3 Gearbox Vault'], + ])('%s %s → %s', (providerId, symbol, metadataName) => { + expect(getYieldDisplayName(mockYield(providerId, symbol, metadataName))).toBe(symbol) + }) + }) + + describe('returns curator name for Morpho/Yearn vaults with known curators', () => { + it.each([ + ['morpho', 'Steakhouse High Yield USDC Morpho Vault', 'Steakhouse High Yield'], + ['morpho', 'Steakhouse Prime USDC Morpho Vault', 'Steakhouse Prime'], + ['morpho', 'Gauntlet USDT Vault Morpho Vault', 'Gauntlet'], + ['morpho', 'Clearstar USDC Reactor Morpho Vault', 'Clearstar'], + ['morpho', 'Yearn OG USDT Morpho Vault', 'Yearn OG'], + ['yearn', 'Yearn OG vbETH Compounder Yearn Vault V3', 'Yearn OG'], + ])('%s "%s" → %s', (providerId, metadataName, expected) => { + expect(getYieldDisplayName(mockYield(providerId, 'TOKEN', metadataName))).toBe(expected) + }) + }) + + describe('returns symbol when vault has no matching curator prefix', () => { + it.each([ + ['yearn', 'vbUSDT', 'Morpho Yearn OG USDT Compounder Yearn Vault V3'], + ['yearn', 'AUSD', 'AUSD yVault Yearn Vault V3'], + ])('%s %s → %s (no curator prefix)', (providerId, symbol, metadataName) => { + expect(getYieldDisplayName(mockYield(providerId, symbol, metadataName))).toBe(symbol) + }) + }) +}) diff --git a/src/lib/yieldxyz/getYieldDisplayName.ts b/src/lib/yieldxyz/getYieldDisplayName.ts new file mode 100644 index 00000000000..78035d559d8 --- /dev/null +++ b/src/lib/yieldxyz/getYieldDisplayName.ts @@ -0,0 +1,31 @@ +import type { AugmentedYieldDto } from './types' + +const VAULT_CURATORS = [ + 'Steakhouse High Yield', + 'Steakhouse Prime', + 'Steakhouse', + 'Gauntlet', + 'Clearstar', + 'Yearn OG', + 'Yearn', + 'Re7', + 'Usual', + 'Smokehouse', +] + +export const getYieldDisplayName = (yieldItem: AugmentedYieldDto): string => { + const { token, providerId, metadata } = yieldItem + const metadataName = metadata?.name ?? '' + + const isVaultWithCurator = + providerId === 'morpho' || + metadataName.includes('Morpho Vault') || + metadataName.includes('Yearn Vault') + + if (isVaultWithCurator) { + const curator = VAULT_CURATORS.find(c => metadataName.startsWith(c)) + if (curator) return curator + } + + return token.symbol +} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 2c125218b60..f6dc1ddb184 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -21,6 +21,7 @@ import { SHAPESHIFT_VALIDATOR_NAME, SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, } from '@/lib/yieldxyz/constants' +import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName' import { YieldBalanceType } from '@/lib/yieldxyz/types' import { YieldAvailableToDeposit } from '@/pages/Yields/components/YieldAvailableToDeposit' import { YieldHero } from '@/pages/Yields/components/YieldHero' @@ -150,7 +151,7 @@ export const YieldDetail = memo(() => { const isNativeStaking = yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection if (isNativeStaking) return translate('yieldXYZ.nativeStaking') - return yieldItem.token.symbol + return getYieldDisplayName(yieldItem) })() const userBalances = (() => { diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 30a830a11b5..268e180a42d 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -205,11 +205,6 @@ export const YieldHero = memo(function YieldHero({ )} - {yieldItem.metadata.name && yieldItem.metadata.name !== yieldItem.token.symbol && ( - - {yieldItem.metadata.name} - - )} - iconSource.assetId ? ( - - ) : ( - - ), - [iconSource], + const assetIcon = iconSource.assetId ? ( + + ) : ( + ) const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId - const stackedIconElement = useMemo( - () => - !hasOverlay ? ( - assetIcon - ) : ( - - {assetIcon} - {validatorOrProvider?.logoURI ? ( - - ) : yieldItem.chainId ? ( - - - - ) : null} + const stackedIconElement = !hasOverlay ? ( + assetIcon + ) : ( + + {assetIcon} + {validatorOrProvider?.logoURI ? ( + + ) : yieldItem.chainId ? ( + + - ), - [assetIcon, hasOverlay, validatorOrProvider, yieldItem.chainId], + ) : null} + ) - const type = yieldItem.mechanics.type - const typeLabel = type.charAt(0).toUpperCase() + type.slice(1) - - const description = yieldItem.metadata.description - return ( @@ -165,8 +155,9 @@ export const YieldInfoCard = memo( py={1.5} fontWeight='medium' fontSize='sm' + textTransform='capitalize' > - {typeLabel} + {type} diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index fe16af20385..33ca81e0fec 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -17,13 +17,14 @@ import { Tooltip, } from '@chakra-ui/react' import type BigNumber from 'bignumber.js' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback } 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 { bnOrZero } from '@/lib/bignumber/bignumber' +import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' @@ -152,12 +153,12 @@ export const YieldItem = memo(function YieldItem({ stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets') }` - const title = titleOverride ?? (isSingle ? data.yieldItem.metadata.name : data.assetSymbol) + const title = titleOverride ?? (isSingle ? getYieldDisplayName(data.yieldItem) : data.assetSymbol) const underMaintenance = isSingle ? data.yieldItem.metadata.underMaintenance : undefined const deprecated = isSingle ? data.yieldItem.metadata.deprecated : undefined - const statusBadge = useMemo(() => { + const statusBadge = (() => { if (!isSingle) return null if (deprecated) { return ( @@ -178,7 +179,7 @@ export const YieldItem = memo(function YieldItem({ ) } return null - }, [isSingle, underMaintenance, deprecated, translate]) + })() const showAvailable = isSingle && hasAvailable && !hasBalance diff --git a/src/pages/Yields/components/YieldProviderInfo.tsx b/src/pages/Yields/components/YieldProviderInfo.tsx index 97e85016141..1d381aa27c8 100644 --- a/src/pages/Yields/components/YieldProviderInfo.tsx +++ b/src/pages/Yields/components/YieldProviderInfo.tsx @@ -41,7 +41,7 @@ export const YieldProviderInfo = memo( {description} {providerWebsite && ( - + + ) +} +``` + +**Example: preload when feature flag is enabled** + +```tsx +function FlagsProvider({ children, flags }: Props) { + useEffect(() => { + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) + } + }, [flags.editorEnabled]) + + return + {children} + +} +``` + +The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed. + +--- + +## 3. Server-Side Performance + +**Impact: HIGH** + +Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times. + +### 3.1 Cross-Request LRU Caching + +`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache. + +**Implementation:** + +```typescript +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache({ + max: 1000, + ttl: 5 * 60 * 1000 // 5 minutes +}) + +export async function getUser(id: string) { + const cached = cache.get(id) + if (cached) return cached + + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user +} + +// Request 1: DB query, result cached +// Request 2: cache hit, no DB query +``` + +Use when sequential user actions hit multiple endpoints needing the same data within seconds. In serverless, consider Redis for cross-process caching. + +Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) + +### 3.2 Minimize Serialization at RSC Boundaries + +The React Server/Client boundary serializes all object properties. Only pass fields that the client actually uses. + +**Incorrect: serializes all 50 fields** + +```tsx +async function Page() { + const user = await fetchUser() // 50 fields + return +} + +'use client' +function Profile({ user }: { user: User }) { + return
{user.name}
// uses 1 field +} +``` + +**Correct: serializes only 1 field** + +```tsx +async function Page() { + const user = await fetchUser() + return +} + +'use client' +function Profile({ name }: { name: string }) { + return
{name}
+} +``` + +### 3.3 Parallel Data Fetching with Component Composition + +React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching. + +**Incorrect: Sidebar waits for Page's fetch to complete** + +```tsx +export default async function Page() { + const header = await fetchHeader() + return ( +
+
{header}
+ +
+ ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} +``` + +**Correct: both fetch simultaneously** + +```tsx +async function Header() { + const data = await fetchHeader() + return
{data}
+} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +export default function Page() { + return ( +
+
+ +
+ ) +} +``` + +**Alternative with children prop:** + +```tsx +async function Layout({ children }: { children: ReactNode }) { + const header = await fetchHeader() + return ( +
+
{header}
+ {children} +
+ ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +export default function Page() { + return ( + + + + ) +} +``` + +### 3.4 Per-Request Deduplication with React.cache() + +Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most. + +**Usage:** + +```typescript +import { cache } from 'react' + +export const getCurrentUser = cache(async () => { + const session = await auth() + if (!session?.user?.id) return null + return await db.user.findUnique({ + where: { id: session.user.id } + }) +}) +``` + +Within a single request, multiple calls to `getCurrentUser()` execute the query only once. + +--- + +## 4. Client-Side Data Fetching + +**Impact: MEDIUM-HIGH** + +Automatic deduplication and efficient data fetching patterns reduce redundant network requests. + +### 4.1 Deduplicate Global Event Listeners + +Use `useSWRSubscription()` to share global event listeners across component instances. + +**Incorrect: N instances = N listeners** + +```tsx +function useKeyboardShortcut(key: string, callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.key === key) { + callback() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) +} +``` + +When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener. + +**Correct: N instances = 1 listener** + +```tsx +import useSWRSubscription from 'swr/subscription' + +// Module-level Map to track callbacks per key +const keyCallbacks = new Map void>>() + +function useKeyboardShortcut(key: string, callback: () => void) { + // Register this callback in the Map + useEffect(() => { + if (!keyCallbacks.has(key)) { + keyCallbacks.set(key, new Set()) + } + keyCallbacks.get(key)!.add(callback) + + return () => { + const set = keyCallbacks.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) { + keyCallbacks.delete(key) + } + } + } + }, [key, callback]) + + useSWRSubscription('global-keydown', () => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && keyCallbacks.has(e.key)) { + keyCallbacks.get(e.key)!.forEach(cb => cb()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + } +} + +function Profile() { + // Multiple shortcuts will share the same listener + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) + // ... +} +``` + +### 4.2 Use SWR for Automatic Deduplication + +SWR enables request deduplication, caching, and revalidation across component instances. + +**Incorrect: no deduplication, each instance fetches** + +```tsx +function UserList() { + const [users, setUsers] = useState([]) + useEffect(() => { + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) +} +``` + +**Correct: multiple instances share one request** + +```tsx +import useSWR from 'swr' + +function UserList() { + const { data: users } = useSWR('/api/users', fetcher) +} +``` + +**For immutable data:** + +```tsx +import { useImmutableSWR } from '@/lib/swr' + +function StaticContent() { + const { data } = useImmutableSWR('/api/config', fetcher) +} +``` + +**For mutations:** + +```tsx +import { useSWRMutation } from 'swr/mutation' + +function UpdateButton() { + const { trigger } = useSWRMutation('/api/user', updateUser) + return +} +``` + +Reference: [https://swr.vercel.app](https://swr.vercel.app) + +--- + +## 5. Re-render Optimization + +**Impact: MEDIUM** + +Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness. + +### 5.1 Defer State Reads to Usage Point + +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. + +**Incorrect: subscribes to all searchParams changes** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +**Correct: reads on demand, no subscription** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +### 5.2 Extract to Memoized Components + +Extract expensive work into memoized components to enable early returns before computation. + +**Incorrect: computes avatar even when loading** + +```tsx +function Profile({ user, loading }: Props) { + const avatar = useMemo(() => { + const id = computeAvatarId(user) + return + }, [user]) + + if (loading) return + return
{avatar}
+} +``` + +**Correct: skips computation when loading** + +```tsx +const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { + const id = useMemo(() => computeAvatarId(user), [user]) + return +}) + +function Profile({ user, loading }: Props) { + if (loading) return + return ( +
+ +
+ ) +} +``` + +### 5.3 Narrow Effect Dependencies + +Specify primitive dependencies instead of objects to minimize effect re-runs. + +**Incorrect: re-runs on any user field change** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user]) +``` + +**Correct: re-runs only when id changes** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user.id]) +``` + +**For derived state, compute outside effect:** + +```tsx +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) +``` + +### 5.4 Subscribe to Derived State + +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. + +**Incorrect: re-renders on every pixel change** + +```tsx +function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return