diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx index 259636f78f..216528eb74 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx +++ b/packages/checkout/widgets-lib/src/widgets/checkout/CheckoutWidget.tsx @@ -15,6 +15,7 @@ import { CheckoutContextProvider } from './context/CheckoutContextProvider'; import { CheckoutAppIframe } from './views/CheckoutAppIframe'; import { getIframeURL } from './functions/iframeParams'; import { useMount } from './hooks/useMount'; +import { useAsyncMemo } from './hooks/useAsyncMemo'; export type CheckoutWidgetInputs = { checkout: Checkout; @@ -28,10 +29,10 @@ export default function CheckoutWidget(props: CheckoutWidgetInputs) { config, checkout, params, provider, } = props; - const [, iframeURL] = useMemo(() => { - if (!checkout.config.publishableKey) return ['', '']; - return getIframeURL(params, config, checkout.config); - }, [params, config, checkout.config]); + const iframeURL = useAsyncMemo( + async () => getIframeURL(params, config, checkout.config), + [params, config, checkout.config], + ); const [checkoutState, checkoutDispatch] = useReducer( checkoutReducer, diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/functions/iframeParams.ts b/packages/checkout/widgets-lib/src/widgets/checkout/functions/iframeParams.ts index bbbfeeaaab..387450b1be 100644 --- a/packages/checkout/widgets-lib/src/widgets/checkout/functions/iframeParams.ts +++ b/packages/checkout/widgets-lib/src/widgets/checkout/functions/iframeParams.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { CheckoutConfiguration, CheckoutFlowType, @@ -6,6 +7,8 @@ import { } from '@imtbl/checkout-sdk'; import { Environment } from '@imtbl/config'; + +import { encodeObject } from '../utils/encode'; import { CHECKOUT_APP_URL, ENV_DEVELOPMENT } from '../../../lib/constants'; /** @@ -29,11 +32,11 @@ const toQueryString = (params: Record): string => { /** * Maps the flow configuration and params to the corresponding query parameters. */ -const getIframeParams = ( +const getIframeParams = async ( params: CheckoutWidgetParams, widgetConfig: CheckoutWidgetConfiguration, checkoutConfig: CheckoutConfiguration, -): string => { +): Promise => { const { flow } = params; const commonConfig = { theme: widgetConfig.theme, @@ -108,6 +111,8 @@ const getIframeParams = ( toAmount: params.tokenAddress, }); case CheckoutFlowType.SALE: + const items = await encodeObject(params.items || []); + return toQueryString({ ...commonConfig, ...(widgetConfig.sale || {}), @@ -123,7 +128,7 @@ const getIframeParams = ( environmentId: params.environmentId, collectionName: params.collectionName, - items: params.items, + items, preferredCurrency: params.preferredCurrency, excludePaymentTypes: params.excludePaymentTypes, excludeFiatCurrencies: params.excludeFiatCurrencies, @@ -136,28 +141,34 @@ const getIframeParams = ( /** * Returns the iframe URL for the Checkout App based on the environment. */ -export const getIframeURL = ( +export const getIframeURL = async ( params: CheckoutWidgetParams, widgetConfig: CheckoutWidgetConfiguration, checkoutConfig: CheckoutConfiguration, -) => { +): Promise => { const { flow } = params; const { publishableKey } = checkoutConfig; - const language = params.language || widgetConfig.language; + if (!publishableKey) return ''; let environment: Environment = checkoutConfig.environment || Environment.SANDBOX; if (checkoutConfig.isDevelopment) { environment = ENV_DEVELOPMENT; } + if (checkoutConfig.overrides?.environment) { environment = checkoutConfig.overrides.environment; } - const baseURL = checkoutConfig.overrides?.checkoutAppUrl as string ?? CHECKOUT_APP_URL[environment]; - const queryParams = getIframeParams(params, widgetConfig, checkoutConfig); + const baseURL = (checkoutConfig.overrides?.checkoutAppUrl as string) + ?? CHECKOUT_APP_URL[environment]; + const queryParams = await getIframeParams( + params, + widgetConfig, + checkoutConfig, + ); - const iframeURL = `${baseURL}/${publishableKey}/${language}/${flow}?${queryParams}`; + const iframeURL = `${baseURL}/${flow}?${queryParams}`; - return [baseURL, iframeURL] as const; + return iframeURL; }; diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/hooks/useAsyncMemo.ts b/packages/checkout/widgets-lib/src/widgets/checkout/hooks/useAsyncMemo.ts new file mode 100644 index 0000000000..2bae383ba8 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/checkout/hooks/useAsyncMemo.ts @@ -0,0 +1,26 @@ +import { useEffect, 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 => { + const [value, setValue] = useState(); + + useEffect(() => { + let isMounted = true; + + asyncFn().then((result) => { + if (isMounted) setValue(result); + }); + + return () => { + isMounted = false; + }; + }, dependencies); + + return value as T; +}; diff --git a/packages/checkout/widgets-lib/src/widgets/checkout/utils/encode.ts b/packages/checkout/widgets-lib/src/widgets/checkout/utils/encode.ts new file mode 100644 index 0000000000..82404a60e5 --- /dev/null +++ b/packages/checkout/widgets-lib/src/widgets/checkout/utils/encode.ts @@ -0,0 +1,26 @@ +/** + * Encodes a JSON object using base64 encoding. + */ +export const encodeObject = async (value: Object): Promise => { + try { + const str = JSON.stringify(value); + const base64String = btoa(str); + return encodeURIComponent(base64String); + } catch (error) { + throw new Error(`Compression failed: ${(error as Error).message}`); + } +}; + +/** + * Decodes a string encoded using encodeObject. + */ +export const decodeObject = async ( + encodedValue: string, +): Promise => { + try { + const decodedString = atob(decodeURIComponent(encodedValue)); + return JSON.parse(decodedString); + } catch (error) { + throw new Error(`Decompression failed: ${(error as Error).message}`); + } +}; 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 6daee3e100..2fcd6e12f0 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,10 +1,15 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { AppHeaderBar, + Body, Box, Button, + Checkbox, FormControl, + Heading, + Link, MenuItem, + Select, Stack, Sticker, Toggle, @@ -22,28 +27,40 @@ import { WidgetType, CheckoutFlowType, WalletProviderName, + Widget, } from "@imtbl/checkout-sdk"; import { Passport } from "@imtbl/passport"; import { WidgetsFactory } from "@imtbl/checkout-widgets"; import { Environment, ImmutableConfiguration } from "@imtbl/config"; import { useAsyncMemo, usePrevState } from "../../../hooks"; +import { Message } from "./components/messages"; +import { Legend } from "./components/legend"; +import { itemsMock } from "./items.mock"; + +// +const ENVIRONMENT_DEV = "development" as Environment; const publishableKey = "pk_imapik-test-Xdera@"; // create a base config -const getBaseConfig = () => - new ImmutableConfiguration({ - environment: Environment.SANDBOX, +const getBaseConfig = (_environment: Environment) => { + // skip DEV as its not technically supported by config + const environment = + _environment === ENVIRONMENT_DEV ? Environment.SANDBOX : _environment; + + return new ImmutableConfiguration({ + environment, publishableKey, // apiKey // rateLimitingKey }); +}; // create a passport client -const getPassportClient = () => +const getPassportClient = (environment: Environment) => new Passport({ - baseConfig: getBaseConfig(), + baseConfig: getBaseConfig(environment), audience: "platform_api", scope: "openid offline_access email transact", clientId: "ViaYO6JWck4TZOiiojEak8mz6WvQh3wK", @@ -52,11 +69,11 @@ const getPassportClient = () => }); // create Checkout SDK -const getCheckoutSdk = (passportClient: Passport) => +const getCheckoutSdk = (passportClient: Passport, environment: Environment) => new Checkout({ publishableKey, passport: passportClient, - baseConfig: getBaseConfig(), + baseConfig: getBaseConfig(environment), overrides: { // checkoutAppUrl: "http://localhost:3001", // environment: "development" as Environment, @@ -105,19 +122,39 @@ const createWeb3Provider = async ( } }; +// checkout widget flows +const flows: Array = [ + CheckoutFlowType.CONNECT, + CheckoutFlowType.WALLET, + CheckoutFlowType.ONRAMP, + CheckoutFlowType.SWAP, + CheckoutFlowType.BRIDGE, + CheckoutFlowType.SALE, +]; + function CheckoutUI() { // avoid re mounting the widget const mounted = useRef(false); + // + const [environment, prevEnvironment, setEnvironment] = usePrevState( + Environment.SANDBOX + ); + + const [checkoutAppURL, setCheckoutAppURL] = useState(""); + // setup passport client - const passportClient = useMemo(() => getPassportClient(), []); + const passportClient = useMemo( + () => getPassportClient(environment), + [environment] + ); // handle passport login usePassportLoginCallback(passportClient); // setup checkout sdk const checkoutSdk = useMemo( - () => getCheckoutSdk(passportClient), - [passportClient] + () => getCheckoutSdk(passportClient, environment), + [passportClient, environment] ); // set a state to keep widget params and configs @@ -125,6 +162,19 @@ function CheckoutUI() { undefined ); + const [flowParams, setFlowParams] = useState< + Partial> + >({ + sale: { + flow: CheckoutFlowType.SALE, + items: itemsMock, + environmentId: "249d9b0b-ee16-4dd5-91ee-96bece3b0473", + collectionName: "Pixel Aussie Farm", + // excludePaymentTypes: [checkout.SalePaymentTypes.CREDIT], + // preferredCurrency: 'USDC', + }, + }); + // set a state to keep widget event results const [eventResults, setEventResults] = useState([]); @@ -178,6 +228,9 @@ function CheckoutUI() { // create the widget once factory is available // ignore language or theme changes + const prevWidget = useRef | undefined>( + undefined + ); const widget = useAsyncMemo(async () => { if (widgetsFactory === undefined) return undefined; if (renderAfterConnect && !web3Provider) return undefined; @@ -210,7 +263,18 @@ function CheckoutUI() { // add event listeners widget.addListener(CheckoutEventType.INITIALISED, () => { setEventResults((prev) => [...prev, { initialised: true }]); + + if (typeof window === "undefined") return; + + const checkoutAppIframe = document.getElementById( + "checkout-app" + ) as HTMLIFrameElement; + + if (checkoutAppIframe?.src) { + setCheckoutAppURL(checkoutAppIframe.src); + } }); + widget.addListener(CheckoutEventType.DISCONNECTED, () => { setEventResults((prev) => [...prev, { disconnected: true }]); }); @@ -250,9 +314,8 @@ function CheckoutUI() { // }); }, [widget]); - // mount & re-rende widget everytime params change + // mount & re-render widget everytime params change useEffect(() => { - if (mounted.current) return; if (params == undefined) return; if (renderAfterConnect && !web3Provider) return; @@ -289,9 +352,24 @@ function CheckoutUI() { } }, [renderAfterConnect, prevRenderAfterConnect, unmount]); + // unmount when environment changes + useEffect(() => { + if (environment !== prevEnvironment) { + console.log("ENV", environment, prevEnvironment); + unmount(); + } + }, [environment, prevEnvironment]); + + // unmount when web3Provider is undefined + useEffect(() => { + if (web3Provider === undefined && widget && mounted.current) { + unmount(); + } + }, [web3Provider, widget]); + return ( - <> - + + 🇰🇷 - - - {!isPassport && ( - - )} - {isPassport && ( - - )} - - - - {!isMetamask && ( - - )} - {isMetamask && ( - - )} - {params?.flow || ""} - - - - - Render after connect - - - - - - Events Log - {eventResults.map((result) => ( - - ))} + + + {/* --- --- --- */} + Environment: {environment.toUpperCase()} + {checkoutAppURL && new URL(checkoutAppURL).origin} + + setEnvironment(ENVIRONMENT_DEV)} + /> + {ENVIRONMENT_DEV.toUpperCase()} + + + setEnvironment(Environment.SANDBOX)} + /> + {Environment.SANDBOX.toUpperCase()} + + + setEnvironment(Environment.PRODUCTION)} + /> + {Environment.PRODUCTION.toUpperCase()} + + + {/* --- --- --- */} + Flow: {params?.flow.toLocaleUpperCase()} + + Connect a provider first + + + + {(renderAfterConnect || web3Provider) && ( + <> + Connect a provider + + + + {!isPassport && ( + + )} + {isPassport && ( + + )} + + + + {!isMetamask && ( + + )} + {isMetamask && ( + + )} + + + + )} + + {((renderAfterConnect && web3Provider) || !renderAfterConnect) && ( + <> + Select a flow + + + )} + + {/* --- --- --- */} + Params & Config: + + + + + {checkoutAppURL && ( + + { + window.open(checkoutAppURL, "_blank", "noopener,noreferrer"); + }} + > + {checkoutAppURL} + + + + )} + + + Events + {eventResults.map((result) => ( + + ))} - + ); } diff --git a/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/legend.tsx b/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/legend.tsx new file mode 100644 index 0000000000..4dc920c942 --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/legend.tsx @@ -0,0 +1,11 @@ +import { Divider, Heading } from '@biom3/react'; + +export const Legend = ({ children }: { children: React.ReactNode }) => ( + {children}) as any} + /> +); diff --git a/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/messages.tsx b/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/messages.tsx new file mode 100644 index 0000000000..e84b2c85d4 --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/checkout/components/messages.tsx @@ -0,0 +1,52 @@ +import { Box, Body, Heading } from "@biom3/react"; + +const backgroundColors = { + error: "base.color.status.fatal.bright", + warning: "base.color.status.attention.bright", + success: "base.color.status.success.bright", +}; + +type MessageType = "error" | "warning" | "success"; + +type MessageProps = { + children: React.ReactNode; + type: MessageType; + title?: string; +}; + +/** + * A versatile message component for displaying error, warning, or success messages. + * + * @param {Object} props - The component props. + * @param {React.ReactNode} props.children - The content of the message. + * @param {MessageType} props.type - The type of message ('error', 'warning', or 'success'). + * + * @example + * // Error message + * An error occurred. Please try again. + * + * @example + * // Warning message + * Your session will expire in 5 minutes. + * + * @example + * // Success message + * Your changes have been saved successfully. + */ +export const Message = ({ children, type, title }: MessageProps) => { + return ( + + {title && {title}} + + {children} + + + ); +}; diff --git a/packages/checkout/widgets-sample-app/src/components/ui/checkout/items.mock.ts b/packages/checkout/widgets-sample-app/src/components/ui/checkout/items.mock.ts new file mode 100644 index 0000000000..1165e6bf88 --- /dev/null +++ b/packages/checkout/widgets-sample-app/src/components/ui/checkout/items.mock.ts @@ -0,0 +1,68 @@ +import { SaleItem } from "@imtbl/checkout-sdk"; + +export const itemsMock: Array = [ + { + 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", + }, + { + productId: "quokka", + qty: 1, + name: "Quokka", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-9-1.png", + description: "Pixel Art Quokka", + }, + { + productId: "wombat", + qty: 1, + name: "Wombat", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-8-1.png", + description: "Pixel Art Wombat", + }, + { + productId: "kiwi", + qty: 1, + name: "Kiwi", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-7-1.png", + description: "Pixel Art Kiwi", + }, + { + productId: "emu", + qty: 1, + name: "Emu", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-5-1.png", + description: "Pixel Art Emu", + }, + { + productId: "corgi", + qty: 1, + name: "Corgi", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-3-1.png", + description: "Pixel Art Corgi", + }, + { + productId: "bull", + qty: 1, + name: "Bull", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-2-1.png", + description: "Pixel Art Bull", + }, + { + productId: "ibis", + qty: 1, + name: "Ibis", + image: + "https://iguanas.mystagingwebsite.com/wp-content/uploads/2024/05/character-image-1-1.png", + description: "Pixel Art Ibis", + }, +];