diff --git a/packages/widget/src/domain/earn/stake.ts b/packages/widget/src/domain/earn/stake.ts index fa81df9d9..c52def0bb 100644 --- a/packages/widget/src/domain/earn/stake.ts +++ b/packages/widget/src/domain/earn/stake.ts @@ -3,7 +3,7 @@ import { Array as EArray, Option } from "effect"; import { exactDecimal, exactZero } from "../finance/exact"; import type { YieldId } from "../identity/identifiers"; import type { Network } from "../network/network"; -import { equalTokens, type Token } from "../token/token"; +import { equalTokens, isNativeToken, type Token } from "../token/token"; import type { EarnValidator, EarnYieldWithProvider } from "./models"; import type { ValidatorKey } from "./validator"; import { getYieldActionArg, isBittensorStaking } from "./yield"; @@ -13,8 +13,11 @@ export const stakeTokenSameAsGasToken = ({ yieldDto, }: { stakeToken: Token; - yieldDto: EarnYieldWithProvider; -}) => equalTokens(stakeToken, yieldDto.mechanics.gasFeeToken); + yieldDto: EarnYieldWithProvider | null; +}) => + isNativeToken(stakeToken) || + (yieldDto !== null && + equalTokens(stakeToken, yieldDto.mechanics.gasFeeToken)); export const getMaxAmount = ({ availableAmount, diff --git a/packages/widget/src/domain/token/token.ts b/packages/widget/src/domain/token/token.ts index 315715a49..211ba6d9f 100644 --- a/packages/widget/src/domain/token/token.ts +++ b/packages/widget/src/domain/token/token.ts @@ -30,3 +30,4 @@ export const tokenString = (token: TokenLike): TokenString => { export const equalTokens = (a: TokenLike, b: TokenLike) => tokenString(a) === tokenString(b); +export const isNativeToken = (token: TokenLike) => token.address === undefined; diff --git a/packages/widget/src/features/activity/react/activity-action-route.tsx b/packages/widget/src/features/activity/react/activity-action-route.tsx index 468d92029..37bf74c33 100644 --- a/packages/widget/src/features/activity/react/activity-action-route.tsx +++ b/packages/widget/src/features/activity/react/activity-action-route.tsx @@ -1,7 +1,7 @@ import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react"; import { createContext, useContext } from "react"; import { Navigate, Outlet, useMatch, useParams } from "react-router"; -import { ContentLoaderSquare } from "../../../shared/ui/primitives/content-loader"; +import { LoadingSkeleton } from "../../../shared/ui/components/loading-skeleton"; import { YieldActionContinuationSessionRoute } from "../../classic-transaction-flow/views"; import { walletScopeAtom } from "../../wallet/index"; import type { YieldSummaryProvider } from "../../yield-summary/index"; @@ -83,7 +83,7 @@ const BoundActivityActionRoute = ({ const retry = useAtomSet(retryActivityActionRouteAtom(selectionKey)); if (result.status === "loading") { - return ; + return ; } if (result.status === "failed") { return retry(undefined)} />; diff --git a/packages/widget/src/features/activity/ui/activity-page/activity-page-presentation.tsx b/packages/widget/src/features/activity/ui/activity-page/activity-page-presentation.tsx index f271aa2c5..c337034c6 100644 --- a/packages/widget/src/features/activity/ui/activity-page/activity-page-presentation.tsx +++ b/packages/widget/src/features/activity/ui/activity-page/activity-page-presentation.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { VirtualList } from "../../../../shared/ui/components/virtual-list"; import { Box } from "../../../../shared/ui/primitives/box"; import { Button } from "../../../../shared/ui/primitives/button"; -import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../shared/ui/primitives/typography/text"; import { FallbackContent } from "../../../widget-shell/views"; import type { ActivityActionItem } from "../../model/activity-action"; @@ -12,7 +11,10 @@ import type { ActivityPagePagination, ActivityPageView, } from "../../state/page"; -import { ActionListItem } from "./components/action-list-item"; +import { + ActionListItem, + ActionListItemSkeleton, +} from "./components/action-list-item"; import { ActivityFilters } from "./components/activity-filters"; import { container } from "./style.css"; @@ -23,11 +25,10 @@ const ActivityPageSkeleton = () => ( aria-hidden="true" data-rk="activity-page-skeleton" display="flex" - gap="1" flexDirection="column" > {[...Array(5).keys()].map((item) => ( - + ))} ); diff --git a/packages/widget/src/features/activity/ui/activity-page/components/action-list-item/index.tsx b/packages/widget/src/features/activity/ui/activity-page/components/action-list-item/index.tsx index 5fb347ccc..3733e814f 100644 --- a/packages/widget/src/features/activity/ui/activity-page/components/action-list-item/index.tsx +++ b/packages/widget/src/features/activity/ui/activity-page/components/action-list-item/index.tsx @@ -1,10 +1,12 @@ import { useTranslation } from "react-i18next"; import { Box } from "../../../../../../shared/ui/primitives/box"; +import { ContentLoaderLine } from "../../../../../../shared/ui/primitives/content-loader"; import { ListItem } from "../../../../../../shared/ui/primitives/list/list-item"; import { Text } from "../../../../../../shared/ui/primitives/typography/text"; import type { ActivityActionItem } from "../../../../model/activity-action"; +import type { ActivityStatusLabel } from "../../../../model/activity-action-list-item"; import { useActionListItem } from "../../hooks/use-action-list-item"; -import { ActivityIcon } from "../activity-icon"; +import { ActivityIcon, type ActivityIconType } from "../activity-icon"; import { amountNeutral, amountPositive, @@ -33,9 +35,58 @@ export const ActionListItem = ({ if (!listItemView) return null; + const { providersDetails } = listItemView; + + const firstProvider = providersDetails?.[0]; + const providerLabel = firstProvider + ? t("positions.via", { + providerName: firstProvider.name ?? firstProvider.address, + count: Math.max((providersDetails?.length ?? 0) - 1, 1), + }) + : null; + return ( + onActionSelect(action)} + /> + ); +}; + +export const ActionListItemSkeleton = () => ; + +type ActionListItemContent = { + readonly canOpenDetails: boolean; + readonly iconType: ActivityIconType; + readonly title: string; + readonly tokenSymbol: string | null; + readonly amount: string | null; + readonly amountSign: "" | "+" | "-"; + readonly isPositive: boolean; + readonly timestampAbsolute: string; + readonly timestampRelative: string; + readonly badgeLabel: string | null; + readonly statusLabel: ActivityStatusLabel | null; +}; + +const ActionListItemPresentation = ({ + view, + viaLabel, + isSelected = false, + onSelect, +}: { + readonly view?: ActionListItemContent; + readonly viaLabel?: string | null; + readonly isSelected?: boolean; + readonly onSelect?: () => void; +}) => { + const loading = !view; + const readyDataRk = isSelected + ? "activity-list-item-selected" + : "activity-list-item"; const { canOpenDetails, - providersDetails, iconType, title, tokenSymbol, @@ -46,25 +97,14 @@ export const ActionListItem = ({ timestampRelative, badgeLabel, statusLabel, - } = listItemView; - - const firstProvider = providersDetails?.[0]; - const providerLabel = firstProvider - ? t("positions.via", { - providerName: firstProvider.name ?? firstProvider.address, - count: Math.max((providersDetails?.length ?? 0) - 1, 1), - }) - : null; - const viaLabel = providerLabel; + } = view ?? {}; return ( - + onActionSelect(action) : undefined} + onClick={canOpenDetails ? onSelect : undefined} className={listItem} - data-rk={ - isSelected ? "activity-list-item-selected" : "activity-list-item" - } + data-rk={loading ? "activity-list-item-skeleton" : readyDataRk} variant={{ active: isSelected ? "active" : "inactive", hover: canOpenDetails ? "enabled" : "disabled", @@ -88,9 +128,11 @@ export const ActionListItem = ({ - {title} + + {loading ? : title} + - {badgeLabel || viaLabel ? ( + {loading || badgeLabel || viaLabel ? ( {badgeLabel && statusLabel ? ( ) : null} - {viaLabel ? ( + {loading || viaLabel ? ( - {viaLabel} + {loading ? ( + + ) : ( + viaLabel + )} ) : null} @@ -132,10 +178,16 @@ export const ActionListItem = ({ gap="3" flexShrink={0} > - {amount ? ( + {loading || amount ? ( - {amountSign} - {tokenSymbol ? `${amount} ${tokenSymbol}` : amount} + {loading ? ( + + ) : ( + <> + {amountSign} + {tokenSymbol ? `${amount} ${tokenSymbol}` : amount} + + )} ) : null} @@ -143,12 +195,20 @@ export const ActionListItem = ({ - {timestampAbsolute} + {loading ? ( + + ) : ( + timestampAbsolute + )} - {timestampRelative} + {loading ? ( + + ) : ( + timestampRelative + )} diff --git a/packages/widget/src/features/activity/ui/activity-page/components/activity-icon/index.tsx b/packages/widget/src/features/activity/ui/activity-page/components/activity-icon/index.tsx index 501b4d940..9d9ab576c 100644 --- a/packages/widget/src/features/activity/ui/activity-page/components/activity-icon/index.tsx +++ b/packages/widget/src/features/activity/ui/activity-page/components/activity-icon/index.tsx @@ -1,35 +1,41 @@ import { Match } from "effect"; import { Box } from "../../../../../../shared/ui/primitives/box"; +import { ContentLoaderCircle } from "../../../../../../shared/ui/primitives/content-loader"; import { Arrow } from "../../../../../../shared/ui/primitives/icons/arrow"; import { GifIcon } from "../../../../../../shared/ui/primitives/icons/gift"; import { iconCircle } from "../activity-item.css"; export type ActivityIconType = "in" | "neutral" | "out" | "rewards"; -export const ActivityIcon = ({ type }: { type: ActivityIconType }) => { - const icon = Match.value(type).pipe( - Match.when("rewards", () => ), - Match.when("out", () => ), - Match.when("in", () => ), - Match.when("neutral", () => ( - - )), - Match.exhaustive - ); +export const ActivityIcon = ({ type }: { type?: ActivityIconType }) => { + const icon = + type === undefined ? ( + + ) : ( + Match.value(type).pipe( + Match.when("rewards", () => ), + Match.when("out", () => ), + Match.when("in", () => ), + Match.when("neutral", () => ( + + )), + Match.exhaustive + ) + ); return {icon}; }; diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/components/amount-field.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/components/amount-field.tsx index 615e4d8e8..8991ddf35 100644 --- a/packages/widget/src/features/borrow/borrow-entry/ui/components/amount-field.tsx +++ b/packages/widget/src/features/borrow/borrow-entry/ui/components/amount-field.tsx @@ -10,72 +10,104 @@ import { import * as AmountToggle from "../../../../../shared/ui/components/amount-toggle"; import { MaxButton } from "../../../../../shared/ui/components/max-button"; import { NumberInput } from "../../../../../shared/ui/components/number-input"; +import * as inputStyles from "../../../../../shared/ui/components/number-input/styles.css"; import { Box } from "../../../../../shared/ui/primitives/box"; +import { ContentLoaderLine } from "../../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import { WarningBox } from "../../../../../shared/ui/primitives/warning-box"; import * as styles from "../../../amount-input/views"; +import { StaticAmountTokenButton } from "./asset-selector"; -export const AmountField = ({ - amount, - balanceLabel, - highlight = false, - label, - onMaxClick, - onAmountChange, - tokenSelector, - usdValue, - warningText, -}: { - readonly amount: BigNumber; - readonly balanceLabel: ReactNode; - readonly highlight?: boolean; - readonly label: string; - readonly onMaxClick: (() => void) | null; - readonly onAmountChange: (amount: BigNumber) => void; - readonly tokenSelector: ReactNode; - readonly usdValue: BigNumber; - readonly warningText?: string | null; -}) => ( - - {label} +export const AmountField = ( + props: { + readonly highlight?: boolean; + readonly label: string; + } & ( + | { readonly loading: true; readonly showBalance?: boolean } + | { + readonly loading?: false; + readonly amount: BigNumber; + readonly balanceLabel: ReactNode; + readonly onMaxClick: (() => void) | null; + readonly onAmountChange: (amount: BigNumber) => void; + readonly tokenSelector: ReactNode; + readonly usdValue: BigNumber; + readonly warningText?: string | null; + } + ) +) => ( + + {props.label} - - - {tokenSelector} + {props.loading ? ( + + + + + + ) : ( + + )} + {props.loading ? ( + + ) : ( + props.tokenSelector + )} - {formatUsd(usdValue)} + {props.loading ? ( + + ) : ( + formatUsd(props.usdValue) + )} - {balanceLabel} + {props.loading + ? props.showBalance && + : props.balanceLabel} - {onMaxClick ? : null} + {!props.loading && props.onMaxClick ? ( + + ) : null} - {warningText ? : null} + {!props.loading && props.warningText ? ( + + ) : null} ); -export const BorrowBalanceLabel = ({ - amount, - symbol, -}: { - readonly amount: string | number | BigNumber; - readonly symbol: string; -}) => { +export const BorrowBalanceLabel = ( + props: + | { readonly loading: true } + | { + readonly loading?: false; + readonly amount: string | number | BigNumber; + readonly symbol: string; + } +) => { const { t } = useTranslation(); + if (props.loading) { + return ; + } + + const { amount, symbol } = props; + return ( diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/components/asset-selector.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/components/asset-selector.tsx index 2b47fab46..f49ed424c 100644 --- a/packages/widget/src/features/borrow/borrow-entry/ui/components/asset-selector.tsx +++ b/packages/widget/src/features/borrow/borrow-entry/ui/components/asset-selector.tsx @@ -3,12 +3,16 @@ import clsx from "clsx"; import type { ReactNode } from "react"; import { useWidgetConfig } from "../../../../../features/widget-configuration/index"; import { combineRecipeWithVariant } from "../../../../../shared/styles/recipe-variant"; -import { TokenIcon } from "../../../../../shared/ui/components/token-icon"; +import { + TokenIcon, + TokenIconSkeleton, +} from "../../../../../shared/ui/components/token-icon"; import { Box } from "../../../../../shared/ui/primitives/box"; import { pressAnimation, selectTokenButton, } from "../../../../../shared/ui/primitives/button/styles.css"; +import { ContentLoaderLine } from "../../../../../shared/ui/primitives/content-loader"; import { CaretDownIcon } from "../../../../../shared/ui/primitives/icons/caret-down"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import * as amountStyles from "../../../amount-input/views"; @@ -20,33 +24,41 @@ const AmountTokenButtonContent = ({ token, }: { readonly showCaret: boolean; - readonly token: BorrowEntryToken; -}) => ( - <> - - - {token.symbol} - - {showCaret ? ( - - - - ) : null} - -); + readonly token: BorrowEntryToken | null; +}) => + token ? ( + <> + + + {token.symbol} + + {showCaret ? ( + + + + ) : null} + + ) : ( + <> + + + + ); export const StaticAmountTokenButton = ({ token, }: { - readonly token: BorrowEntryToken; + readonly token: BorrowEntryToken | null; }) => { const variant = useWidgetConfig("variant"); return ( ["metricCards"]; + readonly cards: ReadonlyArray<{ + readonly id: string; + readonly label: string; + readonly loading?: boolean; + readonly subValue?: string | null; + readonly value: string | null; + }>; }) => ( {cards.map((card) => ( {card.label} - {card.value} - {card.subValue ? ( + + {card.loading ? : card.value} + + {card.subValue || (card.loading && card.subValue === null) ? ( - {card.subValue} + {card.loading ? : card.subValue} ) : null} @@ -53,16 +68,7 @@ export const BorrowDetailsPanel = ({ readonly view: BorrowEntryView; }) => { const { t } = useTranslation(); - const { - borrowAmount, - collateralAmount, - integrationsResult, - marketsResult, - projection, - selectedIntegration, - selectedMarket, - walletBalances, - } = view; + const { integrationsResult, marketsResult, selectedMarket } = view; if ( (view.markets.length === 0 && @@ -71,7 +77,7 @@ export const BorrowDetailsPanel = ({ AsyncResult.isInitial(integrationsResult) || AsyncResult.isWaiting(integrationsResult) ) { - return ; + return ; } if ( @@ -93,78 +99,169 @@ export const BorrowDetailsPanel = ({ ); } - const model = getBorrowDetailsModel({ - balances: walletBalances, - borrowAmount, - collateralAmount, - integration: selectedIntegration, - market: selectedMarket, - projection, - t, - }); - const loanToken = toBorrowEntryToken({ - network: selectedMarket.network, - token: selectedMarket.loanToken, - }); - const providerName = formatBorrowProviderName( - selectedIntegration?.name ?? selectedMarket.integrationId - ); + return ; +}; + +const BorrowDetailsContent = ({ + view, +}: { + readonly view: BorrowEntryView | null; +}) => { + const { t } = useTranslation(); + const selectedMarket = view?.selectedMarket; + const selectedIntegration = view?.selectedIntegration; + const model = + view && selectedMarket + ? getBorrowDetailsModel({ + balances: view.walletBalances, + borrowAmount: view.borrowAmount, + collateralAmount: view.collateralAmount, + integration: selectedIntegration ?? null, + market: selectedMarket, + projection: view.projection, + t, + }) + : null; + const loanToken = selectedMarket + ? toBorrowEntryToken({ + network: selectedMarket.network, + token: selectedMarket.loanToken, + }) + : null; + const providerName = selectedMarket + ? formatBorrowProviderName( + selectedIntegration?.name ?? selectedMarket.integrationId + ) + : null; + const metricCards = model?.metricCards ?? [ + { + id: "borrow-apy", + label: t("dashboard.borrow.details.borrow_apy"), + loading: true, + subValue: null, + value: null, + }, + { + id: "max-ltv", + label: t("dashboard.borrow.details.max_ltv"), + loading: true, + value: null, + }, + ]; + const marketRows = model?.marketRows ?? [ + { id: "total-supply", label: t("dashboard.borrow.details.total_supply") }, + { id: "total-borrow", label: t("dashboard.borrow.details.total_borrow") }, + { + id: "available-liquidity", + label: t("dashboard.borrow.details.available_liquidity"), + }, + { id: "utilization", label: t("dashboard.borrow.details.utilization") }, + ]; + const protocolRows = model?.protocolRows ?? [ + { id: "provider", label: t("dashboard.borrow.details.provider") }, + { id: "network", label: t("dashboard.borrow.details.network") }, + { id: "market-type", label: t("dashboard.borrow.details.market_type") }, + ]; return ( - - + {loanToken ? ( + + ) : ( + + )} - {model.title} + + {model?.title ?? } + - + + {providerName ? ( + + ) : ( + + )} + - {providerName} + {providerName ?? } {" · "} - {formatNetworkName(selectedMarket.network)} + {selectedMarket ? ( + formatNetworkName(selectedMarket.network) + ) : ( + + )} + ) + } /> - + - + - {selectedIntegration?.metadata.description ?? + {model && providerName ? ( + (selectedIntegration?.metadata.description ?? t("dashboard.borrow.details.about_fallback", { market: model.title, provider: providerName, - })} + })) + ) : ( + + )} - - {model.marketRows.map((row) => ( - + + {marketRows.map((row) => ( + ))} - - {model.protocolRows.map((row) => ( - + + {protocolRows.map((row) => ( + ))} - - {selectedMarket.poolAddress ? ( + {selectedMarket?.poolAddress ? ( { +export const BorrowFormDetails = ( + props: + | { readonly loading: true } + | { + readonly loading?: false; + readonly borrowAmount: BigNumber; + readonly collateralAmount: BigNumber; + readonly ltvGreaterThanMax: boolean; + readonly market: Market; + readonly projection: BorrowFormProjection; + readonly walletBalances: BorrowMarketWalletBalances | null; + } +) => { const { t } = useTranslation(); - const model = getBorrowDetailsModel({ - balances: walletBalances, - borrowAmount, - collateralAmount, - integration: null, - market, - projection, - t, - }); + const model = props.loading + ? null + : getBorrowDetailsModel({ + balances: props.walletBalances, + borrowAmount: props.borrowAmount, + collateralAmount: props.collateralAmount, + integration: null, + market: props.market, + projection: props.projection, + t, + }); + const rows = model?.formRows ?? [ + { id: "max-ltv", label: t("dashboard.borrow.details.max_ltv") }, + { + id: "collateral-value", + label: t("dashboard.borrow.form.collateral_value"), + }, + { id: "loan", label: t("dashboard.borrow.form.loan") }, + { id: "borrow-rate", label: t("dashboard.borrow.form.borrow_rate") }, + ]; return ( @@ -43,11 +52,16 @@ export const BorrowFormDetails = ({ {t("dashboard.borrow.form.details")} - {model.formRows.map((row) => ( - + {rows.map((row) => ( + ))} - {ltvGreaterThanMax ? ( + {!props.loading && props.ltvGreaterThanMax ? ( ) : ( {t("dashboard.borrow.form.ltv_note")} diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/page.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/page.tsx index 1452fe2a8..c87e44da5 100644 --- a/packages/widget/src/features/borrow/borrow-entry/ui/page.tsx +++ b/packages/widget/src/features/borrow/borrow-entry/ui/page.tsx @@ -2,7 +2,6 @@ import { useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader"; import { useTrackPage } from "../../../tracking/index"; import { ConnectButton } from "../../../wallet/views"; import { PageCtaButton } from "../../../widget-shell/views"; @@ -133,7 +132,21 @@ const BorrowFormPanel = ({ view }: { readonly view: BorrowEntryView }) => { (AsyncResult.isInitial(marketsResult) || AsyncResult.isWaiting(marketsResult)) ) { - return ; + return ( + <> + + + + + ); } if (markets.length === 0 && AsyncResult.isFailure(marketsResult)) { return ( diff --git a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx index c870d4b3d..517f4e43f 100644 --- a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx @@ -1,7 +1,9 @@ +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; import { Box } from "../../../../shared/ui/primitives/box"; import { Button } from "../../../../shared/ui/primitives/button"; +import { ContentLoaderLine } from "../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../shared/ui/primitives/typography/text"; import { type BorrowPositionAction, @@ -12,7 +14,6 @@ import { useBorrowPositionContext } from "./context"; import * as styles from "./styles.css"; export const BorrowPositionActionsPage = () => { - const { t } = useTranslation(); const navigate = useNavigate(); const { actions: positionActions, @@ -30,38 +31,94 @@ export const BorrowPositionActionsPage = () => { <> - - - {t("dashboard.borrow.position_details.actions_title")} - + + + ); +}; + +const BorrowPositionActionCard = ({ + action, + description, + label, + onClick, +}: { + readonly action?: BorrowPositionAction; + readonly description: ReactNode; + readonly label: ReactNode; + readonly onClick?: () => void; +}) => { + const { t } = useTranslation(); - {!position || actions.length === 0 ? ( - - {t("dashboard.borrow.position_details.no_actions")} - - ) : ( - actions.map((action) => ( - - - {action.label} - - {t( - `dashboard.borrow.position_details.action_descriptions.${action.type}` - )} - - - - - )) - )} + return ( + + + {label} + {description} - + + + ); +}; + +export const BorrowPositionActions = ( + props: + | { readonly loading: true } + | { + readonly loading?: false; + readonly actions: BorrowPositionAction[]; + readonly onActionSelect: (action: BorrowPositionAction) => void; + } +) => { + const { t } = useTranslation(); + + return ( + + + {t("dashboard.borrow.position_details.actions_title")} + + {props.loading && ( + // Available actions depend on the position's permissions. + } + label={} + /> + )} + {!props.loading && props.actions.length === 0 && ( + + {t("dashboard.borrow.position_details.no_actions")} + + )} + {!props.loading && + props.actions.map((action) => ( + props.onActionSelect(action)} + /> + ))} + ); }; diff --git a/packages/widget/src/features/borrow/market-position/ui/components/metric-cards.tsx b/packages/widget/src/features/borrow/market-position/ui/components/metric-cards.tsx index 211bdd731..1ffa2b26b 100644 --- a/packages/widget/src/features/borrow/market-position/ui/components/metric-cards.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/components/metric-cards.tsx @@ -1,17 +1,44 @@ import type BigNumber from "bignumber.js"; +import { useTranslation } from "react-i18next"; import { PositionMetricCards } from "../../../../../shared/ui/components/position-details"; import type { getBorrowPositionDetailsModel } from "../../model/details"; import * as styles from "../styles.css"; -export const MetricCards = ({ - cards, - healthFactor, -}: { - readonly cards: ReturnType< - typeof getBorrowPositionDetailsModel - >["metricCards"]; - readonly healthFactor: BigNumber | null | undefined; -}) => { +export const MetricCards = ( + props: + | { readonly loading: true } + | { + readonly loading?: false; + readonly cards: ReturnType< + typeof getBorrowPositionDetailsModel + >["metricCards"]; + readonly healthFactor: BigNumber | null | undefined; + } +) => { + const { t } = useTranslation(); + + if (props.loading) { + return ( + + ); + } + + const { cards, healthFactor } = props; const positionCards = cards.map((card) => { const isHealthCard = card.id === "health-factor"; const getToneClass = () => { diff --git a/packages/widget/src/features/borrow/market-position/ui/components/position-info.tsx b/packages/widget/src/features/borrow/market-position/ui/components/position-info.tsx index 69550af5a..7960a4142 100644 --- a/packages/widget/src/features/borrow/market-position/ui/components/position-info.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/components/position-info.tsx @@ -9,8 +9,15 @@ import { PositionBreakdownRows, PositionDetailsScrollArea, } from "../../../../../shared/ui/components/position-details"; -import { TokenIcon } from "../../../../../shared/ui/components/token-icon"; +import { + TokenIcon, + TokenIconSkeleton, +} from "../../../../../shared/ui/components/token-icon"; import { Box } from "../../../../../shared/ui/primitives/box"; +import { + ContentLoaderCircle, + ContentLoaderLine, +} from "../../../../../shared/ui/primitives/content-loader"; import { Image } from "../../../../../shared/ui/primitives/image"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import type { @@ -21,22 +28,25 @@ import { CollateralList } from "./collateral-list"; import { LtvGauge } from "./ltv-gauge"; import { MetricCards } from "./metric-cards"; -export const BorrowPositionInfo = ({ - actions, - content, - model, - onActionSelect, - position, -}: { - readonly actions: BorrowPositionAction[]; - readonly content: "details" | "fallback"; - readonly model: ReturnType | null; - readonly onActionSelect: (action: BorrowPositionAction) => void; - readonly position: MarketPosition | null; -}) => { +export const BorrowPositionInfo = ( + props: + | { readonly content: "loading" } + | { + readonly actions: BorrowPositionAction[]; + readonly content: "details" | "fallback"; + readonly model: ReturnType | null; + readonly onActionSelect: (action: BorrowPositionAction) => void; + readonly position: MarketPosition | null; + } +) => { const { t } = useTranslation(); + const model = props.content === "loading" ? null : props.model; + const position = props.content === "loading" ? null : props.position; - if (content === "fallback" || !position || !model) { + if ( + props.content !== "loading" && + (props.content === "fallback" || !position || !model) + ) { return ( {t("dashboard.borrow.position_details.empty")} @@ -44,67 +54,118 @@ export const BorrowPositionInfo = ({ ); } + const detailRows = model?.detailRows ?? [ + { id: "provider", label: t("dashboard.borrow.details.provider") }, + { id: "network", label: t("dashboard.borrow.details.network") }, + { id: "market-type", label: t("dashboard.borrow.details.market_type") }, + { id: "max-ltv", label: t("dashboard.borrow.details.max_ltv") }, + { + id: "liquidation-threshold", + label: t("dashboard.borrow.position_details.liquidation_threshold"), + }, + { + id: "liquidation-penalty", + label: t("dashboard.borrow.position_details.liquidation_penalty"), + }, + { id: "borrow-apy", label: t("dashboard.borrow.details.borrow_apy") }, + ]; + return ( - - + + {model ? ( + + ) : ( + + )} - {model.title} + + {model?.title ?? } + - + + {position && model ? ( + + ) : ( + + )} + - {t("positions.via", { - providerName: model.providerName, - count: 1, - })} + {model ? ( + t("positions.via", { + providerName: model.providerName, + count: 1, + }) + ) : ( + <> + {t("positions.via", { providerName: "", count: 1 })} + + + )} {" · "} - {model.marketLabel} + {model?.marketLabel ?? } - - - - - - - {model.breakdownRows.length > 0 && ( - - - + {model ? ( + + ) : ( + )} - - {model.detailRows.map((row) => ( - - ))} + {model && props.content !== "loading" ? ( + <> + + + {model.breakdownRows.length > 0 && ( + + + + )} + + ) : null} - - + {detailRows.map((row) => ( + - + ))} + + {position ? ( + + + + ) : null} ); diff --git a/packages/widget/src/features/borrow/market-position/ui/components/skeletons.tsx b/packages/widget/src/features/borrow/market-position/ui/components/skeletons.tsx deleted file mode 100644 index bf7d5f7f1..000000000 --- a/packages/widget/src/features/borrow/market-position/ui/components/skeletons.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { PositionMetricCards } from "../../../../../shared/ui/components/position-details"; -import { Box } from "../../../../../shared/ui/primitives/box"; -import { - ContentLoaderCircle, - ContentLoaderLine, -} from "../../../../../shared/ui/primitives/content-loader"; -import * as styles from "../styles.css"; - -export const BorrowPositionActionsSkeleton = () => ( - - - - - - {[0, 1].map((index) => ( - - - - - - - - - - - - - - ))} - -); - -export const BorrowPositionInfoSkeleton = () => ( - - - - - - - - - - - - - - ({ - id: String(index), - label: ( - - - - ), - value: ( - - - - ), - }))} - /> - - - - - - - - - - - - - - - - - - - - - - - - - {[0, 1, 2, 3].map((index) => ( - - - - - - - - - ))} - - -); diff --git a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx index b7077fbd0..919d7fdbd 100644 --- a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx @@ -16,12 +16,9 @@ import { makeBorrowPositionActionRouteKey, startBorrowPositionActionReviewAtom, } from "../state/action-form"; +import { BorrowPositionActions } from "./actions.page"; import { BorrowPositionBreadcrumb } from "./components/breadcrumb"; import { BorrowPositionInfo } from "./components/position-info"; -import { - BorrowPositionActionsSkeleton, - BorrowPositionInfoSkeleton, -} from "./components/skeletons"; import { type BorrowPositionContext, getBorrowPositionBasePath, @@ -78,12 +75,12 @@ export const BorrowPositionDetailsPage = () => { primary={ - + } secondary={ - + } /> @@ -93,7 +90,7 @@ export const BorrowPositionDetailsPage = () => { const rightContent = (() => { if (isPositionLoading) { - return ; + return ; } if (AsyncResult.isFailure(borrowPosition.positionResult)) { diff --git a/packages/widget/src/features/classic-transaction-flow/ui/review/pages/common-page/common.page.tsx b/packages/widget/src/features/classic-transaction-flow/ui/review/pages/common-page/common.page.tsx index e362db4fd..5ba0fb65a 100644 --- a/packages/widget/src/features/classic-transaction-flow/ui/review/pages/common-page/common.page.tsx +++ b/packages/widget/src/features/classic-transaction-flow/ui/review/pages/common-page/common.page.tsx @@ -4,7 +4,7 @@ import type { Token } from "../../../../../../domain/token/token"; import { Divider } from "../../../../../../shared/ui/components/divider"; import { ToolTip } from "../../../../../../shared/ui/components/tooltip"; import { Box } from "../../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../../shared/ui/primitives/content-loader"; +import { ContentLoaderLine } from "../../../../../../shared/ui/primitives/content-loader"; import { InfoIcon } from "../../../../../../shared/ui/primitives/icons/info"; import { Text } from "../../../../../../shared/ui/primitives/typography/text"; import { WarningBox } from "../../../../../../shared/ui/primitives/warning-box"; @@ -184,21 +184,11 @@ const GasFee = ({ marginTop="2" marginBottom="2" data-testid="estimated_gas_fee" - height="4" > {label} - {loading ? ( - - - - ) : ( - - {price} - - )} + + {loading ? : price} + ); }; diff --git a/packages/widget/src/features/earn/state/entry.ts b/packages/widget/src/features/earn/state/entry.ts index 26c9d0f3c..252daccbf 100644 --- a/packages/widget/src/features/earn/state/entry.ts +++ b/packages/widget/src/features/earn/state/entry.ts @@ -10,7 +10,7 @@ import { getYieldRewardTokens, isBittensorStaking, } from "../../../domain/earn/yield"; -import { exactDecimal } from "../../../domain/finance/exact"; +import { exactDecimal, exactZero } from "../../../domain/finance/exact"; import { getTokenPriceInUSD } from "../../../domain/finance/price"; import type { YieldId } from "../../../domain/identity/identifiers"; import { hasActivePositionForYield } from "../../../domain/portfolio/positions"; @@ -148,27 +148,29 @@ export const earnEntryViewAtom = Atom.make((get) => { cta: entry.cta, estimatedRewards: entry.estimatedRewards, footerIsLoading: input.footerIsLoading, - formattedPrice: - prices && selectedToken && selectedYield - ? formatUsd( - getTokenPriceInUSD({ - amount: entry.amount, - baseToken: selectedYield.token, - pricePerShare: null, - prices, - token: selectedToken, - }) - ) - : "", + formattedPrice: (() => { + if (entry.amount.isZero()) return formatUsd(exactZero()); + if (prices && selectedToken && selectedYield) { + return formatUsd( + getTokenPriceInUSD({ + amount: entry.amount, + baseToken: selectedYield.token, + pricePerShare: null, + prices, + token: selectedToken, + }) + ); + } + return ""; + })(), isFetching: entry.isFetching, isLedgerLiveAccountPlaceholder: entry.isLedgerAccountPlaceholder, - isStakeTokenSameAsGasToken: - selectedYield && selectedToken - ? stakeTokenSameAsGasToken({ - stakeToken: selectedToken, - yieldDto: selectedYield, - }) - : false, + isStakeTokenSameAsGasToken: selectedToken + ? stakeTokenSameAsGasToken({ + stakeToken: selectedToken, + yieldDto: selectedYield, + }) + : false, kyc: entry.kyc, pointsRewardTokens: selectedYield ? getYieldRewardTokens(selectedYield).filter((token) => token.isPoints) diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-provider/index.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-provider/index.tsx index ba51ea52d..ce4c3673c 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-provider/index.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-provider/index.tsx @@ -2,13 +2,17 @@ import { useAtomValue } from "@effect/atom-react"; import { Trigger } from "@radix-ui/react-dialog"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useTranslation } from "react-i18next"; +import type { EarnYieldWithProvider } from "../../../../../../../domain/earn/models"; import { getYieldProviderYieldIds, isYieldWithProviderOptions, } from "../../../../../../../domain/earn/yield"; import { formatUsd } from "../../../../../../../shared/lib/formatters"; import { Box } from "../../../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../../../shared/ui/primitives/content-loader"; +import { + ContentLoaderLine, + ContentLoaderSquare, +} from "../../../../../../../shared/ui/primitives/content-loader"; import { CaretDownIcon } from "../../../../../../../shared/ui/primitives/icons/caret-down"; import { Image } from "../../../../../../../shared/ui/primitives/image"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; @@ -48,8 +52,6 @@ export const SelectProvider = () => { const { selectProvider, view } = useEarnEntry(); const { appLoading, selectedProviderYieldId, selectedStake } = view; - const { t } = useTranslation(); - const providerYieldIdOptions = selectedStake && isYieldWithProviderOptions(selectedStake) ? getYieldProviderYieldIds(selectedStake) @@ -73,12 +75,8 @@ export const SelectProvider = () => { : null; const provider = selectedProviderYield?.provider; - if (appLoading) { - return ( - - - - ); + if (appLoading || (providerYieldIdOptions && !selectedProviderYield)) { + return ; } if ( !selectedStake || @@ -94,56 +92,75 @@ export const SelectProvider = () => { onItemClick={(yieldDto) => selectProvider(yieldDto.id)} providerYieldIds={providerYieldIdOptions} selectedYieldId={selectedProviderYield.id} - trigger={ - - + trigger={} + /> + ); +}; + +export const SelectProviderCard = ({ + provider, +}: { + provider?: NonNullable; +}) => { + const { t } = useTranslation(); + const tvl = provider ? getProviderTvl(provider.tvlUsd) : null; + const changeButton = ( + + {t("shared.change")} + + + ); + + return ( + + + + {provider ? ( + ) : ( + + )} + - - - {provider.name} - + + + {provider ? provider.name : } + - {getProviderTvl(provider.tvlUsd) && ( - - - TVL {getProviderTvl(provider.tvlUsd)} - - - )} - - {provider.website && ( - - {getDisplayWebsite(provider.website)} - - )} + {tvl && ( + + + TVL {tvl} + - + )} - - - {t("shared.change")} - - - + {getDisplayWebsite(provider.website)} + + )} - } - /> + + + {provider ? {changeButton} : changeButton} + ); }; diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/index.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/index.tsx index 3ef271629..37a921349 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/index.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/index.tsx @@ -4,26 +4,43 @@ import { useWidgetConfig } from "../../../../../../../features/widget-configurat import { formatNumber } from "../../../../../../../shared/lib/number-format"; import { combineRecipeWithVariant } from "../../../../../../../shared/styles/recipe-variant"; import * as AmountToggle from "../../../../../../../shared/ui/components/amount-toggle"; -import { - minMaxContainer, - priceTxt, - selectTokenBalance, - selectTokenSection, -} from "../../../../../../../shared/ui/components/amount-token-section/styles.css"; -import { MaxButton } from "../../../../../../../shared/ui/components/max-button"; -import { NumberInput } from "../../../../../../../shared/ui/components/number-input"; +import { AmountTokenSection } from "../../../../../../../shared/ui/components/amount-token-section"; +import { minMaxContainer } from "../../../../../../../shared/ui/components/amount-token-section/styles.css"; import { Box, type BoxProps, } from "../../../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; import { useEarnEntry, useEarnTokenSelection, } from "../../../../../react/use-earn-facades"; -import { SelectToken } from "./select-token"; -import { SelectTokenTitle } from "./title"; +import { SelectToken, SelectTokenTrigger } from "./select-token"; +import { SelectTokenTitle, SelectTokenTitleView } from "./title"; + +export const SelectTokenSectionSkeleton = ({ + canSelectToken = true, + sectionMarginTop = "2", +}: { + canSelectToken?: boolean; + sectionMarginTop?: BoxProps["marginTop"]; +} = {}) => { + const variant = useWidgetConfig("variant"); + return ( + } + header={ + variant === "zerion" ? ( + + + + ) : undefined + } + /> + ); +}; export const SelectTokenSection = ({ canSelectToken = true, @@ -78,6 +95,7 @@ export const SelectTokenSection = ({ stakeMaxAmount === null ? null : `${t("shared.max")} ${formatNumber(stakeMaxAmount)} ${symbol}`; + const minMaxLabel = min && max ? `${min} / ${max}` : (min ?? max); const minStakeAmount = min || max ? ( - {min && max ? `${min} / ${max}` : (min ?? max)} + {minMaxLabel} ) : null; @@ -134,98 +152,37 @@ export const SelectTokenSection = ({ const balanceContent = getBalanceContent(); return isLoading ? ( - - - + ) : ( - - {variant === "zerion" && ( - - - {minStakeAmount} - - )} - - - - - - - - - - - - {variant !== "zerion" && minStakeAmount} - - - - - {formattedPrice} - - - - - - - {balanceContent} - + accessory={} + formattedPrice={formattedPrice} + balance={balanceContent} + balanceError={errorBalance} + onMaxClick={ + isStakeTokenSameAsGasToken ? undefined : () => setMaxAmount(undefined) + } + minMaxLabel={variant === "zerion" ? undefined : minMaxLabel} + minMaxError={stakeAmountLessThanMin} + minMaxTextAlign="left" + header={ + variant === "zerion" ? ( + + + {minStakeAmount} - - {!isStakeTokenSameAsGasToken && ( - setMaxAmount(undefined)} /> - )} - - - + ) : undefined + } + /> ); }; diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/select-token.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/select-token.tsx index f137589f2..a9dc8feaa 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/select-token.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/select-token.tsx @@ -1,18 +1,29 @@ import { Trigger } from "@radix-ui/react-dialog"; import clsx from "clsx"; +import { forwardRef } from "react"; import { useTranslation } from "react-i18next"; -import { equalTokens } from "../../../../../../../domain/token/token"; +import { + equalTokens, + type Token, +} from "../../../../../../../domain/token/token"; import { useWidgetConfig } from "../../../../../../../features/widget-configuration/index"; import { combineRecipeWithVariant } from "../../../../../../../shared/styles/recipe-variant"; import { SelectModal } from "../../../../../../../shared/ui/components/select-modal"; import { SelectedToken } from "../../../../../../../shared/ui/components/selected-token"; -import { TokenIcon } from "../../../../../../../shared/ui/components/token-icon"; +import { + TokenIcon, + TokenIconSkeleton, +} from "../../../../../../../shared/ui/components/token-icon"; import { VirtualList } from "../../../../../../../shared/ui/components/virtual-list"; -import { Box } from "../../../../../../../shared/ui/primitives/box"; +import { + Box, + type BoxProps, +} from "../../../../../../../shared/ui/primitives/box"; import { pressAnimation, selectTokenButton, } from "../../../../../../../shared/ui/primitives/button/styles.css"; +import { ContentLoaderLine } from "../../../../../../../shared/ui/primitives/content-loader"; import { CaretDownIcon } from "../../../../../../../shared/ui/primitives/icons/caret-down"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; import { useTrackEvent } from "../../../../../../tracking/index"; @@ -27,8 +38,6 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { const { select, setSearch, view } = useEarnTokenSelection(); const { view: entry } = useEarnEntry(); - const variant = useWidgetConfig("variant"); - const trackEvent = useTrackEvent(); const { t } = useTranslation(); @@ -52,34 +61,7 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { onOpen={() => trackEvent("selectTokenModalOpened")} trigger={ - - - - {data.st.symbol} - - - + } > @@ -108,3 +90,65 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { ); }; + +export const SelectTokenTrigger = forwardRef< + unknown, + BoxProps & { canSelect?: boolean; token?: Token } +>(({ canSelect = true, token, ...buttonProps }, ref) => { + const variant = useWidgetConfig("variant"); + + if (!canSelect) { + return token ? : ; + } + + return ( + + {token ? ( + <> + + + {token.symbol} + + + + ) : ( + <> + + + + + + + )} + + ); +}); diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/title.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/title.tsx index b561fd77e..890827b70 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/title.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-token-section/title.tsx @@ -27,6 +27,16 @@ export const SelectTokenTitle = () => { ? getYieldTypeLabels(entry.selectedStake, t).title : ""; + return ; +}; + +export const SelectTokenTitleView = ({ + isLoading, + title, +}: { + isLoading: boolean; + title?: string; +}) => { const variant = useWidgetConfig("variant"); return ( @@ -42,7 +52,7 @@ export const SelectTokenTitle = () => { variant, })} > - {yieldType} + {title} )} diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/index.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/index.tsx index 0849db9d2..4ed2f974f 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/index.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/index.tsx @@ -1,10 +1,11 @@ import { motion } from "motion/react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useWidgetConfig } from "../../../../../../../features/widget-configuration/index"; import { combineRecipeWithVariant } from "../../../../../../../shared/styles/recipe-variant"; import { Divider } from "../../../../../../../shared/ui/components/divider"; import { Box } from "../../../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../../../shared/ui/primitives/content-loader"; +import { ContentLoaderLine } from "../../../../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; import { YieldRiskRatingSummary } from "../../../../../../yield-summary/views"; import { @@ -12,8 +13,14 @@ import { useEarnYieldSelection, } from "../../../../../react/use-earn-facades"; import { apyYield } from "../../styles.css"; -import { SelectOpportunity } from "./select-opportunity"; -import { SelectYieldRewardDetails } from "./select-yield-reward-details"; +import { + SelectOpportunity, + SelectOpportunityTrigger, +} from "./select-opportunity"; +import { + SelectYieldRewardDetails, + SelectYieldRewardDetailsSkeleton, +} from "./select-yield-reward-details"; import { selectYieldSection } from "./styles.css"; import { useAnimateYieldPercent } from "./use-animated-yield-percent-boundary"; @@ -21,9 +28,6 @@ export const SelectYieldSection = () => { const { view: entry } = useEarnEntry(); const { view: yieldSelection } = useEarnYieldSelection(); - const dashboardVariant = useWidgetConfig("dashboardVariant"); - const variant = useWidgetConfig("variant"); - const { t } = useTranslation(); const isLoading = entry.appLoading || yieldSelection.isLoading; @@ -32,19 +36,10 @@ export const SelectYieldSection = () => { const riskSummary = entry.selectedStake ? ( ) : null; - const showSectionTitle = - !dashboardVariant && - variant !== "zerion" && - variant !== "utila" && - variant !== "porto"; const opportunityCount = yieldSelection.all.length; if (isLoading) { - return ( - - - - ); + return ; } if (opportunityCount === 0) { @@ -55,6 +50,44 @@ export const SelectYieldSection = () => { ); } + return ( + {yieldPerc}} + opportunity={} + rewardDetails={} + riskSummary={riskSummary} + /> + ); +}; + +export const SelectYieldSectionSkeleton = () => ( + } + opportunity={} + rewardDetails={} + /> +); + +const SelectYieldSectionLayout = ({ + rewardPercent, + opportunity, + rewardDetails, + riskSummary, +}: { + rewardPercent: ReactNode; + opportunity: ReactNode; + rewardDetails: ReactNode; + riskSummary?: ReactNode; +}) => { + const { t } = useTranslation(); + const dashboardVariant = useWidgetConfig("dashboardVariant"); + const variant = useWidgetConfig("variant"); + const showSectionTitle = + !dashboardVariant && + variant !== "zerion" && + variant !== "utila" && + variant !== "porto"; + return ( {showSectionTitle && ( @@ -83,23 +116,25 @@ export const SelectYieldSection = () => { - {yieldPerc} + + {rewardPercent} + - + {opportunity} - {variant !== "zerion" && } + {variant !== "zerion" && rewardDetails} {variant !== "zerion" && !dashboardVariant && riskSummary} {variant === "zerion" && ( - + {rewardDetails} {!dashboardVariant && riskSummary} diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-opportunity.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-opportunity.tsx index 246a1cfcd..38ced7f7c 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-opportunity.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-opportunity.tsx @@ -1,6 +1,7 @@ import { Trigger } from "@radix-ui/react-dialog"; import clsx from "clsx"; import { Array as EArray, Option } from "effect"; +import { type ComponentProps, forwardRef } from "react"; import { useTranslation } from "react-i18next"; import { getYieldOutputToken, @@ -13,10 +14,15 @@ import { SelectModalItemContainer, } from "../../../../../../../shared/ui/components/select-modal"; import { selectModalGroupLabel } from "../../../../../../../shared/ui/components/select-modal/styles.css"; +import { TokenIconSkeleton } from "../../../../../../../shared/ui/components/token-icon"; import { ProviderIcon } from "../../../../../../../shared/ui/components/token-icon/provider-icon"; import { GroupedVirtualList } from "../../../../../../../shared/ui/components/virtual-list"; -import { Box } from "../../../../../../../shared/ui/primitives/box"; +import { + Box, + type BoxProps, +} from "../../../../../../../shared/ui/primitives/box"; import { pressAnimation } from "../../../../../../../shared/ui/primitives/button/styles.css"; +import { ContentLoaderLine } from "../../../../../../../shared/ui/primitives/content-loader"; import { CaretDownIcon } from "../../../../../../../shared/ui/primitives/icons/caret-down"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; import { useTrackEvent } from "../../../../../../tracking/index"; @@ -45,8 +51,6 @@ export const SelectOpportunity = () => { } : null; - const variant = useWidgetConfig("variant"); - if (!data) return null; const displayToken = getYieldOutputToken(data.ss) ?? data.ss.token; @@ -60,35 +64,16 @@ export const SelectOpportunity = () => { onOpen={() => trackEvent("selectYieldModalOpened")} trigger={ - - - - {displayToken.symbol} - - - + } > @@ -148,3 +133,55 @@ export const SelectOpportunity = () => { ); }; + +export const SelectOpportunityTrigger = forwardRef< + unknown, + BoxProps & { icon?: ComponentProps } +>(({ icon, ...buttonProps }, ref) => { + const variant = useWidgetConfig("variant"); + + return ( + + {icon ? ( + <> + + + {icon.token.symbol} + + + + ) : ( + <> + + + + + + + )} + + ); +}); diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-yield-reward-details.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-yield-reward-details.tsx index ae05836f7..6d5a634b5 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-yield-reward-details.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/components/select-yield-section/select-yield-reward-details.tsx @@ -1,3 +1,4 @@ +import type { PropsWithChildren } from "react"; import { Trans, useTranslation } from "react-i18next"; import type { EarnValidator } from "../../../../../../../domain/earn/models"; import { getEffectiveYieldRewardRateDetails } from "../../../../../../../domain/earn/reward-rate"; @@ -11,6 +12,7 @@ import { useWidgetConfig } from "../../../../../../../features/widget-configurat import { formatNumber } from "../../../../../../../shared/lib/number-format"; import { Divider } from "../../../../../../../shared/ui/components/divider"; import { Box } from "../../../../../../../shared/ui/primitives/box"; +import { ContentLoaderLine } from "../../../../../../../shared/ui/primitives/content-loader"; import { MorphoStarsIcon } from "../../../../../../../shared/ui/primitives/icons/morpho-stars"; import { Image } from "../../../../../../../shared/ui/primitives/image"; import { Text } from "../../../../../../../shared/ui/primitives/typography/text"; @@ -29,6 +31,40 @@ type StrategyProvider = { name: string; }; +const YieldRewardDetailsLayout = ({ children }: PropsWithChildren) => ( + + + {children} + + +); + +export const SelectYieldRewardDetailsSkeleton = () => { + const dashboardVariant = useWidgetConfig("dashboardVariant"); + const variant = useWidgetConfig("variant"); + + return ( + + {variant !== "zerion" && dashboardVariant && ( + <> + + + + + + + )} + + + ); +}; + export const SelectYieldRewardDetails = () => { const dashboardVariant = useWidgetConfig("dashboardVariant"); const variant = useWidgetConfig("variant"); @@ -109,89 +145,87 @@ export const SelectYieldRewardDetails = () => { : null; return ( - - - {showYieldStrategyDetails && ( - <> - {strategyDetails && - (dashboardVariant || strategyDetails.outputToken) ? ( - - ) : null} + + {showYieldStrategyDetails && ( + <> + {strategyDetails && + (dashboardVariant || strategyDetails.outputToken) ? ( + + ) : null} - {dashboardVariant && } - - )} + {dashboardVariant && } + + )} + + {variant === "zerion" && rewardToken ? ( + + + + {getRewardTokenSymbols(rewardToken.rewardTokens)} + + ), + }} + /> + - {variant === "zerion" && rewardToken ? ( + {rewardToken.logoUri && ( + + + + {isMorphoProvider(rewardToken.providerName) && ( + + + + )} + + )} - - {getRewardTokenSymbols(rewardToken.rewardTokens)} - - ), - }} - /> + {rewardToken.providerName} - - - {rewardToken.logoUri && ( - - - - {isMorphoProvider(rewardToken.providerName) && ( - - - - )} - - )} - - {rewardToken.providerName} - - - ) : null} + + ) : null} - + - {rewardRateDetails ? ( - - ) : null} - - + {rewardRateDetails ? ( + + ) : null} + ); }; diff --git a/packages/widget/src/features/earn/ui/classic/earn-page/earn.page.tsx b/packages/widget/src/features/earn/ui/classic/earn-page/earn.page.tsx index 2dc2f4dc2..018247621 100644 --- a/packages/widget/src/features/earn/ui/classic/earn-page/earn.page.tsx +++ b/packages/widget/src/features/earn/ui/classic/earn-page/earn.page.tsx @@ -3,8 +3,6 @@ import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useWidgetConfig } from "../../../../../features/widget-configuration/index"; import { Box } from "../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../shared/ui/primitives/content-loader"; -import { Spinner } from "../../../../../shared/ui/primitives/spinner"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import { useMountAnimation } from "../../../../mount-animation/index"; import { useTrackPage } from "../../../../tracking/index"; @@ -14,16 +12,29 @@ import { type PageCta, PageCtaButton, } from "../../../../widget-shell/views"; +import { MetaInfoSkeleton } from "../../../../yield-summary/views"; import { useEarnPageStatus } from "../../../react/use-earn-facades"; import { EarnKycGate } from "../../components/earn-kyc-gate"; import { EarnPageCta } from "../../components/earn-page-cta"; import { ExtraArgsSelection } from "./components/extra-args-selection"; import { Footer } from "./components/footer"; -import { SelectProvider } from "./components/select-provider"; -import { SelectTokenSection } from "./components/select-token-section"; -import { SelectTokenTitle } from "./components/select-token-section/title"; +import { + SelectProvider, + SelectProviderCard, +} from "./components/select-provider"; +import { + SelectTokenSection, + SelectTokenSectionSkeleton, +} from "./components/select-token-section"; +import { + SelectTokenTitle, + SelectTokenTitleView, +} from "./components/select-token-section/title"; import { SelectValidatorSection } from "./components/select-validator-section"; -import { SelectYieldSection } from "./components/select-yield-section"; +import { + SelectYieldSection, + SelectYieldSectionSkeleton, +} from "./components/select-yield-section"; const hiddenLivePresentationStyle = { inset: 0, @@ -46,27 +57,17 @@ const EarnPageSkeleton = () => { return ( + + + {item.pointsRewardTokenBalances.length > 0 && ( + + {item.pointsRewardTokenBalances.map((val, i) => ( + + + + + {val.amount} + + + ))} + + )} + ); diff --git a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx index c2a75ac1c..d97950200 100644 --- a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx +++ b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx @@ -2,10 +2,13 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; import type { MarketPosition } from "../../../../../../domain/borrow/positions/market-position"; import type { PositionDetailsLabelType } from "../../../../../../domain/portfolio/positions"; -import { TokenIcon } from "../../../../../../shared/ui/components/token-icon"; +import { + TokenIcon, + TokenIconSkeleton, +} from "../../../../../../shared/ui/components/token-icon"; import { ToolTip } from "../../../../../../shared/ui/components/tooltip"; import { Box } from "../../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../../shared/ui/primitives/content-loader"; +import { ContentLoaderLine } from "../../../../../../shared/ui/primitives/content-loader"; import { SKLink } from "../../../../../../shared/ui/primitives/link"; import { ListItem } from "../../../../../../shared/ui/primitives/list/list-item"; import { Spinner } from "../../../../../../shared/ui/primitives/spinner"; @@ -132,24 +135,25 @@ const EarnPositionsListItem = ({ viewTransition > - {integrationData ? ( - + + + {/* Yield */} - {/* Yield */} - - {item.token ? ( + {!integrationData && } + {integrationData && + (item.token ? ( - )} - - - - - {integrationData.metadata.name} - + ))} - {item.yieldLabelDto ? ( - - | undefined - )} - > - - - {t( - `position_details.labels.${item.yieldLabelDto.type as PositionDetailsLabelType}.label` - )} - - - - ) : null} + + + + {integrationData ? ( + integrationData.metadata.name + ) : ( + + )} + - {(item.actionRequired || - item.hasPendingClaimRewards || - !!inactiveValidator) && ( + {item.yieldLabelDto ? ( + + | undefined + )} + > - {actionBadgeLabel} + {t( + `position_details.labels.${item.yieldLabelDto.type as PositionDetailsLabelType}.label` + )} - )} - + + ) : null} - {providersDetails?.[0] ? ( - - {t("positions.via", { - providerName: - providersDetails[0].name ?? - providersDetails[0].address, - count: Math.max(providersDetails.length - 1, 1), + {(item.actionRequired || + item.hasPendingClaimRewards || + !!inactiveValidator) && ( + - ) : null} + > + + {actionBadgeLabel} + + + )} - - - {/* Reward rate + staked */} - - {rewardRateAverage ? ( - {rewardRateAverage} - ) : null} - {totalAmountFormatted && item.token ? ( - - - {totalAmountFormatted} {item.token.symbol} - - - {totalAmountPriceFormatted ? ( - - ≈ ${totalAmountPriceFormatted} - - ) : null} - - ) : ( - - - )} + {t("positions.via", { + providerName: + providersDetails[0].name ?? providersDetails[0].address, + count: Math.max(providersDetails.length - 1, 1), + })} + + ) : null} - {item.pointsRewardTokenBalances.length > 0 && ( - - {item.pointsRewardTokenBalances.map((val, i) => ( - - + {/* Reward rate + staked */} + + {!integrationData || rewardRateAverage ? ( + + {integrationData ? ( + rewardRateAverage + ) : ( + + )} + + ) : null} + {!integrationData || (totalAmountFormatted && item.token) ? ( + + + {integrationData ? ( + <> + {totalAmountFormatted} {item.token?.symbol} + + ) : ( + + )} + + + {!integrationData || totalAmountPriceFormatted ? ( - {val.amount} + {integrationData ? ( + <>≈ ${totalAmountPriceFormatted} + ) : ( + + )} - - ))} - - )} - - ) : ( - - )} + ) : null} + + ) : ( + - + )} + + + + {item.pointsRewardTokenBalances.length > 0 && ( + + {item.pointsRewardTokenBalances.map((val, i) => ( + + + + + {val.amount} + + + ))} + + )} + ); diff --git a/packages/widget/src/features/position-details/state/dashboard-stake-facade.ts b/packages/widget/src/features/position-details/state/dashboard-stake-facade.ts index 8888fd74d..791a73b03 100644 --- a/packages/widget/src/features/position-details/state/dashboard-stake-facade.ts +++ b/packages/widget/src/features/position-details/state/dashboard-stake-facade.ts @@ -253,26 +253,28 @@ const positionDetailsStakeFacadeAtom = Atom.family( cta, estimatedRewards: entry.estimatedRewards, footerIsLoading: input.isFetching, - formattedPrice: - input.prices && selectedYield && selectedToken - ? formatUsd( - getTokenPriceInUSD({ - amount: entry.amount, - baseToken: selectedYield.token, - pricePerShare: null, - prices: input.prices, - token: selectedToken, - }) - ) - : "", - isFetching: input.isFetching, - isStakeTokenSameAsGasToken: - selectedYield && selectedToken - ? stakeTokenSameAsGasToken({ - stakeToken: selectedToken, - yieldDto: selectedYield, + formattedPrice: (() => { + if (entry.amount.isZero()) return formatUsd(exactZero()); + if (input.prices && selectedYield && selectedToken) { + return formatUsd( + getTokenPriceInUSD({ + amount: entry.amount, + baseToken: selectedYield.token, + pricePerShare: null, + prices: input.prices, + token: selectedToken, }) - : false, + ); + } + return ""; + })(), + isFetching: input.isFetching, + isStakeTokenSameAsGasToken: selectedToken + ? stakeTokenSameAsGasToken({ + stakeToken: selectedToken, + yieldDto: selectedYield, + }) + : false, kyc: entry.kyc, ownerCurrent: input.ownerCurrent, preparation: entry.preparation, diff --git a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-info.tsx b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-info.tsx index 37a32d77f..aa3c36072 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-info.tsx +++ b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-info.tsx @@ -56,6 +56,7 @@ export const PositionDetailsInfo = () => { ); if (positionDetails.isLoading) { + // Balances and integration metadata determine the metrics and detail rows. return ; } diff --git a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx index 1047240e9..a1e613ccd 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx +++ b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx @@ -10,7 +10,6 @@ import { AmountTokenSection } from "../../../../../shared/ui/components/amount-t import { Dropdown } from "../../../../../shared/ui/components/dropdown"; import { SelectedToken } from "../../../../../shared/ui/components/selected-token"; import { Box } from "../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import { type PageCta, PageCtaButton } from "../../../../widget-shell/views"; import { KycGateCard, MetaInfo } from "../../../../yield-summary/views"; @@ -73,9 +72,12 @@ const PositionDetailsStakeTokenSection = ({ if (isLoading) { return ( - - - + } + marginTop="0" + showMaxButton={!stake.isStakeTokenSameAsGasToken} + /> ); } diff --git a/packages/widget/src/features/yield-summary/ui/components/estimated-reward-amounts/index.tsx b/packages/widget/src/features/yield-summary/ui/components/estimated-reward-amounts/index.tsx index e6592b068..377dcf91f 100644 --- a/packages/widget/src/features/yield-summary/ui/components/estimated-reward-amounts/index.tsx +++ b/packages/widget/src/features/yield-summary/ui/components/estimated-reward-amounts/index.tsx @@ -1,45 +1,39 @@ import clsx from "clsx"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useWidgetConfig } from "../../../../../features/widget-configuration/index"; import { combineRecipeWithVariant } from "../../../../../shared/styles/recipe-variant"; import { VerticalDivider } from "../../../../../shared/ui/components/divider"; import { Box } from "../../../../../shared/ui/primitives/box"; +import { ContentLoaderLine } from "../../../../../shared/ui/primitives/content-loader"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import { selectYieldRewardsText } from "./styles.css"; -type EstimatedRewardAmountsProps = { - earnYearly: string; - earnMonthly: string; -}; +type EstimatedRewardAmountsProps = + | { + loading: true; + earnYearly?: ReactNode; + earnMonthly?: ReactNode; + } + | { + loading?: false; + earnYearly: ReactNode; + earnMonthly: ReactNode; + }; -export const EstimatedRewardAmounts = ({ - earnYearly, - earnMonthly, -}: EstimatedRewardAmountsProps) => { +export const EstimatedRewardAmounts = (props: EstimatedRewardAmountsProps) => { const dashboardVariant = useWidgetConfig("dashboardVariant"); const variant = useWidgetConfig("variant"); if (dashboardVariant || variant === "utila" || variant === "porto") { - return ( - - ); + return ; } - return ( - - ); + return ; }; -const DefaultEarnYearlyOrMonthly = ({ - earnMonthly, - earnYearly, -}: EstimatedRewardAmountsProps) => { +const DefaultEarnYearlyOrMonthly = (props: EstimatedRewardAmountsProps) => { + const { earnMonthly, earnYearly, loading = false } = props; const { t } = useTranslation(); const variant = useWidgetConfig("variant"); @@ -64,17 +58,21 @@ const DefaultEarnYearlyOrMonthly = ({ > {t(variant === "zerion" ? "details.rewards.yearly" : "shared.yearly")} - - {earnYearly} - + {loading ? ( + + ) : ( + + {earnYearly} + + )} {t("shared.monthly")} - - {earnMonthly} - + {loading ? ( + + ) : ( + + {earnMonthly} + + )} ); }; -const CompactEarnYearlyOrMonthly = ({ - earnMonthly, - earnYearly, -}: EstimatedRewardAmountsProps) => { +const CompactEarnYearlyOrMonthly = (props: EstimatedRewardAmountsProps) => { + const { earnMonthly, earnYearly, loading = false } = props; const { t } = useTranslation(); + if (loading) { + return ( + + + + + + + + + + + + ); + } + return ( diff --git a/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx b/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx index 2e92998c2..03e8dd5df 100644 --- a/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx +++ b/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx @@ -6,7 +6,10 @@ import type { import type { ValidatorKey } from "../../../../../domain/earn/validator"; import type { Token } from "../../../../../domain/token/token"; import { Box } from "../../../../../shared/ui/primitives/box"; -import { ContentLoaderSquare } from "../../../../../shared/ui/primitives/content-loader"; +import { + ContentLoaderLine, + ContentLoaderSquare, +} from "../../../../../shared/ui/primitives/content-loader"; import { ArrowsLeftRightIcon } from "../../../../../shared/ui/primitives/icons/arrows-left-right"; import { ClockClockWiseIcon } from "../../../../../shared/ui/primitives/icons/clock-clock-wise"; import { GifIcon } from "../../../../../shared/ui/primitives/icons/gift"; @@ -14,7 +17,6 @@ import { InfoIcon } from "../../../../../shared/ui/primitives/icons/info"; import type { TextVariants } from "../../../../../shared/ui/primitives/typography/styles.css"; import { Text } from "../../../../../shared/ui/primitives/typography/text"; import { useYieldMetaInfo } from "../../../react/use-yield-meta-info"; -import { dotContainer, dotText } from "./styles.css"; type MetaInfoTextSize = NonNullable["size"]>; @@ -76,33 +78,49 @@ export const MetaInfo = ({ ); return isLoading ? ( - + ) : ( - - {items.map((item, i) => ( - - - {item.icon ? ( - item.icon - ) : ( - - - {"\u2B24"} - - - )} - - - - - {item.text} - - - - ))} - + ); }; + +const loadingRows = ["90%", "75%", "60%"].map((width) => ({ + icon: , + text: , +})); + +export const MetaInfoSkeleton = ({ + textSize, +}: { + textSize?: MetaInfoTextSize; +}) => ; + +const MetaInfoRows = ({ + items, + textSize, +}: { + items: ReadonlyArray<{ icon: ReactNode; text: ReactNode }>; + textSize?: MetaInfoTextSize; +}) => ( + + {items.map((item, i) => ( + + + {item.icon} + + + + + {item.text} + + + + ))} + +); diff --git a/packages/widget/src/features/yield-summary/ui/components/meta-info/styles.css.ts b/packages/widget/src/features/yield-summary/ui/components/meta-info/styles.css.ts deleted file mode 100644 index 21a90da1b..000000000 --- a/packages/widget/src/features/yield-summary/ui/components/meta-info/styles.css.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { style } from "@vanilla-extract/css"; - -export const dotContainer = style({ - width: "16px", - height: "16px", - textAlign: "center", -}); - -export const dotText = style({ - fontSize: "7px", -}); diff --git a/packages/widget/src/features/yield-summary/views.ts b/packages/widget/src/features/yield-summary/views.ts index 9d52cb64d..f216de231 100644 --- a/packages/widget/src/features/yield-summary/views.ts +++ b/packages/widget/src/features/yield-summary/views.ts @@ -1,7 +1,7 @@ export { useYieldMetaInfo } from "./react/use-yield-meta-info"; export { EstimatedRewardAmounts } from "./ui/components/estimated-reward-amounts"; export { KycGateCard } from "./ui/components/kyc-gate-card"; -export { MetaInfo } from "./ui/components/meta-info"; +export { MetaInfo, MetaInfoSkeleton } from "./ui/components/meta-info"; export { RewardRateBreakdown } from "./ui/components/reward-rate-breakdown"; export { isMorphoProvider, diff --git a/packages/widget/src/shared/ui/components/amount-token-section/index.tsx b/packages/widget/src/shared/ui/components/amount-token-section/index.tsx index a49574855..448d5bb60 100644 --- a/packages/widget/src/shared/ui/components/amount-token-section/index.tsx +++ b/packages/widget/src/shared/ui/components/amount-token-section/index.tsx @@ -1,10 +1,13 @@ import type { ReactNode } from "react"; +import { exactZero } from "../../../../domain/finance/exact"; import { combineRecipeWithVariant } from "../../../styles/recipe-variant"; import { Box, type BoxProps } from "../../primitives/box"; +import { ContentLoaderLine } from "../../primitives/content-loader"; import { Text } from "../../primitives/typography/text"; import { useWidgetPresentation } from "../../widget-presentation"; import { MaxButton } from "../max-button"; import { NumberInput, type NumberInputProps } from "../number-input"; +import * as inputStyles from "../number-input/styles.css"; import { amountTokenSection, minMaxContainer, @@ -13,19 +16,18 @@ import { } from "./styles.css"; type AmountTokenSectionProps = { - readonly value: NumberInputProps["value"]; - readonly onChange: NumberInputProps["onChange"]; readonly disabled?: boolean; readonly isInvalid?: boolean; /** Right-hand control: selected token chip, token picker, or small CTA. */ readonly accessory: ReactNode; - readonly formattedPrice: string; readonly balance?: ReactNode; readonly balanceError?: boolean; readonly onMaxClick?: (() => void) | null; readonly maxButtonProps?: Pick; readonly minMaxLabel?: string | null; readonly minMaxError?: boolean; + readonly minMaxTextAlign?: BoxProps["textAlign"]; + readonly showMaxButton?: boolean; readonly state?: "default" | "danger"; /** Stake/unstake token border vs muted action-card border. */ readonly tone?: "stake" | "action"; @@ -33,11 +35,28 @@ type AmountTokenSectionProps = { readonly dataRk?: string; readonly header?: ReactNode; readonly children?: ReactNode; -}; +} & ( + | { + readonly loading: true; + readonly value?: never; + readonly onChange?: never; + readonly formattedPrice?: never; + } + | { + readonly loading?: false; + readonly value: NumberInputProps["value"]; + readonly onChange: NumberInputProps["onChange"]; + readonly formattedPrice: ReactNode; + } +); + +const loadingAmount = exactZero(); +const ignoreAmountChange = () => undefined; export const AmountTokenSection = ({ - value, - onChange, + loading = false, + value = loadingAmount, + onChange = ignoreAmountChange, disabled, isInvalid, accessory, @@ -48,6 +67,8 @@ export const AmountTokenSection = ({ maxButtonProps, minMaxLabel, minMaxError = false, + minMaxTextAlign = "right", + showMaxButton = true, state = "default", tone = "stake", marginTop, @@ -84,18 +105,31 @@ export const AmountTokenSection = ({ py="4" px="4" data-rk={dataRk} + data-testid={dataRk} + aria-busy={loading || undefined} > {header} - + {loading ? ( + + + + + + ) : ( + + )} @@ -109,11 +143,17 @@ export const AmountTokenSection = ({ rec: minMaxContainer, variant, })} + style={ + minMaxTextAlign === "left" + ? { justifyContent: "flex-start" } + : undefined + } data-rk="stake-token-section-min-max" > {minMaxLabel} @@ -137,37 +177,51 @@ export const AmountTokenSection = ({ variant, })} > - {formattedPrice} + {loading || !formattedPrice ? ( + + ) : ( + formattedPrice + )} - - {balance ? ( - - {balance} - - ) : null} - + {loading ? ( + + ) : ( + <> + + {balance ? ( + + {balance} + + ) : null} + - {onMaxClick ? ( - - ) : null} + {showMaxButton && onMaxClick ? ( + + ) : null} + + )} diff --git a/packages/widget/src/shared/ui/components/amount-token-section/styles.css.ts b/packages/widget/src/shared/ui/components/amount-token-section/styles.css.ts index 6c0f438a3..28a304652 100644 --- a/packages/widget/src/shared/ui/components/amount-token-section/styles.css.ts +++ b/packages/widget/src/shared/ui/components/amount-token-section/styles.css.ts @@ -105,8 +105,6 @@ export const amountTokenSection = recipe({ ], }); -export const selectTokenSection = amountTokenSection; - export const selectTokenBalance = recipe({ variants: { variant: { diff --git a/packages/widget/src/shared/ui/components/details-section/index.tsx b/packages/widget/src/shared/ui/components/details-section/index.tsx index 80527d5ec..9f4897f83 100644 --- a/packages/widget/src/shared/ui/components/details-section/index.tsx +++ b/packages/widget/src/shared/ui/components/details-section/index.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { formatAddress } from "../../../lib/general"; import { Box } from "../../primitives/box"; +import { ContentLoaderLine } from "../../primitives/content-loader"; import * as CopyText from "../../primitives/copy-text"; import { Text } from "../../primitives/typography/text"; import { @@ -14,6 +15,7 @@ import * as styles from "./styles.css"; type DetailRowProps = Readonly<{ readonly id?: string; readonly label: string; + readonly loading?: boolean; readonly value: ReactNode; }>; @@ -25,12 +27,14 @@ type AddressRowProps = Readonly<{ export const DetailsSection = ({ children, title, + loading, }: { children: ReactNode; title: string; + loading?: boolean; }) => ( - + {title} - + @@ -48,28 +57,32 @@ export const DetailsSection = ({ ); -export const DetailRow = ({ label, value }: DetailRowProps) => ( - - - {label} - - {typeof value === "string" ? ( +export const DetailRow = ({ label, loading, value }: DetailRowProps) => { + const content = loading ? : value; + + return ( + - {value} + {label} - ) : ( - {value} - )} - -); + {loading || typeof value === "string" ? ( + + {content} + + ) : ( + {value} + )} + + ); +}; export const AddressRow = ({ address, label }: AddressRowProps) => ( diff --git a/packages/widget/src/shared/ui/components/max-button/index.tsx b/packages/widget/src/shared/ui/components/max-button/index.tsx index c38dea8b2..4e84fccc6 100644 --- a/packages/widget/src/shared/ui/components/max-button/index.tsx +++ b/packages/widget/src/shared/ui/components/max-button/index.tsx @@ -4,31 +4,39 @@ import { useTranslation } from "react-i18next"; import { combineRecipeWithVariant } from "../../../styles/recipe-variant"; import { Box, type BoxProps } from "../../primitives/box"; import { pressAnimation } from "../../primitives/button/styles.css"; +import { ContentLoaderLine } from "../../primitives/content-loader"; import { Text } from "../../primitives/typography/text"; import { useWidgetPresentation } from "../../widget-presentation"; import { container, text } from "./styles.css"; type MaxButtonProps = PropsWithChildren<{ onMaxClick: () => void; + loading?: boolean; }> & BoxProps; export const MaxButton = ({ onMaxClick, className, + loading = false, + disabled, ...rest }: MaxButtonProps) => { const { t } = useTranslation(); const { variant } = useWidgetPresentation(); + const isDisabled = disabled || loading; return ( - {t("shared.max")} + {loading ? : t("shared.max")} ); diff --git a/packages/widget/src/shared/ui/components/position-details/index.tsx b/packages/widget/src/shared/ui/components/position-details/index.tsx index d6a6736f7..63dd2361f 100644 --- a/packages/widget/src/shared/ui/components/position-details/index.tsx +++ b/packages/widget/src/shared/ui/components/position-details/index.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import type { ReactNode } from "react"; import { Box } from "../../primitives/box"; +import { ContentLoaderLine } from "../../primitives/content-loader"; import { Text } from "../../primitives/typography/text"; import * as styles from "./styles.css"; @@ -9,6 +10,7 @@ type PositionMetricTone = "action" | "claim" | "default"; export type PositionMetricCard = Readonly<{ readonly id: string; readonly label: ReactNode; + readonly loading?: boolean; readonly subValue?: ReactNode; readonly tone?: PositionMetricTone; readonly value: ReactNode; @@ -94,6 +96,11 @@ export const PositionMetricCards = ({ {cards.map((card) => { const tone = card.tone ?? "default"; + const value = card.loading ? ( + + ) : ( + card.value + ); return ( {typeof card.label === "string" ? ( - {card.value} + {value} ) : ( card.value diff --git a/packages/widget/src/shared/ui/components/selected-token/index.tsx b/packages/widget/src/shared/ui/components/selected-token/index.tsx index 8ff1202cb..663d10a26 100644 --- a/packages/widget/src/shared/ui/components/selected-token/index.tsx +++ b/packages/widget/src/shared/ui/components/selected-token/index.tsx @@ -2,12 +2,21 @@ import type { Token } from "../../../../domain/token/token"; import { combineRecipeWithVariant } from "../../../styles/recipe-variant"; import { Box } from "../../primitives/box"; import { selectTokenButton } from "../../primitives/button/styles.css"; +import { ContentLoaderLine } from "../../primitives/content-loader"; import { Text } from "../../primitives/typography/text"; import { useWidgetPresentation } from "../../widget-presentation"; -import { TokenIcon } from "../token-icon"; +import { TokenIcon, TokenIconSkeleton } from "../token-icon"; /** Non-interactive selected-token chip shown beside amount inputs. */ -export const SelectedToken = ({ token }: { readonly token: Token }) => { +export const SelectedToken = ({ + loading, + token, +}: + | { readonly loading: true; readonly token?: never } + | { + readonly loading?: false; + readonly token: Token; + }) => { const { variant } = useWidgetPresentation(); return ( @@ -25,8 +34,10 @@ export const SelectedToken = ({ token }: { readonly token: Token }) => { rec: selectTokenButton, })} > - - {token.symbol} + {loading ? : } + + {loading ? : token.symbol} + ); }; diff --git a/packages/widget/src/shared/ui/components/token-icon/index.tsx b/packages/widget/src/shared/ui/components/token-icon/index.tsx index 1b0434baf..6ca680eb2 100644 --- a/packages/widget/src/shared/ui/components/token-icon/index.tsx +++ b/packages/widget/src/shared/ui/components/token-icon/index.tsx @@ -3,7 +3,7 @@ import type { Token } from "../../../../domain/token/token"; import type { Atoms } from "../../../styles/theme/atoms.css"; import { useWidgetPresentation } from "../../widget-presentation"; import { NetworkLogoImage } from "./network-icon-image"; -import { TokenIconContainer } from "./token-icon-container"; +import { TokenIconContainer, TokenIconFrame } from "./token-icon-container"; import { TokenIconImage } from "./token-icon-image"; export const TokenIcon = ({ @@ -47,3 +47,15 @@ export const TokenIcon = ({ ); }; + +export const TokenIconSkeleton = ({ + tokenLogoHw, + hideNetwork, +}: { + tokenLogoHw?: Atoms["hw"]; + hideNetwork?: boolean; +}) => ( + + + +); diff --git a/packages/widget/src/shared/ui/components/token-icon/token-icon-container/index.tsx b/packages/widget/src/shared/ui/components/token-icon/token-icon-container/index.tsx index 4b614ee83..09b55bccb 100644 --- a/packages/widget/src/shared/ui/components/token-icon/token-icon-container/index.tsx +++ b/packages/widget/src/shared/ui/components/token-icon/token-icon-container/index.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from "react"; +import type { PropsWithChildren, ReactElement } from "react"; import type { YieldMetadata } from "../../../../../domain/earn/yield"; import type { Network } from "../../../../../domain/network/network"; import type { Token } from "../../../../../domain/token/token"; @@ -17,6 +17,15 @@ type TokenIconContainerReturnType = ReturnType & { networkLogoUri: string; }; +export const TokenIconFrame = ({ + children, + hideNetwork, +}: PropsWithChildren<{ hideNetwork?: boolean }>) => ( + + {children} + +); + export const TokenIconContainer = ({ token, metadata, @@ -31,12 +40,8 @@ export const TokenIconContainer = ({ const networkLogoUri = useVariantNetworkUrls(token.network as Network); return ( - + {children({ mainUrl, fallbackUrl, name, networkLogoUri, providerIcon })} - + ); }; diff --git a/packages/widget/src/shared/ui/components/token-icon/token-icon-image/index.tsx b/packages/widget/src/shared/ui/components/token-icon/token-icon-image/index.tsx index bc798a627..755a1c9f3 100644 --- a/packages/widget/src/shared/ui/components/token-icon/token-icon-image/index.tsx +++ b/packages/widget/src/shared/ui/components/token-icon/token-icon-image/index.tsx @@ -1,24 +1,42 @@ import type { Atoms } from "../../../../styles/theme/atoms.css"; +import { Box } from "../../../primitives/box"; +import { ContentLoaderCircle } from "../../../primitives/content-loader"; import { Image } from "../../../primitives/image"; type TokenIconProps = { - mainUrl?: string; - fallbackUrl?: string; - name: string; tokenLogoHw?: Atoms["hw"]; -}; +} & ( + | { + loading: true; + mainUrl?: never; + fallbackUrl?: never; + name?: never; + } + | { + loading?: false; + mainUrl?: string; + fallbackUrl?: string; + name: string; + } +); export const TokenIconImage = ({ mainUrl, fallbackUrl, name, + loading, tokenLogoHw = "9", -}: TokenIconProps) => ( - -); +}: TokenIconProps) => + loading ? ( + + + + ) : ( + + ); diff --git a/packages/widget/src/shared/ui/primitives/content-loader/index.tsx b/packages/widget/src/shared/ui/primitives/content-loader/index.tsx index fb221b8c7..010aeab51 100644 --- a/packages/widget/src/shared/ui/primitives/content-loader/index.tsx +++ b/packages/widget/src/shared/ui/primitives/content-loader/index.tsx @@ -1,47 +1,31 @@ import Skeleton from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; -import type { ComponentProps } from "react"; import { vars } from "../../../styles/theme/contract.css"; +import { fillContainer } from "./styles.css"; -export const ContentLoaderSquare = ({ - heightPx, - variant, - containerClassName, -}: { - heightPx: number; - variant?: { size?: "regular" | "medium" }; - containerClassName?: ComponentProps["containerClassName"]; -}) => { +export const ContentLoaderSquare = ({ heightPx }: { heightPx?: number }) => { return ( ); }; export const ContentLoaderLine = ({ - heightPx = 12, widthPx, - containerClassName, }: { - heightPx?: number; widthPx?: number | string; - containerClassName?: ComponentProps["containerClassName"]; }) => { return ( { +export const ContentLoaderCircle = () => { return ( ( ( - - - -); +export const CaretDownIcon = ({ + size = 12, + loading = false, +}: { + size?: number; + loading?: boolean; +}) => + loading ? ( + + + + ) : ( + + + + ); diff --git a/packages/widget/tests/components/loading-skeleton.browser.test.tsx b/packages/widget/tests/components/loading-skeleton.browser.test.tsx index 87c1b16bb..bc9cea7de 100644 --- a/packages/widget/tests/components/loading-skeleton.browser.test.tsx +++ b/packages/widget/tests/components/loading-skeleton.browser.test.tsx @@ -1,19 +1,152 @@ +import { assignInlineVars } from "@vanilla-extract/dynamic"; import { expect, it } from "vitest"; import { render } from "vitest-browser-react"; -import { LoadingSkeleton } from "../../src/shared/ui/components/loading-skeleton"; +import { vars } from "../../src/shared/styles/theme/contract.css"; +import { lightTheme } from "../../src/shared/styles/theme/themes"; +import { AmountTokenSection } from "../../src/shared/ui/components/amount-token-section"; +import { DetailRow } from "../../src/shared/ui/components/details-section"; +import { PositionMetricCards } from "../../src/shared/ui/components/position-details"; +import { + ContentLoaderCircle, + ContentLoaderLine, + ContentLoaderSquare, +} from "../../src/shared/ui/primitives/content-loader"; +import { CaretDownIcon } from "../../src/shared/ui/primitives/icons/caret-down"; -it("reserves space while route state is loading", async () => { +it("follows the surrounding typography when the host changes text size", async () => { const app = await render( -
- +
+
); - const loading = app.container.querySelector('[aria-busy="true"]'); - const skeleton = app.container.querySelector(".react-loading-skeleton"); + const skeleton = app.container.querySelector(".react-loading-skeleton")!; - expect(loading).not.toBeNull(); - expect(skeleton).not.toBeNull(); - expect(loading!.getBoundingClientRect().height).toBeGreaterThanOrEqual(320); - expect(skeleton!.getBoundingClientRect().height).toBe(320); - expect(skeleton!.getBoundingClientRect().width).toBe(360); + expect(skeleton.getBoundingClientRect().height).toBe(16); + expect(skeleton.getBoundingClientRect().width).toBe(240); + + await app.rerender( +
+ +
+ ); + + expect(skeleton.getBoundingClientRect().height).toBe(24); + expect(skeleton.getBoundingClientRect().width).toBe(180); +}); + +it("fills panel and avatar slots without adding a text line below them", async () => { + const app = await render( +
+
+ +
+
+ +
+
+ ); + const [panel, avatar] = app.container.querySelectorAll( + ".react-loading-skeleton" + ); + + expect(panel!.getBoundingClientRect().width).toBe(240); + expect(panel!.getBoundingClientRect().height).toBe(80); + expect(avatar!.getBoundingClientRect().width).toBe(40); + expect(avatar!.getBoundingClientRect().height).toBe(40); + expect(panel!.parentElement!.getBoundingClientRect().height).toBe(80); + + await app.rerender( +
+
+ +
+
+ +
+
+ ); + + expect(panel!.getBoundingClientRect().width).toBe(180); + expect(panel!.getBoundingClientRect().height).toBe(120); + expect(avatar!.getBoundingClientRect().width).toBe(56); + expect(avatar!.getBoundingClientRect().height).toBe(56); +}); + +it("keeps known metric and detail row geometry when values arrive", async () => { + const theme = assignInlineVars(vars, { + ...lightTheme, + font: { body: "Georgia, serif" }, + fontSize: { ...lightTheme.fontSize, md: "20px", lg: "24px" }, + }); + const Presentation = ({ loading }: { loading: boolean }) => ( +
+
+ +
+
+ +
+
+ ); + const app = await render(); + const metric = app.getByTestId("metric").element(); + const detail = app.getByTestId("detail").element(); + const metricHeight = metric.getBoundingClientRect().height; + const detailHeight = detail.getBoundingClientRect().height; + + await expect.element(app.getByText("Debt")).toBeVisible(); + await expect.element(app.getByText("Network")).toBeVisible(); + + await app.rerender(); + + await expect.element(app.getByText("$100")).toBeVisible(); + await expect.element(app.getByText("Ethereum")).toBeVisible(); + expect(metric.getBoundingClientRect().height).toBeCloseTo(metricHeight, 0); + expect(detail.getBoundingClientRect().height).toBeCloseTo(detailHeight, 0); +}); +it("renders single loaders for amount, carets, and right balance section", async () => { + const theme = assignInlineVars(vars, lightTheme); + const app = await render( +
+ } /> +
+ ); + + // Amount input does not show "0", renders a skeleton loader line instead + expect(app.container.textContent).not.toContain("0"); + expect(app.container.querySelector('input[name="stake-amount"]')).toBeNull(); + + // Caret renders a 12x12 skeleton loader + const caretSkeleton = app.container.querySelector( + '[style*="width: 12px"] .react-loading-skeleton' + ); + expect(caretSkeleton).not.toBeNull(); + expect(caretSkeleton!.getBoundingClientRect().width).toBe(12); + expect(caretSkeleton!.getBoundingClientRect().height).toBe(12); + + // Balance row contains price loader on the left and a single loader line on the right (no Max button) + const balanceRow = app.container.querySelector( + '[data-rk="stake-token-section-balance"]' + )!; + expect(balanceRow).not.toBeNull(); + const rightSection = balanceRow.children[1]!; + expect(rightSection.querySelectorAll(".react-loading-skeleton")).toHaveLength( + 1 + ); + expect( + balanceRow.querySelector('[data-rk="stake-token-section-max-button"]') + ).toBeNull(); }); diff --git a/packages/widget/tests/features/activity-workflow.dom.test.tsx b/packages/widget/tests/features/activity-workflow.dom.test.tsx index 2353e9add..16607f697 100644 --- a/packages/widget/tests/features/activity-workflow.dom.test.tsx +++ b/packages/widget/tests/features/activity-workflow.dom.test.tsx @@ -16,15 +16,6 @@ import { render } from "../utils/test-utils.dom.tsx"; const i18nInstance = createWidgetI18nInstance(); -vi.mock( - "../../src/features/activity/ui/activity-page/components/action-list-item", - () => ({ - ActionListItem: ({ action }: { readonly action: ActivityActionItem }) => ( -
{action.actionData.id}
- ), - }) -); - const settings = { apiKey: "test-api-key", variant: "default" as const, diff --git a/packages/widget/tests/use-cases/classic-mount-geometry.browser.test.tsx b/packages/widget/tests/use-cases/classic-mount-geometry.browser.test.tsx index 3d917e3a1..e3d51860a 100644 --- a/packages/widget/tests/use-cases/classic-mount-geometry.browser.test.tsx +++ b/packages/widget/tests/use-cases/classic-mount-geometry.browser.test.tsx @@ -1,6 +1,7 @@ import { MotionGlobalConfig } from "motion/react"; import { delay, HttpResponse, http } from "msw"; import { afterEach } from "vitest"; +import { userEvent } from "vitest/browser"; import { yieldApiYieldDtoFixture } from "../fixtures"; import { yieldApiRoute } from "../mocks/api-routes"; import { describe, expect, it } from "../utils/test-extend"; @@ -189,4 +190,53 @@ describe("classic mount geometry", () => { await expect.poll(() => defaultApp.container.textContent).toContain("Earn"); await defaultApp.unmount(); }); + + it("keeps the amount layout stable with a loader until the catalog is ready", async ({ + worker, + }) => { + const catalog = Promise.withResolvers(); + const readyYield = yieldApiYieldDtoFixture(); + worker.use( + http.get(yieldApiRoute("/v1/yields"), async () => { + await catalog.promise; + return HttpResponse.json({ + items: [readyYield], + total: 1, + limit: 20, + offset: 0, + }); + }), + http.get(yieldApiRoute(`/v1/yields/${readyYield.id}`), () => + HttpResponse.json(readyYield) + ) + ); + const app = await renderApp({ + skProps: { + apiKey: import.meta.env.VITE_API_KEY, + disableInitLayoutAnimation: true, + language: "fr", + theme: { + font: { body: "Georgia, serif" }, + fontSize: { md: "20px" }, + }, + }, + }); + + try { + const section = app.getByTestId("stake-token-section"); + await expect.element(section).toBeVisible(); + await expect.element(app.getByRole("textbox")).not.toBeInTheDocument(); + + catalog.resolve(); + + const input = app.getByRole("textbox"); + await expect.element(input).toBeVisible(); + await expect.element(input).toBeEnabled(); + await userEvent.fill(input, "1"); + await expect.element(input).toHaveValue("1"); + } finally { + catalog.resolve(); + await app.unmount(); + } + }); });