From bd4fa0613f089bd9a0d05e6dad7a0538454f4677 Mon Sep 17 00:00:00 2001 From: Jhonatan Gonzalez Date: Wed, 17 Jan 2024 23:59:24 -0500 Subject: [PATCH] [GPR-354] Sale Widget | Handle Default Error and Transaction Error (#1353) --- .../src/blockExplorer/blockExplorer.test.ts | 18 ++++++ .../sdk/src/blockExplorer/blockExplorer.ts | 16 ++++++ .../checkout/sdk/src/blockExplorer/index.ts | 1 + packages/checkout/sdk/src/index.ts | 1 + .../view-context/SaleViewContextTypes.ts | 1 + .../src/widgets/sale/SaleWidget.tsx | 24 ++++++-- .../src/widgets/sale/SaleWidgetRoot.tsx | 1 - .../sale/context/SaleContextProvider.tsx | 55 ++++++++++++------- .../src/widgets/sale/hooks/useSignOrder.ts | 20 +++---- .../src/widgets/sale/views/SaleErrorView.tsx | 19 +++++-- .../src/components/ui/sale/sale.tsx | 2 +- 11 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 packages/checkout/sdk/src/blockExplorer/blockExplorer.test.ts create mode 100644 packages/checkout/sdk/src/blockExplorer/blockExplorer.ts create mode 100644 packages/checkout/sdk/src/blockExplorer/index.ts diff --git a/packages/checkout/sdk/src/blockExplorer/blockExplorer.test.ts b/packages/checkout/sdk/src/blockExplorer/blockExplorer.test.ts new file mode 100644 index 0000000000..d0d4a29bcc --- /dev/null +++ b/packages/checkout/sdk/src/blockExplorer/blockExplorer.test.ts @@ -0,0 +1,18 @@ +/* eslint @typescript-eslint/naming-convention: off */ + +import { BLOCKSCOUT_CHAIN_URL_MAP } from '../env'; +import { ChainId } from '../types'; +import { BlockExplorerService } from './blockExplorer'; + +describe('BlockExplorerService', () => { + it('Should not return link for unknown chainId', () => { + const url = BlockExplorerService.getTransactionLink('unknown' as unknown as ChainId, '0x123'); + expect(url).toBe(undefined); + }); + + it('Should return link for known chainId', () => { + const expectedUrl = BLOCKSCOUT_CHAIN_URL_MAP[ChainId.IMTBL_ZKEVM_TESTNET].url; + const url = BlockExplorerService.getTransactionLink(ChainId.IMTBL_ZKEVM_TESTNET, '0x123'); + expect(url).toBe(`${expectedUrl}/tx/0x123`); + }); +}); diff --git a/packages/checkout/sdk/src/blockExplorer/blockExplorer.ts b/packages/checkout/sdk/src/blockExplorer/blockExplorer.ts new file mode 100644 index 0000000000..5558076d3a --- /dev/null +++ b/packages/checkout/sdk/src/blockExplorer/blockExplorer.ts @@ -0,0 +1,16 @@ +import { BLOCKSCOUT_CHAIN_URL_MAP } from '../env'; +import { ChainId } from '../types'; + +export class BlockExplorerService { + /** + * getTransationLink returns the link to the transaction on blockscout + * @param hash transaction hash + * @returns link to the transaction on blockscout + */ + public static getTransactionLink(chainId: ChainId, hash: string): string | undefined { + const url = BLOCKSCOUT_CHAIN_URL_MAP?.[chainId]?.url; + if (!url || !hash) return undefined; + + return `${url}/tx/${hash}`; + } +} diff --git a/packages/checkout/sdk/src/blockExplorer/index.ts b/packages/checkout/sdk/src/blockExplorer/index.ts new file mode 100644 index 0000000000..94e90c0e21 --- /dev/null +++ b/packages/checkout/sdk/src/blockExplorer/index.ts @@ -0,0 +1 @@ +export { BlockExplorerService } from './blockExplorer'; diff --git a/packages/checkout/sdk/src/index.ts b/packages/checkout/sdk/src/index.ts index 914a417495..893654c977 100644 --- a/packages/checkout/sdk/src/index.ts +++ b/packages/checkout/sdk/src/index.ts @@ -129,3 +129,4 @@ export type { ErrorType } from './errors'; export { CheckoutErrorType } from './errors'; export { CheckoutConfiguration } from './config'; +export { BlockExplorerService } from './blockExplorer'; diff --git a/packages/checkout/widgets-lib/src/context/view-context/SaleViewContextTypes.ts b/packages/checkout/widgets-lib/src/context/view-context/SaleViewContextTypes.ts index 9bf5a64d5e..e378ff81bc 100644 --- a/packages/checkout/widgets-lib/src/context/view-context/SaleViewContextTypes.ts +++ b/packages/checkout/widgets-lib/src/context/view-context/SaleViewContextTypes.ts @@ -41,6 +41,7 @@ interface SaleFailView extends ViewType { type: SaleWidgetViews.SALE_FAIL; data?: { errorType: SaleErrorTypes; + transactionHash?: string; [key: string]: unknown; }; } diff --git a/packages/checkout/widgets-lib/src/widgets/sale/SaleWidget.tsx b/packages/checkout/widgets-lib/src/widgets/sale/SaleWidget.tsx index a6ec62e32f..14aedf727a 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/SaleWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/sale/SaleWidget.tsx @@ -2,7 +2,7 @@ import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, } from 'react'; -import { SaleWidgetParams } from '@imtbl/checkout-sdk'; +import { BlockExplorerService, ChainId, SaleWidgetParams } from '@imtbl/checkout-sdk'; import { Environment } from '@imtbl/config'; import { useTranslation } from 'react-i18next'; import { ConnectLoaderContext } from '../../context/connect-loader-context/ConnectLoaderContext'; @@ -40,9 +40,9 @@ export function SaleWidget(props: SaleWidgetProps) { fromTokenAddress, collectionName, } = props; - const { connectLoaderState } = useContext(ConnectLoaderContext); const { checkout, provider } = connectLoaderState; + const chainId = useRef(); const { theme } = config; const biomeTheme = useMemo(() => widgetTheme(theme), [theme]); @@ -53,6 +53,15 @@ export function SaleWidget(props: SaleWidgetProps) { const loadingText = viewState.view.data?.loadingText || t('views.LOADING_VIEW.text'); + useEffect(() => { + if (!checkout || !provider) return; + + (async () => { + const network = await checkout.getNetworkInfo({ provider }); + chainId.current = network.chainId; + })(); + }, [checkout, provider]); + const mounted = useRef(false); const onMount = useCallback(() => { if (!checkout || !provider) return; @@ -93,7 +102,6 @@ export function SaleWidget(props: SaleWidgetProps) { }} > - {viewState.view.type === SharedViews.LOADING_VIEW && ( )} @@ -107,7 +115,15 @@ export function SaleWidget(props: SaleWidgetProps) { )} {viewState.view.type === SaleWidgetViews.SALE_FAIL && ( - + )} {viewState.view.type === SaleWidgetViews.SALE_SUCCESS && provider && ( diff --git a/packages/checkout/widgets-lib/src/widgets/sale/SaleWidgetRoot.tsx b/packages/checkout/widgets-lib/src/widgets/sale/SaleWidgetRoot.tsx index 0c3d3ab505..e4068ac74d 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/SaleWidgetRoot.tsx +++ b/packages/checkout/widgets-lib/src/widgets/sale/SaleWidgetRoot.tsx @@ -119,7 +119,6 @@ export class Sale extends Base { environmentId={this.parameters.environmentId!} collectionName={this.parameters.collectionName!} language="en" - /> diff --git a/packages/checkout/widgets-lib/src/widgets/sale/context/SaleContextProvider.tsx b/packages/checkout/widgets-lib/src/widgets/sale/context/SaleContextProvider.tsx index 868bba3409..a106e34f89 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/context/SaleContextProvider.tsx +++ b/packages/checkout/widgets-lib/src/widgets/sale/context/SaleContextProvider.tsx @@ -17,7 +17,10 @@ import { useState, } from 'react'; import { ConnectLoaderState } from '../../../context/connect-loader-context/ConnectLoaderContext'; -import { FundWithSmartCheckoutSubViews, SaleWidgetViews } from '../../../context/view-context/SaleViewContextTypes'; +import { + FundWithSmartCheckoutSubViews, + SaleWidgetViews, +} from '../../../context/view-context/SaleViewContextTypes'; import { ViewActions, ViewContext, @@ -67,7 +70,9 @@ type SaleContextValues = SaleContextProps & { goBackToPaymentMethods: (paymentMethod?: SalePaymentTypes | undefined) => void; goToErrorView: (type: SaleErrorTypes, data?: Record) => void; goToSuccessView: (data?: Record) => void; - querySmartCheckout: ((callback?: (r?: SmartCheckoutResult) => void) => Promise); + querySmartCheckout: ( + callback?: (r?: SmartCheckoutResult) => void + ) => Promise; smartCheckoutResult: SmartCheckoutResult | undefined; fundingRoutes: FundingRoute[]; disabledPaymentTypes: SalePaymentTypes[] @@ -231,21 +236,24 @@ export function SaleContextProvider(props: { [paymentMethod, setPaymentMethod, executeResponse], ); - const goToSuccessView = useCallback((data?: Record) => { - viewDispatch({ - payload: { - type: ViewActions.UPDATE_VIEW, - view: { - type: SaleWidgetViews.SALE_SUCCESS, - data: { - paymentMethod, - transactions: executeResponse.transactions, - ...data, + const goToSuccessView = useCallback( + (data?: Record) => { + viewDispatch({ + payload: { + type: ViewActions.UPDATE_VIEW, + view: { + type: SaleWidgetViews.SALE_SUCCESS, + data: { + paymentMethod, + transactions: executeResponse.transactions, + ...data, + }, }, }, - }, - }); - }, [[paymentMethod, executeResponse]]); + }); + }, + [[paymentMethod, executeResponse]], + ); useEffect(() => { if (!signError) return; @@ -270,11 +278,14 @@ export function SaleContextProvider(props: { goToErrorView(smartCheckoutError.type, smartCheckoutError.data); }, [smartCheckoutError]); - const querySmartCheckout = useCallback(async (callback?: (r?: SmartCheckoutResult) => void) => { - const result = await smartCheckout(); - callback?.(result); - return result; - }, [smartCheckout]); + const querySmartCheckout = useCallback( + async (callback?: (r?: SmartCheckoutResult) => void) => { + const result = await smartCheckout(); + callback?.(result); + return result; + }, + [smartCheckout], + ); useEffect(() => { if (!smartCheckoutResult) { @@ -294,7 +305,9 @@ export function SaleContextProvider(props: { if (!smartCheckoutResult.sufficient) { switch (smartCheckoutResult.router.routingOutcome.type) { case RoutingOutcomeType.ROUTES_FOUND: - setFundingRoutes(smartCheckoutResult.router.routingOutcome.fundingRoutes); + setFundingRoutes( + smartCheckoutResult.router.routingOutcome.fundingRoutes, + ); viewDispatch({ payload: { type: ViewActions.UPDATE_VIEW, diff --git a/packages/checkout/widgets-lib/src/widgets/sale/hooks/useSignOrder.ts b/packages/checkout/widgets-lib/src/widgets/sale/hooks/useSignOrder.ts index f648b14c5b..dcda89286e 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/hooks/useSignOrder.ts +++ b/packages/checkout/widgets-lib/src/widgets/sale/hooks/useSignOrder.ts @@ -14,10 +14,8 @@ import { } from '../types'; const PRIMARY_SALES_API_BASE_URL = { - [Environment.SANDBOX]: - 'https://api.sandbox.immutable.com/v1/primary-sales', - [Environment.PRODUCTION]: - 'https://api.immutable.com/v1/primary-sales', + [Environment.SANDBOX]: 'https://api.sandbox.immutable.com/v1/primary-sales', + [Environment.PRODUCTION]: 'https://api.immutable.com/v1/primary-sales', }; type SignApiTransaction = { @@ -199,15 +197,13 @@ export const useSignOrder = (input: SignOrderInput) => { setExecuteTransactions({ method, hash: txnResponse?.hash }); await txnResponse?.wait(1); - transactionHash = txnResponse?.hash; + transactionHash = txnResponse?.hash || ''; return transactionHash; } catch (e) { - // TODO: check error type to send - // SaleErrorTypes.WALLET_REJECTED or SaleErrorTypes.WALLET_REJECTED_NO_FUNDS - - const reason = typeof e === 'string' ? e : (e as any).reason || ''; - let errorType = SaleErrorTypes.TRANSACTION_FAILED; + const reason = `${(e as any)?.reason || (e as any)?.message || ''}`.toLowerCase(); + transactionHash = (e as any)?.transactionHash; + let errorType = SaleErrorTypes.DEFAULT; if (reason.includes('rejected') && reason.includes('user')) { errorType = SaleErrorTypes.WALLET_REJECTED; } @@ -219,6 +215,10 @@ export const useSignOrder = (input: SignOrderInput) => { errorType = SaleErrorTypes.WALLET_REJECTED_NO_FUNDS; } + if (reason.includes('status failed') || reason.includes('transaction failed')) { + errorType = SaleErrorTypes.TRANSACTION_FAILED; + } + setSignError({ type: errorType, data: { error: e }, diff --git a/packages/checkout/widgets-lib/src/widgets/sale/views/SaleErrorView.tsx b/packages/checkout/widgets-lib/src/widgets/sale/views/SaleErrorView.tsx index f9c3bd5c4a..e9b80ca838 100644 --- a/packages/checkout/widgets-lib/src/widgets/sale/views/SaleErrorView.tsx +++ b/packages/checkout/widgets-lib/src/widgets/sale/views/SaleErrorView.tsx @@ -17,11 +17,15 @@ interface ErrorHandlerConfig { } type SaleErrorViewProps = { - errorType: SaleErrorTypes | undefined, biomeTheme: BaseTokens + errorType: SaleErrorTypes | undefined, + transactionHash?: string, + blockExplorerLink?: string, }; -export function SaleErrorView({ errorType = SaleErrorTypes.DEFAULT, biomeTheme }: SaleErrorViewProps) { +export function SaleErrorView({ + errorType = SaleErrorTypes.DEFAULT, transactionHash, blockExplorerLink, biomeTheme, +}: SaleErrorViewProps) { const { t } = useTranslation(); const { goBackToPaymentMethods } = useSaleContext(); const { eventTargetState: { eventTarget } } = useContext(EventTargetContext); @@ -33,9 +37,9 @@ export function SaleErrorView({ errorType = SaleErrorTypes.DEFAULT, biomeTheme } const errorHandlersConfig: Record = { [SaleErrorTypes.TRANSACTION_FAILED]: { onActionClick: goBackToPaymentMethods, - onSecondaryActionClick: () => { - /* TODO: redirects to Immutascan to check the transaction if has is given */ - }, + onSecondaryActionClick: transactionHash ? () => { + window.open(blockExplorerLink); + } : closeWidget, statusType: StatusType.FAILURE, statusIconStyles: { fill: biomeTheme.color.status.destructive.dim, @@ -94,13 +98,16 @@ export function SaleErrorView({ errorType = SaleErrorTypes.DEFAULT, biomeTheme } const getErrorViewProps = (): StatusViewProps => { const handlers = errorHandlersConfig[errorType] || {}; + const secondaryActionText = errorType === SaleErrorTypes.TRANSACTION_FAILED && transactionHash + ? t(`views.SALE_FAIL.errors.${SaleErrorTypes.DEFAULT}.secondaryAction`) + : t(`views.SALE_FAIL.errors.${errorType}.secondaryAction`); return { testId: 'fail-view', statusText: t(`views.SALE_FAIL.errors.${errorType}.description`), actionText: t(`views.SALE_FAIL.errors.${errorType}.primaryAction`), onActionClick: handlers?.onActionClick, - secondaryActionText: t(`views.SALE_FAIL.errors.${errorType}.secondaryAction`), + secondaryActionText, onSecondaryActionClick: handlers?.onSecondaryActionClick, onCloseClick: closeWidget, statusType: handlers.statusType, diff --git a/packages/checkout/widgets-sample-app/src/components/ui/sale/sale.tsx b/packages/checkout/widgets-sample-app/src/components/ui/sale/sale.tsx index c35d9fa5ed..81b878578f 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/sale/sale.tsx +++ b/packages/checkout/widgets-sample-app/src/components/ui/sale/sale.tsx @@ -8,7 +8,7 @@ import { Passport } from '@imtbl/passport'; const defaultPassportConfig = { environment: 'sandbox', - clientId: 'XuGsHvMqMJrb73diq1fCswWwn4AYhcM6', + clientId: 'q4gEET7vAKD5jsBWV6j8eoYNKEYpOOw1', redirectUri: 'http://localhost:3000/sale?login=true', logoutRedirectUri: 'http://localhost:3000/sale?logout=true', audience: 'platform_api',