From 186436170c80f145eb46c9fa4e9634eda65febb5 Mon Sep 17 00:00:00 2001
From: alec <93971719+0xAlec@users.noreply.github.com>
Date: Wed, 14 Aug 2024 18:15:23 -0700
Subject: [PATCH] feat: `WalletDropdownFundLink` (#1021)
---
.changeset/witty-crabs-change.md | 5 +
site/docs/pages/wallet/types.mdx | 15 +++
src/internal/svg/fundWallet.tsx | 19 +++
.../WalletDropdownFundLink.test.tsx | 118 ++++++++++++++++++
.../components/WalletDropdownFundLink.tsx | 85 +++++++++++++
src/wallet/components/WalletDropdownLink.tsx | 16 +--
src/wallet/hooks/useIcon.test.tsx | 63 ++++++++++
src/wallet/hooks/useIcon.tsx | 20 +++
src/wallet/index.ts | 2 +
src/wallet/types.ts | 22 ++++
src/wallet/utils/getWindowDimensions.test.ts | 52 ++++++++
src/wallet/utils/getWindowDimensions.ts | 35 ++++++
12 files changed, 438 insertions(+), 14 deletions(-)
create mode 100644 .changeset/witty-crabs-change.md
create mode 100644 src/internal/svg/fundWallet.tsx
create mode 100644 src/wallet/components/WalletDropdownFundLink.test.tsx
create mode 100644 src/wallet/components/WalletDropdownFundLink.tsx
create mode 100644 src/wallet/hooks/useIcon.test.tsx
create mode 100644 src/wallet/hooks/useIcon.tsx
create mode 100644 src/wallet/utils/getWindowDimensions.test.ts
create mode 100644 src/wallet/utils/getWindowDimensions.ts
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 };
+};