From 50d6f53ef5b425f768686eff8317fb690169d9c3 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 28 May 2026 12:22:10 +0200 Subject: [PATCH 1/4] fix: share validator cache between activity and list hooks --- .../molecules/select-validator/index.tsx | 31 +- packages/widget/src/domain/types/action.ts | 8 +- packages/widget/src/domain/types/positions.ts | 2 +- packages/widget/src/domain/types/yields.ts | 4 - .../src/hooks/api/use-activity-actions.ts | 25 +- .../src/hooks/api/use-yield-validators.ts | 344 ++++++++++++++-- .../widget/src/hooks/use-estimated-rewards.ts | 2 +- .../widget/src/hooks/use-positions-data.ts | 65 +-- .../widget/src/hooks/use-provider-details.ts | 154 +++---- packages/widget/src/hooks/use-summary.tsx | 6 +- .../activity/activity.page.tsx | 3 + .../utila-select-validator-section.tsx | 6 + .../components/position-details-actions.tsx | 6 + .../hooks/use-activity-complete.hook.ts | 14 +- .../complete/pages/pending-complete.page.tsx | 4 +- .../complete/pages/stake-complete.page.tsx | 2 +- .../complete/pages/unstake-complete.page.tsx | 4 +- .../hooks/use-action-list-item.ts | 9 +- .../state/activity-page.context.tsx | 2 + .../src/pages/details/activity-page/types.ts | 2 + .../select-validator-section/index.tsx | 6 + .../use-select-validator.ts | 6 + .../earn-page/state/earn-page-context.tsx | 47 +-- .../pages/details/earn-page/state/types.ts | 3 + .../state/use-stake-enter-request-dto.ts | 12 +- .../hooks/use-position-list-item.ts | 4 +- .../positions-page/hooks/use-positions.ts | 5 +- .../hooks/use-position-details.ts | 8 +- .../position-details.page.tsx | 6 + .../pages/steps/pages/activity-steps.page.tsx | 16 +- .../pages/steps/pages/pending-steps.page.tsx | 4 +- .../pages/steps/pages/stake-steps.page.tsx | 2 +- .../pages/steps/pages/unstake-steps.page.tsx | 4 +- .../src/providers/activity-provider/index.tsx | 11 +- .../tests/hooks/validator-loading.test.tsx | 385 ++++++++++++++++++ 35 files changed, 946 insertions(+), 266 deletions(-) create mode 100644 packages/widget/tests/hooks/validator-loading.test.tsx diff --git a/packages/widget/src/components/molecules/select-validator/index.tsx b/packages/widget/src/components/molecules/select-validator/index.tsx index f748eba4..35e91e75 100644 --- a/packages/widget/src/components/molecules/select-validator/index.tsx +++ b/packages/widget/src/components/molecules/select-validator/index.tsx @@ -13,6 +13,9 @@ type SelectValidatorProps = PropsWithChildren< selectedValidators: Set; onItemClick: (item: ValidatorDto) => void; onViewMoreClick?: () => void; + onLoadMore?: () => void; + hasMore?: boolean; + isLoadingMore?: boolean; validators: ValidatorDto[]; selectedStake: Yield; multiSelect: boolean; @@ -33,6 +36,9 @@ export const SelectValidator = ({ selectedValidators, onItemClick, onViewMoreClick, + onLoadMore, + hasMore, + isLoadingMore, validators, multiSelect, selectedStake, @@ -45,6 +51,7 @@ export const SelectValidator = ({ const _onViewMoreClick = () => { onViewMoreClick?.(); + onLoadMore?.(); setViewMore(true); }; @@ -53,13 +60,21 @@ export const SelectValidator = ({ onClose?.(); }; - const viewMore = !!multiSelect || _viewMore || rest.searchValue; + const viewMore = multiSelect || _viewMore || !!rest.searchValue; const data = useMemo<{ tableData: ValidatorDto[]; groupedItems: GroupedItem[]; groupCounts: number[]; }>(() => { + if (!validators.length && hasMore && !viewMore) { + return { + tableData: [], + groupedItems: [{ items: [], label: "view_more" }], + groupCounts: [1], + }; + } + if (!validators.length) { return { tableData: [], @@ -108,7 +123,8 @@ export const SelectValidator = ({ } const canViewMore = - !viewMore && groupedItems.preferred.items.length !== validators.length; + !viewMore && + (!!hasMore || groupedItems.preferred.items.length !== validators.length); const groupedItemsValues = Object.values(groupedItems); @@ -125,7 +141,7 @@ export const SelectValidator = ({ ...(canViewMore ? [1] : []), ], }; - }, [validators, t, viewMore]); + }, [hasMore, validators, t, viewMore]); const searchProps = rest.onSearch ? { @@ -133,6 +149,14 @@ export const SelectValidator = ({ searchValue: rest.searchValue, } : {}; + const infiniteScrollProps = + viewMore && onLoadMore + ? { + hasNextPage: !!hasMore, + isFetchingNextPage: !!isLoadingMore, + fetchNextPage: onLoadMore, + } + : {}; return ( {children} diff --git a/packages/widget/src/domain/types/action.ts b/packages/widget/src/domain/types/action.ts index 27c26f6b..24aa53c3 100644 --- a/packages/widget/src/domain/types/action.ts +++ b/packages/widget/src/domain/types/action.ts @@ -6,7 +6,7 @@ import type { TransactionDto as YieldTransactionDtoGenerated, } from "../../generated/api/yield"; import type { TokenDto } from "./tokens"; -import { getYieldMetadataTokens, type Yield } from "./yields"; +import type { Yield } from "./yields"; export type ActionDto = YieldActionDtoGenerated; export type TransactionDto = YieldTransactionDtoGenerated; @@ -107,11 +107,7 @@ export const getActionInputToken = ({ const needle = toLower(inputTokenValue); return ( - [ - yieldDto.token, - ...(yieldDto.tokens ?? []), - ...getYieldMetadataTokens(yieldDto), - ].find((token) => { + [yieldDto.token, ...(yieldDto.tokens ?? [])].find((token) => { const address = token.address ? toLower(token.address) : null; return ( diff --git a/packages/widget/src/domain/types/positions.ts b/packages/widget/src/domain/types/positions.ts index 79c400dc..00a49368 100644 --- a/packages/widget/src/domain/types/positions.ts +++ b/packages/widget/src/domain/types/positions.ts @@ -38,7 +38,7 @@ export type PositionsData = Map< balanceData: Map< BalanceDataKey, { balances: YieldBalanceDto[] } & ( - | { type: "validators"; validatorsAddresses: string[] } + | { type: "validators"; validators: ReadonlyArray } | { type: "default" } ) >; diff --git a/packages/widget/src/domain/types/yields.ts b/packages/widget/src/domain/types/yields.ts index fcc089ef..a4de95c8 100644 --- a/packages/widget/src/domain/types/yields.ts +++ b/packages/widget/src/domain/types/yields.ts @@ -284,10 +284,6 @@ export const getYieldTVL = (yieldDto: Yield) => export const getYieldLockupPeriod = (yieldDto: Yield) => yieldDto.__fallback__.metadata.lockupPeriod; -export const getYieldMetadataTokens = (yieldDto: Yield) => [ - ...(yieldDto.__fallback__.metadata.tokens ?? []), -]; - export const hasYieldExitSignatureVerification = (yieldDto: Yield) => !!yieldDto.__fallback__.args.exit?.args?.signatureVerification?.required; diff --git a/packages/widget/src/hooks/api/use-activity-actions.ts b/packages/widget/src/hooks/api/use-activity-actions.ts index b3fc0db1..05491a88 100644 --- a/packages/widget/src/hooks/api/use-activity-actions.ts +++ b/packages/widget/src/hooks/api/use-activity-actions.ts @@ -1,18 +1,25 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { EitherAsync } from "purify-ts"; import { useMemo } from "react"; -import { type ActionDto, getActionInputToken } from "../../domain/types/action"; +import { + type ActionDto, + getActionInputToken, + getActionValidatorAddresses, +} from "../../domain/types/action"; +import type { ValidatorDto } from "../../domain/types/validators"; import type { Yield } from "../../domain/types/yields"; import { useApiClient } from "../../providers/api/api-client-provider"; import { useSKQueryClient } from "../../providers/query-client"; import { useSKWallet } from "../../providers/sk-wallet"; import { getYieldOpportunity } from "./use-yield-opportunity/get-yield-opportunity"; +import { getYieldValidatorsByAddresses } from "./use-yield-validators"; const PAGE_SIZE = 50; type ActivityActionItem = { actionData: ActionDto; yieldData: Yield; + validatorsData: ValidatorDto[]; }; type UseActivityActionsResult = ReturnType & { @@ -74,6 +81,22 @@ export const useActivityActions = (): UseActivityActionsResult => { }) ) ) + .chain((items) => + EitherAsync(() => + Promise.all( + items.map(async (item) => ({ + ...item, + validatorsData: await getYieldValidatorsByAddresses({ + apiClient, + queryClient, + yieldId: item.yieldData.id, + addresses: + getActionValidatorAddresses(item.actionData) ?? [], + }), + })) + ) + ) + ) .map((data) => ({ ...actionList, data })) ) ).unsafeCoerce(); diff --git a/packages/widget/src/hooks/api/use-yield-validators.ts b/packages/widget/src/hooks/api/use-yield-validators.ts index f306978e..7238b182 100644 --- a/packages/widget/src/hooks/api/use-yield-validators.ts +++ b/packages/widget/src/hooks/api/use-yield-validators.ts @@ -1,96 +1,372 @@ -import { useQuery } from "@tanstack/react-query"; +import { type QueryClient, useInfiniteQuery } from "@tanstack/react-query"; import type { ValidatorDto } from "../../domain/types/validators"; import type { ValidatorsConfig } from "../../domain/types/yields"; import { filterValidators, type Yield } from "../../domain/types/yields"; import { useApiClient } from "../../providers/api/api-client-provider"; +import { useSKQueryClient } from "../../providers/query-client"; import { useValidatorsConfig } from "../use-validators-config"; const PAGE_SIZE = 100; -const staleTime = 1000 * 60 * 2; type Params = { yieldId: string; network?: Yield["token"]["network"]; + search?: string; validatorsConfig: ValidatorsConfig; apiClient: ReturnType; + queryClient: QueryClient; signal?: AbortSignal; }; -const getYieldValidatorsQueryKey = ({ yieldId }: Pick) => [ - "yield-validators", +type PageParam = + | { type: "preferred" } + | { type: "other"; offset: number } + | { type: "search"; offset: number; search: string }; + +type Page = { + validators: ValidatorDto[]; + nextPageParam?: PageParam; +}; + +type RawValidatorsPage = { + total: number; + offset: number; + items?: ReadonlyArray; +}; + +export const getYieldValidatorQueryKey = ({ + yieldId, + address, +}: { + yieldId: Yield["id"]; + address: ValidatorDto["address"]; +}) => ["yield-validator", yieldId, address] as const; + +const setYieldValidatorsQueryData = ({ + queryClient, yieldId, -]; + validators, +}: { + queryClient: QueryClient; + yieldId: Yield["id"]; + validators: ReadonlyArray; +}) => { + validators.forEach((validator) => { + queryClient.setQueryData( + getYieldValidatorQueryKey({ yieldId, address: validator.address }), + validator + ); + }); +}; -const getYieldValidatorsQueryFn = async ({ +export const getYieldValidatorsByAddresses = async ({ + apiClient, + queryClient, yieldId, + addresses, +}: { + apiClient: ReturnType; + queryClient: QueryClient; + yieldId: Yield["id"]; + addresses: ReadonlyArray; +}): Promise => { + const uniqueAddresses = [...new Set(addresses)]; + const validators = new Map( + await Promise.all( + uniqueAddresses.map( + async (address): Promise<[ValidatorDto["address"], ValidatorDto]> => { + try { + const validator = await queryClient.fetchQuery({ + queryKey: getYieldValidatorQueryKey({ yieldId, address }), + staleTime: Number.POSITIVE_INFINITY, + queryFn: async ({ signal }) => { + const validatorsPage = await apiClient + .withRunOptions({ signal }) + .yield.YieldsControllerGetYieldValidators(yieldId, { + params: { + address, + limit: 100, + offset: 0, + }, + }); + const lowerCaseAddress = address.toLowerCase(); + + return ( + validatorsPage.items?.find( + (validator) => + validator.address.toLowerCase() === lowerCaseAddress + ) ?? null + ); + }, + }); + + return [address, validator ?? { address }]; + } catch { + return [address, { address }]; + } + } + ) + ) + ); + + return addresses.flatMap((address) => { + const validator = validators.get(address); + + return validator ? [validator] : []; + }); +}; + +const getRawNextOffset = ({ + offset, + total, +}: Pick) => { + const nextOffset = offset + PAGE_SIZE; + + return nextOffset < total ? nextOffset : undefined; +}; + +const getFilteredValidators = ({ + validators, network, validatorsConfig, + yieldId, +}: Pick & { + validators: ValidatorDto[]; +}) => + network + ? filterValidators({ + validatorsConfig, + validators, + network, + yieldId, + }) + : validators; + +const fetchPagedValidators = async ({ + yieldId, + network, + search, + validatorsConfig, apiClient, + queryClient, signal, -}: Params): Promise => { - const fetchPage = (offset: number) => + pageParam, +}: Params & { + pageParam: Exclude; +}): Promise => { + const fetchPage = (params: { + offset?: number; + preferred?: boolean; + name?: string; + address?: string; + limit?: number; + }) => apiClient .withRunOptions({ signal }) .yield.YieldsControllerGetYieldValidators(yieldId, { params: { - offset, - limit: PAGE_SIZE, + limit: params.limit ?? PAGE_SIZE, + offset: params.offset ?? 0, + preferred: params.preferred, + name: params.name, + address: params.address, }, }); - const firstPage = await fetchPage(0); + let offset = pageParam.offset; + + while (true) { + const rawPage: RawValidatorsPage = await (async () => { + if (pageParam.type === "search" && search) { + const [namePage, addressPage] = await Promise.all([ + fetchPage({ offset, name: search }), + fetchPage({ offset, address: search }), + ]); + + return { + total: Math.max(namePage.total, addressPage.total), + offset, + items: [...(namePage.items ?? []), ...(addressPage.items ?? [])], + }; + } + + return fetchPage({ offset, preferred: false }); + })(); + const rawValidators = [...(rawPage.items ?? [])]; + + setYieldValidatorsQueryData({ + queryClient, + yieldId, + validators: rawValidators, + }); + + const validators = getFilteredValidators({ + validators: rawValidators, + network, + validatorsConfig, + yieldId, + }); + const nextOffset = getRawNextOffset(rawPage); + + if (validators.length || nextOffset === undefined) { + return { + validators, + nextPageParam: + nextOffset === undefined + ? undefined + : pageParam.type === "search" + ? { type: "search", offset: nextOffset, search: pageParam.search } + : { type: "other", offset: nextOffset }, + }; + } + + offset = nextOffset; + } +}; + +const fetchValidatorsPage = async ({ + yieldId, + network, + search, + validatorsConfig, + apiClient, + queryClient, + signal, + pageParam, +}: Params & { pageParam: PageParam }): Promise => { + if (pageParam.type !== "preferred") { + return fetchPagedValidators({ + yieldId, + network, + search, + validatorsConfig, + apiClient, + queryClient, + signal, + pageParam, + }); + } + + const fetchPage = (params: { + offset?: number; + preferred?: boolean; + limit?: number; + }) => + apiClient + .withRunOptions({ signal }) + .yield.YieldsControllerGetYieldValidators(yieldId, { + params: { + limit: params.limit ?? PAGE_SIZE, + offset: params.offset ?? 0, + preferred: params.preferred, + }, + }); + const [firstPage, otherPage] = await Promise.all([ + fetchPage({ preferred: true, offset: 0 }), + fetchPage({ preferred: false, offset: 0, limit: 1 }), + ]); const remainingOffsets = Array.from( - { length: Math.ceil(firstPage.total / PAGE_SIZE) - 1 }, + { length: Math.max(0, Math.ceil(firstPage.total / PAGE_SIZE) - 1) }, (_, index) => (index + 1) * PAGE_SIZE ); - const remainingPages = await Promise.all( - remainingOffsets.map((offset) => - fetchPage(offset).catch(() => ({ items: [] })) - ) + remainingOffsets.map((offset) => fetchPage({ preferred: true, offset })) ); - - const validators = [firstPage, ...remainingPages].flatMap( + const rawValidators = [firstPage, ...remainingPages].flatMap( (page) => page.items ?? [] ); - return network - ? filterValidators({ - validatorsConfig, - validators, - network, - yieldId, - }) - : validators; + setYieldValidatorsQueryData({ + queryClient, + yieldId, + validators: [...rawValidators, ...(otherPage.items ?? [])], + }); + + const validators = getFilteredValidators({ + validators: rawValidators, + network, + validatorsConfig, + yieldId, + }); + + if (validators.length || otherPage.total === 0) { + return { + validators, + nextPageParam: + otherPage.total > 0 ? { type: "other", offset: 0 } : undefined, + }; + } + + return fetchPagedValidators({ + yieldId, + network, + search, + validatorsConfig, + apiClient, + queryClient, + signal, + pageParam: { type: "other", offset: 0 }, + }); +}; + +const getInitialPageParam = (params: Params): PageParam => { + if (params.search) { + return { type: "search", offset: 0, search: params.search }; + } + + return { type: "preferred" }; }; const getYieldValidatorsQueryOptions = (params: Params) => ({ - queryKey: getYieldValidatorsQueryKey(params), - staleTime, - queryFn: ({ signal }: { signal?: AbortSignal }) => - getYieldValidatorsQueryFn({ ...params, signal }), + queryKey: [ + "yield-validators", + params.yieldId, + params.search ?? "", + params.validatorsConfig, + ], + staleTime: Number.POSITIVE_INFINITY, + initialPageParam: getInitialPageParam(params), + queryFn: ({ + signal, + pageParam, + }: { + signal?: AbortSignal; + pageParam: PageParam; + }): Promise => + fetchValidatorsPage({ + ...params, + signal, + pageParam, + }), + getNextPageParam: (lastPage: Page) => lastPage.nextPageParam, }); export const useYieldValidators = ({ - enabled = true, yieldId, network, + search, + enabled = true, }: { enabled?: boolean; yieldId?: string; network?: Yield["token"]["network"]; + search?: string; }) => { const apiClient = useApiClient(); + const queryClient = useSKQueryClient(); const validatorsConfig = useValidatorsConfig(); - return useQuery({ + return useInfiniteQuery({ ...getYieldValidatorsQueryOptions({ + apiClient, + queryClient, + validatorsConfig, yieldId: yieldId ?? "", network, - validatorsConfig, - apiClient, + search, }), enabled: enabled && !!yieldId, + select: (data) => data.pages.flatMap((page) => page.validators), }); }; diff --git a/packages/widget/src/hooks/use-estimated-rewards.ts b/packages/widget/src/hooks/use-estimated-rewards.ts index ebcc619a..cac0fd56 100644 --- a/packages/widget/src/hooks/use-estimated-rewards.ts +++ b/packages/widget/src/hooks/use-estimated-rewards.ts @@ -24,7 +24,7 @@ export const useEstimatedRewards = ({ }) => { const providersDetails = useProvidersDetails({ integrationData: selectedStake, - validatorsAddresses: Maybe.of(selectedValidators), + validators: Maybe.of(selectedValidators), selectedProviderYieldId, }); diff --git a/packages/widget/src/hooks/use-positions-data.ts b/packages/widget/src/hooks/use-positions-data.ts index 440d0e31..fcffacce 100644 --- a/packages/widget/src/hooks/use-positions-data.ts +++ b/packages/widget/src/hooks/use-positions-data.ts @@ -35,44 +35,47 @@ const positionsDataSelector = createSelector( getPositionBalanceDataKey(b) ) ) - .reduce((acc, b) => { - const key = getPositionBalanceDataKey(b); - const prev = acc.get(key); - const validatorsAddresses = getBalanceValidatorAddresses(b); + .reduce( + (acc, b) => { + const key = getPositionBalanceDataKey(b); + const prev = acc.get(key); + const validators = getBalanceValidators(b); - if (prev) { - prev.balances.push(b); - } else { - if (key === "default") { - acc.set(key, { - balances: [b], - type: "default", - }); + if (prev) { + prev.balances.push(b); } else { - acc.set(key, { - balances: [b], - type: "validators", - validatorsAddresses, - }); + if (key === "default") { + acc.set(key, { + balances: [b], + type: "default", + }); + } else { + acc.set(key, { + balances: [b], + type: "validators", + validators, + }); + } } - } - return acc; - }, new Map< - BalanceDataKey, - { balances: YieldBalanceDto[] } & ( - | { type: "validators"; validatorsAddresses: string[] } - | { type: "default" } - ) - >()), + return acc; + }, + new Map< + BalanceDataKey, + { balances: YieldBalanceDto[] } & ( + | { + type: "validators"; + validators: NonNullable; + } + | { type: "default" } + ) + >() + ), }); return acc; }, new Map() as PositionsData) ); -const getBalanceValidatorAddresses = (balance: YieldBalanceDto) => - ( - balance.validators?.map((validator) => validator.address) ?? - (balance.validator?.address ? [balance.validator.address] : []) - ).filter(Boolean); +const getBalanceValidators = (balance: YieldBalanceDto) => + balance.validators ?? (balance.validator ? [balance.validator] : []); diff --git a/packages/widget/src/hooks/use-provider-details.ts b/packages/widget/src/hooks/use-provider-details.ts index 671a921b..1f692786 100644 --- a/packages/widget/src/hooks/use-provider-details.ts +++ b/packages/widget/src/hooks/use-provider-details.ts @@ -12,7 +12,6 @@ import { import type { GetMaybeJust } from "../types/utils"; import { getRewardRateFormatted } from "../utils/formatters"; import { useMultiYields } from "./api/use-multi-yields"; -import { useYieldValidators } from "./api/use-yield-validators"; type Res = Maybe<{ logo: string | undefined; @@ -31,16 +30,14 @@ type Res = Maybe<{ export const getProviderDetails = ({ integrationData, - validatorAddress, + validator, yields, selectedProviderYieldId, - validatorsData, }: { integrationData: Maybe; - validatorAddress: Maybe; + validator: Maybe; yields: Maybe>; selectedProviderYieldId: Maybe; - validatorsData?: ReadonlyArray; }): Res => { const def = integrationData.chain((val) => { const rewardRate = val.rewardRate.total; @@ -60,7 +57,7 @@ export const getProviderDetails = ({ rewardRate, rewardType, website: v.externalLink, - address: validatorAddress.extract(), + address: validator.map((v) => v.address).extract(), })) .altLazy(() => Maybe.of({ @@ -69,129 +66,80 @@ export const getProviderDetails = ({ rewardRateFormatted, rewardRate, rewardType, - address: validatorAddress.extract(), + address: validator.map((v) => v.address).extract(), }) ); }); return integrationData.chain((yieldDto) => - validatorAddress - .chain>((addr) => - List.find( - (v) => - v.address === addr || - v.providerId === addr || - v.provider?.id === addr, - [...(validatorsData ?? [])] - ).map((validator) => { - const { rewardRate, rewardType } = Maybe.fromRecord({ - _: Maybe.fromFalsy(isYieldWithProviderOptions(yieldDto)), - selectedProviderYieldId, - }) - .chain(({ selectedProviderYieldId }) => - yields.chain((list) => - List.find((v) => v.id === selectedProviderYieldId, [...list]) - ) - ) - .map((v) => v.rewardRate.total + v.rewardRate.total) - .map<{ rewardRate: number | undefined; rewardType: RewardTypes }>( - (res) => ({ - rewardRate: res, - rewardType: getYieldRewardType(yieldDto), - }) + validator + .map>((validator) => { + const { rewardRate, rewardType } = Maybe.fromRecord({ + _: Maybe.fromFalsy(isYieldWithProviderOptions(yieldDto)), + selectedProviderYieldId, + }) + .chain(({ selectedProviderYieldId }) => + yields.chain((list) => + List.find((v) => v.id === selectedProviderYieldId, [...list]) ) - .orDefault({ - rewardRate: validator.rewardRate?.total, + ) + .map((v) => v.rewardRate.total + v.rewardRate.total) + .map<{ rewardRate: number | undefined; rewardType: RewardTypes }>( + (res) => ({ + rewardRate: res, rewardType: getYieldRewardType(yieldDto), - }); + }) + ) + .orDefault({ + rewardRate: validator.rewardRate?.total, + rewardType: getYieldRewardType(yieldDto), + }); - return { - logo: validator.logoURI, - name: validator.name ?? validator.address, - rewardRateFormatted: getRewardRateFormatted({ - rewardRate, - rewardType, - }), + return { + logo: validator.logoURI, + name: validator.name ?? validator.address, + rewardRateFormatted: getRewardRateFormatted({ rewardRate, - rewardType: getYieldRewardType(yieldDto), - address: validator.address, - stakedBalance: validator.tvl, - votingPower: validator.votingPower, - commission: validator.commission, - status: validator.status, - website: validator.website, - preferred: validator.preferred, - }; - }) - ) + rewardType, + }), + rewardRate, + rewardType: getYieldRewardType(yieldDto), + address: validator.address, + stakedBalance: validator.tvl, + votingPower: validator.votingPower, + commission: validator.commission, + status: validator.status, + website: validator.website, + preferred: validator.preferred, + }; + }) .altLazy(() => def) ); }; export const useProvidersDetails = ({ integrationData, - validatorsAddresses, + validators, selectedProviderYieldId, - validatorsData, }: { integrationData: Maybe; - validatorsAddresses: Maybe | Map>; + validators: Maybe | Map>; selectedProviderYieldId: Maybe; - validatorsData?: Maybe>; }) => { const yields = useMultiYields( integrationData.map(getYieldProviderYieldIds).orDefault([]) ); - const shouldFetchValidators = validatorsAddresses - .filter((val): val is ReadonlyArray => !(val instanceof Map)) - .map((val) => val.length > 0) - .chain((val) => - validatorsData?.isJust() ? Maybe.of(false) : Maybe.of(val) - ) - .orDefault(false); - - const yieldValidators = useYieldValidators({ - enabled: shouldFetchValidators, - yieldId: - integrationData.map((val) => val.id).extractNullable() ?? undefined, - network: - integrationData.map((val) => val.token.network).extractNullable() ?? - undefined, - }); - - const resolvedValidatorsData = useMemo( - () => - validatorsData?.altLazy(() => - validatorsAddresses.chain((val) => - val instanceof Map - ? Maybe.of([...val.values()]) - : Maybe.fromNullable(yieldValidators.data) - ) - ) ?? - validatorsAddresses.chain((val) => - val instanceof Map - ? Maybe.of([...val.values()]) - : Maybe.fromNullable(yieldValidators.data) - ), - [validatorsAddresses, validatorsData, yieldValidators.data] - ); - return useMemo>[]>>( () => - validatorsAddresses.chain((val) => + validators.chain((val) => Maybe.sequence( - (val instanceof Map - ? [...val.values()].map((v) => v.address) - : val - ).map((v) => + (val instanceof Map ? [...val.values()] : val).map((v) => getProviderDetails({ integrationData, - validatorAddress: Maybe.of(v), + validator: Maybe.of(v), yields: Maybe.fromNullable(yields.data), selectedProviderYieldId, - validatorsData: - resolvedValidatorsData.extractNullable() ?? undefined, }) ) ).chain((val) => @@ -199,18 +147,12 @@ export const useProvidersDetails = ({ ? Maybe.of(val) : getProviderDetails({ integrationData, - validatorAddress: Maybe.empty(), + validator: Maybe.empty(), yields: Maybe.fromNullable(yields.data), selectedProviderYieldId, }).map((v) => [v]) ) ), - [ - integrationData, - validatorsAddresses, - yields.data, - selectedProviderYieldId, - resolvedValidatorsData, - ] + [integrationData, validators, yields.data, selectedProviderYieldId] ); }; diff --git a/packages/widget/src/hooks/use-summary.tsx b/packages/widget/src/hooks/use-summary.tsx index d7a609b0..f9386545 100644 --- a/packages/widget/src/hooks/use-summary.tsx +++ b/packages/widget/src/hooks/use-summary.tsx @@ -114,10 +114,8 @@ export const SummaryProvider = ({ const providerDetails = getProviderDetails({ integrationData: Maybe.of(yieldDto), - validatorAddress: - p.type === "validators" - ? List.head(p.validatorsAddresses) - : Maybe.empty(), + validator: + p.type === "validators" ? List.head(p.validators) : Maybe.empty(), selectedProviderYieldId: Maybe.empty(), yields: Maybe.of(yields), }); diff --git a/packages/widget/src/pages-dashboard/activity/activity.page.tsx b/packages/widget/src/pages-dashboard/activity/activity.page.tsx index 62ed1f5c..f2ce4bb1 100644 --- a/packages/widget/src/pages-dashboard/activity/activity.page.tsx +++ b/packages/widget/src/pages-dashboard/activity/activity.page.tsx @@ -115,6 +115,7 @@ const _ActivityPage = () => { data: Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, + selectedValidators: data.validatorsData, }), }); } @@ -129,6 +130,7 @@ const _ActivityPage = () => { data: Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, + selectedValidators: data.validatorsData, }), }); } @@ -170,6 +172,7 @@ const _ActivityPage = () => { data: Maybe.of({ selectedAction: val.actionData, selectedYield: val.yieldData, + selectedValidators: val.validatorsData, }), }) ); diff --git a/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx b/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx index a3d7a23f..4957ae23 100644 --- a/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx +++ b/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx @@ -18,6 +18,9 @@ export const UtilaSelectValidatorSection = () => { validatorsData, validatorSearch, onValidatorSearch, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, } = useSelectValidator(); return isLoading ? ( @@ -55,6 +58,9 @@ export const UtilaSelectValidatorSection = () => { onSearch={onValidatorSearch} searchValue={validatorSearch} validators={val.validatorsData} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} /> ); }) diff --git a/packages/widget/src/pages-dashboard/position-details/components/position-details-actions.tsx b/packages/widget/src/pages-dashboard/position-details/components/position-details-actions.tsx index 481a42ce..31d6c8aa 100644 --- a/packages/widget/src/pages-dashboard/position-details/components/position-details-actions.tsx +++ b/packages/widget/src/pages-dashboard/position-details/components/position-details-actions.tsx @@ -36,6 +36,9 @@ export const PositionDetailsActions = () => { isLoading, integrationData, validatorsData, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, positionBalancesByType, unstakeToken, providersDetails, @@ -176,6 +179,9 @@ export const PositionDetailsActions = () => { }} selectedStake={v.integrationData} validators={validatorsData} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} multiSelect={validatorAddressesHandling.multiSelect} state={validatorAddressesHandling.modalState} > diff --git a/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts b/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts index e43c0fcf..9f1bd518 100644 --- a/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts +++ b/packages/widget/src/pages/complete/hooks/use-activity-complete.hook.ts @@ -1,10 +1,7 @@ import { useSelector } from "@xstate/store/react"; import { Maybe } from "purify-ts"; import { useMemo } from "react"; -import { - getActionInputToken, - getActionValidatorAddresses, -} from "../../../domain/types/action"; +import { getActionInputToken } from "../../../domain/types/action"; import type { TokenDto } from "../../../domain/types/tokens"; import { getYieldProviderDetails } from "../../../domain/types/yields"; import { useTrackPage } from "../../../hooks/tracking/use-track-page"; @@ -36,6 +33,11 @@ export const useActivityComplete = () => { (state) => state.context.selectedYield ); + const selectedValidators = useSelector( + activityContext, + (state) => state.context.selectedValidators + ); + const yieldType = useYieldType(selectedYield).map((v) => v.type); const inputToken = useMemo( @@ -63,9 +65,7 @@ export const useActivityComplete = () => { const providerDetails = useProvidersDetails({ integrationData: selectedYield, - validatorsAddresses: Maybe.of( - getActionValidatorAddresses(selectedAction) ?? [] - ), + validators: selectedValidators, selectedProviderYieldId: Maybe.of(selectedAction.yieldId), }); diff --git a/packages/widget/src/pages/complete/pages/pending-complete.page.tsx b/packages/widget/src/pages/complete/pages/pending-complete.page.tsx index f83fd03a..c200c51d 100644 --- a/packages/widget/src/pages/complete/pages/pending-complete.page.tsx +++ b/packages/widget/src/pages/complete/pages/pending-complete.page.tsx @@ -39,8 +39,8 @@ export const PendingCompletePage = () => { const providerDetails = useProvidersDetails({ integrationData, - validatorsAddresses: positionBalances.data.map((p) => - p.type === "validators" ? p.validatorsAddresses : [] + validators: positionBalances.data.map((p) => + p.type === "validators" ? p.validators : [] ), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/complete/pages/stake-complete.page.tsx b/packages/widget/src/pages/complete/pages/stake-complete.page.tsx index fcf4dffe..d87ecfb6 100644 --- a/packages/widget/src/pages/complete/pages/stake-complete.page.tsx +++ b/packages/widget/src/pages/complete/pages/stake-complete.page.tsx @@ -53,7 +53,7 @@ export const StakeCompletePage = () => { const providerDetails = useProvidersDetails({ integrationData: selectedStake, - validatorsAddresses: Maybe.of(enterRequest.selectedValidators), + validators: Maybe.of(enterRequest.selectedValidators), selectedProviderYieldId, }); diff --git a/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx b/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx index 9f9a81e5..25bdb0f4 100644 --- a/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx +++ b/packages/widget/src/pages/complete/pages/unstake-complete.page.tsx @@ -32,8 +32,8 @@ export const UnstakeCompletePage = () => { const providerDetails = useProvidersDetails({ integrationData, - validatorsAddresses: positionBalances.data.map((p) => - p.type === "validators" ? p.validatorsAddresses : [] + validators: positionBalances.data.map((p) => + p.type === "validators" ? p.validators : [] ), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/details/activity-page/hooks/use-action-list-item.ts b/packages/widget/src/pages/details/activity-page/hooks/use-action-list-item.ts index aee691ad..525eb699 100644 --- a/packages/widget/src/pages/details/activity-page/hooks/use-action-list-item.ts +++ b/packages/widget/src/pages/details/activity-page/hooks/use-action-list-item.ts @@ -1,10 +1,7 @@ import { Maybe } from "purify-ts"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - ActionStatus, - getActionValidatorAddresses, -} from "../../../../domain/types/action"; +import { ActionStatus } from "../../../../domain/types/action"; import { useProvidersDetails } from "../../../../hooks/use-provider-details"; import { defaultFormattedNumber } from "../../../../utils"; import { dateOlderThen7Days } from "../../../../utils/date"; @@ -33,9 +30,7 @@ export const useActionListItem = (action: ActionYieldDto) => { const providersDetails = useProvidersDetails({ integrationData, - validatorsAddresses: Maybe.of( - getActionValidatorAddresses(action.actionData) ?? [] - ), + validators: Maybe.of(action.validatorsData), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx b/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx index d51451b1..6a0b1d5b 100644 --- a/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx +++ b/packages/widget/src/pages/details/activity-page/state/activity-page.context.tsx @@ -45,6 +45,7 @@ export const ActivityPageContextProvider = ({ data: Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, + selectedValidators: data.validatorsData, }), }); @@ -78,6 +79,7 @@ export const ActivityPageContextProvider = ({ data: Maybe.of({ selectedAction: data.actionData, selectedYield: data.yieldData, + selectedValidators: data.validatorsData, }), }); diff --git a/packages/widget/src/pages/details/activity-page/types.ts b/packages/widget/src/pages/details/activity-page/types.ts index b62c78e2..fc1ee090 100644 --- a/packages/widget/src/pages/details/activity-page/types.ts +++ b/packages/widget/src/pages/details/activity-page/types.ts @@ -1,10 +1,12 @@ import type { TFunction } from "i18next"; import type { ActionDto } from "../../../domain/types/action"; +import type { ValidatorDto } from "../../../domain/types/validators"; import type { Yield } from "../../../domain/types/yields"; export type ActionYieldDto = { actionData: ActionDto; yieldData: Yield; + validatorsData: ValidatorDto[]; }; type DateGroupLabels = "today" | "yesterday" | string; diff --git a/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx b/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx index 4f72b879..edb1c221 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx +++ b/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx @@ -22,6 +22,9 @@ export const SelectValidatorSection = () => { validatorsData, validatorSearch, onValidatorSearch, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, } = useSelectValidator(); return isLoading ? ( @@ -64,6 +67,9 @@ export const SelectValidatorSection = () => { onSearch={onValidatorSearch} searchValue={validatorSearch} validators={val.validatorsData} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} /> ); }) diff --git a/packages/widget/src/pages/details/earn-page/components/select-validator-section/use-select-validator.ts b/packages/widget/src/pages/details/earn-page/components/select-validator-section/use-select-validator.ts index 1ae82740..3f71d705 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-validator-section/use-select-validator.ts +++ b/packages/widget/src/pages/details/earn-page/components/select-validator-section/use-select-validator.ts @@ -13,6 +13,9 @@ export const useSelectValidator = () => { onValidatorSearch, validatorsData, validatorSearch, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, } = useEarnPageContext(); const isLoading = appLoading || selectValidatorIsLoading; @@ -51,5 +54,8 @@ export const useSelectValidator = () => { onValidatorSearch, validatorsData, validatorSearch, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, }; }; diff --git a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx index e687acee..580f22ce 100644 --- a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx +++ b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx @@ -327,6 +327,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { enabled: shouldFetchValidators, yieldId: selectedStake.extract()?.id, network: selectedStake.extract()?.token.network, + search: deferredValidatorSearch, }); const initialValidatorSelectionYieldIdRef = useRef(null); @@ -387,38 +388,18 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { selectedStake .filter(() => shouldFetchValidators) .chain(() => - Maybe.fromNullable(yieldValidators.data) - .map((validators) => { - const searchInput = deferredValidatorSearch.toLowerCase(); - - if (!searchInput) { - return validators; - } - - return validators.filter( - (validator) => - validator.name?.toLowerCase().includes(searchInput) || - validator.address.toLowerCase().includes(searchInput) + Maybe.fromNullable(yieldValidators.data).map((validators) => { + if (variant === "utila" || variant === "porto") { + return [...validators].sort( + (a, b) => + (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0) ); - }) - .map((validators) => { - if (variant === "utila" || variant === "porto") { - return [...validators].sort( - (a, b) => - (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0) - ); - } + } - return validators; - }) + return validators; + }) ), - [ - deferredValidatorSearch, - selectedStake, - shouldFetchValidators, - variant, - yieldValidators.data, - ] + [selectedStake, shouldFetchValidators, variant, yieldValidators.data] ); const onYieldSearch: SelectModalProps["onSearch"] = (val) => @@ -611,7 +592,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const providersDetails = useProvidersDetails({ integrationData: selectedStake, - validatorsAddresses: Maybe.of(selectedValidators), + validators: Maybe.of(selectedValidators), selectedProviderYieldId, }); @@ -667,8 +648,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { tokenBalancesScanLoading || initYieldRes.isLoading || yieldOpportunityLoading || - (shouldFetchValidators && - (yieldValidators.isLoading || yieldValidators.isFetching)); + (shouldFetchValidators && yieldValidators.isLoading); const footerIsLoading = defaultTokensIsLoading || @@ -765,6 +745,9 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { stakeMinAmount, selectedToken, validatorsData, + hasMoreValidators: !!yieldValidators.hasNextPage, + isLoadingMoreValidators: yieldValidators.isFetchingNextPage, + onLoadMoreValidators: yieldValidators.fetchNextPage, validatorSearch, hasNotYieldsForToken, isStakeTokenSameAsGasToken, diff --git a/packages/widget/src/pages/details/earn-page/state/types.ts b/packages/widget/src/pages/details/earn-page/state/types.ts index 5fd3000a..48374d70 100644 --- a/packages/widget/src/pages/details/earn-page/state/types.ts +++ b/packages/widget/src/pages/details/earn-page/state/types.ts @@ -134,5 +134,8 @@ export type EarnPageContextType = { stakeMaxAmount: Maybe; stakeMinAmount: Maybe; validatorsData: Maybe; + hasMoreValidators: boolean; + isLoadingMoreValidators: boolean; + onLoadMoreValidators: () => void; isStakeTokenSameAsGasToken: boolean; }; diff --git a/packages/widget/src/pages/details/earn-page/state/use-stake-enter-request-dto.ts b/packages/widget/src/pages/details/earn-page/state/use-stake-enter-request-dto.ts index 33571106..acce9100 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-stake-enter-request-dto.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-stake-enter-request-dto.ts @@ -52,11 +52,15 @@ export const useStakeEnterRequestDto = () => { return Maybe.empty(); } + const selectedProviderArgs = isYieldIntegrationAggregator(selectedStake) + ? {} + : { providerId }; + const validatorsOrProvider = (() => { if (isYieldIntegrationAggregator(selectedStake)) { - return List.head(validators).map((v) => ({ - providerId: v.providerId, - })); + return List.head(validators) + .chainNullable((v) => v.providerId) + .map((providerId) => ({ providerId })); } if ( @@ -109,7 +113,7 @@ export const useStakeEnterRequestDto = () => { tronResource: tronResource.extract(), amount: stakeAmount.toString(10), useMaxAmount: useMaxAmount || undefined, - providerId, + ...selectedProviderArgs, ...validatorsOrProvider, ...(additionalAddresses ?? {}), }, diff --git a/packages/widget/src/pages/details/positions-page/hooks/use-position-list-item.ts b/packages/widget/src/pages/details/positions-page/hooks/use-position-list-item.ts index 2d1fc2da..f537a884 100644 --- a/packages/widget/src/pages/details/positions-page/hooks/use-position-list-item.ts +++ b/packages/widget/src/pages/details/positions-page/hooks/use-position-list-item.ts @@ -20,9 +20,7 @@ export const usePositionListItem = ( const providersDetails = useProvidersDetails({ integrationData, - validatorsAddresses: Maybe.of( - item.type === "validators" ? item.validatorsAddresses : [] - ), + validators: Maybe.of(item.type === "validators" ? item.validators : []), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/details/positions-page/hooks/use-positions.ts b/packages/widget/src/pages/details/positions-page/hooks/use-positions.ts index 5b3394a6..2ee2b2ce 100644 --- a/packages/widget/src/pages/details/positions-page/hooks/use-positions.ts +++ b/packages/widget/src/pages/details/positions-page/hooks/use-positions.ts @@ -114,7 +114,10 @@ const positionsTableDataSelector = createSelector( token: Maybe; yieldLabelDto: Maybe; } & ( - | { type: "validators"; validatorsAddresses: string[] } + | { + type: "validators"; + validators: NonNullable; + } | { type: "default" } ))[] ) diff --git a/packages/widget/src/pages/position-details/hooks/use-position-details.ts b/packages/widget/src/pages/position-details/hooks/use-position-details.ts index e17380e5..1c1aeabb 100644 --- a/packages/widget/src/pages/position-details/hooks/use-position-details.ts +++ b/packages/widget/src/pages/position-details/hooks/use-position-details.ts @@ -128,11 +128,10 @@ export const usePositionDetails = () => { const providersDetails = useProvidersDetails({ integrationData, - validatorsAddresses: positionBalances.data.map((b) => { - return b.type === "validators" ? b.validatorsAddresses : []; + validators: positionBalances.data.map((b) => { + return b.type === "validators" ? b.validators : []; }), selectedProviderYieldId: Maybe.empty(), - validatorsData: Maybe.fromNullable(validatorsData), }); const personalizedRewardRate = useMemo( @@ -229,6 +228,9 @@ export const usePositionDetails = () => { return { integrationData, validatorsData: validatorsData ?? [], + hasMoreValidators: !!yieldValidators.hasNextPage, + isLoadingMoreValidators: yieldValidators.isFetchingNextPage, + onLoadMoreValidators: yieldValidators.fetchNextPage, reducedStakedOrLiquidBalance, positionBalancesByType, canUnstake, diff --git a/packages/widget/src/pages/position-details/position-details.page.tsx b/packages/widget/src/pages/position-details/position-details.page.tsx index ead3c428..8f3ebdd1 100644 --- a/packages/widget/src/pages/position-details/position-details.page.tsx +++ b/packages/widget/src/pages/position-details/position-details.page.tsx @@ -31,6 +31,9 @@ const PositionDetails = () => { onPendingActionAmountChange, integrationData, validatorsData, + hasMoreValidators, + isLoadingMoreValidators, + onLoadMoreValidators, isLoading, reducedStakedOrLiquidBalance, positionBalancesByType, @@ -330,6 +333,9 @@ const PositionDetails = () => { }} selectedStake={integrationData} validators={validatorsData} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} multiSelect={validatorAddressesHandling.multiSelect} state={validatorAddressesHandling.modalState} > diff --git a/packages/widget/src/pages/steps/pages/activity-steps.page.tsx b/packages/widget/src/pages/steps/pages/activity-steps.page.tsx index 1b41c0bd..878bb46b 100644 --- a/packages/widget/src/pages/steps/pages/activity-steps.page.tsx +++ b/packages/widget/src/pages/steps/pages/activity-steps.page.tsx @@ -1,10 +1,7 @@ import { useSelector } from "@xstate/store/react"; import { Maybe } from "purify-ts"; import { useMemo } from "react"; -import { - getActionInputToken, - getActionValidatorAddresses, -} from "../../../domain/types/action"; +import { getActionInputToken } from "../../../domain/types/action"; import { useTrackPage } from "../../../hooks/tracking/use-track-page"; import { useProvidersDetails } from "../../../hooks/use-provider-details"; import { useActivityContext } from "../../../providers/activity-provider"; @@ -25,11 +22,16 @@ export const ActivityStepsPage = () => { (state) => state.context.selectedYield ).unsafeCoerce(); + const selectedValidators = useSelector( + activityContext, + (state) => state.context.selectedValidators + ).unsafeCoerce(); + const providersDetails = useProvidersDetails({ integrationData: useMemo(() => Maybe.of(selectedYield), [selectedYield]), - validatorsAddresses: useMemo( - () => Maybe.of(getActionValidatorAddresses(selectedAction) ?? []), - [selectedAction] + validators: useMemo( + () => Maybe.of(selectedValidators), + [selectedValidators] ), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/steps/pages/pending-steps.page.tsx b/packages/widget/src/pages/steps/pages/pending-steps.page.tsx index ae34ece4..6be4a85d 100644 --- a/packages/widget/src/pages/steps/pages/pending-steps.page.tsx +++ b/packages/widget/src/pages/steps/pages/pending-steps.page.tsx @@ -28,8 +28,8 @@ export const PendingStepsPage = () => { () => Maybe.of(pendingRequest.integrationData), [pendingRequest.integrationData] ), - validatorsAddresses: positionBalances.data.map((p) => - p.type === "validators" ? p.validatorsAddresses : [] + validators: positionBalances.data.map((p) => + p.type === "validators" ? p.validators : [] ), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/pages/steps/pages/stake-steps.page.tsx b/packages/widget/src/pages/steps/pages/stake-steps.page.tsx index 27a0daa6..363dafe1 100644 --- a/packages/widget/src/pages/steps/pages/stake-steps.page.tsx +++ b/packages/widget/src/pages/steps/pages/stake-steps.page.tsx @@ -28,7 +28,7 @@ export const StakeStepsPage = () => { () => Maybe.of(enterRequest.selectedStake), [enterRequest.selectedStake] ), - validatorsAddresses: useMemo( + validators: useMemo( () => Maybe.of(enterRequest.selectedValidators), [enterRequest.selectedValidators] ), diff --git a/packages/widget/src/pages/steps/pages/unstake-steps.page.tsx b/packages/widget/src/pages/steps/pages/unstake-steps.page.tsx index 56baef36..42f7aa3d 100644 --- a/packages/widget/src/pages/steps/pages/unstake-steps.page.tsx +++ b/packages/widget/src/pages/steps/pages/unstake-steps.page.tsx @@ -27,8 +27,8 @@ export const UnstakeStepsPage = () => { () => Maybe.of(exitRequest.integrationData), [exitRequest.integrationData] ), - validatorsAddresses: positionBalances.data.map((p) => - p.type === "validators" ? p.validatorsAddresses : [] + validators: positionBalances.data.map((p) => + p.type === "validators" ? p.validators : [] ), selectedProviderYieldId: Maybe.empty(), }); diff --git a/packages/widget/src/providers/activity-provider/index.tsx b/packages/widget/src/providers/activity-provider/index.tsx index 4d7a167b..cf5364c5 100644 --- a/packages/widget/src/providers/activity-provider/index.tsx +++ b/packages/widget/src/providers/activity-provider/index.tsx @@ -2,22 +2,31 @@ import { createStore } from "@xstate/store"; import { Maybe } from "purify-ts"; import { createContext, type PropsWithChildren, useContext } from "react"; import type { ActionDto } from "../../domain/types/action"; +import type { ValidatorDto } from "../../domain/types/validators"; import type { Yield } from "../../domain/types/yields"; const store = createStore({ context: { selectedAction: Maybe.empty() as Maybe, selectedYield: Maybe.empty() as Maybe, + selectedValidators: Maybe.empty() as Maybe, }, on: { setSelectedAction: ( _, event: { - data: Maybe<{ selectedAction: ActionDto; selectedYield: Yield }>; + data: Maybe<{ + selectedAction: ActionDto; + selectedYield: Yield; + selectedValidators: ValidatorDto[]; + }>; } ) => ({ selectedAction: event.data.map(({ selectedAction }) => selectedAction), selectedYield: event.data.map(({ selectedYield }) => selectedYield), + selectedValidators: event.data.map( + ({ selectedValidators }) => selectedValidators + ), }), }, }); diff --git a/packages/widget/tests/hooks/validator-loading.test.tsx b/packages/widget/tests/hooks/validator-loading.test.tsx new file mode 100644 index 00000000..30add89a --- /dev/null +++ b/packages/widget/tests/hooks/validator-loading.test.tsx @@ -0,0 +1,385 @@ +import { HttpResponse, http } from "msw"; +import type { PropsWithChildren } from "react"; +import { + getYieldValidatorQueryKey, + useYieldValidators, +} from "../../src/hooks/api/use-yield-validators"; +import { SKApiClientProvider } from "../../src/providers/api/api-client-provider"; +import { + SKQueryClientProvider, + useSKQueryClient, +} from "../../src/providers/query-client"; +import { SettingsContextProvider } from "../../src/providers/settings"; +import type { SettingsContextType } from "../../src/providers/settings/types"; +import { + yieldApiValidatorFixture, + yieldApiValidatorsFixture, +} from "../fixtures"; +import { describe, expect, it } from "../utils/test-extend"; +import { renderHook } from "../utils/test-utils"; + +const yieldApiUrl = "https://yield.example.com"; + +const createWrapper = + (validatorsConfig?: SettingsContextType["validatorsConfig"]) => + ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + +const Wrapper = createWrapper(); + +const ConfiguredWrapper = createWrapper({ + ethereum: { + allowed: ["preferred-0", "allowed-200"], + }, +}); + +describe("validator loading", () => { + it("loads preferred validators first and defers non-preferred pages", async ({ + worker, + }) => { + const calls: Array<{ + limit: string | null; + offset: string | null; + preferred: string | null; + }> = []; + const preferredValidators = yieldApiValidatorsFixture( + Array.from({ length: 150 }, (_, index) => ({ + address: `preferred-${index}`, + preferred: true, + })) + ); + const otherValidators = yieldApiValidatorsFixture([ + { address: "other-0", preferred: false }, + ]); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const offset = Number(url.searchParams.get("offset") ?? 0); + const preferred = url.searchParams.get("preferred"); + + calls.push({ + limit: url.searchParams.get("limit"), + offset: url.searchParams.get("offset"), + preferred, + }); + + if (preferred === "false") { + return HttpResponse.json({ + items: otherValidators, + total: 10000, + offset, + limit: 100, + }); + } + + return HttpResponse.json({ + items: preferredValidators.slice(offset, offset + 100), + total: preferredValidators.length, + offset, + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => useYieldValidators({ yieldId: "yield-1", network: "ethereum" }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(150); + expect(result.current.hasNextPage).toBe(true); + + expect(calls).toContainEqual({ + limit: "100", + offset: "0", + preferred: "true", + }); + expect(calls).toContainEqual({ + limit: "1", + offset: "0", + preferred: "false", + }); + expect(calls).toContainEqual({ + limit: "100", + offset: "100", + preferred: "true", + }); + expect(calls).toHaveLength(3); + + await result.current.fetchNextPage(); + + await expect.poll(() => result.current.data?.length).toBe(151); + + expect(calls).toContainEqual({ + limit: "100", + offset: "0", + preferred: "false", + }); + }); + + it("hydrates the individual validator cache from loaded pages", async ({ + worker, + }) => { + const preferredValidator = yieldApiValidatorFixture({ + address: "preferred-0", + preferred: true, + }); + const otherValidator = yieldApiValidatorFixture({ + address: "other-0", + preferred: false, + }); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const preferred = url.searchParams.get("preferred"); + + if (preferred === "false") { + return HttpResponse.json({ + items: [otherValidator], + total: 1, + offset: 0, + limit: Number(url.searchParams.get("limit") ?? 100), + }); + } + + return HttpResponse.json({ + items: [preferredValidator], + total: 1, + offset: 0, + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => ({ + queryClient: useSKQueryClient(), + validators: useYieldValidators({ + yieldId: "yield-1", + network: "ethereum", + }), + }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.validators.data?.length).toBe(1); + + expect( + result.current.queryClient.getQueryData( + getYieldValidatorQueryKey({ + yieldId: "yield-1", + address: preferredValidator.address, + }) + ) + ).toEqual(preferredValidator); + expect( + result.current.queryClient.getQueryData( + getYieldValidatorQueryKey({ + yieldId: "yield-1", + address: otherValidator.address, + }) + ) + ).toEqual(otherValidator); + }); + + it("skips raw pages that are empty after validator config filtering", async ({ + worker, + }) => { + const calls: Array<{ + limit: string | null; + offset: string | null; + preferred: string | null; + }> = []; + const preferredValidators = yieldApiValidatorsFixture([ + { address: "preferred-0", preferred: true }, + ]); + const blockedValidators = (offset: number) => + yieldApiValidatorsFixture( + Array.from({ length: 100 }, (_, index) => ({ + address: `blocked-${offset + index}`, + preferred: false, + })) + ); + const allowedValidators = yieldApiValidatorsFixture([ + { address: "allowed-200", preferred: false }, + ]); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const offset = Number(url.searchParams.get("offset") ?? 0); + const preferred = url.searchParams.get("preferred"); + + calls.push({ + limit: url.searchParams.get("limit"), + offset: url.searchParams.get("offset"), + preferred, + }); + + if (preferred === "true") { + return HttpResponse.json({ + items: preferredValidators, + total: preferredValidators.length, + offset, + limit: 100, + }); + } + + return HttpResponse.json({ + items: + offset === 200 ? allowedValidators : blockedValidators(offset), + total: 201, + offset, + limit: Number(url.searchParams.get("limit") ?? 100), + }); + } + ) + ); + + const { result } = await renderHook( + () => useYieldValidators({ yieldId: "yield-1", network: "ethereum" }), + { wrapper: ConfiguredWrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(1); + expect(result.current.hasNextPage).toBe(true); + + await result.current.fetchNextPage(); + + await expect.poll(() => result.current.data?.length).toBe(2); + expect(result.current.hasNextPage).toBe(false); + expect(result.current.data?.map((validator) => validator.address)).toEqual([ + "preferred-0", + "allowed-200", + ]); + expect(calls).toContainEqual({ + limit: "100", + offset: "100", + preferred: "false", + }); + expect(calls).toContainEqual({ + limit: "100", + offset: "200", + preferred: "false", + }); + }); + + it("does not expose a next page when there are no non-preferred validators", async ({ + worker, + }) => { + const calls: Array<{ + limit: string | null; + preferred: string | null; + }> = []; + const preferredValidators = yieldApiValidatorsFixture([ + { address: "preferred-0", preferred: true }, + ]); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const preferred = url.searchParams.get("preferred"); + + calls.push({ + limit: url.searchParams.get("limit"), + preferred, + }); + + if (preferred === "false") { + return HttpResponse.json({ + items: [], + total: 0, + offset: 0, + limit: Number(url.searchParams.get("limit") ?? 100), + }); + } + + return HttpResponse.json({ + items: preferredValidators, + total: preferredValidators.length, + offset: 0, + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => useYieldValidators({ yieldId: "yield-1", network: "ethereum" }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(1); + + expect(result.current.hasNextPage).toBe(false); + expect(calls).toContainEqual({ limit: "1", preferred: "false" }); + }); + + it("searches validators on the server by name and address", async ({ + worker, + }) => { + const calls: Array<{ name: string | null; address: string | null }> = []; + const searchedValidator = yieldApiValidatorFixture({ + address: "searched-address", + name: "Searched Validator", + }); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const name = url.searchParams.get("name"); + const address = url.searchParams.get("address"); + + calls.push({ name, address }); + + return HttpResponse.json({ + items: name || address ? [searchedValidator] : [], + total: name || address ? 1 : 0, + offset: Number(url.searchParams.get("offset") ?? 0), + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => + useYieldValidators({ + yieldId: "yield-1", + network: "ethereum", + search: "searched", + }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(2); + + expect(calls).toContainEqual({ name: "searched", address: null }); + expect(calls).toContainEqual({ name: null, address: "searched" }); + }); +}); From 360505e6f3b2ac656b25ec9b9c8cc745ea06b582 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 28 May 2026 13:27:07 +0200 Subject: [PATCH 2/4] fix: keep validator selector stable during search --- .../molecules/select-validator/index.tsx | 37 ++- .../molecules/select-validator/styles.css.ts | 12 + .../src/hooks/api/use-yield-validators.ts | 7 +- .../widget/src/hooks/use-debounced-value.ts | 16 ++ .../utila-select-validator-section.tsx | 86 ++++--- .../select-validator-section/index.tsx | 92 ++++---- .../earn-page/state/earn-page-context.tsx | 12 +- .../src/translation/English/translations.json | 2 + .../src/translation/French/translations.json | 2 + .../select-validator-section.test.tsx | 220 ++++++++++++++++++ .../tests/hooks/use-debounced-value.test.tsx | 32 +++ .../tests/hooks/validator-loading.test.tsx | 91 +++++++- 12 files changed, 503 insertions(+), 106 deletions(-) create mode 100644 packages/widget/src/hooks/use-debounced-value.ts create mode 100644 packages/widget/tests/components/select-validator-section.test.tsx create mode 100644 packages/widget/tests/hooks/use-debounced-value.test.tsx diff --git a/packages/widget/src/components/molecules/select-validator/index.tsx b/packages/widget/src/components/molecules/select-validator/index.tsx index 35e91e75..248b4c08 100644 --- a/packages/widget/src/components/molecules/select-validator/index.tsx +++ b/packages/widget/src/components/molecules/select-validator/index.tsx @@ -3,13 +3,19 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ValidatorDto } from "../../../domain/types/validators"; import type { Yield } from "../../../domain/types/yields"; +import { Box } from "../../atoms/box"; import type { SelectModalProps } from "../../atoms/select-modal"; import { SelectModal } from "../../atoms/select-modal"; +import { Text } from "../../atoms/typography/text"; import type { GroupedItem } from "./select-validator-list"; import { SelectValidatorList } from "./select-validator-list"; +import { emptyState } from "./styles.css"; type SelectValidatorProps = PropsWithChildren< - Pick & { + Pick< + SelectModalProps, + "isLoading" | "onClose" | "onOpen" | "state" | "trigger" + > & { selectedValidators: Set; onItemClick: (item: ValidatorDto) => void; onViewMoreClick?: () => void; @@ -30,6 +36,7 @@ type SelectValidatorProps = PropsWithChildren< export const SelectValidator = ({ state, + isLoading, onClose, onOpen, trigger, @@ -157,6 +164,9 @@ export const SelectValidator = ({ fetchNextPage: onLoadMore, } : {}; + const emptyMessage = rest.searchValue?.trim().length + ? t("details.validators_no_results") + : t("details.validators_empty"); return ( - + {data.groupCounts.length ? ( + + ) : isLoading ? null : ( + + {emptyMessage} + + )} {children} diff --git a/packages/widget/src/components/molecules/select-validator/styles.css.ts b/packages/widget/src/components/molecules/select-validator/styles.css.ts index 05d16559..4a271225 100644 --- a/packages/widget/src/components/molecules/select-validator/styles.css.ts +++ b/packages/widget/src/components/molecules/select-validator/styles.css.ts @@ -4,6 +4,18 @@ import { vars } from "../../../styles/theme/contract.css"; export const validatorVirtuosoContainer = style([atoms({ marginTop: "2" })]); +export const emptyState = style([ + atoms({ + display: "flex", + justifyContent: "center", + alignItems: "center", + px: "4", + }), + { + minHeight: "180px", + }, +]); + const breakWord = style({ wordBreak: "break-all" }); export const modalItemNameContainer = style([ diff --git a/packages/widget/src/hooks/api/use-yield-validators.ts b/packages/widget/src/hooks/api/use-yield-validators.ts index 7238b182..94366d8b 100644 --- a/packages/widget/src/hooks/api/use-yield-validators.ts +++ b/packages/widget/src/hooks/api/use-yield-validators.ts @@ -1,4 +1,8 @@ -import { type QueryClient, useInfiniteQuery } from "@tanstack/react-query"; +import { + keepPreviousData, + type QueryClient, + useInfiniteQuery, +} from "@tanstack/react-query"; import type { ValidatorDto } from "../../domain/types/validators"; import type { ValidatorsConfig } from "../../domain/types/yields"; import { filterValidators, type Yield } from "../../domain/types/yields"; @@ -367,6 +371,7 @@ export const useYieldValidators = ({ search, }), enabled: enabled && !!yieldId, + placeholderData: keepPreviousData, select: (data) => data.pages.flatMap((page) => page.validators), }); }; diff --git a/packages/widget/src/hooks/use-debounced-value.ts b/packages/widget/src/hooks/use-debounced-value.ts new file mode 100644 index 00000000..82f10a94 --- /dev/null +++ b/packages/widget/src/hooks/use-debounced-value.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +export const useDebouncedValue = (value: T, delayMs: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timeoutId = window.setTimeout( + () => setDebouncedValue(value), + delayMs + ); + + return () => window.clearTimeout(timeoutId); + }, [delayMs, value]); + + return debouncedValue; +}; diff --git a/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx b/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx index 4957ae23..a2e9ef08 100644 --- a/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx +++ b/packages/widget/src/pages-dashboard/overview/earn-page/utila-select-validator-section.tsx @@ -1,8 +1,9 @@ import { Maybe } from "purify-ts"; -import { Box } from "../../../components/atoms/box"; -import { ContentLoaderSquare } from "../../../components/atoms/content-loader"; import { SelectValidator } from "../../../components/molecules/select-validator"; -import { isYieldActionArgRequired } from "../../../domain/types/yields"; +import { + isYieldActionArgRequired, + isYieldValidatorSelectionRequired, +} from "../../../domain/types/yields"; import { useSelectValidator } from "../../../pages/details/earn-page/components/select-validator-section/use-select-validator"; import { SelectValidatorTrigger } from "./utila-select-validator-trigger"; @@ -23,47 +24,44 @@ export const UtilaSelectValidatorSection = () => { onLoadMoreValidators, } = useSelectValidator(); - return isLoading ? ( - - - - ) : ( - Maybe.fromRecord({ selectedStake, validatorsData }) - .filter((val) => !!val.validatorsData.length) - .map((val) => { - const selectedValidatorsArr = [...selectedValidators.values()]; + const validators = validatorsData.orDefault([]); - const multiSelect = isYieldActionArgRequired( - val.selectedStake, - "enter", - "validatorAddresses" - ); + return Maybe.fromRecord({ selectedStake }) + .filter((val) => isYieldValidatorSelectionRequired(val.selectedStake)) + .map((val) => { + const selectedValidatorsArr = [...selectedValidators.values()]; - return ( - - } - selectedValidators={ - new Set(selectedValidatorsArr.map((v) => v.address)) - } - multiSelect={multiSelect} - selectedStake={val.selectedStake} - onItemClick={onItemClick} - onViewMoreClick={onViewMoreClick} - onClose={onClose} - onOpen={onOpen} - onSearch={onValidatorSearch} - searchValue={validatorSearch} - validators={val.validatorsData} - hasMore={hasMoreValidators} - isLoadingMore={isLoadingMoreValidators} - onLoadMore={onLoadMoreValidators} - /> - ); - }) - .extractNullable() - ); + const multiSelect = isYieldActionArgRequired( + val.selectedStake, + "enter", + "validatorAddresses" + ); + + return ( + + } + selectedValidators={ + new Set(selectedValidatorsArr.map((v) => v.address)) + } + multiSelect={multiSelect} + selectedStake={val.selectedStake} + onItemClick={onItemClick} + onViewMoreClick={onViewMoreClick} + onClose={onClose} + onOpen={onOpen} + onSearch={onValidatorSearch} + searchValue={validatorSearch} + isLoading={isLoading} + validators={validators} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} + /> + ); + }) + .extractNullable(); }; diff --git a/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx b/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx index edb1c221..723cdc00 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx +++ b/packages/widget/src/pages/details/earn-page/components/select-validator-section/index.tsx @@ -1,9 +1,8 @@ import { Maybe } from "purify-ts"; -import { Box } from "../../../../../components/atoms/box"; -import { ContentLoaderSquare } from "../../../../../components/atoms/content-loader"; import { SelectValidator } from "../../../../../components/molecules/select-validator"; import { isYieldActionArgRequired, + isYieldValidatorSelectionRequired, isYieldWithProviderOptions, } from "../../../../../domain/types/yields"; import { SelectValidatorTrigger } from "./select-validator-trigger"; @@ -27,52 +26,49 @@ export const SelectValidatorSection = () => { onLoadMoreValidators, } = useSelectValidator(); - return isLoading ? ( - - - - ) : ( - Maybe.fromRecord({ selectedStake, validatorsData }) - .filter((val) => !!val.validatorsData.length) - .map((val) => { - const selectedValidatorsArr = [...selectedValidators.values()]; + const validators = validatorsData.orDefault([]); - const multiSelect = isYieldActionArgRequired( - val.selectedStake, - "enter", - "validatorAddresses" - ); + return Maybe.fromRecord({ selectedStake }) + .filter((val) => isYieldValidatorSelectionRequired(val.selectedStake)) + .map((val) => { + const selectedValidatorsArr = [...selectedValidators.values()]; - return ( - - } - selectedValidators={ - new Set(selectedValidatorsArr.map((v) => v.address)) - } - multiSelect={multiSelect} - selectedStake={val.selectedStake} - onItemClick={onItemClick} - onViewMoreClick={onViewMoreClick} - onClose={onClose} - onOpen={onOpen} - onSearch={onValidatorSearch} - searchValue={validatorSearch} - validators={val.validatorsData} - hasMore={hasMoreValidators} - isLoadingMore={isLoadingMoreValidators} - onLoadMore={onLoadMoreValidators} - /> - ); - }) - .extractNullable() - ); + const multiSelect = isYieldActionArgRequired( + val.selectedStake, + "enter", + "validatorAddresses" + ); + + return ( + + } + selectedValidators={ + new Set(selectedValidatorsArr.map((v) => v.address)) + } + multiSelect={multiSelect} + selectedStake={val.selectedStake} + onItemClick={onItemClick} + onViewMoreClick={onViewMoreClick} + onClose={onClose} + onOpen={onOpen} + onSearch={onValidatorSearch} + searchValue={validatorSearch} + isLoading={isLoading} + validators={validators} + hasMore={hasMoreValidators} + isLoadingMore={isLoadingMoreValidators} + onLoadMore={onLoadMoreValidators} + /> + ); + }) + .extractNullable(); }; diff --git a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx index 580f22ce..bbdf0848 100644 --- a/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx +++ b/packages/widget/src/pages/details/earn-page/state/earn-page-context.tsx @@ -46,6 +46,7 @@ import { useYieldValidators } from "../../../../hooks/api/use-yield-validators"; import { useNavigateWithScrollToTop } from "../../../../hooks/navigation/use-navigate-with-scroll-to-top"; import { useTrackEvent } from "../../../../hooks/tracking/use-track-event"; import { useAddLedgerAccount } from "../../../../hooks/use-add-ledger-account"; +import { useDebouncedValue } from "../../../../hooks/use-debounced-value"; import { useEstimatedRewards } from "../../../../hooks/use-estimated-rewards"; import { useInitParams } from "../../../../hooks/use-init-params"; import { useMaxMinYieldAmount } from "../../../../hooks/use-max-min-yield-amount"; @@ -177,7 +178,13 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { const [tokenSearch, setTokenSearch] = useState(""); const deferredTokenSearch = useDeferredValue(tokenSearch); const [validatorSearch, setValidatorSearch] = useState(""); - const deferredValidatorSearch = useDeferredValue(validatorSearch); + const normalizedValidatorSearch = validatorSearch.trim(); + const debouncedValidatorSearch = useDebouncedValue( + normalizedValidatorSearch, + 300 + ); + const validatorSearchDebouncing = + normalizedValidatorSearch !== debouncedValidatorSearch; const multiYields = useStreamMultiYields( useMemo(() => availableYields.orDefault([]), [availableYields]) @@ -327,7 +334,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { enabled: shouldFetchValidators, yieldId: selectedStake.extract()?.id, network: selectedStake.extract()?.token.network, - search: deferredValidatorSearch, + search: debouncedValidatorSearch, }); const initialValidatorSelectionYieldIdRef = useRef(null); @@ -648,6 +655,7 @@ export const EarnPageContextProvider = ({ children }: PropsWithChildren) => { tokenBalancesScanLoading || initYieldRes.isLoading || yieldOpportunityLoading || + validatorSearchDebouncing || (shouldFetchValidators && yieldValidators.isLoading); const footerIsLoading = diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index d4ad99e1..d32437b4 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -159,6 +159,8 @@ "validators_view_all": "View all", "validators_inactive": "Inactive", "validators_jailed": "Jailed", + "validators_empty": "No validators available", + "validators_no_results": "No validators found", "validators_staked_balance": "Validator stake", "validators_voting_power": "Voting power", "validators_address": "Address", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index 50ad0f2d..d8f89115 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -156,6 +156,8 @@ "validators_view_all": "Tout voir", "validators_inactive": "Inactif", "validators_jailed": "Banni", + "validators_empty": "Aucun validateur disponible", + "validators_no_results": "Aucun validateur trouvé", "validators_staked_balance": "Solde validateur", "validators_voting_power": "Droit de vote", "validators_address": "Adresse", diff --git a/packages/widget/tests/components/select-validator-section.test.tsx b/packages/widget/tests/components/select-validator-section.test.tsx new file mode 100644 index 00000000..6fa24407 --- /dev/null +++ b/packages/widget/tests/components/select-validator-section.test.tsx @@ -0,0 +1,220 @@ +import { Maybe } from "purify-ts"; +import { I18nextProvider } from "react-i18next"; +import { vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import type { Yield } from "../../src/domain/types/yields"; +import { SelectValidatorSection } from "../../src/pages/details/earn-page/components/select-validator-section"; +import type { useSelectValidator } from "../../src/pages/details/earn-page/components/select-validator-section/use-select-validator"; +import { UtilaSelectValidatorSection } from "../../src/pages-dashboard/overview/earn-page/utila-select-validator-section"; +import { SettingsContextProvider } from "../../src/providers/settings"; +import { i18nInstance } from "../../src/translation"; +import { + legacyYieldFixture, + yieldApiValidatorFixture, + yieldApiYieldFixture, +} from "../fixtures"; +import { describe, expect, it } from "../utils/test-extend"; +import { render } from "../utils/test-utils"; + +const hookState = vi.hoisted(() => ({ + current: undefined as unknown as ReturnType, +})); + +vi.mock( + "../../src/pages/details/earn-page/components/select-validator-section/use-select-validator", + () => ({ + useSelectValidator: () => hookState.current, + }) +); + +const baseYield = yieldApiYieldFixture(); +const selectedStake = { + ...baseYield, + mechanics: { + ...baseYield.mechanics, + requiresValidatorSelection: true, + arguments: { + enter: { + fields: [ + { + label: "Validator", + name: "validatorAddress", + required: true, + type: "address", + }, + ], + }, + exit: { fields: [] }, + }, + }, + __fallback__: legacyYieldFixture(), +} as Yield; + +const createHookValue = ( + overrides: Partial> = {} +): ReturnType => ({ + isLoading: false, + onViewMoreClick: vi.fn(), + onClose: vi.fn(), + onOpen: vi.fn(), + onItemClick: vi.fn(), + onRemoveValidator: vi.fn(), + selectedValidators: new Map(), + selectedStake: Maybe.of(selectedStake), + onValidatorSearch: vi.fn(), + validatorsData: Maybe.of([yieldApiValidatorFixture()]), + validatorSearch: "", + hasMoreValidators: false, + isLoadingMoreValidators: false, + onLoadMoreValidators: vi.fn(), + ...overrides, +}); + +const renderSection = () => + render( + + + + + + ); + +const renderUtilaSection = () => + render( + + + + + + ); + +describe("SelectValidatorSection", () => { + it("keeps validator selection available when search has no results", async () => { + hookState.current = createHookValue({ + isLoading: false, + validatorsData: Maybe.of([]), + validatorSearch: "missing validator", + }); + + const app = await renderSection(); + + await expect.element(app.getByText("Earn with")).toBeInTheDocument(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByTestId("select-modal__search-input")) + .toHaveValue("missing validator"); + await expect + .element(app.getByText("No validators found")) + .toBeInTheDocument(); + }); + + it("keeps validator selection available while initial validators load", async () => { + hookState.current = createHookValue({ + isLoading: true, + validatorsData: Maybe.empty(), + validatorSearch: "", + }); + + const app = await renderSection(); + + await expect.element(app.getByText("Earn with")).toBeInTheDocument(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByTestId("select-modal__search-input")) + .toBeInTheDocument(); + await expect + .element(app.getByText("No validators available")) + .not.toBeInTheDocument(); + }); + + it("shows an empty state in the dialog when no validators are available", async () => { + hookState.current = createHookValue({ + isLoading: false, + validatorsData: Maybe.of([]), + validatorSearch: "", + }); + + const app = await renderSection(); + + const trigger = app.container.querySelector("button"); + expect(trigger).not.toBeNull(); + + await userEvent.click(trigger as HTMLButtonElement); + + await expect + .element(app.getByText("No validators available")) + .toBeInTheDocument(); + }); + + it("keeps the dashboard Change action when search has no results", async () => { + const selectedValidator = yieldApiValidatorFixture({ + address: "selected-validator", + name: "Selected Validator", + }); + + hookState.current = createHookValue({ + isLoading: false, + selectedValidators: new Map([ + [selectedValidator.address, selectedValidator], + ]), + validatorsData: Maybe.of([]), + validatorSearch: "missing validator", + }); + + const app = await renderUtilaSection(); + + await expect.element(app.getByText("Change")).toBeInTheDocument(); + + await userEvent.click(app.getByText("Change")); + + await expect + .element(app.getByTestId("select-modal__search-input")) + .toHaveValue("missing validator"); + await expect + .element(app.getByText("No validators found")) + .toBeInTheDocument(); + }); + + it("keeps the dashboard Change action while cleared search reloads validators", async () => { + const selectedValidator = yieldApiValidatorFixture({ + address: "selected-validator", + name: "Selected Validator", + }); + + hookState.current = createHookValue({ + isLoading: true, + selectedValidators: new Map([ + [selectedValidator.address, selectedValidator], + ]), + validatorsData: Maybe.of([]), + validatorSearch: "", + }); + + const app = await renderUtilaSection(); + + await expect.element(app.getByText("Change")).toBeInTheDocument(); + await expect + .element(app.getByText("Selected Validator")) + .toBeInTheDocument(); + }); +}); diff --git a/packages/widget/tests/hooks/use-debounced-value.test.tsx b/packages/widget/tests/hooks/use-debounced-value.test.tsx new file mode 100644 index 00000000..8077fd5e --- /dev/null +++ b/packages/widget/tests/hooks/use-debounced-value.test.tsx @@ -0,0 +1,32 @@ +import { useDebouncedValue } from "../../src/hooks/use-debounced-value"; +import { describe, expect, it, vi } from "../utils/test-extend"; +import { renderHook } from "../utils/test-utils"; + +describe("useDebouncedValue", () => { + it("updates only after the debounce delay", async () => { + vi.useFakeTimers(); + + try { + const hook = await renderHook( + (props) => useDebouncedValue(props?.value ?? "", 300), + { initialProps: { value: "" } } + ); + + expect(hook.result.current).toBe(""); + + await hook.rerender({ value: "ta" }); + + expect(hook.result.current).toBe(""); + + await hook.act(() => vi.advanceTimersByTime(299)); + + expect(hook.result.current).toBe(""); + + await hook.act(() => vi.advanceTimersByTime(1)); + + expect(hook.result.current).toBe("ta"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/widget/tests/hooks/validator-loading.test.tsx b/packages/widget/tests/hooks/validator-loading.test.tsx index 30add89a..b2568185 100644 --- a/packages/widget/tests/hooks/validator-loading.test.tsx +++ b/packages/widget/tests/hooks/validator-loading.test.tsx @@ -1,4 +1,4 @@ -import { HttpResponse, http } from "msw"; +import { delay, HttpResponse, http } from "msw"; import type { PropsWithChildren } from "react"; import { getYieldValidatorQueryKey, @@ -382,4 +382,93 @@ describe("validator loading", () => { expect(calls).toContainEqual({ name: "searched", address: null }); expect(calls).toContainEqual({ name: null, address: "searched" }); }); + + it("keeps previous validators while a new search is loading", async ({ + worker, + }) => { + const defaultValidator = yieldApiValidatorFixture({ + address: "default-address", + name: "Default Validator", + preferred: true, + }); + const searchedValidator = yieldApiValidatorFixture({ + address: "searched-address", + name: "Searched Validator", + }); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + async ({ request }) => { + const url = new URL(request.url); + const name = url.searchParams.get("name"); + const address = url.searchParams.get("address"); + const preferred = url.searchParams.get("preferred"); + + await delay(50); + + if (name === "searched") { + return HttpResponse.json({ + items: [searchedValidator], + total: 1, + offset: 0, + limit: 100, + }); + } + + if (address === "searched") { + return HttpResponse.json({ + items: [], + total: 0, + offset: 0, + limit: 100, + }); + } + + if (preferred === "false") { + return HttpResponse.json({ + items: [], + total: 0, + offset: 0, + limit: Number(url.searchParams.get("limit") ?? 100), + }); + } + + return HttpResponse.json({ + items: [defaultValidator], + total: 1, + offset: 0, + limit: 100, + }); + } + ) + ); + + const { result, rerender } = await renderHook( + (props) => + useYieldValidators({ + yieldId: "yield-1", + network: "ethereum", + search: props?.search, + }), + { + initialProps: { search: "" }, + wrapper: Wrapper, + } + ); + + await expect + .poll(() => result.current.data?.map((validator) => validator.address)) + .toEqual(["default-address"]); + + await rerender({ search: "searched" }); + + expect(result.current.data?.map((validator) => validator.address)).toEqual([ + "default-address", + ]); + + await expect + .poll(() => result.current.data?.map((validator) => validator.address)) + .toEqual(["searched-address"]); + }); }); From 662961f136e20e46947b2a224d331e7dc28c8ce2 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 28 May 2026 14:05:31 +0200 Subject: [PATCH 3/4] fix: review --- .../src/hooks/api/use-yield-validators.ts | 26 +++- .../widget/src/hooks/use-provider-details.ts | 2 +- .../tests/hooks/validator-loading.test.tsx | 127 +++++++++++++++++- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/packages/widget/src/hooks/api/use-yield-validators.ts b/packages/widget/src/hooks/api/use-yield-validators.ts index 94366d8b..d6e662f0 100644 --- a/packages/widget/src/hooks/api/use-yield-validators.ts +++ b/packages/widget/src/hooks/api/use-yield-validators.ts @@ -146,6 +146,24 @@ const getFilteredValidators = ({ }) : validators; +const deduplicateValidatorsByAddress = ( + validators: ReadonlyArray +) => { + const seenAddresses = new Set(); + + return validators.filter((validator) => { + const address = validator.address.toLowerCase(); + + if (seenAddresses.has(address)) { + return false; + } + + seenAddresses.add(address); + + return true; + }); +}; + const fetchPagedValidators = async ({ yieldId, network, @@ -186,11 +204,15 @@ const fetchPagedValidators = async ({ fetchPage({ offset, name: search }), fetchPage({ offset, address: search }), ]); + const items = deduplicateValidatorsByAddress([ + ...(namePage.items ?? []), + ...(addressPage.items ?? []), + ]); return { - total: Math.max(namePage.total, addressPage.total), + total: Math.max(namePage.total ?? 0, addressPage.total ?? 0), offset, - items: [...(namePage.items ?? []), ...(addressPage.items ?? [])], + items, }; } diff --git a/packages/widget/src/hooks/use-provider-details.ts b/packages/widget/src/hooks/use-provider-details.ts index 1f692786..72999772 100644 --- a/packages/widget/src/hooks/use-provider-details.ts +++ b/packages/widget/src/hooks/use-provider-details.ts @@ -83,7 +83,7 @@ export const getProviderDetails = ({ List.find((v) => v.id === selectedProviderYieldId, [...list]) ) ) - .map((v) => v.rewardRate.total + v.rewardRate.total) + .map((v) => v.rewardRate.total) .map<{ rewardRate: number | undefined; rewardType: RewardTypes }>( (res) => ({ rewardRate: res, diff --git a/packages/widget/tests/hooks/validator-loading.test.tsx b/packages/widget/tests/hooks/validator-loading.test.tsx index b2568185..c61c53d2 100644 --- a/packages/widget/tests/hooks/validator-loading.test.tsx +++ b/packages/widget/tests/hooks/validator-loading.test.tsx @@ -377,12 +377,137 @@ describe("validator loading", () => { { wrapper: Wrapper } ); - await expect.poll(() => result.current.data?.length).toBe(2); + await expect.poll(() => result.current.data?.length).toBe(1); expect(calls).toContainEqual({ name: "searched", address: null }); expect(calls).toContainEqual({ name: null, address: "searched" }); }); + it("uses server totals to paginate deduplicated search results", async ({ + worker, + }) => { + const calls: Array<{ + offset: string | null; + name: string | null; + address: string | null; + }> = []; + const firstValidator = yieldApiValidatorFixture({ + address: "searched-address-0", + name: "Searched Validator 0", + }); + const secondValidator = yieldApiValidatorFixture({ + address: "searched-address-100", + name: "Searched Validator 100", + }); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const offset = Number(url.searchParams.get("offset") ?? 0); + const name = url.searchParams.get("name"); + const address = url.searchParams.get("address"); + + calls.push({ + offset: url.searchParams.get("offset"), + name, + address, + }); + + if (name === "searched") { + return HttpResponse.json({ + items: offset === 100 ? [secondValidator] : [firstValidator], + total: 101, + offset, + limit: 100, + }); + } + + return HttpResponse.json({ + items: [], + total: 0, + offset, + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => + useYieldValidators({ + yieldId: "yield-1", + network: "ethereum", + search: "searched", + }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(1); + expect(result.current.hasNextPage).toBe(true); + + await result.current.fetchNextPage(); + + await expect.poll(() => result.current.data?.length).toBe(2); + expect(result.current.hasNextPage).toBe(false); + expect(calls).toContainEqual({ + offset: "100", + name: "searched", + address: null, + }); + expect(calls).toContainEqual({ + offset: "100", + name: null, + address: "searched", + }); + }); + + it("does not add independent search totals when checking for more pages", async ({ + worker, + }) => { + const nameValidator = yieldApiValidatorFixture({ + address: "searched-name-address", + name: "Searched Name Validator", + }); + const addressValidator = yieldApiValidatorFixture({ + address: "searched-address-address", + name: "Searched Address Validator", + }); + + worker.use( + http.get( + `${yieldApiUrl}/v1/yields/:yieldId/validators`, + ({ request }) => { + const url = new URL(request.url); + const name = url.searchParams.get("name"); + const address = url.searchParams.get("address"); + + return HttpResponse.json({ + items: name ? [nameValidator] : address ? [addressValidator] : [], + total: name || address ? 80 : 0, + offset: Number(url.searchParams.get("offset") ?? 0), + limit: 100, + }); + } + ) + ); + + const { result } = await renderHook( + () => + useYieldValidators({ + yieldId: "yield-1", + network: "ethereum", + search: "searched", + }), + { wrapper: Wrapper } + ); + + await expect.poll(() => result.current.data?.length).toBe(2); + + expect(result.current.hasNextPage).toBe(false); + }); + it("keeps previous validators while a new search is loading", async ({ worker, }) => { From ef0c79a4752ee9e36c09c39fb2c912337b5e6992 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 28 May 2026 17:54:10 +0200 Subject: [PATCH 4/4] feat(widget): add utila pending approvals notice --- .../src/pages/steps/pages/common.page.tsx | 25 ++++++++++++++++++- .../src/pages/steps/pages/styles.css.ts | 5 ++++ .../styles/theme/variant-overrides/utila.ts | 1 + .../src/styles/tokens/colors/contract.ts | 2 ++ .../widget/src/styles/tokens/colors/values.ts | 2 ++ .../src/translation/English/translations.json | 2 ++ .../src/translation/French/translations.json | 2 ++ 7 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/widget/src/pages/steps/pages/common.page.tsx b/packages/widget/src/pages/steps/pages/common.page.tsx index b195400a..f864bb39 100644 --- a/packages/widget/src/pages/steps/pages/common.page.tsx +++ b/packages/widget/src/pages/steps/pages/common.page.tsx @@ -3,15 +3,20 @@ import { useTranslation } from "react-i18next"; import { Box } from "../../../components/atoms/box"; import { Button } from "../../../components/atoms/button"; import { Heading } from "../../../components/atoms/typography/heading"; +import { Text } from "../../../components/atoms/typography/text"; import type { ActionDto } from "../../../domain/types/action"; import type { TokenDto, YieldTokenDto } from "../../../domain/types/tokens"; import type { useProvidersDetails } from "../../../hooks/use-provider-details"; import { AnimationPage } from "../../../navigation/containers/animation-page"; import { useIsDashboard } from "../../../pages-dashboard/providers/dashboard-context"; +import { useSettings } from "../../../providers/settings"; import { PageContainer } from "../../components/page-container"; import { useIsUnstakeOrPendingAction } from "../../position-details/state"; import { useSteps } from "../hooks/use-steps.hook"; -import { stepsHeadingContainer } from "./styles.css"; +import { + stepsHeadingContainer, + utilaPendingApprovalsBanner, +} from "./styles.css"; import { TxState } from "./tx-state"; type StepsPageProps = { @@ -29,6 +34,7 @@ export const StepsPage = ({ }: StepsPageProps) => { const isUnstakeOrPendingAction = useIsUnstakeOrPendingAction(); const isDashboard = useIsDashboard(); + const { variant } = useSettings(); const { retry, txStates } = useSteps({ inputToken, @@ -38,6 +44,7 @@ export const StepsPage = ({ }); const { t } = useTranslation(); + const showUtilaPendingApprovals = variant === "utila"; return ( @@ -55,6 +62,22 @@ export const StepsPage = ({ {t("steps.title")} + {showUtilaPendingApprovals && ( + + + {t("steps.pending_approvals")} + + + {t("steps.pending_approvals_desc")} + + + )} + = { tokenSelectBackground: vars.color.__internal__utila__grey__one__, dashboardDetailsSectionBackground: vars.color.__internal__utila__grey__one__, + warningBoxBackground: vars.color.__internal__utila__warning__background__, primaryButtonBackground: vars.color.__internal__utila__primary__blue__, primaryButtonColor: vars.color.white, diff --git a/packages/widget/src/styles/tokens/colors/contract.ts b/packages/widget/src/styles/tokens/colors/contract.ts index 165bf250..d8f49632 100644 --- a/packages/widget/src/styles/tokens/colors/contract.ts +++ b/packages/widget/src/styles/tokens/colors/contract.ts @@ -113,6 +113,7 @@ export const colorsContract: typeof baseColorsContract & { __internal__utila__max__button__text__: string; __internal__utila__badge__text__success__: string; __internal__utila__badge__text__error__: string; + __internal__utila__warning__background__: string; __internal__finery__grey__one__: string; __internal__finery__grey__two__: string; @@ -150,6 +151,7 @@ export const colorsContract: typeof baseColorsContract & { __internal__utila__max__button__text__: "", __internal__utila__badge__text__success__: "", __internal__utila__badge__text__error__: "", + __internal__utila__warning__background__: "", __internal__finery__grey__one__: "", __internal__finery__grey__two__: "", __internal__finery__grey__three__: "", diff --git a/packages/widget/src/styles/tokens/colors/values.ts b/packages/widget/src/styles/tokens/colors/values.ts index add5ebbf..6d603f0b 100644 --- a/packages/widget/src/styles/tokens/colors/values.ts +++ b/packages/widget/src/styles/tokens/colors/values.ts @@ -116,6 +116,7 @@ export const lightThemeColors: typeof colorsContract = { __internal__utila__max__button__text__: "#5C70FF", __internal__utila__badge__text__success__: "#4BAA82", __internal__utila__badge__text__error__: "#E73F4A", + __internal__utila__warning__background__: "#FFE9BD", __internal__finery__grey__one__: "#FDFDFD", __internal__finery__grey__two__: "#00000008", @@ -260,6 +261,7 @@ export const darkThemeColors: typeof colorsContract = { __internal__utila__max__button__text__: "#5C70FF", __internal__utila__badge__text__success__: "#4BAA82", __internal__utila__badge__text__error__: "#E73F4A", + __internal__utila__warning__background__: "#FFE9BD", __internal__finery__grey__one__: "#243034", __internal__finery__grey__two__: "#FFFFFF14", diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index d32437b4..99e15742 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -258,6 +258,8 @@ "tx_of_one": "Transaction - {{type}}", "tx_of_other": "Transaction {{current}} / {{count}} - {{type}}", "skipped": "Skipped", + "pending_approvals": "Pending approvals", + "pending_approvals_desc": "Please open your wallet to review and securely sign the transaction", "approve_error": "Something went wrong. Check if wallet is connected", "tx_type": { "SWAP": "SWAP", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index d8f89115..3549f3ce 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -208,6 +208,8 @@ "tx_of_one": "Transaction - {{type}}", "tx_of_other": "Transaction {{current}} / {{count}} - {{type}}", "skipped": "Ignorée", + "pending_approvals": "Approbations en attente", + "pending_approvals_desc": "Ouvrez votre portefeuille pour vérifier et signer la transaction en toute sécurité", "approve_error": "Une erreur s'est produite. Vérifiez si le portefeuille est bien connecté", "tx_type": { "SWAP": "ÉCHANGE",