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; - } ->;