diff --git a/.changeset/quick-countries-invent.md b/.changeset/quick-countries-invent.md
new file mode 100644
index 0000000000..b334bd7178
--- /dev/null
+++ b/.changeset/quick-countries-invent.md
@@ -0,0 +1,5 @@
+---
+'@coinbase/onchainkit': patch
+---
+
+**feat**: Implement the fund button integrated with Coinbase Onramp. By @steveviselli-cb #1322
diff --git a/package.json b/package.json
index 4a07686eca..70b4541c8c 100644
--- a/package.json
+++ b/package.json
@@ -137,6 +137,12 @@
"import": "./esm/frame/index.js",
"default": "./esm/frame/index.js"
},
+ "./fund": {
+ "types": "./esm/fund/index.d.ts",
+ "module": "./esm/fund/index.js",
+ "import": "./esm/fund/index.js",
+ "default": "./esm/fund/index.js"
+ },
"./identity": {
"types": "./esm/identity/index.d.ts",
"module": "./esm/identity/index.js",
diff --git a/playground/nextjs-app-router/components/AppProvider.tsx b/playground/nextjs-app-router/components/AppProvider.tsx
index f8add83c5e..35caa59497 100644
--- a/playground/nextjs-app-router/components/AppProvider.tsx
+++ b/playground/nextjs-app-router/components/AppProvider.tsx
@@ -6,6 +6,7 @@ import { useConnect, useConnectors } from 'wagmi';
import { WalletPreference } from './form/wallet-type';
export enum OnchainKitComponent {
+ Fund = 'fund',
Identity = 'identity',
Swap = 'swap',
Transaction = 'transaction',
diff --git a/playground/nextjs-app-router/components/Demo.tsx b/playground/nextjs-app-router/components/Demo.tsx
index 3805932b7c..3da1b9cd1f 100644
--- a/playground/nextjs-app-router/components/Demo.tsx
+++ b/playground/nextjs-app-router/components/Demo.tsx
@@ -5,6 +5,7 @@ import { PaymasterUrl } from '@/components/form/paymaster';
import { SwapConfig } from '@/components/form/swap-config';
import { WalletType } from '@/components/form/wallet-type';
import { useContext, useEffect, useState } from 'react';
+import FundDemo from './demo/Fund';
import IdentityDemo from './demo/Identity';
import SwapDemo from './demo/Swap';
import TransactionDemo from './demo/Transaction';
@@ -40,6 +41,10 @@ function Demo() {
}`;
function renderActiveComponent() {
+ if (activeComponent === OnchainKitComponent.Fund) {
+ return ;
+ }
+
if (activeComponent === OnchainKitComponent.Identity) {
return ;
}
diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx
new file mode 100644
index 0000000000..dfc49a6280
--- /dev/null
+++ b/playground/nextjs-app-router/components/demo/Fund.tsx
@@ -0,0 +1,9 @@
+import { FundButton } from '@coinbase/onchainkit/fund';
+
+export default function FundDemo() {
+ return (
+
+
+
+ );
+}
diff --git a/playground/nextjs-app-router/components/form/active-component.tsx b/playground/nextjs-app-router/components/form/active-component.tsx
index 00e4da4f54..8e44580b1f 100644
--- a/playground/nextjs-app-router/components/form/active-component.tsx
+++ b/playground/nextjs-app-router/components/form/active-component.tsx
@@ -25,6 +25,7 @@ export function ActiveComponent() {
+ Fund
Identity
Transaction
diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json
index f13252113c..18d414696e 100644
--- a/playground/nextjs-app-router/onchainkit/package.json
+++ b/playground/nextjs-app-router/onchainkit/package.json
@@ -47,6 +47,12 @@
"import": "./esm/frame/index.js",
"default": "./esm/frame/index.js"
},
+ "./fund": {
+ "types": "./esm/fund/index.d.ts",
+ "module": "./esm/fund/index.js",
+ "import": "./esm/fund/index.js",
+ "default": "./esm/fund/index.js"
+ },
"./identity": {
"types": "./esm/identity/index.d.ts",
"module": "./esm/identity/index.js",
diff --git a/src/fund/components/FundButton.test.tsx b/src/fund/components/FundButton.test.tsx
new file mode 100644
index 0000000000..2efc6bad6a
--- /dev/null
+++ b/src/fund/components/FundButton.test.tsx
@@ -0,0 +1,93 @@
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { type Mock, afterEach, describe, expect, it, vi } from 'vitest';
+import { openPopup } from '../../internal/utils/openPopup';
+import { useGetFundingUrl } from '../hooks/useGetFundingUrl';
+import { getFundingPopupSize } from '../utils/getFundingPopupSize';
+import { FundButton } from './FundButton';
+
+vi.mock('../hooks/useGetFundingUrl', () => ({
+ useGetFundingUrl: vi.fn(),
+}));
+
+vi.mock('../utils/getFundingPopupSize', () => ({
+ getFundingPopupSize: vi.fn(),
+}));
+
+vi.mock('../../internal/utils/openPopup', () => ({
+ openPopup: vi.fn(),
+}));
+
+describe('WalletDropdownFundLink', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the fund button with the fundingUrl prop when it is defined', () => {
+ const fundingUrl = 'https://props.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
+
+ render();
+
+ expect(useGetFundingUrl).not.toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+ expect(screen.getByText('Fund')).toBeInTheDocument();
+
+ fireEvent.click(buttonElement);
+ expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
+ expect(openPopup as Mock).toHaveBeenCalledWith({
+ url: fundingUrl,
+ height,
+ width,
+ target: undefined,
+ });
+ });
+
+ it('renders the fund button with the default fundingUrl when the fundingUrl prop is undefined', () => {
+ const fundingUrl = 'https://default.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (useGetFundingUrl as Mock).mockReturnValue(fundingUrl);
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
+
+ render();
+
+ expect(useGetFundingUrl).toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+
+ fireEvent.click(buttonElement);
+ expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
+ expect(openPopup as Mock).toHaveBeenCalledWith({
+ url: fundingUrl,
+ height,
+ width,
+ target: undefined,
+ });
+ });
+
+ it('renders a disabled fund button when no funding URL is provided and the default cannot be fetched', () => {
+ (useGetFundingUrl as Mock).mockReturnValue(undefined);
+
+ render();
+
+ expect(useGetFundingUrl).toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+ expect(buttonElement).toHaveClass('pointer-events-none');
+
+ fireEvent.click(buttonElement);
+ expect(openPopup as Mock).not.toHaveBeenCalled();
+ });
+
+ it('renders the fund button as a link when the openIn prop is set to tab', () => {
+ const fundingUrl = 'https://props.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
+
+ render();
+
+ expect(useGetFundingUrl).not.toHaveBeenCalled();
+ const linkElement = screen.getByRole('link');
+ expect(screen.getByText('Fund')).toBeInTheDocument();
+ expect(linkElement).toHaveAttribute('href', fundingUrl);
+ });
+});
diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx
new file mode 100644
index 0000000000..ead48c43bf
--- /dev/null
+++ b/src/fund/components/FundButton.tsx
@@ -0,0 +1,86 @@
+import { useCallback } from 'react';
+import { addSvg } from '../../internal/svg/addSvg';
+import { openPopup } from '../../internal/utils/openPopup';
+import { cn, color, pressable, text } from '../../styles/theme';
+import { useGetFundingUrl } from '../hooks/useGetFundingUrl';
+import type { FundButtonReact } from '../types';
+import { getFundingPopupSize } from '../utils/getFundingPopupSize';
+
+export function FundButton({
+ className,
+ disabled = false,
+ fundingUrl,
+ hideIcon = false,
+ hideText = false,
+ openIn = 'popup',
+ popupSize = 'md',
+ rel,
+ target,
+ text: buttonText = 'Fund',
+}: FundButtonReact) {
+ // If the fundingUrl prop is undefined, fallback to our recommended funding URL based on the wallet type
+ const fundingUrlToRender = fundingUrl ?? useGetFundingUrl();
+ const isDisabled = disabled || !fundingUrlToRender;
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (fundingUrlToRender) {
+ const { height, width } = getFundingPopupSize(
+ popupSize,
+ fundingUrlToRender,
+ );
+ openPopup({
+ url: fundingUrlToRender,
+ height,
+ width,
+ target,
+ });
+ }
+ },
+ [fundingUrlToRender, popupSize, target],
+ );
+
+ const classNames = cn(
+ pressable.primary,
+ 'rounded-xl px-4 py-3',
+ 'inline-flex items-center justify-center space-x-2 disabled',
+ isDisabled && pressable.disabled,
+ text.headline,
+ color.inverse,
+ className,
+ );
+
+ const buttonContent = (
+ <>
+ {/* h-6 is to match the icon height to the line-height set by text.headline */}
+ {hideIcon || {addSvg}}
+ {hideText || {buttonText}}
+ >
+ );
+
+ if (openIn === 'tab') {
+ return (
+
+ {buttonContent}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/fund/constants.ts b/src/fund/constants.ts
index 779de86dd6..d2d3a9f2a8 100644
--- a/src/fund/constants.ts
+++ b/src/fund/constants.ts
@@ -1,3 +1,5 @@
+// The base URL for the Coinbase Onramp widget.
+export const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy';
// The recommended height of a Coinbase Onramp popup window.
export const ONRAMP_POPUP_HEIGHT = 720;
// The recommended width of a Coinbase Onramp popup window.
diff --git a/src/fund/hooks/useGetFundingUrl.test.ts b/src/fund/hooks/useGetFundingUrl.test.ts
index 6de3e876ed..ee574f5bb2 100644
--- a/src/fund/hooks/useGetFundingUrl.test.ts
+++ b/src/fund/hooks/useGetFundingUrl.test.ts
@@ -3,7 +3,6 @@ import { type Mock, describe, expect, it, vi } from 'vitest';
import { useAccount } from 'wagmi';
import { useOnchainKit } from '../../useOnchainKit';
import { useIsWalletACoinbaseSmartWallet } from '../../wallet/hooks/useIsWalletACoinbaseSmartWallet';
-import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
import { getCoinbaseSmartWalletFundUrl } from '../utils/getCoinbaseSmartWalletFundUrl';
import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';
import { useGetFundingUrl } from './useGetFundingUrl';
@@ -45,9 +44,7 @@ describe('useGetFundingUrl', () => {
const { result } = renderHook(() => useGetFundingUrl());
- expect(result.current?.url).toBe('https://keys.coinbase.com/fund');
- expect(result.current?.popupHeight).toBeUndefined();
- expect(result.current?.popupWidth).toBeUndefined();
+ expect(result.current).toBe('https://keys.coinbase.com/fund');
});
it('should return a Coinbase Onramp fund URL if connected wallet is not a Coinbase Smart Wallet', () => {
@@ -64,9 +61,7 @@ describe('useGetFundingUrl', () => {
const { result } = renderHook(() => useGetFundingUrl());
- expect(result.current?.url).toBe('https://pay.coinbase.com/buy');
- expect(result.current?.popupHeight).toBe(ONRAMP_POPUP_HEIGHT);
- expect(result.current?.popupWidth).toBe(ONRAMP_POPUP_WIDTH);
+ expect(result.current).toBe('https://pay.coinbase.com/buy');
expect(getOnrampBuyUrl).toHaveBeenCalledWith(
expect.objectContaining({
@@ -90,9 +85,7 @@ describe('useGetFundingUrl', () => {
const { result } = renderHook(() => useGetFundingUrl());
- expect(result.current?.url).toBe('https://pay.coinbase.com/buy');
- expect(result.current?.popupHeight).toBe(ONRAMP_POPUP_HEIGHT);
- expect(result.current?.popupWidth).toBe(ONRAMP_POPUP_WIDTH);
+ expect(result.current).toBe('https://pay.coinbase.com/buy');
expect(getOnrampBuyUrl).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/src/fund/hooks/useGetFundingUrl.ts b/src/fund/hooks/useGetFundingUrl.ts
index 96e90fb395..0bb6bc114b 100644
--- a/src/fund/hooks/useGetFundingUrl.ts
+++ b/src/fund/hooks/useGetFundingUrl.ts
@@ -2,8 +2,6 @@ import { useMemo } from 'react';
import { useAccount } from 'wagmi';
import { useOnchainKit } from '../../useOnchainKit';
import { useIsWalletACoinbaseSmartWallet } from '../../wallet/hooks/useIsWalletACoinbaseSmartWallet';
-import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
-import type { UseGetFundingUrlResponse } from '../types';
import { getCoinbaseSmartWalletFundUrl } from '../utils/getCoinbaseSmartWalletFundUrl';
import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';
@@ -12,7 +10,7 @@ import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl';
* user to keys.coinbase.com, otherwise it will send them to pay.coinbase.com.
* @returns the funding URL and optional popup dimensions if the URL requires them
*/
-export function useGetFundingUrl(): UseGetFundingUrlResponse | undefined {
+export function useGetFundingUrl(): string | undefined {
const { projectId, chain: defaultChain } = useOnchainKit();
const { address, chain: accountChain } = useAccount();
const isCoinbaseSmartWallet = useIsWalletACoinbaseSmartWallet();
@@ -23,23 +21,16 @@ export function useGetFundingUrl(): UseGetFundingUrlResponse | undefined {
return useMemo(() => {
if (isCoinbaseSmartWallet) {
- return {
- url: getCoinbaseSmartWalletFundUrl(),
- };
+ return getCoinbaseSmartWalletFundUrl();
}
if (projectId === null || address === undefined) {
return undefined;
}
- return {
- url: getOnrampBuyUrl({
- projectId,
- addresses: { [address]: [chain.name.toLowerCase()] },
- }),
- // The Coinbase Onramp widget is not very responsive, so we need to set a fixed popup size.
- popupHeight: ONRAMP_POPUP_HEIGHT,
- popupWidth: ONRAMP_POPUP_WIDTH,
- };
+ return getOnrampBuyUrl({
+ projectId,
+ addresses: { [address]: [chain.name.toLowerCase()] },
+ });
}, [isCoinbaseSmartWallet, projectId, address, chain]);
}
diff --git a/src/fund/index.ts b/src/fund/index.ts
index 955b3a18c8..88e1fecbc2 100644
--- a/src/fund/index.ts
+++ b/src/fund/index.ts
@@ -1,3 +1,4 @@
+export { FundButton } from './components/FundButton';
export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl';
export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl';
export type {
diff --git a/src/fund/types.ts b/src/fund/types.ts
index 6acbc3f604..ea01fad030 100644
--- a/src/fund/types.ts
+++ b/src/fund/types.ts
@@ -59,7 +59,7 @@ export type GetOnrampUrlWithSessionTokenParams = {
} & GetOnrampBuyUrlOptionalProps;
/**
- * The properties used to create an Onramp buy URL.
+ * The optional properties that can be used to create an Onramp buy URL.
*/
type GetOnrampBuyUrlOptionalProps = {
/**
@@ -72,10 +72,49 @@ type GetOnrampBuyUrlOptionalProps = {
* name in lower case e.g. ethereum, base.
*/
defaultNetwork?: string;
+ /**
+ * A unique identifier that will be associated with any transactions created by the user during their Onramp session.
+ * You can use this with the Transaction Status API to check the status of the user's transaction.
+ * See https://docs.cdp.coinbase.com/onramp/docs/api-reporting#buy-transaction-status
+ */
+ partnerUserId?: string;
+ /**
+ * This amount will be used to pre-fill the amount of crypto the user is buying or sending. The user can choose to
+ * change this amount in the UI. Only one of presetCryptoAmount or presetFiatAmount should be provided.
+ */
+ presetCryptoAmount?: number;
+ /**
+ * This amount will be used to pre-fill the fiat value of the crypto the user is buying or sending. The user can
+ * choose to change this amount in the UI. Only one of presetCryptoAmount or presetFiatAmount should be provided.
+ */
+ presetFiatAmount?: number;
+ /**
+ * The currency code of the fiat amount provided in the presetFiatAmount param e.g. USD, CAD, EUR.
+ */
+ fiatCurrency?: string;
+ /**
+ * A URL that the user will be automatically redirected to after a successful buy/send. The domain must match a domain
+ * on the domain allowlist in Coinbase Developer Platform (https://portal.cdp.coinbase.com/products/onramp).
+ */
+ redirectUrl?: string;
};
-export type UseGetFundingUrlResponse = {
- url: string;
- popupHeight?: number;
- popupWidth?: number;
+/**
+ * Note: exported as public Type
+ */
+export type FundButtonReact = {
+ className?: string; // An optional CSS class name for styling the button component
+ disabled?: boolean; // A optional prop to disable the fund button
+ text?: string; // An optional text to be displayed in the button component
+ hideText?: boolean; // An optional prop to hide the text in the button component
+ hideIcon?: boolean; // An optional prop to hide the icon in the button component
+ fundingUrl?: string; // An optional prop to provide a custom funding URL
+ openIn?: 'popup' | 'tab'; // Whether to open the funding flow in a tab or a popup window
+ /**
+ * Note: popupSize will be ignored when using a Coinbase Onramp URL (i.e. https://pay.coinbase.com/*) as it requires
+ * a fixed popup size.
+ */
+ 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
};
diff --git a/src/fund/utils/getFundingPopupSize.test.ts b/src/fund/utils/getFundingPopupSize.test.ts
new file mode 100644
index 0000000000..9121324fc4
--- /dev/null
+++ b/src/fund/utils/getFundingPopupSize.test.ts
@@ -0,0 +1,37 @@
+import { type Mock, afterEach, describe, expect, it, vi } from 'vitest';
+import { getWindowDimensions } from '../../internal/utils/getWindowDimensions';
+import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
+import { getFundingPopupSize } from './getFundingPopupSize';
+
+vi.mock('../../internal/utils/getWindowDimensions', () => ({
+ getWindowDimensions: vi.fn(),
+}));
+
+describe('getFundingPopupSize', () => {
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('returns size based on window dimensions when no fundingUrl is provided', () => {
+ (getWindowDimensions as Mock).mockReturnValue({ height: 200, width: 100 });
+ const result = getFundingPopupSize('md');
+ expect(result).toEqual({ height: 200, width: 100 });
+ expect(getWindowDimensions).toHaveBeenCalledWith('md');
+ });
+
+ it('returns size based on window dimensions when fundingUrl is not an Onramp fund URL', () => {
+ (getWindowDimensions as Mock).mockReturnValue({ height: 200, width: 100 });
+ const result = getFundingPopupSize('lg', 'https://fund.url');
+ expect(result).toEqual({ height: 200, width: 100 });
+ expect(getWindowDimensions).toHaveBeenCalledWith('lg');
+ });
+
+ it('returns Onramp Popup size when fundingUrl matches an Onramp fund URL', () => {
+ const result = getFundingPopupSize('md', 'https://pay.coinbase.com/buy');
+ expect(result).toEqual({
+ height: ONRAMP_POPUP_HEIGHT,
+ width: ONRAMP_POPUP_WIDTH,
+ });
+ expect(getWindowDimensions).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/fund/utils/getFundingPopupSize.ts b/src/fund/utils/getFundingPopupSize.ts
new file mode 100644
index 0000000000..02e90838ab
--- /dev/null
+++ b/src/fund/utils/getFundingPopupSize.ts
@@ -0,0 +1,29 @@
+import {
+ getWindowDimensions,
+ type popupSizes,
+} from '../../internal/utils/getWindowDimensions';
+import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '../constants';
+import { ONRAMP_BUY_URL } from '../constants';
+
+type PopupSize = {
+ height: number;
+ width: number;
+};
+
+/**
+ * Gets the appropriate popup dimensions for the given size and funding URL.
+ */
+export function getFundingPopupSize(
+ size: keyof typeof popupSizes,
+ fundingUrl?: string,
+): PopupSize {
+ // The Coinbase Onramp widget is not very responsive, so we need to set a fixed popup size.
+ if (fundingUrl?.includes(ONRAMP_BUY_URL)) {
+ return {
+ height: ONRAMP_POPUP_HEIGHT,
+ width: ONRAMP_POPUP_WIDTH,
+ };
+ }
+
+ return getWindowDimensions(size);
+}
diff --git a/src/fund/utils/getOnrampBuyUrl.ts b/src/fund/utils/getOnrampBuyUrl.ts
index 206a47dabd..2ef7992f88 100644
--- a/src/fund/utils/getOnrampBuyUrl.ts
+++ b/src/fund/utils/getOnrampBuyUrl.ts
@@ -1,10 +1,9 @@
+import { ONRAMP_BUY_URL } from '../constants';
import type {
GetOnrampUrlWithProjectIdParams,
GetOnrampUrlWithSessionTokenParams,
} from '../types';
-const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy';
-
/**
* Builds a Coinbase Onramp buy URL using the provided parameters.
* @param projectId a projectId generated in the Coinbase Developer Portal
diff --git a/src/internal/components/Spinner.test.tsx b/src/internal/components/Spinner.test.tsx
index 5a2c15b6b1..b8af38a0ae 100644
--- a/src/internal/components/Spinner.test.tsx
+++ b/src/internal/components/Spinner.test.tsx
@@ -12,7 +12,7 @@ describe('Spinner component', () => {
const spinner = spinnerContainer.firstChild;
expect(spinner).toHaveClass(
- 'animate-spin border-2 border-gray-200 border-t-3 rounded-full border-t-blue-500 px-2.5 py-2.5',
+ 'animate-spin border-2 border-gray-200 border-t-3 rounded-full border-t-gray-400 px-2.5 py-2.5',
);
});
});
diff --git a/src/internal/components/Spinner.tsx b/src/internal/components/Spinner.tsx
index 73bc98dad8..aa18a4f2ee 100644
--- a/src/internal/components/Spinner.tsx
+++ b/src/internal/components/Spinner.tsx
@@ -13,7 +13,7 @@ export function Spinner({ className }: SpinnerReact) {
diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx
new file mode 100644
index 0000000000..dda9e6e613
--- /dev/null
+++ b/src/internal/svg/addSvg.tsx
@@ -0,0 +1,19 @@
+import { fill } from '../../styles/theme';
+
+export const addSvg = (
+
+);
diff --git a/src/wallet/utils/getWindowDimensions.test.ts b/src/internal/utils/getWindowDimensions.test.ts
similarity index 100%
rename from src/wallet/utils/getWindowDimensions.test.ts
rename to src/internal/utils/getWindowDimensions.test.ts
diff --git a/src/wallet/utils/getWindowDimensions.ts b/src/internal/utils/getWindowDimensions.ts
similarity index 87%
rename from src/wallet/utils/getWindowDimensions.ts
rename to src/internal/utils/getWindowDimensions.ts
index eb5ee11268..bd22fe8aa6 100644
--- a/src/wallet/utils/getWindowDimensions.ts
+++ b/src/internal/utils/getWindowDimensions.ts
@@ -1,6 +1,12 @@
-import type { WindowSizes } from '../types';
+export type WindowSizes = Record<
+ 'sm' | 'md' | 'lg',
+ {
+ width: string;
+ height: string;
+ }
+>;
-const popupSizes: WindowSizes = {
+export const popupSizes: WindowSizes = {
sm: { width: '24.67vw', height: '30.83vw' },
md: { width: '29vw', height: '36.25vw' },
lg: { width: '35vw', height: '43.75vw' },
diff --git a/src/internal/utils/openPopup.test.ts b/src/internal/utils/openPopup.test.ts
new file mode 100644
index 0000000000..2745d1286e
--- /dev/null
+++ b/src/internal/utils/openPopup.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { openPopup } from './openPopup';
+
+const mockOpen = vi.fn();
+
+describe('openPopup', () => {
+ beforeEach(() => {
+ vi.stubGlobal('open', mockOpen);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('opens a popup in the center of the screen', () => {
+ vi.stubGlobal('screen', { width: 1200, height: 900 });
+
+ openPopup({
+ url: 'https://my.popup.com',
+ height: 200,
+ width: 100,
+ });
+
+ expect(mockOpen).toHaveBeenCalledWith(
+ expect.stringContaining('https://my.popup.com'),
+ undefined,
+ expect.stringContaining(
+ 'width=100,height=200,resizable,scrollbars=yes,status=1,left=550,top=350',
+ ),
+ );
+ });
+});
diff --git a/src/internal/utils/openPopup.ts b/src/internal/utils/openPopup.ts
new file mode 100644
index 0000000000..4b5ff6a7ca
--- /dev/null
+++ b/src/internal/utils/openPopup.ts
@@ -0,0 +1,18 @@
+type OpenPopupProps = {
+ url: string;
+ height: number;
+ width: number;
+ target?: string;
+};
+
+/**
+ * Open a popup in the center of the screen with the specified size.
+ */
+export function openPopup({ url, target, height, width }: OpenPopupProps) {
+ // Center the popup window in the screen
+ const left = Math.round((window.screen.width - width) / 2);
+ const top = Math.round((window.screen.height - height) / 2);
+
+ const windowFeatures = `width=${width},height=${height},resizable,scrollbars=yes,status=1,left=${left},top=${top}`;
+ window.open(url, target, windowFeatures);
+}
diff --git a/src/styles/index.css b/src/styles/index.css
index a096e72c77..91db93392b 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -8,6 +8,10 @@
}
/* Fill */
+.fill-ock-default {
+ fill: var(--bg-ock-default);
+}
+
.fill-ock-default-reverse {
fill: var(--bg-ock-default-reverse);
}
diff --git a/src/styles/theme.ts b/src/styles/theme.ts
index c166d237a8..06136277cf 100644
--- a/src/styles/theme.ts
+++ b/src/styles/theme.ts
@@ -28,7 +28,7 @@ export const pressable = {
secondary:
'cursor-pointer bg-ock-secondary active:bg-ock-secondary-active hover:bg-[var(--bg-ock-secondary-hover)]',
shadow: 'shadow-ock-default',
- disabled: 'opacity-[0.38]',
+ disabled: 'opacity-[0.38] pointer-events-none',
} as const;
export const background = {
@@ -54,6 +54,7 @@ export const color = {
} as const;
export const fill = {
+ default: 'fill-ock-default',
defaultReverse: 'fill-ock-default-reverse',
inverse: 'fill-ock-inverse',
} as const;
diff --git a/src/wallet/components/WalletDropdownFundLink.test.tsx b/src/wallet/components/WalletDropdownFundLink.test.tsx
index 7da33da01c..c43668d25c 100644
--- a/src/wallet/components/WalletDropdownFundLink.test.tsx
+++ b/src/wallet/components/WalletDropdownFundLink.test.tsx
@@ -1,59 +1,93 @@
import '@testing-library/jest-dom';
-import { render } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { type Mock, afterEach, describe, expect, it, vi } from 'vitest';
import { useGetFundingUrl } from '../../fund/hooks/useGetFundingUrl';
+import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize';
+import { openPopup } from '../../internal/utils/openPopup';
import { WalletDropdownFundLink } from './WalletDropdownFundLink';
vi.mock('../../fund/hooks/useGetFundingUrl', () => ({
useGetFundingUrl: vi.fn(),
}));
-const mockWalletDropdownFundLinkButton = vi.fn();
-vi.mock('./WalletDropdownFundLinkButton', () => ({
- WalletDropdownFundLinkButton: (props) => {
- mockWalletDropdownFundLinkButton(props);
- return ;
- },
+vi.mock('../../fund/utils/getFundingPopupSize', () => ({
+ getFundingPopupSize: vi.fn(),
}));
-describe('WalletDropdownFund', () => {
+vi.mock('../../internal/utils/openPopup', () => ({
+ openPopup: vi.fn(),
+}));
+
+describe('WalletDropdownFundLink', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders the fund link button with the fundingUrl prop when it is defined', () => {
- render();
+ const fundingUrl = 'https://props.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
+
+ render();
+
+ expect(useGetFundingUrl).not.toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+ expect(screen.getByText('Fund wallet')).toBeInTheDocument();
- expect(mockWalletDropdownFundLinkButton).toHaveBeenCalledWith(
- expect.objectContaining({
- fundingUrl: 'https://props.funding.url',
- }),
- );
+ fireEvent.click(buttonElement);
+ expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
+ expect(openPopup as Mock).toHaveBeenCalledWith({
+ url: fundingUrl,
+ height,
+ width,
+ target: undefined,
+ });
});
it('renders the fund link button with the default fundingUrl when the fundingUrl prop is undefined', () => {
- (useGetFundingUrl as Mock).mockReturnValue({
- url: 'https://default.funding.url',
- popupHeight: 100,
- popupWidth: 100,
- });
+ const fundingUrl = 'https://default.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (useGetFundingUrl as Mock).mockReturnValue(fundingUrl);
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
render();
- expect(mockWalletDropdownFundLinkButton).toHaveBeenCalledWith(
- expect.objectContaining({
- fundingUrl: 'https://default.funding.url',
- popupHeightOverride: 100,
- popupWidthOverride: 100,
- }),
- );
+ expect(useGetFundingUrl).toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+
+ fireEvent.click(buttonElement);
+ expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl);
+ expect(openPopup as Mock).toHaveBeenCalledWith({
+ url: fundingUrl,
+ height,
+ width,
+ target: undefined,
+ });
});
- it('does not render the fund link when the fundingUrl prop is undefined and useGetFundingUrl returns undefined', () => {
+ it('renders a disabled fund link button when no funding URL is provided and the default cannot be fetched', () => {
(useGetFundingUrl as Mock).mockReturnValue(undefined);
render();
- expect(mockWalletDropdownFundLinkButton).not.toHaveBeenCalled();
+ expect(useGetFundingUrl).toHaveBeenCalled();
+ const buttonElement = screen.getByRole('button');
+ expect(buttonElement).toHaveClass('pointer-events-none');
+
+ fireEvent.click(buttonElement);
+ expect(openPopup as Mock).not.toHaveBeenCalled();
+ });
+
+ it('renders the fund link as a link when the openIn prop is set to tab', () => {
+ const fundingUrl = 'https://props.funding.url';
+ const { height, width } = { height: 200, width: 100 };
+ (getFundingPopupSize as Mock).mockReturnValue({ height, width });
+
+ render();
+
+ expect(useGetFundingUrl).not.toHaveBeenCalled();
+ const linkElement = screen.getByRole('link');
+ expect(screen.getByText('Fund wallet')).toBeInTheDocument();
+ expect(linkElement).toHaveAttribute('href', fundingUrl);
});
});
diff --git a/src/wallet/components/WalletDropdownFundLink.tsx b/src/wallet/components/WalletDropdownFundLink.tsx
index 6c240e47ee..5d20e5cc6f 100644
--- a/src/wallet/components/WalletDropdownFundLink.tsx
+++ b/src/wallet/components/WalletDropdownFundLink.tsx
@@ -1,34 +1,82 @@
+import { useCallback, useMemo } from 'react';
import { useGetFundingUrl } from '../../fund/hooks/useGetFundingUrl';
+import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize';
+import { openPopup } from '../../internal/utils/openPopup';
+import { cn, pressable, text as themeText } from '../../styles/theme';
+import { useIcon } from '../hooks/useIcon';
import type { WalletDropdownFundLinkReact } from '../types';
-import { WalletDropdownFundLinkButton } from './WalletDropdownFundLinkButton';
export function WalletDropdownFundLink({
+ className,
fundingUrl,
- ...props
+ icon = 'fundWallet',
+ openIn = 'popup',
+ popupSize = 'md',
+ rel,
+ target,
+ text = 'Fund wallet',
}: WalletDropdownFundLinkReact) {
- const defaultFundingUrl = useGetFundingUrl();
+ // If we can't get a funding URL, this component will be a no-op and render a disabled link
+ const fundingUrlToRender = fundingUrl ?? useGetFundingUrl();
+ const iconSvg = useIcon({ icon });
- // If the fundingUrl prop is undefined, fallback to the default funding URL
- const fundingUrlToRender = fundingUrl ?? defaultFundingUrl?.url;
- const popupHeightOverride = fundingUrl
- ? undefined
- : defaultFundingUrl?.popupHeight;
- const popupWidthOverride = fundingUrl
- ? undefined
- : defaultFundingUrl?.popupWidth;
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (fundingUrlToRender) {
+ const { height, width } = getFundingPopupSize(
+ popupSize,
+ fundingUrlToRender,
+ );
+ openPopup({
+ url: fundingUrlToRender,
+ height,
+ width,
+ target,
+ });
+ }
+ },
+ [fundingUrlToRender, popupSize, target],
+ );
- if (fundingUrlToRender) {
+ const overrideClassName = cn(
+ pressable.default,
+ // Disable hover effects if there is no funding URL
+ !fundingUrlToRender && 'pointer-events-none',
+ 'relative flex items-center px-4 py-3 w-full',
+ className,
+ );
+
+ const linkContent = useMemo(
+ () => (
+ // We put disabled on the content wrapper rather than the button/link because we dont wan't to change the
+ // background color of the dropdown item, just the text and icon
+
+
+ {iconSvg}
+
+ {text}
+
+ ),
+ [fundingUrlToRender, iconSvg, text],
+ );
+
+ if (openIn === 'tab') {
return (
-
+
+ {linkContent}
+
);
}
- // If the fundingUrl prop is undefined, and we couldn't get a default funding URL (maybe there is no wallet connected,
- // or projectId is undefined in OnchainKitConfig), don't render anything
- return null;
+ return (
+
+ );
}
diff --git a/src/wallet/components/WalletDropdownFundLinkButton.test.tsx b/src/wallet/components/WalletDropdownFundLinkButton.test.tsx
deleted file mode 100644
index 5fd02fdde6..0000000000
--- a/src/wallet/components/WalletDropdownFundLinkButton.test.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import '@testing-library/jest-dom';
-import { fireEvent, render, screen } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { WindowSizes } from '../types';
-import { WalletDropdownFundLinkButton } from './WalletDropdownFundLinkButton';
-
-describe('WalletDropdownFundLinkButton', () => {
- beforeEach(() => {
- // Mock window.location
- vi.spyOn(window, 'location', 'get').mockReturnValue({
- href: 'http://localhost:3000/',
- } as Location);
-
- // Mock document.title
- Object.defineProperty(document, 'title', {
- value: '',
- writable: true,
- });
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('renders correctly with default props', () => {
- render(
- ,
- );
-
- const buttonElement = screen.getByRole('button');
- expect(buttonElement).toBeInTheDocument();
- expect(screen.getByText('Fund wallet')).toBeInTheDocument();
- });
-
- it('renders correctly with custom icon element', () => {
- const customIcon = ;
- render(
- ,
- );
-
- const buttonElement = screen.getByRole('button');
- expect(buttonElement).toBeInTheDocument();
- expect(screen.getByText('Fund wallet')).toBeInTheDocument();
- expect(screen.getByLabelText('custom-icon')).toBeInTheDocument();
- });
-
- it('renders correctly with custom text', () => {
- render(
- ,
- );
-
- const buttonElement = screen.getByRole('button');
- expect(buttonElement).toBeInTheDocument();
- expect(screen.getByText('test')).toBeInTheDocument();
- });
-
- it('opens a new window when clicked with openIn="popup" (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 buttonElement = screen.getByText('Fund wallet');
- fireEvent.click(buttonElement);
-
- // Check if window.open was called with the correct arguments
- expect(mockOpen).toHaveBeenCalledWith(
- expect.stringContaining('https://pay.coinbase.com'),
- undefined,
- expect.stringContaining(
- 'width=297,height=371,resizable,scrollbars=yes,status=1,left=364,top=199',
- ),
- );
-
- // Clean up
- vi.unstubAllGlobals();
- });
-
- it('renders as a link when openIn="tab"', () => {
- render(
- ,
- );
-
- const linkElement = screen.getByRole('link');
- expect(linkElement).toBeInTheDocument();
- expect(linkElement).toHaveAttribute(
- 'href',
- expect.stringContaining('https://pay.coinbase.com'),
- );
- expect(screen.getByText('Fund wallet')).toBeInTheDocument();
- });
-
- 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 openIn="popup" 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('https://pay.coinbase.com'),
- undefined,
- expect.stringContaining(
- `width=${expectedWidth},height=${adjustedHeight},resizable,scrollbars=yes,status=1,left=${expectedLeft},top=${expectedTop}`,
- ),
- );
-
- vi.unstubAllGlobals();
- vi.clearAllMocks();
- });
- }
-
- it(`opens a new window when clicked with openIn="popup" with popup size override props`, () => {
- const mockOpen = vi.fn();
- const screenWidth = 1024;
- const screenHeight = 768;
-
- vi.stubGlobal('open', mockOpen);
- vi.stubGlobal('screen', { width: screenWidth, height: screenHeight });
-
- render(
- ,
- );
-
- const linkElement = screen.getByText('Fund wallet');
- fireEvent.click(linkElement);
-
- expect(mockOpen).toHaveBeenCalledWith(
- expect.stringContaining('https://pay.coinbase.com'),
- undefined,
- expect.stringContaining('width=430,height=600'),
- );
-
- vi.unstubAllGlobals();
- vi.clearAllMocks();
- });
-});
diff --git a/src/wallet/components/WalletDropdownFundLinkButton.tsx b/src/wallet/components/WalletDropdownFundLinkButton.tsx
deleted file mode 100644
index 6e6fdc55de..0000000000
--- a/src/wallet/components/WalletDropdownFundLinkButton.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { cn, pressable, text as themeText } from '../../styles/theme';
-import { useIcon } from '../hooks/useIcon';
-import type { WalletDropdownFundLinkReact } from '../types';
-import { getWindowDimensions } from '../utils/getWindowDimensions';
-
-type WalletDropdownFundLinkButtonPrivateProps = {
- popupHeightOverride?: number;
- popupWidthOverride?: number;
-};
-
-export function WalletDropdownFundLinkButton({
- className,
- icon = 'fundWallet',
- openIn = 'popup',
- popupSize = 'md',
- rel,
- target,
- text = 'Fund wallet',
- fundingUrl,
- popupHeightOverride,
- popupWidthOverride,
-}: Required> &
- WalletDropdownFundLinkReact &
- WalletDropdownFundLinkButtonPrivateProps) {
- const iconSvg = useIcon({ icon });
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- let { width, height } = getWindowDimensions(popupSize);
-
- if (popupHeightOverride && popupWidthOverride) {
- width = popupWidthOverride;
- height = popupHeightOverride;
- }
-
- const left = Math.round((window.screen.width - width) / 2);
- const top = Math.round((window.screen.height - height) / 2);
-
- const windowFeatures = `width=${width},height=${height},resizable,scrollbars=yes,status=1,left=${left},top=${top}`;
- window.open(fundingUrl, target, windowFeatures);
- },
- [fundingUrl, popupSize, target, popupWidthOverride, popupHeightOverride],
- );
-
- const overrideClassName = cn(
- pressable.default,
- 'relative flex items-center px-4 py-3 w-full',
- className,
- );
-
- const linkContent = useMemo(
- () => (
- <>
-
- {iconSvg}
-
- {text}
- >
- ),
- [iconSvg, text],
- );
-
- if (openIn === 'tab') {
- return (
-
- {linkContent}
-
- );
- }
- return (
-
- );
-}
diff --git a/src/wallet/types.ts b/src/wallet/types.ts
index 2335a4bb55..61dd253780 100644
--- a/src/wallet/types.ts
+++ b/src/wallet/types.ts
@@ -145,11 +145,3 @@ export type WalletDropdownLinkReact = {
rel?: string;
target?: string;
};
-
-export type WindowSizes = Record<
- 'sm' | 'md' | 'lg',
- {
- width: string;
- height: string;
- }
->;