diff --git a/.changeset/witty-crabs-change.md b/.changeset/witty-crabs-change.md new file mode 100644 index 0000000000..169f381068 --- /dev/null +++ b/.changeset/witty-crabs-change.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +**feat**: `WalletDropdownFundLink` - add a wallet dropdown link for the keys.coinbase.com funding flow by @0xAlec #1021 diff --git a/site/docs/pages/wallet/types.mdx b/site/docs/pages/wallet/types.mdx index b8a617f0a8..b10a8897f8 100644 --- a/site/docs/pages/wallet/types.mdx +++ b/site/docs/pages/wallet/types.mdx @@ -82,6 +82,21 @@ export type WalletDropdownDisconnectReact = { }; ``` +## `WalletDropdownFundLinkReact` + +```ts +export type WalletDropdownFundLinkReact = { + className?: string; // Optional className override for the element + icon?: ReactNode; // Optional icon override + openIn?: 'popup' | 'tab'; // Whether to open the funding flow in a tab or a popup window + popupFeatures?: string; // Optional features override for the popup window if `openIn` is set to `popup` + popupSize?: 'sm' | 'md' | 'lg'; // Size of the popup window if `openIn` is set to `popup` + rel?: string; // Specifies the relationship between the current document and the linked document + target?: string; // Where to open the target if `openIn` is set to tab + text?: string; // Optional text override +}; +``` + ## `WalletDropdownLinkReact` ```ts diff --git a/src/internal/svg/fundWallet.tsx b/src/internal/svg/fundWallet.tsx new file mode 100644 index 0000000000..2e55a2e952 --- /dev/null +++ b/src/internal/svg/fundWallet.tsx @@ -0,0 +1,19 @@ +import { fill } from '../../styles/theme'; + +export const fundWalletSvg = ( + + + +); diff --git a/src/wallet/components/WalletDropdownFundLink.test.tsx b/src/wallet/components/WalletDropdownFundLink.test.tsx new file mode 100644 index 0000000000..1294112081 --- /dev/null +++ b/src/wallet/components/WalletDropdownFundLink.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import { version } from '../../version'; +import type { WindowSizes } from '../types'; +import { WalletDropdownFundLink } from './WalletDropdownFundLink'; + +const FUNDING_URL = `http://keys.coinbase.com/fund?dappName=&dappUrl=http%3A%2F%2Flocalhost%3A3000%2F&version=${version}&source=onchainkit`; + +describe('WalletDropdownFundLink', () => { + it('renders correctly with default props', () => { + render(); + + const linkElement = screen.getByRole('link'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', FUNDING_URL); + expect(screen.getByText('Fund wallet')).toBeInTheDocument(); + }); + + it('renders correctly with custom icon element', () => { + const customIcon = ; + render(); + + const linkElement = screen.getByRole('link'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', FUNDING_URL); + expect(screen.getByText('Fund wallet')).toBeInTheDocument(); + expect(screen.getByLabelText('custom-icon')).toBeInTheDocument(); + }); + + it('renders correctly with custom text', () => { + render(); + + const linkElement = screen.getByRole('link'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', FUNDING_URL); + expect(screen.getByText('test')).toBeInTheDocument(); + }); + + it('opens a new window when clicked with type="window" (default size medium)', () => { + // Mock window.open + const mockOpen = vi.fn(); + vi.stubGlobal('open', mockOpen); + + // Mock window.screen + vi.stubGlobal('screen', { width: 1024, height: 768 }); + + render(); + + const linkElement = screen.getByText('Fund wallet'); + fireEvent.click(linkElement); + + // Check if window.open was called with the correct arguments + expect(mockOpen).toHaveBeenCalledWith( + expect.stringContaining('http://keys.coinbase.com/fund'), + undefined, + expect.stringContaining( + 'width=297,height=371,resizable,scrollbars=yes,status=1,left=364,top=199', + ), + ); + + // Clean up + vi.unstubAllGlobals(); + }); + + const testCases: WindowSizes = { + sm: { width: '23vw', height: '28.75vw' }, + md: { width: '29vw', height: '36.25vw' }, + lg: { width: '35vw', height: '43.75vw' }, + }; + + const minWidth = 280; + const minHeight = 350; + + for (const [size, { width, height }] of Object.entries(testCases)) { + it(`opens a new window when clicked with type="window" and popupSize="${size}"`, () => { + const mockOpen = vi.fn(); + const screenWidth = 1024; + const screenHeight = 768; + const innerWidth = 1024; + const innerHeight = 768; + + vi.stubGlobal('open', mockOpen); + vi.stubGlobal('screen', { width: screenWidth, height: screenHeight }); + + render( + , + ); + + const linkElement = screen.getByText('Fund wallet'); + fireEvent.click(linkElement); + + const vwToPx = (vw: string) => + Math.round((Number.parseFloat(vw) / 100) * innerWidth); + + const expectedWidth = Math.max(minWidth, vwToPx(width)); + const expectedHeight = Math.max(minHeight, vwToPx(height)); + const adjustedHeight = Math.min( + expectedHeight, + Math.round(innerHeight * 0.9), + ); + const expectedLeft = Math.round((screenWidth - expectedWidth) / 2); + const expectedTop = Math.round((screenHeight - adjustedHeight) / 2); + expect(mockOpen).toHaveBeenCalledWith( + expect.stringContaining('http://keys.coinbase.com/fund'), + undefined, + expect.stringContaining( + `width=${expectedWidth},height=${adjustedHeight},resizable,scrollbars=yes,status=1,left=${expectedLeft},top=${expectedTop}`, + ), + ); + + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + } +}); diff --git a/src/wallet/components/WalletDropdownFundLink.tsx b/src/wallet/components/WalletDropdownFundLink.tsx new file mode 100644 index 0000000000..6ebfb8069f --- /dev/null +++ b/src/wallet/components/WalletDropdownFundLink.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo } from 'react'; +import { useEffect, useState } from 'react'; +import { cn, pressable, text as themeText } from '../../styles/theme'; +import { version } from '../../version'; +import { useIcon } from '../hooks/useIcon'; +import type { WalletDropdownFundLinkReact } from '../types'; +import { getWindowDimensions } from '../utils/getWindowDimensions'; + +export function WalletDropdownFundLink({ + className, + icon = 'fundWallet', + openIn = 'tab', + popupFeatures, + popupSize = 'md', + rel, + target, + text = 'Fund wallet', +}: WalletDropdownFundLinkReact) { + const [fundingUrl, setFundingUrl] = useState(''); + + const iconSvg = useIcon({ icon }); + + useEffect(() => { + const currentURL = window.location.href; + const tabName = document.title; + const url = `http://keys.coinbase.com/fund?dappName=${encodeURIComponent( + tabName, + )}&dappUrl=${encodeURIComponent(currentURL)}&version=${encodeURIComponent( + version, + )}&source=onchainkit`; + setFundingUrl(url); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const { width, height } = getWindowDimensions(popupSize); + + const left = Math.round((window.screen.width - width) / 2); + const top = Math.round((window.screen.height - height) / 2); + + const windowFeatures = + popupFeatures || + `width=${width},height=${height},resizable,scrollbars=yes,status=1,left=${left},top=${top}`; + window.open(fundingUrl, target, windowFeatures); + }, + [fundingUrl, popupFeatures, popupSize, target], + ); + + const overrideClassName = cn( + pressable.default, + 'relative flex items-center px-4 py-3', + className, + ); + + const linkContent = useMemo( + () => ( + <> +
+ {iconSvg} +
+ {text} + + ), + [iconSvg, text], + ); + + if (openIn === 'tab') { + return ( + + {linkContent} + + ); + } + return ( + + ); +} diff --git a/src/wallet/components/WalletDropdownLink.tsx b/src/wallet/components/WalletDropdownLink.tsx index 3801126149..0f04539083 100644 --- a/src/wallet/components/WalletDropdownLink.tsx +++ b/src/wallet/components/WalletDropdownLink.tsx @@ -1,6 +1,5 @@ -import { isValidElement, useMemo } from 'react'; -import { walletSvg } from '../../internal/svg/walletSvg'; import { cn, pressable, text } from '../../styles/theme'; +import { useIcon } from '../hooks/useIcon'; import type { WalletDropdownLinkReact } from '../types'; export function WalletDropdownLink({ @@ -11,18 +10,7 @@ export function WalletDropdownLink({ rel, target, }: WalletDropdownLinkReact) { - const iconSvg = useMemo(() => { - if (icon === undefined) { - return null; - } - switch (icon) { - case 'wallet': - return walletSvg; - } - if (isValidElement(icon)) { - return icon; - } - }, [icon]); + const iconSvg = useIcon({ icon }); return ( { + it('should return null when icon is undefined', () => { + const { result } = renderHook(() => useIcon({ icon: undefined })); + expect(result.current).toBeNull(); + }); + + it('should return walletSvg when icon is "wallet"', () => { + const { result } = renderHook(() => useIcon({ icon: 'wallet' })); + expect(result.current).toBe(walletSvg); + }); + + it('should return fundWalletSvg when icon is "fundWallet"', () => { + const { result } = renderHook(() => useIcon({ icon: 'fundWallet' })); + expect(result.current).toBe(fundWalletSvg); + }); + + it('should memoize the result for undefined', () => { + const { result, rerender } = renderHook(() => useIcon({}), { + initialProps: {}, + }); + + const initialResult = result.current; + rerender({}); + expect(result.current).toBe(initialResult); + }); + + it('should memoize the result for wallet', () => { + const { result, rerender } = renderHook(({ icon }) => useIcon({ icon }), { + initialProps: { icon: 'wallet' }, + }); + + const initialResult = result.current; + rerender({ icon: 'wallet' }); + expect(result.current).toBe(initialResult); + }); + + it('should memoize the result for fundWallet', () => { + const { result, rerender } = renderHook(({ icon }) => useIcon({ icon }), { + initialProps: { icon: 'fundWallet' }, + }); + + const initialResult = result.current; + rerender({ icon: 'fundWallet' }); + expect(result.current).toBe(initialResult); + }); + + it('should memoize the result for custom icon', () => { + const customIcon = ; + const { result, rerender } = renderHook(({ icon }) => useIcon({ icon }), { + initialProps: { icon: customIcon }, + }); + + const initialResult = result.current; + rerender({ icon: customIcon }); + expect(result.current).toBe(initialResult); + }); +}); diff --git a/src/wallet/hooks/useIcon.tsx b/src/wallet/hooks/useIcon.tsx new file mode 100644 index 0000000000..468a5874e9 --- /dev/null +++ b/src/wallet/hooks/useIcon.tsx @@ -0,0 +1,20 @@ +import { isValidElement, useMemo } from 'react'; +import { fundWalletSvg } from '../../internal/svg/fundWallet'; +import { walletSvg } from '../../internal/svg/walletSvg'; + +export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { + return useMemo(() => { + if (icon === undefined) { + return null; + } + switch (icon) { + case 'wallet': + return walletSvg; + case 'fundWallet': + return fundWalletSvg; + } + if (isValidElement(icon)) { + return icon; + } + }, [icon]); +}; diff --git a/src/wallet/index.ts b/src/wallet/index.ts index 6063116126..f26161188d 100644 --- a/src/wallet/index.ts +++ b/src/wallet/index.ts @@ -4,6 +4,7 @@ export { Wallet } from './components/Wallet'; export { WalletDropdown } from './components/WalletDropdown'; export { WalletDropdownBaseName } from './components/WalletDropdownBaseName'; export { WalletDropdownDisconnect } from './components/WalletDropdownDisconnect'; +export { WalletDropdownFundLink } from './components/WalletDropdownFundLink'; export { WalletDropdownLink } from './components/WalletDropdownLink'; export { isValidAAEntrypoint } from './utils/isValidAAEntrypoint'; export { isWalletACoinbaseSmartWallet } from './utils/isWalletACoinbaseSmartWallet'; @@ -15,6 +16,7 @@ export type { WalletContextType, WalletDropdownBaseNameReact, WalletDropdownDisconnectReact, + WalletDropdownFundLinkReact, WalletDropdownLinkReact, WalletDropdownReact, WalletReact, diff --git a/src/wallet/types.ts b/src/wallet/types.ts index 631a2566f9..7782411331 100644 --- a/src/wallet/types.ts +++ b/src/wallet/types.ts @@ -96,6 +96,20 @@ export type WalletDropdownDisconnectReact = { text?: string; // Optional text override for the button }; +/** + * Note: exported as public Type + */ +export type WalletDropdownFundLinkReact = { + className?: string; // Optional className override for the element + icon?: ReactNode; // Optional icon override + openIn?: 'popup' | 'tab'; // Whether to open the funding flow in a tab or a popup window + popupFeatures?: string; // Optional features override for the popup window if `openIn` is set to `popup` + popupSize?: 'sm' | 'md' | 'lg'; // Size of the popup window if `openIn` is set to `popup` + rel?: string; // Specifies the relationship between the current document and the linked document + target?: string; // Where to open the target if `openIn` is set to tab + text?: string; // Optional text override +}; + /** * Note: exported as public Type */ @@ -107,3 +121,11 @@ export type WalletDropdownLinkReact = { rel?: string; target?: string; }; + +export type WindowSizes = Record< + 'sm' | 'md' | 'lg', + { + width: string; + height: string; + } +>; diff --git a/src/wallet/utils/getWindowDimensions.test.ts b/src/wallet/utils/getWindowDimensions.test.ts new file mode 100644 index 0000000000..f89354d2a1 --- /dev/null +++ b/src/wallet/utils/getWindowDimensions.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { getWindowDimensions } from './getWindowDimensions'; + +describe('getWindowDimensions', () => { + beforeEach(() => { + // Mock window.innerWidth and window.innerHeight + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1000, + }); + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 800, + }); + }); + + it('should return minimum width for small screens', () => { + window.innerWidth = 300; + window.innerHeight = 400; + + const result = getWindowDimensions('sm'); + expect(result).toEqual({ width: 270, height: 350 }); + }); + + it('should calculate correct dimensions for medium size', () => { + const result = getWindowDimensions('md'); + expect(result).toEqual({ width: 290, height: 363 }); + }); + + it('should calculate correct dimensions for large size', () => { + const result = getWindowDimensions('lg'); + expect(result).toEqual({ width: 350, height: 438 }); + }); + + it('should limit dimensions to 35vw for large viewport', () => { + window.innerWidth = 2000; + window.innerHeight = 1500; + + const result = getWindowDimensions('lg'); + expect(result).toEqual({ width: 700, height: 875 }); + }); + + it('should handle different aspect ratios', () => { + window.innerWidth = 1920; + window.innerHeight = 1080; + + const result = getWindowDimensions('md'); + expect(result).toEqual({ width: 557, height: 696 }); + }); +}); diff --git a/src/wallet/utils/getWindowDimensions.ts b/src/wallet/utils/getWindowDimensions.ts new file mode 100644 index 0000000000..eb5ee11268 --- /dev/null +++ b/src/wallet/utils/getWindowDimensions.ts @@ -0,0 +1,35 @@ +import type { WindowSizes } from '../types'; + +const popupSizes: WindowSizes = { + sm: { width: '24.67vw', height: '30.83vw' }, + md: { width: '29vw', height: '36.25vw' }, + lg: { width: '35vw', height: '43.75vw' }, +}; + +export const getWindowDimensions = (size: keyof typeof popupSizes) => { + const { width, height } = popupSizes[size]; + + // Define minimum sizes (in pixels) + const minWidth = 280; + const minHeight = 350; + + // Convert viewport units to pixels + const vwToPx = (vw: number) => (vw / 100) * window.innerWidth; + + const widthPx = Math.max( + minWidth, + Math.round(vwToPx(Number.parseFloat(width))), + ); + const heightPx = Math.max( + minHeight, + Math.round(vwToPx(Number.parseFloat(height))), + ); + + // Ensure the width and height don't exceed 90% of the viewport dimensions + const maxWidth = Math.round(window.innerWidth * 0.9); + const maxHeight = Math.round(window.innerHeight * 0.9); + const adjustedWidthPx = Math.min(widthPx, maxWidth); + const adjustedHeightPx = Math.min(heightPx, maxHeight); + + return { width: adjustedWidthPx, height: adjustedHeightPx }; +};