diff --git a/playground/nextjs-app-router/components/Demo.tsx b/playground/nextjs-app-router/components/Demo.tsx index 2052e425c9..498d195e55 100644 --- a/playground/nextjs-app-router/components/Demo.tsx +++ b/playground/nextjs-app-router/components/Demo.tsx @@ -7,7 +7,8 @@ import { useContext, useEffect, useState } from 'react'; import DemoOptions from './DemoOptions'; import BuyDemo from './demo/Buy'; import CheckoutDemo from './demo/Checkout'; -import FundDemo from './demo/Fund'; +import FundButtonDemo from './demo/FundButton'; +import FundCardDemo from './demo/FundCard'; import IdentityDemo from './demo/Identity'; import { IdentityCardDemo } from './demo/IdentityCard'; import NFTCardDemo from './demo/NFTCard'; @@ -24,8 +25,9 @@ import WalletDefaultDemo from './demo/WalletDefault'; import WalletIslandDemo from './demo/WalletIsland'; const activeComponentMapping: Record = { + [OnchainKitComponent.FundButton]: FundButtonDemo, + [OnchainKitComponent.FundCard]: FundCardDemo, [OnchainKitComponent.Buy]: BuyDemo, - [OnchainKitComponent.Fund]: FundDemo, [OnchainKitComponent.Identity]: IdentityDemo, [OnchainKitComponent.Transaction]: TransactionDemo, [OnchainKitComponent.Checkout]: CheckoutDemo, diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/FundButton.tsx similarity index 78% rename from playground/nextjs-app-router/components/demo/Fund.tsx rename to playground/nextjs-app-router/components/demo/FundButton.tsx index dfc49a6280..651a18b318 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/FundButton.tsx @@ -1,6 +1,6 @@ import { FundButton } from '@coinbase/onchainkit/fund'; -export default function FundDemo() { +export default function FundButtonDemo() { return (
diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx new file mode 100644 index 0000000000..261379f570 --- /dev/null +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -0,0 +1,20 @@ +import { FundCard } from '@coinbase/onchainkit/fund'; +export default function FundCardDemo() { + return ( +
+ { + console.log('FundCard onError', error); + }} + onStatus={(status) => { + console.log('FundCard onStatus', status); + }} + onSuccess={() => { + console.log('FundCard onSuccess'); + }} + /> +
+ ); +} diff --git a/playground/nextjs-app-router/components/form/active-component.tsx b/playground/nextjs-app-router/components/form/active-component.tsx index 2af203956e..292f831ab5 100644 --- a/playground/nextjs-app-router/components/form/active-component.tsx +++ b/playground/nextjs-app-router/components/form/active-component.tsx @@ -26,8 +26,13 @@ export function ActiveComponent() { + + Fund Button + + + Fund Card + Buy - Fund Identity IdentityCard diff --git a/playground/nextjs-app-router/types/onchainkit.ts b/playground/nextjs-app-router/types/onchainkit.ts index d2100c0b88..00d1c8a5b3 100644 --- a/playground/nextjs-app-router/types/onchainkit.ts +++ b/playground/nextjs-app-router/types/onchainkit.ts @@ -1,6 +1,7 @@ export enum OnchainKitComponent { + FundButton = 'fund-button', + FundCard = 'fund-card', Buy = 'buy', - Fund = 'fund', Identity = 'identity', IdentityCard = 'identity-card', Checkout = 'checkout', diff --git a/src/buy/components/BuyOnrampItem.test.tsx b/src/buy/components/BuyOnrampItem.test.tsx index 3b72fb3aad..41eb0ed232 100644 --- a/src/buy/components/BuyOnrampItem.test.tsx +++ b/src/buy/components/BuyOnrampItem.test.tsx @@ -36,9 +36,9 @@ describe('BuyOnrampItem', () => { ); expect(screen.getByRole('button')).toBeInTheDocument(); - expect(screen.getByText('Apple Pay')).toBeInTheDocument(); + expect(screen.getByTestId('ock-applePayOnrampItem')).toBeInTheDocument(); expect(screen.getByText('Fast and secure payments.')).toBeInTheDocument(); - expect(screen.getByTestId('appleSvg')).toBeInTheDocument(); + expect(screen.getByTestId('ock-applePaySvg')).toBeInTheDocument(); }); it('handles icon rendering based on the icon prop', () => { diff --git a/src/buy/components/BuyOnrampItem.tsx b/src/buy/components/BuyOnrampItem.tsx index 12342af12e..bb607b7ab9 100644 --- a/src/buy/components/BuyOnrampItem.tsx +++ b/src/buy/components/BuyOnrampItem.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { appleSvg } from '../../internal/svg/appleSvg'; +import { applePaySvg } from '../../internal/svg/applePaySvg'; import { cardSvg } from '../../internal/svg/cardSvg'; import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; import { cn, color, pressable, text } from '../../styles/theme'; @@ -15,7 +15,7 @@ type OnrampItemReact = { }; const ONRAMP_ICON_MAP: Record = { - applePay: appleSvg, + applePay: applePaySvg, coinbasePay: coinbaseLogoSvg, creditCard: cardSvg, }; diff --git a/src/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index 6c7f7a7595..50459a8710 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,8 +1,12 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { applePaySvg } from '../../../internal/svg/applePaySvg'; +import { appleSvg } from '../../../internal/svg/appleSvg'; import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +import { creditCardSvg } from '../../../internal/svg/creditCardSvg'; import { fundWalletSvg } from '../../../internal/svg/fundWallet'; import { swapSettingsSvg } from '../../../internal/svg/swapSettings'; +import { toggleSvg } from '../../../internal/svg/toggleSvg'; import { walletSvg } from '../../../internal/svg/walletSvg'; import { useIcon } from './useIcon'; @@ -17,7 +21,27 @@ describe('useIcon', () => { expect(result.current).toBe(walletSvg); }); - it('should return coinbasePaySvg when icon is "coinbasePay"', () => { + it('should return toggleSvg when icon is "toggle"', () => { + const { result } = renderHook(() => useIcon({ icon: 'toggle' })); + expect(result.current).toBe(toggleSvg); + }); + + it('should return appleSvg when icon is "apple"', () => { + const { result } = renderHook(() => useIcon({ icon: 'apple' })); + expect(result.current).toBe(appleSvg); + }); + + it('should return applePaySvg when icon is "applePay"', () => { + const { result } = renderHook(() => useIcon({ icon: 'applePay' })); + expect(result.current).toBe(applePaySvg); + }); + + it('should return creditCardSvg when icon is "creditCard"', () => { + const { result } = renderHook(() => useIcon({ icon: 'creditCard' })); + expect(result.current).toBe(creditCardSvg); + }); + + it('should return CoinbasePaySvg when icon is "coinbasePay"', () => { const { result } = renderHook(() => useIcon({ icon: 'coinbasePay' })); expect(result.current).toBe(coinbasePaySvg); }); diff --git a/src/core-react/internal/hooks/useIcon.tsx b/src/core-react/internal/hooks/useIcon.tsx index d7214f12dd..e76136f79d 100644 --- a/src/core-react/internal/hooks/useIcon.tsx +++ b/src/core-react/internal/hooks/useIcon.tsx @@ -1,7 +1,12 @@ +import { applePaySvg } from '@/internal/svg/applePaySvg'; import { isValidElement, useMemo } from 'react'; +import { appleSvg } from '../../../internal/svg/appleSvg'; +import { coinbaseLogoSvg } from '../../../internal/svg/coinbaseLogoSvg'; import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +import { creditCardSvg } from '../../../internal/svg/creditCardSvg'; import { fundWalletSvg } from '../../../internal/svg/fundWallet'; import { swapSettingsSvg } from '../../../internal/svg/swapSettings'; +import { toggleSvg } from '../../../internal/svg/toggleSvg'; import { walletSvg } from '../../../internal/svg/walletSvg'; export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { @@ -12,12 +17,22 @@ export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { switch (icon) { case 'coinbasePay': return coinbasePaySvg; + case 'coinbaseLogo': + return coinbaseLogoSvg; case 'fundWallet': return fundWalletSvg; case 'swapSettings': return swapSettingsSvg; case 'wallet': return walletSvg; + case 'toggle': + return toggleSvg; + case 'applePay': + return applePaySvg; + case 'apple': + return appleSvg; + case 'creditCard': + return creditCardSvg; } if (isValidElement(icon)) { return icon; diff --git a/src/fund/components/FundButton.test.tsx b/src/fund/components/FundButton.test.tsx index af8e4631db..6401bfd8ca 100644 --- a/src/fund/components/FundButton.test.tsx +++ b/src/fund/components/FundButton.test.tsx @@ -1,7 +1,17 @@ -import '@testing-library/jest-dom'; +import { pressable } from '@/styles/theme'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; +import '@testing-library/jest-dom'; import { fireEvent, render, screen } from '@testing-library/react'; -import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { useAccount } from 'wagmi'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundButton } from './FundButton'; @@ -22,7 +32,41 @@ vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: vi.fn(), })); -describe('WalletDropdownFundLink', () => { +vi.mock('../../wallet/components/ConnectWallet', () => ({ + ConnectWallet: ({ className }: { className?: string }) => ( +
+ Connect Wallet +
+ ), +})); + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), +})); + +const mockResponseData = { + payment_total: { value: '100.00', currency: 'USD' }, + payment_subtotal: { value: '120.00', currency: 'USD' }, + purchase_amount: { value: '0.1', currency: 'BTC' }, + coinbase_fee: { value: '2.00', currency: 'USD' }, + network_fee: { value: '1.00', currency: 'USD' }, + quote_id: 'quote-id-123', +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('FundButton', () => { + beforeEach(() => { + (useAccount as Mock).mockReturnValue({ + address: '0x123', + }); + }); + afterEach(() => { vi.clearAllMocks(); }); @@ -34,7 +78,6 @@ describe('WalletDropdownFundLink', () => { render(); - expect(useGetFundingUrl).not.toHaveBeenCalled(); const buttonElement = screen.getByRole('button'); expect(screen.getByText('Fund')).toBeInTheDocument(); @@ -69,29 +112,114 @@ describe('WalletDropdownFundLink', () => { }); }); - it('renders a disabled fund button when no funding URL is provided and the default cannot be fetched', () => { - (useGetFundingUrl as Mock).mockReturnValue(undefined); + 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(); + render(); - expect(useGetFundingUrl).toHaveBeenCalled(); - const buttonElement = screen.getByRole('button'); - expect(buttonElement).toHaveClass('pointer-events-none'); + const linkElement = screen.getByRole('link'); + expect(screen.getByText('Fund')).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', fundingUrl); + }); + + it('displays a spinner when in loading state', () => { + render(); + expect(screen.getByTestId('ockSpinner')).toBeInTheDocument(); + }); + + it('displays success text when in success state', () => { + render(); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Success', + ); + }); + + it('displays error text when in error state', () => { + render(); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Something went wrong', + ); + }); + + it('adds disabled class when the button is disabled', () => { + render(); + expect(screen.getByRole('button')).toHaveClass(pressable.disabled); + }); + it('calls onPopupClose when the popup window is closed', () => { + vi.useFakeTimers(); + const fundingUrl = 'https://props.funding.url'; + const onPopupClose = vi.fn(); + const { height, width } = { height: 200, width: 100 }; + const mockPopupWindow = { + closed: false, + close: vi.fn(), + }; + (getFundingPopupSize as Mock).mockReturnValue({ height, width }); + (openPopup as Mock).mockReturnValue(mockPopupWindow); + + render(); + + const buttonElement = screen.getByRole('button'); fireEvent.click(buttonElement); - expect(openPopup as Mock).not.toHaveBeenCalled(); + + // Simulate closing the popup + mockPopupWindow.closed = true; + vi.runOnlyPendingTimers(); + + expect(onPopupClose).toHaveBeenCalled(); }); - it('renders the fund button as a link when the openIn prop is set to tab', () => { + it('calls onClick when the fund button is clicked', () => { const fundingUrl = 'https://props.funding.url'; + const onClick = vi.fn(); const { height, width } = { height: 200, width: 100 }; (getFundingPopupSize as Mock).mockReturnValue({ height, width }); - render(); + render(); - expect(useGetFundingUrl).not.toHaveBeenCalled(); - const linkElement = screen.getByRole('link'); - expect(screen.getByText('Fund')).toBeInTheDocument(); - expect(linkElement).toHaveAttribute('href', fundingUrl); + const buttonElement = screen.getByRole('button'); + fireEvent.click(buttonElement); + + expect(onClick).toHaveBeenCalled(); + expect(getFundingPopupSize as Mock).toHaveBeenCalledWith('md', fundingUrl); + expect(openPopup as Mock).toHaveBeenCalledWith({ + url: fundingUrl, + height, + width, + target: undefined, + }); + }); + + it('icon is not shown when hideIcon is passed', () => { + render(); + expect(screen.queryByTestId('ockFundButtonIcon')).not.toBeInTheDocument(); + }); + + it('shows ConnectWallet when no wallet is connected', () => { + (useAccount as Mock).mockReturnValue({ + address: undefined, + }); + + render(); + + expect( + screen.queryByTestId('ockConnectWallet_Container'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('ockFundButton')).not.toBeInTheDocument(); + expect(screen.getByTestId('ockConnectWallet_Container')).toHaveClass( + 'custom-class', + ); + }); + + it('shows Fund button when wallet is connected', () => { + render(); + + expect(screen.queryByTestId('ockFundButton')).toBeInTheDocument(); + expect( + screen.queryByTestId('ockConnectWallet_Container'), + ).not.toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index a5e78fac74..4e346c7d74 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -2,10 +2,18 @@ import { useCallback } from 'react'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; -import { addSvg } from '../../internal/svg/addSvg'; import { border, cn, color, pressable, text } from '../../styles/theme'; +import { usePopupMonitor } from '@/buy/hooks/usePopupMonitor'; +import { ErrorSvg } from '@/internal/svg/errorSvg'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; +import { useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { Spinner } from '../../internal/components/Spinner'; +import { AddSvg } from '../../internal/svg/addSvg'; +import { SuccessSvg } from '../../internal/svg/successSvg'; +import { background } from '../../styles/theme'; +import { ConnectWallet } from '../../wallet/components/ConnectWallet'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; @@ -21,49 +29,119 @@ export function FundButton({ rel, target, text: buttonText = 'Fund', + successText: buttonSuccessText = 'Success', + errorText: buttonErrorText = 'Something went wrong', + state: buttonState = 'default', + onPopupClose, + onClick, }: FundButtonReact) { const componentTheme = useTheme(); // If the fundingUrl prop is undefined, fallback to our recommended funding URL based on the wallet type - const fundingUrlToRender = fundingUrl ?? useGetFundingUrl(); + const fallbackFundingUrl = useGetFundingUrl(); + const { address } = useAccount(); + const fundingUrlToRender = fundingUrl ?? fallbackFundingUrl; const isDisabled = disabled || !fundingUrlToRender; + const shouldShowConnectWallet = !address; + + const { startPopupMonitor } = usePopupMonitor(onPopupClose); const handleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + if (fundingUrlToRender) { + onClick?.(); const { height, width } = getFundingPopupSize( popupSize, fundingUrlToRender, ); - openPopup({ + const popupWindow = openPopup({ url: fundingUrlToRender, height, width, target, }); + + if (popupWindow) { + startPopupMonitor(popupWindow); + } } }, - [fundingUrlToRender, popupSize, target], + [fundingUrlToRender, popupSize, target, onClick, startPopupMonitor], ); + const buttonColorClass = useMemo(() => { + if (buttonState === 'error') { + return background.error; + } + return pressable.primary; + }, [buttonState]); + const classNames = cn( componentTheme, - pressable.primary, - 'px-4 py-3 inline-flex items-center justify-center space-x-2 disabled', - isDisabled && pressable.disabled, + buttonColorClass, + 'px-4 py-3 inline-flex items-center justify-center space-x-2', + { + [pressable.disabled]: isDisabled, + }, text.headline, border.radius, 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}} - - ); + const buttonIcon = useMemo(() => { + if (hideIcon) { + return null; + } + switch (buttonState) { + case 'loading': + return ''; + case 'success': + return ; + case 'error': + return ; + default: + return ; + } + }, [buttonState, hideIcon]); + + const buttonTextContent = useMemo(() => { + switch (buttonState) { + case 'loading': + return ''; + case 'success': + return buttonSuccessText; + case 'error': + return buttonErrorText; + default: + return buttonText; + } + }, [buttonState, buttonSuccessText, buttonErrorText, buttonText]); + + const buttonContent = useMemo(() => { + if (buttonState === 'loading') { + return ; + } + + return ( + <> + {buttonIcon && ( + + {buttonIcon} + + )} + {hideText || ( + + {buttonTextContent} + + )} + + ); + }, [buttonState, buttonIcon, buttonTextContent, hideText]); if (openIn === 'tab') { return ( @@ -79,12 +157,17 @@ export function FundButton({ ); } + if (shouldShowConnectWallet) { + return ; + } + return ( diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx new file mode 100644 index 0000000000..bf1985e7ec --- /dev/null +++ b/src/fund/components/FundCard.test.tsx @@ -0,0 +1,296 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; +import '@testing-library/jest-dom'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount } from 'wagmi'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { getFundingPopupSize } from '../utils/getFundingPopupSize'; +import { FundCard } from './FundCard'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; + +const mockUpdateInputWidth = vi.fn(); +vi.mock('../hooks/useInputResize', () => ({ + useInputResize: () => mockUpdateInputWidth, +})); + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: () => 'mocked-theme-class', +})); + +vi.mock('../hooks/useGetFundingUrl', () => ({ + useGetFundingUrl: vi.fn(), +})); + +vi.mock('../hooks/useFundCardFundingUrl', () => ({ + useFundCardFundingUrl: vi.fn(), +})); + +vi.mock('../../useOnchainKit'); + +vi.mock('../utils/setupOnrampEventListeners', () => ({ + setupOnrampEventListeners: vi.fn(), +})); + +vi.mock('@/ui-react/internal/utils/openPopup', () => ({ + openPopup: vi.fn(), +})); + +vi.mock('../utils/getFundingPopupSize', () => ({ + getFundingPopupSize: vi.fn(), +})); + +vi.mock('../hooks/useFundCardSetupOnrampEventListeners'); +vi.mock('../utils/fetchOnrampQuote'); + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), +})); + +vi.mock('../../wallet/components/ConnectWallet', () => ({ + ConnectWallet: ({ className }: { className?: string }) => ( +
+ Connect Wallet +
+ ), +})); + +const mockResponseData = { + paymentTotal: { value: '100.00', currency: 'USD' }, + paymentSubtotal: { value: '120.00', currency: 'USD' }, + purchaseAmount: { value: '0.1', currency: 'BTC' }, + coinbaseFee: { value: '2.00', currency: 'USD' }, + networkFee: { value: '1.00', currency: 'USD' }, + quoteId: 'quote-id-123', +}; + +// Test component to access context values +const TestComponent = () => { + const { + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + setFundAmountFiat, + setSelectedInputType, + } = useFundContext(); + + return ( +
+ {fundAmountFiat} + {fundAmountCrypto} + {exchangeRate} + + {exchangeRateLoading ? 'loading' : 'not-loading'} + +
+ ); +}; + +const renderComponent = () => + render( + + + + , + ); + +describe('FundCard', () => { + beforeEach(() => { + vi.resetAllMocks(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + mockUpdateInputWidth.mockClear(); + (getFundingPopupSize as Mock).mockImplementation(() => ({ + height: 200, + width: 100, + })); + (useFundCardFundingUrl as Mock).mockReturnValue('mock-funding-url'); + (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); + (useAccount as Mock).mockReturnValue({ + address: '0x123', + }); + }); + + it('renders without crashing', () => { + renderComponent(); + expect(screen.getByTestId('ockFundCardHeader')).toBeInTheDocument(); + expect(screen.getByTestId('ockFundButtonTextContent')).toBeInTheDocument(); + }); + + it('displays the correct header text', () => { + renderComponent(); + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( + 'Buy BTC', + ); + }); + + it('displays the correct button text', () => { + renderComponent(); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Buy', + ); + }); + + it('handles input changes for fiat amount', () => { + renderComponent(); + + const input = screen.getByTestId('ockTextInput_Input') as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: '100' } }); + }); + + expect(input.value).toBe('100'); + }); + + it('switches input type from fiat to crypto', async () => { + renderComponent(); + + await waitFor(() => { + const switchButton = screen.getByTestId('ockAmountTypeSwitch'); + fireEvent.click(switchButton); + }); + + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('BTC'); + }); + + it('disables the submit button when fund amount is zero and type is fiat', () => { + renderComponent(); + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + const button = screen.getByTestId('ockFundButton'); + expect(button).toBeDisabled(); + }); + + it('disables the submit button when fund amount is zero and input type is crypto', () => { + renderComponent(); + const setCryptoInputTypeButton = screen.getByTestId( + 'set-crypto-input-type', + ); + fireEvent.click(setCryptoInputTypeButton); + + const button = screen.getByTestId('ockFundButton'); + expect(button).toBeDisabled(); + }); + + it('enables the submit button when fund amount is greater than zero and type is fiat', async () => { + renderComponent(); + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + const input = screen.getByTestId('ockTextInput_Input'); + fireEvent.change(input, { target: { value: '1000' } }); + + const button = screen.getByTestId('ockFundButton'); + expect(button).not.toBeDisabled(); + }); + }); + + it('enables the submit button when fund amount is greater than zero and type is crypto', async () => { + renderComponent(); + const setCryptoInputTypeButton = screen.getByTestId( + 'set-crypto-input-type', + ); + fireEvent.click(setCryptoInputTypeButton); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + const input = screen.getByTestId('ockTextInput_Input'); + fireEvent.change(input, { target: { value: '1000' } }); + + const button = screen.getByTestId('ockFundButton'); + expect(button).not.toBeDisabled(); + }); + }); + + it('shows loading state when submitting', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '1000' } }); + + const button = screen.getByTestId('ockFundButton'); + + expect(screen.queryByTestId('ockSpinner')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(button); + }); + + expect(screen.getByTestId('ockSpinner')).toBeInTheDocument(); + }); + }); + + it('sets submit button state to default on popup close', async () => { + (openPopup as Mock).mockImplementation(() => ({ closed: true })); + + renderComponent(); + + const button = screen.getByTestId('ockFundButton'); + + const submitButton = screen.getByTestId('ockFundButton'); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + + // Simulate entering a valid amount + const input = screen.getByTestId( + 'ockTextInput_Input', + ) as HTMLInputElement; + + fireEvent.change(input, { target: { value: '100' } }); + + fireEvent.click(button); + + // Assert that the submit button state is set to 'default' + expect(submitButton).not.toBeDisabled(); + }); + }); + + it('renders custom children instead of default children', () => { + render( + +
Custom Content
+
, + ); + + expect(screen.getByTestId('custom-child')).toBeInTheDocument(); + expect(screen.queryByTestId('ockFundCardHeader')).not.toBeInTheDocument(); + }); +}); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx new file mode 100644 index 0000000000..5ab00b9ec2 --- /dev/null +++ b/src/fund/components/FundCard.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { background, border, cn, color, text } from '../../styles/theme'; +import { DEFAULT_PAYMENT_METHODS } from '../constants'; +import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; +import type { FundCardPropsReact } from '../types'; +import FundCardAmountInput from './FundCardAmountInput'; +import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; +import { FundCardHeader } from './FundCardHeader'; +import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; +import { FundCardProvider } from './FundCardProvider'; +import { FundCardSubmitButton } from './FundCardSubmitButton'; + +export function FundCard({ + assetSymbol, + buttonText = 'Buy', + headerText, + country = 'US', + subdivision, + children = , + className, + onError, + onStatus, + onSuccess, +}: FundCardPropsReact) { + const componentTheme = useTheme(); + + return ( + +
+ {children} +
+
+ ); +} + +function FundCardContent({ children }: { children: ReactNode }) { + // Setup event listeners for the onramp + useFundCardSetupOnrampEventListeners(); + return ( +
+ {children} +
+ ); +} + +function DefaultFundCardContent() { + return ( + <> + + + + + + + ); +} diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx new file mode 100644 index 0000000000..83d61e9e4c --- /dev/null +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -0,0 +1,341 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FundCardProviderReact } from '../types'; +import { FundCardAmountInput } from './FundCardAmountInput'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; + +const mockResponseData = { + payment_total: { value: '100.00', currency: 'USD' }, + payment_subtotal: { value: '120.00', currency: 'USD' }, + purchase_amount: { value: '0.1', currency: 'BTC' }, + coinbase_fee: { value: '2.00', currency: 'USD' }, + network_fee: { value: '1.00', currency: 'USD' }, + quote_id: 'quote-id-123', +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +// Mock ResizeObserver +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('FundCardAmountInput', () => { + beforeEach(() => { + global.ResizeObserver = ResizeObserverMock; + setOnchainKitConfig({ apiKey: '123456789' }); + vi.clearAllMocks(); + }); + + // Test component to access context values + const TestComponent = () => { + const { + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + } = useFundContext(); + + return ( +
+ {fundAmountFiat} + {fundAmountCrypto} + {exchangeRate} + + {exchangeRateLoading ? 'loading' : 'not-loading'} + +
+ ); + }; + + const renderWithProvider = ( + initialProps: Partial = {}, + ) => { + return render( + + + + , + ); + }; + + it('renders correctly with fiat input type', () => { + renderWithProvider(); + expect(screen.getByTestId('ockTextInput_Input')).toBeInTheDocument(); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('USD'); + }); + + it('renders correctly with crypto input type', () => { + renderWithProvider({ inputType: 'crypto' }); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('ETH'); + }); + + it('handles fiat input change', async () => { + act(() => { + renderWithProvider({ inputType: 'fiat' }); + }); + + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '10' } }); + const valueFiat = screen.getByTestId('test-value-fiat'); + const valueCrypto = screen.getByTestId('test-value-crypto'); + expect(valueFiat.textContent).toBe('10'); + expect(valueCrypto.textContent).toBe(''); + }); + }); + + it('handles crypto input change', async () => { + act(() => { + renderWithProvider({ inputType: 'crypto' }); + }); + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '1' } }); + + const valueCrypto = screen.getByTestId('test-value-crypto'); + expect(valueCrypto.textContent).toBe('1'); + }); + }); + + it('does not allow non-numeric input', async () => { + act(() => { + renderWithProvider(); + }); + + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: 'ABC' } }); + + const valueFiat = screen.getByTestId('test-value-fiat'); + expect(valueFiat.textContent).toBe(''); + }); + }); + + it('applies custom className', () => { + act(() => { + render( + + + , + ); + }); + + const container = screen.getByTestId('ockFundCardAmountInputContainer'); + expect(container).toHaveClass('custom-class'); + }); + + it('handles truncation of crypto decimals', async () => { + act(() => { + renderWithProvider({ inputType: 'crypto' }); + }); + + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + + // Test decimal truncation + fireEvent.change(input, { target: { value: '0.123456789' } }); + + const valueCrypto = screen.getByTestId('test-value-crypto'); + expect(valueCrypto.textContent).toBe('0.12345678'); // Truncated to 8 decimals + }); + }); + + it('handles truncation of fiat decimals', async () => { + act(() => { + renderWithProvider({ inputType: 'fiat' }); + }); + + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + fireEvent.change(input, { target: { value: '1000.123456789' } }); + const valueFiat = screen.getByTestId('test-value-fiat'); + expect(valueFiat.textContent).toBe('1000.12'); + }); + }); + + it('handles zero and empty values in crypto mode', async () => { + act(() => { + render( + + + + , + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + + const input = screen.getByTestId('ockTextInput_Input'); + const valueFiat = screen.getByTestId('test-value-fiat'); + const valueCrypto = screen.getByTestId('test-value-crypto'); + + // Test zero value + fireEvent.change(input, { target: { value: '0' } }); + expect(valueCrypto.textContent).toBe('0'); + expect(valueFiat.textContent).toBe(''); + + // Test empty value + fireEvent.change(input, { target: { value: '' } }); + expect(valueCrypto.textContent).toBe(''); + expect(valueFiat.textContent).toBe(''); + }); + }); + + it('handles zero and empty values in fiat mode', async () => { + act(() => { + render( + + + + , + ); + }); + + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '0' } }); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + + const valueFiat = screen.getByTestId('test-value-fiat'); + const valueCrypto = screen.getByTestId('test-value-crypto'); + + expect(valueCrypto.textContent).toBe(''); + expect(valueFiat.textContent).toBe('0'); + }); + }); + it('handles non zero values in fiat mode', async () => { + act(() => { + render( + + + + , + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + const input = screen.getByTestId('ockTextInput_Input'); + + fireEvent.change(input, { target: { value: '400' } }); + + const valueFiat = screen.getByTestId('test-value-fiat'); + const valueCrypto = screen.getByTestId('test-value-crypto'); + expect(valueCrypto.textContent).toBe('0.33333333'); + expect(valueFiat.textContent).toBe('400'); + }); + }); + + it('updates width based on currency label', async () => { + const mockResizeObserver = vi.fn(); + global.ResizeObserver = vi.fn().mockImplementation((callback) => { + // Call the callback to simulate resize + callback([ + { + contentRect: { width: 300 }, + target: screen.getByTestId('ockFundCardAmountInputContainer'), + }, + ]); + return { + observe: mockResizeObserver, + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }); + + render( + + + , + ); + + const input = screen.getByTestId('ockTextInput_Input'); + const container = screen.getByTestId('ockFundCardAmountInputContainer'); + + // Mock getBoundingClientRect for container and currency label + Object.defineProperty(container, 'getBoundingClientRect', { + value: () => ({ width: 300 }), + configurable: true, + }); + + const currencyLabel = screen.getByTestId('ockCurrencySpan'); + Object.defineProperty(currencyLabel, 'getBoundingClientRect', { + value: () => ({ width: 20 }), + configurable: true, + }); + + // Trigger width update + act(() => { + fireEvent.change(input, { target: { value: '10' } }); + window.dispatchEvent(new Event('resize')); + }); + + await waitFor(() => { + expect(input.style.maxWidth).toBe('280px'); // 300 - 20 + }); + }); + + it('sets empty string for crypto when calculated value is zero', async () => { + // Mock fetch to return an exchange rate that will make calculatedCryptoValue === '0' + global.fetch = vi.fn(() => + Promise.resolve({ + json: () => + Promise.resolve({ + payment_total: { value: '100.00', currency: 'USD' }, + payment_subtotal: { value: '0', currency: 'USD' }, + purchase_amount: { value: '0', currency: 'ETH' }, // This will make exchange rate 0 + coinbase_fee: { value: '0', currency: 'USD' }, + network_fee: { value: '0', currency: 'USD' }, + quote_id: 'quote-id-123', + }), + }), + ) as Mock; + + act(() => { + render( + + + + , + ); + }); + + await waitFor(() => { + const input = screen.getByTestId('ockTextInput_Input'); + const valueFiat = screen.getByTestId('test-value-fiat'); + const valueCrypto = screen.getByTestId('test-value-crypto'); + + // Enter a value that will result in calculatedCryptoValue === '0' + fireEvent.change(input, { target: { value: '1' } }); + + expect(valueFiat.textContent).toBe('1'); + expect(valueCrypto.textContent).toBe(''); // Should be empty string when calculatedCryptoValue === '0' + + // Verify the actual calculated value was '0' + const exchangeRate = screen.getByTestId('test-value-exchange-rate'); + expect(exchangeRate.textContent).toBe('0'); + }); + }); +}); diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx new file mode 100644 index 0000000000..b6d318c6f3 --- /dev/null +++ b/src/fund/components/FundCardAmountInput.tsx @@ -0,0 +1,158 @@ +import { isValidAmount } from '@/core/utils/isValidAmount'; +import { TextInput } from '@/internal/components/TextInput'; +import { useCallback, useEffect, useRef } from 'react'; +import { cn, text } from '../../styles/theme'; +import { useInputResize } from '../hooks/useInputResize'; +import type { FundCardAmountInputPropsReact } from '../types'; +import { truncateDecimalPlaces } from '../utils/truncateDecimalPlaces'; +import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import { useFundContext } from './FundCardProvider'; + +export const FundCardAmountInput = ({ + className, +}: FundCardAmountInputPropsReact) => { + // TODO: Get currency label from country (This is coming in the follow up PRs) + const currencyLabel = 'USD'; + + const { + fundAmountFiat, + setFundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + asset, + selectedInputType, + exchangeRate, + } = useFundContext(); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const hiddenSpanRef = useRef(null); + const currencySpanRef = useRef(null); + + const value = + selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; + + const updateInputWidth = useInputResize( + containerRef, + inputRef, + hiddenSpanRef, + currencySpanRef, + ); + + const handleFiatChange = useCallback( + (value: string) => { + const fiatValue = truncateDecimalPlaces(value, 2); + setFundAmountFiat(fiatValue); + + const calculatedCryptoValue = String( + Number(fiatValue) * Number(exchangeRate), + ); + const resultCryptoValue = truncateDecimalPlaces(calculatedCryptoValue, 8); + setFundAmountCrypto( + calculatedCryptoValue === '0' ? '' : resultCryptoValue, + ); + }, + [exchangeRate, setFundAmountFiat, setFundAmountCrypto], + ); + + const handleCryptoChange = useCallback( + (value: string) => { + const truncatedValue = truncateDecimalPlaces(value, 8); + setFundAmountCrypto(truncatedValue); + + const calculatedFiatValue = String( + Number(truncatedValue) / Number(exchangeRate), + ); + + const resultFiatValue = truncateDecimalPlaces(calculatedFiatValue, 2); + setFundAmountFiat(resultFiatValue === '0' ? '' : resultFiatValue); + }, + [exchangeRate, setFundAmountFiat, setFundAmountCrypto], + ); + + const handleChange = useCallback( + (value: string) => { + if (selectedInputType === 'fiat') { + handleFiatChange(value); + } else { + handleCryptoChange(value); + } + }, + [handleFiatChange, handleCryptoChange, selectedInputType], + ); + + // Update width when value changes + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update the input width when the value changes + useEffect(() => { + updateInputWidth(); + }, [value, updateInputWidth]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: We only want to focus the input when the input type changes + useEffect(() => { + // focus the input when the input type changes + handleFocusInput(); + }, [selectedInputType]); + + const handleFocusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + return ( +
+
+ + + +
+ + {/* Hidden span for measuring text width + Without this span the input field would not adjust its width based on the text width and would look like this: + [0.12--------Empty Space-------][ETH] - As you can see the currency symbol is far away from the inputed value + + With this span we can measure the width of the text in the input field and set the width of the input field to match the text width + [0.12][ETH] - Now the currency symbol is displayed next to the input field + */} + + {value ? `${value}.` : '0.'} + +
+ ); +}; + +export default FundCardAmountInput; diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx new file mode 100644 index 0000000000..3ddc13eaeb --- /dev/null +++ b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx @@ -0,0 +1,187 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FundCardProviderReact } from '../types'; +import { FundCardAmountInputTypeSwitch } from './FundCardAmountInputTypeSwitch'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; + +const mockResponseData = { + payment_total: { value: '100.00', currency: 'USD' }, + payment_subtotal: { value: '120.00', currency: 'USD' }, + purchase_amount: { value: '0.1', currency: 'BTC' }, + coinbase_fee: { value: '2.00', currency: 'USD' }, + network_fee: { value: '1.00', currency: 'USD' }, + quote_id: 'quote-id-123', +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +const mockContext: FundCardProviderReact = { + asset: 'ETH', + country: 'US', + inputType: 'fiat', + children:
Test
, +}; +describe('FundCardAmountInputTypeSwitch', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: '123456789' }); + vi.clearAllMocks(); + }); + + // Test component to access context values + const TestComponent = () => { + const { + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + inputType, + } = useFundContext(); + + return ( +
+ {fundAmountFiat} + {fundAmountCrypto} + {exchangeRate} + {inputType} + + {exchangeRateLoading ? 'loading' : 'not-loading'} + +
+ ); + }; + + it('renders fiat to crypto conversion', async () => { + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('0 ETH'); + expect(screen.getByTestId('ockExchangeRateLine')).toHaveTextContent( + '($1 = 0.00083333 ETH)', + ); + }); + }); + + it('renders crypto to fiat conversion', async () => { + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + expect(screen.getByTestId('ockAmountLine')).toHaveTextContent('$0'); + expect(screen.getByTestId('ockExchangeRateLine')).toHaveTextContent( + '($1 = 0.00083333 ETH)', + ); + }); + }); + + it('toggles input type when clicked', async () => { + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + expect(screen.getByTestId('ockAmountLine').textContent).toBe('0 ETH'); + }); + + fireEvent.click(screen.getByTestId('ockAmountTypeSwitch')); + + await waitFor(() => { + expect(screen.getByTestId('ockAmountLine').textContent).toBe('$0'); + }); + }); + + it('renders loading skeleton when exchange rate is loading', () => { + render( + + + + , + ); + + expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); + }); + + it('applies custom className', async () => { + render( + + + + , + ); + + await waitFor(() => { + const container = screen.getByTestId('ockAmountTypeSwitch').parentElement; + expect(container).toHaveClass('custom-class'); + }); + }); + + it('toggles input type from fiat to crypto when clicked', async () => { + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + expect(screen.getByTestId('ockAmountLine').textContent).toBe('0 ETH'); + }); + + fireEvent.click(screen.getByTestId('ockAmountTypeSwitch')); + + await waitFor(() => { + expect(screen.getByTestId('ockAmountLine').textContent).toBe('$0'); + }); + }); + + it('toggles input type from crypto to fiat when clicked', async () => { + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + expect(screen.getByTestId('ockAmountLine').textContent).toBe('$0'); + }); + + fireEvent.click(screen.getByTestId('ockAmountTypeSwitch')); + + await waitFor(() => { + expect(screen.getByTestId('ockAmountLine').textContent).toBe('0 ETH'); + }); + }); +}); diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx new file mode 100644 index 0000000000..744e61a4c0 --- /dev/null +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -0,0 +1,99 @@ +import { useCallback, useMemo } from 'react'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { Skeleton } from '../../internal/components/Skeleton'; +import { cn, color, pressable, text } from '../../styles/theme'; +import type { FundCardAmountInputTypeSwitchPropsReact } from '../types'; +import { truncateDecimalPlaces } from '../utils/truncateDecimalPlaces'; +import { useFundContext } from './FundCardProvider'; + +export const FundCardAmountInputTypeSwitch = ({ + className, +}: FundCardAmountInputTypeSwitchPropsReact) => { + const { + selectedInputType, + setSelectedInputType, + asset, + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + } = useFundContext(); + + const iconSvg = useIcon({ icon: 'toggle' }); + + const handleToggle = useCallback(() => { + setSelectedInputType(selectedInputType === 'fiat' ? 'crypto' : 'fiat'); + }, [selectedInputType, setSelectedInputType]); + + const formatUSD = useCallback((amount: string) => { + const roundedAmount = Number(getRoundedAmount(amount || '0', 2)); + return `$${roundedAmount}`; + }, []); + + const formatCrypto = useCallback( + (amount: string) => { + return `${truncateDecimalPlaces(amount || '0', 8)} ${asset}`; + }, + [asset], + ); + + const exchangeRateLine = useMemo(() => { + return ( + + ({formatUSD('1')} = {exchangeRate?.toFixed(8)} {asset}) + + ); + }, [formatUSD, exchangeRate, asset]); + + const amountLine = useMemo(() => { + return ( + + {selectedInputType === 'fiat' + ? formatCrypto(fundAmountCrypto) + : formatUSD(fundAmountFiat)} + + ); + }, [ + fundAmountCrypto, + fundAmountFiat, + selectedInputType, + formatUSD, + formatCrypto, + ]); + + if (exchangeRateLoading || !exchangeRate) { + return ; + } + + return ( +
+ +
+ {amountLine} + {exchangeRateLine} +
+
+ ); +}; + +export default FundCardAmountInputTypeSwitch; diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/fund/components/FundCardCurrencyLabel.test.tsx new file mode 100644 index 0000000000..383cb68ef0 --- /dev/null +++ b/src/fund/components/FundCardCurrencyLabel.test.tsx @@ -0,0 +1,19 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; + +describe('FundCardCurrencyLabel', () => { + it('renders the currency sign', () => { + render(); + expect(screen.getByText('$')).toBeInTheDocument(); + }); + + it('applies the correct classes', () => { + render(); + const spanElement = screen.getByText('$'); + expect(spanElement).toHaveClass( + 'flex items-center justify-center bg-transparent text-6xl leading-none outline-none', + ); + }); +}); diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx new file mode 100644 index 0000000000..7b0fcc7ed1 --- /dev/null +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -0,0 +1,23 @@ +import { forwardRef } from 'react'; +import { cn, color, text } from '../../styles/theme'; +import type { FundCardCurrencyLabelPropsReact } from '../types'; + +export const FundCardCurrencyLabel = forwardRef< + HTMLSpanElement, + FundCardCurrencyLabelPropsReact +>(({ label }, ref) => { + return ( + + {label} + + ); +}); diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx new file mode 100644 index 0000000000..5d160f7b62 --- /dev/null +++ b/src/fund/components/FundCardHeader.test.tsx @@ -0,0 +1,47 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { FundCardHeader } from './FundCardHeader'; +import { FundCardProvider } from './FundCardProvider'; + +const mockResponseData = { + paymentTotal: { value: '100.00', currency: 'USD' }, + paymentSubtotal: { value: '120.00', currency: 'USD' }, + purchaseAmount: { value: '0.1', currency: 'BTC' }, + coinbaseFee: { value: '2.00', currency: 'USD' }, + networkFee: { value: '1.00', currency: 'USD' }, + quoteId: 'quote-id-123', +}; + +vi.mock('../utils/fetchOnrampQuote'); + +describe('FundCardHeader', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); + }); + + it('renders the provided headerText', () => { + render( + + + , + ); + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( + 'Custom header', + ); + }); + + it('renders the default header text when headerText is not provided', () => { + render( + + + , + ); + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( + 'Buy ETH', + ); + }); +}); diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx new file mode 100644 index 0000000000..ceb2f28be2 --- /dev/null +++ b/src/fund/components/FundCardHeader.tsx @@ -0,0 +1,16 @@ +import { cn, text } from '@/styles/theme'; +import type { FundCardHeaderPropsReact } from '../types'; +import { useFundContext } from './FundCardProvider'; + +export function FundCardHeader({ className }: FundCardHeaderPropsReact) { + const { headerText } = useFundContext(); + + return ( +
+ {headerText} +
+ ); +} diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx new file mode 100644 index 0000000000..9f1ddc4744 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -0,0 +1,341 @@ +import { isApplePaySupported } from '@/buy/utils/isApplePaySupported'; +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PAYMENT_METHODS } from '../constants'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; + +const mockResponseData = { + paymentTotal: { value: '100.00', currency: 'USD' }, + paymentSubtotal: { value: '120.00', currency: 'USD' }, + purchaseAmount: { value: '0.1', currency: 'BTC' }, + coinbaseFee: { value: '2.00', currency: 'USD' }, + networkFee: { value: '1.00', currency: 'USD' }, + quoteId: 'quote-id-123', +}; + +vi.mock('../utils/fetchOnrampQuote'); + +// Mock the useOutsideClick hook +vi.mock('@/ui-react/internal/hooks/useOutsideClick', () => ({ + useOutsideClick: (ref: React.RefObject, handler: () => void) => { + // Add click listener to document that calls handler when clicking outside ref + document.addEventListener('mousedown', (event) => { + // If ref or event target is null, return + if (!ref.current || !event.target) { + return; + } + + // If click is outside ref element, call handler + if (!ref.current.contains(event.target as Node)) { + handler(); + } + }); + }, +})); + +// Mock isApplePaySupported +vi.mock('@/buy/utils/isApplePaySupported', () => ({ + isApplePaySupported: vi.fn(), +})); + +// Test component to access and modify context +const TestComponent = ({ amount = '5' }: { amount?: string }) => { + const { setFundAmountFiat } = useFundContext(); + return ( + <> + + + + + ); +}; + +describe('FundCardPaymentMethodDropdown', () => { + beforeEach(() => { + vi.resetAllMocks(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + (isApplePaySupported as Mock).mockResolvedValue(true); // Default to supported + (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); + }); + + const renderWithProvider = ({ amount = '5' }: { amount?: string }) => { + return render( + + + , + ); + }; + + it('disables card payment methods when amount is less than minimum', () => { + renderWithProvider({ amount: '1' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + + // Check Apple Pay is disabled + const applePayButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__APPLE_PAY', + ); + expect(applePayButton).toBeDisabled(); + expect(applePayButton).toHaveAttribute( + 'title', + 'Minimum amount of $5 required', + ); + + // Check Debit Card is disabled + const debitCardButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__ACH_BANK_ACCOUNT', + ); + expect(debitCardButton).toBeDisabled(); + expect(debitCardButton).toHaveAttribute( + 'title', + 'Minimum amount of $5 required', + ); + + // Check Coinbase is not disabled + const coinbaseButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__', + ); + expect(coinbaseButton).not.toBeDisabled(); + }); + + it('enables card payment methods when amount meets minimum', () => { + renderWithProvider({ amount: '5' }); + + // Set amount to 5 + fireEvent.click(screen.getByTestId('setAmount')); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + + // Check all payment methods are enabled + const applePayButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__APPLE_PAY', + ); + const debitCardButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__ACH_BANK_ACCOUNT', + ); + const coinbaseButton = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__', + ); + + expect(applePayButton).not.toBeDisabled(); + expect(debitCardButton).not.toBeDisabled(); + expect(coinbaseButton).not.toBeDisabled(); + }); + + it('switches to Coinbase when selected method becomes disabled', async () => { + renderWithProvider({ amount: '5' }); + + // Set amount to 5 + fireEvent.click(screen.getByTestId('setAmount')); + + // Open dropdown and select Apple Pay + fireEvent.click( + screen.getByTestId( + 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', + ), + ); + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'), + ); + + // Verify Apple Pay is selected + expect( + screen.getByTestId( + 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', + ), + ).toHaveTextContent('Apple Pay'); + + // Change amount to below minimum + fireEvent.click(screen.getByTestId('setAmount1')); + + // Verify it switched to Coinbase + await waitFor(() => { + expect( + screen.getByTestId( + 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', + ), + ).toHaveTextContent('Coinbase'); + }); + }); + + it('shows original description when payment method is not disabled', () => { + renderWithProvider({ amount: '5' }); + + // Set amount to 5 + fireEvent.click(screen.getByTestId('setAmount')); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + + // Check descriptions are original + expect( + screen.getByText('ACH, cash, crypto and balance'), + ).toBeInTheDocument(); + + expect( + screen.getAllByText('Up to $500/week. No sign up required.'), + ).toHaveLength(2); + }); + + it('closes dropdown when clicking outside', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); + + // Click outside + fireEvent.mouseDown(document.body); + + // Verify dropdown is closed + expect( + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), + ).not.toBeInTheDocument(); + }); + + it('closes dropdown when pressing Escape key', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); + + // Press Escape + fireEvent.keyUp( + screen.getByTestId('ockFundCardPaymentMethodDropdownContainer'), + { key: 'Escape' }, + ); + + // Verify dropdown is closed + expect( + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), + ).not.toBeInTheDocument(); + }); + + it('toggles dropdown visibility when clicking the toggle button', () => { + renderWithProvider({ amount: '5' }); + + // Initially dropdown should be closed + expect( + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), + ).not.toBeInTheDocument(); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); + + // Close dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + expect( + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), + ).not.toBeInTheDocument(); + }); + + it('ignores non-Escape key presses', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); + + // Press a different key + fireEvent.keyUp( + screen.getByTestId('ockFundCardPaymentMethodDropdownContainer'), + { key: 'Enter' }, + ); + + // Verify dropdown is still open + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); + }); + + it('hides Apple Pay option when not supported', async () => { + (isApplePaySupported as Mock).mockReturnValue(false); + renderWithProvider({ amount: '5' }); + + // Wait for Apple Pay check + await waitFor(() => { + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + }); + + // Apple Pay should not be in the list + expect( + screen.queryByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'), + ).not.toBeInTheDocument(); + + // Other payment methods should still be there + expect( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__ACH_BANK_ACCOUNT'), + ).toBeInTheDocument(); + }); + + it('shows Apple Pay option when supported', async () => { + (isApplePaySupported as Mock).mockResolvedValue(true); + renderWithProvider({ amount: '5' }); + + // Wait for Apple Pay check + await waitFor(() => { + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); + }); + + // Apple Pay should be in the list + expect( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx new file mode 100644 index 0000000000..78a60c6a26 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -0,0 +1,140 @@ +import { isApplePaySupported } from '@/buy/utils/isApplePaySupported'; +import { useOutsideClick } from '@/ui-react/internal/hooks/useOutsideClick'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { background, border, cn } from '../../styles/theme'; +import type { + FundCardPaymentMethodDropdownPropsReact, + PaymentMethodReact, +} from '../types'; +import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; +import { FundCardPaymentMethodSelectorToggle } from './FundCardPaymentMethodSelectorToggle'; +import { useFundContext } from './FundCardProvider'; + +const MIN_AMOUNT_FOR_CARD_PAYMENTS = 5; + +export function FundCardPaymentMethodDropdown({ + className, +}: FundCardPaymentMethodDropdownPropsReact) { + const [isOpen, setIsOpen] = useState(false); + + const { + selectedPaymentMethod, + setSelectedPaymentMethod, + paymentMethods, + fundAmountFiat, + } = useFundContext(); + + const filteredPaymentMethods = useMemo(() => { + return paymentMethods.filter( + (method) => method.id !== 'APPLE_PAY' || isApplePaySupported(), + ); + }, [paymentMethods]); + + const isPaymentMethodDisabled = useCallback( + (method: PaymentMethodReact) => { + const amount = Number(fundAmountFiat); + return ( + (method.id === 'APPLE_PAY' || method.id === 'ACH_BANK_ACCOUNT') && + amount < MIN_AMOUNT_FOR_CARD_PAYMENTS + ); + }, + [fundAmountFiat], + ); + + // If current selected method becomes disabled, switch to Coinbase + useEffect(() => { + if ( + selectedPaymentMethod && + isPaymentMethodDisabled(selectedPaymentMethod) + ) { + const coinbaseMethod = paymentMethods.find((m) => m.id === ''); + if (coinbaseMethod) { + setSelectedPaymentMethod(coinbaseMethod); + } + } + }, [ + selectedPaymentMethod, + paymentMethods, + setSelectedPaymentMethod, + isPaymentMethodDisabled, + ]); + + const handlePaymentMethodSelect = useCallback( + (paymentMethod: PaymentMethodReact) => { + if (!isPaymentMethodDisabled(paymentMethod)) { + setSelectedPaymentMethod(paymentMethod); + setIsOpen(false); + } + }, + [setSelectedPaymentMethod, isPaymentMethodDisabled], + ); + + const handleToggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const dropdownRef = useRef(null); + const dropdownContainerRef = useRef(null); + const buttonRef = useRef(null); + + useOutsideClick(dropdownContainerRef, () => { + if (isOpen) { + setIsOpen(false); + } + }); + + const handleEscKeyPress = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }, + [], + ); + + return ( +
+ + {isOpen && ( +
+
+ {filteredPaymentMethods.map((paymentMethod) => { + const isDisabled = isPaymentMethodDisabled(paymentMethod); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/fund/components/FundCardPaymentMethodImage.test.tsx b/src/fund/components/FundCardPaymentMethodImage.test.tsx new file mode 100644 index 0000000000..ede4039df0 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodImage.test.tsx @@ -0,0 +1,84 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { type Mock, describe, expect, it, vi } from 'vitest'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; + +vi.mock('../../core-react/internal/hooks/useIcon', () => ({ + useIcon: vi.fn(), +})); + +describe('FundCardPaymentMethodImage', () => { + it('renders the icon when iconSvg is available', () => { + (useIcon as Mock).mockReturnValue(() => ); + render( + , + ); + expect( + screen.queryByTestId('ockFundCardPaymentMethodImage__iconContainer'), + ).toBeInTheDocument(); + }); + + it('applies primary color when the icon is coinbasePay', () => { + (useIcon as Mock).mockReturnValue(() => ); + + render( + , + ); + expect( + screen.getByTestId('ockFundCardPaymentMethodImage__iconContainer'), + ).toBeInTheDocument(); + }); + + it('renders with custom className and size', () => { + (useIcon as Mock).mockReturnValue(() => ); + render( + , + ); + const container = screen.getByTestId( + 'ockFundCardPaymentMethodImage__iconContainer', + ); + expect(container).toHaveClass('custom-class'); + }); + + it('does not apply primary color for non-coinbasePay icons', () => { + (useIcon as Mock).mockReturnValue(() => ); + render( + , + ); + const container = screen.getByTestId( + 'ockFundCardPaymentMethodImage__iconContainer', + ); + expect(container).not.toHaveClass('primary'); + }); +}); diff --git a/src/fund/components/FundCardPaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx new file mode 100644 index 0000000000..a387ce3a37 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -0,0 +1,24 @@ +import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import { cn } from '../../styles/theme'; +import type { FundCardPaymentMethodImagePropsReact } from '../types'; + +export function FundCardPaymentMethodImage({ + className, + paymentMethod, +}: FundCardPaymentMethodImagePropsReact) { + const { icon } = paymentMethod; + + const iconSvg = useIcon({ icon }); + + return ( +
+ {iconSvg} +
+ ); +} diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx new file mode 100644 index 0000000000..3ff3944241 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { PaymentMethodReact } from '../types'; +import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; + +describe('FundCardPaymentMethodSelectRow', () => { + const mockPaymentMethod: PaymentMethodReact = { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'apple', + }; + + it('renders disabled state correctly', () => { + const onClick = vi.fn(); + render( + , + ); + + const button = screen.getByTestId('ockFundCardPaymentMethodSelectRow'); + + expect(button).toBeDisabled(); + expect(button).toHaveClass('cursor-not-allowed', 'opacity-50'); + expect(button).toHaveAttribute('title', 'Minimum amount required'); + expect(screen.getByText('Minimum amount required')).toBeInTheDocument(); + }); + + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('ockFundCardPaymentMethodSelectRow')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('shows original description when not disabled', () => { + render( + , + ); + + expect(screen.getByText('Up to $500/week')).toBeInTheDocument(); + }); +}); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx new file mode 100644 index 0000000000..295a48287d --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -0,0 +1,62 @@ +import { memo, useCallback } from 'react'; +import { + background, + border, + cn, + color, + pressable, + text, +} from '../../styles/theme'; +import type { FundCardPaymentMethodSelectRowPropsReact } from '../types'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; + +export const FundCardPaymentMethodSelectRow = memo( + ({ + paymentMethod, + onClick, + hideImage, + hideDescription, + disabled, + disabledReason, + testId, + }: FundCardPaymentMethodSelectRowPropsReact) => { + const handleOnClick = useCallback( + () => !disabled && onClick?.(paymentMethod), + [disabled, onClick, paymentMethod], + ); + + return ( + + ); + }, +); diff --git a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx new file mode 100644 index 0000000000..b4fca4ca2d --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -0,0 +1,56 @@ +import { type ForwardedRef, forwardRef } from 'react'; +import { caretUpSvg } from '../../internal/svg/caretUpSvg'; +import { border, cn, color, pressable, text } from '../../styles/theme'; +import type { FundCardPaymentMethodSelectorTogglePropsReact } from '../types'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; + +export const FundCardPaymentMethodSelectorToggle = forwardRef( + ( + { + onClick, + paymentMethod, + isOpen, + className, + }: FundCardPaymentMethodSelectorTogglePropsReact, + ref: ForwardedRef, + ) => { + return ( + + ); + }, +); diff --git a/src/fund/components/FundCardProvider.test.tsx b/src/fund/components/FundCardProvider.test.tsx new file mode 100644 index 0000000000..f0ff575c44 --- /dev/null +++ b/src/fund/components/FundCardProvider.test.tsx @@ -0,0 +1,92 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { render, screen, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; + +const mockResponseData = { + payment_total: { value: '100.00', currency: 'USD' }, + payment_subtotal: { value: '120.00', currency: 'USD' }, + purchase_amount: { value: '0.1', currency: 'BTC' }, + coinbase_fee: { value: '2.00', currency: 'USD' }, + network_fee: { value: '1.00', currency: 'USD' }, + quote_id: 'quote-id-123', +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +const TestComponent = () => { + const context = useFundContext(); + return ( +
+ {context.asset} + {context.exchangeRate} + + {context.exchangeRateLoading ? 'loading' : 'not-loading'} + +
+ ); +}; + +describe('FundCardProvider', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: '123456789' }); + vi.clearAllMocks(); + }); + + it('provides default context values', () => { + render( + + + , + ); + expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); + }); + + it('fetches and sets exchange rate on mount', async () => { + act(() => { + render( + + + , + ); + }); + + // Check initial loading state + expect(screen.getByTestId('loading-state').textContent).toBe('loading'); + + // Wait for exchange rate to be set + await waitFor(() => { + expect(screen.getByTestId('exchange-rate').textContent).toBe( + '0.0008333333333333334', + ); + expect(screen.getByTestId('loading-state').textContent).toBe( + 'not-loading', + ); + }); + + // Verify fetch was called with correct parameters + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/quote'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"purchase_currency":"BTC"'), + }), + ); + }); + + it('throws error when useFundContext is used outside of FundCardProvider', () => { + const TestOutsideProvider = () => { + useFundContext(); + return
Test
; + }; + + expect(() => render()).toThrow( + 'useFundContext must be used within a FundCardProvider', + ); + }); +}); diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx new file mode 100644 index 0000000000..23ff9415f0 --- /dev/null +++ b/src/fund/components/FundCardProvider.tsx @@ -0,0 +1,140 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import { DEFAULT_PAYMENT_METHODS } from '../constants'; +import { useEmitLifecycleStatus } from '../hooks/useEmitLifecycleStatus'; +import type { + FundButtonStateReact, + FundCardProviderReact, + LifecycleStatus, + LifecycleStatusUpdate, + PaymentMethodReact, +} from '../types'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; + +type FundCardContextType = { + asset: string; + selectedPaymentMethod?: PaymentMethodReact; + setSelectedPaymentMethod: (paymentMethod: PaymentMethodReact) => void; + selectedInputType?: 'fiat' | 'crypto'; + setSelectedInputType: (inputType: 'fiat' | 'crypto') => void; + fundAmountFiat: string; + setFundAmountFiat: (amount: string) => void; + fundAmountCrypto: string; + setFundAmountCrypto: (amount: string) => void; + exchangeRate?: number; + setExchangeRate: (exchangeRate: number) => void; + exchangeRateLoading?: boolean; + setExchangeRateLoading: (loading: boolean) => void; + submitButtonState: FundButtonStateReact; + setSubmitButtonState: (state: FundButtonStateReact) => void; + paymentMethods: PaymentMethodReact[]; + headerText?: string; + buttonText?: string; + country: string; + subdivision?: string; + inputType?: 'fiat' | 'crypto'; + lifecycleStatus: LifecycleStatus; + updateLifecycleStatus: (newStatus: LifecycleStatusUpdate) => void; +}; + +const FundContext = createContext(undefined); + +export function FundCardProvider({ + children, + asset, + paymentMethods, + headerText = `Buy ${asset.toUpperCase()}`, + buttonText, + country, + subdivision, + inputType, + onError, + onStatus, + onSuccess, +}: FundCardProviderReact) { + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState< + PaymentMethodReact | undefined + >(); + const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>( + inputType || 'fiat', + ); + const [fundAmountFiat, setFundAmountFiat] = useState(''); + const [fundAmountCrypto, setFundAmountCrypto] = useState(''); + const [exchangeRate, setExchangeRate] = useState(0); + const [exchangeRateLoading, setExchangeRateLoading] = useState< + boolean | undefined + >(); + const [submitButtonState, setSubmitButtonState] = + useState('default'); + + const { lifecycleStatus, updateLifecycleStatus } = useEmitLifecycleStatus({ + onError, + onSuccess, + onStatus, + }); + + const fetchExchangeRate = useCallback(async () => { + setExchangeRateLoading(true); + const quote = await fetchOnrampQuote({ + purchaseCurrency: asset, + paymentCurrency: 'USD', + paymentAmount: '100', + paymentMethod: 'CARD', + country, + subdivision, + }); + + setExchangeRateLoading(false); + + setExchangeRate( + Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), + ); + }, [asset, country, subdivision]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect + useEffect(() => { + fetchExchangeRate(); + }, []); + + const value = useValue({ + asset, + selectedPaymentMethod, + setSelectedPaymentMethod, + fundAmountFiat, + setFundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + selectedInputType, + setSelectedInputType, + exchangeRate, + setExchangeRate, + exchangeRateLoading, + setExchangeRateLoading, + submitButtonState, + setSubmitButtonState, + paymentMethods: paymentMethods || DEFAULT_PAYMENT_METHODS, + headerText, + buttonText, + country, + subdivision, + lifecycleStatus, + updateLifecycleStatus, + }); + return {children}; +} + +export function useFundContext() { + const context = useContext(FundContext); + + if (!context) { + throw new Error('useFundContext must be used within a FundCardProvider'); + } + + return context; +} diff --git a/src/fund/components/FundCardSubmitButton.test.tsx b/src/fund/components/FundCardSubmitButton.test.tsx new file mode 100644 index 0000000000..234ae195c5 --- /dev/null +++ b/src/fund/components/FundCardSubmitButton.test.tsx @@ -0,0 +1,235 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; +import '@testing-library/jest-dom'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount } from 'wagmi'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { getFundingPopupSize } from '../utils/getFundingPopupSize'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; +import { FundCardSubmitButton } from './FundCardSubmitButton'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: () => 'mocked-theme-class', +})); + +vi.mock('../hooks/useGetFundingUrl', () => ({ + useGetFundingUrl: vi.fn(), +})); + +vi.mock('../hooks/useFundCardFundingUrl', () => ({ + useFundCardFundingUrl: vi.fn(), +})); + +vi.mock('../../useOnchainKit'); + +vi.mock('../utils/setupOnrampEventListeners', () => ({ + setupOnrampEventListeners: vi.fn(), +})); + +vi.mock('@/ui-react/internal/utils/openPopup', () => ({ + openPopup: vi.fn(), +})); + +vi.mock('../hooks/useFundCardFundingUrl', () => ({ + useFundCardFundingUrl: vi.fn(), +})); + +vi.mock('../hooks/useFundCardSetupOnrampEventListeners', () => ({ + useFundCardSetupOnrampEventListeners: vi.fn(), +})); + +vi.mock('../utils/getFundingPopupSize', () => ({ + getFundingPopupSize: vi.fn(), +})); + +vi.mock('../utils/fetchOnrampQuote'); + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), +})); + +vi.mock('../../wallet/components/ConnectWallet', () => ({ + ConnectWallet: ({ className }: { className?: string }) => ( +
+ Connect Wallet +
+ ), +})); + +const mockResponseData = { + paymentTotal: { value: '100.00', currency: 'USD' }, + paymentSubtotal: { value: '120.00', currency: 'USD' }, + purchaseAmount: { value: '0.1', currency: 'BTC' }, + coinbaseFee: { value: '2.00', currency: 'USD' }, + networkFee: { value: '1.00', currency: 'USD' }, + quoteId: 'quote-id-123', +}; + +// Test component to access context values +const TestHelperComponent = () => { + const { + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + setFundAmountFiat, + setFundAmountCrypto, + } = useFundContext(); + + return ( +
+ {fundAmountFiat} + {fundAmountCrypto} + {exchangeRate} + + {exchangeRateLoading ? 'loading' : 'not-loading'} + +
+ ); +}; + +describe('FundCardSubmitButton', () => { + beforeEach(() => { + vi.resetAllMocks(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + (getFundingPopupSize as Mock).mockImplementation(() => ({ + height: 200, + width: 100, + })); + (useFundCardFundingUrl as Mock).mockReturnValue('mock-funding-url'); + (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); + (useAccount as Mock).mockReturnValue({ + address: '0x123', + }); + }); + + const renderComponent = () => { + return render( + + + + , + ); + }; + + it('renders disabled by default when no amount is set', () => { + renderComponent(); + expect(screen.getByTestId('ockFundButton')).toBeDisabled(); + }); + + it('enables when fiat amount is set', async () => { + renderComponent(); + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + const setCryptoAmountButton = screen.getByTestId('set-crypto-amount'); + fireEvent.click(setCryptoAmountButton); + + await waitFor(() => { + expect(screen.getByTestId('ockFundButton')).not.toBeDisabled(); + }); + }); + + it('disables when fiat amount is set to zero', async () => { + renderComponent(); + + const button = screen.getByTestId('set-fiat-amount-zero'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('ockFundButton')).toBeDisabled(); + }); + }); + + it('disables when crypto amount is set to zero', async () => { + renderComponent(); + + const setCryptoAmountButton = screen.getByTestId('set-crypto-amount-zero'); + fireEvent.click(setCryptoAmountButton); + + await waitFor(() => { + expect(screen.getByTestId('ockFundButton')).toBeDisabled(); + }); + }); + + it('shows loading state when clicked', async () => { + renderComponent(); + + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + const setCryptoAmountButton = screen.getByTestId('set-crypto-amount'); + fireEvent.click(setCryptoAmountButton); + + const fundButton = screen.getByTestId('ockFundButton'); + fireEvent.click(fundButton); + + expect(screen.getByTestId('ockSpinner')).toBeInTheDocument(); + }); + + it('shows ConnectWallet when no wallet is connected', () => { + (useAccount as Mock).mockReturnValue({ address: undefined }); + renderComponent(); + + expect( + screen.queryByTestId('ockConnectWallet_Container'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('ockFundButton')).not.toBeInTheDocument(); + }); + + it('sets submit button state to default on popup close', () => { + vi.useFakeTimers(); + + (openPopup as Mock).mockImplementation(() => ({ closed: true })); + renderComponent(); + const button = screen.getByTestId('ockFundButton'); + + // Simulate entering a valid amount + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + const setCryptoAmountButton = screen.getByTestId('set-crypto-amount'); + fireEvent.click(setCryptoAmountButton); + + // Click the submit button to trigger loading state + act(() => { + fireEvent.click(button); + }); + + vi.runOnlyPendingTimers(); + + const submitButton = screen.getByTestId('ockFundButton'); + + // Assert that the submit button state is set to 'default' + expect(submitButton).not.toBeDisabled(); + }); +}); diff --git a/src/fund/components/FundCardSubmitButton.tsx b/src/fund/components/FundCardSubmitButton.tsx new file mode 100644 index 0000000000..18a0f53114 --- /dev/null +++ b/src/fund/components/FundCardSubmitButton.tsx @@ -0,0 +1,47 @@ +import { useCallback, useMemo } from 'react'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { FundButton } from './FundButton'; +import { useFundContext } from './FundCardProvider'; + +export function FundCardSubmitButton() { + const { + fundAmountFiat, + fundAmountCrypto, + submitButtonState, + setSubmitButtonState, + buttonText, + updateLifecycleStatus, + } = useFundContext(); + + const fundingUrl = useFundCardFundingUrl(); + + const handleOnClick = useCallback( + () => setSubmitButtonState('loading'), + [setSubmitButtonState], + ); + + const handleOnPopupClose = useCallback(() => { + updateLifecycleStatus({ statusName: 'exit', statusData: undefined }); + setSubmitButtonState('default'); + }, [updateLifecycleStatus, setSubmitButtonState]); + + const isButtonDisabled = useMemo( + () => + (!fundAmountFiat || Number(fundAmountCrypto) === 0) && + (!fundAmountCrypto || Number(fundAmountFiat) === 0), + [fundAmountCrypto, fundAmountFiat], + ); + + return ( + + ); +} diff --git a/src/fund/constants.ts b/src/fund/constants.ts index d3fef285d5..be1555c8f9 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -1,3 +1,5 @@ +import type { PaymentMethodReact } from './types'; + export const DEFAULT_ONRAMP_URL = 'https://pay.coinbase.com'; // The base URL for the Coinbase Onramp widget. export const ONRAMP_BUY_URL = `${DEFAULT_ONRAMP_URL}/buy`; @@ -5,6 +7,40 @@ export const ONRAMP_BUY_URL = `${DEFAULT_ONRAMP_URL}/buy`; export const ONRAMP_POPUP_HEIGHT = 720; // The recommended width of a Coinbase Onramp popup window. export const ONRAMP_POPUP_WIDTH = 460; - export const ONRAMP_API_BASE_URL = 'https://api.developer.coinbase.com/onramp/v1'; +// Time in milliseconds to wait before resetting the button state to default after a transaction is completed. +export const FUND_BUTTON_RESET_TIMEOUT = 3000; + +export const COINBASE: PaymentMethodReact = { + id: '', + name: 'Coinbase', + description: 'ACH, cash, crypto and balance', + icon: 'coinbaseLogo', +}; + +export const DEBIT_CARD: PaymentMethodReact = { + id: 'ACH_BANK_ACCOUNT', + name: 'Debit Card', + description: 'Up to $500/week. No sign up required.', + icon: 'creditCard', +}; + +export const APPLE_PAY: PaymentMethodReact = { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week. No sign up required.', + icon: 'apple', +}; + +export const ALL_PAYMENT_METHODS = [COINBASE, DEBIT_CARD, APPLE_PAY]; + +// Preset combinations +export const PAYMENT_METHODS = { + ALL: ALL_PAYMENT_METHODS, + CARD_AND_COINBASE: [COINBASE, DEBIT_CARD], + COINBASE_ONLY: [COINBASE], + CARD_ONLY: [DEBIT_CARD], +} as const; + +export const DEFAULT_PAYMENT_METHODS = PAYMENT_METHODS.ALL; diff --git a/src/fund/hooks/useEmitLifecycleStatus.test.ts b/src/fund/hooks/useEmitLifecycleStatus.test.ts new file mode 100644 index 0000000000..15ad181081 --- /dev/null +++ b/src/fund/hooks/useEmitLifecycleStatus.test.ts @@ -0,0 +1,133 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useEmitLifecycleStatus } from './useEmitLifecycleStatus'; + +describe('useEmitLifecycleStatus', () => { + it('initializes with init status', () => { + const { result } = renderHook(() => + useEmitLifecycleStatus({ + onError: vi.fn(), + onSuccess: vi.fn(), + onStatus: vi.fn(), + }), + ); + + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'init', + statusData: null, + }); + }); + + it('calls onError when error status is set', () => { + const onError = vi.fn(); + const onStatus = vi.fn(); + + const { result } = renderHook(() => + useEmitLifecycleStatus({ + onError, + onSuccess: vi.fn(), + onStatus, + }), + ); + + const error = { + errorType: 'network_error' as const, + code: 'ERROR_CODE', + debugMessage: 'Test error', + }; + + act(() => { + result.current.updateLifecycleStatus({ + statusName: 'error', + statusData: error, + }); + }); + + expect(onError).toHaveBeenCalledWith(error); + expect(onStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: error, + }); + }); + + it('calls onSuccess when transaction succeeds', () => { + const onSuccess = vi.fn(); + const onStatus = vi.fn(); + + const { result } = renderHook(() => + useEmitLifecycleStatus({ + onError: vi.fn(), + onSuccess, + onStatus, + }), + ); + + act(() => { + result.current.updateLifecycleStatus({ + statusName: 'transactionSuccess', + statusData: undefined, + }); + }); + + expect(onSuccess).toHaveBeenCalled(); + expect(onStatus).toHaveBeenCalledWith({ + statusName: 'init', + statusData: null, + }); + }); + + it('calls onStatus for all status changes', () => { + const onStatus = vi.fn(); + + const { result } = renderHook(() => + useEmitLifecycleStatus({ + onError: vi.fn(), + onSuccess: vi.fn(), + onStatus, + }), + ); + + // Test transition_view status + act(() => { + result.current.updateLifecycleStatus({ + statusName: 'transactionPending', + statusData: undefined, + }); + }); + + expect(onStatus.mock.calls[0][0]).toEqual({ + statusName: 'init', + statusData: null, + }); + expect(onStatus.mock.calls[1][0]).toEqual({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + it('handles undefined callbacks gracefully', () => { + const { result } = renderHook(() => + useEmitLifecycleStatus({ + onError: undefined, + onSuccess: undefined, + onStatus: undefined, + }), + ); + + // Should not throw when callbacks are undefined + act(() => { + result.current.updateLifecycleStatus({ + statusName: 'error', + statusData: { errorType: 'network_error' }, + }); + }); + + act(() => { + result.current.updateLifecycleStatus({ + statusName: 'transactionSuccess', + statusData: undefined, + }); + }); + }); +}); diff --git a/src/fund/hooks/useEmitLifecycleStatus.ts b/src/fund/hooks/useEmitLifecycleStatus.ts new file mode 100644 index 0000000000..fc3c119dc3 --- /dev/null +++ b/src/fund/hooks/useEmitLifecycleStatus.ts @@ -0,0 +1,40 @@ +import { useEffect, useMemo } from 'react'; +import type { LifecycleEvents } from '../types'; +import { useLifecycleStatus } from './useLifecycleStatus'; + +export const useEmitLifecycleStatus = ({ + onError, + onSuccess, + onStatus, +}: LifecycleEvents) => { + const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ + statusName: 'init', + statusData: null, + }); + + // Lifecycle emitters + useEffect(() => { + // Error + if (lifecycleStatus.statusName === 'error') { + onError?.(lifecycleStatus.statusData); + } + // Success + if (lifecycleStatus.statusName === 'transactionSuccess') { + onSuccess?.(lifecycleStatus.statusData); + } + // Emit Status + onStatus?.(lifecycleStatus); + }, [ + onError, + onStatus, + onSuccess, + lifecycleStatus, + lifecycleStatus.statusData, + lifecycleStatus.statusName, + ]); + + return useMemo( + () => ({ lifecycleStatus, updateLifecycleStatus }), + [lifecycleStatus, updateLifecycleStatus], + ); +}; diff --git a/src/fund/hooks/useFundCardFundingUrl.test.ts b/src/fund/hooks/useFundCardFundingUrl.test.ts new file mode 100644 index 0000000000..756d0b57aa --- /dev/null +++ b/src/fund/hooks/useFundCardFundingUrl.test.ts @@ -0,0 +1,141 @@ +import { useOnchainKit } from '@/core-react/useOnchainKit'; +import { renderHook } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAccount } from 'wagmi'; +import { useFundContext } from '../components/FundCardProvider'; +import { useFundCardFundingUrl } from './useFundCardFundingUrl'; + +vi.mock('../components/FundCardProvider', () => ({ + useFundContext: vi.fn(), +})); + +vi.mock('@/core-react/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); + +describe('useFundCardFundingUrl', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return undefined if projectId is null', () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: null, + chain: { name: 'base' }, + }); + + (useAccount as Mock).mockReturnValue({ + address: '0x123', + chain: { name: 'base' }, + }); + + (useFundContext as Mock).mockReturnValue({ + selectedPaymentMethod: { id: 'FIAT_WALLET' }, + selectedInputType: 'fiat', + fundAmountFiat: '100', + fundAmountCrypto: '0', + asset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if address is undefined', () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'project123', + chain: { name: 'base' }, + }); + + (useAccount as Mock).mockReturnValue({ + address: undefined, + chain: { name: 'base' }, + }); + + (useFundContext as Mock).mockReturnValue({ + selectedPaymentMethod: { id: 'FIAT_WALLET' }, + selectedInputType: 'fiat', + fundAmountFiat: '100', + fundAmountCrypto: '0', + asset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toBeUndefined(); + }); + + it('should return valid URL when input type is fiat', () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'project123', + chain: { name: 'base' }, + }); + + (useAccount as Mock).mockReturnValue({ + address: '0x123', + chain: { name: 'base' }, + }); + + (useFundContext as Mock).mockReturnValue({ + selectedPaymentMethod: { id: 'FIAT_WALLET' }, + selectedInputType: 'fiat', + fundAmountFiat: '100', + fundAmountCrypto: '0', + asset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toContain('appId=project123'); + expect(result.current).toContain('presetFiatAmount=100'); + }); + + it('should return valid URL when input type is crypto', () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'project123', + chain: { name: 'base' }, + }); + + (useAccount as Mock).mockReturnValue({ + address: '0x123', + chain: { name: 'base' }, + }); + + (useFundContext as Mock).mockReturnValue({ + selectedPaymentMethod: { id: 'CRYPTO_WALLET' }, + selectedInputType: 'crypto', + fundAmountFiat: '0', + fundAmountCrypto: '1.5', + asset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toContain('appId=project123'); + expect(result.current).toContain('presetCryptoAmount=1.5'); + }); + + it('should use defaultChain when accountChain is undefined', () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'project123', + chain: { name: 'base' }, + }); + + (useAccount as Mock).mockReturnValue({ + address: '0x123', + chain: undefined, + }); + + (useFundContext as Mock).mockReturnValue({ + selectedPaymentMethod: { id: 'FIAT_WALLET' }, + selectedInputType: 'fiat', + fundAmountFiat: '100', + fundAmountCrypto: '0', + asset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toContain(encodeURI('0x123')); + }); +}); diff --git a/src/fund/hooks/useFundCardFundingUrl.ts b/src/fund/hooks/useFundCardFundingUrl.ts new file mode 100644 index 0000000000..09dd1e82f2 --- /dev/null +++ b/src/fund/hooks/useFundCardFundingUrl.ts @@ -0,0 +1,48 @@ +import { useOnchainKit } from '@/core-react/useOnchainKit'; +import { useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { useFundContext } from '../components/FundCardProvider'; +import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl'; + +export const useFundCardFundingUrl = () => { + const { projectId, chain: defaultChain } = useOnchainKit(); + const { address, chain: accountChain } = useAccount(); + const { + selectedPaymentMethod, + selectedInputType, + fundAmountFiat, + fundAmountCrypto, + asset, + } = useFundContext(); + + const chain = accountChain || defaultChain; + + return useMemo(() => { + if (projectId === null || address === undefined) { + return undefined; + } + + const fundAmount = + selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; + + return getOnrampBuyUrl({ + projectId, + assets: [asset], + presetFiatAmount: + selectedInputType === 'fiat' ? Number(fundAmount) : undefined, + presetCryptoAmount: + selectedInputType === 'crypto' ? Number(fundAmount) : undefined, + defaultPaymentMethod: selectedPaymentMethod?.id, + addresses: { [address]: [chain.name.toLowerCase()] }, + }); + }, [ + asset, + fundAmountFiat, + fundAmountCrypto, + selectedPaymentMethod, + selectedInputType, + projectId, + address, + chain, + ]); +}; diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx new file mode 100644 index 0000000000..f4560c488f --- /dev/null +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx @@ -0,0 +1,256 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { act, renderHook } from '@testing-library/react'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { FundCardProvider } from '../components/FundCardProvider'; +import { FUND_BUTTON_RESET_TIMEOUT } from '../constants'; +import type { EventMetadata, OnrampError } from '../types'; +import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; +import { useFundCardSetupOnrampEventListeners } from './useFundCardSetupOnrampEventListeners'; + +vi.mock('../utils/setupOnrampEventListeners', () => ({ + setupOnrampEventListeners: vi.fn(), +})); + +describe('useFundCardSetupOnrampEventListeners', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const mockError: OnrampError = { + errorType: 'internal_error', + code: 'ERROR_CODE', + debugMessage: 'Error message', + }; + + const mockEvent: EventMetadata = { + eventName: 'error', + error: mockError, + }; + + const renderHookWithProvider = ({ + onError = vi.fn(), + onStatus = vi.fn(), + onSuccess = vi.fn(), + } = {}) => { + return renderHook(() => useFundCardSetupOnrampEventListeners(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + }; + + it('calls onStatus when exit event occurs', () => { + let exitHandler: (event: EventMetadata) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onExit }) => { + exitHandler = onExit; + return () => {}; + }); + + const onStatus = vi.fn(); + renderHookWithProvider({ onStatus }); + + act(() => { + exitHandler({ + eventName: 'exit', + }); + }); + expect(onStatus.mock.calls[1][0]).toEqual({ + statusName: 'exit', + statusData: {}, + }); + }); + + it('calls onStatus when event occurs', () => { + let eventHandler: (event: EventMetadata) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { + eventHandler = onEvent; + return () => {}; + }); + + const onStatus = vi.fn(); + renderHookWithProvider({ onStatus }); + + act(() => { + eventHandler({ + eventName: 'transition_view', + pageRoute: '/some-route', + }); + }); + expect(onStatus.mock.calls[1][0]).toEqual({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + it('calls onSuccess when success event occurs', () => { + let successHandler: () => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onSuccess }) => { + successHandler = onSuccess; + return () => {}; + }); + + const onSuccess = vi.fn(); + renderHookWithProvider({ onSuccess }); + + act(() => { + successHandler(); + }); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('sets button state to error and resets after timeout when error event occurs', () => { + let eventHandler: (event: EventMetadata) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { + eventHandler = onEvent; + return () => {}; + }); + + const { result } = renderHookWithProvider(); + + eventHandler(mockEvent); + expect(result.current).toBe(undefined); // Hook doesn't return anything + + vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); + }); + + it('sets button state to success and resets after timeout when success occurs', () => { + let successHandler: () => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onSuccess }) => { + successHandler = onSuccess; + return () => {}; + }); + + const { result } = renderHookWithProvider(); + + successHandler(); + expect(result.current).toBe(undefined); // Hook doesn't return anything + + vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); + }); + + it('cleans up event listeners on unmount', () => { + const unsubscribe = vi.fn(); + (setupOnrampEventListeners as Mock).mockReturnValue(unsubscribe); + + const { unmount } = renderHookWithProvider(); + unmount(); + + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('handles transition_view event correctly', () => { + let eventHandler: (event: EventMetadata) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { + eventHandler = onEvent; + return () => {}; + }); + + const onStatus = vi.fn(); + renderHookWithProvider({ onStatus }); + + expect(onStatus).toHaveBeenCalledWith({ + statusName: 'init', + statusData: null, + }); + + // First transition_view event should update status + act(() => { + eventHandler({ + eventName: 'transition_view', + pageRoute: '/some-route', + }); + }); + + expect(onStatus.mock.calls[1][0]).toEqual({ + statusName: 'transactionPending', + statusData: {}, + }); + + // Second transition_view event while already pending should not update status + act(() => { + eventHandler({ + eventName: 'transition_view', + pageRoute: '/another-route', + }); + }); + + // Should not call onStatus again since we're already in pending state + expect(onStatus.mock.calls[2][0]).toEqual({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + it('preserves existing status data when handling events', () => { + let eventHandler: (event: EventMetadata) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { + eventHandler = onEvent; + return () => {}; + }); + + const onStatus = vi.fn(); + renderHookWithProvider({ onStatus }); + + // Set initial state with some data + act(() => { + eventHandler({ + eventName: 'transition_view', + pageRoute: '/some-route', + }); + }); + + // Clear first call + onStatus.mockClear(); + + // Trigger error event + act(() => { + eventHandler({ + eventName: 'error', + error: { + errorType: 'network_error', + code: 'ERROR_CODE', + debugMessage: 'Error message', + }, + }); + }); + + // Verify error state includes the error data + expect(onStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: { + errorType: 'network_error', + code: 'ERROR_CODE', + debugMessage: 'Error message', + }, + }); + }); +}); diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts new file mode 100644 index 0000000000..ce81b22b12 --- /dev/null +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect } from 'react'; +import { useFundContext } from '../components/FundCardProvider'; +import { FUND_BUTTON_RESET_TIMEOUT } from '../constants'; +import type { EventMetadata, SuccessEventData } from '../types'; +import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; + +export const useFundCardSetupOnrampEventListeners = () => { + const { setSubmitButtonState, updateLifecycleStatus } = useFundContext(); + + const handleOnrampEvent = useCallback( + (data: EventMetadata) => { + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + statusData: undefined, + }); + } else if (data.eventName === 'error') { + updateLifecycleStatus({ + statusName: 'error', + statusData: data.error, + }); + + setSubmitButtonState('error'); + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); + } + }, + [updateLifecycleStatus, setSubmitButtonState], + ); + + const handleOnrampSuccess = useCallback( + (data?: SuccessEventData) => { + updateLifecycleStatus({ + statusName: 'transactionSuccess', + statusData: data, + }); + + setSubmitButtonState('success'); + + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); + }, + [updateLifecycleStatus, setSubmitButtonState], + ); + + const handleOnrampExit = useCallback(() => { + setSubmitButtonState('default'); + + updateLifecycleStatus({ + statusName: 'exit', + statusData: undefined, + }); + }, [updateLifecycleStatus, setSubmitButtonState]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to run this effect once + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: handleOnrampEvent, + onExit: handleOnrampExit, + onSuccess: handleOnrampSuccess, + }); + + return () => { + unsubscribe(); + }; + }, []); +}; diff --git a/src/fund/hooks/useInputResize.test.ts b/src/fund/hooks/useInputResize.test.ts new file mode 100644 index 0000000000..d1d4832c80 --- /dev/null +++ b/src/fund/hooks/useInputResize.test.ts @@ -0,0 +1,149 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useInputResize } from './useInputResize'; + +describe('useInputResize', () => { + let resizeCallback: (entries: ResizeObserverEntry[]) => void; + + beforeEach(() => { + // Mock ResizeObserver with callback capture + global.ResizeObserver = vi.fn().mockImplementation((callback) => { + resizeCallback = callback; + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }); + }); + + it('handles null refs', () => { + const containerRef = { current: null }; + const inputRef = { current: null }; + const hiddenSpanRef = { current: null }; + const currencySpanRef = { current: null }; + + const { result } = renderHook(() => + useInputResize(containerRef, inputRef, hiddenSpanRef, currencySpanRef), + ); + + expect(() => result.current()).not.toThrow(); + }); + + it('updates input width based on measurements', () => { + const containerRef = { + current: { + getBoundingClientRect: () => ({ width: 300 }), + }, + }; + const inputRef = { + current: { + style: {}, + }, + }; + const hiddenSpanRef = { + current: { + offsetWidth: 100, + }, + }; + const currencySpanRef = { + current: { + getBoundingClientRect: () => ({ width: 20 }), + }, + }; + const { result } = renderHook(() => + useInputResize( + containerRef as React.RefObject, + inputRef as React.RefObject, + hiddenSpanRef as React.RefObject, + currencySpanRef as React.RefObject, + ), + ); + + result.current(); + + expect((inputRef.current as HTMLInputElement).style.width).toBe('100px'); + expect((inputRef.current as HTMLInputElement).style.maxWidth).toBe('280px'); + }); + + it('calls updateInputWidth when ResizeObserver triggers', () => { + const containerRef = { + current: { + getBoundingClientRect: () => ({ width: 300 }), + }, + }; + const inputRef = { + current: { + style: {}, + }, + }; + const hiddenSpanRef = { + current: { + offsetWidth: 100, + }, + }; + const currencySpanRef = { + current: { + getBoundingClientRect: () => ({ width: 20 }), + }, + }; + + renderHook(() => + useInputResize( + containerRef as React.RefObject, + inputRef as React.RefObject, + hiddenSpanRef as React.RefObject, + currencySpanRef as React.RefObject, + ), + ); + + // Trigger the ResizeObserver callback + resizeCallback([ + { + contentRect: { width: 300 } as DOMRectReadOnly, + target: document.createElement('div'), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ]); + + // Verify the input width was updated + expect((inputRef.current as HTMLInputElement).style.width).toBe('100px'); + expect((inputRef.current as HTMLInputElement).style.maxWidth).toBe('280px'); + }); + + it('handles missing currency ref but present other refs', () => { + const containerRef = { + current: { + getBoundingClientRect: () => ({ width: 300 }), + }, + }; + const inputRef = { + current: { + style: {}, + }, + }; + const hiddenSpanRef = { + current: { + offsetWidth: 100, + }, + }; + const currencySpanRef = { current: null }; + + const { result } = renderHook(() => + useInputResize( + containerRef as React.RefObject, + inputRef as React.RefObject, + hiddenSpanRef as React.RefObject, + currencySpanRef as React.RefObject, + ), + ); + + result.current(); + + // Should still work but with 0 currency width + expect((inputRef.current as HTMLInputElement).style.width).toBe('100px'); + expect((inputRef.current as HTMLInputElement).style.maxWidth).toBe('300px'); // full container width since currency width is 0 + }); +}); diff --git a/src/fund/hooks/useInputResize.ts b/src/fund/hooks/useInputResize.ts new file mode 100644 index 0000000000..4681d3b4ae --- /dev/null +++ b/src/fund/hooks/useInputResize.ts @@ -0,0 +1,41 @@ +import { type RefObject, useCallback, useEffect } from 'react'; + +export const useInputResize = ( + containerRef: RefObject, + inputRef: RefObject, + hiddenSpanRef: RefObject, + currencySpanRef: RefObject, +) => { + const updateInputWidth = useCallback(() => { + if (hiddenSpanRef.current && inputRef.current && containerRef.current) { + const textWidth = Math.max(42, hiddenSpanRef.current.offsetWidth); + const currencyWidth = + currencySpanRef.current?.getBoundingClientRect().width || 0; + const containerWidth = containerRef.current.getBoundingClientRect().width; + + // Set the input width based on available space + inputRef.current.style.width = `${textWidth}px`; + inputRef.current.style.maxWidth = `${containerWidth - currencyWidth}px`; + } + }, [containerRef, inputRef, hiddenSpanRef, currencySpanRef]); + + // Set up resize observer + useEffect(() => { + if (!containerRef.current) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + updateInputWidth(); + }); + + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [containerRef, updateInputWidth]); + + // Update width when value changes + return updateInputWidth; +}; diff --git a/src/fund/hooks/useLifecycleStatus.test.ts b/src/fund/hooks/useLifecycleStatus.test.ts new file mode 100644 index 0000000000..60138f5357 --- /dev/null +++ b/src/fund/hooks/useLifecycleStatus.test.ts @@ -0,0 +1,141 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useLifecycleStatus } from './useLifecycleStatus'; + +describe('useLifecycleStatus', () => { + it('initializes with provided status', () => { + const { result } = renderHook(() => + useLifecycleStatus({ + statusName: 'init', + statusData: null, + }), + ); + + expect(result.current[0]).toEqual({ + statusName: 'init', + statusData: null, + }); + }); + + it('updates status and preserves data', () => { + const { result } = renderHook(() => + useLifecycleStatus({ + statusName: 'init', + statusData: null, + }), + ); + + const [, updateStatus] = result.current; + // Update to pending + act(() => { + updateStatus({ + statusName: 'transactionPending', + statusData: { + abc: 'def', + }, + }); + }); + + expect(result.current[0]).toEqual({ + statusName: 'transactionPending', + statusData: { + abc: 'def', + }, + }); + + // Update to success + + act(() => { + updateStatus({ + statusName: 'transactionSuccess', + statusData: { + assetImageUrl: 'a', + assetName: 'b', + assetSymbol: 'c', + chainId: 'd', + }, + }); + }); + + expect(result.current[0]).toEqual({ + statusName: 'transactionSuccess', + statusData: { + abc: 'def', + assetImageUrl: 'a', + assetName: 'b', + assetSymbol: 'c', + chainId: 'd', + }, + }); + }); + + it('handles error states correctly', () => { + const { result } = renderHook(() => + useLifecycleStatus({ + statusName: 'init', + statusData: null, + }), + ); + + const error = { + errorType: 'network_error' as const, + code: 'ERROR_CODE', + debugMessage: 'Something went wrong', + }; + + // Set error state + act(() => { + result.current[1]({ + statusName: 'error', + statusData: error, + }); + }); + + expect(result.current[0]).toEqual({ + statusName: 'error', + statusData: error, + }); + + // Moving from error to another state should clear error data + act(() => { + result.current[1]({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + expect(result.current[0]).toEqual({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + it('transitions through a complete lifecycle', () => { + const { result } = renderHook(() => + useLifecycleStatus({ + statusName: 'init', + statusData: null, + }), + ); + + // Init -> Pending + act(() => { + result.current[1]({ + statusName: 'transactionPending', + statusData: {}, + }); + }); + + expect(result.current[0].statusName).toBe('transactionPending'); + + // Pending -> Success + act(() => { + result.current[1]({ + statusName: 'transactionSuccess', + statusData: {}, + }); + }); + + expect(result.current[0].statusName).toBe('transactionSuccess'); + }); +}); diff --git a/src/fund/hooks/useLifecycleStatus.ts b/src/fund/hooks/useLifecycleStatus.ts new file mode 100644 index 0000000000..f0883783a0 --- /dev/null +++ b/src/fund/hooks/useLifecycleStatus.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react'; +import type { LifecycleStatus, LifecycleStatusUpdate } from '../types'; + +type UseLifecycleStatusReturn = [ + lifecycleStatus: LifecycleStatus, + updatelifecycleStatus: (newStatus: LifecycleStatusUpdate) => void, +]; + +export function useLifecycleStatus( + initialState: LifecycleStatus, +): UseLifecycleStatusReturn { + const [lifecycleStatus, setLifecycleStatus] = + useState(initialState); // Component lifecycle + + // Update lifecycle status, statusData will be persisted for the full lifecycle + const updateLifecycleStatus = useCallback( + (newStatus: LifecycleStatusUpdate) => { + setLifecycleStatus((prevStatus: LifecycleStatus) => { + // do not persist errors + const persistedStatusData = + prevStatus.statusName === 'error' + ? (({ errorType, code, debugMessage, ...statusData }) => + statusData)(prevStatus.statusData) + : prevStatus.statusData; + return { + statusName: newStatus.statusName, + statusData: { + ...persistedStatusData, + ...newStatus.statusData, + }, + } as LifecycleStatus; + }); + }, + [], + ); + + return [lifecycleStatus, updateLifecycleStatus]; +} diff --git a/src/fund/index.ts b/src/fund/index.ts index 5df99a67ff..be1340fee7 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,18 +1,24 @@ // 🌲☀🌲 // Components export { FundButton } from './components/FundButton'; +export { FundCard } from './components/FundCard'; +export { FundCardProvider } from './components/FundCardProvider'; // Utils -export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; -export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; -export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; -export { fetchOnrampTransactionStatus } from './utils/fetchOnrampTransactionStatus'; export { fetchOnrampConfig } from './utils/fetchOnrampConfig'; export { fetchOnrampOptions } from './utils/fetchOnrampOptions'; export { fetchOnrampQuote } from './utils/fetchOnrampQuote'; +export { fetchOnrampTransactionStatus } from './utils/fetchOnrampTransactionStatus'; +export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; +export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; +export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; // Types export type { + EventMetadata, + FundCardPropsReact, GetOnrampUrlWithProjectIdParams, GetOnrampUrlWithSessionTokenParams, + OnrampError, + PaymentMethodReact, } from './types'; diff --git a/src/fund/types.ts b/src/fund/types.ts index 3b1661d240..915ff12ea2 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + /** * Props used to get an Onramp buy URL by directly providing a CDP project ID. * See https://docs.cdp.coinbase.com/onramp/docs/api-initializing#generating-the-coinbase-onramp-buysell-url @@ -88,6 +90,11 @@ type GetOnrampBuyUrlOptionalProps = { * choose to change this amount in the UI. Only one of presetCryptoAmount or presetFiatAmount should be provided. */ presetFiatAmount?: number; + + /** + * The default payment method that will be selected for the user in the Onramp UI. Should be one of the payment methods + */ + defaultPaymentMethod?: PaymentAccountReact; /** * The currency code of the fiat amount provided in the presetFiatAmount param e.g. USD, CAD, EUR. */ @@ -106,6 +113,9 @@ 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 + successText?: string; // An optional text to be displayed in the button component when the transaction is successful + errorText?: string; // An optional text to be displayed in the button component when the transaction fails + state?: FundButtonStateReact; // The state of 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 @@ -117,8 +127,12 @@ export type FundButtonReact = { 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 + onPopupClose?: () => void; // A callback function that will be called when the popup window is closed + onClick?: () => void; // A callback function that will be called when the button is clicked }; +export type FundButtonStateReact = 'default' | 'success' | 'error' | 'loading'; + /** * Matches a JSON object. * This type can be useful to enforce some input to be JSON-compatible or as a super-type to be extended from. Don't use this as a direct return type as the user would have to double-cast it: `jsonObject as unknown as CustomResponse`. Instead, you could extend your CustomResponse type from it to ensure your type only uses JSON-compatible types: `interface CustomResponse extends JsonObject { … }`. @@ -167,6 +181,14 @@ export type ExitEvent = { export type SuccessEvent = { eventName: 'success'; + data?: SuccessEventData; +}; + +export type SuccessEventData = { + assetImageUrl: string; + assetName: string; + assetSymbol: string; + chainId: string; }; export type RequestOpenUrlEvent = { @@ -196,7 +218,7 @@ export type OnrampTransactionStatusName = | 'ONRAMP_TRANSACTION_STATUS_FAILED'; export type OnrampAmount = { - amount: string; + value: string; currency: string; }; @@ -247,3 +269,154 @@ export type OnrampPaymentCurrency = { id: string; paymentMethodLimits: OnrampPaymentMethodLimit[]; }; + +export type FundCardAmountInputPropsReact = { + className?: string; +}; + +export type FundCardAmountInputTypeSwitchPropsReact = { + className?: string; +}; + +export type FundCardHeaderPropsReact = { + className?: string; +}; + +export type FundCardPaymentMethodImagePropsReact = { + className?: string; + size?: number; + paymentMethod: PaymentMethodReact; +}; + +export type PaymentAccountReact = + | 'COINBASE' + | 'CRYPTO_ACCOUNT' + | 'FIAT_WALLET' + | 'CARD' + | 'ACH_BANK_ACCOUNT' + | 'APPLE_PAY' + | ''; // Empty string represents Coinbase default payment method + +export type PaymentMethodReact = { + id: PaymentAccountReact; + name: string; + description: string; + icon: string; +}; + +export type FundCardPaymentMethodDropdownPropsReact = { + className?: string; +}; + +export type FundCardCurrencyLabelPropsReact = { + label: string; +}; + +export type FundCardPropsReact = { + children?: ReactNode; + assetSymbol: string; + placeholder?: string | React.ReactNode; + headerText?: string; + buttonText?: string; + country: string; + subdivision?: string; + className?: string; +} & LifecycleEvents; + +export type FundCardContentPropsReact = { + children?: ReactNode; +}; + +export type FundCardPaymentMethodSelectorTogglePropsReact = { + className?: string; + isOpen: boolean; // Determines carot icon direction + onClick: () => void; // Button on click handler + paymentMethod: PaymentMethodReact; +}; + +export type FundCardPaymentMethodSelectRowPropsReact = { + paymentMethod: PaymentMethodReact; + onClick?: (paymentMethod: PaymentMethodReact) => void; + hideImage?: boolean; + hideDescription?: boolean; + disabled?: boolean; + disabledReason?: string; + testId?: string; +}; + +export type FundCardProviderReact = { + children: ReactNode; + asset: string; + paymentMethods?: PaymentMethodReact[]; + headerText?: string; + buttonText?: string; + country: string; + subdivision?: string; + inputType?: 'fiat' | 'crypto'; +} & LifecycleEvents; + +export type LifecycleEvents = { + onError?: (e: OnrampError | undefined) => void; + onStatus?: (lifecycleStatus: LifecycleStatus) => void; + onSuccess?: (result: SuccessEventData) => void; +}; + +export type LifecycleStatus = + | { + statusName: 'init'; + statusData: null; + } + | { + statusName: 'exit'; + statusData: null; + } + | { + statusName: 'error'; + statusData: OnrampError; + } + | { + statusName: 'transactionSuccess'; + statusData: SuccessEventData; + } + | { + statusName: 'transactionPending'; + statusData: null; + }; + +type LifecycleStatusDataShared = Record; + +// make all keys in T optional if they are in K +type PartialKeys = Omit & + Partial> extends infer O + ? { [P in keyof O]: O[P] } + : never; + +// check if all keys in T are a key of LifecycleStatusDataShared +type AllKeysInShared = keyof T extends keyof LifecycleStatusDataShared + ? true + : false; + +/** + * LifecycleStatus updater type + * Used to type the statuses used to update LifecycleStatus + * LifecycleStatusData is persisted across state updates allowing SharedData to be optional except for in init step + */ +export type LifecycleStatusUpdate = LifecycleStatus extends infer T + ? T extends { statusName: infer N; statusData: infer D } + ? { statusName: N } & (N extends 'init' // statusData required in statusName "init" + ? { statusData: D } + : AllKeysInShared extends true // is statusData is LifecycleStatusDataShared, make optional + ? { + statusData?: PartialKeys< + D, + keyof D & keyof LifecycleStatusDataShared + >; + } // make all keys in LifecycleStatusDataShared optional + : { + statusData: PartialKeys< + D, + keyof D & keyof LifecycleStatusDataShared + >; + }) + : never + : never; diff --git a/src/fund/utils/fetchOnrampQuote.test.ts b/src/fund/utils/fetchOnrampQuote.test.ts index 3274153638..477913f736 100644 --- a/src/fund/utils/fetchOnrampQuote.test.ts +++ b/src/fund/utils/fetchOnrampQuote.test.ts @@ -13,14 +13,12 @@ const mockCountry = 'US'; const mockSubdivision = 'NY'; const mockResponseData = { - data: { - payment_total: { amount: '105.00', currency: 'USD' }, - payment_subtotal: { amount: '100.00', currency: 'USD' }, - purchase_amount: { amount: '0.0025', currency: 'BTC' }, - coinbase_fee: { amount: '3.00', currency: 'USD' }, - network_fee: { amount: '2.00', currency: 'USD' }, - quote_id: 'quote-id-123', - }, + payment_total: { amount: '105.00', currency: 'USD' }, + payment_subtotal: { amount: '100.00', currency: 'USD' }, + purchase_amount: { amount: '0.0025', currency: 'BTC' }, + coinbase_fee: { amount: '3.00', currency: 'USD' }, + network_fee: { amount: '2.00', currency: 'USD' }, + quote_id: 'quote-id-123', }; global.fetch = vi.fn(() => diff --git a/src/fund/utils/fetchOnrampQuote.ts b/src/fund/utils/fetchOnrampQuote.ts index 7e31cb4a40..35b94e678f 100644 --- a/src/fund/utils/fetchOnrampQuote.ts +++ b/src/fund/utils/fetchOnrampQuote.ts @@ -86,5 +86,5 @@ export async function fetchOnrampQuote({ const responseJson = await response.json(); - return convertSnakeToCamelCase(responseJson.data); + return convertSnakeToCamelCase(responseJson); } diff --git a/src/fund/utils/setupOnrampEventListeners.ts b/src/fund/utils/setupOnrampEventListeners.ts index 215752230b..19bfc26aa6 100644 --- a/src/fund/utils/setupOnrampEventListeners.ts +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -1,10 +1,10 @@ import { DEFAULT_ONRAMP_URL } from '../constants'; -import type { EventMetadata, OnrampError } from '../types'; +import type { EventMetadata, OnrampError, SuccessEventData } from '../types'; import { subscribeToWindowMessage } from './subscribeToWindowMessage'; type SetupOnrampEventListenersParams = { host?: string; - onSuccess?: () => void; + onSuccess?: (data?: SuccessEventData) => void; onExit?: (error?: OnrampError) => void; onEvent?: (event: EventMetadata) => void; }; @@ -28,7 +28,7 @@ export function setupOnrampEventListeners({ const metadata = data as EventMetadata; if (metadata.eventName === 'success') { - onSuccess?.(); + onSuccess?.(metadata.data); } if (metadata.eventName === 'exit') { onExit?.(metadata.error); diff --git a/src/fund/utils/truncateDecimalPlaces.test.ts b/src/fund/utils/truncateDecimalPlaces.test.ts new file mode 100644 index 0000000000..6063bac3a8 --- /dev/null +++ b/src/fund/utils/truncateDecimalPlaces.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { truncateDecimalPlaces } from './truncateDecimalPlaces'; + +describe('truncateDecimalPlaces', () => { + it('handles string inputs', () => { + expect(truncateDecimalPlaces('123.456', 2)).toBe('123.45'); + expect(truncateDecimalPlaces('0.123456', 4)).toBe('0.1234'); + expect(truncateDecimalPlaces('100', 2)).toBe('100'); + }); + + it('handles number inputs', () => { + expect(truncateDecimalPlaces(123.456, 2)).toBe('123.45'); + expect(truncateDecimalPlaces(0.123456, 4)).toBe('0.1234'); + expect(truncateDecimalPlaces(100, 2)).toBe('100'); + }); + + it('handles edge cases', () => { + expect(truncateDecimalPlaces('', 2)).toBe(''); + expect(truncateDecimalPlaces('.123', 2)).toBe('.12'); + expect(truncateDecimalPlaces(0, 2)).toBe('0'); + expect(truncateDecimalPlaces('.', 2)).toBe('.'); + }); + + it('preserves trailing zeros if present in input string', () => { + expect(truncateDecimalPlaces('123.450', 2)).toBe('123.45'); + expect(truncateDecimalPlaces('0.120', 3)).toBe('0.120'); + }); + + it('handles negative numbers', () => { + expect(truncateDecimalPlaces(-123.456, 2)).toBe('-123.45'); + expect(truncateDecimalPlaces('-0.123456', 4)).toBe('-0.1234'); + }); +}); diff --git a/src/fund/utils/truncateDecimalPlaces.ts b/src/fund/utils/truncateDecimalPlaces.ts new file mode 100644 index 0000000000..8bb03ab678 --- /dev/null +++ b/src/fund/utils/truncateDecimalPlaces.ts @@ -0,0 +1,20 @@ +/** + * Limit the value to N decimal places + */ +export const truncateDecimalPlaces = ( + value: string | number, + decimalPlaces: number, +) => { + const stringValue = String(value); + const decimalIndex = stringValue.indexOf('.'); + let resultValue = stringValue; + + if ( + decimalIndex !== -1 && + stringValue.length - decimalIndex - 1 > decimalPlaces + ) { + resultValue = stringValue.substring(0, decimalIndex + decimalPlaces + 1); + } + + return resultValue; +}; diff --git a/src/internal/components/Skeleton.tsx b/src/internal/components/Skeleton.tsx new file mode 100644 index 0000000000..35dd5ab14a --- /dev/null +++ b/src/internal/components/Skeleton.tsx @@ -0,0 +1,22 @@ +import { background, border, cn } from '../../styles/theme'; + +type SkeletonReact = { + className?: string; +}; + +/** + * A skeleton component is a visual placeholder that mimics the content of an element while it's loading + */ +export function Skeleton({ className }: SkeletonReact) { + return ( +
+ ); +} diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index 31f870aa58..dd96776c10 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -1,67 +1,77 @@ -import { useCallback } from 'react'; -import type { ChangeEvent, InputHTMLAttributes } from 'react'; +import { + type ChangeEvent, + type InputHTMLAttributes, + forwardRef, + useCallback, +} from 'react'; import { useDebounce } from '../../core-react/internal/hooks/useDebounce'; type TextInputReact = { 'aria-label'?: string; className: string; - delayMs: number; + delayMs?: number; disabled?: boolean; // specify 'decimal' to trigger numeric keyboards on mobile devices inputMode?: InputHTMLAttributes['inputMode']; onBlur?: () => void; onChange: (s: string) => void; placeholder: string; - setValue: (s: string) => void; + setValue?: (s: string) => void; value: string; inputValidator?: (s: string) => boolean; }; -export function TextInput({ - 'aria-label': ariaLabel, - className, - delayMs, - disabled = false, - onBlur, - onChange, - placeholder, - setValue, - inputMode, - value, - inputValidator = () => true, -}: TextInputReact) { - const handleDebounce = useDebounce((value) => { - onChange(value); - }, delayMs); +export const TextInput = forwardRef( + ( + { + 'aria-label': ariaLabel, + className, + delayMs = 0, + disabled = false, + onBlur, + onChange, + placeholder, + setValue, + inputMode, + value, + inputValidator = () => true, + }, + ref, + ) => { + const handleDebounce = useDebounce((value) => { + onChange(value); + }, delayMs); - const handleChange = useCallback( - (evt: ChangeEvent) => { - const value = evt.target.value; + const handleChange = useCallback( + (evt: ChangeEvent) => { + const value = evt.target.value; - if (inputValidator(value)) { - setValue(value); - if (delayMs > 0) { - handleDebounce(value); - } else { - onChange(value); + if (inputValidator(value)) { + setValue?.(value); + if (delayMs > 0) { + handleDebounce(value); + } else { + onChange(value); + } } - } - }, - [onChange, handleDebounce, delayMs, setValue, inputValidator], - ); + }, + [onChange, handleDebounce, delayMs, setValue, inputValidator], + ); - return ( - - ); -} + return ( + + ); + }, +); diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx index 91c7f117ee..a399b497cc 100644 --- a/src/internal/svg/addSvg.tsx +++ b/src/internal/svg/addSvg.tsx @@ -1,6 +1,6 @@ -import { icon } from '../../styles/theme'; +import { cn, icon } from '../../styles/theme'; -export const addSvg = ( +export const AddSvg = ({ className = cn(icon.inverse) }) => ( + Add ); diff --git a/src/internal/svg/applePaySvg.tsx b/src/internal/svg/applePaySvg.tsx new file mode 100644 index 0000000000..7933a34301 --- /dev/null +++ b/src/internal/svg/applePaySvg.tsx @@ -0,0 +1,29 @@ +export const applePaySvg = ( + + Apple Pay + + + + + + + + + + +); diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index c67a88fdf2..aec86928bb 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -1,29 +1,24 @@ +import { icon } from '../../styles/theme'; + export const appleSvg = ( - Apple Pay Onramp + Apple - - - - - - - ); diff --git a/src/internal/svg/creditCardSvg.tsx b/src/internal/svg/creditCardSvg.tsx new file mode 100644 index 0000000000..740559dd13 --- /dev/null +++ b/src/internal/svg/creditCardSvg.tsx @@ -0,0 +1,24 @@ +import { icon } from '../../styles/theme'; + +export const creditCardSvg = ( + + Credit Card + + + +); diff --git a/src/internal/svg/errorSvg.tsx b/src/internal/svg/errorSvg.tsx index 43aaf3ee26..fecafd0410 100644 --- a/src/internal/svg/errorSvg.tsx +++ b/src/internal/svg/errorSvg.tsx @@ -1,4 +1,4 @@ -export const errorSvg = ( +export const ErrorSvg = ({ fill = '#E11D48' }) => ( - Error SVG + Error ); diff --git a/src/internal/svg/successSvg.tsx b/src/internal/svg/successSvg.tsx index 0fa5d4cb9d..0103f5cc64 100644 --- a/src/internal/svg/successSvg.tsx +++ b/src/internal/svg/successSvg.tsx @@ -1,4 +1,4 @@ -export const successSvg = ( +export const SuccessSvg = ({ fill = '#65A30D' }) => ( Success SVG ); diff --git a/src/styles/index.css b/src/styles/index.css index f8b20280f3..4c8234a05f 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -63,7 +63,7 @@ fill: var(--ock-icon-color-inverse); } .ock-icon-color-error { - fill: var(--ock-icon-color-erro); + fill: var(--ock-icon-color-error); } .ock-icon-color-success { fill: var(--ock-icon-color-success); diff --git a/src/styles/theme.ts b/src/styles/theme.ts index c60d1c4553..088be03e49 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -1,5 +1,4 @@ -import { clsx } from 'clsx'; -import type { ClassValue } from 'clsx'; +import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { diff --git a/src/swap/components/SwapAmountInput.tsx b/src/swap/components/SwapAmountInput.tsx index 22af518a74..1ce83507df 100644 --- a/src/swap/components/SwapAmountInput.tsx +++ b/src/swap/components/SwapAmountInput.tsx @@ -12,8 +12,8 @@ import { pressable, text, } from '../../styles/theme'; -import { TokenChip, TokenSelectDropdown } from '../../token'; import type { Token } from '../../token'; +import { TokenChip, TokenSelectDropdown } from '../../token'; import type { SwapAmountInputReact } from '../types'; import { formatAmount } from '../utils/formatAmount'; import { useSwapContext } from './SwapProvider'; diff --git a/src/swap/components/SwapToast.tsx b/src/swap/components/SwapToast.tsx index 9ccfe3787a..9d2e53dbad 100644 --- a/src/swap/components/SwapToast.tsx +++ b/src/swap/components/SwapToast.tsx @@ -5,7 +5,7 @@ import { cn, color, text } from '../../styles/theme'; import { useAccount } from 'wagmi'; import { getChainExplorer } from '../../core/network/getChainExplorer'; import { Toast } from '../../internal/components/Toast'; -import { successSvg } from '../../internal/svg/successSvg'; +import { SuccessSvg } from '../../internal/svg/successSvg'; import type { SwapToastReact } from '../types'; import { useSwapContext } from './SwapProvider'; @@ -41,7 +41,9 @@ export function SwapToast({ isVisible={isToastVisible} onClose={resetToastState} > -
{successSvg}
+
+ +

Successful

diff --git a/src/transaction/components/TransactionToastIcon.tsx b/src/transaction/components/TransactionToastIcon.tsx index 19915e01e8..0903d55098 100644 --- a/src/transaction/components/TransactionToastIcon.tsx +++ b/src/transaction/components/TransactionToastIcon.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Spinner } from '../../internal/components/Spinner'; -import { errorSvg } from '../../internal/svg/errorSvg'; -import { successSvg } from '../../internal/svg/successSvg'; +import { ErrorSvg } from '../../internal/svg/errorSvg'; +import { SuccessSvg } from '../../internal/svg/successSvg'; import { cn, text } from '../../styles/theme'; import type { TransactionToastIconReact } from '../types'; import { useTransactionContext } from './TransactionProvider'; @@ -14,10 +14,10 @@ export function TransactionToastIcon({ className }: TransactionToastIconReact) { const icon = useMemo(() => { // txn successful if (receipt) { - return successSvg; + return ; } if (errorMessage) { - return errorSvg; + return ; } if (isInProgress) { return ; diff --git a/vitest.config.ts b/vitest.config.ts index 694e3f9be7..d645aee1ea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'playground/**', 'site/**', 'create-onchain/**', + '**/**.test.tsx', ], reportOnFailure: true, thresholds: {