diff --git a/apps/cow-fi/app/(main)/widget/page.tsx b/apps/cow-fi/app/(main)/widget/page.tsx index 1fd9b3d00c7..eac22b5bc2d 100644 --- a/apps/cow-fi/app/(main)/widget/page.tsx +++ b/apps/cow-fi/app/(main)/widget/page.tsx @@ -51,7 +51,7 @@ const widgetParams: CowSwapWidgetParams = { appCode: 'CoW Protocol: Widget Demo', theme: 'light', standaloneMode: true, - width: '100%', + iframeStyle: { width: '100%' }, } export default function Page(): ReactNode { diff --git a/apps/cowswap-frontend/src/common/hooks/useThrottleFn.ts b/apps/cowswap-frontend/src/common/hooks/useThrottleFn.ts deleted file mode 100644 index 97de6c0a29f..00000000000 --- a/apps/cowswap-frontend/src/common/hooks/useThrottleFn.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useCallback, useState } from 'react' - -export function useThrottleFn(fn: (...args: unknown[]) => unknown, timeout: number): typeof fn { - const [lastCallTimestamp, setLastCallTimestamp] = useState(null) - - return useCallback( - (...args) => { - const now = Date.now() - - if (lastCallTimestamp && now - lastCallTimestamp < timeout) return - - setLastCallTimestamp(now) - - return fn(args) - }, - [fn, lastCallTimestamp, timeout], - ) -} diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/styled.test.tsx b/apps/cowswap-frontend/src/modules/application/containers/App/styled.test.tsx new file mode 100644 index 00000000000..6f6b8e4fc56 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/application/containers/App/styled.test.tsx @@ -0,0 +1,85 @@ +import { render } from '@testing-library/react' +import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components/macro' + +import { AppWrapper, BodyWrapper, Marginer } from './styled' + +describe('AppWrapper', () => { + it('does not enforce a minimum viewport height in widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('app-wrapper')).minHeight).toBe('auto') + }) + + it('keeps the full-page minimum height outside widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('app-wrapper')).minHeight).toBe('100vh') + }) +}) + +describe('BodyWrapper', () => { + it('does not flex-grow in widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('body-wrapper')).flex).toBe('0 0 auto') + }) + + it('uses the legacy widget shell padding by default', () => { + const { getByTestId } = render( + + + , + ) + + const computedStyle = window.getComputedStyle(getByTestId('body-wrapper')) + + expect(computedStyle.paddingTop).toBe('16px') + expect(computedStyle.paddingRight).toBe('16px') + expect(computedStyle.paddingBottom).toBe('0px') + expect(computedStyle.paddingLeft).toBe('16px') + }) + + it('keeps the legacy flex behavior outside widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('body-wrapper')).flex).toBe('1 1 auto') + }) +}) + +describe('Marginer', () => { + it('does not add vertical spacing in widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('marginer')).marginTop).toBe('0px') + }) + + it('keeps the legacy spacing outside widget mode', () => { + const { getByTestId } = render( + + + , + ) + + expect(window.getComputedStyle(getByTestId('marginer')).marginTop).toBe('5rem') + }) +}) diff --git a/apps/cowswap-frontend/src/modules/application/containers/App/styled.ts b/apps/cowswap-frontend/src/modules/application/containers/App/styled.ts index 64683896c1f..e9aab2b889e 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/App/styled.ts +++ b/apps/cowswap-frontend/src/modules/application/containers/App/styled.ts @@ -29,13 +29,13 @@ export const AppWrapper = styled.div>` display: flex; flex-flow: column; align-items: flex-start; - min-height: ${({ theme }) => (theme.isWidget ? '400px' : '100vh')}; + min-height: ${({ theme }) => (theme.isWidget ? 'auto' : '100vh')}; height: ${({ theme }) => (theme.isWidget ? 'initial' : '100%')}; position: relative; ` export const Marginer = styled.div` - margin-top: 5rem; + margin-top: ${({ theme }) => (theme.isWidget ? '0' : '5rem')}; ` export const SceneContainer = styled.div` @@ -72,7 +72,7 @@ export const BodyWrapper = styled.div<{ width: 100%; align-items: flex-start; justify-content: center; - flex: 1 1 auto; + flex: ${({ theme }) => (theme.isWidget ? '0 0 auto' : '1 1 auto')}; z-index: 2; color: inherit; padding: ${({ theme, $hasActiveSpeechBubbleNotification }) => diff --git a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx index 0badd91de12..0268975a204 100644 --- a/apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx +++ b/apps/cowswap-frontend/src/modules/application/containers/AppContainer/AppContainer.container.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useMemo, useState } from 'react' +import { type CSSProperties, type ReactNode, useMemo, useState } from 'react' import { useAnalyticsReporter } from '@cowprotocol/analytics' import { useFeatureFlags, useMediaQuery } from '@cowprotocol/common-hooks' @@ -7,6 +7,8 @@ import type { NotificationModel } from '@cowprotocol/core' import { Footer, Media } from '@cowprotocol/ui' import { useWalletDetails, useWalletInfo } from '@cowprotocol/wallet' +import { useInjectedWidgetParams } from 'entities/injectedWidget' + import { URLWarning } from 'legacy/components/Header/URLWarning' import { useDarkModeManager } from 'legacy/state/user/hooks' @@ -70,6 +72,7 @@ export function AppContainer({ children }: AppContainerProps): ReactNode { useInitializeUtm() const isInjectedWidgetMode = isInjectedWidget() + const { bodyWrapperStyle } = useInjectedWidgetParams() const [darkMode] = useDarkModeManager() const [pageBackgroundVariant, setPageBackgroundVariant] = useState('default') const [pageScene, setPageScene] = useState(null) @@ -114,6 +117,8 @@ export function AppContainer({ children }: AppContainerProps): ReactNode { {isYieldEnabled && } ({ + isInjectedWidget: jest.fn(), +})) + +jest.mock('jotai-devtools', () => ({ + DevTools: () =>
, +})) + +const isInjectedWidgetMock = isInjectedWidget as jest.MockedFunction + +describe('BalancesDevtools', () => { + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + isInjectedWidgetMock.mockReturnValue(false) + }) + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv + }) + + it('renders in development outside widget mode', () => { + process.env.NODE_ENV = 'development' + + render() + + expect(screen.getByTestId('balances-devtools')).toBeTruthy() + }) + + it('does not render inside widget mode', () => { + process.env.NODE_ENV = 'development' + isInjectedWidgetMock.mockReturnValue(true) + + render() + + expect(screen.queryByTestId('balances-devtools')).toBeNull() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/BalancesDevtools.tsx b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/BalancesDevtools.tsx index b5afc21965b..d75e12b8ae5 100644 --- a/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/BalancesDevtools.tsx +++ b/apps/cowswap-frontend/src/modules/balancesAndAllowances/updaters/BalancesDevtools.tsx @@ -1,9 +1,11 @@ import { JSX } from 'react' +import { isInjectedWidget } from '@cowprotocol/common-utils' + import { DevTools } from 'jotai-devtools' export function BalancesDevtools(): JSX.Element | null { - if (process.env.NODE_ENV !== 'development') return null + if (process.env.NODE_ENV !== 'development' || isInjectedWidget()) return null return } diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.test.tsx b/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.test.tsx new file mode 100644 index 00000000000..7d5d605bac9 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.test.tsx @@ -0,0 +1,45 @@ +import { createElement, type ReactNode } from 'react' + +import { renderHook, type RenderHookResult } from '@testing-library/react' +import { MemoryRouter } from 'react-router' + +import { useInjectedWidgetPalette } from './useInjectedWidgetPalette' + +function renderPaletteHook(search: string): RenderHookResult, unknown> { + return renderHook(() => useInjectedWidgetPalette(), { + wrapper: ({ children }: { children: ReactNode }) => + createElement(MemoryRouter, { initialEntries: [`/widget${search}`] }, children), + }) +} + +describe('useInjectedWidgetPalette', () => { + it('returns undefined when the palette param is absent', () => { + const { result } = renderPaletteHook('') + + expect(result.current).toBeUndefined() + }) + + it('returns null when palette=null', () => { + const { result } = renderPaletteHook('?palette=null') + + expect(result.current).toBeNull() + }) + + it('returns parsed palette JSON from the URL', () => { + const palette = { paper: '#ff0', primary: '#052b65' } + const { result } = renderPaletteHook(`?palette=${encodeURIComponent(JSON.stringify(palette))}`) + + expect(result.current).toEqual(palette) + }) + + it('returns null when the palette param cannot be parsed', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const { result } = renderPaletteHook('?palette=not-valid-json') + + expect(result.current).toBeNull() + expect(consoleError).toHaveBeenCalled() + + consoleError.mockRestore() + }) +}) diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.ts b/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.ts index 9ab1b3e548a..cec386fa232 100644 --- a/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.ts +++ b/apps/cowswap-frontend/src/modules/injectedWidget/hooks/useInjectedWidgetPalette.ts @@ -4,28 +4,36 @@ import { CowSwapWidgetPalette } from '@cowprotocol/widget-lib' import { useLocation } from 'react-router' -// The theme palette provided by a consumer -export function useInjectedWidgetPalette(): Partial | undefined { +/** + * Palette from the widget URL query string. + * + * - No param (`palette === null`): Keep the last applied custom palette. + * - Explicitly set to null (`palette === "null"`): Reset to the default theme. + * - Unparseable `palette` value: Reset to the default theme. + * - object: custom palette JSON from the host. + */ +export function useInjectedWidgetPalette(): Partial | null | undefined { const { search } = useLocation() return useMemo(() => { const searchParams = new URLSearchParams(search) const palette = searchParams.get('palette') - // When the palette is not provided, then do nothing - if (!palette) return undefined + // When the palette param is absent, do not change the current palette state. + if (palette === null) { + return undefined + } - // Reset palette state when the value is null + // Explicit reset to defaults (see widget-lib `addThemePaletteToQuery`). if (palette === 'null') { - return undefined + return null } try { - return JSON.parse(decodeURIComponent(palette)) + return JSON.parse(decodeURIComponent(palette)) as Partial } catch (e) { console.error('Failed to parse palette from URL', e) + return null } - - return undefined }, [search]) } diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.test.tsx b/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.test.tsx new file mode 100644 index 00000000000..71ccb2f0d04 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.test.tsx @@ -0,0 +1,336 @@ +import { useAtomValue } from 'jotai' + +import { isIframe, isInjectedWidget } from '@cowprotocol/common-utils' +import { WidgetMethodsEmit, widgetIframeTransport } from '@cowprotocol/widget-lib' + +import { act, render } from '@testing-library/react' +import { useInjectedWidgetParams } from 'entities/injectedWidget' + +import { IframeResizer } from './IframeResizer' + +jest.mock('jotai', () => ({ + ...jest.requireActual('jotai'), + useAtomValue: jest.fn(), +})) + +jest.mock('@cowprotocol/common-utils', () => ({ + isIframe: jest.fn(), + isInjectedWidget: jest.fn(), +})) + +jest.mock('entities/injectedWidget', () => ({ + useInjectedWidgetParams: jest.fn(() => ({})), +})) + +jest.mock('@cowprotocol/iframe-transport', () => ({ + ...jest.requireActual('@cowprotocol/iframe-transport'), + getParentOrigin: jest.fn(() => 'https://parent.example'), +})) + +const MOCK_PARENT_ORIGIN = 'https://parent.example' + +const useAtomValueMock = useAtomValue as jest.MockedFunction +const isIframeMock = isIframe as jest.MockedFunction +const isInjectedWidgetMock = isInjectedWidget as jest.MockedFunction +const useInjectedWidgetParamsMock = useInjectedWidgetParams as jest.MockedFunction + +const postMessageToWindowSpy = jest.spyOn(widgetIframeTransport, 'postMessageToWindow') + +const resizeObserverObserveMock = jest.fn() +const resizeObserverDisconnectMock = jest.fn() +const resizeObserverUnobserveMock = jest.fn() +const mutationObserverObserveMock = jest.fn() +const mutationObserverDisconnectMock = jest.fn() + +const originalResizeObserver = global.ResizeObserver +const originalMutationObserver = global.MutationObserver + +let triggerResizeObserver: (() => void) | null = null +let triggerMutationObserver: (() => void) | null = null +let rootElement: HTMLDivElement | null = null + +class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + triggerResizeObserver = () => callback([], this as unknown as ResizeObserver) + } + + observe = resizeObserverObserveMock + disconnect = resizeObserverDisconnectMock + unobserve = resizeObserverUnobserveMock +} + +class MockMutationObserver { + constructor(callback: MutationCallback) { + triggerMutationObserver = () => callback([], this as unknown as MutationObserver) + } + + observe = mutationObserverObserveMock + disconnect = mutationObserverDisconnectMock + takeRecords(): MutationRecord[] { + return [] + } +} + +describe('IframeResizer', () => { + beforeEach(() => { + jest.clearAllMocks() + + triggerResizeObserver = null + triggerMutationObserver = null + rootElement = document.createElement('div') + rootElement.id = 'root' + document.body.appendChild(rootElement) + document.documentElement.style.removeProperty('overflow') + + useAtomValueMock.mockReturnValue(false as never) + useInjectedWidgetParamsMock.mockReturnValue({} as never) + isIframeMock.mockReturnValue(true) + isInjectedWidgetMock.mockReturnValue(true) + + setContentSize({ bodyOffsetWidth: 400, rootScrollHeight: 520, rootOffsetHeight: 500 }) + setResizeObserver(MockResizeObserver) + setMutationObserver(MockMutationObserver) + }) + + afterEach(() => { + rootElement?.remove() + rootElement = null + document.documentElement.style.removeProperty('overflow') + }) + + afterAll(() => { + setResizeObserver(originalResizeObserver) + setMutationObserver(originalMutationObserver) + }) + + it('uses ResizeObserver and window resize events to emit updated heights', () => { + const { unmount } = render() + + expect(postMessageToWindowSpy).toHaveBeenCalledWith( + window.parent, + WidgetMethodsEmit.UPDATE_HEIGHT, + { + height: 500, + }, + MOCK_PARENT_ORIGIN, + ) + expect(resizeObserverObserveMock).toHaveBeenCalledWith(rootElement) + expect(resizeObserverObserveMock).toHaveBeenCalledWith(document.body) + expect(mutationObserverObserveMock).not.toHaveBeenCalled() + + setContentSize({ bodyOffsetWidth: 400, rootScrollHeight: 640, rootOffsetHeight: 610 }) + + act(() => { + triggerResizeObserver?.() + }) + + expect(postMessageToWindowSpy).toHaveBeenLastCalledWith( + window.parent, + WidgetMethodsEmit.UPDATE_HEIGHT, + { + height: 610, + }, + MOCK_PARENT_ORIGIN, + ) + + setContentSize({ bodyOffsetWidth: 400, rootScrollHeight: 680, rootOffsetHeight: 700 }) + + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + expect(postMessageToWindowSpy).toHaveBeenLastCalledWith( + window.parent, + WidgetMethodsEmit.UPDATE_HEIGHT, + { + height: 700, + }, + MOCK_PARENT_ORIGIN, + ) + + unmount() + + expect(resizeObserverDisconnectMock).toHaveBeenCalled() + }) + + it('ignores viewport-only height changes that would otherwise cause a resize loop', () => { + render() + + expect(postMessageToWindowSpy).toHaveBeenCalledTimes(1) + + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + expect(postMessageToWindowSpy).toHaveBeenCalledTimes(1) + }) + + it('uses the rendered root height when scrollHeight over-reports content size', () => { + setContentSize({ bodyOffsetWidth: 400, rootScrollHeight: 700, rootOffsetHeight: 640 }) + + render() + + expect(postMessageToWindowSpy).toHaveBeenCalledWith( + window.parent, + WidgetMethodsEmit.UPDATE_HEIGHT, + { + height: 640, + }, + MOCK_PARENT_ORIGIN, + ) + }) + + it('falls back to MutationObserver when ResizeObserver is unavailable', () => { + setResizeObserver(undefined) + + render() + + expect(mutationObserverObserveMock).toHaveBeenCalledWith(document.body, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + + setContentSize({ bodyOffsetWidth: 400, rootScrollHeight: 580, rootOffsetHeight: 560 }) + + act(() => { + triggerMutationObserver?.() + }) + + expect(postMessageToWindowSpy).toHaveBeenLastCalledWith( + window.parent, + WidgetMethodsEmit.UPDATE_HEIGHT, + { + height: 560, + }, + MOCK_PARENT_ORIGIN, + ) + }) + + it('emits full-height updates while a modal is open', () => { + useAtomValueMock.mockReturnValue(true as never) + setContentSize({ + rootScrollHeight: 520, + rootOffsetHeight: 500, + }) + + render() + + expect(postMessageToWindowSpy).toHaveBeenCalledWith( + window.parent, + WidgetMethodsEmit.SET_FULL_HEIGHT, + void 0, + MOCK_PARENT_ORIGIN, + ) + + setContentSize({ + rootScrollHeight: 560, + rootOffsetHeight: 540, + }) + + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + expect(postMessageToWindowSpy).toHaveBeenLastCalledWith( + window.parent, + WidgetMethodsEmit.SET_FULL_HEIGHT, + void 0, + MOCK_PARENT_ORIGIN, + ) + }) + + it('hides document overflow when disableScrollbars is enabled and restores it on unmount', () => { + useInjectedWidgetParamsMock.mockReturnValue({ disableScrollbars: true } as never) + + const { unmount } = render() + + expect(document.documentElement.style.overflow).toBe('hidden') + + unmount() + + expect(document.documentElement.style.overflow).toBe('') + }) + + it('restores document overflow when disableScrollbars is disabled', () => { + useInjectedWidgetParamsMock.mockReturnValue({ disableScrollbars: true } as never) + + const { rerender } = render() + + expect(document.documentElement.style.overflow).toBe('hidden') + + useInjectedWidgetParamsMock.mockReturnValue({ disableScrollbars: false } as never) + rerender() + + expect(document.documentElement.style.overflow).toBe('') + }) +}) + +function setContentSize({ + bodyOffsetWidth, + rootScrollHeight, + rootOffsetHeight, +}: { + bodyOffsetWidth: number + rootScrollHeight: number + rootOffsetHeight: number +}): void { + Object.defineProperty(document.body, 'offsetWidth', { + configurable: true, + value: bodyOffsetWidth, + }) + + const root = getRootElement() + + Object.defineProperty(root, 'scrollHeight', { + configurable: true, + value: rootScrollHeight, + }) + + Object.defineProperty(root, 'offsetHeight', { + configurable: true, + value: rootOffsetHeight, + }) + + Object.defineProperty(root, 'clientHeight', { + configurable: true, + value: rootOffsetHeight, + }) + + root.getBoundingClientRect = jest.fn(() => ({ + bottom: rootOffsetHeight, + height: rootOffsetHeight, + left: 0, + right: 0, + toJSON: () => undefined, + top: 0, + width: 0, + x: 0, + y: 0, + })) +} + +function getRootElement(): HTMLDivElement { + if (!rootElement) { + throw new Error('Root element is not initialized') + } + + return rootElement +} + +function setResizeObserver(value: typeof ResizeObserver | undefined): void { + Object.defineProperty(global, 'ResizeObserver', { + configurable: true, + value, + writable: true, + }) +} + +function setMutationObserver(value: typeof MutationObserver | undefined): void { + Object.defineProperty(global, 'MutationObserver', { + configurable: true, + value, + writable: true, + }) +} diff --git a/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.ts b/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.ts index fcb88949e58..0529bf2d251 100644 --- a/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.ts +++ b/apps/cowswap-frontend/src/modules/injectedWidget/updaters/IframeResizer.ts @@ -3,34 +3,46 @@ import { useLayoutEffect, useRef } from 'react' import { isIframe, isInjectedWidget } from '@cowprotocol/common-utils' import { getParentOrigin } from '@cowprotocol/iframe-transport' -import { MEDIA_WIDTHS } from '@cowprotocol/ui' import { widgetIframeTransport, WidgetMethodsEmit } from '@cowprotocol/widget-lib' +import { useInjectedWidgetParams } from 'entities/injectedWidget' + import { openModalState } from 'common/state/openModalState' export function IframeResizer(): null { const isModalOpen = useAtomValue(openModalState) const previousHeightRef = useRef(0) + const { disableScrollbars } = useInjectedWidgetParams() useLayoutEffect(() => { - if (!isIframe() || !isInjectedWidget()) return const parentOrigin = getParentOrigin() - if (!parentOrigin) return + if (!shouldPropagateHeightUpdates(parentOrigin)) return - // Initial height calculation and message - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const sendHeightUpdate = () => { - const contentHeight = document.body.scrollHeight + if (disableScrollbars) { + document.documentElement.style.overflow = 'hidden' + } - if (isModalOpen) { - const isUpToSmall = document.body.offsetWidth <= MEDIA_WIDTHS.upToSmall + return () => { + document.documentElement.style.removeProperty('overflow') + } + }, [disableScrollbars]) + useLayoutEffect(() => { + const parentOrigin = getParentOrigin() + + if (!shouldPropagateHeightUpdates(parentOrigin)) return + + const contentElement = getContentElement(document) + + const sendHeightUpdate = (): void => { + const contentHeight = getContentHeight(contentElement) + + if (isModalOpen) { widgetIframeTransport.postMessageToWindow( window.parent, WidgetMethodsEmit.SET_FULL_HEIGHT, - { isUpToSmall }, + void 0, parentOrigin, ) @@ -52,19 +64,57 @@ export function IframeResizer(): null { } sendHeightUpdate() - // Set up a MutationObserver to watch for changes in the DOM - const observer = new MutationObserver(() => { - sendHeightUpdate() - }) + window.addEventListener('resize', sendHeightUpdate) - // Start observing the entire body for changes that might affect its height - observer.observe(document.body, { childList: true, subtree: true }) + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => { + sendHeightUpdate() + }) + : null + + resizeObserver?.observe(contentElement) + + if (contentElement !== document.body) { + resizeObserver?.observe(document.body) + } + + const mutationObserver = + !resizeObserver && typeof MutationObserver !== 'undefined' + ? new MutationObserver(() => { + sendHeightUpdate() + }) + : null + + mutationObserver?.observe(document.body, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) - // Cleanup: Disconnect the observer when the component is unmounted return () => { - observer.disconnect() + window.removeEventListener('resize', sendHeightUpdate) + resizeObserver?.disconnect() + mutationObserver?.disconnect() } }, [isModalOpen]) return null } + +function shouldPropagateHeightUpdates(parentOrigin: string | null | undefined): boolean { + return isIframe() && isInjectedWidget() && Boolean(parentOrigin) +} + +function getContentElement(doc: Document): HTMLElement { + return doc.getElementById('root') ?? doc.body +} + +function getContentHeight(contentElement: HTMLElement): number { + return Math.max( + contentElement.offsetHeight, + contentElement.clientHeight, + Math.ceil(contentElement.getBoundingClientRect().height), + ) +} diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.test.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.test.tsx index 387fe05c2d4..31039d8d2ff 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.test.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.test.tsx @@ -32,6 +32,7 @@ jest.mock('@cowprotocol/common-hooks', () => ({ useFeatureFlags: () => ({}), useTheme: () => ({ darkMode: false }), useMediaQuery: () => false, + useThrottledCallback: (fn: unknown) => fn, })) jest.mock('@cowprotocol/common-utils', () => ({ @@ -125,10 +126,6 @@ jest.mock('common/hooks/useIsProviderNetworkUnsupported', () => ({ jest.mock('common/hooks/useIsProviderNetworkDeprecated', () => ({ useIsProviderNetworkDeprecated: () => false, })) -jest.mock('common/hooks/useThrottleFn', () => ({ - useThrottleFn: (fn: unknown) => fn, -})) - jest.mock('./styled', () => ({ ContainerBox: ({ children }: { children: React.ReactNode }) =>
{children}
, Header: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx index 9afdf7b82ee..657d6bd3479 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetForm.tsx @@ -1,7 +1,7 @@ -import React, { ReactNode, useCallback, useMemo } from 'react' +import React, { type CSSProperties, ReactNode, useCallback, useMemo } from 'react' import svgOrdersSrc from '@cowprotocol/assets/svg/orders.svg' -import { useFeatureFlags, useTheme, useMediaQuery } from '@cowprotocol/common-hooks' +import { useFeatureFlags, useMediaQuery, useTheme, useThrottledCallback } from '@cowprotocol/common-hooks' import { isInjectedWidget, isSellOrder, maxAmountSpend } from '@cowprotocol/common-utils' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Currency } from '@cowprotocol/currency' @@ -22,7 +22,6 @@ import { WalletStatusButton } from 'modules/wallet' import { useIsProviderNetworkDeprecated } from 'common/hooks/useIsProviderNetworkDeprecated' import { useIsProviderNetworkUnsupported } from 'common/hooks/useIsProviderNetworkUnsupported' -import { useThrottleFn } from 'common/hooks/useThrottleFn' import { CurrencyArrowSeparator } from 'common/pure/CurrencyArrowSeparator' import { CurrencyInputPanel, CurrencyInputPanelProps } from 'common/pure/CurrencyInputPanel' import { PoweredFooter } from 'common/pure/PoweredFooter' @@ -67,7 +66,7 @@ const scrollToMyOrders = () => { // eslint-disable-next-line max-lines-per-function, complexity export function TradeWidgetForm(props: TradeWidgetProps): ReactNode { const isInjectedWidgetMode = isInjectedWidget() - const { standaloneMode, hideOrdersTable } = useInjectedWidgetParams() + const { standaloneMode, hideOrdersTable, cardStyle } = useInjectedWidgetParams() const isMobile = useMediaQuery(Media.upToSmall(false)) const tradeTypeInfo = useTradeTypeInfoFromUrl() @@ -166,7 +165,7 @@ export function TradeWidgetForm(props: TradeWidgetProps): ReactNode { primaryFormValidation === TradeFormValidation.WrapUnwrapFlow // Disable too frequent tokens switching - const throttledOnSwitchTokens = useThrottleFn(onSwitchTokens, 500) + const throttledOnSwitchTokens = useThrottledCallback(onSwitchTokens, 500) const isUpToLarge = useMediaQuery(Media.upToLarge(false)) @@ -226,7 +225,7 @@ export function TradeWidgetForm(props: TradeWidgetProps): ReactNode { return ( <> - + {shouldLockForAlternativeOrder ?
: } {isInjectedWidgetMode && standaloneMode !== false && ( diff --git a/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.test.tsx b/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.test.tsx index f8e3d31a7c0..2b1d4cdec1d 100644 --- a/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.test.tsx +++ b/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.test.tsx @@ -130,19 +130,31 @@ describe('ThemeConfigUpdater', () => { expect(mockSetThemeConfig).toHaveBeenCalledWith(lightTheme) }) - it('preserves last non-undefined widgetTheme when palette disappears from URL', () => { + it('preserves last widgetTheme when the palette query param is removed', () => { mockedUseInjectedWidgetPalette.mockReturnValue(palette) const { rerender } = renderHook(() => ThemeConfigUpdater()) - // Palette disappears (e.g. URL changes to null) mockedUseInjectedWidgetPalette.mockReturnValue(undefined) rerender() - // widgetTheme state should still hold the last non-undefined palette expect(mockedMapWidgetTheme).toHaveBeenLastCalledWith(palette, lightTheme) }) + it('resets widgetTheme when palette=null is sent in the URL', () => { + mockedUseInjectedWidgetPalette.mockReturnValue(palette) + mockedMapWidgetTheme.mockReturnValue(widgetTheme) + + const { rerender } = renderHook(() => ThemeConfigUpdater()) + + mockedUseInjectedWidgetPalette.mockReturnValue(null) + mockedMapWidgetTheme.mockReturnValue(lightTheme) + rerender() + + expect(mockedMapWidgetTheme).toHaveBeenLastCalledWith(undefined, lightTheme) + expect(mockSetThemeConfig).toHaveBeenLastCalledWith(lightTheme) + }) + it('updates widgetTheme state when a new palette arrives', () => { const newPalette = { paper: '#0f0' } const newWidgetTheme = { primary: '#0f0', isWidget: true } as never @@ -158,5 +170,19 @@ describe('ThemeConfigUpdater', () => { expect(mockedMapWidgetTheme).toHaveBeenLastCalledWith(newPalette, lightTheme) expect(mockSetThemeConfig).toHaveBeenLastCalledWith(newWidgetTheme) }) + + it('resets widgetTheme when an invalid palette arrives after a valid one', () => { + mockedUseInjectedWidgetPalette.mockReturnValue(palette) + mockedMapWidgetTheme.mockReturnValue(widgetTheme) + + const { rerender } = renderHook(() => ThemeConfigUpdater()) + + mockedUseInjectedWidgetPalette.mockReturnValue(null) + mockedMapWidgetTheme.mockReturnValue(lightTheme) + rerender() + + expect(mockedMapWidgetTheme).toHaveBeenLastCalledWith(undefined, lightTheme) + expect(mockSetThemeConfig).toHaveBeenLastCalledWith(lightTheme) + }) }) }) diff --git a/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.ts b/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.ts index c98d4bc0797..f2d573023fa 100644 --- a/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.ts +++ b/apps/cowswap-frontend/src/theme/ThemeConfigUpdater.ts @@ -2,6 +2,7 @@ import { useSetAtom } from 'jotai' import { useEffect, useState } from 'react' import { isInjectedWidget } from '@cowprotocol/common-utils' +import type { CowSwapWidgetPalette } from '@cowprotocol/widget-lib' import { getCowswapTheme } from './getCowswapTheme' import { mapWidgetTheme } from './mapWidgetTheme' @@ -15,15 +16,19 @@ export function ThemeConfigUpdater(): null { const darkMode = useIsDarkMode() const injectedWidgetTheme = useInjectedWidgetPalette() - const [widgetTheme, setWidgetTheme] = useState(injectedWidgetTheme) + const [widgetTheme, setWidgetTheme] = useState | undefined>(() => + injectedWidgetTheme && typeof injectedWidgetTheme === 'object' ? injectedWidgetTheme : undefined, + ) /** - * Save widgetTheme from URL to state only if it's present + * Sync widgetTheme from URL when the host sends a palette or an explicit reset (`palette=null`). */ useEffect(() => { - if (injectedWidgetTheme) { - setWidgetTheme(injectedWidgetTheme) + if (injectedWidgetTheme === undefined) { + return } + + setWidgetTheme(injectedWidgetTheme ?? undefined) }, [injectedWidgetTheme]) useEffect(() => { diff --git a/apps/cowswap-frontend/src/theme/mapWidgetTheme.test.ts b/apps/cowswap-frontend/src/theme/mapWidgetTheme.test.ts index 7db07420444..367837052a7 100644 --- a/apps/cowswap-frontend/src/theme/mapWidgetTheme.test.ts +++ b/apps/cowswap-frontend/src/theme/mapWidgetTheme.test.ts @@ -5,21 +5,23 @@ import { mapWidgetTheme } from './mapWidgetTheme' import type { DefaultTheme } from 'styled-components/macro' describe('mapWidgetTheme', () => { - it('maps custom widget shadow to the main widget container shadow', () => { + it('merges palette colors and maps paper to button text', () => { const defaultTheme = { boxShadow1: '0 12px 12px rgba(5, 43, 101, 0.06)', paper: '#ffffff', } as DefaultTheme const widgetTheme: Partial = { + baseTheme: 'dark', paper: '#101010', - boxShadow: 'none', + primary: '#ffffff', } const result = mapWidgetTheme(widgetTheme, defaultTheme) expect(result.paper).toBe('#101010') expect(result.buttonTextCustom).toBe('#101010') - expect(result.boxShadow1).toBe('none') + expect(result.primary).toBe('#ffffff') + expect(result.boxShadow1).toBe('0 12px 12px rgba(5, 43, 101, 0.06)') }) }) diff --git a/apps/cowswap-frontend/src/theme/mapWidgetTheme.ts b/apps/cowswap-frontend/src/theme/mapWidgetTheme.ts index 85904e63ace..79a8ff17a5a 100644 --- a/apps/cowswap-frontend/src/theme/mapWidgetTheme.ts +++ b/apps/cowswap-frontend/src/theme/mapWidgetTheme.ts @@ -2,14 +2,21 @@ import type { CowSwapWidgetPalette } from '@cowprotocol/widget-lib' import { DefaultTheme } from 'styled-components/macro' -// Map the provided data from consumer to styled-components theme +/** + * Map the provided data from consumer to styled-components theme. + * + * Layout and shell styling (padding, border radius, iframe shadow) now live in + * `iframeStyle`, `bodyWrapperStyle`, and `cardStyle` instead of the palette. + * + * Keep the legacy `boxShadow` to `boxShadow1` mapping to avoid breaking live integrations. + */ export function mapWidgetTheme( widgetTheme: Partial | undefined, defaultTheme: DefaultTheme, ): DefaultTheme { if (!widgetTheme) return defaultTheme - const { boxShadow, ...widgetPalette } = widgetTheme + const { boxShadow, ...widgetPalette } = widgetTheme as Partial & { boxShadow?: string } return { ...defaultTheme, diff --git a/apps/widget-configurator/.env b/apps/widget-configurator/.env index a44bea436b8..e1b31055617 100644 --- a/apps/widget-configurator/.env +++ b/apps/widget-configurator/.env @@ -5,3 +5,5 @@ NODE_ENV=development # Analytics #REACT_APP_GOOGLE_ANALYTICS_ID= + +REACT_APP_ENVIRONMENT=local diff --git a/apps/widget-configurator/AGENTS.md b/apps/widget-configurator/AGENTS.md index cfca9f9db83..d7b4dfcd30e 100644 --- a/apps/widget-configurator/AGENTS.md +++ b/apps/widget-configurator/AGENTS.md @@ -1,7 +1,7 @@ --- author: agents status: normative -last_reviewed: 2026-03-05 +last_reviewed: 2026-06-04 --- # widget-configurator AGENTS.md diff --git a/apps/widget-configurator/index.html b/apps/widget-configurator/index.html index 7163ce6a624..53382a4b58c 100644 --- a/apps/widget-configurator/index.html +++ b/apps/widget-configurator/index.html @@ -2,7 +2,7 @@ - CoW Swap: Widget configurator + CoW Swap Widget Configurator diff --git a/apps/widget-configurator/package.json b/apps/widget-configurator/package.json index 690d574fbf8..e2cc8388d4c 100644 --- a/apps/widget-configurator/package.json +++ b/apps/widget-configurator/package.json @@ -23,6 +23,7 @@ ] }, "dependencies": { + "@coinbase/wallet-sdk": "4.3.7", "@cowprotocol/analytics": "workspace:*", "@cowprotocol/assets": "workspace:*", "@cowprotocol/common-const": "workspace:*", @@ -31,27 +32,33 @@ "@cowprotocol/cow-sdk": "9.1.5", "@cowprotocol/events": "workspace:*", "@cowprotocol/types": "workspace:*", + "@cowprotocol/ui": "workspace:*", "@cowprotocol/widget-lib": "workspace:*", "@cowprotocol/widget-react": "workspace:*", - "@mui/icons-material": "5.17.1", "@mui/material": "5.17.1", - "@reown/appkit": "1.8.19", "@reown/appkit-adapter-wagmi": "1.8.19", "@reown/appkit-controllers": "1.8.19", - "@coinbase/wallet-sdk": "4.3.7", + "@reown/appkit": "1.8.19", "@tanstack/react-query": "5.90.20", "@wagmi/connectors": "8.0.9", + "csstype": "3.1.3", "inter-ui": "3.19.3", "launchdarkly-react-client-sdk": "3.0.8", "mui-color-input": "2.0.1", - "react": "19.1.2", "react-dom": "19.1.2", + "react-feather": "2.0.10", "react-inlinesvg": "4.2.0", "react-syntax-highlighter": "15.5.0", + "react": "19.1.2", "viem": "2.48.8", - "wagmi": "3.6.9" + "wagmi": "3.6.9", + "widget-react-v3.0.5": "npm:@cowprotocol/widget-react@3.0.5", + "widget-react-v2.0.2": "npm:@cowprotocol/widget-react@2.0.2", + "widget-react-v1.3.5": "npm:@cowprotocol/widget-react@1.3.5", + "widget-react-v0.13.0": "npm:@cowprotocol/widget-react@0.13.0" }, "devDependencies": { + "@testing-library/react": "16.3.0", "@types/react-dom": "19.1.3", "@types/react-syntax-highlighter": "15.5.9", "@types/react": "19.1.3", diff --git a/apps/widget-configurator/scripts/widget-sdk-versions.script.mjs b/apps/widget-configurator/scripts/widget-sdk-versions.script.mjs new file mode 100644 index 00000000000..8f971bde0f3 --- /dev/null +++ b/apps/widget-configurator/scripts/widget-sdk-versions.script.mjs @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +/** + * Syncs widget SDK version constants with the workspace libs and npm latest release. + * + * Usage: + * node apps/widget-configurator/scripts/widget-sdk-versions.script.mjs + * + * Or via pnpm: + * pnpm update-widget-sdk-versions + */ + +import fs from 'node:fs' +import https from 'node:https' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const WIDGET_CONFIGURATOR_ROOT = path.resolve(__dirname, '..') +const REPO_ROOT = path.resolve(WIDGET_CONFIGURATOR_ROOT, '../..') + +const PATHS = { + constants: path.join( + WIDGET_CONFIGURATOR_ROOT, + 'src/utils/widget-sdk-versions/widget-sdk-versions.constants.ts', + ), + loaders: path.join(WIDGET_CONFIGURATOR_ROOT, 'src/utils/widget-sdk-versions/widget-sdk-versions.loaders.ts'), + declarations: path.join(WIDGET_CONFIGURATOR_ROOT, 'src/declarations.d.ts'), + packageJson: path.join(WIDGET_CONFIGURATOR_ROOT, 'package.json'), + widgetReactPkg: path.join(REPO_ROOT, 'libs/widget-react/package.json'), + widgetLibPkg: path.join(REPO_ROOT, 'libs/widget-lib/package.json'), +} + +const AUTO_GENERATED_BEGIN = '// --- AUTO-GENERATED by widget-sdk-versions.script (do not edit) ---' +const AUTO_GENERATED_END = '// --- END AUTO-GENERATED ---' +const PINNED_LEGACY_BEGIN = '// --- AUTO-GENERATED PINNED LEGACY by widget-sdk-versions.script (do not edit) ---' +const PINNED_LEGACY_END = '// --- END AUTO-GENERATED PINNED LEGACY ---' + +function fetchJson(url) { + return new Promise((resolve, reject) => { + https + .get(url, { headers: { 'User-Agent': 'cowswap-widget-sdk-versions-updater' } }, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + fetchJson(response.headers.location).then(resolve, reject) + return + } + + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode} from ${url}`)) + return + } + + let data = '' + response.on('data', (chunk) => { + data += chunk + }) + response.on('end', () => { + try { + resolve(JSON.parse(data)) + } catch (error) { + reject(error) + } + }) + }) + .on('error', reject) + }) +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function replaceAutoGeneratedBlock(filePath, beginMarker, endMarker, content) { + const source = fs.readFileSync(filePath, 'utf8') + const pattern = new RegExp(`${escapeRegExp(beginMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`, 'm') + + if (!pattern.test(source)) { + throw new Error(`Missing auto-generated block in ${filePath}`) + } + + fs.writeFileSync(filePath, source.replace(pattern, content.trim())) +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function stripSemverRange(versionRange) { + return versionRange.replace(/^[\^~>=<]+/, '') +} + +async function getNpmPackageVersion(packageName, versionTag = 'latest') { + const url = `https://registry.npmjs.org/${packageName}/${versionTag}` + const data = await fetchJson(url) + return data.version +} + +async function getWidgetLibVersionForWidgetReact(widgetReactVersion) { + const url = `https://registry.npmjs.org/@cowprotocol/widget-react/${widgetReactVersion}` + const data = await fetchJson(url) + const widgetLibRange = data.dependencies?.['@cowprotocol/widget-lib'] + + if (!widgetLibRange) { + throw new Error(`Could not resolve @cowprotocol/widget-lib for @cowprotocol/widget-react@${widgetReactVersion}`) + } + + return stripSemverRange(widgetLibRange) +} + +function getLegacyPinnedVersions(constantsSource) { + const legacySection = constantsSource.split(PINNED_LEGACY_BEGIN)[1]?.split(PINNED_LEGACY_END)[0] ?? '' + + return [...legacySection.matchAll(/'(\d+\.\d+\.\d+)': \{/g)].map((match) => match[1]) +} + +function buildConstantsBlock({ + localWidgetReactVersion, + localWidgetLibVersion, + npmWidgetReactLatestVersion, + npmWidgetLibLatestVersion, +}) { + return `${AUTO_GENERATED_BEGIN} +/** Workspace widget-react and widget-lib versions (keep in sync with libs package.json files). */ +export const LOCAL_WIDGET_REACT_VERSION = '${localWidgetReactVersion}' as const +export const LOCAL_WIDGET_LIB_VERSION = '${localWidgetLibVersion}' as const + +/** Latest published @cowprotocol/widget-react on npm. */ +export const NPM_WIDGET_REACT_LATEST_VERSION = '${npmWidgetReactLatestVersion}' as const + +/** @cowprotocol/widget-lib version bundled by {@link NPM_WIDGET_REACT_LATEST_VERSION} on npm. */ +export const NPM_WIDGET_LIB_LATEST_VERSION = '${npmWidgetLibLatestVersion}' as const +${AUTO_GENERATED_END}` +} + +function buildPinnedLegacyBlock(legacyVersions, widgetLibVersionsByVersion) { + const entries = legacyVersions + .map( + (version) => ` '${version}': { + widgetReactVersion: '${version}', + widgetLibVersion: '${widgetLibVersionsByVersion[version]}', + },`, + ) + .join('\n') + + return `${PINNED_LEGACY_BEGIN} +/** Legacy pinned npm releases and their widget-lib versions. */ +export const PINNED_LEGACY_WIDGET_SDK_META = { +${entries} +} as const +${PINNED_LEGACY_END}` +} + +function buildLoaderEntry(version) { + const packageName = `widget-react-v${version}` + return ` '${version}': lazy(() => + import('${packageName}').then((module) => ({ + default: module.CowSwapWidget as CowSwapWidgetComponent, + })), + ),` +} + +function buildLoadersBlock(pinnedVersions) { + const entries = pinnedVersions.map(buildLoaderEntry).join('\n') + + return `${AUTO_GENERATED_BEGIN} +export const LAZY_WIDGETS_BY_VERSION: Record> = { +${entries} +} +${AUTO_GENERATED_END}` +} + +function buildModuleDeclaration(version) { + const packageName = `widget-react-v${version}` + return `declare module '${packageName}' { + import type { ComponentType } from 'react' + + import type { CowSwapWidgetProps } from '@cowprotocol/widget-lib' + + export const CowSwapWidget: ComponentType +}` +} + +function syncDeclarations(pinnedVersions) { + const moduleDeclarations = pinnedVersions.map(buildModuleDeclaration).join('\n\n') + + fs.writeFileSync(PATHS.declarations, `declare module '*.woff2'\n\n${moduleDeclarations}\n`) +} + +function syncPackageJsonAliases(pinnedVersions) { + const packageJson = readJson(PATHS.packageJson) + const dependencies = packageJson.dependencies ?? {} + + for (const key of Object.keys(dependencies)) { + if (key.startsWith('widget-react-v')) { + delete dependencies[key] + } + } + + for (const version of pinnedVersions) { + dependencies[`widget-react-v${version}`] = `npm:@cowprotocol/widget-react@${version}` + } + + packageJson.dependencies = dependencies + fs.writeFileSync(PATHS.packageJson, `${JSON.stringify(packageJson, null, 2)}\n`) +} + +async function main() { + const constantsSource = fs.readFileSync(PATHS.constants, 'utf8') + const legacyVersions = getLegacyPinnedVersions(constantsSource) + + const localWidgetReactVersion = readJson(PATHS.widgetReactPkg).version + const localWidgetLibVersion = readJson(PATHS.widgetLibPkg).version + const npmWidgetReactLatestVersion = await getNpmPackageVersion('@cowprotocol/widget-react') + const npmWidgetLibLatestVersion = await getWidgetLibVersionForWidgetReact(npmWidgetReactLatestVersion) + + const widgetLibVersionsByVersion = {} + for (const version of legacyVersions) { + widgetLibVersionsByVersion[version] = await getWidgetLibVersionForWidgetReact(version) + } + + const pinnedVersions = [ + npmWidgetReactLatestVersion, + ...legacyVersions.filter((version) => version !== npmWidgetReactLatestVersion), + ] + + console.log('Updating widget SDK versions:') + console.log(` local widget-react: ${localWidgetReactVersion}`) + console.log(` local widget-lib: ${localWidgetLibVersion}`) + console.log(` npm latest react: ${npmWidgetReactLatestVersion}`) + console.log(` npm latest lib: ${npmWidgetLibLatestVersion}`) + + replaceAutoGeneratedBlock( + PATHS.constants, + AUTO_GENERATED_BEGIN, + AUTO_GENERATED_END, + buildConstantsBlock({ + localWidgetReactVersion, + localWidgetLibVersion, + npmWidgetReactLatestVersion, + npmWidgetLibLatestVersion, + }), + ) + + replaceAutoGeneratedBlock( + PATHS.constants, + PINNED_LEGACY_BEGIN, + PINNED_LEGACY_END, + buildPinnedLegacyBlock(legacyVersions, widgetLibVersionsByVersion), + ) + + replaceAutoGeneratedBlock( + PATHS.loaders, + AUTO_GENERATED_BEGIN, + AUTO_GENERATED_END, + buildLoadersBlock(pinnedVersions), + ) + + syncDeclarations(pinnedVersions) + syncPackageJsonAliases(pinnedVersions) + + console.log('\nUpdated widget-sdk-versions.constants.ts, loaders, declarations.d.ts, and package.json.') + console.log('Run `pnpm install` to refresh lockfile aliases.') +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/widget-configurator/src/app/configurator/consts.ts b/apps/widget-configurator/src/app/configurator/consts.ts deleted file mode 100644 index 7b11b4136d6..00000000000 --- a/apps/widget-configurator/src/app/configurator/consts.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { COW_CDN } from '@cowprotocol/common-const' -import { CowWidgetEventListeners, CowWidgetEvents, ToastMessageType } from '@cowprotocol/events' -import { CowSwapWidgetPaletteParams, TokenInfo, TradeType, WidgetHookEvents } from '@cowprotocol/widget-lib' - -import { TokenListItem } from './types' - -// CoW DAO addresses - -export const TRADE_MODES = [TradeType.SWAP, TradeType.LIMIT, TradeType.ADVANCED, TradeType.YIELD] - -export const WIDGET_HOOKS = Object.values(WidgetHookEvents) - -// Sourced from https://tokenlists.org/ -export const DEFAULT_TOKEN_LISTS: TokenListItem[] = [ - { url: `${COW_CDN}/tokens/CowSwap.json`, enabled: true, enabledForSell: false, enabledForBuy: false }, - { url: `${COW_CDN}/token-lists/CoinGecko.1.json`, enabled: true, enabledForSell: false, enabledForBuy: false }, - { - url: 'https://wispy-bird-88a7.uniswap.workers.dev/?url=http://stablecoin.cmc.eth.link', - enabled: false, - enabledForSell: false, - enabledForBuy: false, - }, - { url: 'https://www.gemini.com/uniswap/manifest.json', enabled: false, enabledForSell: false, enabledForBuy: false }, - { url: 'https://messari.io/tokenlist/messari-verified', enabled: false, enabledForSell: false, enabledForBuy: false }, - { - url: 'https://static.optimism.io/optimism.tokenlist.json', - enabled: false, - enabledForSell: false, - enabledForBuy: false, - }, - { url: 'https://app.tryroll.com/tokens.json', enabled: false, enabledForSell: false, enabledForBuy: false }, - { url: 'https://ipfs.io/ipns/tokens.uniswap.org', enabled: false, enabledForSell: false, enabledForBuy: false }, -] -// TODO: Move default palette to a new lib that only exposes the palette colors. -// This way it can be consumed by both the configurator and the widget. -export const DEFAULT_LIGHT_PALETTE: CowSwapWidgetPaletteParams = { - primary: '#052b65', - background: '#FFFFFF', - paper: '#FFFFFF', - text: '#052B65', - danger: '#D41300', - warning: '#F8D06B', - alert: '#DB971E', - info: '#0d5ed9', - success: '#007B28', -} - -export const DEFAULT_DARK_PALETTE: CowSwapWidgetPaletteParams = { - primary: '#0d5ed9', - background: '#303030', - paper: '#0c264b', - text: '#CAE9FF', - danger: '#f44336', - warning: '#F8D06B', - alert: '#DB971E', - info: '#428dff', - success: '#00D897', -} - -export const COW_LISTENERS: CowWidgetEventListeners = [ - { - event: CowWidgetEvents.ON_TOAST_MESSAGE, - handler: (event) => { - // You cn implement a more complex way to handle toast messages - switch (event.messageType) { - case ToastMessageType.SWAP_ETH_FLOW_SENT_TX: - console.info('[configurator:ON_TOAST_MESSAGE:complex] 🍞 New eth flow order. Tx:', event.data.tx) - break - case ToastMessageType.ORDER_CREATED: - console.info('[configurator:ON_TOAST_MESSAGE:complex] 🍞 Posted order', event.data.orderUid) - break - // ... and so on - default: - console.info('[configurator:ON_TOAST_MESSAGE:complex] 🍞 Default', event.message, event.data) - } - }, - }, - - { - event: CowWidgetEvents.ON_POSTED_ORDER, - handler: (event) => console.log('[configurator:ON_POSTED_ORDER] 💌 Posted order:', event.orderUid), - }, - - { - event: CowWidgetEvents.ON_CANCELLED_ORDER, - handler: (event) => - console.log( - `[configurator:ON_CANCELLED_ORDER] ❌ Cancelled order ${event.order.uid}. Transaction hash: ${event.transactionHash}`, - ), - }, - - { - event: CowWidgetEvents.ON_FULFILLED_ORDER, - handler: (event) => console.log(`[configurator:ON_FULFILLED_ORDER] ✅ Executed order ${event.order.uid}`), - }, - - { - event: CowWidgetEvents.ON_CHANGE_TRADE_PARAMS, - handler: (event) => console.log(`[configurator:ON_TRADE_PARAMS] ✅ Trade params:`, event), - }, - - { - event: CowWidgetEvents.ON_BRIDGING_SUCCESS, - handler: (event) => console.log(`[configurator:ON_BRIDGING_SUCCESS] ✅ Bridging params:`, event), - }, -] - -export const DEFAULT_CUSTOM_TOKENS: TokenInfo[] = [ - { - chainId: 1, - address: '0x69D29F1b0cC37d8d3B61583c99Ad0ab926142069', - name: 'ƎԀƎԀ', - decimals: 9, - symbol: 'ƎԀƎԀ', - logoURI: 'https://assets.coingecko.com/coins/images/31948/large/photo_2023-09-25_14-05-49.jpg?1696530754', - }, - { - chainId: 1, - address: '0x9F9643209dCCe8D7399D7BF932354768069Ebc64', - name: 'Invest Club Global', - decimals: 18, - symbol: 'ICG', - logoURI: 'https://assets.coingecko.com/coins/images/34316/large/thatone_200%281%29.png?1704621005', - }, -] - -export const IS_IFRAME = window.self !== window.top diff --git a/apps/widget-configurator/src/app/configurator/controls/AddCustomListDialog.tsx b/apps/widget-configurator/src/app/configurator/controls/AddCustomListDialog.tsx deleted file mode 100644 index ffca2bdc71c..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/AddCustomListDialog.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { ReactNode, useEffect, useRef, useState } from 'react' - -import { isValidTokenListSource } from '@cowprotocol/common-utils' -import { Command, TokenInfo } from '@cowprotocol/types' - -import { - Box, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - FormHelperText, - Tab, - TextField, -} from '@mui/material' -import Tabs from '@mui/material/Tabs' - -import { DEFAULT_CUSTOM_TOKENS } from '../consts' -import { parseCustomTokensInput } from '../utils/parseCustomTokensInput' - -const jsonTextAreaStyles = { - fontFamily: 'monospace', - width: '100%', - height: '200px', - resize: 'none', - marginTop: '10px', -} - -type AddCustomListDialogProps = { - open: boolean - onClose: Command - customTokens: TokenInfo[] - onAddListUrl: (newListUrl: string) => void - onAddCustomTokens: (tokens: TokenInfo[]) => void -} - -// TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// eslint-disable-next-line max-lines-per-function, @typescript-eslint/explicit-function-return-type -export function AddCustomListDialog({ - open, - onClose, - onAddListUrl, - onAddCustomTokens, - customTokens: customTokensDefault, -}: AddCustomListDialogProps) { - const [customListUrl, setCustomListUrl] = useState('') - const [hasErrors, setHasErrors] = useState(false) - const [hasJsonErrors, setHasJsonErrors] = useState(false) - const textareaRef = useRef(null) - - const [customTokens, setCustomTokens] = useState([]) - - const [tabIndex, setTabIndex] = useState(0) - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const resetForm = () => { - // Reset custom URL - setCustomListUrl('') - setHasErrors(false) - - // Reset custom tokens - setCustomTokens([]) - setHasJsonErrors(false) - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { - setTabIndex(newValue) - resetForm() - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleUrlInputChange = (e: React.ChangeEvent) => { - const value = e.target.value - - setCustomListUrl(value) - - setHasErrors(value ? !isValidTokenListSource(value) : false) - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleJsonInputChange = (e: React.ChangeEvent) => { - setHasJsonErrors(false) - - if (!e.target.value) { - setCustomTokens([]) - return - } - - try { - const parsedTokens = parseCustomTokensInput(e.target.value) - - if (parsedTokens) { - setCustomTokens(parsedTokens) - } else { - setHasJsonErrors(true) - } - } catch { - setHasJsonErrors(true) - } - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleSubmit = () => { - if (customListUrl) { - onAddListUrl(customListUrl) - resetForm() - } else if (customTokens.length) { - onAddCustomTokens(customTokens) - } - - onClose() - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const addJsonExample = () => { - if (textareaRef.current) { - textareaRef.current.value = JSON.stringify(DEFAULT_CUSTOM_TOKENS, null, 2) - } - setCustomTokens(DEFAULT_CUSTOM_TOKENS) - setHasJsonErrors(false) - } - - useEffect(() => { - if (customTokensDefault.length) { - setCustomTokens(customTokensDefault) - } - }, [customTokensDefault]) - - return ( - - Add Custom Token List - - - - - - - - - - - - - - {hasJsonErrors && Enter a token array or token list JSON} - - - - - - - - ) -} - -interface TabPanelProps { - children?: ReactNode - index: number - value: number -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function CustomTabPanel(props: TabPanelProps) { - const { children, value, index, ...other } = props - - return ( - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/CurrencyInputControl.tsx b/apps/widget-configurator/src/app/configurator/controls/CurrencyInputControl.tsx deleted file mode 100644 index 773a4c5c104..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/CurrencyInputControl.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { ChangeEvent, Dispatch, SetStateAction } from 'react' - -import Autocomplete from '@mui/material/Autocomplete' -import TextField from '@mui/material/TextField' - -const TokenOptions = ['COW', 'USDC', 'WBTC'] - -export interface CurrencyInputProps { - label: string - tokenIdState: [string, Dispatch>] - tokenAmountState: [number, Dispatch>] -} -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function CurrencyInputControl(props: CurrencyInputProps) { - const { tokenIdState, tokenAmountState, label } = props - const [tokenId, setTokenId] = tokenIdState - const [amount, setAmount] = tokenAmountState - - return ( - <> - , newValue: string | null) => { - setTokenId(newValue || '') - }} - inputValue={tokenId || ''} - onInputChange={(event: ChangeEvent, newInputValue: string) => { - setTokenId(newInputValue || '') - }} - id={'selectTokenId' + label} - options={TokenOptions} - size="small" - renderInput={(params) => } - /> - - ) => setAmount(Number(e.target.value || 0))} - size="small" - /> - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/CurrentTradeTypeControl.tsx b/apps/widget-configurator/src/app/configurator/controls/CurrentTradeTypeControl.tsx deleted file mode 100644 index 92673ac6211..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/CurrentTradeTypeControl.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Dispatch, SetStateAction } from 'react' - -import type { TradeType } from '@cowprotocol/widget-lib' - -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' - -import { TRADE_MODES } from '../consts' - -const LABEL = 'Current trade type' - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function CurrentTradeTypeControl({ state }: { state: [TradeType, Dispatch>] }) { - const [tradeType, setTradeType] = state - - return ( - - {LABEL} - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/CustomImagesControl.tsx b/apps/widget-configurator/src/app/configurator/controls/CustomImagesControl.tsx deleted file mode 100644 index e0733bcbd86..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/CustomImagesControl.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ChangeEvent, Dispatch, SetStateAction, useCallback } from 'react' - -import { CowSwapWidgetParams } from '@cowprotocol/widget-lib' - -import TextField from '@mui/material/TextField' - -type CustomImages = CowSwapWidgetParams['images'] - -export interface CustomImagesControlProps { - state: [CustomImages, Dispatch>] -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function CustomImagesControl({ state }: CustomImagesControlProps) { - const [customImages, setCustomImages] = state - - const updateEmptyOrdersImage = useCallback( - (e: ChangeEvent) => { - setCustomImages((prevState) => ({ ...prevState, emptyOrders: e.target.value || '' })) - }, - [setCustomImages], - ) - - return ( -
- -
- ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/CustomSoundsControl.tsx b/apps/widget-configurator/src/app/configurator/controls/CustomSoundsControl.tsx deleted file mode 100644 index e9e32f8b6f9..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/CustomSoundsControl.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { ChangeEvent, Dispatch, SetStateAction, useCallback } from 'react' - -import { CowSwapWidgetParams } from '@cowprotocol/widget-lib' - -import TextField from '@mui/material/TextField' - -type CustomSounds = CowSwapWidgetParams['sounds'] -type WidgetSounds = keyof NonNullable - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const valueNullAsString = (value: string | undefined | null) => (value === null ? 'null' : value || '') - -export interface CustomSoundsControlProps { - state: [CustomSounds, Dispatch>] -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function CustomSoundsControl({ state }: CustomSoundsControlProps) { - const [customSound, setCustomSounds] = state - - const updateSoundCallback = useCallback( - (type: WidgetSounds) => { - return (e: ChangeEvent) => { - const value = e.target.value - setCustomSounds((prevState) => ({ ...prevState, [type]: value === 'null' ? null : value || '' })) - } - }, - [setCustomSounds], - ) - - return ( -
- - - -
- ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/DeadlineControl.tsx b/apps/widget-configurator/src/app/configurator/controls/DeadlineControl.tsx deleted file mode 100644 index 5217694e23c..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/DeadlineControl.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Dispatch, SetStateAction } from 'react' - -import { FormControl, TextField } from '@mui/material' - -export type DeadlineControlProps = { - label: string - deadlineState: [number | undefined, Dispatch>] -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function DeadlineControl({ label, deadlineState: [state, setState] }: DeadlineControlProps) { - return ( - - setState(value && !isNaN(+value) ? Math.max(1, Number(value)) : undefined)} - size="small" - inputProps={{ min: 1 }} // Set minimum value to 1 - /> - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/LocaleControl.tsx b/apps/widget-configurator/src/app/configurator/controls/LocaleControl.tsx deleted file mode 100644 index f442b99407f..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/LocaleControl.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Dispatch, ReactNode, SetStateAction } from 'react' - -import { LOCALE_DISPLAY_NAMES, SupportedLocale, SUPPORTED_LOCALES } from '@cowprotocol/common-const' - -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' - -const LABEL = 'Forced locale' - -type LocaleControlState = [SupportedLocale | '', Dispatch>] - -export function LocaleControl({ state }: { state: LocaleControlState }): ReactNode { - const [locale, setLocale] = state - - return ( - - {LABEL} - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/NetworkControl.tsx b/apps/widget-configurator/src/app/configurator/controls/NetworkControl.tsx deleted file mode 100644 index 76e7258924f..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/NetworkControl.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Dispatch, SetStateAction } from 'react' - -import { CHAIN_INFO } from '@cowprotocol/common-const' -import { isChainDeprecated, SupportedChainId } from '@cowprotocol/cow-sdk' - -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' - -export type NetworkOption = { - chainId: SupportedChainId - label: string -} - -export const NetworkOptions: NetworkOption[] = Object.keys(CHAIN_INFO).map((key) => { - const chainId = +key as SupportedChainId - return { chainId, label: CHAIN_INFO[chainId].label } -}) - -const DEFAULT_CHAIN_ID = NetworkOptions[0].chainId - -const LABEL = 'Network' - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const getNetworkOption = (chainId: SupportedChainId) => NetworkOptions.find((item) => item.chainId === chainId) - -type NetworkControlProps = { - standaloneMode: boolean - state: [NetworkOption, Dispatch>] - availableChains: SupportedChainId[] -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function NetworkControl({ state, standaloneMode, availableChains }: NetworkControlProps) { - const [network, setNetwork] = state - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const switchNetwork = (chainId: number) => { - const targetChainId = chainId || DEFAULT_CHAIN_ID - const targetNetwork = getNetworkOption(targetChainId) - - if (targetNetwork) { - setNetwork(targetNetwork) - } - } - - return ( - - {LABEL} - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/PaletteControl.tsx b/apps/widget-configurator/src/app/configurator/controls/PaletteControl.tsx deleted file mode 100644 index 4f90250da9f..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/PaletteControl.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' - -import { FormControl, Button, Collapse } from '@mui/material' -import { MuiColorInput } from 'mui-color-input' - -import { ColorPaletteManager } from '../hooks/useColorPaletteManager' -import { ColorPalette } from '../types' - -const visibleColorKeys: Array = ['primary', 'paper', 'text'] - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function PaletteControl({ paletteManager }: { paletteManager: ColorPaletteManager }) { - const { colorPalette, resetColorPalette } = paletteManager - - const otherColorKeys = Object.keys(colorPalette).filter( - (key): key is keyof ColorPalette => !visibleColorKeys.includes(key as keyof ColorPalette), - ) - - const [expanded, setExpanded] = React.useState(false) - - return ( -
- {visibleColorKeys.map((key) => ( - - - - ))} - - - {otherColorKeys.map((colorKey) => ( - - - - ))} - - - - -
- ) -} - -interface ColorInputProps { - paletteManager: ColorPaletteManager - colorKey: keyof ColorPalette -} - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function ColorInput({ colorKey, paletteManager }: ColorInputProps) { - const { colorPalette, setColorPalette, defaultPalette } = paletteManager - // Use the custom color or fallback to the default color - const colorValue = colorPalette[colorKey] || defaultPalette[colorKey] - - const handleColorChange = (colorKey: keyof ColorPalette) => (newValue: string) => { - setColorPalette((prevPalette) => ({ ...prevPalette, [colorKey]: newValue })) - } - - return ( - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/PartnerFeeControl.tsx b/apps/widget-configurator/src/app/configurator/controls/PartnerFeeControl.tsx deleted file mode 100644 index f9dbdabc481..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/PartnerFeeControl.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { ChangeEvent, Dispatch, SetStateAction, useCallback } from 'react' - -import TextField from '@mui/material/TextField' - -export interface PartnerFeeControlProps { - feeBpsState: [number, Dispatch>] -} -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function PartnerFeeControl(props: PartnerFeeControlProps) { - const { feeBpsState } = props - const [feeBps, setFeeBps] = feeBpsState - - const onFeeBpsChange = useCallback( - (e: ChangeEvent) => { - const value = Math.ceil(Number(e.target.value || 0)) - - setFeeBps(value) - }, - [setFeeBps], - ) - - return ( - <> - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/ThemeControl.tsx b/apps/widget-configurator/src/app/configurator/controls/ThemeControl.tsx deleted file mode 100644 index 93d39feb2fc..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/ThemeControl.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useContext, useState } from 'react' - -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select, { SelectChangeEvent } from '@mui/material/Select' - -import { ColorModeContext } from '../../../theme/ColorModeContext' - -const AUTO = 'auto' - -const ThemeOptions = [ - { label: 'Auto', value: AUTO }, - { label: 'Light', value: 'light' }, - { label: 'Dark', value: 'dark' }, -] - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function ThemeControl() { - const { mode, toggleColorMode, setAutoMode } = useContext(ColorModeContext) - const [isAutoMode, setIsAutoMode] = useState(false) - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleThemeChange = (event: SelectChangeEvent) => { - const selectedTheme = event.target.value - if (selectedTheme === AUTO) { - setAutoMode() - setIsAutoMode(true) - } else { - toggleColorMode() - setIsAutoMode(false) - } - } - - return ( - - Theme - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/TokenListControl.tsx b/apps/widget-configurator/src/app/configurator/controls/TokenListControl.tsx deleted file mode 100644 index c853ae6601c..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/TokenListControl.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { Dispatch, ReactNode, SetStateAction, useCallback, useMemo, useState } from 'react' - -import { TokenInfo } from '@cowprotocol/types' - -import { - Box, - Button, - Checkbox, - Chip, - FormControl, - InputLabel, - ListItemText, - MenuItem, - OutlinedInput, - Select, - SelectChangeEvent, -} from '@mui/material' - -import { AddCustomListDialog } from './AddCustomListDialog' - -import { TokenListItem } from '../types' - -const ITEM_HEIGHT = 48 -const ITEM_PADDING_TOP = 8 -const MENU_PROPS = { - PaperProps: { - style: { - maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, - width: 250, - }, - }, -} - -type TokenListScope = 'enabled' | 'enabledForSell' | 'enabledForBuy' - -type TokenListControlProps = { - tokenListUrlsState: [TokenListItem[], Dispatch>] - customTokensState: [TokenInfo[], Dispatch>] -} - -interface TokenListSelectProps { - label: string - labelId: string - selectedUrls: string[] - options: ReactNode - onChange(event: SelectChangeEvent): void -} - -interface TokenListSelectionsProps { - tokenListUrls: TokenListItem[] - onChangeByScope: Record) => void> -} - -const TOKEN_LIST_SELECT_CONFIG: { label: string; labelId: string; scope: TokenListScope }[] = [ - { label: 'Active Token Lists', labelId: 'token-list-chip-label', scope: 'enabled' }, - { label: 'Sell Token Lists', labelId: 'sell-token-list-chip-label', scope: 'enabledForSell' }, - { label: 'Buy Token Lists', labelId: 'buy-token-list-chip-label', scope: 'enabledForBuy' }, -] - -const getSelectedTokenListUrls = (tokenListUrls: TokenListItem[], scope: TokenListScope): string[] => { - return tokenListUrls.filter((list) => list[scope]).map((list) => list.url) -} - -const getTokenListOptions = (tokenListUrls: TokenListItem[], scope: TokenListScope): ReactNode[] => { - return [...tokenListUrls] - .sort((a, b) => { - if (a[scope] === b[scope]) { - return a.url.localeCompare(b.url) - } - - return a[scope] ? -1 : 1 - }) - .map((list) => ( - - - - - )) -} - -function TokenListSelect({ label, labelId, selectedUrls, options, onChange }: TokenListSelectProps): ReactNode { - return ( - - {label} - - - ) -} - -function TokenListSelections({ tokenListUrls, onChangeByScope }: TokenListSelectionsProps): ReactNode { - return ( - - {TOKEN_LIST_SELECT_CONFIG.map(({ label, labelId, scope }) => ( - - ))} - - ) -} - -export const TokenListControl = ({ tokenListUrlsState, customTokensState }: TokenListControlProps): ReactNode => { - const [tokenListUrls, setTokenListUrls] = tokenListUrlsState - const [customTokens, setCustomTokens] = customTokensState - const [dialogOpen, setDialogOpen] = useState(false) - - const setTokenListScope = useCallback( - (scope: TokenListScope, selectedUrls: string[]) => { - setTokenListUrls((prev) => prev.map((list) => ({ ...list, [scope]: selectedUrls.includes(list.url) }))) - }, - [setTokenListUrls], - ) - - const onChangeByScope = useMemo( - () => ({ - enabled: (event: SelectChangeEvent) => setTokenListScope('enabled', event.target.value as string[]), - enabledForSell: (event: SelectChangeEvent) => - setTokenListScope('enabledForSell', event.target.value as string[]), - enabledForBuy: (event: SelectChangeEvent) => - setTokenListScope('enabledForBuy', event.target.value as string[]), - }), - [setTokenListScope], - ) - - const handleAddListUrl = useCallback( - (newListUrl: string) => { - const existing = tokenListUrls.find((list) => list.url.toLowerCase() === newListUrl.toLowerCase()) - - if (existing) return - - setTokenListUrls((prev) => [ - ...prev, - { url: newListUrl, enabled: true, enabledForSell: false, enabledForBuy: false }, - ]) - }, - [tokenListUrls, setTokenListUrls], - ) - - return ( - <> - - - - setDialogOpen(false)} - customTokens={customTokens} - onAddListUrl={handleAddListUrl} - onAddCustomTokens={setCustomTokens} - /> - - - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/TradeModesControl.tsx b/apps/widget-configurator/src/app/configurator/controls/TradeModesControl.tsx deleted file mode 100644 index f7c81e53886..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/TradeModesControl.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Dispatch, ReactNode, SetStateAction } from 'react' - -import { TradeType } from '@cowprotocol/widget-lib' - -import Checkbox from '@mui/material/Checkbox' -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import ListItemText from '@mui/material/ListItemText' -import MenuItem from '@mui/material/MenuItem' -import OutlinedInput from '@mui/material/OutlinedInput' -import Select, { SelectChangeEvent } from '@mui/material/Select' - -const LABEL = 'Trade types' - -export function TradeModesControl({ - state, -}: { - state: [TradeType[], Dispatch>] -}): ReactNode { - const [tradeModes, setTradeModes] = state - - const handleTradeModeChange = (event: SelectChangeEvent): void => { - const value = event.target.value - const nextTradeModes = typeof value === 'string' ? (value.split(',') as TradeType[]) : value - - if (!nextTradeModes.length) return - - setTradeModes(nextTradeModes) - } - - return ( - - {LABEL} - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/controls/WidgetHooksControl.tsx b/apps/widget-configurator/src/app/configurator/controls/WidgetHooksControl.tsx deleted file mode 100644 index 1f94baabe31..00000000000 --- a/apps/widget-configurator/src/app/configurator/controls/WidgetHooksControl.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Dispatch, ReactNode, SetStateAction } from 'react' - -import { WidgetHookEvents } from '@cowprotocol/widget-lib' - -import Checkbox from '@mui/material/Checkbox' -import FormControl from '@mui/material/FormControl' -import InputLabel from '@mui/material/InputLabel' -import ListItemText from '@mui/material/ListItemText' -import MenuItem from '@mui/material/MenuItem' -import OutlinedInput from '@mui/material/OutlinedInput' -import Select, { SelectChangeEvent } from '@mui/material/Select' - -import { WIDGET_HOOKS } from '../consts' - -const LABEL = 'Widget hooks' - -export function WidgetHooksControl({ - state, -}: { - state: [WidgetHookEvents[], Dispatch>] -}): ReactNode { - const [widgetHooks, setWidgetHooks] = state - - const handleChange = (event: SelectChangeEvent): void => { - setWidgetHooks(event.target.value as WidgetHookEvents[]) - } - - return ( - - {LABEL} - - - ) -} diff --git a/apps/widget-configurator/src/app/configurator/hooks/useColorPaletteManager.ts b/apps/widget-configurator/src/app/configurator/hooks/useColorPaletteManager.ts deleted file mode 100644 index c9682c5072f..00000000000 --- a/apps/widget-configurator/src/app/configurator/hooks/useColorPaletteManager.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' - -import { PaletteMode } from '@mui/material' - -import { DEFAULT_DARK_PALETTE, DEFAULT_LIGHT_PALETTE } from '../consts' -import { ColorPalette } from '../types' - -const LOCAL_STORAGE_KEY_NAME = 'COW_WIDGET_PALETTE_' - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const getCachedPalette = (mode: PaletteMode) => { - const cache = localStorage.getItem(`${LOCAL_STORAGE_KEY_NAME}${mode}`) - - return cache ? JSON.parse(cache) : null -} - -export interface ColorPaletteManager { - defaultPalette: ColorPalette - colorPalette: ColorPalette - setColorPalette: Dispatch> - resetColorPalette(): void -} - -export function useColorPaletteManager(mode: PaletteMode): ColorPaletteManager { - const defaultPalette = useMemo(() => { - return mode === 'dark' ? DEFAULT_DARK_PALETTE : DEFAULT_LIGHT_PALETTE - }, [mode]) - - const [colorPalette, updateColorPalette] = useState(getCachedPalette(mode) || defaultPalette) - - const persistPalette = useCallback( - (colorPalette: ColorPalette) => { - localStorage.setItem(`${LOCAL_STORAGE_KEY_NAME}${mode}`, JSON.stringify(colorPalette)) - }, - [mode], - ) - - const setColorPalette = useCallback( - (palette: ColorPalette | ((prevState: ColorPalette) => ColorPalette)) => { - const newPalette = typeof palette === 'function' ? palette(colorPalette) : palette - - updateColorPalette(newPalette) - persistPalette(newPalette) - }, - [colorPalette, persistPalette], - ) - - const resetColorPalette = useCallback(() => { - setColorPalette(defaultPalette) - }, [defaultPalette, setColorPalette]) - - // Restore palette from localStorage when mode changes - useEffect(() => { - const newPalette = getCachedPalette(mode) - - updateColorPalette(newPalette || defaultPalette) - }, [mode, defaultPalette]) - - return useMemo( - () => ({ defaultPalette, colorPalette, setColorPalette, resetColorPalette }), - [defaultPalette, colorPalette, setColorPalette, resetColorPalette], - ) -} diff --git a/apps/widget-configurator/src/app/configurator/hooks/useEmbedDialogState.ts b/apps/widget-configurator/src/app/configurator/hooks/useEmbedDialogState.ts deleted file mode 100644 index 0274b82b0ff..00000000000 --- a/apps/widget-configurator/src/app/configurator/hooks/useEmbedDialogState.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMemo, useState } from 'react' - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useEmbedDialogState(initialOpen = false) { - const [open, setOpen] = useState(initialOpen) - - return useMemo(() => { - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleOpen = () => setOpen(true) - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleClose = () => setOpen(false) - - return { - dialogOpen: open, - handleDialogClose: handleClose, - handleDialogOpen: handleOpen, - } - }, [open]) -} diff --git a/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts b/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts deleted file mode 100644 index 78113e2f889..00000000000 --- a/apps/widget-configurator/src/app/configurator/hooks/useWidgetParamsAndSettings.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { useMemo } from 'react' - -import { CowSwapWidgetParams, TradeType, WidgetHookEvents } from '@cowprotocol/widget-lib' - -import { isDev, isLocalHost, isVercel } from '../../../env' -import { ConfiguratorState } from '../types' - -const vercelSuffix = '-cowswap-dev.vercel.app' - -const getBaseUrl = (): string => { - if (typeof window === 'undefined' || !window) return '' - - if (isLocalHost) return 'http://localhost:3000' - - if (isDev) return 'https://dev.swap.cow.fi' - - if (isVercel) { - const prKey = window.location.hostname.replace('widget-configurator-git-', '').replace(vercelSuffix, '') - - return `https://swap-dev-git-${prKey}${vercelSuffix}` - } - - return 'https://swap.cow.fi' -} - -const DEFAULT_BASE_URL = getBaseUrl() - -const getTokenListsParam = ( - tokenListUrls: ConfiguratorState['tokenListUrls'], - key: 'enabled' | 'enabledForSell' | 'enabledForBuy', -): string[] => { - return tokenListUrls.filter((list) => list[key]).map((list) => list.url) -} - -const getForcedOrderDeadline = ({ - deadline, - swapDeadline, - limitDeadline, - advancedDeadline, -}: Pick< - ConfiguratorState, - 'deadline' | 'swapDeadline' | 'limitDeadline' | 'advancedDeadline' ->): CowSwapWidgetParams['forcedOrderDeadline'] => { - if (!swapDeadline && !limitDeadline && !advancedDeadline) { - return deadline - } - - return { - [TradeType.SWAP]: swapDeadline, - [TradeType.LIMIT]: limitDeadline, - [TradeType.ADVANCED]: advancedDeadline, - } -} - -function confirmWidgetHookAction(message: string): boolean { - return prompt(message) === 'ok' -} - -function getWidgetHooks(enabledWidgetHooks: WidgetHookEvents[]): CowSwapWidgetParams['hooks'] { - const hooks: CowSwapWidgetParams['hooks'] = { - ...(enabledWidgetHooks.includes(WidgetHookEvents.ON_BEFORE_APPROVAL) - ? { - onBeforeApproval(payload) { - console.log('[COW][HOOKS] onBeforeApproval', payload) - return confirmWidgetHookAction(`Type "ok" to proceed with approval on chainId ${payload.chainId}`) - }, - } - : null), - ...(enabledWidgetHooks.includes(WidgetHookEvents.ON_BEFORE_TRADE) - ? { - onBeforeTrade(payload) { - const sellToken = payload.sellToken?.symbol || 'unknown' - const buyToken = payload.buyToken?.symbol || 'unknown' - - console.log('[COW][HOOKS] onBeforeTrade', payload) - return confirmWidgetHookAction( - `Type "ok" to proceed with ${payload.orderType} trade ${sellToken} -> ${buyToken}`, - ) - }, - } - : null), - ...(enabledWidgetHooks.includes(WidgetHookEvents.ON_BEFORE_WRAP_UNWRAP) - ? { - onBeforeWrapOrUnwrap(payload) { - const sellToken = payload.sellToken?.symbol || 'unknown' - const buyToken = payload.buyToken?.symbol || 'unknown' - - console.log('[COW][HOOKS] onBeforeWrapOrUnwrap', payload) - return confirmWidgetHookAction(`Type "ok" to proceed with wrap/unwrap ${sellToken} -> ${buyToken}`) - }, - } - : null), - ...(enabledWidgetHooks.includes(WidgetHookEvents.ON_BEFORE_ORDER_CANCEL) - ? { - onBeforeOrderCancel(payload) { - console.log('[COW][HOOKS] onBeforeOrderCancel', payload) - return confirmWidgetHookAction(`Type "ok" to cancel order ${payload.uid}`) - }, - } - : null), - ...(enabledWidgetHooks.includes(WidgetHookEvents.ON_BEFORE_ORDERS_CANCEL) - ? { - onBeforeOrdersCancel(payload) { - console.log('[COW][HOOKS] onBeforeOrdersCancel', payload) - return confirmWidgetHookAction(`Type "ok" to cancel ${payload.length} orders`) - }, - } - : null), - } - - return hooks -} - -function getThemeParam( - theme: ConfiguratorState['theme'], - customColors: ConfiguratorState['customColors'], - defaultColors: ConfiguratorState['defaultColors'], - boxShadow: ConfiguratorState['boxShadow'], -): CowSwapWidgetParams['theme'] { - if (JSON.stringify(customColors) === JSON.stringify(defaultColors) && !boxShadow) { - return theme - } - - const themeColors = { - ...defaultColors, - ...customColors, - } - - return { - baseTheme: theme, - primary: themeColors.primary, - background: themeColors.background, - paper: themeColors.paper, - text: themeColors.text, - danger: themeColors.danger, - warning: themeColors.warning, - alert: themeColors.alert, - info: themeColors.info, - success: themeColors.success, - ...(boxShadow ? { boxShadow } : null), - } -} - -export function useWidgetParams(configuratorState: ConfiguratorState): CowSwapWidgetParams { - return useMemo(() => { - const { - chainId, - locale, - theme, - boxShadow, - currentTradeType, - enabledTradeTypes, - sellToken, - sellTokenAmount, - buyToken, - buyTokenAmount, - deadline, - swapDeadline, - limitDeadline, - advancedDeadline, - tokenListUrls, - customColors, - defaultColors, - partnerFeeBps, - partnerFeeRecipient, - standaloneMode, - disableToastMessages, - disableProgressBar, - disablePostTradeTips, - disableCrossChainSwap, - disableTokenImport, - hideRecentTokens, - hideFavoriteTokens, - hideBridgeInfo, - hideOrdersTable, - disableTradeWhenPriceImpactIsUnknown, - disableTradeWhenPriceImpactIsHigherThan, - slippage, - enabledWidgetHooks, - } = configuratorState - - const params: CowSwapWidgetParams = { - appCode: 'CoW Widget: Configurator', - width: '100%', - height: '640px', - chainId, - locale, - tokenLists: getTokenListsParam(tokenListUrls, 'enabled'), - sellTokenLists: getTokenListsParam(tokenListUrls, 'enabledForSell'), - buyTokenLists: getTokenListsParam(tokenListUrls, 'enabledForBuy'), - baseUrl: DEFAULT_BASE_URL, - tradeType: currentTradeType, - sell: { asset: sellToken, amount: sellTokenAmount ? sellTokenAmount.toString() : undefined }, - buy: { asset: buyToken, amount: buyTokenAmount?.toString() }, - forcedOrderDeadline: getForcedOrderDeadline({ deadline, swapDeadline, limitDeadline, advancedDeadline }), - enabledTradeTypes, - theme: getThemeParam(theme, customColors, defaultColors, boxShadow), - standaloneMode, - disableToastMessages, - disableProgressBar, - disablePostTradeTips, - disableCrossChainSwap, - disableTokenImport, - hideRecentTokens, - hideFavoriteTokens, - - partnerFee: - partnerFeeBps > 0 - ? { - bps: partnerFeeBps, - recipient: partnerFeeRecipient, - } - : undefined, - hideBridgeInfo, - hideOrdersTable, - slippage, - disableTrade: { - whenPriceImpactIsUnknown: disableTradeWhenPriceImpactIsUnknown, - whenPriceImpactIsHigherThan: disableTradeWhenPriceImpactIsHigherThan, - }, - hooks: getWidgetHooks(enabledWidgetHooks), - } - - return params - }, [configuratorState]) -} diff --git a/apps/widget-configurator/src/app/configurator/index.tsx b/apps/widget-configurator/src/app/configurator/index.tsx deleted file mode 100644 index dcc81aafafa..00000000000 --- a/apps/widget-configurator/src/app/configurator/index.tsx +++ /dev/null @@ -1,616 +0,0 @@ -import { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from 'react' - -import { useCowAnalytics } from '@cowprotocol/analytics' -import { DEFAULT_PARTNER_FEE_RECIPIENT_PER_NETWORK, SupportedLocale } from '@cowprotocol/common-const' -import { useAvailableChains } from '@cowprotocol/common-hooks' -import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { CowWidgetEventListeners } from '@cowprotocol/events' -import { CowSwapWidgetParams, TokenInfo, TradeType, WidgetHookEvents } from '@cowprotocol/widget-lib' -import { CowSwapWidget } from '@cowprotocol/widget-react' - -import ChromeReaderModeIcon from '@mui/icons-material/ChromeReaderMode' -import CloseIcon from '@mui/icons-material/Close' -import CodeIcon from '@mui/icons-material/Code' -import EditIcon from '@mui/icons-material/Edit' -import KeyboardDoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeft' -import LanguageIcon from '@mui/icons-material/Language' -import { FormControl, FormControlLabel, FormLabel, IconButton, Radio, RadioGroup, Snackbar } from '@mui/material' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' -import Drawer from '@mui/material/Drawer' -import Fab from '@mui/material/Fab' -import List from '@mui/material/List' -import ListItemButton from '@mui/material/ListItemButton' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' -import TextField from '@mui/material/TextField' -import Typography from '@mui/material/Typography' -import { useAppKitTheme } from '@reown/appkit/react' -import { useConnection } from 'wagmi' - -import { COW_LISTENERS, DEFAULT_TOKEN_LISTS, TRADE_MODES } from './consts' -import { CurrencyInputControl } from './controls/CurrencyInputControl' -import { CurrentTradeTypeControl } from './controls/CurrentTradeTypeControl' -import { CustomImagesControl } from './controls/CustomImagesControl' -import { CustomSoundsControl } from './controls/CustomSoundsControl' -import { DeadlineControl } from './controls/DeadlineControl' -import { LocaleControl } from './controls/LocaleControl' -import { NetworkControl, NetworkOption, NetworkOptions } from './controls/NetworkControl' -import { PaletteControl } from './controls/PaletteControl' -import { PartnerFeeControl } from './controls/PartnerFeeControl' -import { ThemeControl } from './controls/ThemeControl' -import { TokenListControl } from './controls/TokenListControl' -import { TradeModesControl } from './controls/TradeModesControl' -import { WidgetHooksControl } from './controls/WidgetHooksControl' -import { useColorPaletteManager } from './hooks/useColorPaletteManager' -import { useEmbedDialogState } from './hooks/useEmbedDialogState' -import { useProvider } from './hooks/useProvider' -import { useSyncWidgetNetwork } from './hooks/useSyncWidgetNetwork' -import { useToastsManager } from './hooks/useToastsManager' -import { useWidgetParams } from './hooks/useWidgetParamsAndSettings' -import { ContentStyled, DrawerStyled, WalletConnectionWrapper, WrapperStyled } from './styled' -import { ConfiguratorState, TokenListItem } from './types' - -import { AnalyticsCategory } from '../../common/analytics/types' -import { ColorModeContext } from '../../theme/ColorModeContext' -import { EmbedDialog } from '../embedDialog' - -declare global { - interface Window { - cowSwapWidgetParams?: Partial - } -} - -const DEFAULT_STATE = { - sellToken: 'USDC', - buyToken: 'COW', - sellAmount: 100000, - buyAmount: 0, -} - -const UTM_PARAMS = 'utm_content=cow-widget-configurator&utm_medium=web&utm_source=widget.cow.fi' - -export type WidgetMode = 'dapp' | 'standalone' - -// TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// TODO: Reduce function complexity by extracting logic -// eslint-disable-next-line max-lines-per-function, @typescript-eslint/explicit-function-return-type -export function Configurator({ title }: { title: string }) { - const { setThemeMode } = useAppKitTheme() - const { chainId: walletChainId, isConnected } = useConnection() - const provider = useProvider() - const cowAnalytics = useCowAnalytics() - - const [listeners, setListeners] = useState(COW_LISTENERS) - const { mode } = useContext(ColorModeContext) - - const [widgetMode, setWidgetMode] = useState('dapp') - const standaloneMode = widgetMode === 'standalone' - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const selectWidgetMode = (event: ChangeEvent) => { - setWidgetMode(event.target.value as WidgetMode) - } - - const [isDrawerOpen, setIsDrawerOpen] = useState(true) - - const networkControlState = useState(NetworkOptions[0]) - const [{ chainId }, setNetworkControlState] = networkControlState - - const tradeTypeState = useState(TRADE_MODES[0]) - const [currentTradeType] = tradeTypeState - - const localeState = useState('') - const [locale] = localeState - - const tradeModesState = useState(TRADE_MODES) - const [enabledTradeTypes] = tradeModesState - - const widgetHooksState = useState([]) - const [enabledWidgetHooks] = widgetHooksState - - const sellTokenState = useState(DEFAULT_STATE.sellToken) - const sellTokenAmountState = useState(DEFAULT_STATE.sellAmount) - const [sellToken] = sellTokenState - const [sellTokenAmount] = sellTokenAmountState - - const buyTokenState = useState(DEFAULT_STATE.buyToken) - const buyTokenAmountState = useState(DEFAULT_STATE.buyAmount) - const [buyToken] = buyTokenState - const [buyTokenAmount] = buyTokenAmountState - - const deadlineState = useState() - const [deadline] = deadlineState - const swapDeadlineState = useState() - const [swapDeadline] = swapDeadlineState - const limitDeadlineState = useState() - const [limitDeadline] = limitDeadlineState - const advancedDeadlineState = useState() - const [advancedDeadline] = advancedDeadlineState - - const tokenListUrlsState = useState(DEFAULT_TOKEN_LISTS) - const customTokensState = useState([]) - const [tokenListUrls] = tokenListUrlsState - const [customTokens] = customTokensState - - const partnerFeeBpsState = useState(0) - const [partnerFeeBps] = partnerFeeBpsState - - const customImagesState = useState({}) - const customSoundsState = useState({}) - const [customImages] = customImagesState - const [customSounds] = customSoundsState - - const [rawParams, setRawParams] = useState() - const [isWidgetDisplayed, setIsWidgetDisplayed] = useState(true) - - const paletteManager = useColorPaletteManager(mode) - const { colorPalette, defaultPalette } = paletteManager - const [boxShadow, setBoxShadow] = useState('') - - const { dialogOpen, handleDialogClose, handleDialogOpen } = useEmbedDialogState() - - const { closeToast, toasts, selectDisableToastMessages, disableToastMessages } = useToastsManager(setListeners) - const firstToast = toasts?.[0] - - const [disableProgressBar, setDisableProgressBar] = useState(false) - const toggleDisableProgressBar = useCallback(() => setDisableProgressBar((curr) => !curr), []) - - const [disablePostTradeTips, setDisablePostTradeTips] = useState(false) - const toggleDisablePostTradeTips = useCallback(() => setDisablePostTradeTips((curr) => !curr), []) - - const [disableCrossChainSwap, setDisableCrossChainSwap] = useState(false) - const toggleDisableCrossChainSwap = useCallback(() => setDisableCrossChainSwap((curr) => !curr), []) - - const [disableTokenImport, setDisableTokenImport] = useState(false) - const toggleDisableTokenImport = useCallback(() => setDisableTokenImport((curr) => !curr), []) - - const [hideRecentTokens, setHideRecentTokens] = useState(false) - const toggleHideRecentTokens = useCallback(() => setHideRecentTokens((curr) => !curr), []) - - const [hideFavoriteTokens, setHideFavoriteTokens] = useState(false) - const toggleHideFavoriteTokens = useCallback(() => setHideFavoriteTokens((curr) => !curr), []) - - const [hideBridgeInfo, setHideBridgeInfo] = useState(false) - const toggleHideBridgeInfo = useCallback(() => setHideBridgeInfo((curr) => !curr), []) - - const [hideOrdersTable, setHideOrdersTable] = useState(false) - const toggleHideOrdersTable = useCallback(() => setHideOrdersTable((curr) => !curr), []) - - const [disableTradeWhenPriceImpactIsUnknown, setDisableTradeWhenPriceImpactIsUnknown] = useState(false) - const selectBlockUnknownPriceImpact = useCallback((event: ChangeEvent) => { - setDisableTradeWhenPriceImpactIsUnknown(event.target.value === 'true') - }, []) - - const [disableTradeWhenPriceImpactIsHigherThan, setDisableTradeWhenPriceImpactIsHigherThan] = useState< - number | undefined - >() - const setBlockPriceImpactAboveValue = useCallback((event: ChangeEvent) => { - const nextValue = event.target.value.trim() - - if (!nextValue) { - setDisableTradeWhenPriceImpactIsHigherThan(undefined) - - return - } - - const parsedValue = Number(nextValue) - - if (Number.isNaN(parsedValue)) return - - setDisableTradeWhenPriceImpactIsHigherThan(parsedValue) - }, []) - - const LINKS = [ - { icon: , label: 'View embed code', onClick: () => handleDialogOpen() }, - { icon: , label: 'Widget web', url: `https://cow.fi/widget/?${UTM_PARAMS}` }, - { - icon: , - label: 'Developer docs', - url: `https://docs.cow.fi/cow-protocol/tutorials/widget?${UTM_PARAMS}`, - }, - ] - - // Don't change chainId in the widget URL if the user is connected to a wallet - // Because useSyncWidgetNetwork() will send a request to change the network - const state: ConfiguratorState = { - deadline, - swapDeadline, - limitDeadline, - advancedDeadline, - chainId: !isConnected || !walletChainId ? chainId : (walletChainId as SupportedChainId), - locale: locale || undefined, - theme: mode, - boxShadow: boxShadow || undefined, - currentTradeType, - enabledTradeTypes, - enabledWidgetHooks, - sellToken, - sellTokenAmount, - buyToken, - buyTokenAmount, - tokenListUrls, - customColors: colorPalette, - defaultColors: defaultPalette, - partnerFeeBps, - partnerFeeRecipient: DEFAULT_PARTNER_FEE_RECIPIENT_PER_NETWORK[chainId], - standaloneMode, - disableToastMessages, - disableProgressBar, - disablePostTradeTips, - disableCrossChainSwap, - disableTokenImport, - hideRecentTokens, - hideFavoriteTokens, - hideBridgeInfo, - hideOrdersTable, - disableTradeWhenPriceImpactIsUnknown, - disableTradeWhenPriceImpactIsHigherThan, - } - - const rawParamsObject = useMemo(() => { - if (!rawParams) return undefined - try { - return JSON.parse(rawParams) - } catch { - return undefined - } - }, [rawParams]) - - const computedParams = useWidgetParams(state) - const params = useMemo( - () => ({ - ...computedParams, - images: customImages, - sounds: customSounds, - customTokens, - ...rawParamsObject, - ...window.cowSwapWidgetParams, - }), - [computedParams, customImages, customSounds, customTokens, rawParamsObject], - ) - - const updateWidget = useCallback(() => { - setIsWidgetDisplayed(false) - - setTimeout(() => setIsWidgetDisplayed(true), 100) - }, []) - - useEffect(() => { - setThemeMode(mode) - }, [setThemeMode, mode]) - - // Fire an event to GA when user connect a wallet - useEffect(() => { - if (isConnected) { - cowAnalytics.sendEvent({ - category: AnalyticsCategory.WIDGET_CONFIGURATOR, - action: 'Connect wallet', - }) - } - }, [isConnected, cowAnalytics]) - - useSyncWidgetNetwork(chainId, setNetworkControlState, standaloneMode) - - const availableChains = useAvailableChains() - - return ( - - {!isDrawerOpen && ( - { - e.stopPropagation() - setIsDrawerOpen(true) - }} - sx={{ position: 'fixed', bottom: '1.6rem', left: '1.6rem' }} - > - - - )} - - - - {title} - - - { - <> - - Select Mode: - - } label="Dapp mode" /> - } label="Standalone mode" /> - - - {!standaloneMode && ( -
- {/* @ts-ignore */} - -
- )} - - } - - General - - - - - - setBoxShadow(event.target.value)} - size="medium" - /> - - - - - - - - - - { - - } - - Tokens - - - - - - - - Forced Order Deadline - - Global deadline settings - - - Individual deadline settings - - - - - Integrations - - - - Customization - - - - - - Other settings - - Toast notifications: - - } label="Self-contain in Widget" /> - } label="Dapp mode" /> - - - - - Progress bar: - - } label="Show SWAP progress bar" /> - } label="Hide SWAP progress bar" /> - - - - - Post-trade CoW Swap tips: - - } label="Show post-trade tips" /> - } label="Hide post-trade tips" /> - - - - - Cross-chain swaps: - - } label="Enable cross-chain swaps" /> - } label="Disable cross-chain swaps" /> - - - - - Custom tokens and lists: - - } label="Allow importing custom tokens/lists" /> - } label="Disable importing custom tokens/lists" /> - - - - - Recent tokens: - - } label="Show recent tokens" /> - } label="Hide recent tokens" /> - - - - - Favorite tokens: - - } label="Show favorite tokens" /> - } label="Hide favorite tokens" /> - - - - - Hide bridge info: - - } label="Show bridge info" /> - } label="Hide bridge info" /> - - - - - Hide orders table: - - } label="Show orders table" /> - } label="Hide orders table" /> - - - - Disable trade when price impact is unknown: - - } label="Allow trade" /> - } label="Disable trade" /> - - - - - setRawParams(e.target.value)} - size="medium" - /> - - - - {isDrawerOpen && ( - setIsDrawerOpen(false)} - sx={{ position: 'fixed', top: '1.3rem', left: '26.7rem' }} - > - - - )} - - - {LINKS.map(({ label, icon, url, onClick }) => ( - - {icon} - - - ))} - -
- - - {params && ( - <> - - {isWidgetDisplayed && ( - console.log('[configurator:onReady] Widget ready')} - /> - )} - - )} - - - handleDialogOpen()} - > - - View Embed Code - - - - - } - /> -
- ) -} diff --git a/apps/widget-configurator/src/app/configurator/styled.ts b/apps/widget-configurator/src/app/configurator/styled.ts deleted file mode 100644 index 96e963f230c..00000000000 --- a/apps/widget-configurator/src/app/configurator/styled.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Theme } from '@mui/material/styles' - -export const WrapperStyled = { display: 'flex', flexFlow: 'column wrap', width: '100%' } - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const DrawerStyled = (theme: Theme) => ({ - width: '29rem', - flexShrink: 0, - - '& .MuiDrawer-paper': { - width: '29rem', - boxSizing: 'border-box', - display: 'flex', - flexFlow: 'column', - gap: '1.6rem', - height: '100%', - border: 0, - background: theme.palette.background.paper, - boxShadow: 'rgba(5, 43, 101, 0.06) 0 1.2rem 1.2rem', - padding: '1.6rem', - }, -}) - -export const ContentStyled = { - width: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - flexFlow: 'column', - flex: '1 1 auto', - margin: '0 auto', - - '> iframe': { - border: 0, - margin: '0 auto', - borderRadius: '1.6rem', - overflow: 'auto', - }, -} - -export const WalletConnectionWrapper = { - display: 'flex', - justifyContent: 'center', - margin: '0 auto 1rem', - width: '100%', -} diff --git a/apps/widget-configurator/src/app/configurator/types.ts b/apps/widget-configurator/src/app/configurator/types.ts deleted file mode 100644 index 5d75909dda8..00000000000 --- a/apps/widget-configurator/src/app/configurator/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SupportedChainId } from '@cowprotocol/cow-sdk' -import { - CowSwapWidgetPaletteColors, - PartnerFee, - SlippageConfig, - TradeType, - WidgetHookEvents, -} from '@cowprotocol/widget-lib' - -import { PaletteMode } from '@mui/material' - -export type ColorPalette = { - [key in CowSwapWidgetPaletteColors]: string -} - -export interface TokenListItem { - url: string - enabled: boolean - enabledForSell: boolean - enabledForBuy: boolean -} - -export interface ConfiguratorState { - chainId?: SupportedChainId - locale?: string - theme: PaletteMode - boxShadow?: string - currentTradeType: TradeType - enabledTradeTypes: TradeType[] - enabledWidgetHooks: WidgetHookEvents[] - sellToken: string - sellTokenAmount: number | undefined - buyToken: string - buyTokenAmount: number | undefined - deadline: number | undefined - swapDeadline: number | undefined - limitDeadline: number | undefined - advancedDeadline: number | undefined - tokenListUrls: TokenListItem[] - customColors: ColorPalette - defaultColors: ColorPalette - partnerFeeBps: number - partnerFeeRecipient: PartnerFee['recipient'] - standaloneMode: boolean - disableToastMessages: boolean - disableProgressBar: boolean - disablePostTradeTips: boolean - disableCrossChainSwap: boolean - disableTokenImport: boolean - hideRecentTokens: boolean - hideFavoriteTokens: boolean - hideBridgeInfo: boolean | undefined - hideOrdersTable: boolean | undefined - disableTradeWhenPriceImpactIsUnknown: boolean - disableTradeWhenPriceImpactIsHigherThan: number | undefined - slippage?: SlippageConfig -} diff --git a/apps/widget-configurator/src/app/embedDialog/index.tsx b/apps/widget-configurator/src/app/embedDialog/index.tsx deleted file mode 100644 index 9ff311c3d2f..00000000000 --- a/apps/widget-configurator/src/app/embedDialog/index.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { ReactNode, SyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { useCowAnalytics } from '@cowprotocol/analytics' -import svgHtmlSrc from '@cowprotocol/assets/cow-swap/html.svg' -import svgJsSrc from '@cowprotocol/assets/cow-swap/js.svg' -import svgReactSrc from '@cowprotocol/assets/cow-swap/react.svg' -import svgTsSrc from '@cowprotocol/assets/cow-swap/ts.svg' -import { useCopyClipboard } from '@cowprotocol/common-hooks' -import { Command } from '@cowprotocol/types' -import { CowSwapWidgetProps } from '@cowprotocol/widget-react' - -import { Tab } from '@mui/material' -import MuiAlert, { AlertProps } from '@mui/material/Alert' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Dialog, { DialogProps } from '@mui/material/Dialog' -import DialogActions from '@mui/material/DialogActions' -import DialogContent from '@mui/material/DialogContent' -import DialogTitle from '@mui/material/DialogTitle' -import Snackbar from '@mui/material/Snackbar' -import Tabs from '@mui/material/Tabs' -import SVG from 'react-inlinesvg' -import SyntaxHighlighter from 'react-syntax-highlighter' -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { nightOwl } from 'react-syntax-highlighter/dist/esm/styles/hljs' - -import { vanillaNoDepsExample } from './utils/htmlExample' -import { jsExample } from './utils/jsExample' -import { reactTsExample } from './utils/reactTsExample' -import { tsExample } from './utils/tsExample' - -import { AnalyticsCategory } from '../../common/analytics/types' -import { ColorPalette } from '../configurator/types' - -interface TabInfo { - id: number - label: string - language: string - snippetFromParams(params: CowSwapWidgetProps['params'], defaultPalette: ColorPalette): string - icon: string -} - -const TABS: TabInfo[] = [ - { - id: 0, - label: 'React Typescript', - language: 'typescript', - snippetFromParams: reactTsExample, - icon: svgReactSrc, - }, - { - id: 1, - label: 'Typescript', - language: 'typescript', - snippetFromParams: tsExample, - icon: svgTsSrc, - }, - { - id: 2, - label: 'Javascript', - language: 'javascript', - snippetFromParams: jsExample, - icon: svgJsSrc, - }, - { - id: 3, - label: 'Pure HTML', - language: 'html', - snippetFromParams: vanillaNoDepsExample, - icon: svgHtmlSrc, - }, -] - -const Alert = React.forwardRef(function Alert(props, ref) { - return -}) - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function a11yProps(id: number) { - return { - id: `simple-tab-${id}`, - 'aria-controls': `simple-tabpanel-${id}`, - } -} - -export interface EmbedDialogProps { - params: CowSwapWidgetProps['params'] - defaultPalette: ColorPalette - open: boolean - handleClose: Command -} - -// TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function -export function EmbedDialog({ params, open, handleClose, defaultPalette }: EmbedDialogProps): ReactNode { - const [scroll, setScroll] = useState('paper') - const [tabInfo, setCurrentTabInfo] = useState(TABS[0]) - const { id, language, snippetFromParams } = tabInfo - const descriptionElementRef = useRef(null) - const cowAnalytics = useCowAnalytics() - - const [snackbarOpen, setSnackbarOpen] = useState(false) - const [, copyToClipboard] = useCopyClipboard(3000) - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleCopyClick = () => { - copyToClipboard(code) - cowAnalytics.sendEvent({ - category: AnalyticsCategory.WIDGET_CONFIGURATOR, - action: 'Copy code', - }) - setSnackbarOpen(true) - } - - // TODO: Add proper return type annotation - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const handleSnackbarClose = (event?: React.SyntheticEvent | Event, reason?: string) => { - if (reason === 'clickaway') { - return - } - setSnackbarOpen(false) - } - - useEffect(() => { - if (open) { - setScroll('paper') - cowAnalytics.sendEvent({ - category: AnalyticsCategory.WIDGET_CONFIGURATOR, - action: 'View code', - }) - const { current: descriptionElement } = descriptionElementRef - if (descriptionElement !== null) { - descriptionElement.focus() - } - } - }, [open, cowAnalytics]) - - // eslint-disable-next-line react-hooks/preserve-manual-memoization - const code = useMemo(() => { - return snippetFromParams(params, defaultPalette) - }, [snippetFromParams, params, defaultPalette]) - - const onChangeTab = useCallback((_event: SyntheticEvent, newValue: TabInfo) => setCurrentTabInfo(newValue), []) - - return ( -
- - Snippet for CoW Widget - - - - - {TABS.map((info) => { - return ( - } - value={info} - {...a11yProps(info.id)} - /> - ) - })} - - - -
- -
-
- - - - -
- - - - Successfully copied to clipboard! - - -
- ) -} diff --git a/apps/widget-configurator/src/app/embedDialog/utils/htmlExample.ts b/apps/widget-configurator/src/app/embedDialog/utils/htmlExample.ts deleted file mode 100644 index 029c3f07106..00000000000 --- a/apps/widget-configurator/src/app/embedDialog/utils/htmlExample.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { CowSwapWidgetParams } from '@cowprotocol/widget-lib' - -import { formatParameters } from './formatParameters' - -import { ColorPalette } from '../../configurator/types' -import { COMMENTS_BEFORE_PARAMS, PROVIDER_PARAM_COMMENT } from '../const' - -export function vanillaNoDepsExample(params: CowSwapWidgetParams, defaultPalette: ColorPalette): string { - return ` - - - - CoWSwap Widget demo - - - -
- - - - ` -} diff --git a/apps/widget-configurator/src/app/embedDialog/utils/sanitizeParameters.ts b/apps/widget-configurator/src/app/embedDialog/utils/sanitizeParameters.ts deleted file mode 100644 index 1d7133d04f9..00000000000 --- a/apps/widget-configurator/src/app/embedDialog/utils/sanitizeParameters.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { CowSwapWidgetPalette, CowSwapWidgetPaletteColors, CowSwapWidgetParams } from '@cowprotocol/widget-lib' - -import { ColorPalette } from '../../configurator/types' -import { SANITIZE_PARAMS } from '../const' - -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function sanitizeParameters(params: CowSwapWidgetParams, defaultPalette: ColorPalette) { - return { - ...params, - ...SANITIZE_PARAMS, - theme: sanitizePalette(params, defaultPalette), - } -} - -// Keep only changed values -function sanitizePalette(params: CowSwapWidgetParams, defaultPalette: ColorPalette): CowSwapWidgetParams['theme'] { - if (typeof params.theme === 'string' || !params.theme) return params.theme - - const palette = params.theme - - const paletteDiff = Object.keys(palette).reduce((acc, key: string) => { - const colorKey = key as CowSwapWidgetPaletteColors - - if (defaultPalette[colorKey] !== palette[colorKey]) { - acc[colorKey] = palette[colorKey] - } - - return acc - }, {} as CowSwapWidgetPalette) - - if (Object.keys(paletteDiff).length === 1 && paletteDiff.baseTheme) return paletteDiff.baseTheme - - return paletteDiff -} diff --git a/apps/widget-configurator/src/components/VersionedCowSwapWidget/VersionedCowSwapWidget.tsx b/apps/widget-configurator/src/components/VersionedCowSwapWidget/VersionedCowSwapWidget.tsx new file mode 100644 index 00000000000..e4e5ed765fe --- /dev/null +++ b/apps/widget-configurator/src/components/VersionedCowSwapWidget/VersionedCowSwapWidget.tsx @@ -0,0 +1,91 @@ +import { ReactNode, Suspense, useEffect, useRef } from 'react' + +import type { CowWidgetEventListeners } from '@cowprotocol/events' +import type { CowSwapWidgetParams, CowSwapWidgetProps } from '@cowprotocol/widget-lib' +import { CowSwapWidget } from '@cowprotocol/widget-react' + +import { LAZY_WIDGETS_BY_VERSION } from '../../utils/widget-sdk-versions/widget-sdk-versions.loaders' +import { widgetSdkVersionSupportsReadyEvent } from '../../utils/widget-sdk-versions/widget-sdk-versions.utils' + +import type { WidgetSdkVersion } from '../../utils/widget-sdk-versions/widget-sdk-versions.constants' + +type PinnedWidgetSdkVersion = Exclude + +export interface VersionedCowSwapWidgetProps { + sdkVersion: WidgetSdkVersion + params: CowSwapWidgetParams + provider?: CowSwapWidgetProps['provider'] + listeners?: CowWidgetEventListeners + onReady?: () => void +} + +function attachIframeLoadReveal(host: HTMLElement, onIframeLoad: () => void): void { + const iframe = host.querySelector('iframe') + if (!iframe) return + + iframe.addEventListener('load', onIframeLoad, { once: true }) +} + +/** + * Mounts inside Suspense after the pinned chunk loads, so CowSwapWidget's effect has already + * appended the iframe — no MutationObserver needed. + */ +function LegacyPinnedPreviewReveal({ + onIframeLoad, + children, +}: { + onIframeLoad?: () => void + children: ReactNode +}): ReactNode { + const hostRef = useRef(null) + + useEffect(() => { + if (!onIframeLoad) return + + const host = hostRef.current + if (!host) return + + attachIframeLoadReveal(host, onIframeLoad) + }, [onIframeLoad]) + + return ( +
+ {children} +
+ ) +} + +function LazyPinnedCowSwapWidget({ + sdkVersion, + params, + provider, + listeners, + onReady, +}: VersionedCowSwapWidgetProps & { sdkVersion: PinnedWidgetSdkVersion }): ReactNode { + const LazyCowSwapWidget = LAZY_WIDGETS_BY_VERSION[sdkVersion] + const legacyIframeLoadReveal = widgetSdkVersionSupportsReadyEvent(sdkVersion) ? undefined : onReady + + return ( + + + + + + ) +} + +export function VersionedCowSwapWidget({ + sdkVersion, + params, + provider, + listeners, + onReady, +}: VersionedCowSwapWidgetProps): ReactNode { + const widgetProps = { params, provider, listeners, onReady } + + if (sdkVersion === 'local') { + return + } + + return +} diff --git a/apps/widget-configurator/src/components/configurator/configurator.component.tsx b/apps/widget-configurator/src/components/configurator/configurator.component.tsx new file mode 100644 index 00000000000..bd793d82dc2 --- /dev/null +++ b/apps/widget-configurator/src/components/configurator/configurator.component.tsx @@ -0,0 +1,279 @@ +import React, { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { useLocalStorageState } from '@cowprotocol/common-hooks' +import { CowWidgetEventListeners } from '@cowprotocol/events' +import { CowSwapWidgetParams } from '@cowprotocol/widget-lib' + +import { Box, IconButton, Snackbar } from '@mui/material' +import { X } from 'react-feather' +import { useConnection } from 'wagmi' + +import { + COW_CONFIGURATOR_PREVIEW_HOST_ATTR, + configuratorCheckeredCanvasSx, + configuradorRootSx, +} from './configurator.styles' + +import { AnalyticsCategory } from '../../common/analytics/types' +import { + COW_LISTENERS, + CONFIGURATOR_SIDEBAR_OPEN_STORAGE_KEY, + WIDGET_PREVIEW_READY_FALLBACK_MS, +} from '../../configurator.constants' +import { ConfiguratorState } from '../../configurator.types' +import { useProvider } from '../../hooks/useProvider' +import { useResizableDrawerWidth } from '../../hooks/useResizableDrawerWidth' +import { useToastsManager } from '../../hooks/useToastsManager' +import { useWidgetParams } from '../../hooks/useWidgetParamsAndSettings' +import { SidebarControls } from '../sidebar/controls/sidebar-controls.component' +import { Sidebar } from '../sidebar/sidebar.component' +import { DRAWER_WIDTH_CSS_VAR } from '../sidebar/sidebar.styles' +import { Snippet } from '../snippet/snippet.component' +import { VersionedCowSwapWidget } from '../VersionedCowSwapWidget/VersionedCowSwapWidget' + +declare global { + interface Window { + cowSwapWidgetParams?: Partial + } +} + +// eslint-disable-next-line max-lines-per-function +export function Configurator({ title }: { title: string }): ReactNode { + const configuratorRef = useRef(null) + const { isConnected } = useConnection() + const provider = useProvider() + const cowAnalytics = useCowAnalytics() + + // Widget Configurator UI: + + const [widgetKey, setWidgetKey] = useState(0) + const [isWidgetReady, setIsWidgetReadyState] = useState(false) + const widgetReadyRef = useRef(false) + const isWidgetReadyTimeoutId = useRef(0) + + const setIsWidgetReady = useCallback((isReady: boolean): void => { + widgetReadyRef.current = isReady + window.clearTimeout(isWidgetReadyTimeoutId.current) + setIsWidgetReadyState(isReady) + }, []) + + // Sidebar Handling: + + const [isSidebarOpen, setIsSidebarOpen] = useLocalStorageState( + CONFIGURATOR_SIDEBAR_OPEN_STORAGE_KEY, + (persistedValue) => (typeof persistedValue === 'boolean' ? persistedValue : true), + ) + + const { drawerWidth, isResizing, handleResizeStart } = useResizableDrawerWidth( + configuratorRef, + DRAWER_WIDTH_CSS_VAR, + isSidebarOpen, + ) + + const handleSidebarToggle = useCallback(() => { + setIsSidebarOpen((prev) => !prev) + }, [setIsSidebarOpen]) + + // Snippet Handling: + + const [isSnippetOpen, setIsSnippetOpen] = useState(false) + + const handleSnippetToggle = useCallback(() => { + setIsSnippetOpen((prev) => !prev) + }, []) + + // Widget Configurator State: + + const [configuratorState, setConfiguratorState] = useState(null) + const sdkVersion = configuratorState?.sdkVersion + const [params, isPending] = useWidgetParams(configuratorState) + const hasParams = params && configuratorState + + const handlePreviewReady = useCallback((): void => { + console.log(`[WIDGET] READY`) + setIsWidgetReady(true) + }, [setIsWidgetReady]) + + const handleForceWidgetReload = useCallback((): void => { + if (widgetReadyRef.current) { + setIsWidgetReady(false) + setWidgetKey((k) => k + 1) + } else { + // If the widget is not ready yet but we click here, we just force the iframe to appear. + // This is useful for older Widget App versions that did not have the READY event. + setIsWidgetReady(true) + } + }, [setIsWidgetReady]) + + const previousBaseUrlRef = useRef(null) + + useEffect(() => { + if (!params) return + + if (previousBaseUrlRef.current === null) { + previousBaseUrlRef.current = params.baseUrl || null + return + } + + if (previousBaseUrlRef.current !== params.baseUrl) { + previousBaseUrlRef.current = params.baseUrl || null + setIsWidgetReady(false) + setWidgetKey((k) => k + 1) + } + }, [params, setIsWidgetReady]) + + useEffect(() => { + if (!sdkVersion) return + + setIsWidgetReady(false) + }, [sdkVersion, setIsWidgetReady]) + + // onReady when supported; legacy SDKs use iframe `load` via VersionedCowSwapWidget; 60s last resort. + useEffect(() => { + if (isWidgetReady || !hasParams) return + + isWidgetReadyTimeoutId.current = window.setTimeout(() => { + setIsWidgetReady(true) + }, WIDGET_PREVIEW_READY_FALLBACK_MS) + + return () => { + window.clearTimeout(isWidgetReadyTimeoutId.current) + } + }, [isWidgetReady, hasParams, sdkVersion, widgetKey, setIsWidgetReady]) + + const [listeners, setListeners] = useState(COW_LISTENERS) + const toastManager = useToastsManager(setListeners) + const { closeToast, toasts } = toastManager + const firstToast = toasts?.[0] + + // Analytics: Fire an event to GA when user connect a wallet. + + useEffect(() => { + if (isConnected) { + cowAnalytics.sendEvent({ + category: AnalyticsCategory.WIDGET_CONFIGURATOR, + action: 'Connect wallet', + }) + } + }, [isConnected, cowAnalytics]) + + const showIframeOutline = configuratorState?.showIframeOutline ?? false + + const shouldShowLoader = !params || !configuratorState || !isWidgetReady + + const loaderElement = ( + theme.palette.background.paper, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 40, + opacity: shouldShowLoader ? 1 : 0, + visibility: shouldShowLoader ? 'visible' : 'hidden', + pointerEvents: 'none', + transition: 'opacity 300ms ease, visibility 300ms ease', + '@keyframes cowConfiguratorLoaderSpin': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, + animation: shouldShowLoader ? 'cowConfiguratorLoaderSpin 0.25s linear infinite' : 'none', + '&::before': { + content: (theme) => (theme.palette.mode === 'light' ? '"📀"' : '"💿"'), + display: 'block', + lineHeight: 1, + }, + }} + /> + ) + + let configuratorContent: React.ReactNode = null + + if (!params || !configuratorState) { + configuratorContent = ( + + {loaderElement} + + + ) + } else { + const showIframeOutline = configuratorState?.showIframeOutline ?? false + configuratorContent = ( + <> + + {loaderElement} + + + + + + {isSnippetOpen && isWidgetReady ? ( + + ) : null} + + + ) + } + + return ( + + + + + + {configuratorContent} + + + + + } + /> + + ) +} diff --git a/apps/widget-configurator/src/components/configurator/configurator.styles.ts b/apps/widget-configurator/src/components/configurator/configurator.styles.ts new file mode 100644 index 00000000000..36aa0a7f4d7 --- /dev/null +++ b/apps/widget-configurator/src/components/configurator/configurator.styles.ts @@ -0,0 +1,63 @@ +import { WIDGET_IFRAME_ID } from '@cowprotocol/widget-lib' + +import { darken, lighten } from '@mui/material/styles' + +import type { SxProps, Theme } from '@mui/material/styles' + +export const configuradorRootSx: SxProps = { + display: 'flex', + flexFlow: 'row nowrap', + width: '100%', + height: '100vh', + overflowX: 'hidden', +} + +const TRANSPARENCY_CHECKER_PX = 8 +const CONTENT_PADDING_PX = 16 + +/** Stable preview host for layout/checkered styles (npm widget-react omits `#cowswap-widget`). */ +export const COW_CONFIGURATOR_PREVIEW_HOST_ATTR = 'data-cow-configurator-preview-host' + +export const configuratorCheckeredCanvasSx = + (isWidgetReady: boolean, showIframeOutline: boolean, blockScroll = false): SxProps => + (theme) => { + const paper = theme.palette.background.paper + const isDark = theme.palette.mode === 'dark' + const squareA = isDark ? lighten(paper, 0.06) : paper + const squareB = isDark ? paper : darken(paper, 0.11) + const base = paper + const pattern = `repeating-conic-gradient(from 90deg, ${squareA} 0% 25%, ${squareB} 0% 50%)` + + return { + position: 'relative', + flex: '1 1 auto', + overflowY: blockScroll ? 'hidden' : 'scroll', + padding: `${CONTENT_PADDING_PX}px`, + backgroundColor: base, + + [`& > [${COW_CONFIGURATOR_PREVIEW_HOST_ATTR}]`]: { + minWidth: '100%', + minHeight: '100%', + flex: '1 1 auto', + backgroundImage: `${pattern}`, + backgroundSize: `${TRANSPARENCY_CHECKER_PX}px ${TRANSPARENCY_CHECKER_PX}px`, + backgroundRepeat: 'repeat', + backgroundPosition: `right ${CONTENT_PADDING_PX}px top ${CONTENT_PADDING_PX}px`, + backgroundClip: 'content-box', + backgroundOrigin: 'content-box', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + [`& #${WIDGET_IFRAME_ID}, & iframe`]: { + display: 'block', + border: 0, + margin: '0 auto', + outline: showIframeOutline ? '1px dashed cyan' : 'none', + transition: 'opacity 0.3s ease-in-out', + opacity: isWidgetReady ? 1 : 0, + pointerEvents: isWidgetReady ? 'auto' : 'none', + }, + }, + } + } diff --git a/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.component.tsx b/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.component.tsx new file mode 100644 index 00000000000..0391fb640d4 --- /dev/null +++ b/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.component.tsx @@ -0,0 +1,113 @@ +import { useMemo, type ReactNode } from 'react' + +import { WIDGET_IFRAME_ID } from '@cowprotocol/widget-lib' + +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' + +import { + APPEARANCE_STYLE_PRESET_OPTIONS, + applyPresetStyle, + getAppearanceStylePresets, + type AppearanceStylePresetKey, +} from './AppearanceStyleControls.utils' + +import { useAsyncJsonError } from '../../../hooks/useAsyncJsonError' +import { JsonInput } from '../../ui/inputs/JsonInput/JsonInput.component' +import { PresetsButtons } from '../../ui/inputs/PresetsButtons/PresetsButtons.component' + +import type { SidebarSectionFormProps } from '../../forms/forms.types' + +const presetHelperTextSx = { + marginTop: '0.8rem', + color: 'text.secondary', + display: 'block', +} as const + +export interface AppearanceStyleControlsProps extends SidebarSectionFormProps { + paperBackgroundColor: string +} + +export function AppearanceStyleControls({ + values, + onChange, + paperBackgroundColor, +}: AppearanceStyleControlsProps): ReactNode { + const presets = useMemo(() => getAppearanceStylePresets(paperBackgroundColor), [paperBackgroundColor]) + + const handlePresetClick = (value: string): void => { + const preset = presets[value as AppearanceStylePresetKey] + + applyPresetStyle((styleValue) => onChange('iframeStyleJson', styleValue), preset?.iframe) + applyPresetStyle((styleValue) => onChange('bodyWrapperStyleJson', styleValue), preset?.bodyWrapper) + applyPresetStyle((styleValue) => onChange('cardStyleJson', styleValue), preset?.card) + } + + const iframeStyleJsonError = useAsyncJsonError(values.iframeStyleJson) + const bodyWrapperStyleJsonError = useAsyncJsonError(values.bodyWrapperStyleJson) + const cardStyleJsonError = useAsyncJsonError(values.cardStyleJson) + + return ( + + + + Presets + + + + Styles below may need adjusting for your environment (e.g. use position: fixed instead of position: absolute + for some presets). + + + Remember to check the Limit and TWAP pages look properly with your layout. Consider disabling the orders table + in the Behavior section on narrow layouts. + + + You can adjust the iframe height dynamically based on its content using the var(--dynamicHeight) CSS variable. + + + + + #{WIDGET_IFRAME_ID} (host) + + + + + + + #bodyWrapper (inside iframe) + + + + + + + #card (inside iframe) + + + + + ) +} diff --git a/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.utils.ts b/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.utils.ts new file mode 100644 index 00000000000..059cd7fd124 --- /dev/null +++ b/apps/widget-configurator/src/components/controls/AppearanceStyleControls/AppearanceStyleControls.utils.ts @@ -0,0 +1,151 @@ +import type { PresetOption } from '../../ui/inputs/PresetsButtons/PresetsButtons.component' +import type * as CSS from 'csstype' + +export type OnStyleJsonChange = (value: string | null) => void + +export const RESPONSIVE_BLOCK_IFRAME_STYLE: CSS.Properties = { + width: '100%', + height: 'var(--dynamicHeight)', +} + +export const DEFAULT_IFRAME_STYLE_JSON = JSON.stringify(RESPONSIVE_BLOCK_IFRAME_STYLE, null, 2) + +export const APPEARANCE_STYLE_PRESET_OPTIONS = [ + { + label: 'Responsive block (default)', + value: 'responsive-block', + }, + { + label: 'Full-screen', + value: 'full-screen', + }, + { + label: 'Bottom right popup', + value: 'bottom-right-popup', + }, + { + label: 'Right sidebar', + value: 'right-sidebar', + }, + { + label: 'Modal', + value: 'modal', + }, + { + label: 'Debug', + value: 'debug', + }, + { + label: 'None', + value: 'none', + }, +] as const satisfies PresetOption[] + +export type AppearanceStylePresetKey = (typeof APPEARANCE_STYLE_PRESET_OPTIONS)[number]['value'] + +type PresetElement = 'iframe' | 'bodyWrapper' | 'card' + +// eslint-disable-next-line max-lines-per-function +export function getAppearanceStylePresets( + paperBackgroundColor: string, +): Record>> { + return { + none: {}, + 'responsive-block': { + iframe: RESPONSIVE_BLOCK_IFRAME_STYLE, + }, + 'full-screen': { + iframe: { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + backgroundColor: paperBackgroundColor, + }, + }, + 'bottom-right-popup': { + iframe: { + position: 'absolute', + bottom: '24px', + right: '24px', + boxShadow: '0 0 32px 0 black', + borderRadius: '8px', + width: '420px', + maxHeight: 'calc(100lvh - 48px)', + height: 'var(--dynamicHeight)', + backgroundColor: paperBackgroundColor, + }, + bodyWrapper: { + padding: '0', + }, + card: { + borderRadius: '0', + }, + }, + 'right-sidebar': { + iframe: { + position: 'absolute', + top: '0', + bottom: '0', + right: '0', + boxShadow: '0 0 32px 0 black', + borderRadius: '0', + width: '420px', + height: '100dvh', + backgroundColor: paperBackgroundColor, + }, + bodyWrapper: { + padding: '0', + }, + card: { + borderRadius: '0', + }, + }, + modal: { + iframe: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + boxShadow: '0 0 32px 0 black', + borderRadius: '8px', + width: '800px', + height: '600px', + minWidth: '75%', + maxWidth: 'calc(100% - 32px)', + maxHeight: 'calc(100% - 32px)', + backgroundColor: paperBackgroundColor, + }, + bodyWrapper: { + padding: '0', + }, + card: { + borderRadius: '0', + }, + }, + debug: { + iframe: { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + backgroundColor: 'red', + }, + bodyWrapper: { + backgroundColor: 'cyan', + }, + card: { + backgroundColor: 'yellow', + }, + }, + } +} + +export function applyPresetStyle(onStyleJsonChange: OnStyleJsonChange, style: CSS.Properties | undefined): void { + if (style) { + onStyleJsonChange(JSON.stringify(style, null, 2)) + return + } + + onStyleJsonChange(null) +} diff --git a/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.test.tsx b/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.test.tsx new file mode 100644 index 00000000000..e9800b5f7d9 --- /dev/null +++ b/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import { PaletteControl } from './PaletteControl' + +import { ColorPaletteManager } from '../../../hooks/useColorPaletteManager' + +jest.mock('mui-color-input', () => ({ + MuiColorInput: (props: { label: string }) =>
{props.label}
, +})) + +function buildPaletteManager(): ColorPaletteManager { + const defaultPalette = { + primary: '#052b65', + background: '#FFFFFF', + paper: '#FFFFFF', + text: '#052B65', + danger: '#D41300', + warning: '#F8D06B', + alert: '#DB971E', + info: '#0d5ed9', + success: '#007B28', + } + + return { + colorPalette: defaultPalette, + defaultPalette, + resetColorPalette: jest.fn(), + setColorPalette: jest.fn(), + } +} + +describe('PaletteControl', () => { + it('uses a disclosure button for additional colors', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /more colors/i })) + + expect(screen.getByRole('button', { name: /less colors/i })).not.toBeNull() + }) + + it('resets only the theme colors', () => { + const paletteManager = buildPaletteManager() + + render() + + fireEvent.click(screen.getByRole('button', { name: /^reset$/i })) + + expect(paletteManager.resetColorPalette).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.tsx b/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.tsx new file mode 100644 index 00000000000..3f4341ccaa9 --- /dev/null +++ b/apps/widget-configurator/src/components/controls/PaletteControl/PaletteControl.tsx @@ -0,0 +1,58 @@ +import { ReactNode, useState } from 'react' + +import { Box, Collapse } from '@mui/material' +import { ChevronDown, ChevronUp } from 'react-feather' + +import { ColorPalette } from '../../../configurator.types' +import { ColorPaletteManager } from '../../../hooks/useColorPaletteManager' +import { LinkButton } from '../../ui/buttons/link/LinkButton.component' +import { SmallButton } from '../../ui/buttons/small/SmallButton.component' +import { ColorInput } from '../../ui/inputs/ColorInput/ColorInput.component' + +const visibleColorKeys: Array = ['primary', 'paper', 'text'] + +function formatPaletteColorLabel(colorKey: keyof ColorPalette): string { + return String(colorKey).charAt(0).toUpperCase() + String(colorKey).slice(1) +} + +export function PaletteControl({ paletteManager }: { paletteManager: ColorPaletteManager }): ReactNode { + const { colorPalette, setColorPalette, defaultPalette, resetColorPalette } = paletteManager + + const otherColorKeys = (Object.keys(colorPalette) as Array).filter( + (key) => !visibleColorKeys.includes(key), + ) + + const [expanded, setExpanded] = useState(false) + + const renderColorInput = (colorKey: keyof ColorPalette): ReactNode => ( + { + setColorPalette((prevPalette: ColorPalette) => ({ ...prevPalette, [colorKey]: newValue })) + }} + sx={{ my: '10px' }} + /> + ) + + return ( +
+ {visibleColorKeys.map(renderColorInput)} + + {otherColorKeys.map(renderColorInput)} + + + setExpanded(!expanded)} + /> + + +
+ ) +} diff --git a/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.constants.ts b/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.constants.ts new file mode 100644 index 00000000000..d49662481b8 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.constants.ts @@ -0,0 +1,18 @@ +import { CONFIGURATOR_DEFAULT_WIDGET_BASE_URL } from '../../../utils/base-url/baseUrl' +import { PresetOption } from '../../ui/inputs/PresetsButtons/PresetsButtons.component' + +export const ADVANCED_BASE_URL_PRESETS_OPTIONS: PresetOption[] = [ + { label: 'Local', value: 'http://localhost:3000' }, + { label: 'Dev', value: 'https://dev.swap.cow.fi' }, + { label: 'Production', value: 'https://swap.cow.fi' }, +].map((presetOption) => { + return { + ...presetOption, + label: + presetOption.value === CONFIGURATOR_DEFAULT_WIDGET_BASE_URL + ? `${presetOption.label} (default)` + : presetOption.label, + } +}) + +export const ADVANCED_DEFAULT_BASE_URL = CONFIGURATOR_DEFAULT_WIDGET_BASE_URL diff --git a/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.tsx b/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.tsx new file mode 100644 index 00000000000..82e3b9a46c0 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/advanced/AdvancedSectionForm.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react' + +import { ADVANCED_BASE_URL_PRESETS_OPTIONS, ADVANCED_DEFAULT_BASE_URL } from './AdvancedSectionForm.constants' + +import { WIDGET_HOOKS_OPTIONS } from '../../../configurator.constants' +import { useAsyncJsonError } from '../../../hooks/useAsyncJsonError' +import { SDK_VERSION_OPTIONS } from '../../../utils/widget-sdk-versions/widget-sdk-versions.constants' +import { JsonInput } from '../../ui/inputs/JsonInput/JsonInput.component' +import { PresetsButtons } from '../../ui/inputs/PresetsButtons/PresetsButtons.component' +import { MultiSelectInput } from '../../ui/inputs/Select/multi/MultiSelectInput.component' +import { SelectInput } from '../../ui/inputs/Select/single/SelectInput.component' +import { TextInput } from '../../ui/inputs/TextInput/TextInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +export function AdvancedSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + const rawParamsJsonError = useAsyncJsonError(values.rawParamsJson) + + return ( + <> + { + onChange('baseUrl', value === ADVANCED_DEFAULT_BASE_URL ? null : value) + }} + /> + + + + + + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/basics/BasicsSectionForm.tsx b/apps/widget-configurator/src/components/forms/basics/BasicsSectionForm.tsx new file mode 100644 index 00000000000..ae3e5088a17 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/basics/BasicsSectionForm.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react' + +import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' + +import { LOCALE_OPTIONS, WIDGET_MODE_OPTIONS } from '../../../configurator.constants' +import { COMMENTS_BY_PARAM_NAME } from '../../snippet/code-example-templates/common/codeExample.constants' +import { RadioGroupInput } from '../../ui/inputs/RadioGroupInput/RadioGroupInput.component' +import { SelectInput } from '../../ui/inputs/Select/single/SelectInput.component' +import { TextInput } from '../../ui/inputs/TextInput/TextInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +const WIDGET_MODE_TOOLTIP = ( + } spacing={1.2}> + + + Dapp mode: + {' '} + The host app provides the wallet connection and network switching. + + + + Standalone mode: + {' '} + The widget uses its own wallet provider and shows its own connect wallet controls. + + +) + +export function BasicsSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + return ( + <> + + + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/behavior/BehaviorSectionForm.tsx b/apps/widget-configurator/src/components/forms/behavior/BehaviorSectionForm.tsx new file mode 100644 index 00000000000..d4e331fc7c7 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/behavior/BehaviorSectionForm.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' + +import { NumberInput } from '../../ui/inputs/NumberInput/NumberInput.component' +import { SwitchInput } from '../../ui/inputs/SwitchInput/SwitchInput' + +import type { UseToastsManagerReturn } from '../../../hooks/useToastsManager' +import type { SidebarSectionFormProps } from '../forms.types' + +export interface BehaviorSectionFormProps extends SidebarSectionFormProps { + toastManager: UseToastsManagerReturn +} + +export function BehaviorSectionForm({ values, onChange, toastManager }: BehaviorSectionFormProps): ReactNode { + return ( + <> + + onChange('disableProgressBar', !enabled)} + /> + onChange('disablePostTradeTips', !enabled)} + /> + onChange('disableTokenImport', !enabled)} + /> + onChange('hideRecentTokens', !enabled)} + /> + onChange('hideFavoriteTokens', !enabled)} + /> + onChange('hideBridgeInfo', !enabled)} + /> + onChange('hideOrdersTable', !enabled)} + /> + onChange('disableTradeWhenPriceImpactIsUnknown', enabled)} + /> + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/customization/CustomizationSectionForm.tsx b/apps/widget-configurator/src/components/forms/customization/CustomizationSectionForm.tsx new file mode 100644 index 00000000000..253c1dd1ecf --- /dev/null +++ b/apps/widget-configurator/src/components/forms/customization/CustomizationSectionForm.tsx @@ -0,0 +1,66 @@ +import { useCallback, type ReactNode } from 'react' + +import { TextInput } from '../../ui/inputs/TextInput/TextInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +const valueNullAsString = (value: string | undefined | null): string => (value === null ? 'null' : value || '') + +const customizationFields = ['customImages', 'customSounds'] as const + +type CustomizationField = (typeof customizationFields)[number] + +export function CustomizationSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + const { customImages, customSounds } = values + + const handleChange = useCallback( + (name: string, value: string | null): void => { + const [fieldName, subFieldName] = name.split('.') as [CustomizationField, string] + + if (!fieldName || !subFieldName || !customizationFields.includes(fieldName)) { + console.warn('[COW][CONFIGURATOR] Missing field name in change event:', name) + return + } + + const prevValues = { + customImages, + customSounds, + }[fieldName] + + onChange(fieldName, { ...prevValues, [subFieldName]: value === 'null' ? null : value || '' }) + }, + [customImages, customSounds, onChange], + ) + + return ( + <> + + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/deadlines/DeadlinesSectionForm.tsx b/apps/widget-configurator/src/components/forms/deadlines/DeadlinesSectionForm.tsx new file mode 100644 index 00000000000..dd86973930f --- /dev/null +++ b/apps/widget-configurator/src/components/forms/deadlines/DeadlinesSectionForm.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' + +import { NumberInput } from '../../ui/inputs/NumberInput/NumberInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +function parseDeadlineValue(rawValue: string): number | undefined { + const numericValue = Number(rawValue) + if (Number.isNaN(numericValue)) return undefined + + return Math.max(1, numericValue) +} + +export function DeadlinesSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + return ( + <> + + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/forms.types.ts b/apps/widget-configurator/src/components/forms/forms.types.ts new file mode 100644 index 00000000000..3285a3340fc --- /dev/null +++ b/apps/widget-configurator/src/components/forms/forms.types.ts @@ -0,0 +1,13 @@ +import type { ConfiguratorFormValues } from '../../configurator.types' + +export type ConfiguratorFormInputEvent = React.ChangeEvent + +export interface ConfiguratorFormChangeHandler { + (event: ConfiguratorFormInputEvent): void + (name: K, value: ConfiguratorFormValues[K] | null): void +} + +export interface SidebarSectionFormProps { + values: ConfiguratorFormValues + onChange: ConfiguratorFormChangeHandler +} diff --git a/apps/widget-configurator/src/components/forms/integrations/IntegrationsSectionForm.tsx b/apps/widget-configurator/src/components/forms/integrations/IntegrationsSectionForm.tsx new file mode 100644 index 00000000000..59b207ca54a --- /dev/null +++ b/apps/widget-configurator/src/components/forms/integrations/IntegrationsSectionForm.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' + +import { NumberInput } from '../../ui/inputs/NumberInput/NumberInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +function formatBpsAsPercent(bps: number): string { + const percent = bps / 100 + + if (bps === 0) { + return '0%' + } + + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }).format(percent) + + return `${formatted}%` +} + +export function IntegrationsSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + return ( + { + const roundedValue = Math.ceil(Number(rawValue)) + const boundedValue = Math.min(Math.max(roundedValue, 0), 100) + return Number.isNaN(boundedValue) ? 0 : boundedValue + }} + inputProps={{ + min: 0, + max: 100, + step: 1, + }} + /> + ) +} diff --git a/apps/widget-configurator/src/components/forms/layout/LayoutSectionForm.tsx b/apps/widget-configurator/src/components/forms/layout/LayoutSectionForm.tsx new file mode 100644 index 00000000000..4dd576e1f87 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/layout/LayoutSectionForm.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +import { AppearanceStyleControls } from '../../controls/AppearanceStyleControls/AppearanceStyleControls.component' +import { SwitchInput } from '../../ui/inputs/SwitchInput/SwitchInput' + +import type { SidebarSectionFormProps } from '../forms.types' + +export interface LayoutSectionFormProps extends SidebarSectionFormProps { + paperBackgroundColor: string +} + +export function LayoutSectionForm({ values, onChange, paperBackgroundColor }: LayoutSectionFormProps): ReactNode { + return ( + <> + onChange('disableScrollbars', checked)} + helperText="Only disable scrollbars when your iframe height is adjusted dynamically using var(--dynamicHeight) and is not height-constrained (e.g. no max-height). Otherwise leave this off and use a fixed iframe height instead of var(--dynamicHeight)." + /> + onChange('showIframeOutline', checked)} + helperText="Preview-only visual aid to see the iframe boundaries. This setting is not included in the exported widget code." + /> + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/theme-colors/ThemeColorsSectionForm.tsx b/apps/widget-configurator/src/components/forms/theme-colors/ThemeColorsSectionForm.tsx new file mode 100644 index 00000000000..06845880ac4 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/theme-colors/ThemeColorsSectionForm.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { THEME_OPTIONS, ThemeOptionValue } from '../../../configurator.constants' +import { PaletteControl } from '../../controls/PaletteControl/PaletteControl' +import { SelectInput } from '../../ui/inputs/Select/single/SelectInput.component' + +import type { useColorPaletteManager } from '../../../hooks/useColorPaletteManager' +import type { SidebarSectionFormProps } from '../forms.types' + +export interface ThemeColorsSectionFormProps extends SidebarSectionFormProps { + paletteManager: ReturnType +} + +export function ThemeColorsSectionForm({ values, onChange, paletteManager }: ThemeColorsSectionFormProps): ReactNode { + return ( + <> + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.constants.ts b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.constants.ts new file mode 100644 index 00000000000..775828beaf4 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.constants.ts @@ -0,0 +1,29 @@ +import { BASE_SELECT_OPTION_HEIGHT } from '../../ui/inputs/Select/base/BaseSelectInput.styles' + +import type { TokenListScope } from './TokensSectionForm.utils' + +const ITEM_PADDING_TOP = 8 + +export const TOKEN_LIST_MENU_PROPS = { + PaperProps: { + style: { + maxHeight: BASE_SELECT_OPTION_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, +} + +export const TOKEN_LIST_HELPER_TEXT_BY_SCOPE: Record = { + enabled: + 'Lists enabled for the widget on both sell and buy. Empty uses CoW Swap default lists in the widget (not these presets).', + enabledForSell: + 'Optional: when set, the sell picker shows only tokens from these lists (not Active lists). They are still loaded into the widget. Empty = no sell-side filter.', + enabledForBuy: + 'Optional: when set, the buy picker shows only tokens from these lists (not Active lists). They are still loaded into the widget. Empty = no buy-side filter.', +} + +export const TOKEN_LIST_SELECT_CONFIG: { label: string; scope: TokenListScope }[] = [ + { label: 'Active Token Lists', scope: 'enabled' }, + { label: 'Sell Token Lists', scope: 'enabledForSell' }, + { label: 'Buy Token Lists', scope: 'enabledForBuy' }, +] diff --git a/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.tsx b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.tsx new file mode 100644 index 00000000000..27d1451a455 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.tsx @@ -0,0 +1,91 @@ +import { type ReactNode, useCallback, useState } from 'react' + +import { Box } from '@mui/material' +import { Plus } from 'react-feather' + +import { AddCustomTokensDialog } from './dialog/AddCustomTokensDialog.component' +import { + TOKEN_LIST_HELPER_TEXT_BY_SCOPE, + TOKEN_LIST_MENU_PROPS, + TOKEN_LIST_SELECT_CONFIG, +} from './TokensSectionForm.constants' +import { + appendTokenListUrl, + getSelectedTokenListUrls, + getTokenListOptions, + updateTokenListScope, + type TokenListScope, +} from './TokensSectionForm.utils' + +import { LinkButton } from '../../ui/buttons/link/LinkButton.component' +import { CurrencyInputControl } from '../../ui/inputs/CurrencyInput/CurrencyInputControl' +import { MultiSelectInput } from '../../ui/inputs/Select/multi/MultiSelectInput.component' + +import type { SidebarSectionFormProps } from '../forms.types' + +export function TokensSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + const [dialogOpen, setDialogOpen] = useState(false) + + const setTokenListScope = useCallback( + (scope: TokenListScope, selectedUrls: string[]) => { + onChange('tokenListUrls', updateTokenListScope(values.tokenListUrls, scope, selectedUrls)) + }, + [onChange, values.tokenListUrls], + ) + + const handleAddListUrl = useCallback( + (newListUrl: string) => { + const nextTokenListUrls = appendTokenListUrl(values.tokenListUrls, newListUrl) + + if (!nextTokenListUrls) return + + onChange('tokenListUrls', nextTokenListUrls) + }, + [onChange, values.tokenListUrls], + ) + + return ( + <> + + + + {TOKEN_LIST_SELECT_CONFIG.map(({ label, scope }) => ( + setTokenListScope(scope, selectedUrls)} + /> + ))} + + setDialogOpen(false)} + customTokens={values.customTokens} + onAddListUrl={handleAddListUrl} + onAddCustomTokens={(tokens) => onChange('customTokens', tokens)} + /> + + + setDialogOpen(true)} /> + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.utils.ts b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.utils.ts new file mode 100644 index 00000000000..1fe43ca007a --- /dev/null +++ b/apps/widget-configurator/src/components/forms/tokens/TokensSectionForm.utils.ts @@ -0,0 +1,38 @@ +import { TokenListItem } from '../../../configurator.types' + +export type TokenListScope = 'enabled' | 'enabledForSell' | 'enabledForBuy' + +export function getSelectedTokenListUrls(tokenListUrls: TokenListItem[], scope: TokenListScope): string[] { + return tokenListUrls.filter((list) => list[scope]).map((list) => list.url) +} + +export function getTokenListOptions( + tokenListUrls: TokenListItem[], + scope: TokenListScope, +): { label: string; value: string }[] { + return [...tokenListUrls] + .sort((a, b) => { + if (a[scope] === b[scope]) { + return a.url.localeCompare(b.url) + } + + return a[scope] ? -1 : 1 + }) + .map((list) => ({ label: list.url, value: list.url })) +} + +export function updateTokenListScope( + tokenListUrls: TokenListItem[], + scope: TokenListScope, + selectedUrls: string[], +): TokenListItem[] { + return tokenListUrls.map((list) => ({ ...list, [scope]: selectedUrls.includes(list.url) })) +} + +export function appendTokenListUrl(tokenListUrls: TokenListItem[], newListUrl: string): TokenListItem[] | null { + const existing = tokenListUrls.find((list) => list.url.toLowerCase() === newListUrl.toLowerCase()) + + if (existing) return null + + return [...tokenListUrls, { url: newListUrl, enabled: true, enabledForSell: false, enabledForBuy: false }] +} diff --git a/apps/widget-configurator/src/components/forms/tokens/dialog/AddCustomTokensDialog.component.tsx b/apps/widget-configurator/src/components/forms/tokens/dialog/AddCustomTokensDialog.component.tsx new file mode 100644 index 00000000000..5c835c07252 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/tokens/dialog/AddCustomTokensDialog.component.tsx @@ -0,0 +1,178 @@ +import React, { type ReactNode, useEffect, useState } from 'react' + +import { isValidTokenListSource } from '@cowprotocol/common-utils' +import { Command, TokenInfo } from '@cowprotocol/types' + +import { Dialog, DialogContent } from '@mui/material' +import { Plus } from 'react-feather' + +import { DEFAULT_CUSTOM_TOKENS } from '../../../../configurator.constants' +import { parseCustomTokensInput } from '../../../../utils/parse-custom-tokens-input/parseCustomTokensInput' +import { Button } from '../../../ui/buttons/button/Button.component' +import { JsonInput } from '../../../ui/inputs/JsonInput/JsonInput.component' +import { TextInput } from '../../../ui/inputs/TextInput/TextInput.component' +import { ModalFooter } from '../../../ui/surface/modal/footer/ModalFooter.component' +import { ModalHeader } from '../../../ui/surface/modal/header/ModalHeader.component' +import { configuratorDialogBackdropSx, configuratorDialogPaperSx } from '../../../ui/surface/modal/modal.styles' +import { ModalTabPanel } from '../../../ui/surface/modal/tabs/ModalTabPanel.component' +import { ModalLabelTabInfo, ModalTabs } from '../../../ui/surface/modal/tabs/ModalTabs.component' + +const ADD_CUSTOM_LIST_TABS_ID_PREFIX = 'add-custom-list' + +type AddCustomListTabId = 'url' | 'json' + +const ADD_CUSTOM_LIST_TABS = [ + { label: 'URL', value: 'url' }, + { label: 'JSON', value: 'json' }, +] as const satisfies ModalLabelTabInfo[] + +const DEFAULT_ADD_CUSTOM_LIST_TAB_ID = ADD_CUSTOM_LIST_TABS[0].value + +interface AddCustomListDialogProps { + open: boolean + onClose: Command + customTokens: TokenInfo[] + onAddListUrl: (newListUrl: string) => void + onAddCustomTokens: (tokens: TokenInfo[]) => void +} + +// TODO: Break down this large function into smaller functions +// eslint-disable-next-line max-lines-per-function +export function AddCustomTokensDialog({ + open, + onClose, + onAddListUrl, + onAddCustomTokens, + customTokens: customTokensDefault, +}: AddCustomListDialogProps): ReactNode { + const [customListUrl, setCustomListUrl] = useState('') + const [hasErrors, setHasErrors] = useState(false) + const [hasJsonErrors, setHasJsonErrors] = useState(false) + const [customTokensJson, setCustomTokensJson] = useState('') + + const [customTokens, setCustomTokens] = useState([]) + + const [tabValue, setTabValue] = useState(DEFAULT_ADD_CUSTOM_LIST_TAB_ID) + + const resetForm = (): void => { + // Reset custom URL + setCustomListUrl('') + setHasErrors(false) + + // Reset custom tokens + setCustomTokens([]) + setHasJsonErrors(false) + setCustomTokensJson('') + } + + const handleTabChange = (_: React.SyntheticEvent, newValue: AddCustomListTabId): void => { + setTabValue(newValue) + resetForm() + } + + const handleUrlInputChange = (_name: string, value: string | null): void => { + const urlValue = value ?? '' + + setCustomListUrl(urlValue) + setHasErrors(urlValue ? !isValidTokenListSource(urlValue) : false) + } + + const handleJsonInputChange = (_name: string, value: string | null): void => { + setHasJsonErrors(false) + setCustomTokensJson(value) + + if (!value?.trim()) { + setCustomTokens([]) + return + } + + try { + const parsedTokens = parseCustomTokensInput(value) + + if (parsedTokens) { + setCustomTokens(parsedTokens) + } else { + setHasJsonErrors(true) + } + } catch { + setHasJsonErrors(true) + } + } + + const handleSubmit = (): void => { + if (customListUrl) { + onAddListUrl(customListUrl) + resetForm() + } else if (customTokens.length) { + onAddCustomTokens(customTokens) + } + + onClose() + } + + const addJsonExample = (): void => { + setCustomTokensJson(JSON.stringify(DEFAULT_CUSTOM_TOKENS, null, 2)) + setCustomTokens(DEFAULT_CUSTOM_TOKENS) + setHasJsonErrors(false) + } + + useEffect(() => { + if (customTokensDefault.length) { + setCustomTokens(customTokensDefault) + } + }, [customTokensDefault]) + + return ( + + + + + + + + + + + + + ) +} diff --git a/apps/widget-configurator/src/components/forms/trade-setup/TradeSetupSectionForm.tsx b/apps/widget-configurator/src/components/forms/trade-setup/TradeSetupSectionForm.tsx new file mode 100644 index 00000000000..989e8733b22 --- /dev/null +++ b/apps/widget-configurator/src/components/forms/trade-setup/TradeSetupSectionForm.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react' +import { useMemo } from 'react' + +import { CHAIN_INFO } from '@cowprotocol/common-const' +import { useAvailableChains } from '@cowprotocol/common-hooks' +import { isChainDeprecated, type SupportedChainId } from '@cowprotocol/cow-sdk' + +import { TRADE_MODES_OPTIONS, TRADE_TYPE_OPTIONS } from '../../../configurator.constants' +import { MultiSelectInput } from '../../ui/inputs/Select/multi/MultiSelectInput.component' +import { SelectInput } from '../../ui/inputs/Select/single/SelectInput.component' +import { SwitchInput } from '../../ui/inputs/SwitchInput/SwitchInput' + +import type { SelectInputOption } from '../../ui/inputs/Select/base/BaseSelectInput.types' +import type { SidebarSectionFormProps } from '../forms.types' + +export function TradeSetupSectionForm({ values, onChange }: SidebarSectionFormProps): ReactNode { + const availableChains = useAvailableChains() + + const chainOptions: SelectInputOption[] = useMemo(() => { + const availableChainsSet = new Set(availableChains) + + const chainOptions: SelectInputOption[] = Object.entries(CHAIN_INFO).map( + ([chainIdStr, chainInfo]) => { + const chainId = +chainIdStr as SupportedChainId + + return { + value: chainId, + label: `${chainInfo.label}${isChainDeprecated(chainId) ? ' (deprecated)' : ''}`, + disabled: !availableChainsSet.has(chainId), + } + }, + ) + + return chainOptions + }, [availableChains]) + + return ( + <> + + + + + + + onChange('disableCrossChainSwap', !enabled)} + /> + + ) +} diff --git a/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.component.tsx b/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.component.tsx new file mode 100644 index 00000000000..037c0ba2e92 --- /dev/null +++ b/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.component.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react' + +import { Box, IconButton } from '@mui/material' +import { ChevronLeft, ChevronRight } from 'react-feather' + +import { + sidebarControlsZeroWidthColumnSx, + sidebarToggleOpenButton, + sidebarResizeHandle, +} from './sidebar-controls.styles' + +export interface SidebarControlsProps { + isSidebarOpen: boolean + toggleSidebarOpen: () => void + onResizeStart: (event: React.PointerEvent) => void +} + +export function SidebarControls({ isSidebarOpen, toggleSidebarOpen, onResizeStart }: SidebarControlsProps): ReactNode { + return ( + + + {isSidebarOpen ? : } + + + {isSidebarOpen ? ( + + ) : null} + + ) +} diff --git a/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.styles.ts b/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.styles.ts new file mode 100644 index 00000000000..d45307d9399 --- /dev/null +++ b/apps/widget-configurator/src/components/sidebar/controls/sidebar-controls.styles.ts @@ -0,0 +1,66 @@ +import { SxProps } from '@mui/material' +import { Theme } from '@mui/material/styles' + +export const sidebarControlsZeroWidthColumnSx: SxProps = (theme: Theme) => ({ + position: 'relative', + width: 0, + height: '100%', + flexShrink: 0, + + // Above the preview area and sidebar, below AppKit (`w3m-modal`) and MUI modals: + zIndex: theme.zIndex.drawer, +}) + +export const sidebarToggleOpenButton: SxProps = (theme: Theme) => ({ + position: 'absolute', + top: '50%', + left: 0, + width: 48, + height: 48, + p: 0, + pl: '24px', + pr: '4px', + transform: 'translate(-50%, -50%)', + borderRadius: '50%', + border: `none`, + backgroundColor: theme.palette.background.paper, + color: theme.palette.primary.main, + boxShadow: 'none', + zIndex: 3, + transition: 'opacity 0.2s ease-in-out, transform 0.2s ease-in-out', + + '&[aria-hidden="true"]': { + opacity: 0, + pointerEvents: 'none', + }, + + '&:hover': { + boxShadow: 'none', + transform: 'translate(-50%, -50%) scale(2)', + }, +}) + +export const sidebarResizeHandle: SxProps = { + position: 'absolute', + inset: 0, + width: '0.8rem', + marginLeft: '-0.4rem', + cursor: 'col-resize', + zIndex: 2, + + '&::before': { + content: '""', + position: 'absolute', + top: 16, + bottom: 16, + left: '50%', + transform: 'translateX(-50%)', + width: 4, + borderRadius: '999px', + backgroundColor: 'divider', + }, + + '&:hover::before': { + backgroundColor: 'text.secondary', + }, +} diff --git a/apps/widget-configurator/src/components/sidebar/env-badge/SidebarEnvBadge.component.tsx b/apps/widget-configurator/src/components/sidebar/env-badge/SidebarEnvBadge.component.tsx new file mode 100644 index 00000000000..ac00cc43806 --- /dev/null +++ b/apps/widget-configurator/src/components/sidebar/env-badge/SidebarEnvBadge.component.tsx @@ -0,0 +1,106 @@ +import { ReactNode } from 'react' + +import Box from '@mui/material/Box' +import Tooltip from '@mui/material/Tooltip' + +import { getEnvColor, getEnvLabel } from '../../../utils/base-url/baseUrl' +import { + NPM_WIDGET_REACT_LATEST_VERSION, + type WidgetSdkVersion, +} from '../../../utils/widget-sdk-versions/widget-sdk-versions.constants' +import { getSdkEnvColor, getSdkEnvLabel } from '../../../utils/widget-sdk-versions/widget-sdk-versions.utils' + +export interface SidebarEnvBadgeProps { + brandColor: string + baseUrl: string + configuratorOrigin: string + sdkVersion: WidgetSdkVersion +} + +export function SidebarEnvBadge({ + brandColor, + baseUrl, + configuratorOrigin, + sdkVersion, +}: SidebarEnvBadgeProps): ReactNode { + const configuratorLabel = getEnvLabel(configuratorOrigin) + const widgetAppLabel = getEnvLabel(baseUrl) + const sdkLabel = getSdkEnvLabel(sdkVersion) + + const showBadge = + configuratorLabel !== 'Production' || + widgetAppLabel !== 'Production' || + sdkVersion !== NPM_WIDGET_REACT_LATEST_VERSION + + const segmentColors = [ + getEnvColor(brandColor, configuratorOrigin), + getSdkEnvColor(brandColor, sdkVersion), + getEnvColor(brandColor, baseUrl), + ] as const + + return ( + + + + Configurator: + {configuratorLabel} + + + SDK: + {sdkLabel} + + + App: + {widgetAppLabel} + + + + } + placement="bottom" + disableHoverListener={!showBadge} + > + + + {segmentColors.map((color, index) => ( + + ))} + + + + ) +} diff --git a/apps/widget-configurator/src/components/sidebar/footer/sidebar-footer.component.tsx b/apps/widget-configurator/src/components/sidebar/footer/sidebar-footer.component.tsx new file mode 100644 index 00000000000..9b3c3c7eb19 --- /dev/null +++ b/apps/widget-configurator/src/components/sidebar/footer/sidebar-footer.component.tsx @@ -0,0 +1,184 @@ +import { ReactNode } from 'react' + +import Box from '@mui/material/Box' +import Link from '@mui/material/Link' +import { ChevronLeft, ChevronRight, Code, Eye, Moon, Sun, RefreshCw } from 'react-feather' + +import { UTM_PARAMS } from '../../../configurator.constants' +import { useColorMode } from '../../../theme/context/hooks/useColorMode' +import { Button } from '../../ui/buttons/button/Button.component' +import { IconButton } from '../../ui/buttons/icon/IconButton.component' + +const WIDGET_WEB_URL = `https://cow.fi/widget/?${UTM_PARAMS}` +const DEVELOPER_DOCS_URL = `https://docs.cow.fi/cow-protocol/tutorials/widget?${UTM_PARAMS}` + +export interface SidebarFooterProps { + isSidebarOpen: boolean + onSidebarToggle: () => void + isSnippetOpen: boolean + onSnippetToggle: () => void + isWidgetReady: boolean + isWidgetSyncPending: boolean + onForceWidgetReload: () => void + onResetOptions: () => void +} + +// eslint-disable-next-line max-lines-per-function +export function SidebarFooter({ + isSidebarOpen, + onSidebarToggle, + isSnippetOpen, + onSnippetToggle, + isWidgetReady, + isWidgetSyncPending, + onForceWidgetReload, + onResetOptions, +}: SidebarFooterProps): ReactNode { + const { mode, toggleColorMode } = useColorMode() + + const snippetLabel = isSnippetOpen ? 'See preview' : 'Get code' + const SnippetIcon = isSnippetOpen ? Eye : Code + + const themeLabel = mode === 'dark' ? 'Switch to light theme' : 'Switch to dark theme' + const ThemeIcon = mode === 'dark' ? Sun : Moon + + const sidebarLabel = isSidebarOpen ? 'Collapse sidebar' : 'Expand sidebar' + const SidebarIcon = isSidebarOpen ? ChevronLeft : ChevronRight + + const externalLinkSx = { + fontSize: '12px', + color: 'text.secondary', + textDecoration: 'underline', + textDecorationStyle: 'dotted', + textUnderlineOffset: 2, + '&:hover': { + textDecorationStyle: 'solid', + color: 'text.primary', + }, + } as const + + let reloadPreviewLabel = '' + + if (isWidgetSyncPending) { + reloadPreviewLabel = 'Syncing widget...' + } else if (!isWidgetReady) { + reloadPreviewLabel = 'Loading widget... Click to force load.' + } else { + reloadPreviewLabel = 'Reload widget' + } + + const handleResetOptions = (): void => { + if (!confirm("Reset all configurator options to defaults?\nThis can't be undone.")) return + + onResetOptions() + } + + return ( + <> +
+ + t.palette.background.paper, + borderTop: (t) => `1px solid ${t.palette.divider}`, + display: 'flex', + flexDirection: 'column', + gap: 2, + px: 2, + pt: 2, + mt: 'auto', + }} + > + +