diff --git a/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx b/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx index 3ef87a114..0544ec520 100644 --- a/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx +++ b/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx @@ -13,6 +13,7 @@ import { RateLine } from './components/RateLine'; import { SenderReceiverLine } from './components/SenderReceiverLine'; import { FeeLine } from './components/FeeLine'; import { ExplorerInfo } from './components/ExplorerInfo'; +import { LocalActionView } from './components/LocalActionView'; const dateFormatter = new Intl.DateTimeFormat('en', { year: 'numeric', @@ -97,6 +98,11 @@ export function ActionDetailedView({ ) : null} ) : null} + diff --git a/src/ui/pages/History/ActionDetailedView/components/LocalActionView.tsx b/src/ui/pages/History/ActionDetailedView/components/LocalActionView.tsx new file mode 100644 index 000000000..a683505a3 --- /dev/null +++ b/src/ui/pages/History/ActionDetailedView/components/LocalActionView.tsx @@ -0,0 +1,137 @@ +import React, { useMemo } from 'react'; +import type { ActionTransfers } from 'defi-sdk'; +import { useAssetsPrices } from 'defi-sdk'; +import { useStore } from '@store-unit/react'; +import type { SwapLocalAction } from 'src/ui/transactions/local-actions-store'; +import { + localActionsStore, + LocalActionsStore, + type LocalAction, +} from 'src/ui/transactions/local-actions-store'; +import { Button } from 'src/ui/ui-kit/Button'; +import { UnstyledLink } from 'src/ui/ui-kit/UnstyledLink'; +import { VStack } from 'src/ui/ui-kit/VStack'; +import RetryIcon from 'jsx:src/ui/assets/actions/swap.svg'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { createChain } from 'src/modules/networks/Chain'; +import { useCurrency } from 'src/modules/currency/useCurrency'; +import { commonToBase } from 'src/shared/units/convert'; +import { getDecimals } from 'src/modules/networks/asset'; +import type { ClientTransactionStatus } from 'src/modules/ethereum/transactions/addressAction'; +import { TransferInfo } from './TransferInfo'; + +function SwapLocalActionView({ + localAction, + showTransferInfo, + status, +}: { + localAction: SwapLocalAction; + showTransferInfo?: boolean; + status: ClientTransactionStatus; +}) { + const link = LocalActionsStore.getActionLink(localAction); + const chain = createChain(localAction.chain); + const { currency } = useCurrency(); + const { value, isLoading } = useAssetsPrices({ + asset_codes: [localAction.spendTokenId, localAction.receiveTokenId], + currency, + }); + + const transfers = useMemo(() => { + const spendToken = value?.[localAction.spendTokenId]; + const receiveToken = value?.[localAction.receiveTokenId]; + return { + incoming: receiveToken + ? [ + { + asset: { fungible: receiveToken }, + quantity: commonToBase( + localAction.receiveInput, + getDecimals({ asset: receiveToken, chain }) + ).toFixed(), + price: receiveToken.price?.value || null, + }, + ] + : [], + outgoing: spendToken + ? [ + { + asset: { fungible: spendToken }, + quantity: commonToBase( + localAction.spendInput, + getDecimals({ asset: spendToken, chain }) + ).toFixed(), + price: spendToken.price?.value || null, + }, + ] + : [], + }; + }, [value, chain, localAction]); + + const isFailed = status === 'failed' || status === 'dropped'; + + if (isLoading) { + return null; + } + + return ( + + {showTransferInfo ? ( + + {transfers.outgoing ? ( + + ) : null} + {transfers.incoming ? ( + + ) : null} + + ) : null} + {link ? ( + + ) : null} + + ); +} + +export function LocalActionView({ + localActionKey, + showTransferInfo, + status, +}: { + localActionKey: string; + showTransferInfo?: boolean; + status: ClientTransactionStatus; +}) { + const { localActions } = useStore(localActionsStore); + + const localAction = localActions[localActionKey] as LocalAction | undefined; + + if (localAction?.kind === 'swap') { + return ( + + ); + } + return null; +} diff --git a/src/ui/pages/SwapForm/SuccessState/SuccessState.tsx b/src/ui/pages/SwapForm/SuccessState/SuccessState.tsx index 99d896401..9a54b28ab 100644 --- a/src/ui/pages/SwapForm/SuccessState/SuccessState.tsx +++ b/src/ui/pages/SwapForm/SuccessState/SuccessState.tsx @@ -10,14 +10,19 @@ import { SuccessStateLoader } from 'src/ui/shared/forms/SuccessState/SuccessStat import { SuccessStateToken } from 'src/ui/shared/forms/SuccessState/SuccessStateToken'; import { useActionStatusByHash } from 'src/ui/shared/forms/SuccessState/useActionStatusByHash'; import { NavigationTitle } from 'src/ui/components/NavigationTitle'; -import { GasbackDecorated } from '../../SendForm/SuccessState/SuccessState'; +import RetryIcon from 'jsx:src/ui/assets/actions/swap.svg'; +import { Button } from 'src/ui/ui-kit/Button'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { UIText } from 'src/ui/ui-kit/UIText'; import type { BareAddressPosition } from '../BareAddressPosition'; +import { GasbackDecorated } from '../../SendForm/SuccessState/SuccessState'; export function SuccessState({ swapFormState, spendPosition, receivePosition, hash, + onRetry, onDone, gasbackValue, }: { @@ -26,6 +31,7 @@ export function SuccessState({ receivePosition: BareAddressPosition; hash: string; gasbackValue: number | null; + onRetry: () => void; onDone: () => void; }) { const { networks } = useNetworks(); @@ -82,6 +88,16 @@ export function SuccessState({ ) : null } + secondaryAction={ + actionStatus === 'failed' || actionStatus === 'dropped' ? ( + + ) : null + } onDone={onDone} /> diff --git a/src/ui/pages/SwapForm/SwapForm.tsx b/src/ui/pages/SwapForm/SwapForm.tsx index 8d15efb45..8fa1c2898 100644 --- a/src/ui/pages/SwapForm/SwapForm.tsx +++ b/src/ui/pages/SwapForm/SwapForm.tsx @@ -82,6 +82,7 @@ import type { ZerionApiClient } from 'src/modules/zerion-api/zerion-api-bare'; import { useGasbackEstimation } from 'src/modules/ethereum/account-abstraction/rewards'; import { HiddenValidationInput } from 'src/ui/shared/forms/HiddenValidationInput'; import { getNetworksStore } from 'src/modules/networks/networks-store.client'; +import { localActionsStore } from 'src/ui/transactions/local-actions-store'; import { DEFAULT_CONFIGURATION, applyConfiguration, @@ -303,7 +304,10 @@ export function SwapFormComponent() { : null; const { enough_allowance, - allowanceQuery: { refetch: refetchAllowanceQuery }, + allowanceQuery: { + refetch: refetchAllowanceQuery, + remove: removeAllowanceQuery, + }, approvalTransactionQuery: { isFetching: approvalTransactionIsFetching }, approvalTransaction, } = useApproveHandler({ @@ -447,8 +451,8 @@ export function SwapFormComponent() { }, [ approveTxStatus, refetchAllowanceQuery, - refetchNonce, refetchQuotes, + refetchNonce, resetApproveMutation, ]); @@ -506,6 +510,35 @@ export function SwapFormComponent() { onBeforeSubmit(); return 'sendTransaction'; }, + onSuccess: (hash) => { + if (!snapshotRef.current) { + return; + } + const { + chainInput, + receiveInput, + spendInput, + receiveTokenInput, + spendTokenInput, + } = snapshotRef.current; + if ( + !chainInput || + !spendInput || + !receiveInput || + !spendTokenInput || + !receiveTokenInput + ) { + return; + } + localActionsStore.saveAction(hash, { + kind: 'swap', + chain: chainInput, + receiveInput, + spendInput, + spendTokenId: spendTokenInput, + receiveTokenId: receiveTokenInput, + }); + }, }); const resetMutationIfNotLoading = useEvent(() => { @@ -568,6 +601,15 @@ export function SwapFormComponent() { receivePosition={receivePosition} swapFormState={snapshotRef.current} gasbackValue={gasbackValueRef.current} + onRetry={() => { + reset(); + removeAllowanceQuery(); + refetchNonce(); + refetchQuotes(); + snapshotRef.current = null; + feeValueCommonRef.current = null; + gasbackValueRef.current = null; + }} onDone={() => { reset(); snapshotRef.current = null; diff --git a/src/ui/shared/forms/SuccessState/SuccessStateLoader.tsx b/src/ui/shared/forms/SuccessState/SuccessStateLoader.tsx index 14c8f65f5..ebe276727 100644 --- a/src/ui/shared/forms/SuccessState/SuccessStateLoader.tsx +++ b/src/ui/shared/forms/SuccessState/SuccessStateLoader.tsx @@ -181,6 +181,7 @@ export function SuccessStateLoader({ explorerUrl, error, confirmedContent, + secondaryAction, onDone, }: { startItem: React.ReactNode; @@ -193,6 +194,7 @@ export function SuccessStateLoader({ explorerUrl?: string; error?: string; confirmedContent?: React.ReactNode; + secondaryAction?: React.ReactNode; onDone?: () => void; }) { const showLongWaitNotice = useRenderDelay(5000); @@ -309,9 +311,12 @@ export function SuccessStateLoader({ ) : null} - + + {secondaryAction} + + diff --git a/src/ui/transactions/local-actions-store.ts b/src/ui/transactions/local-actions-store.ts new file mode 100644 index 000000000..142dfdc6c --- /dev/null +++ b/src/ui/transactions/local-actions-store.ts @@ -0,0 +1,71 @@ +import { PersistentStore } from 'src/modules/persistent-store'; + +type BaseLocalAction = { + datetime: number; +}; + +export type SendLocalAction = { + kind: 'send'; + recepient: string; + chain: string; + amount: string; + tokenId: string; +}; + +export type SwapLocalAction = { + kind: 'swap'; + chain: string; + spendTokenId: string; + spendInput: string; + receiveTokenId: string; + receiveInput: string; +}; + +// TODO add bridge actions when merged + +export type LocalAction = SendLocalAction | SwapLocalAction; +type State = { + version: 1; + localActions: Record; +}; + +export class LocalActionsStore extends PersistentStore { + static getActionLink(action: LocalAction) { + if (action.kind === 'swap') { + const searchParams = new URLSearchParams({ + chainInput: action.chain, + spendTokenInput: action.spendTokenId, + spendInput: action.spendInput, + receiveTokenInput: action.receiveTokenId, + }); + return `/swap-form/?${searchParams.toString()}`; + } + if (action.kind === 'send') { + const searchParams = new URLSearchParams({ + addressInputValue: action.recepient, + tokenValue: action.amount, + tokenAssetCode: action.tokenId, + }); + return `/send-form/?${searchParams.toString()}`; + } + return null; + } + + saveAction(key: string, action: LocalAction) { + this.setState((state) => ({ + ...state, + localActions: { + ...state.localActions, + [key]: { + ...action, + datetime: new Date().getTime(), + }, + }, + })); + } +} + +export const localActionsStore = new LocalActionsStore( + { version: 1, localActions: {} }, + 'localActions' +);