diff --git a/apps/app/components/Icons/CheckIcon.tsx b/apps/app/components/Icons/CheckIcon.tsx new file mode 100644 index 00000000..27b5b98a --- /dev/null +++ b/apps/app/components/Icons/CheckIcon.tsx @@ -0,0 +1,9 @@ +import { SVGProps } from "react"; + +const CheckIcon = (props: SVGProps) => ( + + + +) + +export default CheckIcon; diff --git a/apps/app/components/Icons/ConnectorIcons.tsx b/apps/app/components/Icons/ConnectorIcons.tsx index 4066d09a..448b0473 100644 --- a/apps/app/components/Icons/ConnectorIcons.tsx +++ b/apps/app/components/Icons/ConnectorIcons.tsx @@ -16,17 +16,19 @@ import OpenMask from "./Wallets/OpenMask"; import TON from "./Wallets/TON"; import MyTonWallet from "./Wallets/MyTonWallet"; import GlowIcon from "./Wallets/Glow"; +import LogoPlaceholder from "./LogoPlaceholder"; + export const ResolveConnectorIcon = ({ connector, iconClassName, className, }: { - connector: string; + connector?: string; iconClassName: string; className?: string; }) => { - switch (connector.toLowerCase()) { + switch (connector?.toLowerCase()) { case KnownConnectors.EVM: return ( @@ -72,8 +74,24 @@ export const ResolveConnectorIcon = ({ ); + case KnownConnectors.Aztec: + return ( + + + + + + + ); default: - return <>; + return ( + + + + + + + ); } }; @@ -88,4 +106,5 @@ const KnownConnectors = { Solana: "solana", Glow: "glow", Fuel: "fuel", + Aztec: "aztec", }; \ No newline at end of file diff --git a/apps/app/components/Icons/GlobeIcon.tsx b/apps/app/components/Icons/GlobeIcon.tsx new file mode 100644 index 00000000..7684a494 --- /dev/null +++ b/apps/app/components/Icons/GlobeIcon.tsx @@ -0,0 +1,9 @@ +import { SVGProps } from "react"; + +const GlobeIcon = (props: SVGProps) => ( + + + +); + +export default GlobeIcon; diff --git a/apps/app/components/Icons/InfoIcon.tsx b/apps/app/components/Icons/InfoIcon.tsx new file mode 100644 index 00000000..86863dd6 --- /dev/null +++ b/apps/app/components/Icons/InfoIcon.tsx @@ -0,0 +1,7 @@ +import { SVGProps } from "react"; + +const InfoIcon = (props: SVGProps) => + +; + +export default InfoIcon; diff --git a/apps/app/components/Icons/TokenIcon.tsx b/apps/app/components/Icons/TokenIcon.tsx new file mode 100644 index 00000000..fd54cda3 --- /dev/null +++ b/apps/app/components/Icons/TokenIcon.tsx @@ -0,0 +1,14 @@ +import { SVGProps } from "react"; + +const TokenIcon = (props: SVGProps) => ( + + + + + + + + +); + +export default TokenIcon; diff --git a/apps/app/components/Input/Address/AddressPicker/ConnectedWallets/ConnectWalletButton.tsx b/apps/app/components/Input/Address/AddressPicker/ConnectedWallets/ConnectWalletButton.tsx index d5ed0158..1d33a57a 100644 --- a/apps/app/components/Input/Address/AddressPicker/ConnectedWallets/ConnectWalletButton.tsx +++ b/apps/app/components/Input/Address/AddressPicker/ConnectedWallets/ConnectWalletButton.tsx @@ -1,51 +1,64 @@ import { RefreshCw } from "lucide-react"; import { ResolveConnectorIcon } from "../../../../Icons/ConnectorIcons"; -import { Network } from "../../../../../Models/Network"; import { FC, useState } from "react"; import { Wallet, WalletProvider } from "../../../../../Models/WalletProvider"; import { useConnectModal } from "../../../../WalletModal"; -type Props = { - provider: WalletProvider, +interface Props extends React.ButtonHTMLAttributes { + provider?: WalletProvider, onConnect?: (wallet: Wallet) => void, + descriptionText?: string } -const ConnectWalletButton: FC = ({ provider, onConnect }) => { - const { connect } = useConnectModal() +const ConnectWalletButton: FC = ({ provider, onConnect, descriptionText, ...rest }) => { const [isLoading, setIsLoading] = useState(false) + const { connect } = useConnectModal() + const isProviderReady = provider?.ready ?? true const handleConnect = async () => { + if (!isProviderReady) return setIsLoading(true) const result = await connect(provider) if (onConnect && result) onConnect(result) setIsLoading(false) } - return <> - - + + } -export default ConnectWalletButton \ No newline at end of file +export default ConnectWalletButton diff --git a/apps/app/components/Input/RoutePicker/Content.tsx b/apps/app/components/Input/RoutePicker/Content.tsx index 7b4d6992..832b2929 100644 --- a/apps/app/components/Input/RoutePicker/Content.tsx +++ b/apps/app/components/Input/RoutePicker/Content.tsx @@ -7,6 +7,8 @@ import Row from "./Rows"; import { Network, Token } from "@/Models/Network"; import RouteSearch from "./RouteSearch"; import NavigatableList from "@/components/NavigatableList"; +import useWallet from "@/hooks/useWallet"; +import ConnectWalletButton from "@/components/Input/Address/AddressPicker/ConnectedWallets/ConnectWalletButton"; type ContentProps = { onSelect: (network: Network, token: Token) => Promise | void; @@ -32,19 +34,22 @@ export const Content: FC = (props) => { shouldFocus={true} direction={props.direction} /> - + } type ItemsProps = ContentProps & { + isScrolling: boolean; onScroll: () => void; setIsItemsScrolling: (isScrolling: boolean) => void; } -const Items: FC = ({ searchQuery, setSearchQuery, rowElements, selectedToken, selectedNetwork, direction, onSelect, onScroll, setIsItemsScrolling }) => { +const Items: FC = ({ searchQuery, setSearchQuery, rowElements, selectedToken, selectedNetwork, direction, onSelect, isScrolling, onScroll, setIsItemsScrolling }) => { const parentRef = useRef(null) const [openValues, setOpenValues] = useState(selectedNetwork ? [selectedNetwork] : []) const scrollTimeoutRef = useRef(null) + const { wallets, providers } = useWallet() + const isProvidersReady = providers.every(p => p.ready) const isSingleNetwork = useMemo(() => { if (!searchQuery) return false; @@ -76,7 +81,7 @@ const Items: FC = ({ searchQuery, setSearchQuery, rowElements, selec count: rowElements.length, estimateSize: (index) => { const item = rowElements[index]; - const key = (item as any)?.network?.name || (item as any)?.symbol; + const key = (item as any)?.network?.caip2Id || (item as any)?.symbol; const isOpen = openValues.includes(key); // Better size estimation based on open state if (isOpen && (item.type === 'network' || item.type === 'grouped_token')) { @@ -109,15 +114,22 @@ const Items: FC = ({ searchQuery, setSearchQuery, rowElements, selec } scrollTimeoutRef.current = setTimeout(() => { setIsItemsScrolling(false); - }, 150); + }, 1000); }; return (
+ {wallets.length === 0 && direction === 'from' && !searchQuery && + + }
@@ -140,7 +152,7 @@ const Items: FC = ({ searchQuery, setSearchQuery, rowElements, selec }}> {items.map((virtualRow) => { const data = rowElements?.[virtualRow.index] - const key = ((data as any)?.network as any)?.name || virtualRow.key; + const key = ((data as any)?.network as any)?.caip2Id || (data as any)?.symbol || virtualRow.key; return
= ({ direction }) => { + const [openModal, setOpenModal] = useState(false); + const { values, setFieldValue } = useFormikContext(); + const swapAccounts = useSwapAccounts(direction); + const selectSwapAccount = useSelectSwapAccount(direction); + const { connect } = useConnectModal(); + + const connectWallet = async () => { + const result = await connect(); + if (result) { + handleSelectAccount({ + walletId: result.id, + address: result.address, + providerName: result.providerName, + }); + } + }; + + const handleSelectAccount = (props: SelectAccountProps) => { + const { walletId, address, providerName } = props; + if (direction == 'to' && Address.isValid(address, values.to)) + setFieldValue(`destination_address`, address); + selectSwapAccount({ id: walletId, address, providerName }); + setOpenModal(false); + }; + + return ( + <> + setOpenModal(true)} /> + + + + {swapAccounts.map((account, index) => ( +
+
+ +
+ +
+ ))} +
+
+ + ); +}; + +const AccountsPickerButton: FC<{ accounts: (Wallet | AccountIdentity)[], onOpenModalClick: () => void }> = ({ accounts, onOpenModalClick }) => { + const firstWallet = useMemo(() => accounts[0], [accounts]); + + if (accounts.length > 0) { + return ( + + ); + } + + return ( + +
+ +
+
+ ); +}; + +type AccountsListProps = { + selectedAccount: AccountIdentity; + onSelect: (props: SelectAccountProps) => void; + network?: Network; +}; + +const AccountsList: FC = ({ selectedAccount, onSelect, network }) => { + const provider = selectedAccount.provider; + const connectedWallets: (Wallet | AccountIdentity)[] = provider.connectedWallets || []; + + const isAccountDuplicate = connectedWallets.some( + w => w.addresses.some(a => Address.equals(a, selectedAccount.address, network ?? null)) + ); + + const accounts: (Wallet | AccountIdentity)[] = [ + ...connectedWallets, + ...(!isAccountDuplicate ? [selectedAccount] : []), + ]; + + return ( +
+ {accounts.length > 0 && +
+ {accounts.map((wallet, index) => ( + + ))} +
+ } +
+ ); +}; + +export default PickerWalletConnect; diff --git a/apps/app/components/Input/RoutePicker/RouteSortingMenu.tsx b/apps/app/components/Input/RoutePicker/RouteSortingMenu.tsx new file mode 100644 index 00000000..a94111c4 --- /dev/null +++ b/apps/app/components/Input/RoutePicker/RouteSortingMenu.tsx @@ -0,0 +1,107 @@ +import { FC, useState } from "react"; +import { useRouteSortingStore, SortingOption } from "@/stores/routeSortingStore"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/shadcn/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/shadcn/tooltip"; +import { ArrowUpDown } from "lucide-react"; +import InfoIcon from "@/components/Icons/InfoIcon"; +import CheckIcon from "@/components/Icons/CheckIcon"; +import clsx from "clsx"; + +const sortingOptions: Array<{ + value: SortingOption; + label: string; + showInfo?: boolean; + infoText?: string; +}> = [ + { + value: SortingOption.RELEVANCE, + label: 'Relevance', + showInfo: true, + infoText: 'Sorted by balance for "from" direction, by usage history for "to" direction' + }, + { + value: SortingOption.MOST_USED, + label: 'Most Used' + }, + { + value: SortingOption.ALPHABETICAL_ASC, + label: 'Alphabetical A-Z' + }, + { + value: SortingOption.ALPHABETICAL_DESC, + label: 'Alphabetical Z-A' + } + ]; + +const RouteSortingMenu: FC = () => { + const [open, setOpen] = useState(false); + const sortingOption = useRouteSortingStore((s) => s.sortingOption); + const setSortingOption = useRouteSortingStore((s) => s.setSortingOption); + + const handleSelect = (option: SortingOption) => { + setSortingOption(option); + setOpen(false); + }; + + return ( + + + + + +
+ {sortingOptions.map((option) => ( + + ))} +
+
+
+ ); +}; + +export default RouteSortingMenu; diff --git a/apps/app/components/Input/RoutePicker/RouteTokenSwitch.tsx b/apps/app/components/Input/RoutePicker/RouteTokenSwitch.tsx new file mode 100644 index 00000000..50df1164 --- /dev/null +++ b/apps/app/components/Input/RoutePicker/RouteTokenSwitch.tsx @@ -0,0 +1,48 @@ +import { FC } from "react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/shadcn/tooltip"; +import clsx from "clsx"; +import { useRouteTokenSwitchStore } from "@/stores/routeTokenSwitchStore"; +import GlobeIcon from "@/components/Icons/GlobeIcon"; +import TokenIcon from "@/components/Icons/TokenIcon"; + +const switchValues = [ + { value: false, id: 'network', label: "Group by Network", Icon: GlobeIcon }, + { value: true, id: 'token', label: "Group by Token", Icon: TokenIcon }, +] + +const RouteTokenSwitch: FC = () => { + const showTokens = useRouteTokenSwitchStore((s) => s.showTokens) + const setShowTokens = useRouteTokenSwitchStore((s) => s.setShowTokens) + const activeTab = switchValues.find(item => item.value === showTokens)?.id || switchValues[0].id; + + return ( +
+
+ {switchValues.map((item, index) => ( + + { setShowTokens(item.value); }} + className={clsx( + "z-10 flex items-center justify-center rounded-lg px-4 py-1 relative outline-hidden transition-colors duration-200", + activeTab === item.id ? "bg-secondary-400" : "bg-transparent" + )} + > + + + +

{item.label}

+
+
+ ))} +
+
+ ); +}; + +export default RouteTokenSwitch; diff --git a/apps/app/components/Input/RoutePicker/Routes.tsx b/apps/app/components/Input/RoutePicker/Routes.tsx index 9a6879d3..ad997864 100644 --- a/apps/app/components/Input/RoutePicker/Routes.tsx +++ b/apps/app/components/Input/RoutePicker/Routes.tsx @@ -7,10 +7,11 @@ import { ImageWithFallback } from "@/components/Common/ImageWithFallback"; import { useBalance } from "@/lib/balances/useBalance"; import { useSwapAccounts } from "@/context/swapAccounts"; import { memo, useMemo } from "react"; -import { RowElement } from "@/Models/Route"; +import { GroupedTokenElement, RowElement } from "@/Models/Route"; import { resolveTokenLogoUrl } from "@/components/utils/resolveTokenLogoUrl"; import { formatUsd } from "@/components/utils/formatUsdAmount"; import { getTotalBalanceInUSD } from "@/helpers/balanceHelper"; +import { getKey, useBalanceStore } from "@/stores/balanceStore"; type TokenItemProps = { network: Network; @@ -152,6 +153,102 @@ export const NetworkRouteSelectItemDisplay = (props: NetworkItemProps) => { ); }; +export const GroupedTokenHeader = ({ + item, + direction, + hideTokenImages, +}: { + item: GroupedTokenElement; + direction: SwapDirection; + hideTokenImages?: boolean; +}) => { + const swapAccounts = useSwapAccounts(direction); + const tokens = item.items; + const balances = useBalanceStore(s => s.balances); + + const getAddress = (caip2Id: string) => + swapAccounts.find(w => + (direction === 'from' ? w.provider?.withdrawalSupportedNetworks : w.provider?.autofillSupportedNetworks) + ?.includes(caip2Id) + )?.address; + + const networksWithBalance: Network[] = Array.from( + new Map( + tokens + .map(({ data }) => { + const address = getAddress(data.network.caip2Id); + const key = address ? getKey(address, data.network) : 'unknown'; + const balanceEntry = balances?.[key]?.data?.balances?.find( + b => b.token === data.token.symbol && b.amount && b.amount >= 0 + ); + return balanceEntry ? [data.network.caip2Id, data.network] as const : null; + }) + .filter((e): e is readonly [string, Network] => !!e) + ).values() + ); + + const tokenBalances = tokens.reduce((acc, { data }) => { + const address = getAddress(data.network.caip2Id); + const key = address ? getKey(address, data.network) : 'unknown'; + const balanceEntry = balances?.[key]?.data?.balances?.find(b => b.token === data.token.symbol); + if (!balanceEntry?.amount) return acc; + return { sum: acc.sum + balanceEntry.amount * (data.token.priceInUsd || 0), hasValue: true }; + }, { sum: 0, hasValue: false }); + + const mainToken = tokens[0]?.data.token; + if (!mainToken) return null; + + const hasLoadedBalances = tokenBalances.hasValue && Number(tokenBalances.sum) >= 0; + const showNetworkIcons = hasLoadedBalances && networksWithBalance.length > 0; + + return ( + + + + <> + {mainToken.symbol} + {hasLoadedBalances ? ( +
+ + {formatUsd(tokenBalances.sum)} + + {showNetworkIcons && ( +
+ {networksWithBalance.slice(0, 3).map((network, index) => ( + + ))} + {networksWithBalance.length > 3 && ( +
+ +{networksWithBalance.length - 3} +
+ )} +
+ )} +
+ ) : <>} +
+
+ ); +}; + type SelectedRouteDisplayProps = { network?: Network; token?: Token; diff --git a/apps/app/components/Input/RoutePicker/Rows/CollapsableHeader.tsx b/apps/app/components/Input/RoutePicker/Rows/CollapsableHeader.tsx index 0aadfa82..adea5e33 100644 --- a/apps/app/components/Input/RoutePicker/Rows/CollapsableHeader.tsx +++ b/apps/app/components/Input/RoutePicker/Rows/CollapsableHeader.tsx @@ -1,8 +1,6 @@ import { SwapDirection } from "@/components/DTOs/SwapFormValues"; import { NetworkElement, GroupedTokenElement } from "@/Models/Route"; -import { NetworkRouteSelectItemDisplay } from "../Routes"; -import { resolveTokenLogoUrl } from "@/components/utils/resolveTokenLogoUrl"; -import { ImageWithFallback } from "@/components/Common/ImageWithFallback"; +import { NetworkRouteSelectItemDisplay, GroupedTokenHeader } from "../Routes"; type Props = { item: NetworkElement | GroupedTokenElement; @@ -21,22 +19,11 @@ export const CollapsableHeader = ({ item, direction, hideTokenImages }: Props) = ); } - // grouped_token case - simplified for now - const mainToken = item.items[0]?.data.token; - if (!mainToken) return null; - return ( -
-
- -
-
- {mainToken.symbol} -
-
+ ); }; diff --git a/apps/app/components/Input/RoutePicker/Rows/TitleRow.tsx b/apps/app/components/Input/RoutePicker/Rows/TitleRow.tsx index f5cedf61..26373bea 100644 --- a/apps/app/components/Input/RoutePicker/Rows/TitleRow.tsx +++ b/apps/app/components/Input/RoutePicker/Rows/TitleRow.tsx @@ -1,13 +1,41 @@ import { TitleElement } from "@/Models/Route"; +import { useBalanceStore } from "@/stores/balanceStore"; +import RouteSortingMenu from "../RouteSortingMenu"; +import RouteTokenSwitch from "../RouteTokenSwitch"; type Props = { item: TitleElement } const TitleRow = ({ item }: Props) => { + const isLoadingBalances = useBalanceStore(s => s.sortingDataIsLoading); + + if (item.text.toLowerCase().includes("suggestions")) { + if (isLoadingBalances) { + return ( +
+ Suggestions +
+ ); + } + return ( +
+ Suggestions +
+ ); + } + return ( -
-

{item.text}

+
+
+

{item.text}

+ {item.text.toLowerCase().includes("all") && } +
+ {item.text.toLowerCase().includes("all") && ( +
+ +
+ )}
); } diff --git a/apps/app/components/Input/RoutePicker/index.tsx b/apps/app/components/Input/RoutePicker/index.tsx index 58e3077b..4b21ab53 100644 --- a/apps/app/components/Input/RoutePicker/index.tsx +++ b/apps/app/components/Input/RoutePicker/index.tsx @@ -10,6 +10,7 @@ import clsx from "clsx"; import useWallet from "@/hooks/useWallet"; import useSuggestionsLimit from "@/hooks/useSuggestionsLimit"; import Balance from "@/components/Input/Amount/Balance"; +import PickerWalletConnect from "./PickerWalletConnect"; const RoutePicker: FC<{ direction: SwapDirection, className?: string }> = ({ direction, className }) => { const { @@ -17,9 +18,9 @@ const RoutePicker: FC<{ direction: SwapDirection, className?: string }> = ({ dir setFieldValue, } = useFormikContext(); const [searchQuery, setSearchQuery] = useState("") - const { wallets } = useWallet() - const { suggestionsLimit } = useSuggestionsLimit({ hasWallet: wallets.length > 0, }); + + const { suggestionsLimit } = useSuggestionsLimit({ hasWallet: wallets.length > 0 }); const { isLoading, networkElements, selectedNetwork, selectedToken } = useFormNetworks({ direction, values }, searchQuery, suggestionsLimit) const currencyFieldName = direction === 'from' ? 'fromCurrency' : 'toCurrency'; @@ -43,6 +44,7 @@ const RoutePicker: FC<{ direction: SwapDirection, className?: string }> = ({ dir } > {({ closeModal }) => ( ((props {(header || showCloseButton) && (
-
+
{header}
{showCloseButton && ( diff --git a/apps/app/components/Swap/Atomic/index.tsx b/apps/app/components/Swap/Atomic/index.tsx index f883890b..26887cc6 100644 --- a/apps/app/components/Swap/Atomic/index.tsx +++ b/apps/app/components/Swap/Atomic/index.tsx @@ -21,6 +21,7 @@ import { NetworkContractType } from "@/Models/Network"; import { HTLCStatus } from "@/Models/HTLCStatus"; import { usePulsatingCircles } from "@/stores/pulsatingCirclesStore"; import AtomicPage from "../AtomicChat"; +import { useRecentNetworksStore } from "@/stores/recentRoutesStore"; export default function Form() { const formikRef = useRef>(null); @@ -40,6 +41,7 @@ export default function Form() { const clearTempSwap = useSwapStore(s => s.clearTempSwap) const setTempSwap = useSwapStore(s => s.setTempSwap) const { setPulseState } = usePulsatingCircles(); + const updateRecentNetworks = useRecentNetworksStore(s => s.updateRecentNetworks); useEffect(() => { if (swapModalOpen) { @@ -101,6 +103,11 @@ export default function Form() { } const formattedReceiveAmount = quote?.receiveAmount ? formatUnits(BigInt(quote?.receiveAmount), values.toCurrency.decimals) : undefined + updateRecentNetworks({ + from: values.from && values.fromCurrency ? { network: values.from.caip2Id, token: values.fromCurrency.symbol } : undefined, + to: values.to && values.toCurrency ? { network: values.to.caip2Id, token: values.toCurrency.symbol } : undefined, + }); + setTempSwap({ requestedAmount: values.amount, address: values.destination_address, diff --git a/apps/app/hooks/useAllWithdrawalBalances.ts b/apps/app/hooks/useAllWithdrawalBalances.ts new file mode 100644 index 00000000..16560f90 --- /dev/null +++ b/apps/app/hooks/useAllWithdrawalBalances.ts @@ -0,0 +1,53 @@ +import { useSettingsState } from "../context/settings" +import { selectResolvedSortingBalances, useBalanceStore } from "@/stores/balanceStore" +import { useEffect, useMemo, useRef } from "react" +import { Network } from "@/Models/Network" +import { NetworkBalance } from "@/Models/Balance" +import { useSwapAccounts } from "@/context/swapAccounts" + +export default function useAllWithdrawalBalances() { + const networks = useSettingsState().networks + const swapAccounts = useSwapAccounts("from") + const walletNetworks = useMemo(() => { + return swapAccounts.map(account => { + const withdrawalNetworks = account.walletWithdrawalSupportedNetworks + if (!withdrawalNetworks || withdrawalNetworks.length === 0) return [] + return withdrawalNetworks.map(caip2Id => { + const network = networks.find(n => n.caip2Id === caip2Id) + if (!network) return null + return { + address: account.address, + network, + } + }) + }).flat().filter(item => item !== null) as Array<{ address: string, network: Network }> + }, [swapAccounts, networks]) + + const walletNetworksString = useMemo(() => { + return walletNetworks.map(item => `${item.address}-${item.network.caip2Id}`).join(',') + }, [walletNetworks]) + + useEffect(() => { + if (walletNetworks.length > 0) + useBalanceStore.getState().initSortingBalances(walletNetworks) + }, [walletNetworksString]) + + useEffect(() => { + return () => { + useBalanceStore.getState().cleanupSortingBalances() + } + }, []) + + const lastBalancesRef = useRef | null>(null) + const resolvedBalances = useBalanceStore(selectResolvedSortingBalances) + const isLoading = useBalanceStore(s => s.sortingDataIsLoading) + const partialPublished = useBalanceStore(s => s.partialPublished) + + if (resolvedBalances != null && Object.keys(resolvedBalances).length > 0) { + lastBalancesRef.current = resolvedBalances + } + + const result = resolvedBalances === null && isLoading ? lastBalancesRef.current : resolvedBalances + + return useMemo(() => ({ isLoading, balances: result, partialPublished }), [result, isLoading, partialPublished]) +} diff --git a/apps/app/hooks/useFormNetworks.ts b/apps/app/hooks/useFormNetworks.ts index 328b2a5e..1aea6ea6 100644 --- a/apps/app/hooks/useFormNetworks.ts +++ b/apps/app/hooks/useFormNetworks.ts @@ -6,9 +6,16 @@ import { RowElement, NetworkTokenElement, TitleElement, + GroupedTokenElement, + TokenSkeletonElement, } from "../Models/Route"; import { useQueryState } from "../context/query"; import { Network, Token } from "../Models/Network"; +import { NetworkBalance } from "../Models/Balance"; +import { useRouteSortingStore, SortingOption } from "@/stores/routeSortingStore"; +import { useRouteTokenSwitchStore } from "@/stores/routeTokenSwitchStore"; +import { useRecentNetworksStore, RoutesHistory } from "@/stores/recentRoutesStore"; +import useAllWithdrawalBalances from "./useAllWithdrawalBalances"; type Props = { direction: SwapDirection; @@ -24,6 +31,12 @@ export default function useFormNetworks( const query = useQueryState(); const { lockFrom, lockTo, lockFromAsset, lockToAsset } = query; + const groupByToken = useRouteTokenSwitchStore(s => s.showTokens); + const sortingOption = useRouteSortingStore(s => s.sortingOption); + const { balances, isLoading, partialPublished } = useAllWithdrawalBalances(); + const routesHistory = useRecentNetworksStore(s => s.recentRoutes); + const loadingSuggestions = !partialPublished && isLoading && direction === "from"; + // Apply query-based filtering (for locked params only) const filteredNetworks = useMemo(() => { return filterNetworksByQuery(networks, direction, { @@ -43,10 +56,15 @@ export default function useFormNetworks( groupNetworks({ networks: filteredNetworks, direction, + balances, + groupBy: groupByToken ? "token" : "network", + recents: routesHistory, + balancesLoading: loadingSuggestions, search, suggestionsLimit, + sortingOption, }), - [filteredNetworks, direction, search, suggestionsLimit] + [filteredNetworks, direction, balances, groupByToken, routesHistory, loadingSuggestions, search, suggestionsLimit, sortingOption] ); const selectedNetwork = useMemo(() => resolveSelectedNetwork(values, direction), [values, direction]); @@ -54,7 +72,7 @@ export default function useFormNetworks( return useMemo(() => ({ allNetworks: filteredNetworks, - isLoading: false, // No API call needed + isLoading: false, networkElements, selectedNetwork, selectedToken, @@ -91,7 +109,6 @@ function filterNetworksByQuery( if (!hasNetworkLock && !hasAssetLock) return networks; - // Resolve locked network and asset const lockedNetworkSlug = direction === 'from' ? (lockFrom && from ? from.toLowerCase() : undefined) : (lockTo && to ? to.toLowerCase() : undefined); @@ -119,63 +136,125 @@ function filterNetworksByQuery( type GroupNetworksProps = { networks: Network[]; direction: SwapDirection; + balances: Record | null; + groupBy: 'token' | 'network'; + recents: RoutesHistory; + balancesLoading: boolean; search?: string; suggestionsLimit?: number; + sortingOption?: SortingOption; } -function groupNetworks({ networks, direction, search, suggestionsLimit = 4 }: GroupNetworksProps): RowElement[] { +function groupNetworks({ + networks, + direction, + balances, + groupBy, + recents, + balancesLoading, + search, + suggestionsLimit = 4, + sortingOption = SortingOption.RELEVANCE, +}: GroupNetworksProps): RowElement[] { if (search) { - return resolveSearch(networks, search); + return resolveSearch(networks, search, direction, balances, recents); } - // Get suggestions (alphabetical for now - can be enhanced with balance-based) - const suggestedTokens = getSuggestedTokens(networks, direction, suggestionsLimit); + const suggestedTokens = getSuggestedTokens(networks, balances, recents, direction, balancesLoading, suggestionsLimit); - // Group all networks - const groupedNetworks = resolveNetworkElements(networks); + if (groupBy === "token") { + const groupedTokens = resolveTokenNetworks(networks, balances, direction, recents, sortingOption); + return mergeGroups(suggestedTokens, groupedTokens); + } + const groupedNetworks = resolveNetworkElements(networks, balances, direction, recents, sortingOption); return mergeGroups(suggestedTokens, groupedNetworks); } const mergeGroups = ( - suggestedTokens: NetworkTokenElement[], - allNetworks: NetworkElement[] + suggestedTokens: (NetworkTokenElement | TokenSkeletonElement)[], + allRoutes: GroupedTokenElement[] | NetworkElement[] ) => { + const allRoutesTitle = allRoutes.find(() => true)?.type === "grouped_token" ? 'All Tokens' : 'All Networks'; return [ ...(suggestedTokens.length ? [resolveTitle('Suggestions'), ...suggestedTokens] : []), - resolveTitle('All Networks'), - ...allNetworks + resolveTitle(allRoutesTitle), + ...allRoutes ]; } -const resolveNetworkElements = (networks: Network[]): NetworkElement[] => { - // Sort networks alphabetically - const sorted = [...networks].sort((a, b) => - a.displayName.localeCompare(b.displayName) - ); +// ---------- Network Mode ---------- - return sorted.map(n => ({ +const resolveNetworkElements = ( + networks: Network[], + balances: Record | null, + direction: SwapDirection, + routesHistory: RoutesHistory, + sortingOption: SortingOption = SortingOption.RELEVANCE +): NetworkElement[] => { + const sortedNetworks = sortNetworks(networks, sortingOption, direction, balances, routesHistory); + return sortedNetworks.map(n => ({ type: 'network' as const, network: { ...n, - tokens: [...n.tokens].sort((a, b) => a.symbol.localeCompare(b.symbol)) + tokens: sortTokens(n.tokens, n, sortingOption, direction, balances, routesHistory) } })); } -// ---------- Search ---------- +// ---------- Token Mode ---------- + +const resolveTokenNetworks = ( + networks: Network[], + balances: Record | null, + direction: SwapDirection, + routesHistory: RoutesHistory, + sortingOption: SortingOption = SortingOption.RELEVANCE +): GroupedTokenElement[] => { + const grouped = groupByTokens(networks); + return sortGroupedTokens(grouped, sortingOption, direction, balances, routesHistory); +} + +function groupByTokens(networks: Network[]): GroupedTokenElement[] { + const tokenMap: Record = {}; + for (const network of networks) { + for (const token of network.tokens || []) { + const el: NetworkTokenElement = { type: 'network_token', data: { token, network } }; + if (!tokenMap[token.symbol]) tokenMap[token.symbol] = []; + tokenMap[token.symbol].push(el); + } + } + return Object.entries(tokenMap).map(([symbol, items]) => ({ + type: 'grouped_token' as const, + symbol, + items + })); +} -function resolveSearch(networks: Network[], search: string): RowElement[] { - const matchedNetworks = searchInNetworks(networks, search); - const matchedTokens = searchInTokens(networks, search); +// ---------- Search ---------- +function resolveSearch( + networks: Network[], + search: string, + direction: SwapDirection, + balances: Record | null, + routesHistory: RoutesHistory +): RowElement[] { + const matchedNetworks = searchInNetworks(networks, search, direction, balances); + const matchedTokens = searchInTokens(networks, search) + .sort(sortSuggestedTokenElements(direction, balances, routesHistory)); return [ ...(matchedNetworks.length ? [resolveTitle('Networks'), ...matchedNetworks] : []), ...(matchedTokens.length ? [resolveTitle('Tokens'), ...matchedTokens] : []) ]; } -const searchInNetworks = (networks: Network[], search: string): NetworkElement[] => { +const searchInNetworks = ( + networks: Network[], + search: string, + direction: SwapDirection, + balances: Record | null +): NetworkElement[] => { const lower = search.toLowerCase().trim(); return networks.filter(n => { @@ -186,7 +265,9 @@ const searchInNetworks = (networks: Network[], search: string): NetworkElement[] type: 'network' as const, network: { ...n, - tokens: [...n.tokens].sort((a, b) => a.symbol.localeCompare(b.symbol)) + tokens: (direction === "from" && balances) + ? sortNetworkTokensByBalance(n, balances) + : [...n.tokens].sort((a, b) => a.symbol.localeCompare(b.symbol)) } })); } @@ -200,7 +281,6 @@ const searchInTokens = (networks: Network[], search: string): NetworkTokenElemen const symbolMatch = token.symbol.toLowerCase().includes(lower); const contractMatch = token.contractAddress?.toLowerCase().includes(lower); - // Support combo search like "USDC ethereum" const splitted = lower.split(' '); const firstpart = splitted?.[0]; const secondpart = splitted?.[1]; @@ -224,13 +304,31 @@ const searchInTokens = (networks: Network[], search: string): NetworkTokenElemen }); }); - return elements.sort((a, b) => - a.data.token.symbol.localeCompare(b.data.token.symbol) - ); + return elements; }; // ---------- Suggestions ---------- +function getSuggestedTokens( + networks: Network[], + balances: Record | null, + routesHistory: RoutesHistory, + direction: SwapDirection, + balancesLoading: boolean, + limit: number +): (NetworkTokenElement | TokenSkeletonElement)[] { + const effectiveLimit = Math.max(4, limit); + + if (direction === "from") { + if (!balancesLoading && !balances) return []; + if (balancesLoading) return Array(effectiveLimit).fill({ type: "skeleton_token" as const }); + } + + const tokenElements = extractTokenElementsAsSuggested(networks); + const sorted = tokenElements.sort(sortSuggestedTokenElements(direction, balances, routesHistory)); + return sorted.slice(0, effectiveLimit); +} + const extractTokenElementsAsSuggested = (networks: Network[]): NetworkTokenElement[] => networks.flatMap(network => (network.tokens || []).map(token => ({ @@ -239,28 +337,333 @@ const extractTokenElementsAsSuggested = (networks: Network[]): NetworkTokenEleme })) ); -const sortSuggestedTokenElements = (direction: SwapDirection) => - (a: NetworkTokenElement, b: NetworkTokenElement) => { - // 1. Sort by priceInUsd descending (higher value tokens first) - const aPrice = a.data.token.priceInUsd ?? 0; - const bPrice = b.data.token.priceInUsd ?? 0; - if (aPrice !== bPrice) { - return bPrice - aPrice; +const sortSuggestedTokenElements = ( + direction: SwapDirection, + balances: Record | null, + routesHistory: RoutesHistory +) => (a: NetworkTokenElement, b: NetworkTokenElement) => { + if (direction === "from" && balances) { + const aBalance = getNetworkTokenElementBalance(a, balances); + const bBalance = getNetworkTokenElementBalance(b, balances); + if (aBalance !== bBalance) return bBalance - aBalance; + } + if (routesHistory) { + const aUsed = getUsedCount(a, routesHistory, direction); + const bUsed = getUsedCount(b, routesHistory, direction); + if (aUsed !== bUsed) return bUsed - aUsed; + } + // Fallback: sort by price descending, then alphabetical + const aPrice = a.data.token.priceInUsd ?? 0; + const bPrice = b.data.token.priceInUsd ?? 0; + if (aPrice !== bPrice) return bPrice - aPrice; + return a.data.token.symbol.localeCompare(b.data.token.symbol); +} + +const getNetworkTokenElementBalance = (item: NetworkTokenElement, balances: Record) => { + return (balances[item.data.network.caip2Id]?.balances?.find(b => b.token === item.data.token.symbol)?.amount || 0) * (item.data.token.priceInUsd || 0); +} + +const getUsedCount = (item: NetworkTokenElement, history: RoutesHistory, direction: SwapDirection) => { + return direction === "from" + ? history.sourceRoutes?.[item.data.network.caip2Id]?.[item.data.token.symbol] || 0 + : history.destinationRoutes?.[item.data.network.caip2Id]?.[item.data.token.symbol] || 0; +} + +// ---------- Sorting ---------- + +const BALANCE_EPSILON = 0.001; // sub-cent threshold for floating-point comparison + +function resolveTokenUSDBalance(network: Network, token: Token, balances: Record): number { + const networkBalance = balances?.[network.caip2Id]?.balances || []; + const match = networkBalance.find(b => b.token === token.symbol); + return match?.amount && match.amount > 0 ? match.amount * (token.priceInUsd || 0) : 0; +} + +function sortNetworks( + networks: Network[], + sortingOption: SortingOption, + direction: SwapDirection, + balances: Record | null, + routesHistory: RoutesHistory +): Network[] { + switch (sortingOption) { + case SortingOption.RELEVANCE: + return sortNetworksByRelevance(networks, balances, routesHistory, direction); + case SortingOption.MOST_USED: + return sortNetworksByMostUsed(networks, routesHistory, direction); + case SortingOption.TRENDING: + case SortingOption.ALPHABETICAL_ASC: + return sortNetworksAlphabetically(networks, true); + case SortingOption.ALPHABETICAL_DESC: + return sortNetworksAlphabetically(networks, false); + default: + return networks; + } +} + +function sortNetworksByRelevance( + networks: Network[], + balances: Record | null, + routesHistory: RoutesHistory, + direction: SwapDirection +): Network[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const history = routesHistory[historyKey] || {}; + + const balanceMap = new Map(); + if (direction === 'from' && balances) { + for (const network of networks) { + const networkBal = balances[network.caip2Id]; + const total = networkBal?.balances?.reduce((sum, b) => { + const token = network.tokens.find(t => t.symbol === b.token); + return sum + ((b.amount || 0) * (token?.priceInUsd || 0)); + }, 0) || 0; + balanceMap.set(network.caip2Id, total); } + } - // 2. Alphabetical fallback - return a.data.token.symbol.localeCompare(b.data.token.symbol); - }; + const usageMap = new Map(); + for (const network of networks) { + usageMap.set(network.caip2Id, Object.values(history[network.caip2Id] || {}).reduce((sum, count) => sum + count, 0)); + } -function getSuggestedTokens( + return [...networks].sort((a, b) => { + if (direction === 'from' && balances) { + const balanceDiff = (balanceMap.get(b.caip2Id) || 0) - (balanceMap.get(a.caip2Id) || 0); + if (Math.abs(balanceDiff) > BALANCE_EPSILON) return balanceDiff; + } + + const aUsage = usageMap.get(a.caip2Id) || 0; + const bUsage = usageMap.get(b.caip2Id) || 0; + if (aUsage !== bUsage) return bUsage - aUsage; + + return a.displayName.localeCompare(b.displayName); + }); +} + +function sortNetworksByMostUsed( networks: Network[], + routesHistory: RoutesHistory, + direction: SwapDirection +): Network[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const history = routesHistory[historyKey] || {}; + + const usageMap = new Map(); + for (const network of networks) { + usageMap.set(network.caip2Id, Object.values(history[network.caip2Id] || {}).reduce((sum, count) => sum + count, 0)); + } + + return [...networks].sort((a, b) => { + const aUsage = usageMap.get(a.caip2Id) || 0; + const bUsage = usageMap.get(b.caip2Id) || 0; + if (bUsage !== aUsage) return bUsage - aUsage; + return a.displayName.localeCompare(b.displayName); + }); +} + +function sortNetworksAlphabetically(networks: Network[], ascending: boolean): Network[] { + return [...networks].sort((a, b) => { + const comparison = a.displayName.localeCompare(b.displayName); + return ascending ? comparison : -comparison; + }); +} + +function sortTokens( + tokens: Token[], + network: Network, + sortingOption: SortingOption, direction: SwapDirection, - limit: number + balances: Record | null, + routesHistory: RoutesHistory +): Token[] { + switch (sortingOption) { + case SortingOption.RELEVANCE: + return sortTokensByRelevance(tokens, network, balances, routesHistory, direction); + case SortingOption.MOST_USED: + return sortTokensByMostUsed(tokens, network, routesHistory, direction); + case SortingOption.TRENDING: + case SortingOption.ALPHABETICAL_ASC: + return [...tokens].sort((a, b) => a.symbol.localeCompare(b.symbol)); + case SortingOption.ALPHABETICAL_DESC: + return [...tokens].sort((a, b) => b.symbol.localeCompare(a.symbol)); + default: + return tokens; + } +} + +function sortTokensByRelevance( + tokens: Token[], + network: Network, + balances: Record | null, + routesHistory: RoutesHistory, + direction: SwapDirection +): Token[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const routeHistory = routesHistory[historyKey]?.[network.caip2Id] || {}; + + return [...tokens].sort((a, b) => { + if (direction === 'from' && balances) { + const aBalance = resolveTokenUSDBalance(network, a, balances); + const bBalance = resolveTokenUSDBalance(network, b, balances); + const balanceDiff = bBalance - aBalance; + if (Math.abs(balanceDiff) > BALANCE_EPSILON) return balanceDiff; + } + + const aUsage = routeHistory[a.symbol] || 0; + const bUsage = routeHistory[b.symbol] || 0; + if (aUsage !== bUsage) return bUsage - aUsage; + + return a.symbol.localeCompare(b.symbol); + }); +} + +function sortTokensByMostUsed( + tokens: Token[], + network: Network, + routesHistory: RoutesHistory, + direction: SwapDirection +): Token[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const routeHistory = routesHistory[historyKey]?.[network.caip2Id] || {}; + + return [...tokens].sort((a, b) => { + const aUsage = routeHistory[a.symbol] || 0; + const bUsage = routeHistory[b.symbol] || 0; + if (bUsage !== aUsage) return bUsage - aUsage; + return a.symbol.localeCompare(b.symbol); + }); +} + +function sortNetworkTokensByBalance(network: Network, balances: Record): Token[] { + return [...(network.tokens || [])].sort((a, b) => { + const balanceA = resolveTokenUSDBalance(network, a, balances); + const balanceB = resolveTokenUSDBalance(network, b, balances); + if (balanceB !== balanceA) return balanceB - balanceA; + return a.symbol.localeCompare(b.symbol); + }); +} + +function sortGroupedTokens( + tokenElements: GroupedTokenElement[], + sortingOption: SortingOption, + direction: SwapDirection, + balances: Record | null, + routesHistory: RoutesHistory +): GroupedTokenElement[] { + const groupsWithSortedItems = tokenElements.map(group => { + const sortedItems = sortGroupedTokenItems(group.items, sortingOption, direction, balances, routesHistory); + const totalUSD = balances + ? sortedItems.reduce((sum, item) => sum + resolveTokenUSDBalance(item.data.network, item.data.token, balances), 0) + : 0; + return { ...group, items: sortedItems, totalUSD }; + }); + + switch (sortingOption) { + case SortingOption.RELEVANCE: + return sortGroupedTokensByRelevance(groupsWithSortedItems, balances, routesHistory, direction); + case SortingOption.MOST_USED: { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const history = routesHistory[historyKey] || {}; + const historyValues = Object.values(history); + const symbolUsageMap = new Map(); + for (const g of groupsWithSortedItems) { + symbolUsageMap.set(g.symbol, historyValues.reduce((sum, routes) => sum + (routes[g.symbol] || 0), 0)); + } + return groupsWithSortedItems.sort((a, b) => { + const aUsage = symbolUsageMap.get(a.symbol) || 0; + const bUsage = symbolUsageMap.get(b.symbol) || 0; + return bUsage - aUsage || a.symbol.localeCompare(b.symbol); + }); + } + case SortingOption.TRENDING: + case SortingOption.ALPHABETICAL_ASC: + return groupsWithSortedItems.sort((a, b) => a.symbol.localeCompare(b.symbol)); + case SortingOption.ALPHABETICAL_DESC: + return groupsWithSortedItems.sort((a, b) => b.symbol.localeCompare(a.symbol)); + default: + return groupsWithSortedItems; + } +} + +function sortGroupedTokenItems( + items: NetworkTokenElement[], + sortingOption: SortingOption, + direction: SwapDirection, + balances: Record | null, + routesHistory: RoutesHistory ): NetworkTokenElement[] { - const effectiveLimit = Math.max(4, limit); - const tokenElements = extractTokenElementsAsSuggested(networks); - const sorted = tokenElements.sort(sortSuggestedTokenElements(direction)); - return sorted.slice(0, effectiveLimit); + switch (sortingOption) { + case SortingOption.RELEVANCE: + return sortTokenItemsByRelevance(items, balances, routesHistory, direction); + case SortingOption.MOST_USED: { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + return [...items].sort((a, b) => { + const aUsage = routesHistory[historyKey]?.[a.data.network.caip2Id]?.[a.data.token.symbol] || 0; + const bUsage = routesHistory[historyKey]?.[b.data.network.caip2Id]?.[b.data.token.symbol] || 0; + return bUsage - aUsage || a.data.network.displayName.localeCompare(b.data.network.displayName); + }); + } + case SortingOption.TRENDING: + case SortingOption.ALPHABETICAL_ASC: + return [...items].sort((a, b) => a.data.network.displayName.localeCompare(b.data.network.displayName)); + case SortingOption.ALPHABETICAL_DESC: + return [...items].sort((a, b) => b.data.network.displayName.localeCompare(a.data.network.displayName)); + default: + return items; + } +} + +function sortGroupedTokensByRelevance( + groups: (GroupedTokenElement & { totalUSD: number })[], + balances: Record | null, + routesHistory: RoutesHistory, + direction: SwapDirection +): GroupedTokenElement[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + const history = routesHistory[historyKey] || {}; + const historyValues = Object.values(history); + const symbolUsageMap = new Map(); + for (const g of groups) { + symbolUsageMap.set(g.symbol, historyValues.reduce((sum, routes) => sum + (routes[g.symbol] || 0), 0)); + } + + return [...groups].sort((a, b) => { + if (direction === 'from') { + const usdDiff = b.totalUSD - a.totalUSD; + if (Math.abs(usdDiff) > BALANCE_EPSILON) return usdDiff; + } + + const aUsage = symbolUsageMap.get(a.symbol) || 0; + const bUsage = symbolUsageMap.get(b.symbol) || 0; + if (aUsage !== bUsage) return bUsage - aUsage; + + return a.symbol.localeCompare(b.symbol); + }); +} + +function sortTokenItemsByRelevance( + items: NetworkTokenElement[], + balances: Record | null, + routesHistory: RoutesHistory, + direction: SwapDirection +): NetworkTokenElement[] { + const historyKey = direction === 'from' ? 'sourceRoutes' : 'destinationRoutes'; + + return [...items].sort((a, b) => { + if (direction === 'from' && balances) { + const aBalance = resolveTokenUSDBalance(a.data.network, a.data.token, balances); + const bBalance = resolveTokenUSDBalance(b.data.network, b.data.token, balances); + const balanceDiff = bBalance - aBalance; + if (Math.abs(balanceDiff) > BALANCE_EPSILON) return balanceDiff; + } + + const aUsage = routesHistory[historyKey]?.[a.data.network.caip2Id]?.[a.data.token.symbol] || 0; + const bUsage = routesHistory[historyKey]?.[b.data.network.caip2Id]?.[b.data.token.symbol] || 0; + if (aUsage !== bUsage) return bUsage - aUsage; + + return a.data.network.displayName.localeCompare(b.data.network.displayName); + }); } // ---------- Resolvers ---------- diff --git a/apps/app/lib/balances/providers/evmBalanceProvider.ts b/apps/app/lib/balances/providers/evmBalanceProvider.ts index 18b44b5e..8bf0e465 100644 --- a/apps/app/lib/balances/providers/evmBalanceProvider.ts +++ b/apps/app/lib/balances/providers/evmBalanceProvider.ts @@ -107,7 +107,7 @@ export class EVMBalanceProvider extends BalanceProvider { const amount = balances[1][index] if (amount >= 0) { - const formattedAmount = formatUnits(BigInt(amount), token.decimals) + const formattedAmount = Number(formatUnits(BigInt(amount), token.decimals)) return { network: network.caip2Id, token: token.symbol, diff --git a/apps/app/stores/recentRoutesStore.ts b/apps/app/stores/recentRoutesStore.ts new file mode 100644 index 00000000..aec5ca1f --- /dev/null +++ b/apps/app/stores/recentRoutesStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware'; + +export type RoutesHistory = { + sourceRoutes: RouteItem, + destinationRoutes: RouteItem +} + +type RouteItem = { + [key: string]: { + [key: string]: number + } +} + +type UpdateHistoryArgs = { + from: { network: string, token: string } | undefined, + to: { network: string, token: string } | undefined, +} + +interface RecentNetworksState { + recentRoutes: RoutesHistory; + updateRecentNetworks: (args: UpdateHistoryArgs) => void; +} + +export const useRecentNetworksStore = create()(persist((set) => ({ + recentRoutes: { + sourceRoutes: {}, + destinationRoutes: {}, + }, + updateRecentNetworks: (args: UpdateHistoryArgs) => { + set(state => ({ + recentRoutes: updateRecentNetworksHelper(state.recentRoutes, args) + })) + } +}), { + name: 'recentRoutes', + storage: createJSONStorage(() => localStorage), +})) + +const updateRecentNetworksHelper = ( + prev: RoutesHistory, + data: UpdateHistoryArgs +): RoutesHistory => { + const { from, to } = data + return { + sourceRoutes: { + ...prev.sourceRoutes, + ...(from ? { + [from.network]: { + ...prev.sourceRoutes[from.network], + [from.token]: (prev.sourceRoutes?.[from.network]?.[from.token] || 0) + 1 + } + } : {}) + }, + destinationRoutes: { + ...prev.destinationRoutes, + ...(to ? { + [to.network]: { + ...prev.destinationRoutes[to.network], + [to.token]: (prev.destinationRoutes?.[to.network]?.[to.token] || 0) + 1 + } + } : {}) + }, + }; +}