From 6d960c6c4b2693f5fedeba5d7af264c32009879d Mon Sep 17 00:00:00 2001 From: Jhonatan Gonzalez Date: Mon, 14 Oct 2024 14:57:58 +1100 Subject: [PATCH] [NO CHANGELOG][Add Funds Widget] Slice 4 MVP Feature Branch (#2276) Co-authored-by: Tim Paul --- .../src/pages/ConnectWidget.tsx | 2 +- .../checkout/sdk/src/errors/checkoutError.ts | 6 +- packages/checkout/sdk/src/index.ts | 2 +- packages/checkout/sdk/src/sdk.ts | 11 + .../widgets/definitions/events/addFunds.ts | 16 + .../widgets/definitions/events/checkout.ts | 8 +- .../sdk/src/widgets/definitions/types.ts | 5 +- .../ChangedYourMindDrawer.tsx | 1 + .../ConnectLoader/ConnectLoader.tsx | 6 + .../components/SimpleLayout/SimpleLayout.tsx | 17 +- .../UnableToConnectDrawer.tsx | 1 + .../WalletDrawer/ConnectWalletDrawer.tsx | 246 ++++++ .../WalletDrawer/DeliverToWalletDrawer.tsx | 64 ++ .../WalletDrawer/NonPassportWarningDrawer.tsx | 103 +++ .../WalletDrawer/PayWithWalletDrawer.tsx | 100 +++ .../WalletDrawer/WalletConnectItem.tsx | 97 +-- .../components/WalletDrawer/WalletDrawer.tsx | 131 ++-- .../components/WalletDrawer/WalletItem.tsx | 18 +- .../providers-context/ProvidersContext.tsx | 162 ++++ .../src/context/view-context/ViewContext.tsx | 7 +- .../src/lib/connectEIP6963Provider.ts | 61 ++ .../checkout/widgets-lib/src/lib/constants.ts | 5 + .../checkout/widgets-lib/src/locales/en.json | 21 +- .../src/widgets/add-funds/AddFundsRoot.tsx | 39 +- .../src/widgets/add-funds/AddFundsWidget.tsx | 190 +++-- .../widgets/add-funds/AddFundsWidgetEvents.ts | 29 + .../add-funds/components/FiatOption.tsx | 36 +- .../add-funds/components/OnboardingDrawer.tsx | 115 +++ .../widgets/add-funds/components/Options.tsx | 156 ++-- .../add-funds/components/OptionsDrawer.tsx | 76 +- .../add-funds/components/RouteFees.tsx | 79 ++ .../add-funds/components/RouteOption.tsx | 172 +++-- .../components/SelectedRouteOption.tsx | 185 +++++ .../add-funds/components/SelectedWallet.tsx | 51 ++ .../add-funds/components/SquidIcon.tsx | 31 +- ...AddFundsContext.ts => AddFundsContext.tsx} | 91 ++- .../add-funds/functions/amountValidation.ts | 17 +- .../functions/convertTokenBalanceToUsd.ts | 20 - .../add-funds/functions/fetchBalances.ts | 12 +- .../add-funds/functions/fetchTokens.ts | 2 +- .../functions/getDurationFormatted.ts | 8 + .../add-funds/functions/getFormattedNumber.ts | 39 + .../functions/getRouteAndTokenBalances.ts | 53 ++ .../add-funds/functions/getRouteChains.ts | 26 + .../add-funds/functions/getTotalRouteFees.ts | 41 + .../add-funds/functions/onboardingState.ts | 32 + .../functions/sortRoutesByFastestTime.ts | 4 +- .../src/widgets/add-funds/hooks/useExecute.ts | 21 +- .../src/widgets/add-funds/hooks/useRoutes.ts | 133 ++-- .../src/widgets/add-funds/types.ts | 2 +- .../src/widgets/add-funds/views/AddFunds.tsx | 385 ++++++--- .../src/widgets/add-funds/views/Review.tsx | 731 +++++++++++------- .../src/widgets/checkout/CheckoutWidget.tsx | 78 +- .../functions/getFlowRequiresContext.ts | 32 + .../functions/getViewShouldConnect.ts | 19 - .../src/widgets/connect/ConnectWidget.tsx | 9 +- .../components/NonPassportWarningDrawer.tsx | 1 + .../widgets/connect/views/ConnectWallet.tsx | 6 + .../src/widgets/on-ramp/OnRampWidgetRoot.tsx | 17 +- .../ui/add-funds-integration/addFunds.tsx | 64 +- .../src/components/ui/add-funds/addFunds.tsx | 109 ++- .../src/components/ui/add-funds/login.tsx | 11 + .../src/components/ui/add-funds/logout.tsx | 11 + .../src/components/ui/add-funds/passport.ts | 14 + .../src/components/ui/checkout/checkout.tsx | 13 +- .../ui/marketplace-orchestrator/passport.ts | 12 +- .../checkout/widgets-sample-app/src/index.tsx | 10 + 67 files changed, 3245 insertions(+), 1027 deletions(-) create mode 100644 packages/checkout/widgets-lib/src/components/WalletDrawer/ConnectWalletDrawer.tsx create mode 100644 packages/checkout/widgets-lib/src/components/WalletDrawer/DeliverToWalletDrawer.tsx create mode 100644 packages/checkout/widgets-lib/src/components/WalletDrawer/NonPassportWarningDrawer.tsx create mode 100644 packages/checkout/widgets-lib/src/components/WalletDrawer/PayWithWalletDrawer.tsx create mode 100644 packages/checkout/widgets-lib/src/context/providers-context/ProvidersContext.tsx create mode 100644 packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/components/OnboardingDrawer.tsx create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteFees.tsx create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedRouteOption.tsx create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedWallet.tsx rename packages/checkout/widgets-lib/src/widgets/add-funds/context/{AddFundsContext.ts => AddFundsContext.tsx} (63%) delete mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/convertTokenBalanceToUsd.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/getDurationFormatted.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/getFormattedNumber.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteAndTokenBalances.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteChains.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/getTotalRouteFees.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/add-funds/functions/onboardingState.ts create mode 100644 packages/checkout/widgets-lib/src/widgets/checkout/functions/getFlowRequiresContext.ts delete mode 100644 packages/checkout/widgets-lib/src/widgets/checkout/functions/getViewShouldConnect.ts create mode 100644 packages/checkout/widgets-sample-app/src/components/ui/add-funds/login.tsx create mode 100644 packages/checkout/widgets-sample-app/src/components/ui/add-funds/logout.tsx create mode 100644 packages/checkout/widgets-sample-app/src/components/ui/add-funds/passport.ts diff --git a/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx b/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx index 2c207ac1fd..ec61523527 100644 --- a/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx +++ b/packages/checkout/sdk-sample-app/src/pages/ConnectWidget.tsx @@ -5,7 +5,7 @@ import { Web3Provider } from '@ethersproject/providers'; import GetAllBalances from '../components/GetAllBalances'; import CheckConnection from '../components/CheckConnection'; import GetAllowList from '../components/GetAllowList'; -import { Body, Box, Checkbox, Divider, Heading, Toggle } from '@biom3/react'; +import { Body, Box, Checkbox, Divider, Heading } from '@biom3/react'; import GetBalance from '../components/GetBalance'; import { Checkout } from '@imtbl/checkout-sdk'; import { Environment } from '@imtbl/config'; diff --git a/packages/checkout/sdk/src/errors/checkoutError.ts b/packages/checkout/sdk/src/errors/checkoutError.ts index 6e1246aa48..bd3bb750f4 100644 --- a/packages/checkout/sdk/src/errors/checkoutError.ts +++ b/packages/checkout/sdk/src/errors/checkoutError.ts @@ -62,16 +62,16 @@ export type ErrorType = { /* The CheckoutError class is a custom error class in TypeScript that includes a message, type, and optional data object. */ -export class CheckoutError extends Error { +export class CheckoutError extends Error { public message: string; - public type: CheckoutErrorType; + public type: T; public data?: { [key: string]: any }; constructor( message: string, - type: CheckoutErrorType, + type: T, data?: { [key: string]: any }, diff --git a/packages/checkout/sdk/src/index.ts b/packages/checkout/sdk/src/index.ts index 3cef2076e0..9e1b13d43f 100644 --- a/packages/checkout/sdk/src/index.ts +++ b/packages/checkout/sdk/src/index.ts @@ -157,6 +157,6 @@ export { isAddressSanctioned } from './sanctions'; export type { ErrorType } from './errors'; -export { CheckoutErrorType } from './errors'; +export { CheckoutErrorType, CheckoutError } from './errors'; export { CheckoutConfiguration } from './config'; export { BlockExplorerService } from './blockExplorer'; diff --git a/packages/checkout/sdk/src/sdk.ts b/packages/checkout/sdk/src/sdk.ts index 2992e50bbd..212c4db457 100644 --- a/packages/checkout/sdk/src/sdk.ts +++ b/packages/checkout/sdk/src/sdk.ts @@ -79,6 +79,7 @@ import { WidgetConfiguration } from './widgets/definitions/configurations'; import { getWidgetsEsmUrl, loadUnresolvedBundle } from './widgets/load'; import { determineWidgetsVersion, validateAndBuildVersion } from './widgets/version'; import { globalPackageVersion } from './env'; +import { isAddressSanctioned } from './sanctions'; const SANDBOX_CONFIGURATION = { baseConfig: { @@ -333,6 +334,16 @@ export class Checkout { return connect.checkIsWalletConnected(web3Provider); } + /** + * Checks if an address is sanctioned. + * @param {string} address - The address to check. + * @param {Environment} environment - The environment to check. + * @returns {Promise} - A promise that resolves to the result of the check. + */ + public async checkIsAddressSanctioned(address: string, environment: Environment): Promise { + return await isAddressSanctioned(address, environment); + } + /** * Connects to a blockchain network using the specified provider. * @param {ConnectParams} params - The parameters for connecting to the network. diff --git a/packages/checkout/sdk/src/widgets/definitions/events/addFunds.ts b/packages/checkout/sdk/src/widgets/definitions/events/addFunds.ts index 7cf1a0607b..ab12affcd3 100644 --- a/packages/checkout/sdk/src/widgets/definitions/events/addFunds.ts +++ b/packages/checkout/sdk/src/widgets/definitions/events/addFunds.ts @@ -1,9 +1,13 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { EIP6963ProviderInfo } from '../../../types'; + /** * Enum of possible Add Funds Widget event types. */ export enum AddFundsEventType { CLOSE_WIDGET = 'close-widget', LANGUAGE_CHANGED = 'language-changed', + CONNECT_SUCCESS = 'connect-success', REQUEST_BRIDGE = 'request-bridge', REQUEST_ONRAMP = 'request-onramp', REQUEST_SWAP = 'request-swap', @@ -29,3 +33,15 @@ export type AddFundsFailed = { /** The timestamp of the failed transaction. */ timestamp: number; }; + +/** + * Type representing a successfull provider connection + * @property {Web3Provider} provider + * @property {EIP6963ProviderInfo} providerInfo + * @property {'from' | 'to'} providerType + */ +export type AddFundsConnectSuccess = { + provider: Web3Provider; + providerInfo: EIP6963ProviderInfo; + providerType: 'from' | 'to'; +}; diff --git a/packages/checkout/sdk/src/widgets/definitions/events/checkout.ts b/packages/checkout/sdk/src/widgets/definitions/events/checkout.ts index adbe04a964..d7d77c6350 100644 --- a/packages/checkout/sdk/src/widgets/definitions/events/checkout.ts +++ b/packages/checkout/sdk/src/widgets/definitions/events/checkout.ts @@ -17,7 +17,7 @@ import { } from './bridge'; import { SwapFailed, SwapRejected, SwapSuccess } from './swap'; import { WalletNetworkSwitch } from './wallet'; -import { AddFundsFailed, AddFundsSuccess } from './addFunds'; +import { AddFundsFailed, AddFundsSuccess, AddFundsConnectSuccess } from './addFunds'; export enum CheckoutEventType { INITIALISED = 'INITIALISED', @@ -106,8 +106,14 @@ export type CheckoutAddFundsSuccessEvent = { data: AddFundsSuccess; }; +export type CheckoutAddFundsConnectSuccessEvent = { + type: CheckoutSuccessEventType.CONNECT_SUCCESS; + data: AddFundsConnectSuccess; +}; + export type CheckoutSuccessEvent = | CheckoutAddFundsSuccessEvent + | CheckoutAddFundsConnectSuccessEvent | CheckoutConnectSuccessEvent | CheckoutBridgeSuccessEvent | CheckoutBridgeClaimWithdrawalSuccessEvent diff --git a/packages/checkout/sdk/src/widgets/definitions/types.ts b/packages/checkout/sdk/src/widgets/definitions/types.ts index 736c36f8ef..72d977e91f 100644 --- a/packages/checkout/sdk/src/widgets/definitions/types.ts +++ b/packages/checkout/sdk/src/widgets/definitions/types.ts @@ -42,6 +42,7 @@ import { RequestAddFundsEvent, RequestGoBackEvent, AddFundsEventType, + AddFundsConnectSuccess, } from './events'; import { BridgeWidgetParams, @@ -212,9 +213,7 @@ export type WidgetEventData = { [WidgetType.ADD_FUNDS]: { [AddFundsEventType.CLOSE_WIDGET]: {}; - [AddFundsEventType.REQUEST_BRIDGE]: {}; - [AddFundsEventType.REQUEST_SWAP]: {}; - [AddFundsEventType.REQUEST_ONRAMP]: {}; + [AddFundsEventType.CONNECT_SUCCESS]: AddFundsConnectSuccess; } & OrchestrationMapping & ProviderEventMapping; }; diff --git a/packages/checkout/widgets-lib/src/components/ChangedYourMindDrawer/ChangedYourMindDrawer.tsx b/packages/checkout/widgets-lib/src/components/ChangedYourMindDrawer/ChangedYourMindDrawer.tsx index 4e6d3aeee8..14c984b696 100644 --- a/packages/checkout/widgets-lib/src/components/ChangedYourMindDrawer/ChangedYourMindDrawer.tsx +++ b/packages/checkout/widgets-lib/src/components/ChangedYourMindDrawer/ChangedYourMindDrawer.tsx @@ -66,6 +66,7 @@ export function ChangedYourMindDrawer({ /> void; widgetConfig: StrongCheckoutWidgetsConfig; + goBackEvent?: () => void; + showBackButton?: boolean; } export interface ConnectLoaderParams { @@ -47,6 +49,8 @@ export function ConnectLoader({ params, widgetConfig, closeEvent, + goBackEvent, + showBackButton, }: ConnectLoaderProps) { const { checkout, @@ -250,6 +254,8 @@ export function ConnectLoader({ sendCloseEventOverride={closeEvent} allowedChains={allowedChains} isCheckNetworkEnabled={isCheckNetworkEnabled ?? true} + showBackButton={showBackButton} + sendGoBackEventOverride={goBackEvent} /> )} {/* If the user has connected then render the widget */} diff --git a/packages/checkout/widgets-lib/src/components/SimpleLayout/SimpleLayout.tsx b/packages/checkout/widgets-lib/src/components/SimpleLayout/SimpleLayout.tsx index 6138f3e86e..c2128442fb 100644 --- a/packages/checkout/widgets-lib/src/components/SimpleLayout/SimpleLayout.tsx +++ b/packages/checkout/widgets-lib/src/components/SimpleLayout/SimpleLayout.tsx @@ -1,4 +1,5 @@ import { Box, BoxProps } from '@biom3/react'; +import merge from 'ts-deepmerge'; import { simpleLayoutStyle, headerStyle, @@ -18,6 +19,7 @@ export interface SimpleLayoutProps { floatHeader?: boolean; footerBackgroundColor?: string; bodyStyleOverrides?: BoxProps['sx']; + containerSx?: BoxProps['sx']; } export function SimpleLayout({ @@ -25,33 +27,34 @@ export function SimpleLayout({ footer, children, heroContent, - testId, + testId = 'container', floatHeader = false, footerBackgroundColor, bodyStyleOverrides, + containerSx = {}, }: SimpleLayoutProps) { return ( - - + + {header && ( - + {header} )} - + {heroContent && ( {heroContent} )} {children && ( - + {children} )} {footer && ( - + {footer} )} diff --git a/packages/checkout/widgets-lib/src/components/UnableToConnectDrawer/UnableToConnectDrawer.tsx b/packages/checkout/widgets-lib/src/components/UnableToConnectDrawer/UnableToConnectDrawer.tsx index 82302a1b70..9c76a53830 100644 --- a/packages/checkout/widgets-lib/src/components/UnableToConnectDrawer/UnableToConnectDrawer.tsx +++ b/packages/checkout/widgets-lib/src/components/UnableToConnectDrawer/UnableToConnectDrawer.tsx @@ -67,6 +67,7 @@ export function UnableToConnectDrawer({ void; + onConnect?: ( + provider: Web3Provider, + providerInfo: EIP6963ProviderInfo + ) => void; + onError?: (errorType: ConnectEIP6963ProviderError) => void; + providerType: 'from' | 'to'; + walletOptions: EIP6963ProviderDetail[]; + bottomSlot?: ReactNode; + menuItemSize?: MenuItemProps['size']; + disabledOptions?: { + label: string; + rdns: string; + }[]; +}; + +export function ConnectWalletDrawer({ + heading, + visible, + onClose, + onConnect, + onError, + providerType, + walletOptions, + bottomSlot, + menuItemSize = 'small', + disabledOptions = [], +}: ConnectWalletDrawerProps) { + const { + providersState: { checkout }, + providersDispatch, + } = useProvidersContext(); + + const { identify, track } = useAnalytics(); + + const prevWalletChangeEvent = useRef(); + const [showNonPassportWarning, setShowNonPassportWarning] = useState(false); + const [showUnableToConnectDrawer, setShowUnableToConnectDrawer] = useState(false); + const [showChangedMindDrawer, setShowChangedMindDrawer] = useState(false); + + const shouldShowNonPassportWarning = (rdns: string): boolean => { + const hasSeenWarning = localStorage.getItem( + HAS_SEEN_NON_PASSPORT_WARNING_KEY, + ); + + if (rdns !== WalletProviderRdns.PASSPORT && !hasSeenWarning) { + return true; + } + + return false; + }; + + const setProviderInContext = async ( + provider: Web3Provider, + providerInfo: EIP6963ProviderInfo, + ) => { + const address = await provider.getSigner().getAddress(); + + if (providerType === 'from') { + providersDispatch({ + payload: { + type: ProvidersContextActions.SET_PROVIDER, + fromAddress: address, + fromProvider: provider, + fromProviderInfo: providerInfo, + }, + }); + } + + if (providerType === 'to') { + providersDispatch({ + payload: { + type: ProvidersContextActions.SET_PROVIDER, + toAddress: address, + toProvider: provider, + toProviderInfo: providerInfo, + }, + }); + } + }; + + const handleOnWalletChangeEvent = async (event: WalletChangeEvent) => { + if (!checkout) { + setShowUnableToConnectDrawer(true); + onError?.(ConnectEIP6963ProviderError.CONNECT_ERROR); + throw new Error('Checkout is not initialized'); + } + + // Keep prev wallet change event + prevWalletChangeEvent.current = event; + + const { providerDetail } = event; + const { info } = providerDetail; + + // check if selected a non passport wallet + if (shouldShowNonPassportWarning(info.rdns)) { + setShowNonPassportWarning(true); + return; + } + + // Trigger analytics connect wallet, menu item, with wallet details + track({ + userJourney: UserJourney.CONNECT, + screen: 'ConnectWallet', + control: info.name, + controlType: 'MenuItem', + extras: { + providerType, + wallet: getProviderSlugFromRdns(info.rdns), + walletRdns: info.rdns, + }, + }); + + // Proceed to disconnect current provider if Passport + if (info.rdns === WalletProviderRdns.PASSPORT) { + const { isConnected } = await checkout.checkIsWalletConnected({ + provider: new Web3Provider(providerDetail.provider!), + }); + + if (isConnected) { + await checkout.passport?.logout(); + } + } + + // Proceed to connect selected provider + try { + const { provider } = await connectEIP6963Provider( + providerDetail, + checkout, + ); + // Identify connected wallet + await identifyUser(identify, provider); + + // Store selected provider as fromProvider in context + setProviderInContext(provider, providerDetail.info); + + // Notify successful connection + onConnect?.(provider, providerDetail.info); + } catch (error: ConnectEIP6963ProviderError | any) { + let errorType = error.message; + switch (error.message) { + case ConnectEIP6963ProviderError.USER_REJECTED_REQUEST_ERROR: + setShowChangedMindDrawer(true); + break; + case ConnectEIP6963ProviderError.SANCTIONED_ADDRESS: + case ConnectEIP6963ProviderError.CONNECT_ERROR: + setShowUnableToConnectDrawer(true); + break; + default: + errorType = ConnectEIP6963ProviderError.CONNECT_ERROR; + } + + // Notify failure to connect + onError?.(errorType as ConnectEIP6963ProviderError); + return; + } + + onClose(); + }; + + const retrySelectedWallet = () => { + if (prevWalletChangeEvent.current) { + handleOnWalletChangeEvent(prevWalletChangeEvent.current); + } + }; + + const handleCloseNonPassportWarningDrawer = () => { + localStorage.setItem(HAS_SEEN_NON_PASSPORT_WARNING_KEY, 'true'); + setShowNonPassportWarning(false); + retrySelectedWallet(); + }; + + const handleCloseChangedMindDrawer = () => { + setShowChangedMindDrawer(false); + retrySelectedWallet(); + }; + + return ( + <> + { + if (show === false) onClose(); + }} + onWalletChange={handleOnWalletChangeEvent} + bottomSlot={bottomSlot} + /> + + setShowUnableToConnectDrawer(false)} + onTryAgain={() => setShowUnableToConnectDrawer(false)} + /> + setShowChangedMindDrawer(false)} + onTryAgain={handleCloseChangedMindDrawer} + /> + + ); +} diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/DeliverToWalletDrawer.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/DeliverToWalletDrawer.tsx new file mode 100644 index 0000000000..0660267bbd --- /dev/null +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/DeliverToWalletDrawer.tsx @@ -0,0 +1,64 @@ +import { + EIP6963ProviderDetail, + EIP6963ProviderInfo, +} from '@imtbl/checkout-sdk'; +import { Web3Provider } from '@ethersproject/providers'; +import { useContext } from 'react'; +import { ConnectWalletDrawer } from './ConnectWalletDrawer'; +import { ConnectEIP6963ProviderError } from '../../lib/connectEIP6963Provider'; +import { + SharedViews, + ViewActions, + ViewContext, +} from '../../context/view-context/ViewContext'; + +type DeliverToWalletDrawerProps = { + visible: boolean; + onClose: () => void; + walletOptions: EIP6963ProviderDetail[]; + onConnect: ( + providerType: 'from' | 'to', + provider: Web3Provider, + providerInfo: EIP6963ProviderInfo + ) => void; +}; + +export function DeliverToWalletDrawer({ + visible, + onClose, + onConnect, + walletOptions, +}: DeliverToWalletDrawerProps) { + const { viewDispatch } = useContext(ViewContext); + + const handleOnConnect = (provider: Web3Provider, providerInfo: EIP6963ProviderInfo) => { + onConnect('to', provider, providerInfo); + }; + + const handleOnError = (errorType: ConnectEIP6963ProviderError) => { + if (errorType === ConnectEIP6963ProviderError.SANCTIONED_ADDRESS) { + onClose(); + viewDispatch({ + payload: { + type: ViewActions.UPDATE_VIEW, + view: { + type: SharedViews.SERVICE_UNAVAILABLE_ERROR_VIEW, + error: new Error(errorType), + }, + }, + }); + } + }; + + return ( + + ); +} diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/NonPassportWarningDrawer.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/NonPassportWarningDrawer.tsx new file mode 100644 index 0000000000..bce70bf0d2 --- /dev/null +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/NonPassportWarningDrawer.tsx @@ -0,0 +1,103 @@ +import { + Body, + Box, + ButtCon, + Button, + Drawer, + Heading, + Link, +} from '@biom3/react'; +import { Trans, useTranslation } from 'react-i18next'; +import { WalletWarningHero } from '../Hero/WalletWarningHero'; + +export interface NonPassportWarningDrawerProps { + visible: boolean; + onCloseDrawer: () => void; + handleCtaButtonClick: () => void; +} + +export function NonPassportWarningDrawer({ + visible, + onCloseDrawer, + handleCtaButtonClick, +}: NonPassportWarningDrawerProps) { + const { t } = useTranslation(); + + return ( + + + + + + + {t('views.CONNECT_WALLET.nonPassportDrawer.heading')} + + + + )} + /> + ), + }} + /> +
+
+ {t('views.CONNECT_WALLET.nonPassportDrawer.body2')} + +
+ + + +
+
+ ); +} diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/PayWithWalletDrawer.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/PayWithWalletDrawer.tsx new file mode 100644 index 0000000000..859c545e5e --- /dev/null +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/PayWithWalletDrawer.tsx @@ -0,0 +1,100 @@ +import { EIP6963ProviderDetail, EIP6963ProviderInfo } from '@imtbl/checkout-sdk'; +import { useContext, useMemo } from 'react'; +import { MenuItem } from '@biom3/react'; +import { Web3Provider } from '@ethersproject/providers'; +import { ConnectWalletDrawer } from './ConnectWalletDrawer'; +import { useProvidersContext } from '../../context/providers-context/ProvidersContext'; +import { ConnectEIP6963ProviderError } from '../../lib/connectEIP6963Provider'; +import { SharedViews, ViewActions, ViewContext } from '../../context/view-context/ViewContext'; + +type PayWithWalletDrawerProps = { + visible: boolean; + onClose: () => void; + onConnect: (providerType: 'from' | 'to', provider: Web3Provider, providerInfo: EIP6963ProviderInfo) => void; + onPayWithCard: () => void; + walletOptions: EIP6963ProviderDetail[]; + insufficientBalance?: boolean; + showOnRampOption?: boolean; +}; + +export function PayWithWalletDrawer({ + visible, + onClose, + onConnect, + onPayWithCard, + walletOptions, + insufficientBalance, + showOnRampOption = true, +}: PayWithWalletDrawerProps) { + const { providersState: { fromProviderInfo } } = useProvidersContext(); + const { viewDispatch } = useContext(ViewContext); + + const disabledOptions = useMemo(() => { + if (insufficientBalance && fromProviderInfo) { + return [{ + label: 'insufficient funds', + rdns: fromProviderInfo.rdns, + }]; + } + + return []; + }, [insufficientBalance, fromProviderInfo]); + + const handleOnConnect = (provider: Web3Provider, providerInfo: EIP6963ProviderInfo) => { + onConnect('from', provider, providerInfo); + }; + + const handleOnError = (errorType: ConnectEIP6963ProviderError) => { + if (errorType === ConnectEIP6963ProviderError.SANCTIONED_ADDRESS) { + onClose(); + viewDispatch({ + payload: { + type: ViewActions.UPDATE_VIEW, + view: { + type: SharedViews.SERVICE_UNAVAILABLE_ERROR_VIEW, + error: new Error(errorType), + }, + }, + }); + } + }; + + const payWithCardItem = useMemo( + () => { + if (!showOnRampOption) return null; + + return ( + { + onClose(); + onPayWithCard(); + }} + > + + Pay with Card + + ); + }, + [onClose, onPayWithCard], + ); + + return ( + + ); +} diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletConnectItem.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletConnectItem.tsx index 762f6895c9..7fc0c222e8 100644 --- a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletConnectItem.tsx +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletConnectItem.tsx @@ -1,55 +1,56 @@ -import { MenuItem } from '@biom3/react'; -import { cloneElement, ReactElement, useState } from 'react'; +import { MenuItem, MenuItemProps } from '@biom3/react'; +import { forwardRef, useImperativeHandle, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useWalletConnect } from '../../lib/hooks/useWalletConnect'; -export interface WalletConnectItemProps { - testId: string; +export interface WalletConnectItemProps { onWalletItemClick: () => Promise; loading: boolean; - rc?: RC; + size?: MenuItemProps['size']; } -export function WalletConnectItem< - RC extends ReactElement | undefined = undefined, ->({ - rc = , - testId, - onWalletItemClick, - loading, -}: WalletConnectItemProps) { - const { t } = useTranslation(); - const [busy, setBusy] = useState(false); - return ( - { - if (loading) return; - setBusy(true); - // let the parent handle errors - try { - await onWalletItemClick(); - } finally { - setBusy(false); - } - }, - })} - testId={`${testId}-wallet-list-walletconnect`} - size="medium" - emphasized - > - - - {t('wallets.walletconnect.heading')} - - {(busy && ( - { + const { t } = useTranslation(); + const { walletConnectBusy } = useWalletConnect(); + const [busy, setBusy] = useState(false); + + const connect = async () => { + if (loading) return; + setBusy(true); + // let the parent handle errors + try { + await onWalletItemClick(); + } finally { + setBusy(false); + } + }; + + useImperativeHandle(ref, () => ({ + connect, + })); + + return ( + + - ))} - - ); -} + + {t('wallets.walletconnect.heading')} + + {busy && } + + ); + }, +); diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletDrawer.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletDrawer.tsx index b89983c0a0..a1665b1022 100644 --- a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletDrawer.tsx +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletDrawer.tsx @@ -1,14 +1,19 @@ -import { Drawer, Select } from '@biom3/react'; -import { useState } from 'react'; +import { + Drawer, MenuItem, MenuItemProps, Select, +} from '@biom3/react'; +import { ReactNode, useState } from 'react'; import { motion } from 'framer-motion'; -import { EIP1193Provider, EIP6963ProviderDetail } from '@imtbl/checkout-sdk'; +import { EIP1193Provider, EIP6963ProviderDetail, WalletProviderRdns } from '@imtbl/checkout-sdk'; import { FormControlWrapper } from '../FormComponents/FormControlWrapper/FormControlWrapper'; import { WalletItem } from './WalletItem'; import { walletItemListStyles } from './WalletDrawerStyles'; import { WalletConnectItem } from './WalletConnectItem'; import { useWalletConnect } from '../../lib/hooks/useWalletConnect'; import { WalletChangeEvent } from './WalletDrawerEvents'; -import { listItemVariants, listVariants } from '../../lib/animation/listAnimation'; +import { + listItemVariants, + listVariants, +} from '../../lib/animation/listAnimation'; import { walletConnectProviderInfo } from '../../lib/walletConnect'; interface WalletDrawerProps { @@ -16,29 +21,40 @@ interface WalletDrawerProps { drawerText: { heading: string; defaultText?: string; - }, + }; showWalletConnect?: boolean; - showWalletSelectorTarget: boolean; + showWalletSelectorTarget?: boolean; walletOptions: EIP6963ProviderDetail[]; showDrawer: boolean; setShowDrawer: (show: boolean) => void; onWalletChange: (event: WalletChangeEvent) => Promise; + menuItemSize?: MenuItemProps['size']; + bottomSlot?: ReactNode; + disabledOptions?: { + label: string; + rdns: string; + }[]; } export function WalletDrawer({ testId, drawerText, walletOptions, showWalletConnect = true, - showWalletSelectorTarget, + showWalletSelectorTarget = false, showDrawer, setShowDrawer, onWalletChange, + menuItemSize, + bottomSlot, + disabledOptions, }: WalletDrawerProps) { const { isWalletConnectEnabled, openWalletConnectModal } = useWalletConnect(); const [walletItemLoading, setWalletItemLoading] = useState(false); const { heading, defaultText } = drawerText; - const handleWalletItemClick = async (providerDetail: EIP6963ProviderDetail) => { + const handleWalletItemClick = async ( + providerDetail: EIP6963ProviderDetail, + ) => { setWalletItemLoading(true); try { await onWalletChange({ @@ -58,7 +74,7 @@ export function WalletDrawer({ try { await openWalletConnectModal({ connectCallback: (ethereumProvider) => { - const walletChangeEvent : WalletChangeEvent = { + const walletChangeEvent: WalletChangeEvent = { walletType: 'walletconnect', provider: ethereumProvider as EIP1193Provider, providerDetail: { @@ -70,7 +86,7 @@ export function WalletDrawer({ }; onWalletChange(walletChangeEvent); }, - restoreSession: true, + restoreSession: false, }); } catch (error) { // eslint-disable-next-line no-console @@ -88,44 +104,58 @@ export function WalletDrawer({ }} visible={showDrawer} > - {showWalletSelectorTarget - && ( - - - setShowDrawer(true)} + /> + + + )} - )} + rc={ + + } > - {walletOptions.map((providerDetail, index) => ( - handleWalletItemClick(providerDetail)} - rc={( - - )} - /> - ))} + {walletOptions.map((providerDetail, index) => { + const unavailableIndex = disabledOptions?.findIndex( + ({ rdns }) => rdns === providerDetail.info.rdns, + ) ?? -1; + + const unavalable = unavailableIndex > -1; + + const badge = unavalable ? ( + + ) : undefined; + + return ( + handleWalletItemClick(providerDetail)} + rc={} + size={menuItemSize} + badge={badge} + disabled={unavalable} + recommended={providerDetail.info.rdns === WalletProviderRdns.PASSPORT} + /> + ); + })} {isWalletConnectEnabled && showWalletConnect && ( )} + {bottomSlot && ( + + {bottomSlot} + + )} ); diff --git a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletItem.tsx b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletItem.tsx index d6056a015d..7fa1673679 100644 --- a/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletItem.tsx +++ b/packages/checkout/widgets-lib/src/components/WalletDrawer/WalletItem.tsx @@ -1,5 +1,7 @@ -import { MenuItem } from '@biom3/react'; -import { cloneElement, ReactElement, useState } from 'react'; +import { MenuItem, MenuItemProps } from '@biom3/react'; +import { + cloneElement, ReactElement, ReactNode, useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { EIP6963ProviderInfo } from '@imtbl/checkout-sdk'; import { RawImage } from '../RawImage/RawImage'; @@ -11,6 +13,9 @@ export interface WalletItemProps void; rc?: RC; + size?: MenuItemProps['size']; + badge?: ReactNode; + disabled?: boolean; } export function WalletItem< @@ -22,6 +27,9 @@ export function WalletItem< recommended = false, providerInfo, onWalletItemClick, + size = 'medium', + badge, + disabled, }: WalletItemProps) { const { t } = useTranslation(); const [busy, setBusy] = useState(false); @@ -41,7 +49,7 @@ export function WalletItem< }, })} testId={`${testId}-wallet-list-${providerInfo.rdns}`} - size="medium" + size={size} emphasized sx={{ position: 'relative' }} > @@ -51,6 +59,7 @@ export function WalletItem< sx={{ position: 'absolute', left: 'base.spacing.x3', + cursor: disabled ? 'not-allowed' : 'pointer', }} /> @@ -58,11 +67,12 @@ export function WalletItem< {((recommended || busy) && ( ))} + {!busy && badge} ); } diff --git a/packages/checkout/widgets-lib/src/context/providers-context/ProvidersContext.tsx b/packages/checkout/widgets-lib/src/context/providers-context/ProvidersContext.tsx new file mode 100644 index 0000000000..2f2b14da38 --- /dev/null +++ b/packages/checkout/widgets-lib/src/context/providers-context/ProvidersContext.tsx @@ -0,0 +1,162 @@ +import { + createContext, + useContext, useMemo, + useReducer, +} from 'react'; +import { Web3Provider } from '@ethersproject/providers'; +import { Checkout, EIP6963ProviderInfo } from '@imtbl/checkout-sdk'; + +export interface ProvidersState { + fromProvider?: Web3Provider; + fromProviderInfo?: EIP6963ProviderInfo; + fromAddress?: string; + toProvider?: Web3Provider; + toProviderInfo?: EIP6963ProviderInfo; + toAddress?: string; + checkout: Checkout; +} + +export const initialProvidersState: ProvidersState = { + fromProvider: undefined, + fromProviderInfo: undefined, + fromAddress: undefined, + toProvider: undefined, + toProviderInfo: undefined, + toAddress: undefined, + checkout: {} as Checkout, +}; + +export interface ProvidersContextState { + providersState: ProvidersState; + providersDispatch: React.Dispatch; +} + +export interface ProvidersContextAction { + payload: ProvidersContextActionPayload; +} + +type ProvidersContextActionPayload = + | SetProviderPayload + | SetCheckoutPayload + | ResetStatePayload; + +export enum ProvidersContextActions { + SET_PROVIDER = 'SET_PROVIDER', + SET_CHECKOUT = 'SET_CHECKOUT', + RESET_STATE = 'RESET_STATE', +} + +export interface SetProviderPayload { + type: ProvidersContextActions.SET_PROVIDER; + fromProvider?: Web3Provider; + fromProviderInfo?: EIP6963ProviderInfo; + fromAddress?: string; + toProvider?: Web3Provider; + toProviderInfo?: EIP6963ProviderInfo; + toAddress?: string; +} + +export interface SetCheckoutPayload { + type: ProvidersContextActions.SET_CHECKOUT; + checkout: Checkout; +} + +export interface ResetStatePayload { + type: ProvidersContextActions.RESET_STATE; + fromProvider?: Web3Provider; + toProvider?: Web3Provider; + checkout: Checkout; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ProvidersContext = createContext({ + providersState: initialProvidersState, + providersDispatch: () => {}, +}); + +ProvidersContext.displayName = 'ProvidersContext'; + +export type Reducer = (prevState: S, action: A) => S; + +export const providersContextReducer: Reducer< +ProvidersState, +ProvidersContextAction +> = (state: ProvidersState, action: ProvidersContextAction) => { + switch (action.payload.type) { + case ProvidersContextActions.SET_PROVIDER: + return { + ...state, + ...(action.payload.fromProvider && { + fromProvider: action.payload.fromProvider, + }), + ...(action.payload.toProvider && { + toProvider: action.payload.toProvider, + }), + ...(action.payload.fromProviderInfo && { + fromProviderInfo: action.payload.fromProviderInfo, + }), + ...(action.payload.toProviderInfo && { + toProviderInfo: action.payload.toProviderInfo, + }), + ...(action.payload.fromAddress && { + fromAddress: action.payload.fromAddress, + }), + ...(action.payload.toAddress && { + toAddress: action.payload.toAddress, + }), + }; + case ProvidersContextActions.SET_CHECKOUT: + return { + ...state, + checkout: action.payload.checkout, + }; + case 'RESET_STATE': + return { + ...initialProvidersState, + }; + default: + return state; + } +}; + +export const useProvidersReducer = (initialState?: ProvidersState) => { + const [providersState, providersDispatch] = useReducer( + providersContextReducer, + { ...initialProvidersState, ...(initialState ?? {}) }, + ); + + return [providersState, providersDispatch] as const; +}; + +export function ProvidersContextProvider({ + children, + initialState, +}: { + children: React.ReactNode; + initialState: ProvidersState; +}) { + const [providersState, providersDispatch] = useProvidersReducer(initialState); + + const value = useMemo( + () => ({ providersState, providersDispatch }), + [providersState, providersDispatch], + ); + + return ( + + {children} + + ); +} + +export const useProvidersContext = () => { + const context = useContext(ProvidersContext); + if (context === undefined) { + // eslint-disable-next-line no-console + console.error( + 'useProvidersContext must be used within a WidgetsContext.Provider', + ); + } + + return context; +}; diff --git a/packages/checkout/widgets-lib/src/context/view-context/ViewContext.tsx b/packages/checkout/widgets-lib/src/context/view-context/ViewContext.tsx index 98cc2eb82d..e383bd6de5 100644 --- a/packages/checkout/widgets-lib/src/context/view-context/ViewContext.tsx +++ b/packages/checkout/widgets-lib/src/context/view-context/ViewContext.tsx @@ -186,8 +186,11 @@ export const viewReducer: Reducer = ( } }; -export const useViewState = () => { - const [viewState, viewDispatch] = useReducer(viewReducer, initialViewState); +export const useViewState = (initialState?: ViewState) => { + const [viewState, viewDispatch] = useReducer(viewReducer, { + ...initialViewState, + ...(initialState ?? {}), + }); return [viewState, viewDispatch] as const; }; diff --git a/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts b/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts new file mode 100644 index 0000000000..5424da2938 --- /dev/null +++ b/packages/checkout/widgets-lib/src/lib/connectEIP6963Provider.ts @@ -0,0 +1,61 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { + Checkout, + CheckoutError, + CheckoutErrorType, + EIP6963ProviderDetail, + WalletProviderRdns, +} from '@imtbl/checkout-sdk'; +import { addProviderListenersForWidgetRoot } from './eip1193Events'; +import { getProviderSlugFromRdns } from './provider/utils'; + +export enum ConnectEIP6963ProviderError { + CONNECT_ERROR = 'CONNECT_ERROR', + SANCTIONED_ADDRESS = 'SANCTIONED_ADDRESS', + USER_REJECTED_REQUEST_ERROR = 'USER_REJECTED_REQUEST_ERROR', +} + +export type ConnectEIP6963ProviderResult = { + provider: Web3Provider; + providerName: string; +}; + +export const connectEIP6963Provider = async ( + providerDetail: EIP6963ProviderDetail, + checkout: Checkout, +): Promise => { + const web3Provider = new Web3Provider(providerDetail.provider as any); + + try { + const requestWalletPermissions = providerDetail.info.rdns === WalletProviderRdns.METAMASK; + const connectResult = await checkout.connect({ + provider: web3Provider, + requestWalletPermissions, + }); + + const address = await connectResult.provider.getSigner().getAddress(); + const isSanctioned = await checkout.checkIsAddressSanctioned( + address, + checkout.config.environment, + ); + + if (isSanctioned) { + throw new CheckoutError('Sanctioned address', ConnectEIP6963ProviderError.SANCTIONED_ADDRESS); + } + + addProviderListenersForWidgetRoot(connectResult.provider); + return { + provider: connectResult.provider, + providerName: getProviderSlugFromRdns(providerDetail.info.rdns), + }; + } catch (error: CheckoutErrorType | ConnectEIP6963ProviderError | any) { + switch (error.type) { + case CheckoutErrorType.USER_REJECTED_REQUEST_ERROR: + throw new Error(ConnectEIP6963ProviderError.USER_REJECTED_REQUEST_ERROR); + case ConnectEIP6963ProviderError.SANCTIONED_ADDRESS: + throw new Error(ConnectEIP6963ProviderError.SANCTIONED_ADDRESS); + default: + throw new Error(ConnectEIP6963ProviderError.CONNECT_ERROR); + } + } +}; diff --git a/packages/checkout/widgets-lib/src/lib/constants.ts b/packages/checkout/widgets-lib/src/lib/constants.ts index f1bc8f27e7..01a85333c8 100644 --- a/packages/checkout/widgets-lib/src/lib/constants.ts +++ b/packages/checkout/widgets-lib/src/lib/constants.ts @@ -115,3 +115,8 @@ export const IMAGE_RESIZER_URL = { }; export const WITHDRAWAL_CLAIM_GAS_LIMIT = 91000; + +/** + * Key for storing if user has seen non-passport warning UI in local storage + */ +export const HAS_SEEN_NON_PASSPORT_WARNING_KEY = '@imtbl/checkout/has-seen-non-passport-warning'; diff --git a/packages/checkout/widgets-lib/src/locales/en.json b/packages/checkout/widgets-lib/src/locales/en.json index 90d4fc8b12..b063beb3e5 100644 --- a/packages/checkout/widgets-lib/src/locales/en.json +++ b/packages/checkout/widgets-lib/src/locales/en.json @@ -650,15 +650,32 @@ }, "debit": { "heading": "Debit Card", - "caption": "The recommended way to pay with card.", + "caption": "", "disabledCaption": "Unavailable for your selection. We recommend adding tokens." }, "credit": { "heading": "Credit Card", - "caption": "Not recommended since transactions may be blocked by your bank.", + "caption": "Failures and extra fees may occur.", "disabledCaption": "Unavailable for your selection. We recommend adding tokens." } } + }, + "onboarding": { + "screen1": { + "title": "Payments on Immutable\nhave evolved", + "caption": "listen up", + "buttonText": "Next" + }, + "screen2": { + "title": "Deliver tokens to Passport\n& pay from any wallet", + "caption": "whats evolved", + "buttonText": "Next" + }, + "screen3": { + "title": "Pay with tokens on other chains,\nwe'll find you the best option", + "caption": "evolution", + "buttonText": "Choose the Wallet to Pay with" + } } } }, diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsRoot.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsRoot.tsx index 0ba1744e3e..d16d3d7e03 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsRoot.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsRoot.tsx @@ -1,5 +1,4 @@ import { - ChainId, IMTBLWidgetEvents, WidgetConfiguration, WidgetProperties, @@ -15,12 +14,10 @@ import i18n from '../../i18n'; import { LoadingView } from '../../views/loading/LoadingView'; import { ThemeProvider } from '../../components/ThemeProvider/ThemeProvider'; import { - ConnectLoader, - ConnectLoaderParams, -} from '../../components/ConnectLoader/ConnectLoader'; -import { getL1ChainId, getL2ChainId } from '../../lib'; -import { sendAddFundsCloseEvent } from './AddFundsWidgetEvents'; -import { isValidAddress, isValidAmount } from '../../lib/validations/widgetValidators'; + isValidAddress, + isValidAmount, +} from '../../lib/validations/widgetValidators'; +import { ProvidersContextProvider } from '../../context/providers-context/ProvidersContext'; const AddFundsWidget = React.lazy(() => import('./AddFundsWidget')); @@ -67,47 +64,31 @@ export class AddFunds extends Base { if (!this.reactRoot) return; const { t } = i18n; - const connectLoaderParams: ConnectLoaderParams = { - targetChainId: this.checkout.config.isProduction - ? ChainId.IMTBL_ZKEVM_MAINNET - : ChainId.IMTBL_ZKEVM_TESTNET, - web3Provider: this.web3Provider, - checkout: this.checkout, - allowedChains: [ - getL1ChainId(this.checkout.config), - getL2ChainId(this.checkout.config), - ], - isCheckNetworkEnabled: false, - }; this.reactRoot.render( - sendAddFundsCloseEvent(window)} + - } + } > - + diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidget.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidget.tsx index 20e58942fc..05107bfdb0 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidget.tsx @@ -1,41 +1,48 @@ -import { Web3Provider } from '@ethersproject/providers'; import { - useContext, useEffect, useMemo, useReducer, + useContext, useEffect, useMemo, useReducer, useRef, } from 'react'; import { useTranslation } from 'react-i18next'; -import { AddFundsWidgetParams, Checkout } from '@imtbl/checkout-sdk'; +import { AddFundsWidgetParams } from '@imtbl/checkout-sdk'; +import { Stack, CloudImage } from '@biom3/react'; import { sendAddFundsCloseEvent } from './AddFundsWidgetEvents'; import { EventTargetContext } from '../../context/event-target-context/EventTargetContext'; import { - AddFundsActions, AddFundsContext, addFundsReducer, initialAddFundsState, + AddFundsActions, + AddFundsContext, + addFundsReducer, + initialAddFundsState, } from './context/AddFundsContext'; import { AddFundsWidgetViews } from '../../context/view-context/AddFundsViewContextTypes'; import { initialViewState, - SharedViews, ViewActions, + SharedViews, + ViewActions, ViewContext, viewReducer, } from '../../context/view-context/ViewContext'; import { AddFunds } from './views/AddFunds'; import { ErrorView } from '../../views/error/ErrorView'; import { useSquid } from './hooks/useSquid'; -import { useAnalytics, UserJourney } from '../../context/analytics-provider/SegmentAnalyticsProvider'; +import { + useAnalytics, + UserJourney, +} from '../../context/analytics-provider/SegmentAnalyticsProvider'; import { fetchChains } from './functions/fetchChains'; import { StrongCheckoutWidgetsConfig } from '../../lib/withDefaultWidgetConfig'; import { Review } from './views/Review'; import { fetchBalances } from './functions/fetchBalances'; import { useTokens } from './hooks/useTokens'; +import { useProvidersContext } from '../../context/providers-context/ProvidersContext'; +import { ServiceUnavailableErrorView } from '../../views/error/ServiceUnavailableErrorView'; +import { ServiceType } from '../../views/error/serviceTypes'; +import { getRemoteImage } from '../../lib/utils'; export type AddFundsWidgetInputs = AddFundsWidgetParams & { - checkout: Checkout; - web3Provider?: Web3Provider; config: StrongCheckoutWidgetsConfig; }; export default function AddFundsWidget({ - checkout, - web3Provider, showOnrampOption = true, showSwapOption = true, showBridgeOption = true, @@ -44,6 +51,8 @@ export default function AddFundsWidget({ showBackButton, config, }: AddFundsWidgetInputs) { + const fetchingBalances = useRef(false); + const [viewState, viewDispatch] = useReducer(viewReducer, { ...initialViewState, view: { type: AddFundsWidgetViews.ADD_FUNDS }, @@ -60,11 +69,16 @@ export default function AddFundsWidget({ [viewState, viewReducer], ); - const [addFundsState, addFundsDispatch] = useReducer(addFundsReducer, initialAddFundsState); + const [addFundsState, addFundsDispatch] = useReducer( + addFundsReducer, + initialAddFundsState, + ); const { - squid, provider, chains, - } = addFundsState; + providersState: { checkout, fromProvider }, + } = useProvidersContext(); + + const { squid, chains } = addFundsState; const addFundsReducerValues = useMemo( () => ({ @@ -91,20 +105,25 @@ export default function AddFundsWidget({ }, []); useEffect(() => { - if (!squid || !chains || !provider) return; + if (!squid || !chains || !fromProvider || fetchingBalances.current) return; (async () => { - const evmChains = chains.filter((chain) => chain.type === 'evm'); - const balances = await fetchBalances(squid, evmChains, provider); - - addFundsDispatch({ - payload: { - type: AddFundsActions.SET_BALANCES, - balances: balances ?? [], - }, - }); + try { + fetchingBalances.current = true; + const evmChains = chains.filter((chain) => chain.type === 'evm'); + const balances = await fetchBalances(squid, evmChains, fromProvider); + + addFundsDispatch({ + payload: { + type: AddFundsActions.SET_BALANCES, + balances, + }, + }); + } finally { + fetchingBalances.current = false; + } })(); - }, [squid, chains, provider]); + }, [squid, chains, fromProvider]); useEffect(() => { if (!squidSdk) return; @@ -128,26 +147,6 @@ export default function AddFundsWidget({ }); }, [tokensResponse]); - useEffect(() => { - if (!web3Provider) return; - addFundsDispatch({ - payload: { - type: AddFundsActions.SET_PROVIDER, - provider: web3Provider, - }, - }); - }, [web3Provider]); - - useEffect(() => { - if (!checkout) return; - addFundsDispatch({ - payload: { - type: AddFundsActions.SET_CHECKOUT, - checkout, - }, - }); - }, [checkout]); - const { eventTargetState: { eventTarget }, } = useContext(EventTargetContext); @@ -164,46 +163,73 @@ export default function AddFundsWidget({ return ( - {viewState.view.type === AddFundsWidgetViews.ADD_FUNDS && ( - sendAddFundsCloseEvent(eventTarget)} - /> - )} - {viewState.view.type === AddFundsWidgetViews.REVIEW && ( - sendAddFundsCloseEvent(eventTarget)} - onBackButtonClick={() => { - viewDispatch({ - payload: { - type: ViewActions.GO_BACK, - }, - }); - }} - showBackButton - /> - )} - {viewState.view.type === SharedViews.ERROR_VIEW && ( - sendAddFundsCloseEvent(eventTarget)} - errorEventAction={() => { - page({ - userJourney: UserJourney.ADD_FUNDS, - screen: 'Error', - }); + + + )} + sx={{ + pos: 'absolute', + h: '100%', + w: '100%', + objectFit: 'cover', + objectPosition: 'center', }} /> - )} + {viewState.view.type === AddFundsWidgetViews.ADD_FUNDS && ( + sendAddFundsCloseEvent(eventTarget)} + /> + )} + {viewState.view.type === AddFundsWidgetViews.REVIEW && ( + sendAddFundsCloseEvent(eventTarget)} + onBackButtonClick={() => { + viewDispatch({ + payload: { + type: ViewActions.GO_BACK, + }, + }); + }} + showBackButton + /> + )} + {viewState.view.type === SharedViews.ERROR_VIEW && ( + sendAddFundsCloseEvent(eventTarget)} + errorEventAction={() => { + page({ + userJourney: UserJourney.ADD_FUNDS, + screen: 'Error', + }); + }} + /> + )} + {viewState.view.type + === SharedViews.SERVICE_UNAVAILABLE_ERROR_VIEW && ( + sendAddFundsCloseEvent(eventTarget)} + /> + )} + ); diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidgetEvents.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidgetEvents.ts index 0239496966..aab1c05011 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidgetEvents.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/AddFundsWidgetEvents.ts @@ -1,8 +1,10 @@ +import { Web3Provider } from '@ethersproject/providers'; import { WidgetEvent, WidgetType, AddFundsEventType, IMTBLWidgetEvents, + EIP6963ProviderInfo, } from '@imtbl/checkout-sdk'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -20,3 +22,30 @@ export function sendAddFundsCloseEvent(eventTarget: Window | EventTarget) { console.log('close widget event:', closeWidgetEvent); if (eventTarget !== undefined) eventTarget.dispatchEvent(closeWidgetEvent); } + +export function sendConnectProviderSuccessEvent( + eventTarget: Window | EventTarget, + providerType: 'from' | 'to', + provider: Web3Provider, + providerInfo: EIP6963ProviderInfo, +) { + const successEvent = new CustomEvent< + WidgetEvent + >(IMTBLWidgetEvents.IMTBL_ADD_FUNDS_WIDGET_EVENT, { + detail: { + type: AddFundsEventType.CONNECT_SUCCESS, + data: { + provider, + providerType, + providerInfo, + }, + }, + }); + // eslint-disable-next-line no-console + console.log( + `connect ${providerType}Provider success event:`, + eventTarget, + successEvent, + ); + if (eventTarget !== undefined) eventTarget.dispatchEvent(successEvent); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/FiatOption.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/FiatOption.tsx index 505ebf4189..211ca42c9d 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/components/FiatOption.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/FiatOption.tsx @@ -3,7 +3,9 @@ import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { FiatOptionType } from '../types'; -export interface FiatOptionProps { +export interface FiatOptionProps< + RC extends ReactElement | undefined = undefined, +> { rc?: RC; type: FiatOptionType; onClick?: (type: FiatOptionType) => void; @@ -15,14 +17,14 @@ export function FiatOption({ type, onClick, disabled = false, - size, + size = 'small', rc = , }: FiatOptionProps) { const { t } = useTranslation(); const icon: Record = { [FiatOptionType.DEBIT]: 'BankCard', - [FiatOptionType.CREDIT]: 'BankCard', + [FiatOptionType.CREDIT]: 'Craft', }; const handleClick = () => { @@ -33,34 +35,28 @@ export function FiatOption({ disabled, emphasized: true, onClick: disabled ? undefined : handleClick, + size, + rc, }; return ( - - - + + + {t(`views.ADD_FUNDS.drawer.options.${type}.heading`)} - {!disabled && } - { t( + {t( `views.ADD_FUNDS.drawer.options.${type}.${ disabled ? 'disabledCaption' : 'caption' }`, )} + {!disabled && } ); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/OnboardingDrawer.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/OnboardingDrawer.tsx new file mode 100644 index 0000000000..78356be24b --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/OnboardingDrawer.tsx @@ -0,0 +1,115 @@ +import { + Box, + Button, + Divider, + Drawer, + Heading, + OnboardingPagination, + vFlex, +} from '@biom3/react'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { Environment } from '@imtbl/config'; +import { + getCacheItem, + SEEN_ONBOARDING_KEY, + setCacheItem, +} from '../functions/onboardingState'; +import { getRemoteImage } from '../../../lib/utils'; + +const HERO_IMAGES = [ + '/add-funds-onboarding-1.svg', + '/add-funds-onboarding-2.svg', + '/add-funds-onboarding-3.svg', +]; + +export type OnboardingDrawerProps = { + environment: Environment; +}; + +export function OnboardingDrawer({ environment }: OnboardingDrawerProps) { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [screenIndex, setScreenIndex] = useState<1 | 2 | 3>(1); + + useEffect(() => { + async function checkToInitialiseDrawer() { + const cachedValue = await getCacheItem(SEEN_ONBOARDING_KEY); + return cachedValue ? setVisible(false) : setVisible(true); + } + + checkToInitialiseDrawer(); + }, []); + + const handleCtaOnClick = useCallback(() => { + switch (screenIndex) { + case 2: { + // @NOTE: once they have "seen" the final slide, mark it as such + // in the cache so that we don't show this to users again + setCacheItem(SEEN_ONBOARDING_KEY, true); + return setScreenIndex(3); + } + case 3: + // @NOTE: they have "seen" all slides - so this drawer can be closed + return setVisible(false); + + case 1: + default: + return setScreenIndex(2); + } + }, [screenIndex]); + + const src = useMemo( + () => getRemoteImage(environment, HERO_IMAGES[screenIndex - 1]), + [screenIndex], + ); + + return ( + + + } sx={{ userSelect: 'none' }} /> + + {t(`views.ADD_FUNDS.onboarding.screen${screenIndex}.caption`)} + + + {t(`views.ADD_FUNDS.onboarding.screen${screenIndex}.title`)} + + + + + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/Options.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/Options.tsx index 761ad48e3d..d509fa9e40 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/components/Options.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/Options.tsx @@ -1,8 +1,14 @@ import { - Box, LoadingOverlay, MenuItemSize, + Body, + FramedVideo, + MenuItem, + Stack, + MenuItemSize, + Divider, + Banner, } from '@biom3/react'; import { motion } from 'framer-motion'; -import { TokenBalance } from '@0xsquid/sdk/dist/types'; +import { useMemo } from 'react'; import { listItemVariants, listVariants, @@ -10,8 +16,6 @@ import { import { FiatOption } from './FiatOption'; import { Chain, FiatOptionType, RouteData } from '../types'; import { RouteOption } from './RouteOption'; -import { convertTokenBalanceToUsd } from '../functions/convertTokenBalanceToUsd'; -import { sortRoutesByFastestTime } from '../functions/sortRoutesByFastestTime'; const defaultFiatOptions: FiatOptionType[] = [ FiatOptionType.DEBIT, @@ -20,99 +24,115 @@ const defaultFiatOptions: FiatOptionType[] = [ export interface OptionsProps { chains: Chain[] | null; - balances: TokenBalance[] | null; onCardClick: (type: FiatOptionType) => void; - onRouteClick: (route: RouteData) => void; + onRouteClick: (route: RouteData, index: number) => void; routes?: RouteData[]; size?: MenuItemSize; showOnrampOption?: boolean; + insufficientBalance?: boolean; + selectedIndex: number; } export function Options({ routes, chains, - balances, onCardClick, onRouteClick, size, showOnrampOption, + insufficientBalance, + selectedIndex, }: OptionsProps) { - const getUsdBalance = (balance: TokenBalance | undefined, route: RouteData) => { - if (!balance) return undefined; - - try { - return convertTokenBalanceToUsd(balance, route.route)?.toString(); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error calculating USD balance:', error); - return undefined; - } - }; - - const sortedRoutes = sortRoutesByFastestTime(routes); - - if (!sortedRoutes) { + // @NOTE: early exit with loading related UI, when the + // routes are not yet available + if (!routes?.length && !insufficientBalance) { return ( - - - - - + + + + + + Finding the best value +
+ across all chains + + +
); } - const routeOptions = sortedRoutes.map((route: RouteData) => { - const { fromToken } = route.amountData; + const routeOptions = routes?.map((routeData: RouteData, index) => ( + onRouteClick(routeData, index)} + isFastest={index === 0} + selected={index === selectedIndex} + rc={} + /> + )); - const chain = chains?.find((c) => c.id === fromToken.chainId); - const balance = balances?.find( - (bal) => bal.address === fromToken.address && bal.chainId === fromToken.chainId, + const fiatOptions = useMemo(() => { + if (!showOnrampOption) return null; + + return ( + <> + + More ways to Pay + + {defaultFiatOptions.map((type, idx) => ( + } + /> + ))} + ); + }, [showOnrampOption, size, onCardClick]); - const usdBalance = getUsdBalance(balance, route); + const noFundsBanner = useMemo(() => { + if (!insufficientBalance || routes?.length) return null; return ( - } - /> + + + No routes found + + Choose a different wallet, token or amount and try again. + + ); - }); - - const fiatOptions = showOnrampOption - ? defaultFiatOptions.map((type, idx) => ( - } - /> - )) - : null; + }, [insufficientBalance, routes]); return ( - } + justifyContent="center" + rc={ + + } > {routeOptions} + {noFundsBanner} {fiatOptions} - + ); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/OptionsDrawer.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/OptionsDrawer.tsx index a29a8aa11a..d95b608315 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/components/OptionsDrawer.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/OptionsDrawer.tsx @@ -1,10 +1,12 @@ -import { Box, Drawer } from '@biom3/react'; +import { Drawer, EllipsizedText, MenuItem } from '@biom3/react'; import { motion } from 'framer-motion'; -import { useContext } from 'react'; +import { useContext, useRef } from 'react'; + import { listVariants } from '../../../lib/animation/listAnimation'; import { Options } from './Options'; import { FiatOptionType, RouteData } from '../types'; import { AddFundsContext } from '../context/AddFundsContext'; +import { useProvidersContext } from '../../../context/providers-context/ProvidersContext'; type OptionsDrawerProps = { routes: RouteData[] | undefined; @@ -15,6 +17,7 @@ type OptionsDrawerProps = { showOnrampOption?: boolean; showSwapOption?: boolean; showBridgeOption?: boolean; + insufficientBalance?: boolean; }; export function OptionsDrawer({ @@ -28,43 +31,66 @@ export function OptionsDrawer({ showSwapOption, // eslint-disable-next-line @typescript-eslint/no-unused-vars showBridgeOption, + insufficientBalance, }: OptionsDrawerProps) { + const { addFundsState: { chains } } = useContext(AddFundsContext); + const { - addFundsState: { chains, balances }, - } = useContext(AddFundsContext); + providersState: { fromProviderInfo, fromAddress }, + } = useProvidersContext(); + + const selectedRouteIndex = useRef(0); + + const handleOnRouteClick = (route: RouteData, index: number) => { + selectedRouteIndex.current = index; + onRouteClick(route); + }; return ( - } + } + sx={{ + pt: 'base.spacing.x3', + px: 'base.spacing.x3', + }} > - - + + } /> - + Pay from + + {fromProviderInfo?.name} + {' • '} + + + +
+ ); diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteFees.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteFees.tsx new file mode 100644 index 0000000000..419bdf968a --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteFees.tsx @@ -0,0 +1,79 @@ +import { RouteResponse } from '@0xsquid/squid-types'; + +import { useMemo } from 'react'; +import { FeesBreakdown } from '../../../components/FeesBreakdown/FeesBreakdown'; +import { FormattedFee } from '../../swap/functions/swapFees'; +import { + getFormattedNumber, + getFormattedAmounts, +} from '../functions/getFormattedNumber'; + +export type RouteFeesProps = { + visible: boolean; + onClose: () => void; + routeData: RouteResponse | undefined; + totalAmount: number; + totalFiatAmount: number; +}; + +export function RouteFees({ + visible, + onClose, + routeData, + totalAmount, + totalFiatAmount, +}: RouteFeesProps) { + const feeCosts = useMemo( + () => routeData?.route.estimate.feeCosts.map((fee) => ({ + label: fee.name, + amount: getFormattedNumber(fee.amount, fee.token.decimals), + fiatAmount: `USD ≈ ${getFormattedAmounts(fee.amountUsd)}`, + token: { + name: fee.token.name, + symbol: fee.token.symbol, + decimals: fee.token.decimals, + address: fee.token.address, + icon: fee.token.logoURI, + }, + prefix: '', + })) ?? [], + [routeData], + ); + + const gasCosts = useMemo( + () => routeData?.route.estimate.gasCosts.map((fee) => ({ + label: 'Gas (transaction)', + amount: getFormattedNumber(fee.amount, fee.token.decimals), + fiatAmount: `USD ≈ ${getFormattedAmounts(fee.amountUsd)}`, + token: { + name: fee.token.name, + symbol: fee.token.symbol, + decimals: fee.token.decimals, + address: fee.token.address, + icon: fee.token.logoURI, + }, + prefix: '', + })) ?? [], + [routeData], + ); + + const feesToken = routeData?.route.estimate.feeCosts?.[0]?.token + || routeData?.route.estimate.gasCosts?.[0]?.token; + + if (!feesToken) { + return null; + } + + const tokenSymbol = feesToken?.symbol || ''; + return ( + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteOption.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteOption.tsx index e438a7a9f3..b1a0c88875 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteOption.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/RouteOption.tsx @@ -1,115 +1,145 @@ import { + Badge, + Body, + centerFlexChildren, + hFlex, Icon, - MenuItem, MenuItemSize, Sticker, + MenuItem, + MenuItemSize, + Stack, + Sticker, } from '@biom3/react'; import { ReactElement, useMemo } from 'react'; -import { ethers } from 'ethers'; import { Chain, RouteData } from '../types'; - -export interface RouteOptionProps { - route: RouteData; +import { getDurationFormatted } from '../functions/getDurationFormatted'; +import { getTotalRouteFees } from '../functions/getTotalRouteFees'; +import { getFormattedAmounts } from '../functions/getFormattedNumber'; +import { getRouteAndTokenBalances } from '../functions/getRouteAndTokenBalances'; + +export interface RouteOptionProps< + RC extends ReactElement | undefined = undefined, +> { + routeData: RouteData; + chains: Chain[] | null; onClick: (route: RouteData) => void; - chain?: Chain; - usdBalance?: string; disabled?: boolean; isFastest?: boolean; size?: MenuItemSize; rc?: RC; + selected?: boolean; } export function RouteOption({ - route, + routeData, onClick, - chain, - usdBalance, + chains, disabled = false, isFastest = false, - size, + size = 'small', rc = , + selected = false, }: RouteOptionProps) { - const { fromToken } = route.amountData; + const { fromToken } = routeData.amountData; + const { estimate } = routeData.route.route; - const { estimate } = route.route.route; + const chain = chains?.find((c) => c.id === fromToken.chainId); - const formattedFromAmount = useMemo(() => Number(ethers.utils.formatUnits( - estimate.fromAmount, - estimate.fromToken.decimals, - )).toFixed(4), [estimate.fromAmount, estimate.fromToken.decimals]); + const estimatedDurationFormatted = getDurationFormatted( + estimate.estimatedRouteDuration, + ); - const formattedUsdBalance = useMemo(() => (usdBalance ? Number(usdBalance).toFixed(2) : undefined), [usdBalance]); + const { totalFeesUsd } = useMemo( + () => getTotalRouteFees(routeData.route), + [routeData], + ); - const estimatedDurationFormatted = useMemo(() => { - const seconds = estimate.estimatedRouteDuration; - if (seconds >= 60) { - const minutes = Math.round(seconds / 60); - return minutes === 1 ? '1 min' : `${minutes} mins`; - } - return `${seconds.toFixed(0)}s`; - }, [estimate.estimatedRouteDuration]); + const { routeBalanceUsd, fromAmount, fromAmountUsd } = useMemo( + () => getRouteAndTokenBalances(routeData), + [routeData], + ); const handleClick = () => { - onClick(route); + onClick(routeData); }; const menuItemProps = { + selected, disabled, emphasized: true, + rc, + size, onClick: disabled ? undefined : handleClick, }; return ( - - {fromToken.name} - - {formattedUsdBalance && ( - - {`Balance: $${formattedUsdBalance}`} - - )} + + {fromToken.name} {chain && ( - - } - sx={{ w: 'base.icon.size.200' }} - /> - - } - /> - + + } + size="xSmall" + /> + + } + /> + )} - {formattedFromAmount && estimate.fromAmountUSD && ( - + {`Balance: USD ${routeBalanceUsd}`} + + - {`USD $${estimate.fromAmountUSD}`} + {`USD $${fromAmountUsd}`} - )} - - {isFastest && ( - - )} - - - - {' '} - { estimatedDurationFormatted } - + + + } + direction="row" + justifyContent="space-between" + sx={{ + w: '100%', + }} + > + + + {estimatedDurationFormatted} + + + + {isFastest && ( + + )} + {`Fee ~ USD $${getFormattedAmounts(totalFeesUsd)}`} + + + ); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedRouteOption.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedRouteOption.tsx new file mode 100644 index 0000000000..a7b2e646f4 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedRouteOption.tsx @@ -0,0 +1,185 @@ +import { + AllDualVariantIconKeys, MenuItem, Stack, Sticker, +} from '@biom3/react'; +import { + MouseEvent, + MouseEventHandler, + ReactNode, + useCallback, + useMemo, +} from 'react'; + +import { Chain, RouteData } from '../types'; +import { getRouteAndTokenBalances } from '../functions/getRouteAndTokenBalances'; + +export interface RouteOptionProps { + routeData?: RouteData; + chains: Chain[] | null; + onClick: MouseEventHandler; + loading?: boolean; + withSelectedToken?: boolean; + withSelectedAmount?: boolean; + withSelectedWallet?: boolean; + insufficientBalance?: boolean; + showOnrampOption?: boolean; +} + +function SelectedRouteOptionContainer({ + children, + onClick, + selected, +}: { + children: ReactNode; + selected?: boolean; + onClick?: MouseEventHandler; +}) { + return ( + (selected + ? `calc(${base.spacing.x12} * -1)` + : `calc(${base.spacing.x16} * -1)`), + w: ({ base }) => (selected + ? `calc(100% + (${base.spacing.x12}))` + : `calc(100% + (${base.spacing.x16}))`), + }} + rc={} + > + {children} + + ); +} + +export function SelectedRouteOption({ + routeData, + chains, + loading = false, + withSelectedWallet = false, + withSelectedToken = false, + withSelectedAmount = false, + insufficientBalance = false, + showOnrampOption = false, + onClick, +}: RouteOptionProps) { + const { fromToken } = routeData?.amountData ?? {}; + const chain = chains?.find((c) => c.id === fromToken?.chainId); + + const { routeBalanceUsd, fromAmount, fromAmountUsd } = useMemo( + () => getRouteAndTokenBalances(routeData), + [routeData], + ); + + const insufficientBalancePayWithCard = insufficientBalance && showOnrampOption; + + const handleOnClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + + if (!loading && !routeData && !insufficientBalancePayWithCard) return false; + + onClick?.(event); + return true; + }, + [onClick, loading, routeData], + ); + + if (!routeData && loading) { + return ( + + + Finding the best payment route... + + ); + } + + if ((!routeData && !loading) || insufficientBalance) { + let icon: AllDualVariantIconKeys = 'Sparkle'; + let copy = "Add your token, we'll find the best payment"; + + if (!withSelectedToken && withSelectedAmount) { + copy = "Add your token, we'll find the best payment"; + } + + if (withSelectedToken && !withSelectedAmount) { + copy = "Add your amount, we'll find the best payment"; + } + + if (!withSelectedWallet && withSelectedToken && withSelectedAmount) { + copy = "Select a wallet, we'll find the best payment"; + } + + if (insufficientBalance) { + icon = 'InformationCircle'; + copy = 'No routes found, choose a different wallet, token or amount.'; + } + + if (insufficientBalancePayWithCard) { + icon = 'BankCard'; + copy = 'No routes found, pay with card available'; + } + + return ( + + + {copy} + + ); + } + + return ( + + {chain && ( + + } + sx={{ w: 'base.icon.size.200' }} + /> + + } + /> + + )} + + + + {fromToken?.name} + {`Balance USD $${routeBalanceUsd}`} + + + + {`USD $${fromAmountUsd}`} + + + + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedWallet.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedWallet.tsx new file mode 100644 index 0000000000..c96c5af951 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SelectedWallet.tsx @@ -0,0 +1,51 @@ +import { MouseEventHandler, ReactNode } from 'react'; +import { EllipsizedText, MenuItem, MenuItemProps } from '@biom3/react'; +import { EIP6963ProviderInfo } from '@imtbl/checkout-sdk'; + +export interface SelectedWalletProps { + children?: ReactNode; + label: string; + providerInfo?: Partial< + EIP6963ProviderInfo & { + address?: string; + } + >; + onClick: MouseEventHandler; +} + +export function SelectedWallet({ + label, + children, + onClick, + providerInfo, +}: SelectedWalletProps) { + const selected = !!children && providerInfo?.rdns; + const size: MenuItemProps['size'] = selected ? 'xSmall' : 'small'; + + return ( + + {!providerInfo?.icon && ( + + )} + {providerInfo?.icon && ( + } + /> + )} + {label} + {providerInfo?.name && ( + + {providerInfo?.name} + {' • '} + + + )} + {children} + + ); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/components/SquidIcon.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SquidIcon.tsx index 6365c30629..80e7cf106f 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/components/SquidIcon.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/components/SquidIcon.tsx @@ -1,10 +1,31 @@ -import { SvgIcon } from '@biom3/react'; +import { DeeplyNestedSx, SvgIcon } from '@biom3/react'; +import merge from 'ts-deepmerge'; -export function SquidIcon() { +export function SquidIcon({ + sx = {}, + className, +}: { + sx?: DeeplyNestedSx; + className?: string; +}) { return ( - - {/* eslint-disable-next-line max-len */} - + + + + ); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.tsx similarity index 63% rename from packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.ts rename to packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.tsx index fb40fb6d8e..01700178a9 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/context/AddFundsContext.tsx @@ -1,28 +1,31 @@ -import { Web3Provider } from '@ethersproject/providers'; import { createContext } from 'react'; -import { Checkout, TokenInfo } from '@imtbl/checkout-sdk'; +import { TokenInfo } from '@imtbl/checkout-sdk'; import { Squid } from '@0xsquid/sdk'; import { TokenBalance } from '@0xsquid/sdk/dist/types'; -import { Chain, Token } from '../types'; +import { Chain, Token, RouteData } from '../types'; export interface AddFundsState { - checkout: Checkout | null; - provider: Web3Provider | null; allowedTokens: TokenInfo[] | null; squid: Squid | null; chains: Chain[] | null; balances: TokenBalance[] | null; tokens: Token[] | null; + routes: RouteData[]; + selectedRouteData: RouteData | undefined; + selectedToken: TokenInfo | undefined; + selectedAmount: string; } export const initialAddFundsState: AddFundsState = { - checkout: null, - provider: null, allowedTokens: null, squid: null, chains: null, balances: null, tokens: null, + routes: [], + selectedRouteData: undefined, + selectedToken: undefined, + selectedAmount: '', }; export interface AddFundsContextState { @@ -35,32 +38,26 @@ export interface AddFundsAction { } type ActionPayload = - | SetCheckoutPayload - | SetProviderPayload | SetAllowedTokensPayload | SetSquid | SetChains | SetBalances - | SetTokens; + | SetTokens + | SetRoutes + | SetSelectedRouteData + | SetSelectedToken + | SetSelectedAmount; export enum AddFundsActions { - SET_CHECKOUT = 'SET_CHECKOUT', - SET_PROVIDER = 'SET_PROVIDER', SET_ALLOWED_TOKENS = 'SET_ALLOWED_TOKENS', SET_SQUID = 'SET_SQUID', SET_CHAINS = 'SET_CHAINS', SET_BALANCES = 'SET_BALANCES', SET_TOKENS = 'SET_TOKENS', -} - -export interface SetCheckoutPayload { - type: AddFundsActions.SET_CHECKOUT; - checkout: Checkout; -} - -export interface SetProviderPayload { - type: AddFundsActions.SET_PROVIDER; - provider: Web3Provider; + SET_ROUTES = 'SET_ROUTES', + SET_SELECTED_ROUTE_DATA = 'SET_SELECTED_ROUTE_DATA', + SET_SELECTED_TOKEN = 'SET_SELECTED_TOKEN', + SET_SELECTED_AMOUNT = 'SET_SELECTED_AMOUNT', } export interface SetAllowedTokensPayload { @@ -87,6 +84,26 @@ export interface SetTokens { tokens: Token[]; } +export interface SetRoutes { + type: AddFundsActions.SET_ROUTES; + routes: RouteData[]; +} + +export interface SetSelectedRouteData { + type: AddFundsActions.SET_SELECTED_ROUTE_DATA; + selectedRouteData: RouteData | undefined; +} + +export interface SetSelectedToken { + type: AddFundsActions.SET_SELECTED_TOKEN; + selectedToken: TokenInfo | undefined; +} + +export interface SetSelectedAmount { + type: AddFundsActions.SET_SELECTED_AMOUNT; + selectedAmount: string; +} + // eslint-disable-next-line @typescript-eslint/naming-convention export const AddFundsContext = createContext({ addFundsState: initialAddFundsState, @@ -102,16 +119,6 @@ export const addFundsReducer: Reducer = ( action: AddFundsAction, ) => { switch (action.payload.type) { - case AddFundsActions.SET_CHECKOUT: - return { - ...state, - checkout: action.payload.checkout, - }; - case AddFundsActions.SET_PROVIDER: - return { - ...state, - provider: action.payload.provider, - }; case AddFundsActions.SET_ALLOWED_TOKENS: return { ...state, @@ -137,6 +144,26 @@ export const addFundsReducer: Reducer = ( ...state, tokens: action.payload.tokens, }; + case AddFundsActions.SET_ROUTES: + return { + ...state, + routes: action.payload.routes, + }; + case AddFundsActions.SET_SELECTED_ROUTE_DATA: + return { + ...state, + selectedRouteData: action.payload.selectedRouteData, + }; + case AddFundsActions.SET_SELECTED_TOKEN: + return { + ...state, + selectedToken: action.payload.selectedToken, + }; + case AddFundsActions.SET_SELECTED_AMOUNT: + return { + ...state, + selectedAmount: action.payload.selectedAmount, + }; default: return state; } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/amountValidation.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/amountValidation.ts index a27c3bf7b0..0ff5232946 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/amountValidation.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/amountValidation.ts @@ -1,5 +1,16 @@ -export const validateToAmount = (amount: string): boolean => { - const validNumberRegex = /^[0-9]+(\.[0-9]+)?$/; +const VALID_NUMBER_REGEX = /^[0-9]+(\.[0-9]+)?$/; - return validNumberRegex.test(amount) && parseFloat(amount) > 0; +/** + * Validate the amount input + * @param amount - The amount to validate + * @returns An object containing the sanitized value, the float amount, and a boolean indicating if the amount is valid + */ +export const validateToAmount = (amount: string) => { + const value = amount || '0'; + const sanitizedValue = value.replace(/^0+(?=\d)/, ''); + const floatAmount = parseFloat(sanitizedValue); + + const isValid = VALID_NUMBER_REGEX.test(sanitizedValue) && floatAmount > 0; + + return { value: sanitizedValue, amount: floatAmount, isValid }; }; diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/convertTokenBalanceToUsd.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/convertTokenBalanceToUsd.ts deleted file mode 100644 index 50a529f667..0000000000 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/convertTokenBalanceToUsd.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TokenBalance } from '@0xsquid/sdk/dist/types'; -import { RouteResponse } from '@0xsquid/squid-types'; -import { ethers } from 'ethers'; - -export function convertTokenBalanceToUsd( - balance: TokenBalance, - routeResponse: RouteResponse, -): number { - const { usdPrice } = routeResponse.route.estimate.fromToken; - - if (!usdPrice) { - throw new Error('USD conversion rate not available'); - } - - const tokenBalance = ethers.utils.formatUnits(balance.balance, balance.decimals); - - const usdBalance = parseFloat(tokenBalance) * usdPrice; - - return usdBalance; -} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchBalances.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchBalances.ts index 8bfe0cfa73..2d900a6d91 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchBalances.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchBalances.ts @@ -3,8 +3,11 @@ import { Squid } from '@0xsquid/sdk'; import { CosmosBalance, TokenBalance } from '@0xsquid/sdk/dist/types'; import { Chain } from '../types'; -export const fetchBalances = async (squid: Squid, chains: Chain[], provider: Web3Provider) -: Promise => { +export const fetchBalances = async ( + squid: Squid, + chains: Chain[], + provider: Web3Provider, +): Promise => { const chainIds = chains.map((chain) => chain.id); const address = await provider?.getSigner().getAddress(); @@ -14,7 +17,10 @@ export const fetchBalances = async (squid: Squid, chains: Chain[], provider: Web }>[] = []; for (const chainId of chainIds) { - const balancePromise = squid.getAllBalances({ chainIds: [chainId], evmAddress: address }); + const balancePromise = squid.getAllBalances({ + chainIds: [chainId], + evmAddress: address, + }); promises.push(balancePromise); } diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchTokens.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchTokens.ts index 6887f3d316..152f487439 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchTokens.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/fetchTokens.ts @@ -15,7 +15,7 @@ type SquidTokensResponse = { tokens: SquidTokenResponse[]; }; -export const fetchTokens = async (integratorId:string): Promise => { +export const fetchTokens = async (integratorId: string): Promise => { const url = `${SQUID_SDK_BASE_URL}/v2/sdk-info`; const response = await fetch(url, { diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getDurationFormatted.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getDurationFormatted.ts new file mode 100644 index 0000000000..7909c141d0 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getDurationFormatted.ts @@ -0,0 +1,8 @@ +export function getDurationFormatted(estimatedRouteDuration: number) { + const seconds = estimatedRouteDuration; + if (seconds >= 60) { + const minutes = Math.round(seconds / 60); + return minutes === 1 ? '1 min' : `${minutes} mins`; + } + return `${seconds.toFixed(0)}s`; +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getFormattedNumber.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getFormattedNumber.ts new file mode 100644 index 0000000000..91dd96f776 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getFormattedNumber.ts @@ -0,0 +1,39 @@ +import { BigNumber, utils } from 'ethers'; + +import { tokenValueFormat } from '../../../lib/utils'; +import { DEFAULT_TOKEN_FORMATTING_DECIMALS } from '../../../lib/constants'; + +/** + * Formats a number to a string with a maximum number of decimals + * removing trailing zeros + */ +export const getFormattedAmounts = ( + amount: string | number, + maxDecimals = DEFAULT_TOKEN_FORMATTING_DECIMALS, +) => tokenValueFormat(amount, maxDecimals).replace(/\.?0+$/, ''); + +/** + * Converts a crypto amount to a formatted string + */ +export function getFormattedNumber( + value?: string | number, + decimals?: number, + maxDecimals = DEFAULT_TOKEN_FORMATTING_DECIMALS, +): string { + const amount = String(value); + let formattedValue = ''; + + try { + if (Number.isNaN(amount) || !decimals) { + throw new Error('Invalid amount or decimals'); + } + + formattedValue = utils + .formatUnits(BigNumber.from(amount), decimals) + .toString(); + } catch { + return '-.--'; + } + + return getFormattedAmounts(formattedValue, maxDecimals); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteAndTokenBalances.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteAndTokenBalances.ts new file mode 100644 index 0000000000..11bc452fa0 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteAndTokenBalances.ts @@ -0,0 +1,53 @@ +import { RouteData } from '../types'; +import { getFormattedAmounts, getFormattedNumber } from './getFormattedNumber'; + +export type RouteBalance = { + routeBalance: string; + routeBalanceUsd: string; + fromAmount: string; + fromAmountUsd: string; +}; + +const emptyRouteBalance: RouteBalance = { + routeBalance: getFormattedAmounts(''), + routeBalanceUsd: getFormattedAmounts(''), + fromAmount: getFormattedAmounts(''), + fromAmountUsd: getFormattedAmounts(''), +}; + +export function getRouteAndTokenBalances(routeData?: RouteData): RouteBalance { + if (!routeData) { + return emptyRouteBalance; + } + + const { fromToken } = routeData.amountData; + + const usdPrice = routeData?.route.route.estimate.fromToken.usdPrice; + if (!usdPrice) { + return emptyRouteBalance; + } + + const { balance } = routeData.amountData; + + const routeBalance = getFormattedNumber( + balance.balance, + fromToken?.decimals, + fromToken?.decimals, // preserve precision for usd conversion down below + ); + const routeBalanceUsd = (parseFloat(routeBalance) * usdPrice).toString(); + + const fromAmount = getFormattedNumber( + routeData.route.route.estimate.fromAmount, + routeData.route.route.estimate.fromToken.decimals, + ); + const fromAmountUsd = getFormattedAmounts( + routeData.route.route.estimate.fromAmountUSD ?? '', + ); + + return { + fromAmount, + fromAmountUsd, + routeBalance: getFormattedAmounts(routeBalance), + routeBalanceUsd: getFormattedAmounts(routeBalanceUsd), + }; +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteChains.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteChains.ts new file mode 100644 index 0000000000..5301fb67a9 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getRouteChains.ts @@ -0,0 +1,26 @@ +import { RouteResponse } from '@0xsquid/squid-types'; +import { Chain } from '../types'; + +/** + * Find a chain by its id + * @param chainId - The id of the chain to find + * @param chains - The chains to search through + * @returns The chain with the matching id, or undefined if no chain is found + */ +function findChainById(chainId: string | undefined, chains: Chain[] | null): Chain | undefined { + return chains?.find((chain) => chain.id === chainId); +} + +/** + * Get the chains from the route + * @param chains - The chains to search through + * @param route - The route to get the chains from + * @returns The chains from the route + */ +export const getRouteChains = (chains: Chain[] | null, route: RouteResponse | undefined): { + fromChain: Chain | undefined; + toChain: Chain | undefined; +} => ({ + fromChain: findChainById(route?.route.estimate.fromToken.chainId, chains), + toChain: findChainById(route?.route.estimate.toToken.chainId, chains), +}); diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getTotalRouteFees.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getTotalRouteFees.ts new file mode 100644 index 0000000000..c9c053eaf0 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/getTotalRouteFees.ts @@ -0,0 +1,41 @@ +import { RouteResponse } from '@0xsquid/squid-types'; + +/** + * Get the total fees for a route + */ +export function getTotalRouteFees(route?: RouteResponse): { + fees: number; + feesUsd: number; + gasFees: number; + gasFeesUsd: number; + totalFees: number; + totalFeesUsd: number; +} { + const [fees, feesUsd] = route?.route.estimate.feeCosts.reduce( + ([acc, accUsd], fee) => [ + acc + parseFloat(fee.amount), + accUsd + parseFloat(fee.amountUsd), + ], + [0, 0], + ) ?? [0, 0]; + + const [gasFees, gasFeesUsd] = route?.route.estimate.gasCosts.reduce( + ([acc, accUsd], fee) => [ + acc + parseFloat(fee.amount), + accUsd + parseFloat(fee.amountUsd), + ], + [0, 0], + ) ?? [0, 0]; + + const totalFees = fees + gasFees; + const totalFeesUsd = feesUsd + gasFeesUsd; + + return { + fees, + feesUsd, + gasFees, + gasFeesUsd, + totalFees, + totalFeesUsd, + }; +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/onboardingState.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/onboardingState.ts new file mode 100644 index 0000000000..ac48d8bf1e --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/onboardingState.ts @@ -0,0 +1,32 @@ +import localForage from 'localforage'; + +export const addFundsOnboardingCache = localForage.createInstance({ + name: 'AddFunds Onboarding State', + version: 1.0, + storeName: 'Internal state', + description: + 'A small IndexDB for storage of state relating to the AddFunds Onboarding Drawer', +}); + +export const SEEN_ONBOARDING_KEY = 'seen-onboarding'; + +type CacheItem = { + value: boolean; +} | null; + +export async function getCacheItem(key: string) { + const data: CacheItem = await addFundsOnboardingCache.getItem(key); + if (!data) return null; + const { value } = data; + return value; +} + +export async function setCacheItem(key: string, value: boolean) { + return addFundsOnboardingCache.setItem(key, { + value, + }); +} + +export async function clearCacheItems() { + return addFundsOnboardingCache.clear(); +} diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/sortRoutesByFastestTime.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/sortRoutesByFastestTime.ts index 858d8094cc..2d425fc91e 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/functions/sortRoutesByFastestTime.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/functions/sortRoutesByFastestTime.ts @@ -1,7 +1,7 @@ import { RouteData } from '../types'; -export const sortRoutesByFastestTime = (routes: RouteData[] | undefined): RouteData[] | undefined => { - if (!routes) return undefined; +export const sortRoutesByFastestTime = (routes: RouteData[]): RouteData[] => { + if (!routes) return []; return routes.slice().sort((a, b) => { const timeA = a.route.route.estimate.estimatedRouteDuration; diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useExecute.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useExecute.ts index ede8544e61..6369d23f55 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useExecute.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useExecute.ts @@ -7,10 +7,7 @@ import { isSquidNativeToken } from '../functions/isSquidNativeToken'; export const useExecute = () => { const convertToNetworkChangeableProvider = async ( provider: Web3Provider, - ): Promise => new ethers.providers.Web3Provider( - provider.provider, - 'any', - ); + ): Promise => new ethers.providers.Web3Provider(provider.provider, 'any'); const checkProviderChain = async ( provider: Web3Provider, @@ -59,7 +56,10 @@ export const useExecute = () => { throw new Error('transactionRequest target is undefined'); } - const allowance = await tokenContract.allowance(ownerAddress, transactionRequestTarget); + const allowance = await tokenContract.allowance( + ownerAddress, + transactionRequestTarget, + ); return allowance; } @@ -89,7 +89,10 @@ export const useExecute = () => { throw new Error('transactionRequest target is undefined'); } - const tx = await tokenContract.approve(transactionRequestTarget, fromAmount); + const tx = await tokenContract.approve( + transactionRequestTarget, + fromAmount, + ); await tx.wait(); } } catch (e) { @@ -118,6 +121,10 @@ export const useExecute = () => { }; return { - convertToNetworkChangeableProvider, checkProviderChain, getAllowance, approve, execute, + convertToNetworkChangeableProvider, + checkProviderChain, + getAllowance, + approve, + execute, }; }; diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useRoutes.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useRoutes.ts index 04e68b7a59..13b4dbf0a3 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useRoutes.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/hooks/useRoutes.ts @@ -2,24 +2,45 @@ import { TokenBalance } from '@0xsquid/sdk/dist/types'; import { RouteResponse } from '@0xsquid/squid-types'; import { Squid } from '@0xsquid/sdk'; import { utils } from 'ethers'; -import { useRef, useState } from 'react'; +import { useContext, useRef } from 'react'; import { delay } from '../functions/delay'; import { AmountData, RouteData, Token } from '../types'; +import { sortRoutesByFastestTime } from '../functions/sortRoutesByFastestTime'; +import { AddFundsActions, AddFundsContext } from '../context/AddFundsContext'; import { retry } from '../../../lib/retry'; export const useRoutes = () => { - const [routes, setRoutes] = useState(undefined); - const latestRequestIdRef = useRef(0); // Track the latest request ID + const latestRequestIdRef = useRef(0); - const resetRoutes = () => { - setRoutes(undefined); + const { addFundsDispatch } = useContext(AddFundsContext); + + const setRoutes = (routes: RouteData[]) => { + addFundsDispatch({ + payload: { + type: AddFundsActions.SET_ROUTES, + routes, + }, + }); }; - const findToken = (tokens: Token[], address: string, chainId: string) - : Token | undefined => tokens.find((value) => value.address.toLowerCase() === address.toLowerCase() - && value.chainId === chainId); + const resetRoutes = () => { + setRoutes([]); + }; - const calculateFromAmount = (fromToken: Token, toToken: Token, toAmount: string) => { + const findToken = ( + tokens: Token[], + address: string, + chainId: string, + ): Token | undefined => tokens.find( + (value) => value.address.toLowerCase() === address.toLowerCase() + && value.chainId === chainId, + ); + + const calculateFromAmount = ( + fromToken: Token, + toToken: Token, + toAmount: string, + ) => { const toAmountNumber = Number(toAmount); const toAmountInUsd = toAmountNumber * toToken.usdPrice; const baseFromAmount = toAmountInUsd / fromToken.usdPrice; @@ -34,7 +55,11 @@ export const useRoutes = () => { toChainId: string, toTokenAddress: string, ): AmountData | undefined => { - const fromToken = findToken(tokens, balance.address, balance.chainId.toString()); + const fromToken = findToken( + tokens, + balance.address, + balance.chainId.toString(), + ); const toToken = findToken(tokens, toTokenAddress, toChainId); if (!fromToken || !toToken) { return undefined; @@ -56,19 +81,23 @@ export const useRoutes = () => { toAmount: string, ): AmountData[] => { const filteredBalances = balances.filter( - (balance) => !(balance.address.toLowerCase() === toTokenAddress.toLowerCase() && balance.chainId === toChainId), + (balance) => !( + balance.address.toLowerCase() === toTokenAddress.toLowerCase() + && balance.chainId === toChainId + ), ); - const amountDataArray: AmountData[] = filteredBalances.map((balance) => getAmountData( - tokens, - balance, - toAmount, - toChainId, - toTokenAddress, - )).filter((value) => value !== undefined); + const amountDataArray: AmountData[] = filteredBalances + .map((balance) => getAmountData(tokens, balance, toAmount, toChainId, toTokenAddress)) + .filter((value) => value !== undefined); return amountDataArray.filter((data: AmountData) => { - const formattedBalance = utils.formatUnits(data.balance.balance, data.balance.decimals); - return parseFloat(formattedBalance.toString()) > parseFloat(data.fromAmount); + const formattedBalance = utils.formatUnits( + data.balance.balance, + data.balance.decimals, + ); + return ( + parseFloat(formattedBalance.toString()) > parseFloat(data.fromAmount) + ); }); }; @@ -82,7 +111,9 @@ export const useRoutes = () => { quoteOnly = true, ): Promise => { try { - const parsedFromAmount = parseFloat(fromAmount).toFixed(fromToken.decimals); + const parsedFromAmount = parseFloat(fromAmount).toFixed( + fromToken.decimals, + ); const formattedFromAmount = utils.parseUnits( parsedFromAmount, fromToken.decimals, @@ -115,21 +146,21 @@ export const useRoutes = () => { amountDataArray: AmountData[], toTokenAddress: string, ): Promise => { - const routePromises = amountDataArray.map( - (data) => getRoute( - squid, - data.fromToken, - data.toToken, - toTokenAddress, - data.fromAmount, - ).then((route) => ({ - amountData: data, - route, - })), - ); + const routePromises = amountDataArray.map((data) => getRoute( + squid, + data.fromToken, + data.toToken, + toTokenAddress, + data.fromAmount, + ).then((route) => ({ + amountData: data, + route, + }))); const routesData = await Promise.all(routePromises); - return routesData.filter((route): route is RouteData => route !== undefined); + return routesData.filter( + (route): route is RouteData => route?.route !== undefined, + ); }; const fetchRoutesWithRateLimit = async ( @@ -153,25 +184,35 @@ export const useRoutes = () => { ); const allRoutes: RouteData[] = []; - - for (let i = 0; i < amountDataArray.length; i += bulkNumber) { - const slicedAmountDataArray = amountDataArray.slice(i, i + bulkNumber); - - // eslint-disable-next-line no-await-in-loop - allRoutes.push(...await getRoutes(squid, slicedAmountDataArray, toTokenAddress)); - - // eslint-disable-next-line no-await-in-loop - await delay(delayMs); - } + await Promise.all( + amountDataArray + .reduce((acc, _, i) => { + if (i % bulkNumber === 0) { + acc.push(amountDataArray.slice(i, i + bulkNumber)); + } + return acc; + }, [] as (typeof amountDataArray)[]) + .map(async (slicedAmountDataArray) => { + allRoutes.push( + ...(await getRoutes(squid, slicedAmountDataArray, toTokenAddress)), + ); + await delay(delayMs); + }), + ); // Only update routes if the request is the latest one if (currentRequestId === latestRequestIdRef.current) { - setRoutes(allRoutes); + const sortedRoutes = sortRoutesByFastestTime(allRoutes); + setRoutes(sortedRoutes); } + return allRoutes; }; return { - routes, fetchRoutesWithRateLimit, getAmountData, getRoute, resetRoutes, + fetchRoutesWithRateLimit, + getAmountData, + getRoute, + resetRoutes, }; }; diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/types.ts b/packages/checkout/widgets-lib/src/widgets/add-funds/types.ts index 44fbbbf88b..e5ac17c834 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/types.ts +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/types.ts @@ -23,7 +23,7 @@ export type NativeCurrency = { name: string; symbol: string; decimals: number; - iconUrl:string; + iconUrl: string; }; export type AmountData = { diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/views/AddFunds.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/views/AddFunds.tsx index 14838272a6..000a280244 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/views/AddFunds.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/views/AddFunds.tsx @@ -1,6 +1,8 @@ import { Body, ButtCon, + Button, + FramedIcon, FramedImage, HeroFormControl, HeroTextInput, @@ -12,9 +14,11 @@ import debounce from 'lodash.debounce'; import { ChainId, type Checkout, + EIP6963ProviderInfo, IMTBLWidgetEvents, TokenFilterTypes, type TokenInfo, + WalletProviderRdns, } from '@imtbl/checkout-sdk'; import { type ChangeEvent, @@ -24,6 +28,7 @@ import { useMemo, useState, } from 'react'; +import { Web3Provider } from '@ethersproject/providers'; import { SimpleLayout } from '../../../components/SimpleLayout/SimpleLayout'; import { EventTargetContext } from '../../../context/event-target-context/EventTargetContext'; import { @@ -42,11 +47,20 @@ import { useRoutes } from '../hooks/useRoutes'; import { SQUID_NATIVE_TOKEN } from '../utils/config'; import { AddFundsWidgetViews } from '../../../context/view-context/AddFundsViewContextTypes'; import type { RouteData } from '../types'; +import { SelectedRouteOption } from '../components/SelectedRouteOption'; +import { SelectedWallet } from '../components/SelectedWallet'; +import { DeliverToWalletDrawer } from '../../../components/WalletDrawer/DeliverToWalletDrawer'; +import { PayWithWalletDrawer } from '../../../components/WalletDrawer/PayWithWalletDrawer'; +import { useInjectedProviders } from '../../../lib/hooks/useInjectedProviders'; +import { getProviderSlugFromRdns } from '../../../lib/provider'; +import { useProvidersContext } from '../../../context/providers-context/ProvidersContext'; +import { sendConnectProviderSuccessEvent } from '../AddFundsWidgetEvents'; import { convertToUsd } from '../functions/convertToUsd'; import { validateToAmount } from '../functions/amountValidation'; +import { OnboardingDrawer } from '../components/OnboardingDrawer'; interface AddFundsProps { - checkout?: Checkout; + checkout: Checkout | null; showBackButton?: boolean; showOnrampOption?: boolean; showSwapOption?: boolean; @@ -70,9 +84,18 @@ export function AddFunds({ showBackButton, onBackButtonClick, }: AddFundsProps) { - const { routes, fetchRoutesWithRateLimit, resetRoutes } = useRoutes(); + const { fetchRoutesWithRateLimit, resetRoutes } = useRoutes(); const { - addFundsState: { squid, balances, tokens }, + addFundsState: { + squid, + chains, + balances, + tokens, + selectedAmount, + routes, + selectedRouteData, + selectedToken, + }, addFundsDispatch, } = useContext(AddFundsContext); @@ -83,30 +106,92 @@ export function AddFunds({ } = useContext(EventTargetContext); const [showOptionsDrawer, setShowOptionsDrawer] = useState(false); + const [showPayWithDrawer, setShowPayWithDrawer] = useState(false); + const [showDeliverToDrawer, setShowDeliverToDrawer] = useState(false); const [onRampAllowedTokens, setOnRampAllowedTokens] = useState( [], ); const [allowedTokens, setAllowedTokens] = useState([]); - const [inputValue, setInputValue] = useState(toAmount || ''); - const [debouncedToAmount, setDebouncedToAmount] = useState(inputValue); - const [currentToTokenAddress, setCurrentToTokenAddress] = useState< - TokenInfo | undefined - >(); - const amountInUsd = useMemo( - () => convertToUsd(tokens, inputValue, currentToTokenAddress), - [tokens, inputValue, currentToTokenAddress], + const [inputValue, setInputValue] = useState( + selectedAmount || toAmount || '', + ); + const [fetchingRoutes, setFetchingRoutes] = useState(false); + const [insufficientBalance, setInsufficientBalance] = useState(false); + + const selectedAmountUsd = useMemo( + () => convertToUsd(tokens, inputValue, selectedToken), + [tokens, inputValue, selectedToken], ); - const debouncedUpdateAmount = debounce((value: string) => { - setDebouncedToAmount(value); + const setSelectedAmount = debounce((value: string) => { + addFundsDispatch({ + payload: { + type: AddFundsActions.SET_SELECTED_AMOUNT, + selectedAmount: value, + }, + }); }, 2500); - const updateAmount = (event: ChangeEvent) => { - const { value } = event.target; + const setSelectedToken = (token: TokenInfo | undefined) => { + addFundsDispatch({ + payload: { + type: AddFundsActions.SET_SELECTED_TOKEN, + selectedToken: token, + }, + }); + }; + + const setSelectedRouteData = (route: RouteData | undefined) => { + addFundsDispatch({ + payload: { + type: AddFundsActions.SET_SELECTED_ROUTE_DATA, + selectedRouteData: route, + }, + }); + }; + + const handleOnAmountInputChange = (event: ChangeEvent) => { + const { value, amount, isValid } = validateToAmount(event.target.value); + + if (!isValid && amount < 0) { + return; + } + setInputValue(value); - debouncedUpdateAmount(value); + setSelectedAmount(value); }; + const { + providersState: { + fromProviderInfo, + toProviderInfo, + fromAddress, + toAddress, + }, + } = useProvidersContext(); + + const { providers } = useInjectedProviders({ checkout }); + const walletOptions = useMemo( + () => providers + // TODO: Check if must filter passport on L1 + .map((detail) => { + if (detail.info.rdns === WalletProviderRdns.PASSPORT) { + return { + ...detail, + info: { + ...detail.info, + name: getProviderSlugFromRdns(detail.info.rdns).replace( + /^\w/, + (c) => c.toUpperCase(), + ), + }, + }; + } + return detail; + }), + [providers], + ); + const showErrorView = useCallback( (error: Error) => { viewDispatch({ @@ -122,31 +207,58 @@ export function AddFunds({ [viewDispatch], ); + useEffect(() => { + if (!toAmount) return; + setSelectedAmount(toAmount); + }, [toAmount]); + useEffect(() => { resetRoutes(); + setInsufficientBalance(false); + setSelectedRouteData(undefined); + }, [fromAddress]); - if ( - balances - && squid - && tokens - && currentToTokenAddress?.address - && debouncedToAmount - && validateToAmount(debouncedToAmount) - ) { - fetchRoutesWithRateLimit( - squid, - tokens, - balances, - ChainId.IMTBL_ZKEVM_MAINNET.toString(), - currentToTokenAddress.address === 'native' - ? SQUID_NATIVE_TOKEN - : currentToTokenAddress.address, - debouncedToAmount, - 5, - 1000, - ); + useEffect(() => { + resetRoutes(); + setInsufficientBalance(false); + setSelectedRouteData(undefined); + + (async () => { + const isValidAmount = validateToAmount(selectedAmount).isValid; + if ( + balances + && squid + && tokens + && selectedToken?.address + && isValidAmount + ) { + setFetchingRoutes(true); + const availableRoutes = await fetchRoutesWithRateLimit( + squid, + tokens, + balances, + ChainId.IMTBL_ZKEVM_MAINNET.toString(), + selectedToken.address === 'native' + ? SQUID_NATIVE_TOKEN + : selectedToken.address, + selectedAmount, + 5, + 1000, + ); + setFetchingRoutes(false); + + if (availableRoutes.length === 0) { + setInsufficientBalance(true); + } + } + })(); + }, [balances, squid, selectedToken, selectedAmount]); + + useEffect(() => { + if (!selectedRouteData && routes.length > 0) { + setSelectedRouteData(routes[0]); } - }, [balances, squid, currentToTokenAddress, debouncedToAmount]); + }, [routes]); useEffect(() => { if (!checkout) { @@ -170,7 +282,7 @@ export function AddFunds({ ); if (token) { - setCurrentToTokenAddress(token); + setSelectedToken(token); } } @@ -212,29 +324,20 @@ export function AddFunds({ fetchOnRampTokens(); }, [checkout]); - const openDrawer = () => { - setShowOptionsDrawer(true); - }; - const isSelected = useCallback( - (token: TokenInfo) => token.address === currentToTokenAddress, - [currentToTokenAddress], + (token: TokenInfo) => token.address === selectedToken, + [selectedToken], ); const handleTokenChange = useCallback((token: TokenInfo) => { - setCurrentToTokenAddress(token); + setSelectedToken(token); }, []); - // @TODO: restore this when we bring back all the templating below - // const handleReviewClick = useCallback(() => { - // // eslint-disable-next-line no-console - // console.log('handle review click'); - // }, []); - - const onCardClick = () => { + const handleCardClick = () => { const data = { - tokenAddress: currentToTokenAddress?.address ?? '', - amount: debouncedToAmount ?? '', + tokenAddress: selectedToken?.address ?? '', + amount: selectedAmount ?? '', + showBackButton: true, }; orchestrationEvents.sendRequestOnrampEvent( eventTarget, @@ -243,10 +346,15 @@ export function AddFunds({ ); }; - const onRouteClick = (routeData: RouteData) => { - if (!debouncedToAmount || !currentToTokenAddress?.address) { - return; - } + const handleRouteClick = (route: RouteData) => { + setShowOptionsDrawer(false); + setShowPayWithDrawer(false); + setShowDeliverToDrawer(false); + setSelectedRouteData(route); + }; + + const handleReviewClick = () => { + if (!selectedRouteData || !selectedToken?.address) return; viewDispatch({ payload: { @@ -254,10 +362,10 @@ export function AddFunds({ view: { type: AddFundsWidgetViews.REVIEW, data: { - balance: routeData.amountData.balance, + balance: selectedRouteData.amountData.balance, toChainId: ChainId.IMTBL_ZKEVM_MAINNET.toString(), - toTokenAddress: currentToTokenAddress.address, - toAmount: debouncedToAmount, + toTokenAddress: selectedToken.address, + toAmount: selectedAmount, }, }, }, @@ -265,18 +373,16 @@ export function AddFunds({ }; const shouldShowOnRampOption = useMemo(() => { - if (showOnrampOption && currentToTokenAddress) { + if (showOnrampOption && selectedToken) { const token = onRampAllowedTokens.find( - (t) => t.address?.toLowerCase() - === currentToTokenAddress.address?.toLowerCase(), + (t) => t.address?.toLowerCase() === selectedToken.address?.toLowerCase(), ); return !!token; } return false; - }, [currentToTokenAddress, onRampAllowedTokens, showOnrampOption]); - - const showInitialEmptyState = !currentToTokenAddress; + }, [selectedToken, onRampAllowedTokens, showOnrampOption]); + const showInitialEmptyState = !selectedToken; const defaultTokenImage = getDefaultTokenImage( checkout?.config.environment, config.theme, @@ -312,16 +418,36 @@ export function AddFunds({ ); const shouldShowBackButton = showBackButton ?? !!onBackButtonClick; + const routeInputsReady = !!selectedToken + && !!fromAddress + && validateToAmount(selectedAmount).isValid; + const loading = (routeInputsReady || fetchingRoutes) + && !(selectedRouteData || insufficientBalance); + const readyToReview = routeInputsReady && !!toAddress && !!selectedRouteData && !loading; + + const handleWalletConnected = ( + providerType: 'from' | 'to', + provider: Web3Provider, + providerInfo: EIP6963ProviderInfo, + ) => { + sendConnectProviderSuccessEvent( + eventTarget, + providerType, + provider, + providerInfo, + ); + }; return ( @@ -365,8 +496,8 @@ export function AddFunds({ size="xLarge" use={( )} @@ -375,6 +506,7 @@ export function AddFunds({ circularFrame sx={{ cursor: 'pointer', + mb: 'base.spacing.x1', // eslint-disable-next-line @typescript-eslint/naming-convention '&:hover': { boxShadow: ({ base }) => `0 0 0 ${base.border.size[200]} ${base.color.text.body.primary}`, @@ -390,24 +522,26 @@ export function AddFunds({ {showInitialEmptyState ? ( Add Token ) : ( - + Add {' '} - {currentToTokenAddress.symbol} + {selectedToken.symbol} updateAmount(value)} + onChange={(value) => handleOnAmountInputChange(value)} placeholder="0" maxTextSize="xLarge" /> - {amountInUsd > 0 && ( + {selectedAmountUsd > 0 && ( USD $ - {amountInUsd.toFixed(2)} + {selectedAmountUsd.toFixed(2)} )} @@ -424,18 +558,34 @@ export function AddFunds({ gap="base.spacing.x6" > - - { + event.stopPropagation(); + setShowPayWithDrawer(true); + }} + > + - - {/* Pay with */} - Choose payment option - - - {/* @TODO: commented out for now, till these features are ready to go + setShowOptionsDrawer(true)} + withSelectedToken={!!selectedToken} + withSelectedAmount={parseFloat(inputValue) > 0} + withSelectedWallet={!!fromAddress} + insufficientBalance={insufficientBalance} + showOnrampOption={shouldShowOnRampOption} + /> + `0 -${base.spacing.x3}`, + translate: '0 -30%', + bg: 'base.color.neutral.800', }} /> - */} - {/* @TODO: commented out for now, till these features are ready to go - { - // // eslint-disable-next-line no-console - // console.log('@TODO - need to hook this up!'); - // }} - > - - Deliver to - */} + + setShowDeliverToDrawer(true)} + /> - {/* - @TODO: commented out for now, till these features are ready to go */} - + + setShowPayWithDrawer(false)} + onPayWithCard={handleCardClick} + onConnect={handleWalletConnected} + insufficientBalance={insufficientBalance} + showOnRampOption={shouldShowOnRampOption} + /> setShowOptionsDrawer(false)} - onCardClick={onCardClick} - onRouteClick={onRouteClick} + onCardClick={handleCardClick} + onRouteClick={handleRouteClick} + insufficientBalance={insufficientBalance} + /> + setShowDeliverToDrawer(false)} + onConnect={handleWalletConnected} /> + diff --git a/packages/checkout/widgets-lib/src/widgets/add-funds/views/Review.tsx b/packages/checkout/widgets-lib/src/widgets/add-funds/views/Review.tsx index 7bd46ed110..61199ceedd 100644 --- a/packages/checkout/widgets-lib/src/widgets/add-funds/views/Review.tsx +++ b/packages/checkout/widgets-lib/src/widgets/add-funds/views/Review.tsx @@ -1,12 +1,17 @@ import { - ReactNode, useContext, useEffect, useState, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, } from 'react'; import { Body, + ButtCon, Button, - centerFlexChildren, - Divider, EllipsizedText, + FramedIcon, FramedImage, Heading, hFlex, @@ -14,23 +19,38 @@ import { Link, PriceDisplay, Stack, + Sticker, + useInterval, } from '@biom3/react'; import { RouteResponse } from '@0xsquid/squid-types'; -import { BigNumber, utils } from 'ethers'; +import { t } from 'i18next'; import { SimpleLayout } from '../../../components/SimpleLayout/SimpleLayout'; import { AddFundsContext } from '../context/AddFundsContext'; import { useRoutes } from '../hooks/useRoutes'; import { AddFundsReviewData } from '../../../context/view-context/AddFundsViewContextTypes'; -import { HeaderNavigation } from '../../../components/Header/HeaderNavigation'; -import { Chain, RiveStateMachineInput } from '../types'; +import { RiveStateMachineInput } from '../types'; import { useExecute } from '../hooks/useExecute'; -import { SharedViews, ViewActions, ViewContext } from '../../../context/view-context/ViewContext'; +import { + SharedViews, + ViewActions, + ViewContext, +} from '../../../context/view-context/ViewContext'; import { SquidIcon } from '../components/SquidIcon'; import { useHandover } from '../../../lib/hooks/useHandover'; import { HandoverTarget } from '../../../context/handover-context/HandoverContext'; import { HandoverContent } from '../../../components/Handover/HandoverContent'; import { getRemoteRive } from '../../../lib/utils'; import { SQUID_NATIVE_TOKEN } from '../utils/config'; +import { useProvidersContext } from '../../../context/providers-context/ProvidersContext'; +import { LoadingView } from '../../../views/loading/LoadingView'; +import { getDurationFormatted } from '../functions/getDurationFormatted'; +import { RouteFees } from '../components/RouteFees'; +import { getTotalRouteFees } from '../functions/getTotalRouteFees'; +import { getRouteChains } from '../functions/getRouteChains'; +import { + getFormattedNumber, + getFormattedAmounts, +} from '../functions/getFormattedNumber'; interface ReviewProps { data: AddFundsReviewData; @@ -40,26 +60,37 @@ interface ReviewProps { } const FIXED_HANDOVER_DURATION = 2000; - const APPROVE_TXN_ANIMATION = '/access_coins.riv'; const EXECUTE_TXN_ANIMATION = '/swapping_coins.riv'; +const dividerSx = { + content: "''", + pos: 'absolute', + left: 'base.spacing.x6', + w: '1px', + bg: 'base.color.translucent.standard.500', +}; + export function Review({ data, showBackButton = false, - onBackButtonClick, onCloseButtonClick, + onBackButtonClick, + onCloseButtonClick, }: ReviewProps) { const { viewDispatch } = useContext(ViewContext); const { - addFundsState: { - squid, provider, chains, checkout, tokens, - }, + addFundsState: { squid, chains, tokens }, } = useContext(AddFundsContext); + const { + providersState: { + checkout, fromProvider, fromAddress, toAddress, + }, + } = useProvidersContext(); + const [route, setRoute] = useState(); - const [fromAddress, setFromAddress] = useState(); - const [getRouteIntervalId, setGetRouteIntervalId] = useState(); const [proceedDisabled, setProceedDisabled] = useState(true); + const [showFeeBreakdown, setShowFeeBreakdown] = useState(false); const { getAmountData, getRoute } = useRoutes(); const { addHandover, closeHandover } = useHandover({ @@ -67,23 +98,26 @@ export function Review({ }); const { - convertToNetworkChangeableProvider, checkProviderChain, getAllowance, approve, execute, + convertToNetworkChangeableProvider, + checkProviderChain, + getAllowance, + approve, + execute, } = useExecute(); const getFromAmountAndRoute = async () => { if (!squid || !tokens) return; - const address = await provider?.getSigner().getAddress(); - - if (!address) return; - setFromAddress(address); + if (!fromAddress || !toAddress) return; const amountData = getAmountData( tokens, data.balance, data.toAmount, data.toChainId, - data.toTokenAddress === 'native' ? SQUID_NATIVE_TOKEN : data.toTokenAddress, + data.toTokenAddress === 'native' + ? SQUID_NATIVE_TOKEN + : data.toTokenAddress, ); if (!amountData) return; @@ -92,86 +126,114 @@ export function Review({ squid, amountData?.fromToken, amountData?.toToken, - address, + toAddress, amountData.fromAmount, - address, + fromAddress, false, ); setRoute(routeResponse); setProceedDisabled(false); }; + const { fromChain, toChain } = useMemo( + () => getRouteChains(chains, route), + [chains, route], + ); + + const getRouteIntervalIdRef = useInterval(getFromAmountAndRoute, 20000); useEffect(() => { - (async () => { - await getFromAmountAndRoute(); - const setIntervalId = setInterval(getFromAmountAndRoute, 20000); - setGetRouteIntervalId(setIntervalId); - })(); - return () => { - if (getRouteIntervalId) { - clearInterval(getRouteIntervalId); - } - }; + getFromAmountAndRoute(); }, []); - const getChain = (chainId: string | undefined) - : Chain | undefined => chains?.find((chain) => chain.id === chainId); - - const getFeeCosts = (): number => route?.route.estimate.feeCosts.reduce((acc, fee) => acc + Number(fee.amountUsd), 0) - ?? 0; - - const getAmountInUSDText = (amount: string | undefined): string => (amount ? `USD $${amount}` : ''); - - const getAmountFormatted = (amount: string | undefined, decimals: number): string => { - if (!amount) { - return '0'; + const { totalFees, totalFeesUsd } = useMemo( + () => getTotalRouteFees(route), + [route], + ); + const routeFees = useMemo(() => { + if (totalFeesUsd) { + return ( + setShowFeeBreakdown(true)} + size="small" + sx={{ + ...hFlex, + alignItems: 'center', + c: 'base.color.text.body.secondary', + cursor: 'pointer', + }} + > + Included fees + {` USD $${getFormattedAmounts(totalFeesUsd)}`} + + + ); } - return utils.formatUnits(BigNumber.from(amount), decimals); - }; - - const getGasCostText = (): string => { - if (!route?.route.estimate.gasCosts || route?.route.estimate.gasCosts.length === 0) { - return ''; - } - const totalGasFee = route?.route.estimate.gasCosts.reduce( - (acc, gas) => acc.add(BigNumber.from(gas.amount)), - BigNumber.from(0), + return ( + + {t('Zero fees')} + ); + }, [totalFeesUsd]); + + const showHandover = useCallback( + ( + animationPath: string, + state: RiveStateMachineInput, + headingText: string, + subheadingText?: ReactNode, + duration?: number, + ) => { + addHandover({ + animationUrl: getRemoteRive( + checkout?.config.environment, + animationPath, + ), + inputValue: state, + duration, + children: ( + + ), + }); + }, + [addHandover, checkout], + ); - const formattedTotalGasFee = utils.formatUnits(totalGasFee, route?.route.estimate.gasCosts[0].token.decimals); - - return `Gas Refuel +${route.route.estimate.gasCosts[0].token.name} ${formattedTotalGasFee}`; - }; - - const showHandover = ( - animationPath: string, - state: RiveStateMachineInput, - headingText: string, - subheadingText?: ReactNode, - duration?: number, - ) => { - addHandover({ - animationUrl: getRemoteRive(checkout?.config.environment, animationPath), - inputValue: state, - duration, - children: , - }); - }; - - const handleTransaction = async () => { - if (!squid || !provider || !route) { + const handleTransaction = useCallback(async () => { + if (!squid || !fromProvider || !route) { return; } try { - clearInterval(getRouteIntervalId); + clearInterval(getRouteIntervalIdRef.current); setProceedDisabled(true); - showHandover(APPROVE_TXN_ANIMATION, RiveStateMachineInput.START, 'Preparing'); + showHandover( + APPROVE_TXN_ANIMATION, + RiveStateMachineInput.START, + 'Preparing', + ); - const changeableProvider = await convertToNetworkChangeableProvider(provider); - await checkProviderChain(changeableProvider, route.route.params.fromChain); + const changeableProvider = await convertToNetworkChangeableProvider( + fromProvider, + ); + await checkProviderChain( + changeableProvider, + route.route.params.fromChain, + ); const allowance = await getAllowance(changeableProvider, route); const { fromAmount } = route.route.params; @@ -204,31 +266,36 @@ export function Review({ const txReceipt = await execute(squid, changeableProvider, route); - showHandover(EXECUTE_TXN_ANIMATION, RiveStateMachineInput.PROCESSING, 'Processing', '', FIXED_HANDOVER_DURATION); + showHandover( + EXECUTE_TXN_ANIMATION, + RiveStateMachineInput.PROCESSING, + 'Processing', + '', + FIXED_HANDOVER_DURATION, + ); showHandover( EXECUTE_TXN_ANIMATION, RiveStateMachineInput.COMPLETED, - 'Funds added successfully', ( - <> - Go to - {' '} - + 'Funds added successfully', + <> + Go to + {' '} + )} - > - Axelarscan - - {' '} - for transaction details - - ), + > + Axelarscan + + {' '} + for transaction details + , ); } catch (e) { closeHandover(); @@ -243,221 +310,343 @@ export function Review({ }, }); } - }; - - const onProceedClick = () => handleTransaction(); + }, [ + route, + squid, + fromProvider, + getRouteIntervalIdRef, + approve, + showHandover, + checkProviderChain, + convertToNetworkChangeableProvider, + getAllowance, + execute, + closeHandover, + viewDispatch, + ]); + + const formattedDuration = route + ? getDurationFormatted(route.route.estimate.estimatedRouteDuration) + : ''; return ( + } + direction="row" + sx={{ + pt: 'base.spacing.x4', + px: 'base.spacing.x5', + h: 'base.spacing.x18', + w: '100%', + }} + justifyContent="flex-start" + > + {showBackButton && ( + + )} + + )} > - {!route && Loading...} - {route && ( - - } justifyContent="center"> + + {!!route && ( + <> Review - - - - } direction="row" gap="base.spacing.x2"> - - } - /> - - - - Pay with - {' '} - {route?.route.estimate.fromToken.name} - + + - + + )} + circularFrame + size="large" + /> + } + emphasized + sx={{ + bottom: 'base.spacing.x2', + right: 'base.spacing.x2', + }} + /> + + + + Send + {' '} + {route.route.estimate.fromToken.name} + + + {fromChain?.name} + + + + - - {getChain(route?.route.estimate.fromToken.chainId)?.name} - + sx={{ flexShrink: 0, alignSelf: 'flex-start' }} + > + + {`USD $${route?.route.estimate.fromAmountUSD ?? ''}`} + + + {/* + */} - - - - - + sx={{ + pos: 'relative', + w: 'base.spacing.x16', + ml: 'base.spacing.x7', + + // eslint-disable-next-line @typescript-eslint/naming-convention + '&::before': { + ...dividerSx, + top: '-14px', + h: 'base.spacing.x10', + }, + }} + /> + {/* + */} - - )} - /> - - Includes Fees USD $ - {getFeeCosts()} - + + } size="medium" padded /> + + + + Swap + {' '} + {route.route.estimate.fromToken.name} + {' '} + to + {' '} + {route.route.estimate.toToken.name} + + + Powered by Squid +
+ 1 + {route.route.estimate.fromToken.name} + {' '} + = + {' '} + {route.route.estimate.exchangeRate} + {' '} + {route.route.estimate.toToken.name} + +
-
- - - - {getAmountInUSDText(route?.route.estimate.fromAmountUSD)} - - - -
- - + {/* - } direction="row" gap="base.spacing.x2"> - - } + */} + - - - - Deliver - {' '} - {route?.route.estimate.toToken.name} - + {/* + + */} - + + )} + circularFrame + size="large" + /> + } + emphasized + sx={{ + bottom: 'base.spacing.x2', + right: 'base.spacing.x2', + }} + /> + + + + Receive + {' '} + {route?.route.estimate.toToken.name} + + + {toChain?.name} + + + + - - {getChain(route?.route.estimate.toToken.chainId)?.name} - + sx={{ flexShrink: 0, alignSelf: 'flex-start' }} + > + + {`USD $${route?.route.estimate.toAmountUSD ?? ''}`} + + + {/* + */} - - - - - + sx={{ + pos: 'relative', + w: 'base.spacing.x16', + ml: 'base.spacing.x7', + + // eslint-disable-next-line @typescript-eslint/naming-convention + '&::before': { + ...dividerSx, + top: '-8px', + h: 'base.spacing.x5', + }, + }} + /> + {/* + + */} - - - {getGasCostText()} + + + + + {formattedDuration} + {routeFees} - - - - {getAmountInUSDText(route?.route.estimate.toAmountUSD)} - - - - - - - - Powered by Squid - - - - - )} + + + )} + + {!route && } +
+ setShowFeeBreakdown(false)} + totalAmount={totalFees} + totalFiatAmount={totalFeesUsd} + />
); } diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx index 7889184ffd..fc3c93b024 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx @@ -1,4 +1,6 @@ -import { Suspense, useEffect, useMemo } from 'react'; +import { + Suspense, useCallback, useEffect, useMemo, +} from 'react'; import { CheckoutEventType, CheckoutWidgetParams, @@ -28,10 +30,14 @@ import OnRampWidget from '../on-ramp/OnRampWidget'; import WalletWidget from '../wallet/WalletWidget'; import SaleWidget from '../sale/SaleWidget'; import AddFundsWidget from '../add-funds/AddFundsWidget'; -import { getViewShouldConnect } from './functions/getViewShouldConnect'; +import { + isConnectLoaderFlow, + isProvidersContextFlow, +} from './functions/getFlowRequiresContext'; import { useWidgetEvents } from './hooks/useWidgetEvents'; import { getConnectLoaderParams } from './functions/getConnectLoaderParams'; import { checkoutFlows } from './functions/isValidCheckoutFlow'; +import { ProvidersContextProvider } from '../../context/providers-context/ProvidersContext'; export type CheckoutWidgetInputs = { checkout: Checkout; @@ -48,7 +54,7 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { const { t } = useTranslation(); const viewState = useViewState(); - const [{ view }, viewDispatch] = viewState; + const [{ view, history }, viewDispatch] = viewState; const [{ eventTarget }] = useEventTargetState(); const connectLoaderParams = useMemo( @@ -56,6 +62,30 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { [view, checkout, web3Provider], ); + const goToPreviousView = useCallback(() => { + const sharedViews = [ + SharedViews.LOADING_VIEW, + SharedViews.ERROR_VIEW, + SharedViews.SUCCESS_VIEW, + SharedViews.TOP_UP_VIEW, + SharedViews.SERVICE_UNAVAILABLE_ERROR_VIEW, + ] as string[]; + + const views = history + .slice(0, -1) + .filter(({ type }) => !sharedViews.includes(type)); + const lastView = views[views.length - 1]; + + if (lastView) { + viewDispatch({ + payload: { + type: ViewActions.UPDATE_VIEW, + view: lastView, + }, + }); + } + }, [history]); + /** * Subscribe and Handle widget events */ @@ -106,8 +136,16 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { /** * Validate if the view requires connect loader */ - const shouldConnectView = useMemo( - () => getViewShouldConnect(view.type), + const shouldWrapWithConnectLoader = useMemo( + () => isConnectLoaderFlow(view.type), + [view.type], + ); + + /** + * Validate if the view requires providers context + */ + const shouldWrapWithProvidersContext = useMemo( + () => isProvidersContextFlow(view.type), [view.type], ); @@ -137,7 +175,7 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { actionText={t('views.ERROR_VIEW.actionText')} /> )} - {/* --- Widgets without connect --- */} + {/* --- Widgets without connect loader or providers context --- */} {view.type === CheckoutFlowType.CONNECT && ( )} - {/* --- Widgets that require connect --- */} - {shouldConnectView && ( + {/* --- Widgets that require providers context --- */} + {shouldWrapWithProvidersContext && ( + + {view.type === CheckoutFlowType.ADD_FUNDS && ( + + )} + + )} + {/* --- Widgets that require connect loader --- */} + {shouldWrapWithConnectLoader && ( )} - {view.type === CheckoutFlowType.ADD_FUNDS && ( - - )} {view.type === CheckoutFlowType.SWAP && ( void; + showBackButton?: boolean; }; export default function ConnectWidget({ @@ -79,6 +80,8 @@ export default function ConnectWidget({ blocklistWalletRdns, deepLink = ConnectWidgetViews.CONNECT_WALLET, isCheckNetworkEnabled, + sendGoBackEventOverride, + showBackButton, }: ConnectWidgetInputs) { const { t } = useTranslation(); const { environment } = config; @@ -224,6 +227,8 @@ export default function ConnectWidget({ allowedChains={allowedChains ?? [targetChain]} blocklistWalletRdns={blocklistWalletRdns} checkNetwork={isCheckNetworkEnabled ?? true} + showBackButton={showBackButton} + onBackButtonClick={sendGoBackEventOverride} /> )} {view.type === ConnectWidgetViews.SWITCH_NETWORK && isZkEvmChainId(targetChain) && ( diff --git a/packages/checkout/widgets-lib/src/widgets/connect/components/NonPassportWarningDrawer.tsx b/packages/checkout/widgets-lib/src/widgets/connect/components/NonPassportWarningDrawer.tsx index d0e317aa90..3b32e85513 100644 --- a/packages/checkout/widgets-lib/src/widgets/connect/components/NonPassportWarningDrawer.tsx +++ b/packages/checkout/widgets-lib/src/widgets/connect/components/NonPassportWarningDrawer.tsx @@ -26,6 +26,7 @@ export function NonPassportWarningDrawer({ void; } export function ConnectWallet({ @@ -23,6 +25,8 @@ export function ConnectWallet({ allowedChains, blocklistWalletRdns, checkNetwork, + showBackButton, + onBackButtonClick, }: ConnectWalletProps) { const { t } = useTranslation(); const { @@ -44,6 +48,8 @@ export function ConnectWallet({ header={( )} footer={} diff --git a/packages/checkout/widgets-lib/src/widgets/on-ramp/OnRampWidgetRoot.tsx b/packages/checkout/widgets-lib/src/widgets/on-ramp/OnRampWidgetRoot.tsx index f55bcf375c..367937bc84 100644 --- a/packages/checkout/widgets-lib/src/widgets/on-ramp/OnRampWidgetRoot.tsx +++ b/packages/checkout/widgets-lib/src/widgets/on-ramp/OnRampWidgetRoot.tsx @@ -18,6 +18,7 @@ import { LoadingView } from '../../views/loading/LoadingView'; import { HandoverProvider } from '../../context/handover-context/HandoverProvider'; import { sendOnRampWidgetCloseEvent } from './OnRampWidgetEvents'; import i18n from '../../i18n'; +import { orchestrationEvents } from '../../lib/orchestrationEvents'; const OnRampWidget = React.lazy(() => import('./OnRampWidget')); @@ -41,7 +42,7 @@ export class OnRamp extends Base { } protected getValidatedParameters(params: OnRampWidgetParams): OnRampWidgetParams { - const validatedParams = params; + const validatedParams = { ...params }; if (!isValidAmount(params.amount)) { // eslint-disable-next-line no-console @@ -55,9 +56,21 @@ export class OnRamp extends Base { validatedParams.tokenAddress = ''; } + if (params.showBackButton) { + validatedParams.showBackButton = true; + } + return validatedParams; } + private goBackEvent = (eventTarget: Window | EventTarget) => { + orchestrationEvents.sendRequestGoBackEvent( + eventTarget, + IMTBLWidgetEvents.IMTBL_ONRAMP_WIDGET_EVENT, + {}, + ); + }; + protected render() { if (!this.reactRoot) return; @@ -81,6 +94,8 @@ export class OnRamp extends Base { widgetConfig={this.strongConfig()} params={connectLoaderParams} closeEvent={() => sendOnRampWidgetCloseEvent(window)} + goBackEvent={() => this.goBackEvent(window)} + showBackButton={this.parameters.showBackButton} > }> { - console.log("CLOSE_WIDGET", data); - backToGame(); - addFunds.unmount(); - }); - addFunds.addListener(AddFundsEventType.REQUEST_ONRAMP, (data: any) => { - console.log("REQUEST_ONRAMP", data); - addFunds.unmount(); - onRamp?.addListener(OnRampEventType.CLOSE_WIDGET, (data: any) => { - console.log("CLOSE_WIDGET", data); - onRamp?.unmount(); - }); - onRamp?.mount(ADD_FUNDS_TARGET_ID, {}); - }); - addFunds.addListener(AddFundsEventType.REQUEST_SWAP, (data: any) => { - console.log("REQUEST_SWAP", data); - addFunds.unmount(); - swap?.addListener(SwapEventType.CLOSE_WIDGET, (data: any) => { - console.log("CLOSE_WIDGET", data); - swap.unmount(); - }); - swap?.mount(ADD_FUNDS_TARGET_ID, {}); - }); - addFunds.addListener(AddFundsEventType.REQUEST_BRIDGE, (data: any) => { - console.log("REQUEST_BRIDGE", data); - addFunds.unmount(); - bridge?.addListener(BridgeEventType.CLOSE_WIDGET, (data: any) => { - console.log("CLOSE_WIDGET", data); - bridge.unmount(); - }); - bridge?.mount(ADD_FUNDS_TARGET_ID, {}); - }); + // addFunds.addListener(AddFundsEventType.CLOSE_WIDGET, (data: any) => { + // console.log("CLOSE_WIDGET", data); + // backToGame(); + // addFunds.unmount(); + // }); + // addFunds.addListener(AddFundsEventType.REQUEST_ONRAMP, (data: any) => { + // console.log("REQUEST_ONRAMP", data); + // addFunds.unmount(); + // onRamp?.addListener(OnRampEventType.CLOSE_WIDGET, (data: any) => { + // console.log("CLOSE_WIDGET", data); + // onRamp?.unmount(); + // }); + // onRamp?.mount(ADD_FUNDS_TARGET_ID, {}); + // }); + // addFunds.addListener(AddFundsEventType.REQUEST_SWAP, (data: any) => { + // console.log("REQUEST_SWAP", data); + // addFunds.unmount(); + // swap?.addListener(SwapEventType.CLOSE_WIDGET, (data: any) => { + // console.log("CLOSE_WIDGET", data); + // swap.unmount(); + // }); + // swap?.mount(ADD_FUNDS_TARGET_ID, {}); + // }); + // addFunds.addListener(AddFundsEventType.REQUEST_BRIDGE, (data: any) => { + // console.log("REQUEST_BRIDGE", data); + // addFunds.unmount(); + // bridge?.addListener(BridgeEventType.CLOSE_WIDGET, (data: any) => { + // console.log("CLOSE_WIDGET", data); + // bridge.unmount(); + // }); + // bridge?.mount(ADD_FUNDS_TARGET_ID, {}); + // }); }, [addFunds]); return ( diff --git a/packages/checkout/widgets-sample-app/src/components/ui/add-funds/addFunds.tsx b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/addFunds.tsx index 9be35e3fa3..e62057b44d 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/add-funds/addFunds.tsx +++ b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/addFunds.tsx @@ -5,22 +5,49 @@ import { WidgetLanguage, AddFundsEventType, OnRampEventType, - SwapEventType, - BridgeEventType, - SwapDirection, + OrchestrationEventType, } from "@imtbl/checkout-sdk"; import { WidgetsFactory } from "@imtbl/checkout-widgets"; +import { Environment } from "@imtbl/config"; import { useMemo, useEffect } from "react"; +import { passport } from "./passport"; + const ADD_FUNDS_TARGET_ID = "add-funds-widget-target"; function AddFundsUI() { - const checkout = useMemo(() => new Checkout(), []); - const factory = useMemo(() => new WidgetsFactory(checkout, {}), [checkout]); + const checkout = useMemo( + () => + new Checkout({ + baseConfig: { + environment: Environment.PRODUCTION, + }, + passport, + }), + [] + ); + const factory = useMemo( + () => + new WidgetsFactory(checkout, { + walletConnect: { + projectId: "938b553484e344b1e0b4bb80edf8c362", + metadata: { + name: "Checkout Marketplace", + description: "Checkout Marketplace", + url: "http://localhost:3000/marketplace-orchestrator", + icons: [], + }, + }, + }), + [checkout] + ); + const addFunds = useMemo( () => factory.create(WidgetType.ADD_FUNDS, { - config: { theme: WidgetTheme.DARK }, + config: { + theme: WidgetTheme.DARK, + }, }), [factory] ); @@ -29,17 +56,53 @@ function AddFundsUI() { const bridge = useMemo(() => factory.create(WidgetType.BRIDGE), [factory]); useEffect(() => { + passport.connectEvm(); + }, []); + + // useEffect(() => { + // if (!checkout || !factory) return; + + // (async () => { + // const { provider } = await checkout.createProvider({ + // walletProviderName: WalletProviderName.METAMASK, + // }); + + // await checkout.connect({ provider, requestWalletPermissions: false }); + + // const { isConnected } = await checkout.checkIsWalletConnected({ + // provider, + // }); + + // if (isConnected) { + // factory.updateProvider(provider); + // } + // })(); + // }, [checkout, factory]); + + const goBack = () => { + addFunds.unmount(); addFunds.mount(ADD_FUNDS_TARGET_ID, { showOnrampOption: true, + showSwapOption: false, showBridgeOption: false, - showSwapOption: true, - toTokenAddress: "0x3b2d8a1931736fc321c24864bceee981b11c3c57", + // toAmount: "1", + // toTokenAddress: "native", + }); + }; + + useEffect(() => { + addFunds.mount(ADD_FUNDS_TARGET_ID, { + showOnrampOption: true, + showSwapOption: false, + showBridgeOption: false, + // toAmount: "1", + // toTokenAddress: "native", }); addFunds.addListener(AddFundsEventType.CLOSE_WIDGET, (data: any) => { console.log("CLOSE_WIDGET", data); addFunds.unmount(); }); - addFunds.addListener(AddFundsEventType.REQUEST_ONRAMP, (data: any) => { + addFunds.addListener(OrchestrationEventType.REQUEST_ONRAMP, (data: any) => { console.log("REQUEST_ONRAMP", data); addFunds.unmount(); onRamp.addListener(OnRampEventType.CLOSE_WIDGET, (data: any) => { @@ -47,31 +110,15 @@ function AddFundsUI() { onRamp.unmount(); }); onRamp.mount(ADD_FUNDS_TARGET_ID, { - amount: data.amount, - tokenAddress: data.tokenAddress, + ...data, + showBackButton: true, }); }); - addFunds.addListener(AddFundsEventType.REQUEST_SWAP, (data: any) => { - console.log("REQUEST_SWAP", data); - addFunds.unmount(); - swap.addListener(SwapEventType.CLOSE_WIDGET, (data: any) => { - console.log("CLOSE_WIDGET", data); - swap.unmount(); - }); - swap.mount(ADD_FUNDS_TARGET_ID, { - amount: data.amount, - toTokenAddress: data.toTokenAddress, - direction: SwapDirection.TO, - }); + addFunds.addListener(AddFundsEventType.CONNECT_SUCCESS, (data: any) => { + console.log("CONNECT_SUCCESS", data); }); - addFunds.addListener(AddFundsEventType.REQUEST_BRIDGE, (data: any) => { - console.log("REQUEST_BRIDGE", data); - addFunds.unmount(); - bridge.addListener(BridgeEventType.CLOSE_WIDGET, (data: any) => { - console.log("CLOSE_WIDGET", data); - bridge.unmount(); - }); - bridge.mount(ADD_FUNDS_TARGET_ID, {}); + onRamp.addListener(OrchestrationEventType.REQUEST_GO_BACK, () => { + goBack(); }); }, [addFunds]); diff --git a/packages/checkout/widgets-sample-app/src/components/ui/add-funds/login.tsx b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/login.tsx new file mode 100644 index 0000000000..33c112cdaf --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/login.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { passport } from "./passport"; + + +export function AddFundsPassportLogin() { + useEffect(() => { + passport.loginCallback(); + }, []); + + return null; +} diff --git a/packages/checkout/widgets-sample-app/src/components/ui/add-funds/logout.tsx b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/logout.tsx new file mode 100644 index 0000000000..96631c486f --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/logout.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { passport } from "./passport"; + + +export function AddFundsPassportLogout() { + useEffect(() => { + passport.logoutSilentCallback('http://localhost:3000/add-funds'); + }, []); + + return null; +} diff --git a/packages/checkout/widgets-sample-app/src/components/ui/add-funds/passport.ts b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/passport.ts new file mode 100644 index 0000000000..e997e099f0 --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/add-funds/passport.ts @@ -0,0 +1,14 @@ +import { Passport } from "@imtbl/passport"; +import { Environment } from "@imtbl/config"; + +export const passport = new Passport({ + baseConfig: { + environment: Environment.SANDBOX, + }, + clientId: "UZhOvcNaFocj5SvqQHh4kvmJYv8fIIYz", + redirectUri: "http://localhost:3000/add-funds/login", + logoutRedirectUri: "http://localhost:3000/add-funds/logout", + logoutMode: "silent", + audience: "platform_api", + scope: "openid offline_access email transact", +}); diff --git a/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx b/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx index df9653eea1..6eb2025fac 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx +++ b/packages/checkout/widgets-sample-app/src/components/ui/checkout/checkout.tsx @@ -68,6 +68,7 @@ const getPassportClient = (environment: Environment) => clientId: "ViaYO6JWck4TZOiiojEak8mz6WvQh3wK", redirectUri: "http://localhost:3000/checkout?login=true", logoutRedirectUri: "http://localhost:3000/checkout?logout=true", + logoutMode: "silent", }); // create Checkout SDK @@ -89,6 +90,14 @@ const getCheckoutSdk = (passportClient: Passport, environment: Environment) => const usePassportLoginCallback = (passportClient: Passport) => { const params = new URLSearchParams(window.location.search); const loginParam = params.get("login"); + const logoutParam = params.get("logout"); + + useEffect(() => { + if (logoutParam === "true") { + passportClient?.logoutSilentCallback('http://localhost:3000/checkout'); + } + }, [logoutParam, passportClient]); + useEffect(() => { if (loginParam === "true") { @@ -194,7 +203,7 @@ function CheckoutUI() { }, ADD_FUNDS: { flow: CheckoutFlowType.ADD_FUNDS, - toAmount: "100", + toAmount: "1", toTokenAddress: "native", }, }); @@ -523,7 +532,7 @@ function CheckoutUI() {
- {params?.flow || ""} + {params?.flow || ""}
diff --git a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/passport.ts b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/passport.ts index a2233d82d3..33210961bd 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/passport.ts +++ b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/passport.ts @@ -2,14 +2,14 @@ import { Environment, ImmutableConfiguration } from "@imtbl/config"; import { Passport } from "@imtbl/passport"; const baseConfig = new ImmutableConfiguration({environment: Environment.SANDBOX}) -const passportConfig = { + +// create and export one instance of passport for marketplace-orchestrator +export const passport = new Passport({ baseConfig, clientId: 'FgazXVH4DAXm5tTTPqpyZa70vUaYhwja', logoutRedirectUri: 'http://localhost:3000/marketplace-orchestrator', redirectUri: 'http://localhost:3000/marketplace-orchestrator/login/callback', scope: 'openid offline_access email transact', - audience: 'platform_api' -} - -// create and export one instance of passport for marketplace-orchestrator -export const passport = new Passport(passportConfig); \ No newline at end of file + audience: 'platform_api', + logoutMode: 'silent', +}); \ No newline at end of file diff --git a/packages/checkout/widgets-sample-app/src/index.tsx b/packages/checkout/widgets-sample-app/src/index.tsx index 40f85237d6..0e9afc879a 100644 --- a/packages/checkout/widgets-sample-app/src/index.tsx +++ b/packages/checkout/widgets-sample-app/src/index.tsx @@ -18,6 +18,8 @@ import AddFundsUI from "./components/ui/add-funds/addFunds"; import { PassportProvider } from "./context/passport"; import { WidgetsProvider } from "./context/widgets"; import AddFundsIntegration from "./components/ui/add-funds-integration/addFunds"; +import { AddFundsPassportLogin } from "./components/ui/add-funds/login"; +import { AddFundsPassportLogout } from "./components/ui/add-funds/logout"; const router = createBrowserRouter([ { @@ -56,6 +58,14 @@ const router = createBrowserRouter([ path: "/add-funds", element: , }, + { + path: "/add-funds/login", + element: , + }, + { + path: "/add-funds/logout", + element: , + }, { path: "/add-funds-integration", element: (