From 3552ba5184cbd3d2083f39219a42a4b39d61d546 Mon Sep 17 00:00:00 2001 From: Jhonatan Gonzalez Date: Fri, 6 Sep 2024 09:50:26 +1000 Subject: [PATCH] [NO CHANGELOG][Checkout Widgets] Update playground app (#2148) --- .../src/components/ui/checkout/checkout.tsx | 724 +++++++++++------- .../ui/marketplace-orchestrator/Checkout.tsx | 11 + .../ui/marketplace-orchestrator/MainPage.tsx | 5 +- .../ui/marketplace-orchestrator/index.ts | 2 + .../widgets-sample-app/src/hooks/index.ts | 61 ++ .../checkout/widgets-sample-app/src/index.tsx | 5 +- 6 files changed, 528 insertions(+), 280 deletions(-) create mode 100644 packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/Checkout.tsx create mode 100644 packages/checkout/widgets-sample-app/src/hooks/index.ts 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 d252eadf94..6daee3e100 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 @@ -1,320 +1,492 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AppHeaderBar, + Box, + Button, + FormControl, + MenuItem, + Stack, + Sticker, + Toggle, +} from "@biom3/react"; +import { Web3Provider, ExternalProvider } from "@ethersproject/providers"; + import { Checkout, - CheckoutFlowType, WidgetLanguage, WidgetTheme, - WidgetType, - WalletProviderName, - SalePaymentTypes, + CreateProviderParams, + CheckoutWidgetParams, CheckoutEventType, CheckoutSuccessEventType, - CheckoutFailureEventType, - CheckoutUserActionEventType, + WidgetType, + CheckoutFlowType, + WalletProviderName, } from "@imtbl/checkout-sdk"; -import { Environment } from "@imtbl/config"; +import { Passport } from "@imtbl/passport"; import { WidgetsFactory } from "@imtbl/checkout-widgets"; -import { passport } from "../marketplace-orchestrator/passport"; -import { Box } from "@biom3/react"; -import { Web3Provider } from "@ethersproject/providers"; +import { Environment, ImmutableConfiguration } from "@imtbl/config"; -function CheckoutUI() { - const [web3Provider, setWeb3Provider] = useState( - undefined - ); +import { useAsyncMemo, usePrevState } from "../../../hooks"; - const checkout = useMemo( - () => - new Checkout({ - baseConfig: { - environment: Environment.SANDBOX, - }, - publishableKey: "pk_imapik-test-gaDU8iOIIn-mLBc@Vvpm", - }), - [] - ); - const factory = useMemo( - () => - web3Provider - ? new WidgetsFactory(checkout, { - theme: WidgetTheme.DARK, - language: "en", - }) - : undefined, - [checkout, web3Provider] - ); - const checkoutWidget = useMemo( - () => - factory - ? factory.create(WidgetType.CHECKOUT, { - config: { - theme: WidgetTheme.LIGHT, - wallet: { showNetworkMenu: false }, - sale: { - hideExcludedPaymentTypes: true, - }, - }, - provider: web3Provider, - }) - : undefined, - [factory] - ); +const publishableKey = "pk_imapik-test-Xdera@"; + +// create a base config +const getBaseConfig = () => + new ImmutableConfiguration({ + environment: Environment.SANDBOX, + publishableKey, + // apiKey + // rateLimitingKey + }); + +// create a passport client +const getPassportClient = () => + new Passport({ + baseConfig: getBaseConfig(), + audience: "platform_api", + scope: "openid offline_access email transact", + clientId: "ViaYO6JWck4TZOiiojEak8mz6WvQh3wK", + redirectUri: "http://localhost:3000/checkout?login=true", + logoutRedirectUri: "http://localhost:3000/checkout?logout=true", + }); + +// create Checkout SDK +const getCheckoutSdk = (passportClient: Passport) => + new Checkout({ + publishableKey, + passport: passportClient, + baseConfig: getBaseConfig(), + overrides: { + // checkoutAppUrl: "http://localhost:3001", + // environment: "development" as Environment, + }, + // swap: { enable: true } + // bridge: { enable: true } + // onRamp: { enable: true } + }); + +// handle passport login +const usePassportLoginCallback = (passportClient: Passport) => { + const params = new URLSearchParams(window.location.search); + const loginParam = params.get("login"); - // Case 1: with MM useEffect(() => { - (async () => { - const { provider: newProvider } = await checkout.createProvider({ - walletProviderName: WalletProviderName.METAMASK, - }); + if (loginParam === "true") { + passportClient?.loginCallback(); + } + }, [loginParam, passportClient]); +}; - await checkout.connect({ - provider: newProvider, - }); +// handle creating and connecting a provider +const createWeb3Provider = async ( + checkoutSdk: Checkout, + params: CreateProviderParams +): Promise => { + try { + const { provider } = await checkoutSdk.createProvider({ ...params }); + const { isConnected } = await checkoutSdk.checkIsWalletConnected({ + provider, + }); - const { isConnected } = await checkout.checkIsWalletConnected({ - provider: newProvider, - }); + if (isConnected) return provider; - if (isConnected) { - setWeb3Provider(newProvider); - } - })(); - }, []); + try { + await checkoutSdk.connect({ provider, requestWalletPermissions: true }); + } catch (connectError) { + console.error("Error connecting provider", connectError); + throw new Error("Failed to connect the wallet. Please try again."); + } + + return provider; + } catch (error) { + console.error("Error in creating provider", error); + throw new Error("An error occurred while creating the provider."); + } +}; + +function CheckoutUI() { + // avoid re mounting the widget + const mounted = useRef(false); + + // setup passport client + const passportClient = useMemo(() => getPassportClient(), []); + // handle passport login + usePassportLoginCallback(passportClient); + + // setup checkout sdk + const checkoutSdk = useMemo( + () => getCheckoutSdk(passportClient), + [passportClient] + ); + + // set a state to keep widget params and configs + const [params, setParams] = useState( + undefined + ); + + // set a state to keep widget event results + const [eventResults, setEventResults] = useState([]); - // Case 1: with Passport - // useEffect(() => { - // const passportProvider = passport.connectEvm(); - // setWeb3Provider(new Web3Provider(passportProvider)); - // }, []); + // set a state to keep app configs such language and theme + const [language, prevLanguage, setLanguage] = + usePrevState("en"); + const [theme, prevTheme, setTheme] = usePrevState( + WidgetTheme.DARK + ); - // Case 2: with MM - // useEffect(() => { - // (async () => { - // const { provider: newProvider } = await checkout.createProvider({ - // walletProviderName: WalletProviderName.METAMASK, - // }); + // set a state to keep connected wallet web3Provider + const [web3Provider, setWeb3Provider] = useState( + undefined + ); - // setWeb3Provider(newProvider); + // setup widgets factory + // ignore language or theme changes + const widgetsFactory = useAsyncMemo( + async () => new WidgetsFactory(checkoutSdk, { theme, language }), + [] + ); - // await checkout.connect({ - // provider: newProvider, - // }); - // })(); - // }, []); + // know connected wallet type + const isMetamask = web3Provider?.provider?.isMetaMask; + const isPassport = ( + web3Provider?.provider as unknown as ExternalProvider & { + isPassport: boolean; + } + )?.isPassport; + // handle removing widget const unmount = () => { - checkoutWidget?.unmount(); + mounted.current = false; + widget?.unmount(); + setEventResults([]); }; - const update = (theme: WidgetTheme) => { - checkoutWidget?.update({ config: { theme } }); + // handle rendering widget + const mount = () => { + unmount(); + mounted.current = true; + widget?.mount("widget-root", params); }; - useEffect(() => { - if (!checkoutWidget) return; - checkoutWidget?.mount("checkout", { - flow: CheckoutFlowType.CONNECT, + // should wait until web3Provider is set to render widget? + const [renderAfterConnect, prevRenderAfterConnect, setRenderAfterConnect] = + usePrevState(false); + const toggleRenderAfterConnect = () => { + setRenderAfterConnect((prev) => !prev); + }; + + // create the widget once factory is available + // ignore language or theme changes + const widget = useAsyncMemo(async () => { + if (widgetsFactory === undefined) return undefined; + if (renderAfterConnect && !web3Provider) return undefined; + + return widgetsFactory.create(WidgetType.CHECKOUT, { + provider: web3Provider, + config: { + theme, + language, + // swap: {}, + // bridge: {}, + // connect: {}, + // onRamp: {}, + // sale: { + // hideExcludedPaymentTypes: false, + // waitFulfillmentSettlements: false, + // }, + // wallet: { + // showDisconnectButton: true, + // showNetworkMenu: true, + // } + }, }); - }, [checkoutWidget]); + }, [widgetsFactory, web3Provider, renderAfterConnect]); + // init, and add event listeners useEffect(() => { - if (!checkoutWidget) return; + if (!widget || mounted.current) return; - checkoutWidget.addListener(CheckoutEventType.INITIALISED, (data) => { - console.log("----------> INITIALISED", data); + // add event listeners + widget.addListener(CheckoutEventType.INITIALISED, () => { + setEventResults((prev) => [...prev, { initialised: true }]); }); - - checkoutWidget.addListener(CheckoutEventType.PROVIDER_UPDATED, (data) => { - console.log("----------> PROVIDER_UPDATED", data); + widget.addListener(CheckoutEventType.DISCONNECTED, () => { + setEventResults((prev) => [...prev, { disconnected: true }]); }); - - checkoutWidget.addListener(CheckoutEventType.CLOSE, (data) => { - console.log("----------> CLOSE", data); + // widget.addListener( + // checkout.CheckoutEventType.PROVIDER_UPDATED, + // ({ provider, ...data }) => { + // console.log('PROVIDER_UPDATED ---->', provider); + // setWeb3Provider(provider); + // setEventResults((prev) => [ + // ...prev, + // { providerUpdated: true, ...data }, + // ]); + // } + // ); + widget.addListener(CheckoutEventType.SUCCESS, (payload) => { + if (payload.type === CheckoutSuccessEventType.CONNECT_SUCCESS) { + const { provider, ...data } = payload.data; + console.log("SUCCESS ---->", provider); + setWeb3Provider(provider); + setEventResults((prev) => [...prev, { success: true, ...data }]); + } + }); + widget.addListener(CheckoutEventType.USER_ACTION, (data) => { + setEventResults((prev) => [...prev, { userAction: true, ...data }]); + }); + widget.addListener(CheckoutEventType.FAILURE, (data) => { + setEventResults((prev) => [...prev, { failure: true, ...data }]); + }); + widget.addListener(CheckoutEventType.CLOSE, () => { + setEventResults((prev) => [...prev, { closed: true }]); + widget.unmount(); }); - checkoutWidget.addListener(CheckoutEventType.SUCCESS, (event) => { - console.log("🐛 ~ event: ----->", event); + // // set initial flow to wallet + // setParams({ + // flow: checkout.CheckoutFlowType.CONNECT, + // }); + }, [widget]); - if (event.type === CheckoutSuccessEventType.CONNECT_SUCCESS) { - console.log("----------> SUCCESS CONNECT_SUCCESS", event); - setWeb3Provider(event.data.provider); - } - if (event.type === CheckoutSuccessEventType.SALE_SUCCESS) { - console.log("----------> SUCCESS SALE_SUCESS", event); - } - if (event.type === CheckoutSuccessEventType.SALE_TRANSACTION_SUCCESS) { - console.log("----------> SUCCESS SALE_TRANSACTION_SUCCESS", event); - } - if (event.type === CheckoutSuccessEventType.ONRAMP_SUCCESS) { - console.log("----------> SUCCESS ONRAMP", event); - } - if (event.type === CheckoutSuccessEventType.BRIDGE_SUCCESS) { - console.log("----------> SUCCESS BRIDGE_SUCCESS", event); - } - if ( - event.type === CheckoutSuccessEventType.BRIDGE_CLAIM_WITHDRAWAL_SUCCESS - ) { - console.log( - "----------> SUCCESS BRIDGE_CLAIM_WITHDRAWAL_SUCCESS", - event.data - ); - } - }); + // mount & re-rende widget everytime params change + useEffect(() => { + if (mounted.current) return; + if (params == undefined) return; + if (renderAfterConnect && !web3Provider) return; - checkoutWidget.addListener(CheckoutEventType.FAILURE, (event) => { - if (event.type === CheckoutFailureEventType.BRIDGE_FAILED) { - console.log("----------> FAILURE BRIDGE_FAILED", event); - } - if ( - event.type === CheckoutFailureEventType.BRIDGE_CLAIM_WITHDRAWAL_FAILED - ) { - console.log( - "----------> FAILURE BRIDGE_CLAIM_WITHDRAWAL_FAILED", - event.data - ); - } - if (event.type === CheckoutFailureEventType.CONNECT_FAILED) { - console.log("----------> FAILURE CONNECT", event); - } - if (event.type === CheckoutFailureEventType.ONRAMP_FAILED) { - console.log("----------> FAILURE ONRAMP", event); - } - if (event.type === CheckoutFailureEventType.SWAP_FAILED) { - console.log("----------> FAILURE SWAP", event); - } - if (event.type === CheckoutFailureEventType.SALE_FAILED) { - console.log("----------> FAILURE SALE", event); - } - console.log("----------> FAILURE", event); - }); + mount(); + }, [params, renderAfterConnect, web3Provider]); - checkoutWidget.addListener(CheckoutEventType.DISCONNECTED, (event) => { - console.log("----------> DISCONNECTED", event); - }); + // if language or theme change, notify widget + useEffect(() => { + if (widget === undefined) return; + if (!(language !== prevLanguage || theme !== prevTheme)) return; - checkoutWidget.addListener(CheckoutEventType.USER_ACTION, (event) => { - if (event.type === CheckoutUserActionEventType.PAYMENT_METHOD_SELECTED) { - console.log( - "----------> USER_ACTION PAYMENT_METHOD_SELECTED", - event.data.paymentMethod - ); - } - if (event.type === CheckoutUserActionEventType.PAYMENT_TOKEN_SELECTED) { - console.log("----------> USER_ACTION PAYMENT_TOKEN_SELECTED", event); - } - if (event.type === CheckoutUserActionEventType.NETWORK_SWITCH) { - console.log("----------> USER_ACTION WALLET", event); - } + widget.update({ config: { language, theme } }); + }, [language, prevLanguage, theme, prevTheme, widget]); - console.log("----------> USER_ACTION", event); - }); - }, [checkoutWidget]); + // announce passport provider + useEffect(() => { + passportClient.connectEvm({ announceProvider: true }); + }, []); + + // after this dApp creates a web3Provider recreate widget + useEffect(() => { + if (web3Provider === undefined || widgetsFactory === undefined) return; + + widgetsFactory.updateProvider(web3Provider); + }, [web3Provider, widgetsFactory]); + + // if render after connect is switched on reset + useEffect(() => { + if (prevRenderAfterConnect === false && renderAfterConnect === true) { + unmount(); + } + if (prevRenderAfterConnect === true && renderAfterConnect === false) { + setWeb3Provider(undefined); + } + }, [renderAfterConnect, prevRenderAfterConnect, unmount]); return ( -
-

Checkout Widget

-
- - - - - - - + <> + + + + { + setParams({ + flow: CheckoutFlowType.CONNECT, + }); + }} + > + Connect + + { + setParams({ + flow: CheckoutFlowType.WALLET, + }); + }} + > + Wallet + + { + setParams({ + flow: CheckoutFlowType.SWAP, + amount: "10", + fromTokenAddress: "native", + }); + }} + > + Swap + + { + setParams({ + flow: CheckoutFlowType.BRIDGE, + }); + }} + > + Bridge + + { + setParams({ + flow: CheckoutFlowType.ONRAMP, + }); + }} + > + On Ramp + + { + setParams({ + flow: CheckoutFlowType.SALE, + items: [ + { + productId: "kangaroo", + qty: 1, + name: "Kangaroo", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-10-1.png", + description: "Pixel Art Kangaroo", + }, + ], + environmentId: "249d9b0b-ee16-4dd5-91ee-96bece3b0473", + collectionName: "Pixel Aussie Farm", + // excludePaymentTypes: [checkout.SalePaymentTypes.CREDIT], + // preferredCurrency: 'USDC', + }); + }} + > + Primary Sale + + + + { + const thm = e.target.value as WidgetTheme; + setTheme(thm); + }} + /> + } + > + + + + { + const lang = e.target.value as WidgetLanguage; + setLanguage(lang); + }} + /> + } + > + + + + + + + + {!isPassport && ( + + )} + {isPassport && ( + + )} + + + + {!isMetamask && ( + + )} + {isMetamask && ( + + )} + + + + {params?.flow || ""} + + + + + + + + Render after connect + + + + + + + Events Log + {eventResults.map((result) => ( + + ))} + - - - - -
+ ); } diff --git a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/Checkout.tsx b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/Checkout.tsx new file mode 100644 index 0000000000..6e4276393b --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/Checkout.tsx @@ -0,0 +1,11 @@ +import { onLightBase } from "@biom3/design-tokens" +import { BiomeCombinedProviders } from "@biom3/react" +import CheckoutUI from "../checkout/checkout" + +export const Checkout = () => { + return( + + + + ) +} diff --git a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/MainPage.tsx b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/MainPage.tsx index af0e7f78ed..d6b452c49f 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/MainPage.tsx +++ b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/MainPage.tsx @@ -22,7 +22,6 @@ import { passport } from './passport'; import { LanguageSelector } from './LanguageSelector'; // Create one instance of Checkout and inject Passport -passport.connectEvm(); const checkout = new Checkout({ baseConfig: { environment: Environment.SANDBOX, @@ -64,6 +63,10 @@ export const MainPage = () => { swapWidget.addListener(SwapEventType.CLOSE_WIDGET, () => swapWidget.unmount()); onRampWidget.addListener(OnRampEventType.CLOSE_WIDGET, () => { onRampWidget.unmount() }); + useEffect(() => { + passport.connectEvm(); + }, []); + useEffect(() => { connectWidget.addListener(ConnectEventType.CLOSE_WIDGET, () => connectWidget.unmount()); connectWidget.addListener(ConnectEventType.SUCCESS, (eventData: ConnectionSuccess) => { diff --git a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/index.ts b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/index.ts index e65c676d25..53190c1eb2 100644 --- a/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/index.ts +++ b/packages/checkout/widgets-sample-app/src/components/ui/marketplace-orchestrator/index.ts @@ -1 +1,3 @@ export * from './Marketplace'; +export * from './Checkout'; + diff --git a/packages/checkout/widgets-sample-app/src/hooks/index.ts b/packages/checkout/widgets-sample-app/src/hooks/index.ts new file mode 100644 index 0000000000..3c44e9c0d5 --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/hooks/index.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Handle asynchronous operations with memoization. + * It only re-executes the async function when dependencies change. + */ +export const useAsyncMemo = ( + asyncFn: () => Promise, + dependencies: any[] +): T | undefined => { + const [value, setValue] = useState(); + + useEffect(() => { + let isMounted = true; + + asyncFn().then((result) => { + if (isMounted) setValue(result); + }); + + return () => { + isMounted = false; + }; + }, dependencies); + + return value; +}; + +/** + * Like useState bu tracks the current and previous values. + * Useful if requires comparing previous and current. + */ +export const usePrevState = ( + initialValue: T +): [T, T | undefined, React.Dispatch>] => { + const [currentValue, setCurrentValue] = useState(initialValue); + const prevValueRef = useRef(undefined); + + useEffect(() => { + prevValueRef.current = currentValue; + }, [currentValue]); + + return [currentValue, prevValueRef.current, setCurrentValue] as const; +}; + +export const useMount = ( + effect: () => void, + dependencies: (T | undefined)[] +) => { + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + + const allDefined = dependencies.every((dep) => dep !== undefined); + + if (allDefined) { + hasRun.current = true; + effect(); + } + }, [dependencies, effect]); +}; diff --git a/packages/checkout/widgets-sample-app/src/index.tsx b/packages/checkout/widgets-sample-app/src/index.tsx index b65861d01e..905df8a1a5 100644 --- a/packages/checkout/widgets-sample-app/src/index.tsx +++ b/packages/checkout/widgets-sample-app/src/index.tsx @@ -9,9 +9,8 @@ import SwapUI from './components/ui/swap/swap'; import BridgeUI from './components/ui/bridge/bridge'; import OnRampUI from "./components/ui/on-ramp/onRamp"; import { PassportLoginCallback } from './components/ui/marketplace-orchestrator/PassportLoginCallback'; -import { Marketplace } from './components/ui/marketplace-orchestrator'; +import { Marketplace, Checkout } from './components/ui/marketplace-orchestrator'; import { SaleUI } from './components/ui/sale/sale'; -import CheckoutUI from './components/ui/checkout/checkout'; import AddFundsUI from './components/ui/add-funds/addFunds'; const router = createBrowserRouter([ @@ -45,7 +44,7 @@ const router = createBrowserRouter([ }, { path: '/checkout', - element: , + element: , }, { path: '/add-funds',