diff --git a/packages/widget-playground-vite/.env.staging b/packages/widget-playground-vite/.env.staging index 2fcbddf09..cf7c1b2ec 100644 --- a/packages/widget-playground-vite/.env.staging +++ b/packages/widget-playground-vite/.env.staging @@ -1,2 +1,3 @@ VITE_API_URL=https://staging.li.quest/v1 -# Set VITE_API_KEY in .env.staging.local (git-ignored) +# in .env.dev.local (git-ignored) +# VITE_API_KEY diff --git a/packages/widget-playground-vite/src/App.tsx b/packages/widget-playground-vite/src/App.tsx index 32807c5da..87af72565 100644 --- a/packages/widget-playground-vite/src/App.tsx +++ b/packages/widget-playground-vite/src/App.tsx @@ -14,10 +14,21 @@ import type { JSX, PropsWithChildren } from 'react' const queryClient = new QueryClient() const AppProvider = ({ children }: PropsWithChildren) => { + const checkoutToChainRaw = import.meta.env.VITE_CHECKOUT_TO_CHAIN + const checkoutToChain = checkoutToChainRaw + ? Number(checkoutToChainRaw) + : undefined + return ( diff --git a/packages/widget-playground/package.json b/packages/widget-playground/package.json index 669d08029..71c74b780 100644 --- a/packages/widget-playground/package.json +++ b/packages/widget-playground/package.json @@ -26,10 +26,13 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@lifi/widget": "workspace:*", + "@lifi/widget-checkout": "workspace:*", "@lifi/widget-provider-bitcoin": "workspace:*", "@lifi/widget-provider-ethereum": "workspace:*", + "@lifi/widget-provider-mesh": "workspace:*", "@lifi/widget-provider-solana": "workspace:*", "@lifi/widget-provider-sui": "workspace:*", + "@lifi/widget-provider-transak": "workspace:*", "@lifi/widget-provider-tron": "workspace:*", "@metamask/connect-evm": "^1.4.0", "@mui/icons-material": "^9.0.1", diff --git a/packages/widget-playground/src/components/ModeDetailView.tsx b/packages/widget-playground/src/components/ModeDetailView.tsx index 5704bb046..88a28b85f 100644 --- a/packages/widget-playground/src/components/ModeDetailView.tsx +++ b/packages/widget-playground/src/components/ModeDetailView.tsx @@ -4,6 +4,7 @@ import { useConfigActions } from '../store/widgetConfig/useConfigActions.js' import { useConfigMode, useConfigModeOptions, + usePlaygroundWidgetMode, } from '../store/widgetConfig/useConfigValues.js' import { useDefaultConfig } from '../store/widgetConfig/useDefaultConfig.js' import { docsLinks } from '../utils/docsLinks.js' @@ -26,26 +27,37 @@ export const ModeDetailView = ({ }: ModeDetailViewProps): JSX.Element => { const { mode } = useConfigMode() const { modeOptions } = useConfigModeOptions() - const { setMode, setSplitOption } = useConfigActions() + const { playgroundWidgetMode } = usePlaygroundWidgetMode() + const { setMode, setSplitOption, setPlaygroundWidgetMode } = + useConfigActions() const { defaultConfig } = useDefaultConfig() const splitOption = getSplitOption(modeOptions) - const activeMode = getActiveMode(mode, splitOption) + const activeMode = + playgroundWidgetMode === 'checkout' + ? 'checkout' + : getActiveMode(mode, splitOption) const handleReset = useCallback((): void => { + setPlaygroundWidgetMode('swap') setMode(defaultConfig?.mode ?? 'default') const defaultSplit = defaultConfig?.modeOptions?.split setSplitOption(typeof defaultSplit === 'string' ? defaultSplit : undefined) - }, [defaultConfig, setMode, setSplitOption]) + }, [defaultConfig, setMode, setSplitOption, setPlaygroundWidgetMode]) const handleSelect = useCallback( (selectedMode: ModeOption): void => { + if (selectedMode === 'checkout') { + setPlaygroundWidgetMode('checkout') + return + } + setPlaygroundWidgetMode('swap') const { mode: nextMode, splitOption: nextSplitOption } = getModeConfig(selectedMode) - setMode(nextMode) + setMode(nextMode ?? 'default') setSplitOption(nextSplitOption) }, - [setMode, setSplitOption] + [setMode, setSplitOption, setPlaygroundWidgetMode] ) return ( diff --git a/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx b/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx new file mode 100644 index 000000000..bbc695096 --- /dev/null +++ b/packages/widget-playground/src/components/Widget/CheckoutWidgetView.tsx @@ -0,0 +1,139 @@ +import { ChainType } from '@lifi/widget' +import { LifiWidgetCheckout } from '@lifi/widget-checkout' +import { meshProvider } from '@lifi/widget-provider-mesh' +import { transakProvider } from '@lifi/widget-provider-transak' +import { Box, Button, Typography } from '@mui/material' +import { useAppKit, useAppKitAccount } from '@reown/appkit/react' +import { type JSX, useCallback, useEffect, useMemo, useState } from 'react' +import { widgetBaseConfig } from '../../defaultWidgetConfig.js' +import { useEnvVariables } from '../../providers/EnvVariablesProvider.js' +import { useConfig } from '../../store/widgetConfig/useConfig.js' + +const DEFAULT_CHECKOUT_INTEGRATOR = 'widget-transak-test' +const DEFAULT_CHECKOUT_TO_CHAIN = 1 +// USDC on Ethereum mainnet — demo destination asset when none is configured. +const DEFAULT_CHECKOUT_TO_TOKEN = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + +export function CheckoutWidgetView(): JSX.Element { + const { config } = useConfig() + const { + checkoutIntegrator, + checkoutToChain, + checkoutToToken, + checkoutToAddress, + } = useEnvVariables() + const [open, setOpen] = useState(false) + const { open: openWallet } = useAppKit() + const { address: connectedAddress } = useAppKitAccount() + + // Keep the first connected address — disconnecting a funding wallet must not wipe the recipient. + const [demoRecipient, setDemoRecipient] = useState() + useEffect(() => { + if (connectedAddress) { + setDemoRecipient(connectedAddress) + } + }, [connectedAddress]) + + const handleOpen = useCallback(() => { + setOpen(true) + }, []) + + const handleClose = useCallback(() => { + setOpen(false) + }, []) + + const handleConnect = useCallback(() => { + openWallet() + }, [openWallet]) + + // TODO(cleanup-remove-integrator-override-heuristic): Remove this heuristic comparison against + // widgetBaseConfig.integrator and use a strict precedence contract. + const resolvedIntegrator = + config?.integrator && config.integrator !== widgetBaseConfig.integrator + ? config.integrator + : checkoutIntegrator?.trim() || DEFAULT_CHECKOUT_INTEGRATOR + + const resolvedToChain = + config?.toChain ?? checkoutToChain ?? DEFAULT_CHECKOUT_TO_CHAIN + const resolvedToToken = + config?.toToken ?? checkoutToToken ?? DEFAULT_CHECKOUT_TO_TOKEN + const resolvedToAddress = checkoutToAddress ?? demoRecipient + + const checkoutConfig = useMemo( + () => ({ + ...config, + providers: config?.providers ?? widgetBaseConfig.providers, + toChain: resolvedToChain, + toToken: resolvedToToken, + ...(resolvedToAddress + ? { + toAddress: { + address: resolvedToAddress, + chainType: ChainType.EVM, + }, + } + : null), + walletConfig: { onConnect: () => openWallet() }, + }), + [config, resolvedToChain, resolvedToToken, resolvedToAddress, openWallet] + ) + + const onRampProviders = useMemo(() => [transakProvider(), meshProvider()], []) + + return ( + + + Checkout — opens as a centered widget + + {resolvedToAddress ? ( + <> + + Recipient: {resolvedToAddress} + + + + ) : ( + <> + + Connect a wallet to set the checkout recipient. + + + + )} + + + ) +} diff --git a/packages/widget-playground/src/components/Widget/WidgetView.tsx b/packages/widget-playground/src/components/Widget/WidgetView.tsx index 3c4d888bf..6a2f601ab 100644 --- a/packages/widget-playground/src/components/Widget/WidgetView.tsx +++ b/packages/widget-playground/src/components/Widget/WidgetView.tsx @@ -2,13 +2,17 @@ import type { FieldNames, FormState, WidgetDrawer } from '@lifi/widget' import { LiFiWidget, WidgetSkeleton } from '@lifi/widget' import type { JSX } from 'react' import { useCallback, useEffect, useRef } from 'react' +import { CheckoutWalletProvider } from '../../providers/ExternalWalletProvider/CheckoutWalletProvider.js' import { useFormValues } from '../../store/editTools/useFormValues.js' import { useSkeletonToolValues } from '../../store/editTools/useSkeletonToolValues.js' import { useConfig } from '../../store/widgetConfig/useConfig.js' +import { usePlaygroundWidgetMode } from '../../store/widgetConfig/useConfigValues.js' +import { CheckoutWidgetView } from './CheckoutWidgetView.js' import { WidgetViewContainer } from './WidgetViewContainer.js' export function WidgetView(): JSX.Element { const { config } = useConfig() + const { playgroundWidgetMode } = usePlaygroundWidgetMode() const drawerRef = useRef(null) const formRef = useRef(null) const { isSkeletonShown } = useSkeletonToolValues() @@ -30,6 +34,16 @@ export function WidgetView(): JSX.Element { } }, [formValues]) + if (playgroundWidgetMode === 'checkout') { + return ( + + + + + + ) + } + return ( {!isSkeletonShown ? ( diff --git a/packages/widget-playground/src/components/Widget/WidgetViewContainer.tsx b/packages/widget-playground/src/components/Widget/WidgetViewContainer.tsx index 4a5397063..52f0f9540 100644 --- a/packages/widget-playground/src/components/Widget/WidgetViewContainer.tsx +++ b/packages/widget-playground/src/components/Widget/WidgetViewContainer.tsx @@ -8,6 +8,7 @@ import { useHeaderAndFooterToolValues } from '../../store/editTools/useHeaderAnd import { useConfigContainer, useConfigVariant, + usePlaygroundWidgetMode, } from '../../store/widgetConfig/useConfigValues.js' import { useWidgetConfigStore } from '../../store/widgetConfig/WidgetConfigProvider.js' import { isFullHeightLayout } from '../../utils/layout.js' @@ -37,13 +38,16 @@ export function WidgetViewContainer({ }: WidgetViewContainerProps): JSX.Element { const { container } = useConfigContainer() const { variant } = useConfigVariant() + const { playgroundWidgetMode } = usePlaygroundWidgetMode() const walletConfig = useWidgetConfigStore((s) => s.config?.walletConfig) const { isDrawerOpen, drawerWidth } = useDrawerToolValues() const { setDrawerOpen } = useEditToolsActions() const { showMockHeader, showMockFooter, isFooterFixed } = useHeaderAndFooterToolValues() - const isWalletManagementExternal = !!walletConfig + // Checkout owns its wallet stack via CheckoutWalletProvider; mounting here would create a second AppKit. + const isWalletManagementExternal = + playgroundWidgetMode !== 'checkout' && !!walletConfig const isFullHeight = isFullHeightLayout(container) const showHeader = isFullHeight && showMockHeader diff --git a/packages/widget-playground/src/env.d.ts b/packages/widget-playground/src/env.d.ts index b86634f30..98475e5d3 100644 --- a/packages/widget-playground/src/env.d.ts +++ b/packages/widget-playground/src/env.d.ts @@ -3,6 +3,9 @@ declare global { readonly VITE_API_URL?: string readonly VITE_API_KEY?: string readonly VITE_TVM_WALLET_CONNECT?: string + readonly VITE_CHECKOUT_INTEGRATOR?: string + readonly VITE_CHECKOUT_TO_CHAIN?: string + readonly VITE_CHECKOUT_TO_TOKEN?: string } interface ImportMeta { diff --git a/packages/widget-playground/src/hooks/useSidebarNavLabels.ts b/packages/widget-playground/src/hooks/useSidebarNavLabels.ts index 00ae286ff..3ae3bfe59 100644 --- a/packages/widget-playground/src/hooks/useSidebarNavLabels.ts +++ b/packages/widget-playground/src/hooks/useSidebarNavLabels.ts @@ -4,6 +4,7 @@ import { useConfigModeOptions, useConfigVariant, useConfigWalletManagement, + usePlaygroundWidgetMode, } from '../store/widgetConfig/useConfigValues.js' import { useThemeValues } from '../store/widgetConfig/useThemeValues.js' import { getLayoutLabel, getLayoutMode } from '../utils/layout.js' @@ -19,6 +20,7 @@ export const useSidebarNavLabels = (): { walletValue: string } => { const { themeMode } = useThemeMode() + const { playgroundWidgetMode } = usePlaygroundWidgetMode() const { mode } = useConfigMode() const { modeOptions } = useConfigModeOptions() const { variant } = useConfigVariant() @@ -33,7 +35,10 @@ export const useSidebarNavLabels = (): { themeLabel: selectedThemeItem ? formatThemeDisplayName(selectedThemeItem, themeMode) : undefined, - modeValue: getModeLabel(mode, modeOptions?.split as string | undefined), + modeValue: + playgroundWidgetMode === 'checkout' + ? 'Checkout' + : getModeLabel(mode, modeOptions?.split as string | undefined), variantValue: variant === 'compact' ? 'Compact' diff --git a/packages/widget-playground/src/providers/EnvVariablesProvider.tsx b/packages/widget-playground/src/providers/EnvVariablesProvider.tsx index c82bca9c1..01bd78a34 100644 --- a/packages/widget-playground/src/providers/EnvVariablesProvider.tsx +++ b/packages/widget-playground/src/providers/EnvVariablesProvider.tsx @@ -1,33 +1,62 @@ import type { JSX, PropsWithChildren } from 'react' import { createContext, useContext } from 'react' -const EnvVariablesContext = createContext({ +export interface PlaygroundEnvVariables { + EVMWalletConnectId: string + TVMWalletConnectId: string + /** Checkout integrator forwarded in body/header, e.g. `local-test`. */ + checkoutIntegrator?: string + /** Default checkout target chain id for testing, e.g. `1`. */ + checkoutToChain?: number + /** Default checkout target token address for testing. */ + checkoutToToken?: string + /** Default checkout recipient address; falls back to the connected wallet. */ + checkoutToAddress?: string +} + +const EnvVariablesContext = createContext({ EVMWalletConnectId: '', TVMWalletConnectId: '', + checkoutIntegrator: undefined, + checkoutToChain: undefined, + checkoutToToken: undefined, + checkoutToAddress: undefined, }) interface EvnVariablesProviderProps extends PropsWithChildren { EVMWalletConnectId: string TVMWalletConnectId: string + checkoutIntegrator?: string + checkoutToChain?: number + checkoutToToken?: string + checkoutToAddress?: string } export const EnvVariablesProvider = ({ children, EVMWalletConnectId, TVMWalletConnectId, + checkoutIntegrator, + checkoutToChain, + checkoutToToken, + checkoutToAddress, }: EvnVariablesProviderProps): JSX.Element => { return ( {children} ) } -export const useEnvVariables = (): { - EVMWalletConnectId: string - TVMWalletConnectId: string -} => { +export const useEnvVariables = (): PlaygroundEnvVariables => { return useContext(EnvVariablesContext) } diff --git a/packages/widget-playground/src/providers/ExternalWalletProvider/CheckoutWalletProvider.tsx b/packages/widget-playground/src/providers/ExternalWalletProvider/CheckoutWalletProvider.tsx new file mode 100644 index 000000000..3be40e823 --- /dev/null +++ b/packages/widget-playground/src/providers/ExternalWalletProvider/CheckoutWalletProvider.tsx @@ -0,0 +1,19 @@ +import { useWidgetChains, type WidgetConfig } from '@lifi/widget' +import type { FC, PropsWithChildren } from 'react' +import { useConfig } from '../../store/widgetConfig/useConfig.js' +import { ReownWalletProvider } from './ReownWalletProvider.js' + +/** + * Mounts the Reown wallet stack for the checkout demo, rendering nothing until + * chains resolve — children call AppKit hooks that throw before `createAppKit`. + */ +export const CheckoutWalletProvider: FC = ({ children }) => { + const { config } = useConfig() + const { chains, isLoading } = useWidgetChains(config as WidgetConfig) + + if (!chains?.length || isLoading) { + return null + } + + return {children} +} diff --git a/packages/widget-playground/src/store/widgetConfig/createWidgetConfigStore.ts b/packages/widget-playground/src/store/widgetConfig/createWidgetConfigStore.ts index 2b78e707c..b7546f534 100644 --- a/packages/widget-playground/src/store/widgetConfig/createWidgetConfigStore.ts +++ b/packages/widget-playground/src/store/widgetConfig/createWidgetConfigStore.ts @@ -25,6 +25,10 @@ export const createWidgetConfigStore = ( }, themeId: 'default', widgetThemeItems: themeItems, + playgroundWidgetMode: 'swap', + setPlaygroundWidgetMode: (playgroundWidgetMode) => { + set({ playgroundWidgetMode }) + }, setConfig: (config) => { set({ config: { @@ -43,6 +47,7 @@ export const createWidgetConfigStore = ( resetConfig: () => { set({ themeId: 'default', + playgroundWidgetMode: 'swap', config: cloneStructuredConfig>( get().defaultConfig! ), @@ -267,13 +272,14 @@ export const createWidgetConfigStore = ( }), { name: 'li.fi-playground-config', - version: 3, + version: 4, migrate: () => ({}), partialize: (state) => ({ config: state?.config ? getLocalStorageOutput(state.config) : undefined, themeId: state.themeId, + playgroundWidgetMode: state.playgroundWidgetMode, }), onRehydrateStorage: () => { return (state) => { diff --git a/packages/widget-playground/src/store/widgetConfig/types.ts b/packages/widget-playground/src/store/widgetConfig/types.ts index c5cf458d8..259f78e3a 100644 --- a/packages/widget-playground/src/store/widgetConfig/types.ts +++ b/packages/widget-playground/src/store/widgetConfig/types.ts @@ -12,11 +12,14 @@ import type { StoreApi, UseBoundStore } from 'zustand' import type { ThemeItem } from '../editTools/types.js' import type { FormValues } from '../types.js' +export type PlaygroundWidgetMode = 'swap' | 'checkout' + interface WidgetConfigValues { defaultConfig?: Partial config?: Partial themeId: string widgetThemeItems: ThemeItem[] + playgroundWidgetMode: PlaygroundWidgetMode } interface WidgetConfigActions { @@ -42,6 +45,7 @@ interface WidgetConfigActions { setFormValues: (formValues: FormValues) => void setChainSidebarDisabled: (disabled: boolean) => void setSplitOption: (option?: SplitMode) => void + setPlaygroundWidgetMode: (mode: PlaygroundWidgetMode) => void } export type WidgetConfigState = WidgetConfigValues & WidgetConfigActions diff --git a/packages/widget-playground/src/store/widgetConfig/useConfigActions.ts b/packages/widget-playground/src/store/widgetConfig/useConfigActions.ts index 3ee63cb56..c10e3208d 100644 --- a/packages/widget-playground/src/store/widgetConfig/useConfigActions.ts +++ b/packages/widget-playground/src/store/widgetConfig/useConfigActions.ts @@ -23,6 +23,7 @@ export const useConfigActions = (): Pick< | 'setFormValues' | 'setChainSidebarDisabled' | 'setSplitOption' + | 'setPlaygroundWidgetMode' > => { const actions = useWidgetConfigStore((state) => ({ setConfig: state.setConfig, @@ -45,6 +46,7 @@ export const useConfigActions = (): Pick< setFormValues: state.setFormValues, setChainSidebarDisabled: state.setChainSidebarDisabled, setSplitOption: state.setSplitOption, + setPlaygroundWidgetMode: state.setPlaygroundWidgetMode, })) return actions diff --git a/packages/widget-playground/src/store/widgetConfig/useConfigValues.ts b/packages/widget-playground/src/store/widgetConfig/useConfigValues.ts index 77a1a474e..9b2c12cff 100644 --- a/packages/widget-playground/src/store/widgetConfig/useConfigValues.ts +++ b/packages/widget-playground/src/store/widgetConfig/useConfigValues.ts @@ -9,8 +9,21 @@ import { palette, paletteDark, paletteLight } from '@lifi/widget' import type { CSSProperties } from 'react' import { useShallow } from 'zustand/shallow' import { getValueFromPath } from '../../utils/getValueFromPath.js' +import type { PlaygroundWidgetMode } from './types.js' import { useWidgetConfigStore } from './WidgetConfigProvider.js' +export const usePlaygroundWidgetMode = (): { + playgroundWidgetMode: PlaygroundWidgetMode +} => { + const playgroundWidgetMode = useWidgetConfigStore( + (store) => store.playgroundWidgetMode + ) + + return { + playgroundWidgetMode, + } +} + export const useConfigVariant = (): { variant: WidgetVariant | 'default' } => { const variant = useWidgetConfigStore((store) => store.config?.variant) diff --git a/packages/widget-playground/src/utils/mode.ts b/packages/widget-playground/src/utils/mode.ts index 32dc34972..e58aaae23 100644 --- a/packages/widget-playground/src/utils/mode.ts +++ b/packages/widget-playground/src/utils/mode.ts @@ -1,12 +1,19 @@ import type { ModeOptions, SplitMode, WidgetMode } from '@lifi/widget' -export type ModeOption = 'exchange' | 'split' | 'swap' | 'bridge' | 'refuel' +export type ModeOption = + | 'exchange' + | 'split' + | 'swap' + | 'bridge' + | 'refuel' + | 'checkout' interface ModeOptionConfig { id: ModeOption title: string description: string - mode: WidgetMode + // checkout switches the playground widget instead of the widget mode config + mode?: WidgetMode splitOption?: SplitMode } @@ -47,6 +54,12 @@ export const MODE_OPTIONS: ModeOptionConfig[] = [ 'Dedicated gas-refuel flow that bridges a small amount of native token.', mode: 'refuel', }, + { + id: 'checkout', + title: 'Checkout', + description: + 'Outcome-based flow where users fund a predefined result, like a purchase or deposit.', + }, ] /** Normalises modeOptions.split when stored as a plain string. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 328535fef..e7f90796c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1663,18 +1663,27 @@ importers: '@lifi/widget': specifier: workspace:* version: link:../widget + '@lifi/widget-checkout': + specifier: workspace:* + version: link:../widget-checkout '@lifi/widget-provider-bitcoin': specifier: workspace:* version: link:../widget-provider-bitcoin '@lifi/widget-provider-ethereum': specifier: workspace:* version: link:../widget-provider-ethereum + '@lifi/widget-provider-mesh': + specifier: workspace:* + version: link:../widget-provider-mesh '@lifi/widget-provider-solana': specifier: workspace:* version: link:../widget-provider-solana '@lifi/widget-provider-sui': specifier: workspace:* version: link:../widget-provider-sui + '@lifi/widget-provider-transak': + specifier: workspace:* + version: link:../widget-provider-transak '@lifi/widget-provider-tron': specifier: workspace:* version: link:../widget-provider-tron