From 9fe2fdeba1a265b4d86a3eaa12e252dc496a9808 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Thu, 14 Nov 2024 13:29:41 -0800 Subject: [PATCH 01/91] Onramp - event subscriptions migration --- src/fund/constants.ts | 3 +- src/fund/index.ts | 2 + src/fund/types.ts | 65 ++++++++++ .../utils/setupOnrampEventListeners.test.ts | 108 ++++++++++++++++ src/fund/utils/setupOnrampEventListeners.ts | 42 ++++++ .../utils/subscribeToWindowMessage.test.ts | 122 ++++++++++++++++++ src/fund/utils/subscribeToWindowMessage.ts | 72 +++++++++++ 7 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/fund/utils/setupOnrampEventListeners.test.ts create mode 100644 src/fund/utils/setupOnrampEventListeners.ts create mode 100644 src/fund/utils/subscribeToWindowMessage.test.ts create mode 100644 src/fund/utils/subscribeToWindowMessage.ts diff --git a/src/fund/constants.ts b/src/fund/constants.ts index d2d3a9f2a8..9e5e4648b5 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -1,5 +1,6 @@ +export const DEFAULT_ONRAMP_URL = 'https://pay.coinbase.com'; // The base URL for the Coinbase Onramp widget. -export const ONRAMP_BUY_URL = 'https://pay.coinbase.com/buy'; +export const ONRAMP_BUY_URL = `${DEFAULT_ONRAMP_URL}/buy`; // The recommended height of a Coinbase Onramp popup window. export const ONRAMP_POPUP_HEIGHT = 720; // The recommended width of a Coinbase Onramp popup window. diff --git a/src/fund/index.ts b/src/fund/index.ts index 88e1fecbc2..a01e7248d8 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,6 +1,8 @@ export { FundButton } from './components/FundButton'; export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; +export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; + export type { GetOnrampUrlWithProjectIdParams, GetOnrampUrlWithSessionTokenParams, diff --git a/src/fund/types.ts b/src/fund/types.ts index ea01fad030..60e3bb2e55 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -118,3 +118,68 @@ export type FundButtonReact = { 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 }; + +/** + * 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 { … }`. + * @category JSON + */ +export type JsonObject = { [Key in string]?: JsonValue }; + +/** + * Matches a JSON array. + * @category JSON + */ +export type JsonArray = JsonValue[]; + +/** + * Matches any valid JSON primitive value. + * @category JSON + */ +export type JsonPrimitive = string | number | boolean | null; + +/** + * Matches any valid JSON value. + * @see `Jsonify` if you need to transform a type to one that is assignable to `JsonValue`. + * @category JSON + */ +export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export type OpenEvent = { + eventName: 'open'; + widgetName: string; +}; + +export type TransitionViewEvent = { + eventName: 'transition_view'; + pageRoute: string; +}; + +export type PublicErrorEvent = { + eventName: 'error'; + // biome-ignore lint/suspicious/noExplicitAny: + error: any; +}; + +export type ExitEvent = { + eventName: 'exit'; + // biome-ignore lint/suspicious/noExplicitAny: + error?: any; +}; + +export type SuccessEvent = { + eventName: 'success'; +}; + +export type RequestOpenUrlEvent = { + eventName: 'request_open_url'; + url: string; +}; + +export type EventMetadata = + | OpenEvent + | TransitionViewEvent + | PublicErrorEvent + | ExitEvent + | SuccessEvent + | RequestOpenUrlEvent; diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts new file mode 100644 index 0000000000..9f563778ab --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EventMetadata } from '../types'; +import { setupOnrampEventListeners } from './setupOnrampEventListeners'; +import { subscribeToWindowMessage } from './subscribeToWindowMessage'; + +vi.mock('./subscribeToWindowMessage', () => ({ + subscribeToWindowMessage: vi.fn(), +})); + +describe('setupOnrampEventListeners', () => { + let unsubscribe: ReturnType; + + beforeEach(() => { + unsubscribe = vi.fn(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should call subscribeToWindowMessage with correct parameters', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + expect(subscribeToWindowMessage).toHaveBeenCalledWith({ + allowedOrigin: host, + onMessage: expect.any(Function), + }); + }); + + it('should call onSuccess callback when success event is received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { eventName: 'success' }; + + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onSuccess).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should call onExit callback when exit event is received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { + eventName: 'exit', + error: 'some error', + }; + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onExit).toHaveBeenCalledWith('some error'); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should call onEvent callback for any event received', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + setupOnrampEventListeners({ onEvent, onExit, onSuccess, host }); + + const eventMetadata: EventMetadata = { eventName: 'success' }; + vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( + eventMetadata, + ); + + expect(onEvent).toHaveBeenCalledWith(eventMetadata); + }); + + it('should return the unsubscribe function', () => { + const onEvent = vi.fn(); + const onExit = vi.fn(); + const onSuccess = vi.fn(); + const host = 'https://example.com'; + + vi.mocked(subscribeToWindowMessage).mockReturnValue(unsubscribe); + + const result = setupOnrampEventListeners({ + onEvent, + onExit, + onSuccess, + host, + }); + + expect(result).toBe(unsubscribe); + }); +}); diff --git a/src/fund/utils/setupOnrampEventListeners.ts b/src/fund/utils/setupOnrampEventListeners.ts new file mode 100644 index 0000000000..e83bacf676 --- /dev/null +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -0,0 +1,42 @@ +import { DEFAULT_ONRAMP_URL } from '../constants'; +import type { EventMetadata } from '../types'; +import { subscribeToWindowMessage } from './subscribeToWindowMessage'; + +type SetupOnrampEventListenersParams = { + host?: string; + onSuccess?: () => void; + // biome-ignore lint/suspicious/noExplicitAny: + onExit?: (error?: any) => void; + onEvent?: (event: EventMetadata) => void; +}; + +/** + * Subscribes to events from the Coinbase Onramp widget. + * @param onEvent - Callback for when any event is received. + * @param onExit - Callback for when an exit event is received. + * @param onSuccess - Callback for when a success event is received. + * @returns a function to unsubscribe from the event listener. + */ +export function setupOnrampEventListeners({ + onEvent, + onExit, + onSuccess, + host = DEFAULT_ONRAMP_URL, +}: SetupOnrampEventListenersParams) { + const unsubscribe = subscribeToWindowMessage({ + allowedOrigin: host, + onMessage: (data) => { + const metadata = data as EventMetadata; + + if (metadata.eventName === 'success') { + onSuccess?.(); + } + if (metadata.eventName === 'exit') { + onExit?.(metadata.error); + } + onEvent?.(metadata); + }, + }); + + return unsubscribe; +} diff --git a/src/fund/utils/subscribeToWindowMessage.test.ts b/src/fund/utils/subscribeToWindowMessage.test.ts new file mode 100644 index 0000000000..2f3be349dc --- /dev/null +++ b/src/fund/utils/subscribeToWindowMessage.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MessageCodes, + subscribeToWindowMessage, +} from './subscribeToWindowMessage'; + +describe('subscribeToWindowMessage', () => { + let unsubscribe: () => void; + const DEFAULT_ORIGIN = 'https://default.origin'; + // biome-ignore lint/suspicious/noExplicitAny: + const mockMessageEvent = (data: any, origin = DEFAULT_ORIGIN) => + new MessageEvent('message', { data, origin }); + + beforeEach(() => { + unsubscribe = () => {}; + }); + + afterEach(() => { + unsubscribe(); + }); + + it('should subscribe to window message and call onMessage when message is received', async () => { + const onMessage = vi.fn(); + unsubscribe = subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('should not call onMessage if the origin is not allowed', async () => { + const onMessage = vi.fn(); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: 'https://not.allowed.origin', + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('should validate the origin using onValidateOrigin callback', async () => { + const onMessage = vi.fn(); + const onValidateOrigin = vi.fn().mockResolvedValue(true); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + onValidateOrigin, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN); + expect(onMessage).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('should not call onMessage if onValidateOrigin returns false', async () => { + const onMessage = vi.fn(); + const onValidateOrigin = vi.fn().mockResolvedValue(false); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + onValidateOrigin, + }); + + const event = mockMessageEvent({ + eventName: MessageCodes.Event, + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onValidateOrigin).toHaveBeenCalledWith(DEFAULT_ORIGIN); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it('should not call onMessage if the message code is not "event"', async () => { + const onMessage = vi.fn(); + subscribeToWindowMessage({ + onMessage, + allowedOrigin: DEFAULT_ORIGIN, + }); + + const event = mockMessageEvent({ + eventName: 'not-event', + data: { key: 'value' }, + }); + window.dispatchEvent(event); + + //wait for the async code to run + await Promise.resolve(); + + expect(onMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/fund/utils/subscribeToWindowMessage.ts b/src/fund/utils/subscribeToWindowMessage.ts new file mode 100644 index 0000000000..c77ed0a238 --- /dev/null +++ b/src/fund/utils/subscribeToWindowMessage.ts @@ -0,0 +1,72 @@ +import { DEFAULT_ONRAMP_URL } from '../constants'; +import type { JsonObject } from '../types'; + +export enum MessageCodes { + AppParams = 'app_params', + PaymentLinkSuccess = 'payment_link_success', + PaymentLinkClosed = 'payment_link_closed', + GuestCheckoutRedirectSuccess = 'guest_checkout_redirect_success', + Success = 'success', + Event = 'event', +} + +export type MessageCode = `${MessageCodes}`; + +export type MessageData = JsonObject; + +export type PostMessageData = { + eventName: MessageCode; + data?: MessageData; +}; + +/** + * Subscribes to a message from the parent window. + * @param messageCode A message code to subscribe to. + * @param onMessage Callback for when the message is received. + * @param allowedOrigin The origin to allow messages from. + * @param onValidateOrigin Callback to validate the origin of the message. + * @returns + */ +export function subscribeToWindowMessage({ + onMessage, + allowedOrigin = DEFAULT_ONRAMP_URL, + onValidateOrigin = () => Promise.resolve(true), +}: { + onMessage: (data?: MessageData) => void; + allowedOrigin: string; + onValidateOrigin?: (origin: string) => Promise; +}) { + const handleMessage = (event: MessageEvent) => { + if (!isAllowedOrigin({ event, allowedOrigin })) { + return; + } + + const { eventName, data } = event.data; + + if (eventName === 'event') { + (async () => { + if (await onValidateOrigin(event.origin)) { + onMessage(data); + } + })(); + } + }; + + window.addEventListener('message', handleMessage); + + // Unsubscribe + return () => { + window.removeEventListener('message', handleMessage); + }; +} + +function isAllowedOrigin({ + event, + allowedOrigin, +}: { + event: MessageEvent; + allowedOrigin: string; +}) { + const isOriginAllowed = !allowedOrigin || event.origin === allowedOrigin; + return isOriginAllowed; +} From 5c95ff9fd130bdbee5a2d55642f65668c3e05aac Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 15 Nov 2024 10:34:18 -0800 Subject: [PATCH 02/91] Fund form --- .vscode/settings.json | 6 + .../components/demo/Fund.tsx | 6 +- .../nextjs-app-router/onchainkit/package.json | 2 +- src/fund/components/FundForm.tsx | 187 ++++++++++++++++++ src/fund/components/FundProvider.test.tsx | 18 ++ src/fund/components/FundProvider.tsx | 41 ++++ src/fund/index.ts | 2 + src/internal/components/TextInput.tsx | 6 +- 8 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/fund/components/FundForm.tsx create mode 100644 src/fund/components/FundProvider.test.tsx create mode 100644 src/fund/components/FundProvider.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..ec7cd0b6a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "eslint.format.enable": true, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnType": true +} \ No newline at end of file diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx index dfc49a6280..e2cf390851 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/Fund.tsx @@ -1,9 +1,13 @@ -import { FundButton } from '@coinbase/onchainkit/fund'; +import { FundButton, FundProvider, FundForm } from '@coinbase/onchainkit/fund'; export default function FundDemo() { return (
+ + + +
); } diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 97aa5d49b3..be64781475 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/onchainkit", - "version": "0.35.4", + "version": "0.35.5", "type": "module", "repository": "https://github.com/coinbase/onchainkit.git", "license": "MIT", diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx new file mode 100644 index 0000000000..78205eafc1 --- /dev/null +++ b/src/fund/components/FundForm.tsx @@ -0,0 +1,187 @@ +import { + ChangeEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { border, cn, color, line, pressable, text } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; +import { useFundContext } from './FundProvider'; +import { FundButton } from './FundButton'; +import { TextInput } from '../../internal/components/TextInput'; +import { formatAmount } from '../../token/utils/formatAmount'; + +type Props = { + assetSymbol: string; + placeholder?: string | React.ReactNode; + headerText?: string; + buttonText?: string; +}; +export function FundForm({ + assetSymbol, + buttonText = 'Buy', + headerText, +}: Props) { + const componentTheme = useTheme(); + + const { setSelectedAsset, setFundAmount, fundAmount } = useFundContext(); + + const defaultHeaderText = `Buy ${assetSymbol.toUpperCase()}`; + return ( +
+
+ {headerText || defaultHeaderText} +
+ +
+ +
+ + + ); +} + +type ResizableInputProps = { + value: string; + setValue: (s: string) => void; + currencySign?: string; +}; + +const ResizableInput = ({ + value, + setValue, + currencySign, +}: ResizableInputProps) => { + const componentTheme = useTheme(); + + const inputRef = useRef(null); + const spanRef = useRef(null); + const previousValueRef = useRef(''); + + const handleChange = (e: ChangeEvent) => { + let value = e.target.value; + /** + * Only allow numbers to be entered into the input + * Using type="number" on the input does not work because it adds a spinner which does not get removed with css '-webkit-appearance': 'none' + */ + if (/^\d*\.?\d*$/.test(value)) { + if (value.length === 1 && value === '.') { + value = '0.'; + } else if (value.length === 1 && value === '0') { + if (previousValueRef.current.length <= value.length) { + // Add a dot if the user types a single zero + value = '0.'; + } else { + value = ''; + } + } else if ( + value[value.length - 1] === '.' && + previousValueRef.current.length >= value.length + ) { + // If we are deleting a character and the last character is a dot, remove it + value = value.slice(0, -1); + } else if (value.length === 2 && value[0] === '0' && value[1] !== '.') { + // Add a dot in case there is a leading zero + value = `${value[0]}.${value[1]}`; + } + + setValue(value); + } + // Update the previous value + previousValueRef.current = value; + }; + + const fontSize = useMemo(() => { + if (value.length < 2) { + return 80; + } + return 80 - Math.min(value.length * 2.5, 60); + }, [value]); + + useEffect(() => { + // Update the input width based on the hidden span's width + if (spanRef.current && inputRef.current) { + if (inputRef.current?.style?.width) { + inputRef.current.style.width = + value.length === 1 + ? `${spanRef.current?.offsetWidth}px` + : `${spanRef.current?.offsetWidth + 10}px`; + } + } + }, [value]); + + return ( +
+ {currencySign && ( + + {currencySign} + + )} + + {/* Hidden span to measure content width */} + + {value || '0'} + +
+ ); +}; + +export default ResizableInput; diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx new file mode 100644 index 0000000000..faf8aed125 --- /dev/null +++ b/src/fund/components/FundProvider.test.tsx @@ -0,0 +1,18 @@ +// import '@testing-library/jest-dom'; +// import { render, renderHook } from '@testing-library/react'; +// import { WalletProvider, useWalletContext } from './WalletProvider'; + +// describe('useWalletContext', () => { +// it('should return default context', () => { +// render( +// +//
+// , +// ); + +// const { result } = renderHook(() => useWalletContext(), { +// wrapper: WalletProvider, +// }); +// expect(result.current.isOpen).toEqual(false); +// }); +// }); diff --git a/src/fund/components/FundProvider.tsx b/src/fund/components/FundProvider.tsx new file mode 100644 index 0000000000..a29d4219bf --- /dev/null +++ b/src/fund/components/FundProvider.tsx @@ -0,0 +1,41 @@ +import { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useValue } from '../../internal/hooks/useValue'; + +type FundContextType = { + selectedAsset?: string; + setSelectedAsset: (asset: string) => void; + fundAmount: string; + setFundAmount: (amount: string) => void; +}; + +const initialState = {} as FundContextType; + +const FundContext = createContext(initialState); + +type FundProviderReact = { + children: ReactNode; +}; + +export function FundProvider({ children }: FundProviderReact) { + const [selectedAsset, setSelectedAsset] = useState(); + const [fundAmount, setFundAmount] = useState(''); + + const value = useValue({ + selectedAsset, + setSelectedAsset, + fundAmount, + setFundAmount, + }); + return {children}; +} + +export function useFundContext() { + const context = useContext(FundContext); + + if (context === undefined) { + throw new Error('useFundContext must be used within a FundProvider'); + } + + return context; +} diff --git a/src/fund/index.ts b/src/fund/index.ts index a01e7248d8..b414257cbe 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,4 +1,6 @@ export { FundButton } from './components/FundButton'; +export { FundProvider } from './components/FundProvider'; +export { FundForm } from './components/FundForm'; export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index 9fae128aef..1940c76d42 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -13,6 +13,8 @@ type TextInputReact = { setValue: (s: string) => void; value: string; inputValidator?: (s: string) => boolean; + style?: React.CSSProperties; + ref: React.RefObject; }; export function TextInput({ @@ -26,6 +28,7 @@ export function TextInput({ setValue, value, inputValidator = () => true, + style, }: TextInputReact) { const handleDebounce = useDebounce((value) => { onChange(value); @@ -44,11 +47,12 @@ export function TextInput({ } } }, - [onChange, handleDebounce, delayMs, setValue, inputValidator], + [onChange, handleDebounce, delayMs, setValue, inputValidator] ); return ( Date: Wed, 20 Nov 2024 10:14:41 -0800 Subject: [PATCH 03/91] Initial utils --- src/fund/constants.ts | 2 ++ src/fund/utils/fetchOnRampConfig.ts | 27 +++++++++++++++ src/fund/utils/fetchOnRampOptions.ts | 52 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/fund/utils/fetchOnRampConfig.ts create mode 100644 src/fund/utils/fetchOnRampOptions.ts diff --git a/src/fund/constants.ts b/src/fund/constants.ts index 9e5e4648b5..7179c20cf6 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -5,3 +5,5 @@ 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/'; \ No newline at end of file diff --git a/src/fund/utils/fetchOnRampConfig.ts b/src/fund/utils/fetchOnRampConfig.ts new file mode 100644 index 0000000000..66f8eb9ba6 --- /dev/null +++ b/src/fund/utils/fetchOnRampConfig.ts @@ -0,0 +1,27 @@ +import { ONRAMP_API_BASE_URL } from '../constants'; + +type OnRampConfigResponseData = { + countries: OnRampConfigCountry[]; +}; + +type OnRampConfigCountry = { + id: string; + subdivisions: string[]; + payment_methods: OnRampConfigPaymentMethod[]; +}; + +type OnRampConfigPaymentMethod = { + id: string; +}; + +export async function fetchOnRampConfig(): Promise { + const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/config`, { + method: 'GET', + }); + + const responseJson = await response.json(); + + const data: OnRampConfigResponseData = responseJson.data; + + return data; +} diff --git a/src/fund/utils/fetchOnRampOptions.ts b/src/fund/utils/fetchOnRampOptions.ts new file mode 100644 index 0000000000..59de602b85 --- /dev/null +++ b/src/fund/utils/fetchOnRampOptions.ts @@ -0,0 +1,52 @@ +import { ONRAMP_API_BASE_URL } from '../constants'; + +type OnRampOptionsResponseData = { + payment_currencies: OnRampOptionsPaymentCurrency[]; + purchase_currencies: OnRampOptionsPurchaseCurrency[]; +}; + +type OnRampOptionsPaymentCurrency = { + id: string; + payment_method_limits: OnRampOptionsPaymentMethodLimit[]; +}; + +type OnRampOptionsPaymentMethodLimit = { + id: string; + min: string; + max: string; +}; + +type OnRampOptionsPurchaseCurrency = { + id: string; + name: string; + symbol: string; + networks: OnRampOptionsNetwork[]; +}; + +type OnRampOptionsNetwork = { + name: string; + display_name: string; + chain_id: string; + contract_address: string; +}; + +export async function fetchOnRampOptions({ + country, + subdivision, +}: { + country: string; + subdivision?: string; +}): Promise { + const response = await fetch( + `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, + { + method: 'GET', + } + ); + + const responseJson = await response.json(); + + const data: OnRampOptionsResponseData = responseJson.data; + + return data; +} From bffb90f8dd439cb17616d6edb012256b2d80d479 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Thu, 5 Dec 2024 14:22:32 -0800 Subject: [PATCH 04/91] Update --- playground/nextjs-app-router/components/demo/Fund.tsx | 5 ++++- playground/nextjs-app-router/onchainkit/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx index dfc49a6280..b6a314aec7 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/Fund.tsx @@ -1,9 +1,12 @@ -import { FundButton } from '@coinbase/onchainkit/fund'; +import { fetchOnrampConfig, FundButton } from '@coinbase/onchainkit/fund'; export default function FundDemo() { + return (
+ +
); } diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index be64781475..309448dd4a 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -12,7 +12,7 @@ "ci:format": "biome ci --linter-enabled=false --organize-imports-enabled=false", "ci:lint": "biome ci --formatter-enabled=false --organize-imports-enabled=false", "cp": "cp -R src site/docs/pages", - "dev:watch": "concurrently \"tailwind -i ./src/styles/index-with-tailwind.css -o ./src/styles.css --watch\" \"tsup --watch ./src/**/*.tsx\"", + "dev:watch": "concurrently \"tailwind -i ./src/styles/index-with-tailwind.css -o ./src/styles.css --watch\" \"tsup\"", "format": "biome format --write .", "lint": "biome lint --write .", "lint:unsafe": "biome lint --write --unsafe .", From a56f4881968eece0d916c20b47e4a8ea7259ffe4 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Thu, 5 Dec 2024 14:59:10 -0800 Subject: [PATCH 05/91] Fix lint issues --- .../components/demo/Fund.tsx | 14 ++- src/fund/constants.ts | 3 +- src/fund/index.ts | 4 + src/fund/types.ts | 66 ++++++++++++- src/fund/utils/fetchOnRampConfig.ts | 32 ++++--- src/fund/utils/fetchOnRampOptions.ts | 64 ++++++------- src/fund/utils/fetchOnrampConfig.test.ts | 55 +++++++++++ src/fund/utils/fetchOnrampOptions.test.ts | 52 ++++++++++ src/fund/utils/fetchOnrampQuote.test.ts | 94 +++++++++++++++++++ src/fund/utils/fetchOnrampQuote.ts | 90 ++++++++++++++++++ .../fetchOnrampTransactionStatus.test.ts | 64 +++++++++++++ .../utils/fetchOnrampTransactionStatus.ts | 46 +++++++++ src/fund/utils/setupOnrampEventListeners.ts | 4 +- .../utils/convertSnakeToCamelCase.test.ts | 46 +++++++++ src/internal/utils/convertSnakeToCamelCase.ts | 31 ++++++ 15 files changed, 609 insertions(+), 56 deletions(-) create mode 100644 src/fund/utils/fetchOnrampConfig.test.ts create mode 100644 src/fund/utils/fetchOnrampOptions.test.ts create mode 100644 src/fund/utils/fetchOnrampQuote.test.ts create mode 100644 src/fund/utils/fetchOnrampQuote.ts create mode 100644 src/fund/utils/fetchOnrampTransactionStatus.test.ts create mode 100644 src/fund/utils/fetchOnrampTransactionStatus.ts create mode 100644 src/internal/utils/convertSnakeToCamelCase.test.ts create mode 100644 src/internal/utils/convertSnakeToCamelCase.ts diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx index b6a314aec7..f92555903c 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/Fund.tsx @@ -1,12 +1,20 @@ -import { fetchOnrampConfig, FundButton } from '@coinbase/onchainkit/fund'; +import { FundButton, fetchOnrampConfig } from '@coinbase/onchainkit/fund'; export default function FundDemo() { - return (
- +
); } diff --git a/src/fund/constants.ts b/src/fund/constants.ts index 7179c20cf6..d3fef285d5 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -6,4 +6,5 @@ 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/'; \ No newline at end of file +export const ONRAMP_API_BASE_URL = + 'https://api.developer.coinbase.com/onramp/v1'; diff --git a/src/fund/index.ts b/src/fund/index.ts index a01e7248d8..10d4d41301 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -2,6 +2,10 @@ export { FundButton } from './components/FundButton'; 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 type { GetOnrampUrlWithProjectIdParams, diff --git a/src/fund/types.ts b/src/fund/types.ts index 84ab23b55d..7334e96e2b 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -157,12 +157,12 @@ export type TransitionViewEvent = { export type PublicErrorEvent = { eventName: 'error'; - error: OnRampError; + error: OnrampError; }; export type ExitEvent = { eventName: 'exit'; - error?: OnRampError; + error?: OnrampError; }; export type SuccessEvent = { @@ -182,8 +182,68 @@ export type EventMetadata = | SuccessEvent | RequestOpenUrlEvent; -export type OnRampError = { +export type OnrampError = { errorType: 'internal_error' | 'handled_error' | 'network_error'; code?: string; debugMessage?: string; }; + +export type OnrampTransactionStatusName = + | 'ONRAMP_TRANSACTION_STATUS_UNSPECIFIED' + | 'ONRAMP_TRANSACTION_STATUS_CREATED' + | 'ONRAMP_TRANSACTION_STATUS_IN_PROGRESS' + | 'ONRAMP_TRANSACTION_STATUS_SUCCESS' + | 'ONRAMP_TRANSACTION_STATUS_FAILED'; + +export type OnrampAmout = { + amount: string; + currency: string; +}; + +export type OnrampTransaction = { + status: OnrampTransactionStatusName; + purchaseCurrency: string; + purchaseNetwork: string; + purchaseAmount: OnrampAmout; + paymentTotal: OnrampAmout; + paymentSubtotal: OnrampAmout; + coinbaseFee: OnrampAmout; + networkFee: OnrampAmout; + exchangeRate: OnrampAmout; + txHash: string; + createdAt: string; + country: string; + userId: string; + paymentMethod: string; + transactionId: string; +}; + +export type OnrampPaymentMethod = { + id: string; +}; + +export type OnrampPaymentMethodLimit = { + id: string; + min: string; + max: string; +}; + +type OnrampNetwork = { + name: string; + displayName: string; + chainId: string; + contractAddress: string; +}; + +export type OnrampPurchaseCurrency = { + id: string; + name: string; + symbol: string; + iconUrl: string; // <---- TODO By API. + networks: OnrampNetwork[]; +}; + +export type OnrampPaymentCurrency = { + id: string; + paymentMethodLimits: OnrampPaymentMethodLimit[]; +}; diff --git a/src/fund/utils/fetchOnRampConfig.ts b/src/fund/utils/fetchOnRampConfig.ts index 66f8eb9ba6..3790c594f4 100644 --- a/src/fund/utils/fetchOnRampConfig.ts +++ b/src/fund/utils/fetchOnRampConfig.ts @@ -1,27 +1,35 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; import { ONRAMP_API_BASE_URL } from '../constants'; +import type { OnrampPaymentMethod } from '../types'; -type OnRampConfigResponseData = { - countries: OnRampConfigCountry[]; +type OnrampConfigResponseData = { + countries: OnrampConfigCountry[]; }; -type OnRampConfigCountry = { +type OnrampConfigCountry = { id: string; subdivisions: string[]; - payment_methods: OnRampConfigPaymentMethod[]; + paymentMethods: OnrampPaymentMethod[]; }; -type OnRampConfigPaymentMethod = { - id: string; -}; - -export async function fetchOnRampConfig(): Promise { +/** + * Returns list of countries supported by Coinbase Onramp, and the payment methods available in each country. + * + * @param apiKey API key for the partner. `required` + */ +export async function fetchOnrampConfig({ + apiKey, +}: { + apiKey: string; +}): Promise { const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/config`, { method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, }); const responseJson = await response.json(); - const data: OnRampConfigResponseData = responseJson.data; - - return data; + return convertSnakeToCamelCase(responseJson.data); } diff --git a/src/fund/utils/fetchOnRampOptions.ts b/src/fund/utils/fetchOnRampOptions.ts index 59de602b85..55db8e6130 100644 --- a/src/fund/utils/fetchOnRampOptions.ts +++ b/src/fund/utils/fetchOnRampOptions.ts @@ -1,52 +1,46 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; import { ONRAMP_API_BASE_URL } from '../constants'; - -type OnRampOptionsResponseData = { - payment_currencies: OnRampOptionsPaymentCurrency[]; - purchase_currencies: OnRampOptionsPurchaseCurrency[]; -}; - -type OnRampOptionsPaymentCurrency = { - id: string; - payment_method_limits: OnRampOptionsPaymentMethodLimit[]; -}; - -type OnRampOptionsPaymentMethodLimit = { - id: string; - min: string; - max: string; +import type { OnrampPaymentCurrency, OnrampPurchaseCurrency } from '../types'; + +type OnrampOptionsResponseData = { + /** + * List of supported fiat currencies that can be exchanged for crypto on Onramp in the given location. + * Each currency contains a list of available payment methods, with min and max transaction limits for that currency. + */ + paymentCurrencies: OnrampPaymentCurrency[]; + /** + * List of available crypto assets that can be bought on Onramp in the given location. + */ + purchaseCurrencies: OnrampPurchaseCurrency[]; }; -type OnRampOptionsPurchaseCurrency = { - id: string; - name: string; - symbol: string; - networks: OnRampOptionsNetwork[]; -}; - -type OnRampOptionsNetwork = { - name: string; - display_name: string; - chain_id: string; - contract_address: string; -}; - -export async function fetchOnRampOptions({ +/** + * Returns supported fiat currencies and available crypto assets that can be passed into the Buy Quote API. + * + * @param apiKey API key for the partner. `required` + * @param country ISO 3166-1 two-digit country code string representing the purchasing user’s country of residence, e.g., US. `required` + * @param subdivision ISO 3166-2 two-digit country subdivision code representing the purchasing user’s subdivision of residence within their country, e.g. `NY`. + */ +export async function fetchOnrampOptions({ + apiKey, country, subdivision, }: { + apiKey: string; country: string; subdivision?: string; -}): Promise { +}): Promise { const response = await fetch( `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, { method: 'GET', - } + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, ); const responseJson = await response.json(); - const data: OnRampOptionsResponseData = responseJson.data; - - return data; + return convertSnakeToCamelCase(responseJson.data); } diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts new file mode 100644 index 0000000000..a3bcab5f23 --- /dev/null +++ b/src/fund/utils/fetchOnrampConfig.test.ts @@ -0,0 +1,55 @@ +import { type Mock, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampConfig } from './fetchOnrampConfig'; + +const mockApiKey = 'test-api-key'; +const mockResponseData = { + data: { + countries: [ + { + id: 'US', + subdivisions: ['CA', 'NY'], + payment_methods: ['credit_card', 'bank_transfer'], + }, + ], + }, +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('fetchOnrampConfig', () => { + it('should fetch onramp config and return data', async () => { + const data = await fetchOnrampConfig({ apiKey: mockApiKey }); + + expect(fetch).toHaveBeenCalledWith(`${ONRAMP_API_BASE_URL}/buy/config`, { + method: 'GET', + headers: { + Authorization: `Bearer ${mockApiKey}`, + }, + }); + + expect(data).toEqual({ + countries: [ + { + id: 'US', + subdivisions: ['CA', 'NY'], + paymentMethods: ['credit_card', 'bank_transfer'], + }, + ], + }); + }); + + it('should throw an error if the fetch fails', async () => { + (fetch as Mock).mockImplementationOnce(() => + Promise.reject(new Error('Fetch failed')), + ); + + await expect(fetchOnrampConfig({ apiKey: mockApiKey })).rejects.toThrow( + 'Fetch failed', + ); + }); +}); diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts new file mode 100644 index 0000000000..131cb3b224 --- /dev/null +++ b/src/fund/utils/fetchOnrampOptions.test.ts @@ -0,0 +1,52 @@ +import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampOptions } from './fetchOnrampOptions'; + +const apiKey = 'test-api-key'; +const country = 'US'; +const subdivision = 'NY'; +const mockResponseData = { + data: { + payment_currencies: [], + purchase_currencies: [], + }, +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('fetchOnrampOptions', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch onramp options successfully', async () => { + const result = await fetchOnrampOptions({ apiKey, country, subdivision }); + + expect(global.fetch).toHaveBeenCalledWith( + `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + expect(result).toEqual({ + paymentCurrencies: [], + purchaseCurrencies: [], + }); + }); + + it('should handle fetch errors', async () => { + (global.fetch as Mock).mockRejectedValue(new Error('Fetch error')); + + await expect( + fetchOnrampOptions({ apiKey, country, subdivision }), + ).rejects.toThrow('Fetch error'); + }); +}); diff --git a/src/fund/utils/fetchOnrampQuote.test.ts b/src/fund/utils/fetchOnrampQuote.test.ts new file mode 100644 index 0000000000..e68bc12fa3 --- /dev/null +++ b/src/fund/utils/fetchOnrampQuote.test.ts @@ -0,0 +1,94 @@ +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampQuote } from './fetchOnrampQuote'; + +const mockApiKey = 'test-api-key'; +const mockPurchaseCurrency = 'BTC'; +const mockPurchaseNetwork = 'bitcoin'; +const mockPaymentCurrency = 'USD'; +const mockPaymentMethod = 'credit_card'; +const mockPaymentAmount = '100.00'; +const mockCountry = 'US'; +const mockSubdivision = 'NY'; + +const mockResponseData = { + data: { + paymen_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(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('fetchOnrampQuote', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch onramp quote successfully', async () => { + const result = await fetchOnrampQuote({ + apiKey: mockApiKey, + purchaseCurrency: mockPurchaseCurrency, + purchaseNetwork: mockPurchaseNetwork, + paymentCurrency: mockPaymentCurrency, + paymentMethod: mockPaymentMethod, + paymentAmount: mockPaymentAmount, + country: mockCountry, + subdivision: mockSubdivision, + }); + + expect(global.fetch).toHaveBeenCalledWith( + `${ONRAMP_API_BASE_URL}/buy/quote`, + { + method: 'POST', + body: JSON.stringify({ + purchase_currency: mockPurchaseCurrency, + purchase_network: mockPurchaseNetwork, + payment_currency: mockPaymentCurrency, + payment_method: mockPaymentMethod, + payment_amount: mockPaymentAmount, + country: mockCountry, + subdivision: mockSubdivision, + }), + headers: { + Authorization: `Bearer ${mockApiKey}`, + }, + }, + ); + expect(result).toEqual({ + paymenTotal: { amount: '105.00', currency: 'USD' }, + paymentSubtotal: { amount: '100.00', currency: 'USD' }, + purchaseAmount: { amount: '0.0025', currency: 'BTC' }, + coinbaseFee: { amount: '3.00', currency: 'USD' }, + networkFee: { amount: '2.00', currency: 'USD' }, + quoteId: 'quote-id-123', + }); + }); + + it('should throw an error if fetch fails', async () => { + global.fetch = vi.fn(() => + Promise.reject(new Error('Fetch failed')), + ) as Mock; + + await expect( + fetchOnrampQuote({ + apiKey: mockApiKey, + purchaseCurrency: mockPurchaseCurrency, + purchaseNetwork: mockPurchaseNetwork, + paymentCurrency: mockPaymentCurrency, + paymentMethod: mockPaymentMethod, + paymentAmount: mockPaymentAmount, + country: mockCountry, + subdivision: mockSubdivision, + }), + ).rejects.toThrow('Fetch failed'); + }); +}); diff --git a/src/fund/utils/fetchOnrampQuote.ts b/src/fund/utils/fetchOnrampQuote.ts new file mode 100644 index 0000000000..ac0610414e --- /dev/null +++ b/src/fund/utils/fetchOnrampQuote.ts @@ -0,0 +1,90 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import type { OnrampAmout } from '../types'; + +type OnrampQuoteResponseData = { + /** + * Object with amount and currency of the total fiat payment required to complete the purchase, inclusive of any fees. + * The currency will match the `paymentCurrency` in the request if it is supported, otherwise it falls back to USD. + */ + paymenTotal: OnrampAmout; + /** + * Object with amount and currency of the fiat cost of the crypto asset to be purchased, exclusive of any fees. + * The currency will match the `paymentCurrency`. + */ + paymentSubtotal: OnrampAmout; + /** + * Object with amount and currency of the crypto that to be purchased. + * The currency will match the `purchaseCurrency` in the request. + * The number of decimals will be based on the crypto asset. + */ + purchaseAmount: OnrampAmout; + /** + * Object with amount and currency of the fee changed by the Coinbase exchange to complete the transaction. + * The currency will match the `paymentCurrency`. + */ + coinbaseFee: OnrampAmout; + /** + * Object with amount and currency of the network fee required to send the purchased crypto to the user’s wallet. + * The currency will match the `paymentCurrency`. + */ + networkFee: OnrampAmout; + /** + * Reference to the quote that should be passed into the initialization parameters when launching the Coinbase Onramp widget via the SDK or URL generator. + */ + quoteId: string; +}; + +/** + * Provides a quote based on the asset the user would like to purchase, plus the network, the fiat payment, the payment currency, payment method, and country. + * + * @param apiKey API key for the partner. `required` + * @param purchaseCurrency ID of the crypto asset the user wants to purchase. Retrieved from the options API. `required` + * @param purchaseNetwork Name of the network that the purchase currency should be purchased on. + * Retrieved from the options API. If omitted, the default network for the crypto currency is used. + * @param paymentCurrency Fiat currency of the payment amount, e.g., `USD`. `required` + * @param paymentMethod ID of payment method used to complete the purchase. Retrieved from the options API. `required` + * @param paymentAmount Fiat amount the user wants to spend to purchase the crypto currency, inclusive of fees with two decimals of precision, e.g., `100.00`. `required` + * @param country ISO 3166-1 two-digit country code string representing the purchasing user’s country of residence, e.g., US. `required` + * @param subdivision ISO 3166-2 two-digit country subdivision code representing the purchasing user’s subdivision of residence within their country, e.g. `NY`. + * Required if the `country=“US”` because certain states (e.g., `NY`) have state specific asset restrictions. + */ +export async function fetchOnrampQuote({ + apiKey, + purchaseCurrency, + purchaseNetwork, + paymentCurrency, + paymentMethod, + paymentAmount, + country, + subdivision, +}: { + apiKey: string; + purchaseCurrency: string; + purchaseNetwork?: string; + paymentCurrency: string; + paymentMethod: string; + paymentAmount: string; + country: string; + subdivision?: string; +}): Promise { + const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/quote`, { + method: 'POST', + body: JSON.stringify({ + purchase_currency: purchaseCurrency, + purchase_network: purchaseNetwork, + payment_currency: paymentCurrency, + payment_method: paymentMethod, + payment_amount: paymentAmount, + country, + subdivision, + }), + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + const responseJson = await response.json(); + + return convertSnakeToCamelCase(responseJson.data); +} diff --git a/src/fund/utils/fetchOnrampTransactionStatus.test.ts b/src/fund/utils/fetchOnrampTransactionStatus.test.ts new file mode 100644 index 0000000000..d7265396b9 --- /dev/null +++ b/src/fund/utils/fetchOnrampTransactionStatus.test.ts @@ -0,0 +1,64 @@ +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampTransactionStatus } from './fetchOnrampTransactionStatus'; + +const apiKey = 'test-api-key'; +const partnerUserId = 'test-user-id'; +const nextPageKey = 'test-next-page-key'; +const pageSize = '10'; + +const mockResponseData = { + data: { + transactions: [], + next_page_key: 'next-page-key', + total_count: '100', + }, +}; + +describe('fetchOnrampTransactionStatus', () => { + beforeEach(() => { + global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), + ) as Mock; + }); + + it('should fetch transaction status and convert response to camel case', async () => { + const result = await fetchOnrampTransactionStatus({ + apiKey, + partnerUserId, + nextPageKey, + pageSize, + }); + + expect(global.fetch).toHaveBeenCalledWith( + `${ONRAMP_API_BASE_URL}/buy/user/${partnerUserId}/transactions?page_key=${nextPageKey}&page_size=${pageSize}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + expect(result).toEqual({ + transactions: [], + nextPageKey: 'next-page-key', + totalCount: '100', + }); + }); + + it('should throw an error if fetch fails', async () => { + global.fetch = vi.fn(() => Promise.reject(new Error('Fetch failed'))); + + await expect( + fetchOnrampTransactionStatus({ + apiKey, + partnerUserId, + nextPageKey, + pageSize, + }), + ).rejects.toThrow('Fetch failed'); + }); +}); diff --git a/src/fund/utils/fetchOnrampTransactionStatus.ts b/src/fund/utils/fetchOnrampTransactionStatus.ts new file mode 100644 index 0000000000..63396c90ad --- /dev/null +++ b/src/fund/utils/fetchOnrampTransactionStatus.ts @@ -0,0 +1,46 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import type { OnrampTransaction } from '../types'; + +type OnrampTransactionStatusResponseData = { + /** + * List of `OnrampTransactions` in reverse chronological order. + */ + transactions: OnrampTransaction[]; + /** + * A reference to the next page of transactions. + */ + nextPageKey: string; + /** + * The total number of transactions made by the user. + */ + totalCount: string; +}; + +export async function fetchOnrampTransactionStatus({ + apiKey, + partnerUserId, + nextPageKey, + pageSize, +}: { + apiKey: string; + partnerUserId: string; + nextPageKey: string; + pageSize: string; +}): Promise { + const response = await fetch( + `${ONRAMP_API_BASE_URL}/buy/user/${partnerUserId}/transactions?page_key=${nextPageKey}&page_size=${pageSize}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + const responseJson = await response.json(); + + return convertSnakeToCamelCase( + responseJson.data, + ); +} diff --git a/src/fund/utils/setupOnrampEventListeners.ts b/src/fund/utils/setupOnrampEventListeners.ts index 98a9a093fe..215752230b 100644 --- a/src/fund/utils/setupOnrampEventListeners.ts +++ b/src/fund/utils/setupOnrampEventListeners.ts @@ -1,11 +1,11 @@ import { DEFAULT_ONRAMP_URL } from '../constants'; -import type { EventMetadata, OnRampError } from '../types'; +import type { EventMetadata, OnrampError } from '../types'; import { subscribeToWindowMessage } from './subscribeToWindowMessage'; type SetupOnrampEventListenersParams = { host?: string; onSuccess?: () => void; - onExit?: (error?: OnRampError) => void; + onExit?: (error?: OnrampError) => void; onEvent?: (event: EventMetadata) => void; }; diff --git a/src/internal/utils/convertSnakeToCamelCase.test.ts b/src/internal/utils/convertSnakeToCamelCase.test.ts new file mode 100644 index 0000000000..9623c31de3 --- /dev/null +++ b/src/internal/utils/convertSnakeToCamelCase.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { convertSnakeToCamelCase } from './convertSnakeToCamelCase'; + +describe('convertSnakeToCamelCase', () => { + it('should convert snake_case to camelCase', () => { + expect(convertSnakeToCamelCase('hello_world')).toBe('helloWorld'); + }); + + it('should handle strings with multiple underscores', () => { + expect(convertSnakeToCamelCase('this_is_a_test')).toBe('thisIsATest'); + }); + + it('should return an empty string if input is empty', () => { + expect(convertSnakeToCamelCase('')).toBe(''); + }); + + it('should convert keys of an object from snake_case to camelCase', () => { + const input = { + hello_world: 'value', + nested_object: { inner_key: 'innerValue' }, + }; + const expected = { + helloWorld: 'value', + nestedObject: { innerKey: 'innerValue' }, + }; + expect(convertSnakeToCamelCase(input)).toEqual(expected); + }); + + it('should convert elements of an array from snake_case to camelCase', () => { + const input = ['hello_world', 'this_is_a_test']; + const expected = ['helloWorld', 'thisIsATest']; + expect(convertSnakeToCamelCase(input)).toEqual(expected); + }); + + it('should convert keys of objects within an array from snake_case to camelCase', () => { + const input = [ + { hello_world: 'value' }, + { nested_object: { inner_key: 'innerValue' } }, + ]; + const expected = [ + { helloWorld: 'value' }, + { nestedObject: { innerKey: 'innerValue' } }, + ]; + expect(convertSnakeToCamelCase(input)).toEqual(expected); + }); +}); diff --git a/src/internal/utils/convertSnakeToCamelCase.ts b/src/internal/utils/convertSnakeToCamelCase.ts new file mode 100644 index 0000000000..d5dc0b8f94 --- /dev/null +++ b/src/internal/utils/convertSnakeToCamelCase.ts @@ -0,0 +1,31 @@ +/** + * Converts snake_case keys to camelCase in an object or array of objects or strings. + * @param {T} obj - The object, array, or string to convert. (required) + * @returns {T} The converted object, array, or string. + */ +export function convertSnakeToCamelCase(obj: T): T { + if (typeof obj === 'string') { + return toCamelCase(obj) as T; + } + + if (Array.isArray(obj)) { + return obj.map((item) => convertSnakeToCamelCase(item)) as T; + } + + if (obj && obj.constructor === Object) { + return Object.keys(obj).reduce((acc, key) => { + const camelCaseKey = toCamelCase(key); + // biome-ignore lint/suspicious/noExplicitAny: + (acc as any)[camelCaseKey] = convertSnakeToCamelCase( + // biome-ignore lint/suspicious/noExplicitAny: + (obj as any)[key], + ); + return acc; + }, {} as T); + } + return obj; +} + +function toCamelCase(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} From 34916c735b7b8d53e08b04ff8c3a008cb9fdff5f Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Thu, 5 Dec 2024 15:11:15 -0800 Subject: [PATCH 06/91] Update --- src/fund/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/types.ts b/src/fund/types.ts index 7334e96e2b..54ed767a47 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -239,7 +239,7 @@ export type OnrampPurchaseCurrency = { id: string; name: string; symbol: string; - iconUrl: string; // <---- TODO By API. + iconUrl: string; networks: OnrampNetwork[]; }; From fc9db1f6ea5382c48a8c8db7533b61b8628d71d9 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 10:17:39 -0800 Subject: [PATCH 07/91] Update --- .../components/demo/Fund.tsx | 6 +- src/fund/components/FundCard.tsx | 42 +++++++++ src/fund/components/FundCardHeader.tsx | 24 +++++ src/fund/components/FundForm.tsx | 88 +++++++------------ src/fund/index.ts | 1 + src/internal/components/TextInput.tsx | 1 - 6 files changed, 100 insertions(+), 62 deletions(-) create mode 100644 src/fund/components/FundCard.tsx create mode 100644 src/fund/components/FundCardHeader.tsx diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx index e2cf390851..71dc71fff4 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/Fund.tsx @@ -1,13 +1,11 @@ -import { FundButton, FundProvider, FundForm } from '@coinbase/onchainkit/fund'; +import { FundButton, FundProvider, FundForm, FundCard } from '@coinbase/onchainkit/fund'; export default function FundDemo() { return (
- - - +
); } diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx new file mode 100644 index 0000000000..25b9d789d7 --- /dev/null +++ b/src/fund/components/FundCard.tsx @@ -0,0 +1,42 @@ +import { border, cn, text } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; +import { FundCardHeader } from './FundCardHeader'; +import { FundForm } from './FundForm'; +import { FundProvider } from './FundProvider'; + +type Props = { + assetSymbol: string; + placeholder?: string | React.ReactNode; + headerText?: string; + buttonText?: string; +}; + +export function FundCard({ + assetSymbol, + buttonText = 'Buy', + headerText, +}: Props) { + const componentTheme = useTheme(); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx new file mode 100644 index 0000000000..7f2ce178d0 --- /dev/null +++ b/src/fund/components/FundCardHeader.tsx @@ -0,0 +1,24 @@ +import { cn } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; + +type Props = { + headerText?: string; + assetSymbol: string; +}; + +export function FundCardHeader({ headerText, assetSymbol }: Props) { + const componentTheme = useTheme(); + const defaultHeaderText = `Buy ${assetSymbol.toUpperCase()}`; + + return ( +
+ {headerText || defaultHeaderText} +
+ ); +} diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx index 78205eafc1..b6309ec774 100644 --- a/src/fund/components/FundForm.tsx +++ b/src/fund/components/FundForm.tsx @@ -1,17 +1,14 @@ import { - ChangeEvent, - useCallback, + type ChangeEvent, useEffect, useMemo, useRef, - useState, } from 'react'; -import { border, cn, color, line, pressable, text } from '../../styles/theme'; +import { border, cn, text } from '../../styles/theme'; import { useTheme } from '../../useTheme'; import { useFundContext } from './FundProvider'; import { FundButton } from './FundButton'; -import { TextInput } from '../../internal/components/TextInput'; -import { formatAmount } from '../../token/utils/formatAmount'; +import { FundCardHeader } from './FundCardHeader'; type Props = { assetSymbol: string; @@ -19,44 +16,24 @@ type Props = { headerText?: string; buttonText?: string; }; + export function FundForm({ assetSymbol, buttonText = 'Buy', headerText, }: Props) { - const componentTheme = useTheme(); - const { setSelectedAsset, setFundAmount, fundAmount } = useFundContext(); - const defaultHeaderText = `Buy ${assetSymbol.toUpperCase()}`; return ( -
-
- {headerText || defaultHeaderText} -
- -
+ +
- + ); } @@ -113,35 +90,32 @@ const ResizableInput = ({ const fontSize = useMemo(() => { if (value.length < 2) { - return 80; + return 60; } - return 80 - Math.min(value.length * 2.5, 60); + return 60 - Math.min(value.length * 2.5, 40); }, [value]); - useEffect(() => { - // Update the input width based on the hidden span's width - if (spanRef.current && inputRef.current) { - if (inputRef.current?.style?.width) { - inputRef.current.style.width = - value.length === 1 - ? `${spanRef.current?.offsetWidth}px` - : `${spanRef.current?.offsetWidth + 10}px`; - } - } - }, [value]); + // useEffect(() => { + // // Update the input width based on the hidden span's width + // if (spanRef.current && inputRef.current) { + // if (inputRef.current?.style?.width) { + // inputRef.current.style.width = + // value.length === 1 + // ? `${spanRef.current?.offsetWidth}px` + // : `${spanRef.current?.offsetWidth + 10}px`; + // } + // } + // }, [value]); return ( -
+
{currencySign && ( {currencySign} @@ -149,7 +123,7 @@ const ResizableInput = ({ {/* Hidden span to measure content width */} - {value || '0'} - + */}
); }; diff --git a/src/fund/index.ts b/src/fund/index.ts index b414257cbe..409cc4172b 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,6 +1,7 @@ export { FundButton } from './components/FundButton'; export { FundProvider } from './components/FundProvider'; export { FundForm } from './components/FundForm'; +export { FundCard } from './components/FundCard'; export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index 1940c76d42..7594d65794 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -14,7 +14,6 @@ type TextInputReact = { value: string; inputValidator?: (s: string) => boolean; style?: React.CSSProperties; - ref: React.RefObject; }; export function TextInput({ From 595f54e4a7aa5001f7852f527e6ac1ff617caf0e Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 11:23:29 -0800 Subject: [PATCH 08/91] Update --- .../nextjs-app-router/components/demo/Fund.tsx | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/Fund.tsx b/playground/nextjs-app-router/components/demo/Fund.tsx index f92555903c..dfc49a6280 100644 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ b/playground/nextjs-app-router/components/demo/Fund.tsx @@ -1,20 +1,9 @@ -import { FundButton, fetchOnrampConfig } from '@coinbase/onchainkit/fund'; +import { FundButton } from '@coinbase/onchainkit/fund'; export default function FundDemo() { return (
- -
); } From 486d61835aee7d82d4f1d003d5c2c3287e9db536 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 11:28:09 -0800 Subject: [PATCH 09/91] Update --- src/fund/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fund/index.ts b/src/fund/index.ts index 10d4d41301..237b756fcd 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -3,8 +3,8 @@ export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFun 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 { fetchOnrampConfig } from './utils/fetchOnRampConfig'; +export { fetchOnrampOptions } from './utils/fetchOnRampOptions'; export { fetchOnrampQuote } from './utils/fetchOnrampQuote'; export type { From 6ed6c16e0f36f885a570ca9cb9c548b676012461 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 12:01:32 -0800 Subject: [PATCH 10/91] Update --- src/fund/utils/fetchOnrampConfig.test.ts | 2 +- src/fund/utils/fetchOnrampOptions.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts index a3bcab5f23..98ca0e0e2d 100644 --- a/src/fund/utils/fetchOnrampConfig.test.ts +++ b/src/fund/utils/fetchOnrampConfig.test.ts @@ -1,6 +1,6 @@ import { type Mock, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampConfig } from './fetchOnrampConfig'; +import { fetchOnrampConfig } from './fetchOnRampConfig'; const mockApiKey = 'test-api-key'; const mockResponseData = { diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts index 131cb3b224..30a7dc8953 100644 --- a/src/fund/utils/fetchOnrampOptions.test.ts +++ b/src/fund/utils/fetchOnrampOptions.test.ts @@ -1,6 +1,6 @@ import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampOptions } from './fetchOnrampOptions'; +import { fetchOnrampOptions } from './fetchOnRampOptions'; const apiKey = 'test-api-key'; const country = 'US'; From b16da4a09f05ecc213d4f07134d32f22a6117649 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 12:33:59 -0800 Subject: [PATCH 11/91] Update --- src/internal/utils/convertSnakeToCamelCase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/utils/convertSnakeToCamelCase.ts b/src/internal/utils/convertSnakeToCamelCase.ts index d5dc0b8f94..ec14cb4205 100644 --- a/src/internal/utils/convertSnakeToCamelCase.ts +++ b/src/internal/utils/convertSnakeToCamelCase.ts @@ -5,7 +5,7 @@ */ export function convertSnakeToCamelCase(obj: T): T { if (typeof obj === 'string') { - return toCamelCase(obj) as T; + return obj as T; } if (Array.isArray(obj)) { From b7b2cd244e7597067fe403da18a4edee13babf46 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 12:55:20 -0800 Subject: [PATCH 12/91] Update --- .../utils/convertSnakeToCamelCase.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/internal/utils/convertSnakeToCamelCase.test.ts b/src/internal/utils/convertSnakeToCamelCase.test.ts index 9623c31de3..feb8d9ff47 100644 --- a/src/internal/utils/convertSnakeToCamelCase.test.ts +++ b/src/internal/utils/convertSnakeToCamelCase.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { convertSnakeToCamelCase } from './convertSnakeToCamelCase'; describe('convertSnakeToCamelCase', () => { - it('should convert snake_case to camelCase', () => { - expect(convertSnakeToCamelCase('hello_world')).toBe('helloWorld'); + it('should convert snake_case keys to camelCase', () => { + expect(convertSnakeToCamelCase({hello_world: 'hello_world'})).toStrictEqual({helloWorld: 'hello_world'}); }); - it('should handle strings with multiple underscores', () => { - expect(convertSnakeToCamelCase('this_is_a_test')).toBe('thisIsATest'); + it('should handle keys with multiple underscores', () => { + expect(convertSnakeToCamelCase({this_is_a_test: 'this_is_a_test'})).toStrictEqual({thisIsATest: 'this_is_a_test'}); }); it('should return an empty string if input is empty', () => { @@ -23,24 +23,24 @@ describe('convertSnakeToCamelCase', () => { helloWorld: 'value', nestedObject: { innerKey: 'innerValue' }, }; - expect(convertSnakeToCamelCase(input)).toEqual(expected); + expect(convertSnakeToCamelCase(input)).toStrictEqual(expected); }); - it('should convert elements of an array from snake_case to camelCase', () => { + it('should not convert elements of an array from snake_case to camelCase', () => { const input = ['hello_world', 'this_is_a_test']; - const expected = ['helloWorld', 'thisIsATest']; - expect(convertSnakeToCamelCase(input)).toEqual(expected); + const expected = ['hello_world', 'this_is_a_test']; + expect(convertSnakeToCamelCase(input)).toStrictEqual(expected); }); it('should convert keys of objects within an array from snake_case to camelCase', () => { const input = [ { hello_world: 'value' }, - { nested_object: { inner_key: 'innerValue' } }, + { nested_object: { inner_key: 'inner_value' } }, ]; const expected = [ { helloWorld: 'value' }, - { nestedObject: { innerKey: 'innerValue' } }, + { nestedObject: { innerKey: 'inner_value' } }, ]; - expect(convertSnakeToCamelCase(input)).toEqual(expected); + expect(convertSnakeToCamelCase(input)).toStrictEqual(expected); }); }); From bb348dfd12ba05129e3385bf548efe5dd8f74c3c Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 13:27:42 -0800 Subject: [PATCH 13/91] Update --- src/fund/utils/fetchOnRampConfig.ts | 35 ------------ src/fund/utils/fetchOnRampOptions.ts | 46 ---------------- src/fund/utils/fetchOnrampConfig.test.ts | 55 ------------------- src/fund/utils/fetchOnrampOptions.test.ts | 52 ------------------ .../utils/convertSnakeToCamelCase.test.ts | 28 ++++++++++ src/internal/utils/convertSnakeToCamelCase.ts | 2 +- 6 files changed, 29 insertions(+), 189 deletions(-) delete mode 100644 src/fund/utils/fetchOnRampConfig.ts delete mode 100644 src/fund/utils/fetchOnRampOptions.ts delete mode 100644 src/fund/utils/fetchOnrampConfig.test.ts delete mode 100644 src/fund/utils/fetchOnrampOptions.test.ts diff --git a/src/fund/utils/fetchOnRampConfig.ts b/src/fund/utils/fetchOnRampConfig.ts deleted file mode 100644 index 3790c594f4..0000000000 --- a/src/fund/utils/fetchOnRampConfig.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; -import { ONRAMP_API_BASE_URL } from '../constants'; -import type { OnrampPaymentMethod } from '../types'; - -type OnrampConfigResponseData = { - countries: OnrampConfigCountry[]; -}; - -type OnrampConfigCountry = { - id: string; - subdivisions: string[]; - paymentMethods: OnrampPaymentMethod[]; -}; - -/** - * Returns list of countries supported by Coinbase Onramp, and the payment methods available in each country. - * - * @param apiKey API key for the partner. `required` - */ -export async function fetchOnrampConfig({ - apiKey, -}: { - apiKey: string; -}): Promise { - const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/config`, { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }); - - const responseJson = await response.json(); - - return convertSnakeToCamelCase(responseJson.data); -} diff --git a/src/fund/utils/fetchOnRampOptions.ts b/src/fund/utils/fetchOnRampOptions.ts deleted file mode 100644 index 55db8e6130..0000000000 --- a/src/fund/utils/fetchOnRampOptions.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; -import { ONRAMP_API_BASE_URL } from '../constants'; -import type { OnrampPaymentCurrency, OnrampPurchaseCurrency } from '../types'; - -type OnrampOptionsResponseData = { - /** - * List of supported fiat currencies that can be exchanged for crypto on Onramp in the given location. - * Each currency contains a list of available payment methods, with min and max transaction limits for that currency. - */ - paymentCurrencies: OnrampPaymentCurrency[]; - /** - * List of available crypto assets that can be bought on Onramp in the given location. - */ - purchaseCurrencies: OnrampPurchaseCurrency[]; -}; - -/** - * Returns supported fiat currencies and available crypto assets that can be passed into the Buy Quote API. - * - * @param apiKey API key for the partner. `required` - * @param country ISO 3166-1 two-digit country code string representing the purchasing user’s country of residence, e.g., US. `required` - * @param subdivision ISO 3166-2 two-digit country subdivision code representing the purchasing user’s subdivision of residence within their country, e.g. `NY`. - */ -export async function fetchOnrampOptions({ - apiKey, - country, - subdivision, -}: { - apiKey: string; - country: string; - subdivision?: string; -}): Promise { - const response = await fetch( - `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - const responseJson = await response.json(); - - return convertSnakeToCamelCase(responseJson.data); -} diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts deleted file mode 100644 index 98ca0e0e2d..0000000000 --- a/src/fund/utils/fetchOnrampConfig.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { type Mock, describe, expect, it, vi } from 'vitest'; -import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampConfig } from './fetchOnRampConfig'; - -const mockApiKey = 'test-api-key'; -const mockResponseData = { - data: { - countries: [ - { - id: 'US', - subdivisions: ['CA', 'NY'], - payment_methods: ['credit_card', 'bank_transfer'], - }, - ], - }, -}; - -global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve(mockResponseData), - }), -) as Mock; - -describe('fetchOnrampConfig', () => { - it('should fetch onramp config and return data', async () => { - const data = await fetchOnrampConfig({ apiKey: mockApiKey }); - - expect(fetch).toHaveBeenCalledWith(`${ONRAMP_API_BASE_URL}/buy/config`, { - method: 'GET', - headers: { - Authorization: `Bearer ${mockApiKey}`, - }, - }); - - expect(data).toEqual({ - countries: [ - { - id: 'US', - subdivisions: ['CA', 'NY'], - paymentMethods: ['credit_card', 'bank_transfer'], - }, - ], - }); - }); - - it('should throw an error if the fetch fails', async () => { - (fetch as Mock).mockImplementationOnce(() => - Promise.reject(new Error('Fetch failed')), - ); - - await expect(fetchOnrampConfig({ apiKey: mockApiKey })).rejects.toThrow( - 'Fetch failed', - ); - }); -}); diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts deleted file mode 100644 index 30a7dc8953..0000000000 --- a/src/fund/utils/fetchOnrampOptions.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; -import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampOptions } from './fetchOnRampOptions'; - -const apiKey = 'test-api-key'; -const country = 'US'; -const subdivision = 'NY'; -const mockResponseData = { - data: { - payment_currencies: [], - purchase_currencies: [], - }, -}; - -global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve(mockResponseData), - }), -) as Mock; - -describe('fetchOnrampOptions', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should fetch onramp options successfully', async () => { - const result = await fetchOnrampOptions({ apiKey, country, subdivision }); - - expect(global.fetch).toHaveBeenCalledWith( - `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }, - ); - - expect(result).toEqual({ - paymentCurrencies: [], - purchaseCurrencies: [], - }); - }); - - it('should handle fetch errors', async () => { - (global.fetch as Mock).mockRejectedValue(new Error('Fetch error')); - - await expect( - fetchOnrampOptions({ apiKey, country, subdivision }), - ).rejects.toThrow('Fetch error'); - }); -}); diff --git a/src/internal/utils/convertSnakeToCamelCase.test.ts b/src/internal/utils/convertSnakeToCamelCase.test.ts index feb8d9ff47..06c0a9e299 100644 --- a/src/internal/utils/convertSnakeToCamelCase.test.ts +++ b/src/internal/utils/convertSnakeToCamelCase.test.ts @@ -43,4 +43,32 @@ describe('convertSnakeToCamelCase', () => { ]; expect(convertSnakeToCamelCase(input)).toStrictEqual(expected); }); + + it('should handle null values', () => { + expect(convertSnakeToCamelCase(null)).toBeNull(); + }); + + it('should handle undefined values', () => { + expect(convertSnakeToCamelCase(undefined)).toBeUndefined(); + }); + + it('should handle nested arrays within objects', () => { + const input = { array_key: [{ nested_key: 'value' }] }; + const expected = { arrayKey: [{ nestedKey: 'value' }] }; + expect(convertSnakeToCamelCase(input)).toStrictEqual(expected); + }); + + it('should handle empty objects', () => { + expect(convertSnakeToCamelCase({})).toStrictEqual({}); + }); + + it('should handle empty arrays', () => { + expect(convertSnakeToCamelCase([])).toStrictEqual([]); + }); + + it('should handle objects with non-string keys', () => { + const input = { 123: 'value', symbol_key: 'value' }; + const expected = { 123: 'value', symbolKey: 'value' }; + expect(convertSnakeToCamelCase(input)).toEqual(expected); + }); }); diff --git a/src/internal/utils/convertSnakeToCamelCase.ts b/src/internal/utils/convertSnakeToCamelCase.ts index ec14cb4205..c49843f5a4 100644 --- a/src/internal/utils/convertSnakeToCamelCase.ts +++ b/src/internal/utils/convertSnakeToCamelCase.ts @@ -1,5 +1,5 @@ /** - * Converts snake_case keys to camelCase in an object or array of objects or strings. + * Converts snake_case keys to camelCase in an object or array of objects. * @param {T} obj - The object, array, or string to convert. (required) * @returns {T} The converted object, array, or string. */ From e92e933632b9dec20ae9d4e9472a806074311f4b Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 13:28:13 -0800 Subject: [PATCH 14/91] Update --- src/fund/utils/fetchOnrampConfig.test.ts | 55 +++++++++++++++++++++++ src/fund/utils/fetchOnrampConfig.ts | 35 +++++++++++++++ src/fund/utils/fetchOnrampOptions.test.ts | 52 +++++++++++++++++++++ src/fund/utils/fetchOnrampOptions.ts | 46 +++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 src/fund/utils/fetchOnrampConfig.test.ts create mode 100644 src/fund/utils/fetchOnrampConfig.ts create mode 100644 src/fund/utils/fetchOnrampOptions.test.ts create mode 100644 src/fund/utils/fetchOnrampOptions.ts diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts new file mode 100644 index 0000000000..a3bcab5f23 --- /dev/null +++ b/src/fund/utils/fetchOnrampConfig.test.ts @@ -0,0 +1,55 @@ +import { type Mock, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampConfig } from './fetchOnrampConfig'; + +const mockApiKey = 'test-api-key'; +const mockResponseData = { + data: { + countries: [ + { + id: 'US', + subdivisions: ['CA', 'NY'], + payment_methods: ['credit_card', 'bank_transfer'], + }, + ], + }, +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('fetchOnrampConfig', () => { + it('should fetch onramp config and return data', async () => { + const data = await fetchOnrampConfig({ apiKey: mockApiKey }); + + expect(fetch).toHaveBeenCalledWith(`${ONRAMP_API_BASE_URL}/buy/config`, { + method: 'GET', + headers: { + Authorization: `Bearer ${mockApiKey}`, + }, + }); + + expect(data).toEqual({ + countries: [ + { + id: 'US', + subdivisions: ['CA', 'NY'], + paymentMethods: ['credit_card', 'bank_transfer'], + }, + ], + }); + }); + + it('should throw an error if the fetch fails', async () => { + (fetch as Mock).mockImplementationOnce(() => + Promise.reject(new Error('Fetch failed')), + ); + + await expect(fetchOnrampConfig({ apiKey: mockApiKey })).rejects.toThrow( + 'Fetch failed', + ); + }); +}); diff --git a/src/fund/utils/fetchOnrampConfig.ts b/src/fund/utils/fetchOnrampConfig.ts new file mode 100644 index 0000000000..3790c594f4 --- /dev/null +++ b/src/fund/utils/fetchOnrampConfig.ts @@ -0,0 +1,35 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import type { OnrampPaymentMethod } from '../types'; + +type OnrampConfigResponseData = { + countries: OnrampConfigCountry[]; +}; + +type OnrampConfigCountry = { + id: string; + subdivisions: string[]; + paymentMethods: OnrampPaymentMethod[]; +}; + +/** + * Returns list of countries supported by Coinbase Onramp, and the payment methods available in each country. + * + * @param apiKey API key for the partner. `required` + */ +export async function fetchOnrampConfig({ + apiKey, +}: { + apiKey: string; +}): Promise { + const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/config`, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + const responseJson = await response.json(); + + return convertSnakeToCamelCase(responseJson.data); +} diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts new file mode 100644 index 0000000000..131cb3b224 --- /dev/null +++ b/src/fund/utils/fetchOnrampOptions.test.ts @@ -0,0 +1,52 @@ +import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import { fetchOnrampOptions } from './fetchOnrampOptions'; + +const apiKey = 'test-api-key'; +const country = 'US'; +const subdivision = 'NY'; +const mockResponseData = { + data: { + payment_currencies: [], + purchase_currencies: [], + }, +}; + +global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +describe('fetchOnrampOptions', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should fetch onramp options successfully', async () => { + const result = await fetchOnrampOptions({ apiKey, country, subdivision }); + + expect(global.fetch).toHaveBeenCalledWith( + `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + expect(result).toEqual({ + paymentCurrencies: [], + purchaseCurrencies: [], + }); + }); + + it('should handle fetch errors', async () => { + (global.fetch as Mock).mockRejectedValue(new Error('Fetch error')); + + await expect( + fetchOnrampOptions({ apiKey, country, subdivision }), + ).rejects.toThrow('Fetch error'); + }); +}); diff --git a/src/fund/utils/fetchOnrampOptions.ts b/src/fund/utils/fetchOnrampOptions.ts new file mode 100644 index 0000000000..55db8e6130 --- /dev/null +++ b/src/fund/utils/fetchOnrampOptions.ts @@ -0,0 +1,46 @@ +import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { ONRAMP_API_BASE_URL } from '../constants'; +import type { OnrampPaymentCurrency, OnrampPurchaseCurrency } from '../types'; + +type OnrampOptionsResponseData = { + /** + * List of supported fiat currencies that can be exchanged for crypto on Onramp in the given location. + * Each currency contains a list of available payment methods, with min and max transaction limits for that currency. + */ + paymentCurrencies: OnrampPaymentCurrency[]; + /** + * List of available crypto assets that can be bought on Onramp in the given location. + */ + purchaseCurrencies: OnrampPurchaseCurrency[]; +}; + +/** + * Returns supported fiat currencies and available crypto assets that can be passed into the Buy Quote API. + * + * @param apiKey API key for the partner. `required` + * @param country ISO 3166-1 two-digit country code string representing the purchasing user’s country of residence, e.g., US. `required` + * @param subdivision ISO 3166-2 two-digit country subdivision code representing the purchasing user’s subdivision of residence within their country, e.g. `NY`. + */ +export async function fetchOnrampOptions({ + apiKey, + country, + subdivision, +}: { + apiKey: string; + country: string; + subdivision?: string; +}): Promise { + const response = await fetch( + `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + const responseJson = await response.json(); + + return convertSnakeToCamelCase(responseJson.data); +} From 2651a5f0f543389b7d8c267226a5bc3973ce3c08 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 13:38:53 -0800 Subject: [PATCH 15/91] Update --- src/fund/index.ts | 4 ++-- ...chOnrampConfig.test.ts => AAfetchOnrampConfig.test.ts} | 2 +- .../{fetchOnrampConfig.ts => AAfetchOnrampConfig.ts} | 0 ...OnrampOptions.test.ts => AAfetchOnrampOptions.test.ts} | 2 +- .../{fetchOnrampOptions.ts => AAfetchOnrampOptions.ts} | 0 src/internal/utils/convertSnakeToCamelCase.test.ts | 8 ++++++-- 6 files changed, 10 insertions(+), 6 deletions(-) rename src/fund/utils/{fetchOnrampConfig.test.ts => AAfetchOnrampConfig.test.ts} (95%) rename src/fund/utils/{fetchOnrampConfig.ts => AAfetchOnrampConfig.ts} (100%) rename src/fund/utils/{fetchOnrampOptions.test.ts => AAfetchOnrampOptions.test.ts} (95%) rename src/fund/utils/{fetchOnrampOptions.ts => AAfetchOnrampOptions.ts} (100%) diff --git a/src/fund/index.ts b/src/fund/index.ts index 237b756fcd..3cab7f38a0 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -3,8 +3,8 @@ export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFun 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 { fetchOnrampConfig } from './utils/AAfetchOnrampConfig'; +export { fetchOnrampOptions } from './utils/AAfetchOnrampOptions'; export { fetchOnrampQuote } from './utils/fetchOnrampQuote'; export type { diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/AAfetchOnrampConfig.test.ts similarity index 95% rename from src/fund/utils/fetchOnrampConfig.test.ts rename to src/fund/utils/AAfetchOnrampConfig.test.ts index a3bcab5f23..094b40aef4 100644 --- a/src/fund/utils/fetchOnrampConfig.test.ts +++ b/src/fund/utils/AAfetchOnrampConfig.test.ts @@ -1,6 +1,6 @@ import { type Mock, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampConfig } from './fetchOnrampConfig'; +import { fetchOnrampConfig } from './AAfetchOnrampConfig'; const mockApiKey = 'test-api-key'; const mockResponseData = { diff --git a/src/fund/utils/fetchOnrampConfig.ts b/src/fund/utils/AAfetchOnrampConfig.ts similarity index 100% rename from src/fund/utils/fetchOnrampConfig.ts rename to src/fund/utils/AAfetchOnrampConfig.ts diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/AAfetchOnrampOptions.test.ts similarity index 95% rename from src/fund/utils/fetchOnrampOptions.test.ts rename to src/fund/utils/AAfetchOnrampOptions.test.ts index 131cb3b224..b07d51ac03 100644 --- a/src/fund/utils/fetchOnrampOptions.test.ts +++ b/src/fund/utils/AAfetchOnrampOptions.test.ts @@ -1,6 +1,6 @@ import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampOptions } from './fetchOnrampOptions'; +import { fetchOnrampOptions } from './AAfetchOnrampOptions'; const apiKey = 'test-api-key'; const country = 'US'; diff --git a/src/fund/utils/fetchOnrampOptions.ts b/src/fund/utils/AAfetchOnrampOptions.ts similarity index 100% rename from src/fund/utils/fetchOnrampOptions.ts rename to src/fund/utils/AAfetchOnrampOptions.ts diff --git a/src/internal/utils/convertSnakeToCamelCase.test.ts b/src/internal/utils/convertSnakeToCamelCase.test.ts index 06c0a9e299..5ba499234e 100644 --- a/src/internal/utils/convertSnakeToCamelCase.test.ts +++ b/src/internal/utils/convertSnakeToCamelCase.test.ts @@ -3,11 +3,15 @@ import { convertSnakeToCamelCase } from './convertSnakeToCamelCase'; describe('convertSnakeToCamelCase', () => { it('should convert snake_case keys to camelCase', () => { - expect(convertSnakeToCamelCase({hello_world: 'hello_world'})).toStrictEqual({helloWorld: 'hello_world'}); + expect( + convertSnakeToCamelCase({ hello_world: 'hello_world' }), + ).toStrictEqual({ helloWorld: 'hello_world' }); }); it('should handle keys with multiple underscores', () => { - expect(convertSnakeToCamelCase({this_is_a_test: 'this_is_a_test'})).toStrictEqual({thisIsATest: 'this_is_a_test'}); + expect( + convertSnakeToCamelCase({ this_is_a_test: 'this_is_a_test' }), + ).toStrictEqual({ thisIsATest: 'this_is_a_test' }); }); it('should return an empty string if input is empty', () => { From e91f67175db5bd2e6009fea98aded3c2ee0cc6df Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 6 Dec 2024 13:39:28 -0800 Subject: [PATCH 16/91] Update --- src/fund/index.ts | 4 ++-- ...{AAfetchOnrampConfig.test.ts => fetchOnrampConfig.test.ts} | 2 +- .../utils/{AAfetchOnrampConfig.ts => fetchOnrampConfig.ts} | 0 ...AfetchOnrampOptions.test.ts => fetchOnrampOptions.test.ts} | 2 +- .../utils/{AAfetchOnrampOptions.ts => fetchOnrampOptions.ts} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename src/fund/utils/{AAfetchOnrampConfig.test.ts => fetchOnrampConfig.test.ts} (95%) rename src/fund/utils/{AAfetchOnrampConfig.ts => fetchOnrampConfig.ts} (100%) rename src/fund/utils/{AAfetchOnrampOptions.test.ts => fetchOnrampOptions.test.ts} (95%) rename src/fund/utils/{AAfetchOnrampOptions.ts => fetchOnrampOptions.ts} (100%) diff --git a/src/fund/index.ts b/src/fund/index.ts index 3cab7f38a0..10d4d41301 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -3,8 +3,8 @@ export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFun export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; export { fetchOnrampTransactionStatus } from './utils/fetchOnrampTransactionStatus'; -export { fetchOnrampConfig } from './utils/AAfetchOnrampConfig'; -export { fetchOnrampOptions } from './utils/AAfetchOnrampOptions'; +export { fetchOnrampConfig } from './utils/fetchOnrampConfig'; +export { fetchOnrampOptions } from './utils/fetchOnrampOptions'; export { fetchOnrampQuote } from './utils/fetchOnrampQuote'; export type { diff --git a/src/fund/utils/AAfetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts similarity index 95% rename from src/fund/utils/AAfetchOnrampConfig.test.ts rename to src/fund/utils/fetchOnrampConfig.test.ts index 094b40aef4..a3bcab5f23 100644 --- a/src/fund/utils/AAfetchOnrampConfig.test.ts +++ b/src/fund/utils/fetchOnrampConfig.test.ts @@ -1,6 +1,6 @@ import { type Mock, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampConfig } from './AAfetchOnrampConfig'; +import { fetchOnrampConfig } from './fetchOnrampConfig'; const mockApiKey = 'test-api-key'; const mockResponseData = { diff --git a/src/fund/utils/AAfetchOnrampConfig.ts b/src/fund/utils/fetchOnrampConfig.ts similarity index 100% rename from src/fund/utils/AAfetchOnrampConfig.ts rename to src/fund/utils/fetchOnrampConfig.ts diff --git a/src/fund/utils/AAfetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts similarity index 95% rename from src/fund/utils/AAfetchOnrampOptions.test.ts rename to src/fund/utils/fetchOnrampOptions.test.ts index b07d51ac03..131cb3b224 100644 --- a/src/fund/utils/AAfetchOnrampOptions.test.ts +++ b/src/fund/utils/fetchOnrampOptions.test.ts @@ -1,6 +1,6 @@ import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; import { ONRAMP_API_BASE_URL } from '../constants'; -import { fetchOnrampOptions } from './AAfetchOnrampOptions'; +import { fetchOnrampOptions } from './fetchOnrampOptions'; const apiKey = 'test-api-key'; const country = 'US'; diff --git a/src/fund/utils/AAfetchOnrampOptions.ts b/src/fund/utils/fetchOnrampOptions.ts similarity index 100% rename from src/fund/utils/AAfetchOnrampOptions.ts rename to src/fund/utils/fetchOnrampOptions.ts From a27489b82237f834ba255489451aa7eaa37d0373 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 9 Dec 2024 10:09:48 -0800 Subject: [PATCH 17/91] Update --- src/fund/components/FundForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx index b6309ec774..d8662d79cf 100644 --- a/src/fund/components/FundForm.tsx +++ b/src/fund/components/FundForm.tsx @@ -123,7 +123,7 @@ const ResizableInput = ({ Date: Mon, 9 Dec 2024 10:49:32 -0800 Subject: [PATCH 18/91] Use Api key from the config --- src/fund/utils/fetchOnrampConfig.test.ts | 13 +++++---- src/fund/utils/fetchOnrampConfig.ts | 9 +++---- src/fund/utils/fetchOnrampOptions.test.ts | 27 ++++++++++++++----- src/fund/utils/fetchOnrampOptions.ts | 6 ++--- src/fund/utils/fetchOnrampQuote.test.ts | 4 +-- src/fund/utils/fetchOnrampQuote.ts | 6 ++--- .../fetchOnrampTransactionStatus.test.ts | 9 ++++--- .../utils/fetchOnrampTransactionStatus.ts | 5 ++-- src/internal/utils/getApiKey.test.ts | 18 +++++++++++++ src/internal/utils/getApiKey.ts | 13 +++++++++ 10 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 src/internal/utils/getApiKey.test.ts create mode 100644 src/internal/utils/getApiKey.ts diff --git a/src/fund/utils/fetchOnrampConfig.test.ts b/src/fund/utils/fetchOnrampConfig.test.ts index a3bcab5f23..4b7a63b891 100644 --- a/src/fund/utils/fetchOnrampConfig.test.ts +++ b/src/fund/utils/fetchOnrampConfig.test.ts @@ -1,4 +1,5 @@ -import { type Mock, describe, expect, it, vi } from 'vitest'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setOnchainKitConfig } from '../../OnchainKitConfig'; import { ONRAMP_API_BASE_URL } from '../constants'; import { fetchOnrampConfig } from './fetchOnrampConfig'; @@ -22,8 +23,12 @@ global.fetch = vi.fn(() => ) as Mock; describe('fetchOnrampConfig', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: mockApiKey }); + }); + it('should fetch onramp config and return data', async () => { - const data = await fetchOnrampConfig({ apiKey: mockApiKey }); + const data = await fetchOnrampConfig(); expect(fetch).toHaveBeenCalledWith(`${ONRAMP_API_BASE_URL}/buy/config`, { method: 'GET', @@ -48,8 +53,6 @@ describe('fetchOnrampConfig', () => { Promise.reject(new Error('Fetch failed')), ); - await expect(fetchOnrampConfig({ apiKey: mockApiKey })).rejects.toThrow( - 'Fetch failed', - ); + await expect(fetchOnrampConfig()).rejects.toThrow('Fetch failed'); }); }); diff --git a/src/fund/utils/fetchOnrampConfig.ts b/src/fund/utils/fetchOnrampConfig.ts index 3790c594f4..230ae3ac31 100644 --- a/src/fund/utils/fetchOnrampConfig.ts +++ b/src/fund/utils/fetchOnrampConfig.ts @@ -1,4 +1,5 @@ import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { getApiKey } from '../../internal/utils/getApiKey'; import { ONRAMP_API_BASE_URL } from '../constants'; import type { OnrampPaymentMethod } from '../types'; @@ -15,13 +16,9 @@ type OnrampConfigCountry = { /** * Returns list of countries supported by Coinbase Onramp, and the payment methods available in each country. * - * @param apiKey API key for the partner. `required` */ -export async function fetchOnrampConfig({ - apiKey, -}: { - apiKey: string; -}): Promise { +export async function fetchOnrampConfig(): Promise { + const apiKey = getApiKey(); const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/config`, { method: 'GET', headers: { diff --git a/src/fund/utils/fetchOnrampOptions.test.ts b/src/fund/utils/fetchOnrampOptions.test.ts index 131cb3b224..8322c0e488 100644 --- a/src/fund/utils/fetchOnrampOptions.test.ts +++ b/src/fund/utils/fetchOnrampOptions.test.ts @@ -1,8 +1,17 @@ -import { type Mock, afterEach, describe, expect, it, vi } from 'vitest'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { setOnchainKitConfig } from '../../OnchainKitConfig'; import { ONRAMP_API_BASE_URL } from '../constants'; import { fetchOnrampOptions } from './fetchOnrampOptions'; -const apiKey = 'test-api-key'; +const mockApiKey = 'test-api-key'; const country = 'US'; const subdivision = 'NY'; const mockResponseData = { @@ -19,19 +28,23 @@ global.fetch = vi.fn(() => ) as Mock; describe('fetchOnrampOptions', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: mockApiKey }); + }); + afterEach(() => { vi.clearAllMocks(); }); it('should fetch onramp options successfully', async () => { - const result = await fetchOnrampOptions({ apiKey, country, subdivision }); + const result = await fetchOnrampOptions({ country, subdivision }); expect(global.fetch).toHaveBeenCalledWith( `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, { method: 'GET', headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${mockApiKey}`, }, }, ); @@ -45,8 +58,8 @@ describe('fetchOnrampOptions', () => { it('should handle fetch errors', async () => { (global.fetch as Mock).mockRejectedValue(new Error('Fetch error')); - await expect( - fetchOnrampOptions({ apiKey, country, subdivision }), - ).rejects.toThrow('Fetch error'); + await expect(fetchOnrampOptions({ country, subdivision })).rejects.toThrow( + 'Fetch error', + ); }); }); diff --git a/src/fund/utils/fetchOnrampOptions.ts b/src/fund/utils/fetchOnrampOptions.ts index 55db8e6130..0e53663267 100644 --- a/src/fund/utils/fetchOnrampOptions.ts +++ b/src/fund/utils/fetchOnrampOptions.ts @@ -1,4 +1,5 @@ import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { getApiKey } from '../../internal/utils/getApiKey'; import { ONRAMP_API_BASE_URL } from '../constants'; import type { OnrampPaymentCurrency, OnrampPurchaseCurrency } from '../types'; @@ -17,19 +18,18 @@ type OnrampOptionsResponseData = { /** * Returns supported fiat currencies and available crypto assets that can be passed into the Buy Quote API. * - * @param apiKey API key for the partner. `required` * @param country ISO 3166-1 two-digit country code string representing the purchasing user’s country of residence, e.g., US. `required` * @param subdivision ISO 3166-2 two-digit country subdivision code representing the purchasing user’s subdivision of residence within their country, e.g. `NY`. */ export async function fetchOnrampOptions({ - apiKey, country, subdivision, }: { - apiKey: string; country: string; subdivision?: string; }): Promise { + const apiKey = getApiKey(); + const response = await fetch( `${ONRAMP_API_BASE_URL}/buy/options?country=${country}&subdivision=${subdivision}`, { diff --git a/src/fund/utils/fetchOnrampQuote.test.ts b/src/fund/utils/fetchOnrampQuote.test.ts index e68bc12fa3..7b3d339bbd 100644 --- a/src/fund/utils/fetchOnrampQuote.test.ts +++ b/src/fund/utils/fetchOnrampQuote.test.ts @@ -1,4 +1,5 @@ import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setOnchainKitConfig } from '../../OnchainKitConfig'; import { ONRAMP_API_BASE_URL } from '../constants'; import { fetchOnrampQuote } from './fetchOnrampQuote'; @@ -31,11 +32,11 @@ global.fetch = vi.fn(() => describe('fetchOnrampQuote', () => { beforeEach(() => { vi.clearAllMocks(); + setOnchainKitConfig({ apiKey: mockApiKey }); }); it('should fetch onramp quote successfully', async () => { const result = await fetchOnrampQuote({ - apiKey: mockApiKey, purchaseCurrency: mockPurchaseCurrency, purchaseNetwork: mockPurchaseNetwork, paymentCurrency: mockPaymentCurrency, @@ -80,7 +81,6 @@ describe('fetchOnrampQuote', () => { await expect( fetchOnrampQuote({ - apiKey: mockApiKey, purchaseCurrency: mockPurchaseCurrency, purchaseNetwork: mockPurchaseNetwork, paymentCurrency: mockPaymentCurrency, diff --git a/src/fund/utils/fetchOnrampQuote.ts b/src/fund/utils/fetchOnrampQuote.ts index ac0610414e..9c28c7c0bd 100644 --- a/src/fund/utils/fetchOnrampQuote.ts +++ b/src/fund/utils/fetchOnrampQuote.ts @@ -1,4 +1,5 @@ import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { getApiKey } from '../../internal/utils/getApiKey'; import { ONRAMP_API_BASE_URL } from '../constants'; import type { OnrampAmout } from '../types'; @@ -38,7 +39,6 @@ type OnrampQuoteResponseData = { /** * Provides a quote based on the asset the user would like to purchase, plus the network, the fiat payment, the payment currency, payment method, and country. * - * @param apiKey API key for the partner. `required` * @param purchaseCurrency ID of the crypto asset the user wants to purchase. Retrieved from the options API. `required` * @param purchaseNetwork Name of the network that the purchase currency should be purchased on. * Retrieved from the options API. If omitted, the default network for the crypto currency is used. @@ -50,7 +50,6 @@ type OnrampQuoteResponseData = { * Required if the `country=“US”` because certain states (e.g., `NY`) have state specific asset restrictions. */ export async function fetchOnrampQuote({ - apiKey, purchaseCurrency, purchaseNetwork, paymentCurrency, @@ -59,7 +58,6 @@ export async function fetchOnrampQuote({ country, subdivision, }: { - apiKey: string; purchaseCurrency: string; purchaseNetwork?: string; paymentCurrency: string; @@ -68,6 +66,8 @@ export async function fetchOnrampQuote({ country: string; subdivision?: string; }): Promise { + const apiKey = getApiKey(); + const response = await fetch(`${ONRAMP_API_BASE_URL}/buy/quote`, { method: 'POST', body: JSON.stringify({ diff --git a/src/fund/utils/fetchOnrampTransactionStatus.test.ts b/src/fund/utils/fetchOnrampTransactionStatus.test.ts index d7265396b9..670dd619a5 100644 --- a/src/fund/utils/fetchOnrampTransactionStatus.test.ts +++ b/src/fund/utils/fetchOnrampTransactionStatus.test.ts @@ -1,8 +1,9 @@ import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setOnchainKitConfig } from '../../OnchainKitConfig'; import { ONRAMP_API_BASE_URL } from '../constants'; import { fetchOnrampTransactionStatus } from './fetchOnrampTransactionStatus'; -const apiKey = 'test-api-key'; +const mockApiKey = 'test-api-key'; const partnerUserId = 'test-user-id'; const nextPageKey = 'test-next-page-key'; const pageSize = '10'; @@ -22,11 +23,12 @@ describe('fetchOnrampTransactionStatus', () => { json: () => Promise.resolve(mockResponseData), }), ) as Mock; + + setOnchainKitConfig({ apiKey: mockApiKey }); }); it('should fetch transaction status and convert response to camel case', async () => { const result = await fetchOnrampTransactionStatus({ - apiKey, partnerUserId, nextPageKey, pageSize, @@ -37,7 +39,7 @@ describe('fetchOnrampTransactionStatus', () => { { method: 'GET', headers: { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${mockApiKey}`, }, }, ); @@ -54,7 +56,6 @@ describe('fetchOnrampTransactionStatus', () => { await expect( fetchOnrampTransactionStatus({ - apiKey, partnerUserId, nextPageKey, pageSize, diff --git a/src/fund/utils/fetchOnrampTransactionStatus.ts b/src/fund/utils/fetchOnrampTransactionStatus.ts index 63396c90ad..31e95d5dd5 100644 --- a/src/fund/utils/fetchOnrampTransactionStatus.ts +++ b/src/fund/utils/fetchOnrampTransactionStatus.ts @@ -1,4 +1,5 @@ import { convertSnakeToCamelCase } from '../../internal/utils/convertSnakeToCamelCase'; +import { getApiKey } from '../../internal/utils/getApiKey'; import { ONRAMP_API_BASE_URL } from '../constants'; import type { OnrampTransaction } from '../types'; @@ -18,16 +19,16 @@ type OnrampTransactionStatusResponseData = { }; export async function fetchOnrampTransactionStatus({ - apiKey, partnerUserId, nextPageKey, pageSize, }: { - apiKey: string; partnerUserId: string; nextPageKey: string; pageSize: string; }): Promise { + const apiKey = getApiKey(); + const response = await fetch( `${ONRAMP_API_BASE_URL}/buy/user/${partnerUserId}/transactions?page_key=${nextPageKey}&page_size=${pageSize}`, { diff --git a/src/internal/utils/getApiKey.test.ts b/src/internal/utils/getApiKey.test.ts new file mode 100644 index 0000000000..cde11400ab --- /dev/null +++ b/src/internal/utils/getApiKey.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { setOnchainKitConfig } from '../../OnchainKitConfig'; +import { getApiKey } from './getApiKey'; + +describe('getApiKey', () => { + it('should throw exception if API key is not set', () => { + setOnchainKitConfig({}); + expect(() => getApiKey()).toThrow( + 'API Key Unset: Please set the API Key by providing it in the `OnchainKitProvider` or by manually calling `setOnchainKitConfig`. For more information, visit: https://portal.cdp.coinbase.com/products/onchainkit', + ); + }); + + it('should return the correct api key', () => { + const apiKey = 'test-api-key'; + setOnchainKitConfig({ apiKey }); + expect(getApiKey()).toEqual(apiKey); + }); +}); diff --git a/src/internal/utils/getApiKey.ts b/src/internal/utils/getApiKey.ts new file mode 100644 index 0000000000..0daf06e858 --- /dev/null +++ b/src/internal/utils/getApiKey.ts @@ -0,0 +1,13 @@ +import { ONCHAIN_KIT_CONFIG } from '../../OnchainKitConfig'; + +/** + * Get the API key for OnchainKit. + */ +export const getApiKey = () => { + if (!ONCHAIN_KIT_CONFIG.apiKey) { + throw new Error( + 'API Key Unset: Please set the API Key by providing it in the `OnchainKitProvider` or by manually calling `setOnchainKitConfig`. For more information, visit: https://portal.cdp.coinbase.com/products/onchainkit', + ); + } + return ONCHAIN_KIT_CONFIG.apiKey; +}; From edd37664da5c09ad00e049a807aee9c3c2753531 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 10 Dec 2024 13:30:16 -0800 Subject: [PATCH 19/91] Update --- .../components/AppProvider.tsx | 2 +- .../components/demo/Fund.tsx | 2 + .../components/demo/Swap.tsx | 4 +- site/docs/components/landing/CheckoutDemo.tsx | 4 +- src/fund/components/FundCard.tsx | 13 +- src/fund/components/FundForm.tsx | 176 ++++-------------- src/fund/components/FundFormAmountInput.tsx | 134 +++++++++++++ .../FundFormAmountInputTypeSwitch.tsx | 59 ++++++ src/fund/components/FundProvider.tsx | 16 +- src/fund/components/PaymentMethodImage.tsx | 62 ++++++ .../components/PaymentMethodSelectRow.tsx | 50 +++++ .../PaymentMethodSelectorDropdown.tsx | 96 ++++++++++ .../PaymentMethodSelectorToggle.tsx | 54 ++++++ src/internal/hooks/useIcon.tsx | 17 +- src/internal/svg/applePaySvg.tsx | 23 +++ src/internal/svg/coinbasePaySvg.tsx | 6 +- src/internal/svg/creditCardSvg.tsx | 23 +++ 17 files changed, 588 insertions(+), 153 deletions(-) create mode 100644 src/fund/components/FundFormAmountInput.tsx create mode 100644 src/fund/components/FundFormAmountInputTypeSwitch.tsx create mode 100644 src/fund/components/PaymentMethodImage.tsx create mode 100644 src/fund/components/PaymentMethodSelectRow.tsx create mode 100644 src/fund/components/PaymentMethodSelectorDropdown.tsx create mode 100644 src/fund/components/PaymentMethodSelectorToggle.tsx create mode 100644 src/internal/svg/applePaySvg.tsx create mode 100644 src/internal/svg/creditCardSvg.tsx diff --git a/playground/nextjs-app-router/components/AppProvider.tsx b/playground/nextjs-app-router/components/AppProvider.tsx index 932a426cd1..74ec01e6dd 100644 --- a/playground/nextjs-app-router/components/AppProvider.tsx +++ b/playground/nextjs-app-router/components/AppProvider.tsx @@ -162,7 +162,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { }} > void }) { >
- {coinbasePaySvg} +
diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 25b9d789d7..9650f96257 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,5 +1,7 @@ -import { border, cn, text } from '../../styles/theme'; +import { background, border, cn, color, text } from '../../styles/theme'; import { useTheme } from '../../useTheme'; +import { fetchOnrampConfig } from '../utils/fetchOnrampConfig'; +import { fetchOnrampOptions } from '../utils/fetchOnrampOptions'; import { FundCardHeader } from './FundCardHeader'; import { FundForm } from './FundForm'; import { FundProvider } from './FundProvider'; @@ -22,13 +24,15 @@ export function FundCard({
- + +
); diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx index d8662d79cf..75592142c9 100644 --- a/src/fund/components/FundForm.tsx +++ b/src/fund/components/FundForm.tsx @@ -1,14 +1,13 @@ -import { - type ChangeEvent, - useEffect, - useMemo, - useRef, -} from 'react'; +import { type ChangeEvent, useEffect, useMemo, useRef } from 'react'; import { border, cn, text } from '../../styles/theme'; import { useTheme } from '../../useTheme'; import { useFundContext } from './FundProvider'; import { FundButton } from './FundButton'; import { FundCardHeader } from './FundCardHeader'; +import { PaymentMethodSelectorDropdown } from './PaymentMethodSelectorDropdown'; +import FundFormAmountInput from './FundFormAmountInput'; +import FundFormAmountInputTypeSwitch from './FundFormAmountInputTypeSwitch'; +import { ONRAMP_BUY_URL } from '../constants'; type Props = { assetSymbol: string; @@ -22,140 +21,45 @@ export function FundForm({ buttonText = 'Buy', headerText, }: Props) { - const { setSelectedAsset, setFundAmount, fundAmount } = useFundContext(); + const { setFundAmount, fundAmount } = useFundContext(); - return ( -
-
- -
- - - ); -} - -type ResizableInputProps = { - value: string; - setValue: (s: string) => void; - currencySign?: string; -}; - -const ResizableInput = ({ - value, - setValue, - currencySign, -}: ResizableInputProps) => { - const componentTheme = useTheme(); - - const inputRef = useRef(null); - const spanRef = useRef(null); - const previousValueRef = useRef(''); - - const handleChange = (e: ChangeEvent) => { - let value = e.target.value; - /** - * Only allow numbers to be entered into the input - * Using type="number" on the input does not work because it adds a spinner which does not get removed with css '-webkit-appearance': 'none' - */ - if (/^\d*\.?\d*$/.test(value)) { - if (value.length === 1 && value === '.') { - value = '0.'; - } else if (value.length === 1 && value === '0') { - if (previousValueRef.current.length <= value.length) { - // Add a dot if the user types a single zero - value = '0.'; - } else { - value = ''; - } - } else if ( - value[value.length - 1] === '.' && - previousValueRef.current.length >= value.length - ) { - // If we are deleting a character and the last character is a dot, remove it - value = value.slice(0, -1); - } else if (value.length === 2 && value[0] === '0' && value[1] !== '.') { - // Add a dot in case there is a leading zero - value = `${value[0]}.${value[1]}`; - } + const fundingUrl = useMemo(() => { + return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["ETH"]&presetFiatAmount=${fundAmount}`; + }, [assetSymbol, fundAmount]); - setValue(value); - } - // Update the previous value - previousValueRef.current = value; - }; + // https://pay.coinbase.com/buy/one-click?appId=9f59d35a-b36b-4395-ae75-560bb696bfe6&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["USDC"]&presetCryptoAmount=5 - const fontSize = useMemo(() => { - if (value.length < 2) { - return 60; - } - return 60 - Math.min(value.length * 2.5, 40); - }, [value]); + return ( +
+ - // useEffect(() => { - // // Update the input width based on the hidden span's width - // if (spanRef.current && inputRef.current) { - // if (inputRef.current?.style?.width) { - // inputRef.current.style.width = - // value.length === 1 - // ? `${spanRef.current?.offsetWidth}px` - // : `${spanRef.current?.offsetWidth + 10}px`; - // } - // } - // }, [value]); + - return ( -
- {currencySign && ( - - {currencySign} - - )} - - {/* Hidden span to measure content width */} - {/* - {value || '0'} - */} -
- ); -}; -export default ResizableInput; + + + ); +} diff --git a/src/fund/components/FundFormAmountInput.tsx b/src/fund/components/FundFormAmountInput.tsx new file mode 100644 index 0000000000..121eb3303b --- /dev/null +++ b/src/fund/components/FundFormAmountInput.tsx @@ -0,0 +1,134 @@ +import { + type ChangeEvent, + useEffect, + useMemo, + useRef, +} from 'react'; +import { border, cn, text } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; +import { useFundContext } from './FundProvider'; +import { FundButton } from './FundButton'; +import { FundCardHeader } from './FundCardHeader'; +import { PaymentMethodSelectorDropdown } from './PaymentMethodSelectorDropdown'; + +type Props = { + value: string; + setValue: (s: string) => void; + currencySign?: string; +}; + +export const FundFormAmountInput = ({ + value, + setValue, + currencySign, +}: Props) => { + const componentTheme = useTheme(); + + const inputRef = useRef(null); + const spanRef = useRef(null); + const previousValueRef = useRef(''); + + const handleChange = (e: ChangeEvent) => { + let value = e.target.value; + /** + * Only allow numbers to be entered into the input + * Using type="number" on the input does not work because it adds a spinner which does not get removed with css '-webkit-appearance': 'none' + */ + if (/^\d*\.?\d*$/.test(value)) { + if (value.length === 1 && value === '.') { + value = '0.'; + } else if (value.length === 1 && value === '0') { + if (previousValueRef.current.length <= value.length) { + // Add a dot if the user types a single zero + value = '0.'; + } else { + value = ''; + } + } else if ( + value[value.length - 1] === '.' && + previousValueRef.current.length >= value.length + ) { + // If we are deleting a character and the last character is a dot, remove it + value = value.slice(0, -1); + } else if (value.length === 2 && value[0] === '0' && value[1] !== '.') { + // Add a dot in case there is a leading zero + value = `${value[0]}.${value[1]}`; + } + + setValue(value); + } + // Update the previous value + previousValueRef.current = value; + }; + + const fontSize = useMemo(() => { + if (value.length < 2) { + return 60; + } + return 60 - Math.min(value.length * 2.5, 40); + }, [value]); + + // useEffect(() => { + // // Update the input width based on the hidden span's width + // if (spanRef.current && inputRef.current) { + // if (inputRef.current?.style?.width) { + // inputRef.current.style.width = + // value.length === 1 + // ? `${spanRef.current?.offsetWidth}px` + // : `${spanRef.current?.offsetWidth + 10}px`; + // } + // } + // }, [value]); + + return ( +
+ {currencySign && ( + + {currencySign} + + )} + + {/* Hidden span to measure content width */} + {/* + {value || '0'} + */} +
+ ); +}; + +export default FundFormAmountInput; diff --git a/src/fund/components/FundFormAmountInputTypeSwitch.tsx b/src/fund/components/FundFormAmountInputTypeSwitch.tsx new file mode 100644 index 0000000000..2226503cb8 --- /dev/null +++ b/src/fund/components/FundFormAmountInputTypeSwitch.tsx @@ -0,0 +1,59 @@ +import { cn, pressable } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; +import { useIcon } from '../../internal/hooks/useIcon'; +import { useFundContext } from './FundProvider'; +import { getRoundedAmount } from '../../internal/utils/getRoundedAmount'; +import { useCallback, useMemo } from 'react'; + +export const FundFormAmountInputTypeSwitch = () => { + const componentTheme = useTheme(); + const { selectedInputType, setSelectedInputType, selectedAsset, fundAmount } = + useFundContext(); + + const iconSvg = useIcon({ icon: 'toggle' }); + + const handleToggle = () => { + console.log('Toggle'); + }; + + const formatUSD = useCallback((amount: string) => { + if (!amount || amount === '0') { + return null; + } + const roundedAmount = Number(getRoundedAmount(amount, 2)); + return `$${roundedAmount.toFixed(2)}`; + }, []); + + const exchangeRate = useMemo(() => { + return `(${formatUSD('1')} = 1 USDC)` + }, [formatUSD]); + + return ( +
+ +
+ {selectedInputType === 'fiat' ? ( + + {formatUSD(fundAmount)} {exchangeRate} + + ) : ( + + 10 {selectedAsset} ($1 = 1 USDC) + + )} +
+
+ ); +}; + +export default FundFormAmountInputTypeSwitch; diff --git a/src/fund/components/FundProvider.tsx b/src/fund/components/FundProvider.tsx index a29d4219bf..d4580f5156 100644 --- a/src/fund/components/FundProvider.tsx +++ b/src/fund/components/FundProvider.tsx @@ -1,10 +1,15 @@ import { createContext, useContext, useState } from 'react'; import type { ReactNode } from 'react'; import { useValue } from '../../internal/hooks/useValue'; +import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; type FundContextType = { selectedAsset?: string; setSelectedAsset: (asset: string) => void; + selectedPaymentMethod?: PaymentMethod + setSelectedPaymentMethod: (paymentMethod: PaymentMethod) => void; + selectedInputType?: 'fiat' | 'crypto'; + setSelectedInputType: (inputType: 'fiat' | 'crypto') => void; fundAmount: string; setFundAmount: (amount: string) => void; }; @@ -15,17 +20,24 @@ const FundContext = createContext(initialState); type FundProviderReact = { children: ReactNode; + asset: string; }; -export function FundProvider({ children }: FundProviderReact) { - const [selectedAsset, setSelectedAsset] = useState(); +export function FundProvider({ children, asset }: FundProviderReact) { + const [selectedAsset, setSelectedAsset] = useState(asset); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(); + const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>('fiat'); const [fundAmount, setFundAmount] = useState(''); const value = useValue({ selectedAsset, setSelectedAsset, + selectedPaymentMethod, + setSelectedPaymentMethod, fundAmount, setFundAmount, + selectedInputType, + setSelectedInputType, }); return {children}; } diff --git a/src/fund/components/PaymentMethodImage.tsx b/src/fund/components/PaymentMethodImage.tsx new file mode 100644 index 0000000000..4ba3f4b39d --- /dev/null +++ b/src/fund/components/PaymentMethodImage.tsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react'; +import { cn, icon as iconTheme} from '../../styles/theme'; +//import type { TokenImageReact } from '../types'; +// import { getTokenImageColor } from '../utils/getTokenImageColor'; +import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; +import { useIcon } from '../../internal/hooks/useIcon'; + +type Props = { + className?: string; + size?: number; + paymentMethod: PaymentMethod +}; + +export function PaymentMethodImage({ className, size = 24, paymentMethod }: Props) { + const { icon, name } = paymentMethod; + const iconColor = icon === 'coinbasePay' ? iconTheme.primary : undefined; + + const iconSvg = useIcon({ icon, className: iconColor }); + + const styles = useMemo(() => { + return { + image: { + width: `${size}px`, + height: `${size}px`, + minWidth: `${size}px`, + minHeight: `${size}px`, + }, + placeholderImage: { + //background: getTokenImageColor(name), + width: `${size}px`, + height: `${size}px`, + minWidth: `${size}px`, + minHeight: `${size}px`, + }, + }; + }, [size]); + + if (!iconSvg) { + return ( +
+
+
+ ); + } + + return ( +
+ {iconSvg} +
+ // token-image + ); +} diff --git a/src/fund/components/PaymentMethodSelectRow.tsx b/src/fund/components/PaymentMethodSelectRow.tsx new file mode 100644 index 0000000000..c51d5c179a --- /dev/null +++ b/src/fund/components/PaymentMethodSelectRow.tsx @@ -0,0 +1,50 @@ +import { memo } from 'react'; +import { cn, color, pressable, text } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; + +import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; +import { PaymentMethodImage } from './PaymentMethodImage'; + +type Props = { + className?: string; + paymentMethod: PaymentMethod; + onClick?: (paymentMethod: PaymentMethod) => void; + hideImage?: boolean; + hideDescription?: boolean; +} + +export const PaymentMethodSelectRow = memo(({ + className, + paymentMethod, + onClick, + hideImage, + hideDescription, +}: Props) => { + const componentTheme = useTheme(); + + return ( + + ); +}); diff --git a/src/fund/components/PaymentMethodSelectorDropdown.tsx b/src/fund/components/PaymentMethodSelectorDropdown.tsx new file mode 100644 index 0000000000..f4433d52ff --- /dev/null +++ b/src/fund/components/PaymentMethodSelectorDropdown.tsx @@ -0,0 +1,96 @@ +import { useCallback, useRef, useState } from 'react'; +import { background, border, cn } from '../../styles/theme'; +import { useTheme } from '../../useTheme'; +import { PaymentMethodSelectRow } from './PaymentMethodSelectRow'; +import { PaymentMethodSelectorToggle } from './PaymentMethodSelectorToggle'; +import { useFundContext } from './FundProvider'; + +export type PaymentMethod = { + name: string; + description: string; + icon: string; +}; + +type Props = { + paymentMethods: PaymentMethod[] +} + +export function PaymentMethodSelectorDropdown({ + paymentMethods, +}: Props) { + const componentTheme = useTheme(); + + const [isOpen, setIsOpen] = useState(false); + const { setFundAmount, fundAmount, selectedPaymentMethod, setSelectedPaymentMethod } = useFundContext(); + + const handleToggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + /* v8 ignore next 11 */ + const handleBlur = useCallback((event: { target: Node; }) => { + const isOutsideDropdown = + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node); + const isOutsideButton = + buttonRef.current && !buttonRef.current.contains(event.target as Node); + + if (isOutsideDropdown && isOutsideButton) { + setIsOpen(false); + } + }, []); + + // useEffect(() => { + // // NOTE: this ensures that handleBlur doesn't get called on initial mount + // // We need to use non-div elements to properly handle onblur events + // setTimeout(() => { + // document.addEventListener('click', handleBlur); + // }, 0); + + // return () => { + // document.removeEventListener('click', handleBlur); + // }; + // }, [handleBlur]); + + return ( +
+ + + {isOpen && ( +
+
+ + {paymentMethods.map((paymentMethod) => ( + { + setSelectedPaymentMethod(paymentMethod); + handleToggle(); + }} + /> + ))} +
+
+ )} +
+ ); +} diff --git a/src/fund/components/PaymentMethodSelectorToggle.tsx b/src/fund/components/PaymentMethodSelectorToggle.tsx new file mode 100644 index 0000000000..efca60daa5 --- /dev/null +++ b/src/fund/components/PaymentMethodSelectorToggle.tsx @@ -0,0 +1,54 @@ +import { type ForwardedRef, forwardRef } from 'react'; +import { caretDownSvg } from '../../internal/svg/caretDownSvg'; +import { caretUpSvg } from '../../internal/svg/caretUpSvg'; +import { border, cn, color, pressable, text } from '../../styles/theme'; +import { PaymentMethodImage } from './PaymentMethodImage'; +import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; + +type Props = { + className?: string; + isOpen: boolean; // Determines carot icon direction + onClick: () => void; // Button on click handler + paymentMethod: PaymentMethod +} + +export const PaymentMethodSelectorToggle = forwardRef(( + { onClick, paymentMethod, isOpen, className }: Props, + ref: ForwardedRef, +) => { + return ( + + ); +}); diff --git a/src/internal/hooks/useIcon.tsx b/src/internal/hooks/useIcon.tsx index 854ad7def1..498d372a02 100644 --- a/src/internal/hooks/useIcon.tsx +++ b/src/internal/hooks/useIcon.tsx @@ -1,26 +1,35 @@ import { isValidElement, useMemo } from 'react'; -import { coinbasePaySvg } from '../svg/coinbasePaySvg'; +import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { toggleSvg } from '../../internal/svg/toggleSvg'; +import { applePaySvg } from '../svg/applePaySvg'; import { fundWalletSvg } from '../svg/fundWallet'; import { swapSettingsSvg } from '../svg/swapSettings'; import { walletSvg } from '../svg/walletSvg'; +import { creditCardSvg } from '../svg/creditCardSvg'; -export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { +export const useIcon = ({ icon, className }: { icon?: React.ReactNode, className?: string }) => { return useMemo(() => { if (icon === undefined) { return null; } switch (icon) { case 'coinbasePay': - return coinbasePaySvg; + return ; case 'fundWallet': return fundWalletSvg; case 'swapSettings': return swapSettingsSvg; case 'wallet': return walletSvg; + case 'toggle': + return toggleSvg; + case 'applePay': + return applePaySvg; + case 'creditCard': + return creditCardSvg; } if (isValidElement(icon)) { return icon; } - }, [icon]); + }, [icon, className]); }; diff --git a/src/internal/svg/applePaySvg.tsx b/src/internal/svg/applePaySvg.tsx new file mode 100644 index 0000000000..44e7720f9e --- /dev/null +++ b/src/internal/svg/applePaySvg.tsx @@ -0,0 +1,23 @@ +import { icon } from '../../styles/theme'; + +export const applePaySvg = ( + + + + +); diff --git a/src/internal/svg/coinbasePaySvg.tsx b/src/internal/svg/coinbasePaySvg.tsx index c7fdba8d39..722e4b662d 100644 --- a/src/internal/svg/coinbasePaySvg.tsx +++ b/src/internal/svg/coinbasePaySvg.tsx @@ -1,4 +1,6 @@ -export const coinbasePaySvg = ( +import { cn, icon } from '../../styles/theme'; + +export const CoinbasePaySvg = ({className = cn(icon.foreground)}) => ( ); diff --git a/src/internal/svg/creditCardSvg.tsx b/src/internal/svg/creditCardSvg.tsx new file mode 100644 index 0000000000..67cfd4a8c9 --- /dev/null +++ b/src/internal/svg/creditCardSvg.tsx @@ -0,0 +1,23 @@ +import { icon } from '../../styles/theme'; + +export const creditCardSvg = ( + + + + +); From 839a045c63420dfc7c5cd7493165f2db4f1f6cdb Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 10 Dec 2024 14:09:08 -0800 Subject: [PATCH 20/91] Update --- src/fund/components/FundForm.tsx | 11 +++++++---- src/fund/components/PaymentMethodSelectorDropdown.tsx | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx index 75592142c9..8fe3a783f5 100644 --- a/src/fund/components/FundForm.tsx +++ b/src/fund/components/FundForm.tsx @@ -21,11 +21,11 @@ export function FundForm({ buttonText = 'Buy', headerText, }: Props) { - const { setFundAmount, fundAmount } = useFundContext(); + const { setFundAmount, fundAmount, selectedPaymentMethod } = useFundContext(); const fundingUrl = useMemo(() => { - return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["ETH"]&presetFiatAmount=${fundAmount}`; - }, [assetSymbol, fundAmount]); + return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=[${assetSymbol}]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod.id}`; + }, [assetSymbol, fundAmount, selectedPaymentMethod]); // https://pay.coinbase.com/buy/one-click?appId=9f59d35a-b36b-4395-ae75-560bb696bfe6&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["USDC"]&presetCryptoAmount=5 @@ -42,16 +42,19 @@ export function FundForm({ ); -} +} \ No newline at end of file diff --git a/src/fund/components/PaymentMethodSelectorDropdown.tsx b/src/fund/components/PaymentMethodSelectorDropdown.tsx index f4433d52ff..d761f1d29e 100644 --- a/src/fund/components/PaymentMethodSelectorDropdown.tsx +++ b/src/fund/components/PaymentMethodSelectorDropdown.tsx @@ -6,6 +6,7 @@ import { PaymentMethodSelectorToggle } from './PaymentMethodSelectorToggle'; import { useFundContext } from './FundProvider'; export type PaymentMethod = { + id: 'CRYPTO_ACCOUNT' | 'FIAT_WALLET' | 'CARD' | 'ACH_BANK_ACCOUNT' | 'APPLE_PAY'; name: string; description: string; icon: string; From c7521d71e70ed48fe3a240f94fff86f66fa845dd Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 10 Dec 2024 15:30:15 -0800 Subject: [PATCH 21/91] Update --- src/fund/components/FundForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx index 8fe3a783f5..ea527c5148 100644 --- a/src/fund/components/FundForm.tsx +++ b/src/fund/components/FundForm.tsx @@ -24,7 +24,7 @@ export function FundForm({ const { setFundAmount, fundAmount, selectedPaymentMethod } = useFundContext(); const fundingUrl = useMemo(() => { - return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=[${assetSymbol}]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod.id}`; + return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["${assetSymbol}"]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; }, [assetSymbol, fundAmount, selectedPaymentMethod]); // https://pay.coinbase.com/buy/one-click?appId=9f59d35a-b36b-4395-ae75-560bb696bfe6&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["USDC"]&presetCryptoAmount=5 From c14a5a0c1a1340d30cdde90f0402095a1a4274cb Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 16 Dec 2024 11:09:59 -0800 Subject: [PATCH 22/91] Intermediate changes --- .../components/AppProvider.tsx | 2 +- .../components/demo/Fund.tsx | 6 +- .../nextjs-app-router/onchainkit/package.json | 8 +- src/fund/components/FundButton.test.tsx | 19 +- src/fund/components/FundButton.tsx | 61 +++++- src/fund/components/FundCard.tsx | 192 +++++++++++++++--- src/fund/components/FundCardAmountInput.tsx | 187 +++++++++++++++++ .../FundCardAmountInputTypeSwitch.tsx | 98 +++++++++ src/fund/components/FundCardCurrencyLabel.tsx | 24 +++ src/fund/components/FundCardHeader.test.tsx | 41 ++++ src/fund/components/FundCardHeader.tsx | 11 +- ...age.tsx => FundCardPaymentMethodImage.tsx} | 24 +-- ...tsx => FundCardPaymentMethodSelectRow.tsx} | 21 +- .../FundCardPaymentMethodSelectorDropdown.tsx | 90 ++++++++ ...> FundCardPaymentMethodSelectorToggle.tsx} | 17 +- src/fund/components/FundCardProvider.tsx | 67 ++++++ src/fund/components/FundForm.tsx | 68 ------- src/fund/components/FundFormAmountInput.tsx | 134 ------------ .../FundFormAmountInputTypeSwitch.tsx | 59 ------ src/fund/components/FundProvider.tsx | 53 ----- .../PaymentMethodSelectorDropdown.tsx | 97 --------- src/fund/hooks/useExchangeRate.ts | 34 ++++ src/fund/index.ts | 3 +- src/fund/types.ts | 117 ++++++++++- src/fund/utils/fetchOnrampQuote.ts | 2 +- src/fund/utils/subscribeToWindowMessage.ts | 11 +- src/internal/components/Skeleton.tsx | 19 ++ src/internal/utils/openPopup.ts | 2 +- 28 files changed, 943 insertions(+), 524 deletions(-) create mode 100644 src/fund/components/FundCardAmountInput.tsx create mode 100644 src/fund/components/FundCardAmountInputTypeSwitch.tsx create mode 100644 src/fund/components/FundCardCurrencyLabel.tsx create mode 100644 src/fund/components/FundCardHeader.test.tsx rename src/fund/components/{PaymentMethodImage.tsx => FundCardPaymentMethodImage.tsx} (58%) rename src/fund/components/{PaymentMethodSelectRow.tsx => FundCardPaymentMethodSelectRow.tsx} (65%) create mode 100644 src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx rename src/fund/components/{PaymentMethodSelectorToggle.tsx => FundCardPaymentMethodSelectorToggle.tsx} (70%) create mode 100644 src/fund/components/FundCardProvider.tsx delete mode 100644 src/fund/components/FundForm.tsx delete mode 100644 src/fund/components/FundFormAmountInput.tsx delete mode 100644 src/fund/components/FundFormAmountInputTypeSwitch.tsx delete mode 100644 src/fund/components/FundProvider.tsx delete mode 100644 src/fund/components/PaymentMethodSelectorDropdown.tsx create mode 100644 src/fund/hooks/useExchangeRate.ts create mode 100644 src/internal/components/Skeleton.tsx diff --git a/playground/nextjs-app-router/components/AppProvider.tsx b/playground/nextjs-app-router/components/AppProvider.tsx index 74ec01e6dd..932a426cd1 100644 --- a/playground/nextjs-app-router/components/AppProvider.tsx +++ b/playground/nextjs-app-router/components/AppProvider.tsx @@ -162,7 +162,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { }} >
+ ); } diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 96bbf7e183..d7123e7aba 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -112,10 +112,10 @@ "default": "./esm/index.js" }, "./api": { - "types": "./esm/api/index.d.ts", - "module": "./esm/api/index.js", - "import": "./esm/api/index.js", - "default": "./esm/api/index.js" + "types": "./esm/core/api/index.d.ts", + "module": "./esm/core/api/index.js", + "import": "./esm/core/api/index.js", + "default": "./esm/core/api/index.js" }, "./checkout": { "types": "./esm/checkout/index.d.ts", diff --git a/src/fund/components/FundButton.test.tsx b/src/fund/components/FundButton.test.tsx index fa4a459361..24a1520ea9 100644 --- a/src/fund/components/FundButton.test.tsx +++ b/src/fund/components/FundButton.test.tsx @@ -22,7 +22,7 @@ vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: vi.fn(), })); -describe('WalletDropdownFundLink', () => { +describe('FundButton', () => { afterEach(() => { vi.clearAllMocks(); }); @@ -94,4 +94,19 @@ describe('WalletDropdownFundLink', () => { 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('fundButtonTextContent')).toHaveTextContent('Success'); + }); + + it('displays error text when in error state', () => { + render(); + expect(screen.getByTestId('fundButtonTextContent')).toHaveTextContent('Something went wrong'); + }); +}); \ No newline at end of file diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 51b8f6063a..1c7b2d7c8b 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -1,11 +1,14 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { addSvg } from '../../internal/svg/addSvg'; +import { successSvg } from '../../internal/svg/successSvg'; +import { errorSvg } from '../../internal/svg/errorSvg'; import { openPopup } from '../../internal/utils/openPopup'; import { border, cn, color, pressable, text } from '../../styles/theme'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; +import { Spinner } from '../../internal/components/Spinner'; export function FundButton({ className, @@ -18,6 +21,11 @@ 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 @@ -28,19 +36,31 @@ export function FundButton({ (e: React.MouseEvent) => { e.preventDefault(); if (fundingUrlToRender) { + onClick?.(); const { height, width } = getFundingPopupSize( popupSize, - fundingUrlToRender, + fundingUrlToRender ); - openPopup({ + const popupWindow = openPopup({ url: fundingUrlToRender, height, width, target, }); + + if (!popupWindow) { + return null; + } + + const interval = setInterval(() => { + if (popupWindow?.closed) { + clearInterval(interval); + onPopupClose?.(); + } + }, 500); } }, - [fundingUrlToRender, popupSize, target], + [fundingUrlToRender, popupSize, target, onPopupClose, onClick] ); const classNames = cn( @@ -51,14 +71,41 @@ export function FundButton({ text.headline, border.radius, color.inverse, - className, + className ); + const buttonIcon = useMemo(() => { + switch(buttonState) { + case 'loading': + return ''; + case 'success': + return successSvg + case 'error': + return errorSvg + default: + return addSvg; + } + }, [buttonState]); + + 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 = ( <> + {buttonState === 'loading' && } {/* h-6 is to match the icon height to the line-height set by text.headline */} - {hideIcon || {addSvg}} - {hideText || {buttonText}} + {hideIcon || {buttonIcon}} + {hideText || {buttonTextContent}} ); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 9650f96257..a1c97da690 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,47 +1,181 @@ +import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { background, border, cn, color, text } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; -import { fetchOnrampConfig } from '../utils/fetchOnrampConfig'; -import { fetchOnrampOptions } from '../utils/fetchOnrampOptions'; +import { FundCardProvider } from './FundCardProvider'; +import { useExchangeRate } from '../hooks/useExchangeRate'; +import { useEffect, useMemo } from 'react'; +import { useFundContext } from './FundCardProvider'; +import { FundButton } from './FundButton'; import { FundCardHeader } from './FundCardHeader'; -import { FundForm } from './FundForm'; -import { FundProvider } from './FundProvider'; +import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; +import FundCardAmountInput from './FundCardAmountInput'; +import { ONRAMP_BUY_URL } from '../constants'; +import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; +import type { FundCardPropsReact, PaymentMethodReact } from '../types'; +import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; -type Props = { - assetSymbol: string; - placeholder?: string | React.ReactNode; - headerText?: string; - buttonText?: string; -}; +const defaultPaymentMethods: PaymentMethodReact[] = [ + { + id: 'CRYPTO_ACCOUNT', + name: 'Coinbase', + description: 'Buy with your Coinbase account', + icon: 'coinbasePay', + }, + { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'applePay', + }, + { + id: 'ACH_BANK_ACCOUNT', + name: 'Debit Card', + description: 'Up to $500/week', + icon: 'creditCard', + }, +]; export function FundCard({ assetSymbol, buttonText = 'Buy', headerText, -}: Props) { + amountInputComponent = FundCardAmountInput, + headerComponent = FundCardHeader, + amountInputTypeSwithComponent = FundCardAmountInputTypeSwitch, + paymentMethodSelectorDropdownComponent = FundCardPaymentMethodSelectorDropdown, + paymentMethods = defaultPaymentMethods, + submitButtonComponent = FundButton, +}: FundCardPropsReact) { const componentTheme = useTheme(); return ( -
- - - - +
+ +
+ + ); +} + +export function FundCardContent({ + assetSymbol, + buttonText = 'Buy', + headerText, + amountInputComponent: AmountInputComponent = FundCardAmountInput, + headerComponent: HeaderComponent = FundCardHeader, + amountInputTypeSwithComponent: + AmountInputTypeSwitch = FundCardAmountInputTypeSwitch, + paymentMethodSelectorDropdownComponent: + PaymentMethodSelectorDropdown = FundCardPaymentMethodSelectorDropdown, + paymentMethods = defaultPaymentMethods, + submitButtonComponent: SubmitButton = FundButton, +}: FundCardPropsReact) { + /** + * Fetches and sets the exchange rate for the asset + */ + useExchangeRate(assetSymbol); + + const { + setFundAmountFiat, + fundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + selectedPaymentMethod, + selectedInputType, + exchangeRate, + setSelectedInputType, + selectedAsset, + exchangeRateLoading, + submitButtonLoading, + setSubmitButtonLoading, + } = useFundContext(); + + const fundAmount = + selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; + + const fundingUrl = useMemo(() => { + if (selectedInputType === 'fiat') { + return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x438BbEF3525eF1b0359160FD78AF9c1158485d87":["base"]}&assets=["${assetSymbol}"]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; + } + + return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x438BbEF3525eF1b0359160FD78AF9c1158485d87":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; + }, [assetSymbol, fundAmount, selectedPaymentMethod, selectedInputType]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to run this effect once + useEffect(() => { + setupOnrampEventListeners({ + onEvent: (event) => { + console.log('onEvent', event); + }, + onExit: (event) => { + setSubmitButtonLoading(false); + console.log('onExit', event); + }, + onSuccess: () => { + setSubmitButtonLoading(false); + console.log('onSuccess'); + }, + }); + }, []); + + return ( +
+ + + + + + + - -
+ setSubmitButtonLoading(true)} + onPopupClose={() => setSubmitButtonLoading(false)} + /> + ); } diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx new file mode 100644 index 0000000000..cc041d85fb --- /dev/null +++ b/src/fund/components/FundCardAmountInput.tsx @@ -0,0 +1,187 @@ +import { type ChangeEvent, useEffect, useRef } from 'react'; +import { cn, text } from '../../styles/theme'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import type { FundCardAmountInputPropsReact } from '../types'; + +export const FundCardAmountInput = ({ + fiatValue, + setFiatValue, + cryptoValue, + setCryptoValue, + currencySign, + assetSymbol, + inputType = 'fiat', + exchangeRate, +}: FundCardAmountInputPropsReact) => { + const componentTheme = useTheme(); + + const inputRef = useRef(null); + const hiddenSpanRef = useRef(null); + const currencySpanRef = useRef(null); + + const value = inputType === 'fiat' ? fiatValue : cryptoValue; + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simplifyed the function as much as possible + const handleChange = (e: ChangeEvent) => { + let value = e.target.value; + + value = formatDecimalInputValue(value); + + if (inputType === 'fiat') { + const fiatValue = limitToTwoDecimalPlaces(value); + setFiatValue(fiatValue); + + // Calculate the crypto value based on the exchange rate + const calculatedCryptoValue = String( + Number(value) / Number(exchangeRate || 1) + ); + setCryptoValue( + calculatedCryptoValue === '0' ? '' : calculatedCryptoValue + ); + } else { + setCryptoValue(value); + + // Calculate the fiat value based on the exchange rate + const calculatedFiatValue = String( + Number(value) / Number(exchangeRate || 1) + ); + const resultFiatValue = limitToTwoDecimalPlaces(calculatedFiatValue); + setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width + useEffect(() => { + if (hiddenSpanRef.current) { + const width = + hiddenSpanRef.current.offsetWidth < 42 + ? 42 + : hiddenSpanRef.current.offsetWidth; + const currencyWidth = + currencySpanRef.current?.getBoundingClientRect().width || 0; + + // Set the input width based on the span width + if (inputRef.current) { + inputRef.current.style.width = `${width}px`; + inputRef.current.style.maxWidth = `${390 - currencyWidth}px`; + } + } + }, [value]); + + // 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 + if (inputRef.current) { + inputRef.current.focus(); + } + }, [inputType]); + + return ( +
+ + +
+ {/* Display the fiat currency sign before the input*/} + {inputType === 'fiat' && currencySign && ( + + )} + + + {/* Display the crypto asset symbol after the input*/} + {inputType === 'crypto' && assetSymbol && ( + + )} +
+ + {/* 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; + +/** + * Ensure the decimal value is formatted correctly (i.e. "0.1" instead of ".1" and "0.1" instead of "01") + */ +const formatDecimalInputValue = (value: string) => { + let resultValue = value; + // Add a leading zero if the value starts with a dot. (i.e. ".1" -> "0.1") + if (resultValue[0] === '.') { + resultValue = `0${resultValue}`; + } + + // Add a leading zero if the value starts with a zero and is not a decimal. (i.e. "01" -> "0.1") + if ( + resultValue.length === 2 && + resultValue[0] === '0' && + resultValue[1] !== '.' + ) { + resultValue = `${resultValue[0]}.${resultValue[1]}`; + } + + return resultValue; +}; + +/** + * Limit the value to two decimal places + */ +const limitToTwoDecimalPlaces = (value: string) => { + const decimalIndex = value.indexOf('.'); + let resultValue = value; + if (decimalIndex !== -1 && value.length - decimalIndex - 1 > 2) { + resultValue = value.substring(0, decimalIndex + 3); + } + + return resultValue; +}; diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx new file mode 100644 index 0000000000..97114afb47 --- /dev/null +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -0,0 +1,98 @@ +import { cn, color, pressable, text } from '../../styles/theme'; +import { useCallback, useMemo } from 'react'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import { Skeleton } from '../../internal/components/Skeleton'; +import type { FundCardAmountInputTypeSwitchPropsReact } from '../types'; + +export const FundCardAmountInputTypeSwitch = ({ + selectedInputType, + setSelectedInputType, + selectedAsset, + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + isLoading, +}: FundCardAmountInputTypeSwitchPropsReact) => { + const componentTheme = useTheme(); + + const iconSvg = useIcon({ icon: 'toggle' }); + + const handleToggle = () => { + setSelectedInputType(selectedInputType === 'fiat' ? 'crypto' : 'fiat'); + }; + + const formatUSD = useCallback((amount: string) => { + if (!amount || amount === '0') { + return null; + } + const roundedAmount = Number(getRoundedAmount(amount, 2)); + return `$${roundedAmount.toFixed(2)}`; + }, []); + + const exchangeRateLine = useMemo(() => { + return ( + + ({formatUSD('1')} = {exchangeRate?.toFixed(8)} {selectedAsset}) + + ); + }, [formatUSD, exchangeRate, selectedAsset]); + + const cryptoAmountLine = useMemo(() => { + return ( + + {Number(fundAmountCrypto).toFixed(8)} {selectedAsset} + + ); + }, [fundAmountCrypto, selectedAsset, componentTheme]); + + const fiatAmountLine = useMemo(() => { + return ( + + {formatUSD(fundAmountFiat)} + + ); + }, [formatUSD, fundAmountFiat, componentTheme]); + + if(isLoading || !exchangeRate) { + return + } + + return ( +
+ +
+ {selectedInputType === 'fiat' ? cryptoAmountLine : fiatAmountLine} + + {exchangeRateLine} +
+
+ ); +}; + +const textStyle = { + textOverflow: 'ellipsis', + width: '390px', + overflow: 'hidden', + whiteSpace: 'nowrap', +}; + +export default FundCardAmountInputTypeSwitch; diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx new file mode 100644 index 0000000000..ba257cb84d --- /dev/null +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -0,0 +1,24 @@ +import { forwardRef } from 'react'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { cn, text } from '../../styles/theme'; +import type { FundCardCurrencyLabelPropsReact } from '../types'; + +export const FundCardCurrencyLabel = forwardRef( + ({ currencySign }, ref) => { + const componentTheme = useTheme(); + + return ( + + {currencySign} + + ); + } +); diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx new file mode 100644 index 0000000000..81ba15e391 --- /dev/null +++ b/src/fund/components/FundCardHeader.test.tsx @@ -0,0 +1,41 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { FundCardHeader } from './FundCardHeader'; + +vi.mock('../hooks/useGetFundingUrl', () => ({ + useGetFundingUrl: vi.fn(), +})); + +vi.mock('../utils/getFundingPopupSize', () => ({ + getFundingPopupSize: vi.fn(), +})); + +vi.mock('../../internal/utils/openPopup', () => ({ + openPopup: vi.fn(), +})); + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +describe('FundCardHeader', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders the provided headerText', () => { + render(); + expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Custom Header'); + }); + + it('renders the default header text when headerText is not provided', () => { + render(); + expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Buy ETH'); + }); + + it('converts assetSymbol to uppercase in default header text', () => { + render(); + expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Buy USDT'); + }); +}); \ No newline at end of file diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index 7f2ce178d0..b801a9465c 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -1,12 +1,8 @@ +import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { cn } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; +import type { FundCardHeaderPropsReact } from '../types'; -type Props = { - headerText?: string; - assetSymbol: string; -}; - -export function FundCardHeader({ headerText, assetSymbol }: Props) { +export function FundCardHeader({ headerText, assetSymbol }: FundCardHeaderPropsReact) { const componentTheme = useTheme(); const defaultHeaderText = `Buy ${assetSymbol.toUpperCase()}`; @@ -17,6 +13,7 @@ export function FundCardHeader({ headerText, assetSymbol }: Props) { 'font-display text-[16px]', 'leading-none outline-none' )} + data-testid="fundCardHeader" > {headerText || defaultHeaderText}
diff --git a/src/fund/components/PaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx similarity index 58% rename from src/fund/components/PaymentMethodImage.tsx rename to src/fund/components/FundCardPaymentMethodImage.tsx index 4ba3f4b39d..8298f47e45 100644 --- a/src/fund/components/PaymentMethodImage.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -1,18 +1,12 @@ import { useMemo } from 'react'; import { cn, icon as iconTheme} from '../../styles/theme'; -//import type { TokenImageReact } from '../types'; -// import { getTokenImageColor } from '../utils/getTokenImageColor'; -import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; -import { useIcon } from '../../internal/hooks/useIcon'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import type { FundCardPaymentMethodImagePropsReact } from '../types'; -type Props = { - className?: string; - size?: number; - paymentMethod: PaymentMethod -}; +export function FundCardPaymentMethodImage({ className, size = 24, paymentMethod }: FundCardPaymentMethodImagePropsReact) { + const { icon } = paymentMethod; -export function PaymentMethodImage({ className, size = 24, paymentMethod }: Props) { - const { icon, name } = paymentMethod; + // Special case for coinbasePay icon color const iconColor = icon === 'coinbasePay' ? iconTheme.primary : undefined; const iconSvg = useIcon({ icon, className: iconColor }); @@ -26,7 +20,6 @@ export function PaymentMethodImage({ className, size = 24, paymentMethod }: Prop minHeight: `${size}px`, }, placeholderImage: { - //background: getTokenImageColor(name), width: `${size}px`, height: `${size}px`, minWidth: `${size}px`, @@ -51,12 +44,5 @@ export function PaymentMethodImage({ className, size = 24, paymentMethod }: Prop
{iconSvg}
- // token-image ); } diff --git a/src/fund/components/PaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx similarity index 65% rename from src/fund/components/PaymentMethodSelectRow.tsx rename to src/fund/components/FundCardPaymentMethodSelectRow.tsx index c51d5c179a..a7f43676da 100644 --- a/src/fund/components/PaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -1,25 +1,16 @@ import { memo } from 'react'; import { cn, color, pressable, text } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import type { FundCardPaymentMethodSelectRowPropsReact } from '../types'; -import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; -import { PaymentMethodImage } from './PaymentMethodImage'; - -type Props = { - className?: string; - paymentMethod: PaymentMethod; - onClick?: (paymentMethod: PaymentMethod) => void; - hideImage?: boolean; - hideDescription?: boolean; -} - -export const PaymentMethodSelectRow = memo(({ +export const FundCardPaymentMethodSelectRow = memo(({ className, paymentMethod, onClick, hideImage, hideDescription, -}: Props) => { +}: FundCardPaymentMethodSelectRowPropsReact) => { const componentTheme = useTheme(); return ( @@ -35,7 +26,7 @@ export const PaymentMethodSelectRow = memo(({ onClick={() => onClick?.(paymentMethod)} > - {!hideImage && } + {!hideImage && } {paymentMethod.name} {!hideDescription && ( diff --git a/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx b/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx new file mode 100644 index 0000000000..1e33e269d7 --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx @@ -0,0 +1,90 @@ +import { useCallback, useRef, useState, useEffect } from 'react'; +import { background, border, cn } from '../../styles/theme'; + +import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; +import { FundCardPaymentMethodSelectorToggle } from './FundCardPaymentMethodSelectorToggle'; +import { useFundContext } from './FundCardProvider'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import type { FundCardPaymentMethodSelectorDropdownPropsReact, PaymentMethodReact } from '../types'; + +export function FundCardPaymentMethodSelectorDropdown({ paymentMethods }: FundCardPaymentMethodSelectorDropdownPropsReact) { + const componentTheme = useTheme(); + const [isOpen, setIsOpen] = useState(false); + + const { + selectedPaymentMethod, + setSelectedPaymentMethod, + } = useFundContext(); + + const handlePaymentMethodSelect = useCallback((paymentMethod: PaymentMethodReact) => { + setSelectedPaymentMethod(paymentMethod); + setIsOpen(false); + }, [setSelectedPaymentMethod]); + + const handleToggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + + const handleBlur = useCallback((event: MouseEvent) => { + const isOutsideDropdown = + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node); + const isOutsideButton = + buttonRef.current && !buttonRef.current.contains(event.target as Node); + + if (isOutsideDropdown && isOutsideButton) { + setIsOpen(false); + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: This useEffect is only called once + useEffect(() => { + setSelectedPaymentMethod(paymentMethods[0]); + }, []); + + useEffect(() => { + // Add event listener for clicks + document.addEventListener('click', handleBlur); + return () => { + // Clean up the event listener + document.removeEventListener('click', handleBlur); + }; + }, [handleBlur]); + + return ( +
+ + {isOpen && ( +
+
+ {paymentMethods.map((paymentMethod) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/fund/components/PaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx similarity index 70% rename from src/fund/components/PaymentMethodSelectorToggle.tsx rename to src/fund/components/FundCardPaymentMethodSelectorToggle.tsx index efca60daa5..bf528d535e 100644 --- a/src/fund/components/PaymentMethodSelectorToggle.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -2,18 +2,11 @@ import { type ForwardedRef, forwardRef } from 'react'; import { caretDownSvg } from '../../internal/svg/caretDownSvg'; import { caretUpSvg } from '../../internal/svg/caretUpSvg'; import { border, cn, color, pressable, text } from '../../styles/theme'; -import { PaymentMethodImage } from './PaymentMethodImage'; -import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; +import type { FundCardPaymentMethodSelectorTogglePropsReact } from '../types'; -type Props = { - className?: string; - isOpen: boolean; // Determines carot icon direction - onClick: () => void; // Button on click handler - paymentMethod: PaymentMethod -} - -export const PaymentMethodSelectorToggle = forwardRef(( - { onClick, paymentMethod, isOpen, className }: Props, +export const FundCardPaymentMethodSelectorToggle = forwardRef(( + { onClick, paymentMethod, isOpen, className }: FundCardPaymentMethodSelectorTogglePropsReact, ref: ForwardedRef, ) => { return ( @@ -33,7 +26,7 @@ export const PaymentMethodSelectorToggle = forwardRef(( {paymentMethod ? ( <>
- +
void; + 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; + submitButtonLoading?: boolean; + setSubmitButtonLoading: (loading: boolean) => void; +}; + +const initialState = {} as FundCardContextType; + +const FundContext = createContext(initialState); + +export function FundCardProvider({ children, asset }: FundCardProviderReact) { + const [selectedAsset, setSelectedAsset] = useState(asset); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(); + const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>('fiat'); + const [fundAmountFiat, setFundAmountFiat] = useState(''); + const [fundAmountCrypto, setFundAmountCrypto] = useState(''); + const [exchangeRate, setExchangeRate] = useState(); + const [exchangeRateLoading, setExchangeRateLoading] = useState(); + const [submitButtonLoading, setSubmitButtonLoading] = useState(); + + const value = useValue({ + selectedAsset, + setSelectedAsset, + selectedPaymentMethod, + setSelectedPaymentMethod, + fundAmountFiat, + setFundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + selectedInputType, + setSelectedInputType, + exchangeRate, + setExchangeRate, + exchangeRateLoading, + setExchangeRateLoading, + submitButtonLoading, + setSubmitButtonLoading, + }); + return {children}; +} + +export function useFundContext() { + const context = useContext(FundContext); + + if (context === undefined) { + throw new Error('useFundContext must be used within a FundCardProvider'); + } + + return context; +} diff --git a/src/fund/components/FundForm.tsx b/src/fund/components/FundForm.tsx deleted file mode 100644 index ea527c5148..0000000000 --- a/src/fund/components/FundForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { type ChangeEvent, useEffect, useMemo, useRef } from 'react'; -import { border, cn, text } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; -import { useFundContext } from './FundProvider'; -import { FundButton } from './FundButton'; -import { FundCardHeader } from './FundCardHeader'; -import { PaymentMethodSelectorDropdown } from './PaymentMethodSelectorDropdown'; -import FundFormAmountInput from './FundFormAmountInput'; -import FundFormAmountInputTypeSwitch from './FundFormAmountInputTypeSwitch'; -import { ONRAMP_BUY_URL } from '../constants'; - -type Props = { - assetSymbol: string; - placeholder?: string | React.ReactNode; - headerText?: string; - buttonText?: string; -}; - -export function FundForm({ - assetSymbol, - buttonText = 'Buy', - headerText, -}: Props) { - const { setFundAmount, fundAmount, selectedPaymentMethod } = useFundContext(); - - const fundingUrl = useMemo(() => { - return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["${assetSymbol}"]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; - }, [assetSymbol, fundAmount, selectedPaymentMethod]); - - // https://pay.coinbase.com/buy/one-click?appId=9f59d35a-b36b-4395-ae75-560bb696bfe6&addresses={"0x3bD7802fD4C3B01dB0767e532fB96AdBa7cd5F14":["base"]}&assets=["USDC"]&presetCryptoAmount=5 - - return ( -
- - - - - - - - - ); -} \ No newline at end of file diff --git a/src/fund/components/FundFormAmountInput.tsx b/src/fund/components/FundFormAmountInput.tsx deleted file mode 100644 index 121eb3303b..0000000000 --- a/src/fund/components/FundFormAmountInput.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { - type ChangeEvent, - useEffect, - useMemo, - useRef, -} from 'react'; -import { border, cn, text } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; -import { useFundContext } from './FundProvider'; -import { FundButton } from './FundButton'; -import { FundCardHeader } from './FundCardHeader'; -import { PaymentMethodSelectorDropdown } from './PaymentMethodSelectorDropdown'; - -type Props = { - value: string; - setValue: (s: string) => void; - currencySign?: string; -}; - -export const FundFormAmountInput = ({ - value, - setValue, - currencySign, -}: Props) => { - const componentTheme = useTheme(); - - const inputRef = useRef(null); - const spanRef = useRef(null); - const previousValueRef = useRef(''); - - const handleChange = (e: ChangeEvent) => { - let value = e.target.value; - /** - * Only allow numbers to be entered into the input - * Using type="number" on the input does not work because it adds a spinner which does not get removed with css '-webkit-appearance': 'none' - */ - if (/^\d*\.?\d*$/.test(value)) { - if (value.length === 1 && value === '.') { - value = '0.'; - } else if (value.length === 1 && value === '0') { - if (previousValueRef.current.length <= value.length) { - // Add a dot if the user types a single zero - value = '0.'; - } else { - value = ''; - } - } else if ( - value[value.length - 1] === '.' && - previousValueRef.current.length >= value.length - ) { - // If we are deleting a character and the last character is a dot, remove it - value = value.slice(0, -1); - } else if (value.length === 2 && value[0] === '0' && value[1] !== '.') { - // Add a dot in case there is a leading zero - value = `${value[0]}.${value[1]}`; - } - - setValue(value); - } - // Update the previous value - previousValueRef.current = value; - }; - - const fontSize = useMemo(() => { - if (value.length < 2) { - return 60; - } - return 60 - Math.min(value.length * 2.5, 40); - }, [value]); - - // useEffect(() => { - // // Update the input width based on the hidden span's width - // if (spanRef.current && inputRef.current) { - // if (inputRef.current?.style?.width) { - // inputRef.current.style.width = - // value.length === 1 - // ? `${spanRef.current?.offsetWidth}px` - // : `${spanRef.current?.offsetWidth + 10}px`; - // } - // } - // }, [value]); - - return ( -
- {currencySign && ( - - {currencySign} - - )} - - {/* Hidden span to measure content width */} - {/* - {value || '0'} - */} -
- ); -}; - -export default FundFormAmountInput; diff --git a/src/fund/components/FundFormAmountInputTypeSwitch.tsx b/src/fund/components/FundFormAmountInputTypeSwitch.tsx deleted file mode 100644 index 2226503cb8..0000000000 --- a/src/fund/components/FundFormAmountInputTypeSwitch.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { cn, pressable } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; -import { useIcon } from '../../internal/hooks/useIcon'; -import { useFundContext } from './FundProvider'; -import { getRoundedAmount } from '../../internal/utils/getRoundedAmount'; -import { useCallback, useMemo } from 'react'; - -export const FundFormAmountInputTypeSwitch = () => { - const componentTheme = useTheme(); - const { selectedInputType, setSelectedInputType, selectedAsset, fundAmount } = - useFundContext(); - - const iconSvg = useIcon({ icon: 'toggle' }); - - const handleToggle = () => { - console.log('Toggle'); - }; - - const formatUSD = useCallback((amount: string) => { - if (!amount || amount === '0') { - return null; - } - const roundedAmount = Number(getRoundedAmount(amount, 2)); - return `$${roundedAmount.toFixed(2)}`; - }, []); - - const exchangeRate = useMemo(() => { - return `(${formatUSD('1')} = 1 USDC)` - }, [formatUSD]); - - return ( -
- -
- {selectedInputType === 'fiat' ? ( - - {formatUSD(fundAmount)} {exchangeRate} - - ) : ( - - 10 {selectedAsset} ($1 = 1 USDC) - - )} -
-
- ); -}; - -export default FundFormAmountInputTypeSwitch; diff --git a/src/fund/components/FundProvider.tsx b/src/fund/components/FundProvider.tsx deleted file mode 100644 index d4580f5156..0000000000 --- a/src/fund/components/FundProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { createContext, useContext, useState } from 'react'; -import type { ReactNode } from 'react'; -import { useValue } from '../../internal/hooks/useValue'; -import type { PaymentMethod } from './PaymentMethodSelectorDropdown'; - -type FundContextType = { - selectedAsset?: string; - setSelectedAsset: (asset: string) => void; - selectedPaymentMethod?: PaymentMethod - setSelectedPaymentMethod: (paymentMethod: PaymentMethod) => void; - selectedInputType?: 'fiat' | 'crypto'; - setSelectedInputType: (inputType: 'fiat' | 'crypto') => void; - fundAmount: string; - setFundAmount: (amount: string) => void; -}; - -const initialState = {} as FundContextType; - -const FundContext = createContext(initialState); - -type FundProviderReact = { - children: ReactNode; - asset: string; -}; - -export function FundProvider({ children, asset }: FundProviderReact) { - const [selectedAsset, setSelectedAsset] = useState(asset); - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(); - const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>('fiat'); - const [fundAmount, setFundAmount] = useState(''); - - const value = useValue({ - selectedAsset, - setSelectedAsset, - selectedPaymentMethod, - setSelectedPaymentMethod, - fundAmount, - setFundAmount, - selectedInputType, - setSelectedInputType, - }); - return {children}; -} - -export function useFundContext() { - const context = useContext(FundContext); - - if (context === undefined) { - throw new Error('useFundContext must be used within a FundProvider'); - } - - return context; -} diff --git a/src/fund/components/PaymentMethodSelectorDropdown.tsx b/src/fund/components/PaymentMethodSelectorDropdown.tsx deleted file mode 100644 index d761f1d29e..0000000000 --- a/src/fund/components/PaymentMethodSelectorDropdown.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { background, border, cn } from '../../styles/theme'; -import { useTheme } from '../../useTheme'; -import { PaymentMethodSelectRow } from './PaymentMethodSelectRow'; -import { PaymentMethodSelectorToggle } from './PaymentMethodSelectorToggle'; -import { useFundContext } from './FundProvider'; - -export type PaymentMethod = { - id: 'CRYPTO_ACCOUNT' | 'FIAT_WALLET' | 'CARD' | 'ACH_BANK_ACCOUNT' | 'APPLE_PAY'; - name: string; - description: string; - icon: string; -}; - -type Props = { - paymentMethods: PaymentMethod[] -} - -export function PaymentMethodSelectorDropdown({ - paymentMethods, -}: Props) { - const componentTheme = useTheme(); - - const [isOpen, setIsOpen] = useState(false); - const { setFundAmount, fundAmount, selectedPaymentMethod, setSelectedPaymentMethod } = useFundContext(); - - const handleToggle = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); - - const dropdownRef = useRef(null); - const buttonRef = useRef(null); - - /* v8 ignore next 11 */ - const handleBlur = useCallback((event: { target: Node; }) => { - const isOutsideDropdown = - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node); - const isOutsideButton = - buttonRef.current && !buttonRef.current.contains(event.target as Node); - - if (isOutsideDropdown && isOutsideButton) { - setIsOpen(false); - } - }, []); - - // useEffect(() => { - // // NOTE: this ensures that handleBlur doesn't get called on initial mount - // // We need to use non-div elements to properly handle onblur events - // setTimeout(() => { - // document.addEventListener('click', handleBlur); - // }, 0); - - // return () => { - // document.removeEventListener('click', handleBlur); - // }; - // }, [handleBlur]); - - return ( -
- - - {isOpen && ( -
-
- - {paymentMethods.map((paymentMethod) => ( - { - setSelectedPaymentMethod(paymentMethod); - handleToggle(); - }} - /> - ))} -
-
- )} -
- ); -} diff --git a/src/fund/hooks/useExchangeRate.ts b/src/fund/hooks/useExchangeRate.ts new file mode 100644 index 0000000000..f5e500fb1c --- /dev/null +++ b/src/fund/hooks/useExchangeRate.ts @@ -0,0 +1,34 @@ +import { useEffect, useMemo } from 'react'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; +import { useDebounce } from '../../core-react/internal/hooks/useDebounce'; +import { useFundContext } from '../components/FundCardProvider'; + +export const useExchangeRate = (assetSymbol: string) => { + const { setExchangeRate, exchangeRate, exchangeRateLoading, setExchangeRateLoading } = useFundContext(); + + const fetchExchangeRate = useDebounce(async () => { + if(exchangeRateLoading) { + return; + } + + setExchangeRateLoading(true); + const quote = await fetchOnrampQuote({ + purchaseCurrency: assetSymbol, + paymentCurrency: 'USD', + paymentAmount: '100', + paymentMethod: 'CARD', + country: 'US', + }); + + setExchangeRateLoading(false); + console.log('quote', quote); + setExchangeRate(Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value)); + }, 1000); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + fetchExchangeRate(); + }, []); + + return useMemo(() => ({ exchangeRate, fetchExchangeRate }), [exchangeRate, fetchExchangeRate]); +}; diff --git a/src/fund/index.ts b/src/fund/index.ts index 79073b5765..921e6b076d 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,6 +1,5 @@ export { FundButton } from './components/FundButton'; -export { FundProvider } from './components/FundProvider'; -export { FundForm } from './components/FundForm'; +export { FundCardProvider } from './components/FundCardProvider'; export { FundCard } from './components/FundCard'; export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; diff --git a/src/fund/types.ts b/src/fund/types.ts index 718cf0cfc5..61790fbfe4 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -1,3 +1,5 @@ +import { 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 @@ -106,6 +108,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?: 'default' | 'success' | 'error' | 'loading'; // 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,6 +122,8 @@ 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 }; /** @@ -196,7 +203,7 @@ export type OnrampTransactionStatusName = | 'ONRAMP_TRANSACTION_STATUS_FAILED'; export type OnrampAmount = { - amount: string; + value: string; currency: string; }; @@ -247,3 +254,111 @@ export type OnrampPaymentCurrency = { id: string; paymentMethodLimits: OnrampPaymentMethodLimit[]; }; + +export type FundCardAmountInputPropsReact = { + fiatValue: string; + setFiatValue: (s: string) => void; + cryptoValue: string; + setCryptoValue: (s: string) => void; + currencySign?: string; + assetSymbol?: string; + inputType?: 'fiat' | 'crypto'; + exchangeRate?: number; +}; + +export type FundCardAmountInputTypeSwitchPropsReact = { + selectedInputType?: 'fiat' | 'crypto'; + setSelectedInputType: (inputType: 'fiat' | 'crypto') => void; + selectedAsset?: string; + fundAmountFiat: string; + fundAmountCrypto: string; + exchangeRate?: number; + isLoading?: boolean; +}; + +export type FundCardHeaderPropsReact = { + headerText?: string; + assetSymbol: string; +}; + +export type FundCardPaymentMethodImagePropsReact = { + className?: string; + size?: number; + paymentMethod: PaymentMethodReact +}; + +export type PaymentMethodReact = { + id: + | 'CRYPTO_ACCOUNT' + | 'FIAT_WALLET' + | 'CARD' + | 'ACH_BANK_ACCOUNT' + | 'APPLE_PAY'; + name: string; + description: string; + icon: string; +}; + +export type FundCardPaymentMethodSelectorDropdownPropsReact = { + paymentMethods: PaymentMethodReact[]; +}; + + +export type FundCardCurrencyLabelPropsReact = { + currencySign: string; +}; + +export type FundCardPropsReact = { + assetSymbol: string; + placeholder?: string | React.ReactNode; + headerText?: string; + buttonText?: string; + /** + * Custom component for the amount input + */ + amountInputComponent?: React.ComponentType; + /** + * Custom component for the header + */ + headerComponent?: React.ComponentType; + + /** + * Custom component for the amount input type switch + */ + amountInputTypeSwithComponent?: React.ComponentType; + + /** + * Custom component for the payment method selector dropdown + */ + paymentMethodSelectorDropdownComponent?: React.ComponentType; + + /** + * Custom component for the submit button + */ + submitButtonComponent?: React.ComponentType; + + /** + * Payment methods to display in the dropdown + */ + paymentMethods?: PaymentMethodReact[]; +}; + +export type FundCardPaymentMethodSelectorTogglePropsReact = { + className?: string; + isOpen: boolean; // Determines carot icon direction + onClick: () => void; // Button on click handler + paymentMethod: PaymentMethodReact +} + +export type FundCardPaymentMethodSelectRowPropsReact = { + className?: string; + paymentMethod: PaymentMethodReact; + onClick?: (paymentMethod: PaymentMethodReact) => void; + hideImage?: boolean; + hideDescription?: boolean; +} + +export type FundCardProviderReact = { + children: ReactNode; + asset: string; +}; diff --git a/src/fund/utils/fetchOnrampQuote.ts b/src/fund/utils/fetchOnrampQuote.ts index 9d5d3f8e09..5d6b0300a2 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/subscribeToWindowMessage.ts b/src/fund/utils/subscribeToWindowMessage.ts index 55edd8edaf..a0399f1090 100644 --- a/src/fund/utils/subscribeToWindowMessage.ts +++ b/src/fund/utils/subscribeToWindowMessage.ts @@ -10,15 +10,8 @@ export enum MessageCodes { Event = 'event', } -type MessageCode = `${MessageCodes}`; - type MessageData = JsonObject; -type PostMessageData = { - eventName: MessageCode; - data?: MessageData; -}; - /** * Subscribes to a message from the parent window. * @param messageCode A message code to subscribe to. @@ -36,12 +29,12 @@ export function subscribeToWindowMessage({ allowedOrigin: string; onValidateOrigin?: (origin: string) => Promise; }) { - const handleMessage = (event: MessageEvent) => { + const handleMessage = (event: MessageEvent) => { if (!isAllowedOrigin({ event, allowedOrigin })) { return; } - const { eventName, data } = event.data; + const { eventName, data } = JSON.parse(event.data); if (eventName === 'event') { (async () => { diff --git a/src/internal/components/Skeleton.tsx b/src/internal/components/Skeleton.tsx new file mode 100644 index 0000000000..7a868286c9 --- /dev/null +++ b/src/internal/components/Skeleton.tsx @@ -0,0 +1,19 @@ +import { background, cn } from '../../styles/theme'; + +type SkeletonReact = { + className?: string; +}; + +export function Skeleton({ className }: SkeletonReact) { + return ( +
+ ); +} diff --git a/src/internal/utils/openPopup.ts b/src/internal/utils/openPopup.ts index 4b5ff6a7ca..e90acd4d2d 100644 --- a/src/internal/utils/openPopup.ts +++ b/src/internal/utils/openPopup.ts @@ -14,5 +14,5 @@ export function openPopup({ url, target, height, width }: OpenPopupProps) { const top = Math.round((window.screen.height - height) / 2); const windowFeatures = `width=${width},height=${height},resizable,scrollbars=yes,status=1,left=${left},top=${top}`; - window.open(url, target, windowFeatures); + return window.open(url, target, windowFeatures); } From 6ee71435a50f0fdf40fe82543e5219fdc385d160 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 16 Dec 2024 21:41:25 -0800 Subject: [PATCH 23/91] Fund card implementation --- .../internal/hooks/useIcon.test.tsx | 4 +- src/fund/components/FundButton.test.tsx | 45 +++++++ src/fund/components/FundButton.tsx | 35 +++-- src/fund/components/FundCard.test.tsx | 126 ++++++++++++++++++ src/fund/components/FundCard.tsx | 31 +++-- .../components/FundCardAmountInput.test.tsx | 93 +++++++++++++ src/fund/components/FundCardAmountInput.tsx | 28 ++-- .../FundCardAmountInputTypeSwitch.test.tsx | 104 +++++++++++++++ .../components/FundCardCurrencyLabel.test.tsx | 23 ++++ src/fund/components/FundCardCurrencyLabel.tsx | 38 +++--- src/fund/components/FundCardHeader.test.tsx | 12 -- .../FundCardPaymentMethodImage.test.tsx | 45 +++++++ .../components/FundCardPaymentMethodImage.tsx | 4 +- .../FundCardPaymentMethodSelectRow.tsx | 2 +- ...CardPaymentMethodSelectorDropdown.test.tsx | 116 ++++++++++++++++ .../FundCardPaymentMethodSelectorDropdown.tsx | 4 +- .../FundCardPaymentMethodSelectorToggle.tsx | 83 ++++++------ src/fund/components/FundCardProvider.tsx | 18 ++- src/fund/components/FundProvider.test.tsx | 73 +++++++--- src/fund/constants.ts | 3 +- src/fund/hooks/useExchangeRate.test.ts | 78 +++++++++++ src/fund/hooks/useExchangeRate.ts | 16 ++- src/fund/types.ts | 4 +- src/fund/utils/fetchOnrampQuote.test.ts | 22 ++- src/internal/components/Skeleton.tsx | 1 + src/internal/hooks/useIcon.test.tsx | 58 ++++++++ src/internal/hooks/useIcon.tsx | 35 +++++ src/internal/svg/addSvg.tsx | 6 +- src/internal/svg/errorSvg.tsx | 6 +- src/internal/svg/successSvg.tsx | 6 +- src/styles/index.css | 2 +- src/styles/theme.ts | 2 + src/swap/components/SwapToast.tsx | 4 +- .../components/TransactionToastIcon.tsx | 8 +- 34 files changed, 966 insertions(+), 169 deletions(-) create mode 100644 src/fund/components/FundCard.test.tsx create mode 100644 src/fund/components/FundCardAmountInput.test.tsx create mode 100644 src/fund/components/FundCardAmountInputTypeSwitch.test.tsx create mode 100644 src/fund/components/FundCardCurrencyLabel.test.tsx create mode 100644 src/fund/components/FundCardPaymentMethodImage.test.tsx create mode 100644 src/fund/components/FundCardPaymentMethodSelectorDropdown.test.tsx create mode 100644 src/fund/hooks/useExchangeRate.test.ts create mode 100644 src/internal/hooks/useIcon.test.tsx create mode 100644 src/internal/hooks/useIcon.tsx diff --git a/src/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index 6c7f7a7595..2784331675 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +import { CoinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; import { fundWalletSvg } from '../../../internal/svg/fundWallet'; import { swapSettingsSvg } from '../../../internal/svg/swapSettings'; import { walletSvg } from '../../../internal/svg/walletSvg'; @@ -19,7 +19,7 @@ describe('useIcon', () => { it('should return coinbasePaySvg when icon is "coinbasePay"', () => { const { result } = renderHook(() => useIcon({ icon: 'coinbasePay' })); - expect(result.current).toBe(coinbasePaySvg); + expect(result.current).toBe(CoinbasePaySvg); }); it('should return fundWalletSvg when icon is "fundWallet"', () => { diff --git a/src/fund/components/FundButton.test.tsx b/src/fund/components/FundButton.test.tsx index 24a1520ea9..63b773d6d4 100644 --- a/src/fund/components/FundButton.test.tsx +++ b/src/fund/components/FundButton.test.tsx @@ -109,4 +109,49 @@ describe('FundButton', () => { render(); expect(screen.getByTestId('fundButtonTextContent')).toHaveTextContent('Something went wrong'); }); + + 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); + + // Simulate closing the popup + mockPopupWindow.closed = true; + vi.runOnlyPendingTimers(); + + expect(onPopupClose).toHaveBeenCalled(); + }); + + 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(); + + 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, + }); + }); }); \ No newline at end of file diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 1c7b2d7c8b..3888e1c9cc 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -1,10 +1,10 @@ import { useCallback, useMemo } from 'react'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; -import { addSvg } from '../../internal/svg/addSvg'; -import { successSvg } from '../../internal/svg/successSvg'; -import { errorSvg } from '../../internal/svg/errorSvg'; +import { AddSvg } from '../../internal/svg/addSvg'; +import { SuccessSvg } from '../../internal/svg/successSvg'; +import { ErrorSvg } from '../../internal/svg/errorSvg'; import { openPopup } from '../../internal/utils/openPopup'; -import { border, cn, color, pressable, text } from '../../styles/theme'; +import { border, cn, color, icon, pressable, text } from '../../styles/theme'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; @@ -63,9 +63,21 @@ export function FundButton({ [fundingUrlToRender, popupSize, target, onPopupClose, onClick] ); + const buttonColorClass = useMemo(() => { + switch (buttonState) { + case 'error': + return pressable.error; + case 'loading': + case 'success': + return pressable.primary; + default: + return pressable.primary; + } + }, [buttonState]); + const classNames = cn( componentTheme, - pressable.primary, + buttonColorClass, 'px-4 py-3 inline-flex items-center justify-center space-x-2 disabled', isDisabled && pressable.disabled, text.headline, @@ -75,17 +87,20 @@ export function FundButton({ ); const buttonIcon = useMemo(() => { + if (hideIcon) { + return null; + } switch(buttonState) { case 'loading': return ''; case 'success': - return successSvg + return ; case 'error': - return errorSvg + return ; default: - return addSvg; + return ; } - }, [buttonState]); + }, [buttonState, hideIcon]); const buttonTextContent = useMemo(() => { switch(buttonState) { @@ -104,7 +119,7 @@ export function FundButton({ <> {buttonState === 'loading' && } {/* h-6 is to match the icon height to the line-height set by text.headline */} - {hideIcon || {buttonIcon}} + {buttonIcon && {buttonIcon}} {hideText || {buttonTextContent}} ); diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx new file mode 100644 index 0000000000..cb60b8fdfe --- /dev/null +++ b/src/fund/components/FundCard.test.tsx @@ -0,0 +1,126 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FundCard } from './FundCard'; +import { FundCardProvider } from './FundCardProvider'; +import type { FundCardPropsReact } from '../types'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: () => 'mocked-theme-class', +})); + +vi.mock('../hooks/useExchangeRate', () => ({ + useExchangeRate: () => {}, +})); + +vi.mock('../utils/setupOnrampEventListeners', () => ({ + setupOnrampEventListeners: vi.fn(), +})); + +describe('FundCard', () => { + const defaultProps: FundCardPropsReact = { + assetSymbol: 'BTC', + buttonText: 'Buy BTC', + headerText: 'Fund Your Account', + }; + + const renderComponent = (props = defaultProps) => + render( + + + + ); + + it('renders without crashing', () => { + renderComponent(); + expect(screen.getByText('Fund Your Account')).toBeInTheDocument(); + expect(screen.getByText('Buy BTC')).toBeInTheDocument(); + }); + + it('displays the correct header text', () => { + renderComponent(); + expect(screen.getByText('Fund Your Account')).toBeInTheDocument(); + }); + + it('displays the correct button text', () => { + renderComponent(); + expect(screen.getByRole('button', { name: 'Buy BTC' })).toBeInTheDocument(); + }); + + it('handles input changes for fiat amount', () => { + renderComponent(); + const input = screen.getByPlaceholderText('$') as HTMLInputElement; + fireEvent.change(input, { target: { value: '100' } }); + expect(input.value).toBe('100'); + }); + + it('switches input type from fiat to crypto', () => { + renderComponent(); + const switchButton = screen.getByRole('button', { name: /switch to crypto/i }); + fireEvent.click(switchButton); + expect(screen.getByPlaceholderText('BTC')).toBeInTheDocument(); + }); + + it('selects a payment method', () => { + renderComponent(); + const dropdown = screen.getByRole('button', { name: /select payment method/i }); + fireEvent.click(dropdown); + const option = screen.getByText('Coinbase'); + fireEvent.click(option); + expect(screen.getByText('Coinbase')).toBeInTheDocument(); + }); + + it('disables the submit button when fund amount is zero', () => { + renderComponent(); + const button = screen.getByRole('button', { name: 'Buy BTC' }); + expect(button).toBeDisabled(); + }); + + it('enables the submit button when fund amount is greater than zero', () => { + renderComponent(); + const input = screen.getByPlaceholderText('$') as HTMLInputElement; + fireEvent.change(input, { target: { value: '100' } }); + const button = screen.getByRole('button', { name: 'Buy BTC' }); + expect(button).not.toBeDisabled(); + }); + + it('shows loading state when submitting', async () => { + renderComponent(); + const input = screen.getByPlaceholderText('$') as HTMLInputElement; + fireEvent.change(input, { target: { value: '100' } }); + const button = screen.getByRole('button', { name: 'Buy BTC' }); + + fireEvent.click(button); + expect(button).toHaveAttribute('state', 'loading'); + + await waitFor(() => { + expect(button).toHaveAttribute('state', 'default'); + }); + }); + + it('handles funding URL correctly for fiat input', () => { + renderComponent(); + const input = screen.getByPlaceholderText('$') as HTMLInputElement; + fireEvent.change(input, { target: { value: '100' } }); + const button = screen.getByRole('button', { name: 'Buy BTC' }); + + expect(button).toHaveAttribute('href'); + }); + + it('handles funding URL correctly for crypto input', () => { + renderComponent(); + const switchButton = screen.getByRole('button', { name: /switch to crypto/i }); + fireEvent.click(switchButton); + const input = screen.getByPlaceholderText('BTC') as HTMLInputElement; + fireEvent.change(input, { target: { value: '0.005' } }); + const button = screen.getByRole('button', { name: 'Buy BTC' }); + + expect(button).toHaveAttribute('href'); + }); + + it('calls onEvent, onExit, and onSuccess from setupOnrampEventListeners', () => { + const { setupOnrampEventListeners } = require('../utils/setupOnrampEventListeners'); + renderComponent(); + expect(setupOnrampEventListeners).toHaveBeenCalled(); + }); +}); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index a1c97da690..f6ad5527bd 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -8,7 +8,7 @@ import { FundButton } from './FundButton'; import { FundCardHeader } from './FundCardHeader'; import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import FundCardAmountInput from './FundCardAmountInput'; -import { ONRAMP_BUY_URL } from '../constants'; +import { FUND_BUTTON_RESET_TIMEOUT, ONRAMP_BUY_URL } from '../constants'; import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; import type { FundCardPropsReact, PaymentMethodReact } from '../types'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; @@ -107,8 +107,8 @@ export function FundCardContent({ setSelectedInputType, selectedAsset, exchangeRateLoading, - submitButtonLoading, - setSubmitButtonLoading, + submitButtonState, + setSubmitButtonState, } = useFundContext(); const fundAmount = @@ -126,15 +126,24 @@ export function FundCardContent({ useEffect(() => { setupOnrampEventListeners({ onEvent: (event) => { - console.log('onEvent', event); + if (event.eventName === 'error') { + setSubmitButtonState('error'); + + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); + } }, onExit: (event) => { - setSubmitButtonLoading(false); + setSubmitButtonState('default'); console.log('onExit', event); }, onSuccess: () => { - setSubmitButtonLoading(false); - console.log('onSuccess'); + setSubmitButtonState('success'); + + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); }, }); }, []); @@ -168,13 +177,13 @@ export function FundCardContent({ setSubmitButtonLoading(true)} - onPopupClose={() => setSubmitButtonLoading(false)} + state={submitButtonState} + onClick={() => setSubmitButtonState('loading')} + onPopupClose={() => setSubmitButtonState('default')} /> ); diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx new file mode 100644 index 0000000000..c6695445ac --- /dev/null +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FundCardAmountInput } from './FundCardAmountInput'; +import type { FundCardAmountInputPropsReact } from '../types'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: () => 'mocked-theme-class', +})); + +describe('FundCardAmountInput', () => { + const defaultProps = { + fiatValue: '100', + setFiatValue: vi.fn(), + cryptoValue: '0.05', + setCryptoValue: vi.fn(), + currencySign: '$', + assetSymbol: 'ETH', + inputType: 'fiat', + exchangeRate: '2', + } as unknown as FundCardAmountInputPropsReact; + + it('renders correctly with fiat input type', () => { + render(); + expect(screen.getByPlaceholderText('0')).toBeInTheDocument(); + expect(screen.getByText('$')).toBeInTheDocument(); + }); + + it('renders correctly with crypto input type', () => { + render(); + expect(screen.getByText('ETH')).toBeInTheDocument(); + }); + + it('handles fiat input change', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '10' } }); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('10'); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('5'); + }); + + it('handles crypto input change', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '0.1' } }); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.1'); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.05'); + }); + + it('formats input value correctly when starting with a dot', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '.5' } }); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.5'); + }); + + it('formats input value correctly when starting with zero', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '01' } }); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.1'); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.05'); + }); + + it('limits decimal places to two', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '123.456' } }); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('123.45'); + }); + + it('focuses input when input type changes', () => { + const { rerender } = render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const focusSpy = vi.spyOn(input, 'focus'); + rerender(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('does not set crypto value to "0" when input is "0"', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '0' } }); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith(''); + }); + + it('does not set fiat value to "0" when crypto input is "0"', () => { + render(); + const input = screen.getByPlaceholderText('0') as HTMLInputElement; + fireEvent.change(input, { target: { value: '0' } }); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith(''); + }); +}); diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index cc041d85fb..5c8d420c4e 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -1,4 +1,4 @@ -import { type ChangeEvent, useEffect, useRef } from 'react'; +import { type ChangeEvent, useCallback, useEffect, useRef } from 'react'; import { cn, text } from '../../styles/theme'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; @@ -23,7 +23,7 @@ export const FundCardAmountInput = ({ const value = inputType === 'fiat' ? fiatValue : cryptoValue; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simplifyed the function as much as possible - const handleChange = (e: ChangeEvent) => { + const handleChange = useCallback((e: ChangeEvent) => { let value = e.target.value; value = formatDecimalInputValue(value); @@ -49,15 +49,15 @@ export const FundCardAmountInput = ({ const resultFiatValue = limitToTwoDecimalPlaces(calculatedFiatValue); setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); } - }; + },[exchangeRate, setFiatValue, setCryptoValue, inputType]); // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width useEffect(() => { - if (hiddenSpanRef.current) { - const width = - hiddenSpanRef.current.offsetWidth < 42 - ? 42 - : hiddenSpanRef.current.offsetWidth; + if (hiddenSpanRef.current) { // istanbul ignore next + const width =// istanbul ignore next + hiddenSpanRef.current.offsetWidth < 42// istanbul ignore next + ? 42// istanbul ignore next + : hiddenSpanRef.current.offsetWidth; // istanbul ignore next const currencyWidth = currencySpanRef.current?.getBoundingClientRect().width || 0; @@ -97,7 +97,10 @@ export const FundCardAmountInput = ({
{/* Display the fiat currency sign before the input*/} {inputType === 'fiat' && currencySign && ( - + )} {/* Display the crypto asset symbol after the input*/} {inputType === 'crypto' && assetSymbol && ( - + )}
@@ -129,6 +136,7 @@ export const FundCardAmountInput = ({ [0.12][ETH] - Now the currency symbol is displayed next to the input field */} ({ + useTheme: () => 'mocked-theme-class', +})); + +describe('FundCardAmountInputTypeSwitch', () => { + it('renders crypto amount when selectedInputType is fiat', () => { + render( + + ); + expect(screen.getByText('200.00000000 ETH')).toBeInTheDocument(); + }); + + it('renders fiat amount when selectedInputType is crypto', () => { + render( + + ); + expect(screen.getByText('$100.00')).toBeInTheDocument(); + }); + + it('toggles input type on button click', () => { + const setSelectedInputType = vi.fn(); + render( + + ); + fireEvent.click(screen.getByLabelText(/amount type switch/i)); + expect(setSelectedInputType).toHaveBeenCalledWith('crypto'); + }); + + it('toggles input type from crypto to fiat on button click', () => { + const setSelectedInputType = vi.fn(); + render( + + ); + fireEvent.click(screen.getByLabelText(/amount type switch/i)); + expect(setSelectedInputType).toHaveBeenCalledWith('fiat'); + }); + + it('does not render fiat amount when fundAmountFiat is "0"', () => { + render( + + ); + expect(screen.queryByText('$0.00')).not.toBeInTheDocument(); + }); + + it('renders Skeleton when exchangeRate does not exist', () => { + render( + + ); + expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); + }); +}); diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/fund/components/FundCardCurrencyLabel.test.tsx new file mode 100644 index 0000000000..289ec6cb8f --- /dev/null +++ b/src/fund/components/FundCardCurrencyLabel.test.tsx @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +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-[60px] leading-none outline-none' + ); + }); +}); diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx index ba257cb84d..80553ce771 100644 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -3,22 +3,24 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { cn, text } from '../../styles/theme'; import type { FundCardCurrencyLabelPropsReact } from '../types'; -export const FundCardCurrencyLabel = forwardRef( - ({ currencySign }, ref) => { - const componentTheme = useTheme(); +export const FundCardCurrencyLabel = forwardRef< + HTMLSpanElement, + FundCardCurrencyLabelPropsReact +>(({ currencySign }, ref) => { + const componentTheme = useTheme(); - return ( - - {currencySign} - - ); - } -); + return ( + + {currencySign} + + ); +}); diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index 81ba15e391..4bb199133f 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -3,18 +3,6 @@ import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { FundCardHeader } from './FundCardHeader'; -vi.mock('../hooks/useGetFundingUrl', () => ({ - useGetFundingUrl: vi.fn(), -})); - -vi.mock('../utils/getFundingPopupSize', () => ({ - getFundingPopupSize: vi.fn(), -})); - -vi.mock('../../internal/utils/openPopup', () => ({ - openPopup: vi.fn(), -})); - vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: vi.fn(), })); diff --git a/src/fund/components/FundCardPaymentMethodImage.test.tsx b/src/fund/components/FundCardPaymentMethodImage.test.tsx new file mode 100644 index 0000000000..37b6bf0e3a --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodImage.test.tsx @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, type Mock, vi } from 'vitest'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +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('fundCardPaymentMethodImage__iconContainer')).toBeInTheDocument(); + expect(screen.queryByTestId('fundCardPaymentMethodImage__noImage')).not.toBeInTheDocument(); + }); + + it('renders the placeholder when iconSvg is not available', () => { + (useIcon as Mock).mockReturnValue(null); + + render(); + expect(screen.queryByTestId('fundCardPaymentMethodImage__noImage')).toBeInTheDocument(); + }); + + it('applies primary color when the icon is coinbasePay', () => { + (useIcon as Mock).mockReturnValue(() => ); + + render( + + ); + expect(screen.getByTestId('fundCardPaymentMethodImage__iconContainer')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/fund/components/FundCardPaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx index 8298f47e45..bd300b6ba4 100644 --- a/src/fund/components/FundCardPaymentMethodImage.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -32,7 +32,7 @@ export function FundCardPaymentMethodImage({ className, size = 24, paymentMethod return (
@@ -41,7 +41,7 @@ export function FundCardPaymentMethodImage({ className, size = 24, paymentMethod } return ( -
+
{iconSvg}
); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx index a7f43676da..dcc797c5bd 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -15,7 +15,7 @@ export const FundCardPaymentMethodSelectRow = memo(({ return ( - ); -}); +export const FundCardPaymentMethodSelectorToggle = forwardRef( + ( + { + onClick, + paymentMethod, + isOpen, + className, + }: FundCardPaymentMethodSelectorTogglePropsReact, + ref: ForwardedRef + ) => { + return ( + + ); + } +); diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 4d6ac57f82..9f85da4e34 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState } from 'react'; import { useValue } from '../../core-react/internal/hooks/useValue'; -import type { FundCardProviderReact, PaymentMethodReact } from '../types'; +import type { FundButtonStateReact, FundCardProviderReact, PaymentMethodReact } from '../types'; type FundCardContextType = { selectedAsset?: string; @@ -17,13 +17,11 @@ type FundCardContextType = { setExchangeRate: (exchangeRate: number) => void; exchangeRateLoading?: boolean; setExchangeRateLoading: (loading: boolean) => void; - submitButtonLoading?: boolean; - setSubmitButtonLoading: (loading: boolean) => void; + submitButtonState: FundButtonStateReact; + setSubmitButtonState: (state: FundButtonStateReact) => void; }; -const initialState = {} as FundCardContextType; - -const FundContext = createContext(initialState); +const FundContext = createContext(undefined); export function FundCardProvider({ children, asset }: FundCardProviderReact) { const [selectedAsset, setSelectedAsset] = useState(asset); @@ -33,7 +31,7 @@ export function FundCardProvider({ children, asset }: FundCardProviderReact) { const [fundAmountCrypto, setFundAmountCrypto] = useState(''); const [exchangeRate, setExchangeRate] = useState(); const [exchangeRateLoading, setExchangeRateLoading] = useState(); - const [submitButtonLoading, setSubmitButtonLoading] = useState(); + const [submitButtonState, setSubmitButtonState] = useState('default'); const value = useValue({ selectedAsset, @@ -50,8 +48,8 @@ export function FundCardProvider({ children, asset }: FundCardProviderReact) { setExchangeRate, exchangeRateLoading, setExchangeRateLoading, - submitButtonLoading, - setSubmitButtonLoading, + submitButtonState, + setSubmitButtonState, }); return {children}; } @@ -59,7 +57,7 @@ export function FundCardProvider({ children, asset }: FundCardProviderReact) { export function useFundContext() { const context = useContext(FundContext); - if (context === undefined) { + if (!context) { throw new Error('useFundContext must be used within a FundCardProvider'); } diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index faf8aed125..f8970fe115 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -1,18 +1,59 @@ -// import '@testing-library/jest-dom'; -// import { render, renderHook } from '@testing-library/react'; -// import { WalletProvider, useWalletContext } from './WalletProvider'; +import { render, screen } from '@testing-library/react'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; +import { describe, expect, it } from 'vitest'; +import { act } from 'react'; -// describe('useWalletContext', () => { -// it('should return default context', () => { -// render( -// -//
-// , -// ); +const TestComponent = () => { + const context = useFundContext(); + return ( +
+ {context.selectedAsset} +
+ ); +}; -// const { result } = renderHook(() => useWalletContext(), { -// wrapper: WalletProvider, -// }); -// expect(result.current.isOpen).toEqual(false); -// }); -// }); +describe('FundCardProvider', () => { + it('provides default context values', () => { + render( + + + + ); + expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); + }); + + it('updates selectedAsset when setSelectedAsset is called', () => { + const TestUpdateComponent = () => { + const { selectedAsset, setSelectedAsset } = useFundContext(); + return ( +
+ {selectedAsset} + +
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); + act(() => { + screen.getByText('Change Asset').click(); + }); + expect(screen.getByTestId('selected-asset').textContent).toBe('ETH'); + }); + + 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/constants.ts b/src/fund/constants.ts index d3fef285d5..251372a0c5 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -5,6 +5,7 @@ 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; \ No newline at end of file diff --git a/src/fund/hooks/useExchangeRate.test.ts b/src/fund/hooks/useExchangeRate.test.ts new file mode 100644 index 0000000000..d630e0ee37 --- /dev/null +++ b/src/fund/hooks/useExchangeRate.test.ts @@ -0,0 +1,78 @@ +import { renderHook } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useExchangeRate } from './useExchangeRate'; +import { useFundContext } from '../components/FundCardProvider'; +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; + +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; + +vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); + +vi.mock('../components/FundCardProvider', () => ({ + useFundContext: vi.fn(), +})); + +let mockSetExchangeRate = vi.fn(); +let mockSetExchangeRateLoading = vi.fn(); + +describe('useExchangeRate', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: '123456789' }); + mockSetExchangeRate = vi.fn(); + mockSetExchangeRateLoading = vi.fn(); + (useFundContext as Mock).mockReturnValue({ + exchangeRateLoading: false, + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }); + }); + + it('should fetch and set exchange rate correctly', async () => { + // Mock dependencies + + renderHook(() => useExchangeRate('BTC')); + + // Assert loading state + expect(mockSetExchangeRateLoading).toHaveBeenCalledWith(true); + + // Wait for the exchange rate to be fetched + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assert loading state is false and exchange rate is set correctly + expect(mockSetExchangeRateLoading).toHaveBeenCalledWith(false); + expect(mockSetExchangeRate).toHaveBeenCalledWith(0.0008333333333333334); + }); + + it('should not fetch exchange rate if already loading', () => { + // Mock exchangeRateLoading as true + (useFundContext as Mock).mockReturnValue({ + exchangeRateLoading: true, + setExchangeRate: mockSetExchangeRate, + setExchangeRateLoading: mockSetExchangeRateLoading, + }); + + // Render the hook + renderHook(() => useExchangeRate('BTC')); + + // Assert that setExchangeRateLoading was not called + expect(mockSetExchangeRateLoading).not.toHaveBeenCalled(); + + // Assert that setExchangeRate was not called + expect(mockSetExchangeRate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/fund/hooks/useExchangeRate.ts b/src/fund/hooks/useExchangeRate.ts index f5e500fb1c..4642245c4a 100644 --- a/src/fund/hooks/useExchangeRate.ts +++ b/src/fund/hooks/useExchangeRate.ts @@ -4,11 +4,12 @@ import { useDebounce } from '../../core-react/internal/hooks/useDebounce'; import { useFundContext } from '../components/FundCardProvider'; export const useExchangeRate = (assetSymbol: string) => { - const { setExchangeRate, exchangeRate, exchangeRateLoading, setExchangeRateLoading } = useFundContext(); + const { setExchangeRate, exchangeRateLoading, setExchangeRateLoading } = + useFundContext(); const fetchExchangeRate = useDebounce(async () => { - if(exchangeRateLoading) { - return; + if (exchangeRateLoading) { + return; } setExchangeRateLoading(true); @@ -21,14 +22,15 @@ export const useExchangeRate = (assetSymbol: string) => { }); setExchangeRateLoading(false); - console.log('quote', quote); - setExchangeRate(Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value)); + setExchangeRate( + Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value) + ); }, 1000); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect useEffect(() => { fetchExchangeRate(); }, []); - return useMemo(() => ({ exchangeRate, fetchExchangeRate }), [exchangeRate, fetchExchangeRate]); + return useMemo(() => ({ fetchExchangeRate }), [fetchExchangeRate]); }; diff --git a/src/fund/types.ts b/src/fund/types.ts index 8756a697b3..224cac11bf 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -110,7 +110,7 @@ export type FundButtonReact = { 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?: 'default' | 'success' | 'error' | 'loading'; // The state of the button component + 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 @@ -126,6 +126,8 @@ export type FundButtonReact = { 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 { … }`. diff --git a/src/fund/utils/fetchOnrampQuote.test.ts b/src/fund/utils/fetchOnrampQuote.test.ts index 3274153638..a0424b3cc1 100644 --- a/src/fund/utils/fetchOnrampQuote.test.ts +++ b/src/fund/utils/fetchOnrampQuote.test.ts @@ -13,20 +13,18 @@ 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(() => Promise.resolve({ json: () => Promise.resolve(mockResponseData), - }), + }) ) as Mock; describe('fetchOnrampQuote', () => { @@ -62,7 +60,7 @@ describe('fetchOnrampQuote', () => { headers: { Authorization: `Bearer ${mockApiKey}`, }, - }, + } ); expect(result).toEqual({ paymentSubtotal: { amount: '100.00', currency: 'USD' }, @@ -76,7 +74,7 @@ describe('fetchOnrampQuote', () => { it('should throw an error if fetch fails', async () => { global.fetch = vi.fn(() => - Promise.reject(new Error('Fetch failed')), + Promise.reject(new Error('Fetch failed')) ) as Mock; await expect( @@ -88,7 +86,7 @@ describe('fetchOnrampQuote', () => { paymentAmount: mockPaymentAmount, country: mockCountry, subdivision: mockSubdivision, - }), + }) ).rejects.toThrow('Fetch failed'); }); }); diff --git a/src/internal/components/Skeleton.tsx b/src/internal/components/Skeleton.tsx index 7a868286c9..2f422f6605 100644 --- a/src/internal/components/Skeleton.tsx +++ b/src/internal/components/Skeleton.tsx @@ -14,6 +14,7 @@ export function Skeleton({ className }: SkeletonReact) { className )} + data-testid="ockSkeleton" /> ); } diff --git a/src/internal/hooks/useIcon.test.tsx b/src/internal/hooks/useIcon.test.tsx new file mode 100644 index 0000000000..ba3173deec --- /dev/null +++ b/src/internal/hooks/useIcon.test.tsx @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react'; +import { useIcon } from './useIcon'; +import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { toggleSvg } from '../../internal/svg/toggleSvg'; +import { applePaySvg } from '../svg/applePaySvg'; +import { fundWalletSvg } from '../svg/fundWallet'; +import { swapSettingsSvg } from '../svg/swapSettings'; +import { walletSvg } from '../svg/walletSvg'; +import { creditCardSvg } from '../svg/creditCardSvg'; +import { describe, expect, it } from 'vitest'; + +describe('useIcon', () => { + it('returns CoinbasePaySvg when icon is "coinbasePay"', () => { + const { result } = renderHook(() => useIcon({ icon: 'coinbasePay' })); + expect(result.current?.type).toBe(CoinbasePaySvg); + }); + + it('returns fundWalletSvg when icon is "fundWallet"', () => { + const { result } = renderHook(() => useIcon({ icon: 'fundWallet' })); + expect(result.current).toBe(fundWalletSvg); + }); + + it('returns swapSettingsSvg when icon is "swapSettings"', () => { + const { result } = renderHook(() => useIcon({ icon: 'swapSettings' })); + expect(result.current).toBe(swapSettingsSvg); + }); + + it('returns walletSvg when icon is "wallet"', () => { + const { result } = renderHook(() => useIcon({ icon: 'wallet' })); + expect(result.current).toBe(walletSvg); + }); + + it('returns toggleSvg when icon is "toggle"', () => { + const { result } = renderHook(() => useIcon({ icon: 'toggle' })); + expect(result.current).toBe(toggleSvg); + }); + + it('returns applePaySvg when icon is "applePay"', () => { + const { result } = renderHook(() => useIcon({ icon: 'applePay' })); + expect(result.current).toBe(applePaySvg); + }); + + it('returns creditCardSvg when icon is "creditCard"', () => { + const { result } = renderHook(() => useIcon({ icon: 'creditCard' })); + expect(result.current).toBe(creditCardSvg); + }); + + it('returns null when icon is undefined', () => { + const { result } = renderHook(() => useIcon({ icon: undefined })); + expect(result.current).toBeNull(); + }); + + it('returns the provided React element when icon is a valid element', () => { + const customIcon =
Custom Icon
; + const { result } = renderHook(() => useIcon({ icon: customIcon })); + expect(result.current).toBe(customIcon); + }); +}); diff --git a/src/internal/hooks/useIcon.tsx b/src/internal/hooks/useIcon.tsx new file mode 100644 index 0000000000..498d372a02 --- /dev/null +++ b/src/internal/hooks/useIcon.tsx @@ -0,0 +1,35 @@ +import { isValidElement, useMemo } from 'react'; +import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { toggleSvg } from '../../internal/svg/toggleSvg'; +import { applePaySvg } from '../svg/applePaySvg'; +import { fundWalletSvg } from '../svg/fundWallet'; +import { swapSettingsSvg } from '../svg/swapSettings'; +import { walletSvg } from '../svg/walletSvg'; +import { creditCardSvg } from '../svg/creditCardSvg'; + +export const useIcon = ({ icon, className }: { icon?: React.ReactNode, className?: string }) => { + return useMemo(() => { + if (icon === undefined) { + return null; + } + switch (icon) { + case 'coinbasePay': + return ; + case 'fundWallet': + return fundWalletSvg; + case 'swapSettings': + return swapSettingsSvg; + case 'wallet': + return walletSvg; + case 'toggle': + return toggleSvg; + case 'applePay': + return applePaySvg; + case 'creditCard': + return creditCardSvg; + } + if (isValidElement(icon)) { + return icon; + } + }, [icon, className]); +}; diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx index 91c7f117ee..e8b5eb5b24 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)}) => ( ); diff --git a/src/internal/svg/errorSvg.tsx b/src/internal/svg/errorSvg.tsx index 43aaf3ee26..94b837d2db 100644 --- a/src/internal/svg/errorSvg.tsx +++ b/src/internal/svg/errorSvg.tsx @@ -1,4 +1,6 @@ -export const errorSvg = ( +import { cn, icon } from '../../styles/theme'; + +export const ErrorSvg = ({className = cn(icon.error)}) => ( Error SVG ); diff --git a/src/internal/svg/successSvg.tsx b/src/internal/svg/successSvg.tsx index 0fa5d4cb9d..3bcaf0d93e 100644 --- a/src/internal/svg/successSvg.tsx +++ b/src/internal/svg/successSvg.tsx @@ -1,4 +1,6 @@ -export const successSvg = ( +import { cn, icon } from '../../styles/theme'; + +export const SuccessSvg = ({className = cn(icon.success)}) => ( 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..a20e0755ca 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -26,6 +26,8 @@ export const pressable = { 'cursor-pointer ock-bg-inverse active:bg-[var(--ock-bg-inverse-active)] hover:bg-[var(--ock-bg-inverse-hover)]', primary: 'cursor-pointer ock-bg-primary active:bg-[var(--ock-bg-primary-active)] hover:bg-[var(--ock-bg-primary-hover)]', + error: + 'cursor-pointer ock-bg-error', secondary: 'cursor-pointer ock-bg-secondary active:bg-[var(--ock-bg-secondary-active)] hover:bg-[var(--ock-bg-secondary-hover)]', coinbaseBranding: 'cursor-pointer bg-[#0052FF] hover:bg-[#0045D8]', diff --git a/src/swap/components/SwapToast.tsx b/src/swap/components/SwapToast.tsx index 5bffc2974a..803b8fa955 100644 --- a/src/swap/components/SwapToast.tsx +++ b/src/swap/components/SwapToast.tsx @@ -4,7 +4,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'; @@ -40,7 +40,7 @@ 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 ; From 535b2f3aebeeeda35cc1e2b0a46687c90bc38b7b Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:11:36 -0800 Subject: [PATCH 24/91] Fund form --- .../nextjs-app-router/components/Demo.tsx | 6 +- .../components/demo/Fund.tsx | 13 -- .../components/demo/FundButton.tsx | 9 + .../components/demo/FundCard.tsx | 9 + .../components/form/active-component.tsx | 7 +- .../nextjs-app-router/onchainkit/package.json | 2 + .../nextjs-app-router/types/onchainkit.ts | 3 +- src/.eslintrc.json | 7 + .../internal/hooks/useIcon.test.tsx | 20 +- src/core-react/internal/hooks/useIcon.tsx | 9 +- src/fund/components/FundButton.test.tsx | 42 +++- src/fund/components/FundButton.tsx | 31 ++- src/fund/components/FundCard.test.tsx | 212 ++++++++++++------ src/fund/components/FundCard.tsx | 64 ++---- .../components/FundCardAmountInput.test.tsx | 52 +++-- src/fund/components/FundCardAmountInput.tsx | 74 +++--- .../FundCardAmountInputTypeSwitch.test.tsx | 14 +- .../FundCardAmountInputTypeSwitch.tsx | 13 +- .../components/FundCardCurrencyLabel.test.tsx | 2 +- src/fund/components/FundCardCurrencyLabel.tsx | 2 +- src/fund/components/FundCardHeader.test.tsx | 6 +- src/fund/components/FundCardHeader.tsx | 7 +- .../FundCardPaymentMethodImage.test.tsx | 48 +++- .../components/FundCardPaymentMethodImage.tsx | 17 +- .../FundCardPaymentMethodSelectRow.test.tsx | 60 +++++ .../FundCardPaymentMethodSelectRow.tsx | 75 ++++--- ...CardPaymentMethodSelectorDropdown.test.tsx | 28 +-- .../FundCardPaymentMethodSelectorDropdown.tsx | 31 +-- .../FundCardPaymentMethodSelectorToggle.tsx | 8 +- src/fund/components/FundCardProvider.tsx | 25 ++- src/fund/components/FundProvider.test.tsx | 18 +- src/fund/constants.ts | 2 +- src/fund/hooks/useExchangeRate.test.ts | 6 +- src/fund/hooks/useExchangeRate.ts | 5 +- src/fund/hooks/useFundCardFundingUrl.test.ts | 143 ++++++++++++ src/fund/hooks/useFundCardFundingUrl.ts | 48 ++++ ...eFundCardSetupOnrampEventListeners.test.ts | 130 +++++++++++ .../useFundCardSetupOnrampEventListeners.ts | 38 ++++ src/fund/types.ts | 30 ++- src/fund/utils/fetchOnrampQuote.test.ts | 8 +- .../utils/setupOnrampEventListeners.test.ts | 6 +- src/internal/components/Skeleton.tsx | 3 +- src/internal/components/TextInput.tsx | 2 +- src/internal/hooks/useIcon.test.tsx | 8 +- src/internal/hooks/useIcon.tsx | 9 +- src/internal/svg/addSvg.tsx | 2 +- src/internal/svg/coinbasePaySvg.tsx | 2 +- src/internal/svg/errorSvg.tsx | 2 +- src/internal/svg/successSvg.tsx | 2 +- src/styles/theme.ts | 3 +- src/swap/components/SwapToast.tsx | 6 +- 51 files changed, 1000 insertions(+), 369 deletions(-) delete mode 100644 playground/nextjs-app-router/components/demo/Fund.tsx create mode 100644 playground/nextjs-app-router/components/demo/FundButton.tsx create mode 100644 playground/nextjs-app-router/components/demo/FundCard.tsx create mode 100644 src/.eslintrc.json create mode 100644 src/fund/components/FundCardPaymentMethodSelectRow.test.tsx create mode 100644 src/fund/hooks/useFundCardFundingUrl.test.ts create mode 100644 src/fund/hooks/useFundCardFundingUrl.ts create mode 100644 src/fund/hooks/useFundCardSetupOnrampEventListeners.test.ts create mode 100644 src/fund/hooks/useFundCardSetupOnrampEventListeners.ts diff --git a/playground/nextjs-app-router/components/Demo.tsx b/playground/nextjs-app-router/components/Demo.tsx index 17327c727f..a230c4574f 100644 --- a/playground/nextjs-app-router/components/Demo.tsx +++ b/playground/nextjs-app-router/components/Demo.tsx @@ -6,7 +6,7 @@ import { OnchainKitComponent } from '@/types/onchainkit'; import { useContext, useEffect, useState } from 'react'; import DemoOptions from './DemoOptions'; import CheckoutDemo from './demo/Checkout'; -import FundDemo from './demo/Fund'; +import FundButtonDemo from './demo/FundButton'; import IdentityDemo from './demo/Identity'; import { IdentityCardDemo } from './demo/IdentityCard'; import NFTCardDemo from './demo/NFTCard'; @@ -19,9 +19,11 @@ import TransactionDemo from './demo/Transaction'; import TransactionDefaultDemo from './demo/TransactionDefault'; import WalletDemo from './demo/Wallet'; import WalletDefaultDemo from './demo/WalletDefault'; +import FundCardDemo from './demo/FundCard'; const activeComponentMapping: Record = { - [OnchainKitComponent.Fund]: FundDemo, + [OnchainKitComponent.FundButton]: FundButtonDemo, + [OnchainKitComponent.FundCard]: FundCardDemo, [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/Fund.tsx deleted file mode 100644 index 1e5d74a3f1..0000000000 --- a/playground/nextjs-app-router/components/demo/Fund.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { FundButton, FundCard } from '@coinbase/onchainkit/fund'; - -export default function FundDemo() { - return ( - -
- - - -
- - ); -} diff --git a/playground/nextjs-app-router/components/demo/FundButton.tsx b/playground/nextjs-app-router/components/demo/FundButton.tsx new file mode 100644 index 0000000000..651a18b318 --- /dev/null +++ b/playground/nextjs-app-router/components/demo/FundButton.tsx @@ -0,0 +1,9 @@ +import { FundButton } from '@coinbase/onchainkit/fund'; + +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..d58e2ea384 --- /dev/null +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -0,0 +1,9 @@ +import { FundCard } from '@coinbase/onchainkit/fund'; + +export default function FundCardDemo() { + return ( +
+ +
+ ); +} diff --git a/playground/nextjs-app-router/components/form/active-component.tsx b/playground/nextjs-app-router/components/form/active-component.tsx index 2df018cf2f..f253bfba84 100644 --- a/playground/nextjs-app-router/components/form/active-component.tsx +++ b/playground/nextjs-app-router/components/form/active-component.tsx @@ -26,7 +26,12 @@ export function ActiveComponent() { - Fund + + Fund Button + + + Fund Card + Identity IdentityCard diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 37f067f7ba..875ca5acb7 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -39,6 +39,7 @@ "clsx": "^2.1.1", "graphql": "^14 || ^15 || ^16", "graphql-request": "^6.1.0", + "qrcode": "^1.5.4", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", "viem": "^2.21.33", @@ -59,6 +60,7 @@ "@storybook/test-runner": "^0.19.1", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^14.2.0", + "@types/qrcode": "^1", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^2.0.5", diff --git a/playground/nextjs-app-router/types/onchainkit.ts b/playground/nextjs-app-router/types/onchainkit.ts index 93471f8cbf..1f9115461b 100644 --- a/playground/nextjs-app-router/types/onchainkit.ts +++ b/playground/nextjs-app-router/types/onchainkit.ts @@ -1,5 +1,6 @@ export enum OnchainKitComponent { - Fund = 'fund', + FundButton = 'fund-button', + FundCard = 'fund-card', Identity = 'identity', IdentityCard = 'identity-card', Checkout = 'checkout', diff --git a/src/.eslintrc.json b/src/.eslintrc.json new file mode 100644 index 0000000000..11cf32a42b --- /dev/null +++ b/src/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "plugins": ["react-hooks"], + "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" + } +} diff --git a/src/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index 2784331675..bdd406a4cf 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,8 +1,11 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { applePaySvg } from '../../../internal/svg/applePaySvg'; 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,9 +20,24 @@ describe('useIcon', () => { expect(result.current).toBe(walletSvg); }); + it('should return toggleSvg when icon is "toggle"', () => { + const { result } = renderHook(() => useIcon({ icon: 'toggle' })); + expect(result.current).toBe(toggleSvg); + }); + + 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); + expect(result.current?.type).toBe(CoinbasePaySvg); }); it('should return fundWalletSvg when icon is "fundWallet"', () => { diff --git a/src/core-react/internal/hooks/useIcon.tsx b/src/core-react/internal/hooks/useIcon.tsx index a447ac81c8..6f37c14888 100644 --- a/src/core-react/internal/hooks/useIcon.tsx +++ b/src/core-react/internal/hooks/useIcon.tsx @@ -1,13 +1,16 @@ import { isValidElement, useMemo } from 'react'; -import { CoinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; -import { toggleSvg } from '../../../internal/svg/toggleSvg'; import { applePaySvg } from '../../../internal/svg/applePaySvg'; +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, className }: { icon?: React.ReactNode, className?: string }) => { +export const useIcon = ({ + icon, + className, +}: { icon?: React.ReactNode; className?: string }) => { return useMemo(() => { if (icon === undefined) { return null; diff --git a/src/fund/components/FundButton.test.tsx b/src/fund/components/FundButton.test.tsx index 0582db9693..0026a1ebe4 100644 --- a/src/fund/components/FundButton.test.tsx +++ b/src/fund/components/FundButton.test.tsx @@ -22,6 +22,21 @@ vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: 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', () => { afterEach(() => { vi.clearAllMocks(); @@ -34,7 +49,6 @@ describe('FundButton', () => { render(); - expect(useGetFundingUrl).not.toHaveBeenCalled(); const buttonElement = screen.getByRole('button'); expect(screen.getByText('Fund')).toBeInTheDocument(); @@ -89,7 +103,6 @@ describe('FundButton', () => { render(); - expect(useGetFundingUrl).not.toHaveBeenCalled(); const linkElement = screen.getByRole('link'); expect(screen.getByText('Fund')).toBeInTheDocument(); expect(linkElement).toHaveAttribute('href', fundingUrl); @@ -102,16 +115,20 @@ describe('FundButton', () => { it('displays success text when in success state', () => { render(); - expect(screen.getByTestId('fundButtonTextContent')).toHaveTextContent('Success'); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Success', + ); }); it('displays error text when in error state', () => { render(); - expect(screen.getByTestId('fundButtonTextContent')).toHaveTextContent('Something went wrong'); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Something went wrong', + ); }); it('calls onPopupClose when the popup window is closed', () => { - vi.useFakeTimers() + vi.useFakeTimers(); const fundingUrl = 'https://props.funding.url'; const onPopupClose = vi.fn(); const { height, width } = { height: 200, width: 100 }; @@ -121,16 +138,16 @@ describe('FundButton', () => { }; (getFundingPopupSize as Mock).mockReturnValue({ height, width }); (openPopup as Mock).mockReturnValue(mockPopupWindow); - + render(); - + const buttonElement = screen.getByRole('button'); fireEvent.click(buttonElement); - + // Simulate closing the popup mockPopupWindow.closed = true; vi.runOnlyPendingTimers(); - + expect(onPopupClose).toHaveBeenCalled(); }); @@ -154,4 +171,9 @@ describe('FundButton', () => { target: undefined, }); }); -}); \ No newline at end of file + + it('icon is not shown when hideIcon is passed', () => { + render(); + expect(screen.queryByTestId('ockFundButtonIcon')).not.toBeInTheDocument(); + }); +}); diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 977f2e80d0..db86abd7f2 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -1,14 +1,14 @@ +import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { useCallback, useMemo } from 'react'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { Spinner } from '../../internal/components/Spinner'; import { AddSvg } from '../../internal/svg/addSvg'; -import { SuccessSvg } from '../../internal/svg/successSvg'; import { ErrorSvg } from '../../internal/svg/errorSvg'; +import { SuccessSvg } from '../../internal/svg/successSvg'; import { border, cn, color, icon, pressable, text } from '../../styles/theme'; -import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; -import { Spinner } from '../../internal/components/Spinner'; export function FundButton({ className, @@ -29,17 +29,19 @@ export function FundButton({ }: 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 fundingUrlToRender = fundingUrl ?? fallbackFundingUrl; const isDisabled = disabled || !fundingUrlToRender; const handleClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + if (fundingUrlToRender) { onClick?.(); const { height, width } = getFundingPopupSize( popupSize, - fundingUrlToRender + fundingUrlToRender, ); const popupWindow = openPopup({ url: fundingUrlToRender, @@ -60,7 +62,7 @@ export function FundButton({ }, 500); } }, - [fundingUrlToRender, popupSize, target, onPopupClose, onClick] + [fundingUrlToRender, popupSize, target, onPopupClose, onClick], ); const buttonColorClass = useMemo(() => { @@ -83,14 +85,14 @@ export function FundButton({ text.headline, border.radius, color.inverse, - className + className, ); const buttonIcon = useMemo(() => { if (hideIcon) { return null; } - switch(buttonState) { + switch (buttonState) { case 'loading': return ''; case 'success': @@ -103,7 +105,7 @@ export function FundButton({ }, [buttonState, hideIcon]); const buttonTextContent = useMemo(() => { - switch(buttonState) { + switch (buttonState) { case 'loading': return ''; case 'success': @@ -119,8 +121,14 @@ export function FundButton({ <> {buttonState === 'loading' && } {/* h-6 is to match the icon height to the line-height set by text.headline */} - {buttonIcon && {buttonIcon}} - {hideText || {buttonTextContent}} + {buttonIcon && ( + + {buttonIcon} + + )} + {hideText || ( + {buttonTextContent} + )} ); @@ -144,6 +152,7 @@ export function FundButton({ onClick={handleClick} type="button" disabled={isDisabled} + data-testid="ockFundButton" > {buttonContent} diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index cb60b8fdfe..b96fd6d927 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -1,126 +1,198 @@ import '@testing-library/jest-dom'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FundCardPropsReact } from '../types'; +import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; import { FundCardProvider } from './FundCardProvider'; -import type { FundCardPropsReact } from '../types'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: () => 'mocked-theme-class', })); -vi.mock('../hooks/useExchangeRate', () => ({ - useExchangeRate: () => {}, +vi.mock('../hooks/useGetFundingUrl', () => ({ + useGetFundingUrl: vi.fn(), +})); + +vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); + +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'); + +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', +}; + +const defaultProps: FundCardPropsReact = { + assetSymbol: 'BTC', + buttonText: 'Buy BTC', + headerText: 'Fund Your Account', +}; + +const renderComponent = (props = defaultProps) => + render( + + + , + ); + describe('FundCard', () => { - const defaultProps: FundCardPropsReact = { - assetSymbol: 'BTC', - buttonText: 'Buy BTC', - headerText: 'Fund Your Account', - }; - - const renderComponent = (props = defaultProps) => - render( - - - - ); + beforeEach(() => { + vi.resetAllMocks(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + (getFundingPopupSize as Mock).mockImplementation(() => ({ + height: 200, + width: 100, + })); + (useFundCardFundingUrl as Mock).mockReturnValue('mock-funding-url'); + (useDebounce as Mock).mockImplementation((callback) => callback); + (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); + }); it('renders without crashing', () => { renderComponent(); - expect(screen.getByText('Fund Your Account')).toBeInTheDocument(); - expect(screen.getByText('Buy BTC')).toBeInTheDocument(); + expect(screen.getByTestId('fundCardHeader')).toBeInTheDocument(); + expect(screen.getByTestId('ockFundButtonTextContent')).toBeInTheDocument(); }); it('displays the correct header text', () => { renderComponent(); - expect(screen.getByText('Fund Your Account')).toBeInTheDocument(); + expect(screen.getByTestId('fundCardHeader')).toHaveTextContent( + 'Fund Your Account', + ); }); it('displays the correct button text', () => { renderComponent(); - expect(screen.getByRole('button', { name: 'Buy BTC' })).toBeInTheDocument(); + expect(screen.getByTestId('ockFundButtonTextContent')).toHaveTextContent( + 'Buy BTC', + ); }); it('handles input changes for fiat amount', () => { renderComponent(); - const input = screen.getByPlaceholderText('$') as HTMLInputElement; - fireEvent.change(input, { target: { value: '100' } }); + + const input = screen.getByTestId( + 'ockFundCardAmountInput', + ) as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: '100' } }); + }); + expect(input.value).toBe('100'); }); - it('switches input type from fiat to crypto', () => { + it('switches input type from fiat to crypto', async () => { renderComponent(); - const switchButton = screen.getByRole('button', { name: /switch to crypto/i }); - fireEvent.click(switchButton); - expect(screen.getByPlaceholderText('BTC')).toBeInTheDocument(); - }); - it('selects a payment method', () => { - renderComponent(); - const dropdown = screen.getByRole('button', { name: /select payment method/i }); - fireEvent.click(dropdown); - const option = screen.getByText('Coinbase'); - fireEvent.click(option); - expect(screen.getByText('Coinbase')).toBeInTheDocument(); + await waitFor(() => { + const switchButton = screen.getByTestId('ockAmountTypeSwitch'); + fireEvent.click(switchButton); + }); + + expect(screen.getByTestId('currencySpan')).toHaveTextContent('BTC'); }); it('disables the submit button when fund amount is zero', () => { renderComponent(); - const button = screen.getByRole('button', { name: 'Buy BTC' }); + const button = screen.getByTestId('ockFundButton'); expect(button).toBeDisabled(); }); it('enables the submit button when fund amount is greater than zero', () => { renderComponent(); - const input = screen.getByPlaceholderText('$') as HTMLInputElement; - fireEvent.change(input, { target: { value: '100' } }); - const button = screen.getByRole('button', { name: 'Buy BTC' }); + const input = screen.getByTestId('ockFundCardAmountInput'); + act(() => { + fireEvent.change(input, { target: { value: '100' } }); + }); + const button = screen.getByTestId('ockFundButton'); expect(button).not.toBeDisabled(); }); it('shows loading state when submitting', async () => { renderComponent(); - const input = screen.getByPlaceholderText('$') as HTMLInputElement; - fireEvent.change(input, { target: { value: '100' } }); - const button = screen.getByRole('button', { name: 'Buy BTC' }); - - fireEvent.click(button); - expect(button).toHaveAttribute('state', 'loading'); - - await waitFor(() => { - expect(button).toHaveAttribute('state', 'default'); + const input = screen.getByTestId('ockFundCardAmountInput'); + act(() => { + fireEvent.change(input, { target: { value: '100' } }); }); - }); + const button = screen.getByTestId('ockFundButton'); - it('handles funding URL correctly for fiat input', () => { - renderComponent(); - const input = screen.getByPlaceholderText('$') as HTMLInputElement; - fireEvent.change(input, { target: { value: '100' } }); - const button = screen.getByRole('button', { name: 'Buy BTC' }); - - expect(button).toHaveAttribute('href'); - }); + expect(screen.queryByTestId('ockSpinner')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(button); + }); - it('handles funding URL correctly for crypto input', () => { - renderComponent(); - const switchButton = screen.getByRole('button', { name: /switch to crypto/i }); - fireEvent.click(switchButton); - const input = screen.getByPlaceholderText('BTC') as HTMLInputElement; - fireEvent.change(input, { target: { value: '0.005' } }); - const button = screen.getByRole('button', { name: 'Buy BTC' }); - - expect(button).toHaveAttribute('href'); + expect(screen.getByTestId('ockSpinner')).toBeInTheDocument(); }); - it('calls onEvent, onExit, and onSuccess from setupOnrampEventListeners', () => { - const { setupOnrampEventListeners } = require('../utils/setupOnrampEventListeners'); - renderComponent(); - expect(setupOnrampEventListeners).toHaveBeenCalled(); + it('renders passed in components', () => { + const CustomAmountInputComponent = () => ( +
+ ); + const CustomHeaderComponent = () =>
; + const CustomAmountInputTypeSwitchComponent = () => ( +
+ ); + const CustomPaymentMethodSelectorDropdownComponent = () => ( +
+ ); + const CustomSubmitButtonComponent = () => ( +
+ ); + renderComponent({ + ...defaultProps, + amountInputComponent: CustomAmountInputComponent, + headerComponent: CustomHeaderComponent, + amountInputTypeSwithComponent: CustomAmountInputTypeSwitchComponent, + paymentMethodSelectorDropdownComponent: + CustomPaymentMethodSelectorDropdownComponent, + submitButtonComponent: CustomSubmitButtonComponent, + }); + + expect(screen.getByTestId('amountInputComponent')).toBeInTheDocument(); + expect(screen.getByTestId('headerComponent')).toBeInTheDocument(); + expect( + screen.getByTestId('amountInputTypeSwitchComponent'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('paymentMethodSelectorDropdownComponent'), + ).toBeInTheDocument(); + expect(screen.getByTestId('submitButtonComponent')).toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index f6ad5527bd..226034d942 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,21 +1,20 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { background, border, cn, color, text } from '../../styles/theme'; -import { FundCardProvider } from './FundCardProvider'; import { useExchangeRate } from '../hooks/useExchangeRate'; -import { useEffect, useMemo } from 'react'; -import { useFundContext } from './FundCardProvider'; +import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; +import type { FundCardPropsReact, PaymentMethodReact } from '../types'; import { FundButton } from './FundButton'; -import { FundCardHeader } from './FundCardHeader'; -import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import FundCardAmountInput from './FundCardAmountInput'; -import { FUND_BUTTON_RESET_TIMEOUT, ONRAMP_BUY_URL } from '../constants'; -import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; -import type { FundCardPropsReact, PaymentMethodReact } from '../types'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; +import { FundCardHeader } from './FundCardHeader'; +import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; +import { FundCardProvider } from './FundCardProvider'; +import { useFundContext } from './FundCardProvider'; +//import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; const defaultPaymentMethods: PaymentMethodReact[] = [ { - id: 'CRYPTO_ACCOUNT', + id: 'FIAT_WALLET', name: 'Coinbase', description: 'Buy with your Coinbase account', icon: 'coinbasePay', @@ -57,7 +56,7 @@ export function FundCard({ 'flex w-[440px] flex-col p-6', text.headline, border.radius, - border.lineDefault + border.lineDefault, )} > { - if (selectedInputType === 'fiat') { - return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x438BbEF3525eF1b0359160FD78AF9c1158485d87":["base"]}&assets=["${assetSymbol}"]&presetFiatAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; - } - - return `${ONRAMP_BUY_URL}/one-click?appId=6eceb045-266a-4940-9d22-35952496ff00&addresses={"0x438BbEF3525eF1b0359160FD78AF9c1158485d87":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${selectedPaymentMethod?.id}`; - }, [assetSymbol, fundAmount, selectedPaymentMethod, selectedInputType]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to run this effect once - useEffect(() => { - setupOnrampEventListeners({ - onEvent: (event) => { - if (event.eventName === 'error') { - setSubmitButtonState('error'); - - setTimeout(() => { - setSubmitButtonState('default'); - }, FUND_BUTTON_RESET_TIMEOUT); - } - }, - onExit: (event) => { - setSubmitButtonState('default'); - console.log('onExit', event); - }, - onSuccess: () => { - setSubmitButtonState('success'); + const fundingUrl = 'test'; //useFundCardFundingUrl(); - setTimeout(() => { - setSubmitButtonState('default'); - }, FUND_BUTTON_RESET_TIMEOUT); - }, - }); - }, []); + // Setup event listeners for the onramp + useFundCardSetupOnrampEventListeners(); return ( -
+ setSubmitButtonState('loading')} - onPopupClose={() => setSubmitButtonState('default')} + //onPopupClose={() => setSubmitButtonState('default')} /> ); diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index c6695445ac..2e169ac0c1 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -1,8 +1,8 @@ import '@testing-library/jest-dom'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { FundCardAmountInput } from './FundCardAmountInput'; import type { FundCardAmountInputPropsReact } from '../types'; +import { FundCardAmountInput } from './FundCardAmountInput'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: () => 'mocked-theme-class', @@ -22,18 +22,18 @@ describe('FundCardAmountInput', () => { it('renders correctly with fiat input type', () => { render(); - expect(screen.getByPlaceholderText('0')).toBeInTheDocument(); - expect(screen.getByText('$')).toBeInTheDocument(); + expect(screen.getByTestId('ockFundCardAmountInput')).toBeInTheDocument(); + expect(screen.getByTestId('currencySpan')).toHaveTextContent('$'); }); it('renders correctly with crypto input type', () => { render(); - expect(screen.getByText('ETH')).toBeInTheDocument(); + expect(screen.getByTestId('currencySpan')).toHaveTextContent('ETH'); }); it('handles fiat input change', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '10' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('10'); expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('5'); @@ -41,7 +41,7 @@ describe('FundCardAmountInput', () => { it('handles crypto input change', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '0.1' } }); expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.1'); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.05'); @@ -49,14 +49,14 @@ describe('FundCardAmountInput', () => { it('formats input value correctly when starting with a dot', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '.5' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.5'); }); it('formats input value correctly when starting with zero', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '01' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.1'); expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.05'); @@ -64,30 +64,52 @@ describe('FundCardAmountInput', () => { it('limits decimal places to two', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '123.456' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('123.45'); }); it('focuses input when input type changes', () => { const { rerender } = render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); const focusSpy = vi.spyOn(input, 'focus'); rerender(); expect(focusSpy).toHaveBeenCalled(); }); - it('does not set crypto value to "0" when input is "0"', () => { + it('sets crypto value to empty string when input is "0"', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '0' } }); expect(defaultProps.setCryptoValue).toHaveBeenCalledWith(''); }); - it('does not set fiat value to "0" when crypto input is "0"', () => { + it('sets fiat value to empty string when crypto input is "0"', () => { render(); - const input = screen.getByPlaceholderText('0') as HTMLInputElement; + const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '0' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith(''); }); + + it('correctly handles when exchange rate is not available', () => { + render(); + const input = screen.getByTestId('ockFundCardAmountInput'); + fireEvent.change(input, { target: { value: '200' } }); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith(''); + expect(defaultProps.setFiatValue).toHaveBeenCalledWith('200'); + }); + + it('hidden span has correct text value when value exist', async () => { + render(); + const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + + expect(hiddenSpan).toHaveTextContent('100.'); + }); + + it('hidden span has correct text value when value does not exist', async () => { + render(); + const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + + expect(hiddenSpan).toHaveTextContent('0.'); + }); }); diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 5c8d420c4e..913dddd06b 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -1,8 +1,8 @@ import { type ChangeEvent, useCallback, useEffect, useRef } from 'react'; -import { cn, text } from '../../styles/theme'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; -import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import { cn, text } from '../../styles/theme'; import type { FundCardAmountInputPropsReact } from '../types'; +import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; export const FundCardAmountInput = ({ fiatValue, @@ -12,7 +12,7 @@ export const FundCardAmountInput = ({ currencySign, assetSymbol, inputType = 'fiat', - exchangeRate, + exchangeRate = 1, }: FundCardAmountInputPropsReact) => { const componentTheme = useTheme(); @@ -23,41 +23,41 @@ export const FundCardAmountInput = ({ const value = inputType === 'fiat' ? fiatValue : cryptoValue; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simplifyed the function as much as possible - const handleChange = useCallback((e: ChangeEvent) => { - let value = e.target.value; - - value = formatDecimalInputValue(value); - - if (inputType === 'fiat') { - const fiatValue = limitToTwoDecimalPlaces(value); - setFiatValue(fiatValue); - - // Calculate the crypto value based on the exchange rate - const calculatedCryptoValue = String( - Number(value) / Number(exchangeRate || 1) - ); - setCryptoValue( - calculatedCryptoValue === '0' ? '' : calculatedCryptoValue - ); - } else { - setCryptoValue(value); - - // Calculate the fiat value based on the exchange rate - const calculatedFiatValue = String( - Number(value) / Number(exchangeRate || 1) - ); - const resultFiatValue = limitToTwoDecimalPlaces(calculatedFiatValue); - setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); - } - },[exchangeRate, setFiatValue, setCryptoValue, inputType]); + const handleChange = useCallback( + (e: ChangeEvent) => { + let value = e.target.value; + + value = formatDecimalInputValue(value); + + if (inputType === 'fiat') { + const fiatValue = limitToTwoDecimalPlaces(value); + setFiatValue(fiatValue); + + // Calculate the crypto value based on the exchange rate + const calculatedCryptoValue = String( + Number(value) / Number(exchangeRate), + ); + setCryptoValue( + calculatedCryptoValue === '0' ? '' : calculatedCryptoValue, + ); + } else { + setCryptoValue(value); + + // Calculate the fiat value based on the exchange rate + const calculatedFiatValue = String( + Number(value) / Number(exchangeRate), + ); + const resultFiatValue = limitToTwoDecimalPlaces(calculatedFiatValue); + setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); + } + }, + [exchangeRate, setFiatValue, setCryptoValue, inputType], + ); // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width useEffect(() => { - if (hiddenSpanRef.current) { // istanbul ignore next - const width =// istanbul ignore next - hiddenSpanRef.current.offsetWidth < 42// istanbul ignore next - ? 42// istanbul ignore next - : hiddenSpanRef.current.offsetWidth; // istanbul ignore next + if (hiddenSpanRef.current) { + const width = Math.max(42, hiddenSpanRef.current.offsetWidth); const currencyWidth = currencySpanRef.current?.getBoundingClientRect().width || 0; @@ -108,7 +108,7 @@ export const FundCardAmountInput = ({ componentTheme, text.body, 'border-[none] bg-transparent', - 'text-[60px] leading-none outline-none' + 'text-[60px] leading-none outline-none', )} type="number" value={value} @@ -142,7 +142,7 @@ export const FundCardAmountInput = ({ componentTheme, text.body, 'border-[none] bg-transparent', - 'text-[60px] leading-none outline-none' + 'text-[60px] leading-none outline-none', )} style={{ position: 'absolute', diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx index 02036885e9..d0102aeca7 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { FundCardAmountInputTypeSwitch } from './FundCardAmountInputTypeSwitch'; @@ -18,7 +18,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="100" fundAmountCrypto="200" exchangeRate={1} - /> + />, ); expect(screen.getByText('200.00000000 ETH')).toBeInTheDocument(); }); @@ -33,7 +33,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="100" fundAmountCrypto="200" exchangeRate={2} - /> + />, ); expect(screen.getByText('$100.00')).toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="100" fundAmountCrypto="0.01" exchangeRate={10000} - /> + />, ); fireEvent.click(screen.getByLabelText(/amount type switch/i)); expect(setSelectedInputType).toHaveBeenCalledWith('crypto'); @@ -66,7 +66,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="100" fundAmountCrypto="0.01" exchangeRate={10000} - /> + />, ); fireEvent.click(screen.getByLabelText(/amount type switch/i)); expect(setSelectedInputType).toHaveBeenCalledWith('fiat'); @@ -82,7 +82,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="0" fundAmountCrypto="0" exchangeRate={2} - /> + />, ); expect(screen.queryByText('$0.00')).not.toBeInTheDocument(); }); @@ -97,7 +97,7 @@ describe('FundCardAmountInputTypeSwitch', () => { fundAmountFiat="100" fundAmountCrypto="0.01" exchangeRate={undefined} - /> + />, ); expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); }); diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx index 97114afb47..e27bb8bed8 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -1,9 +1,9 @@ -import { cn, color, pressable, text } from '../../styles/theme'; import { useCallback, useMemo } from 'react'; +import { useIcon } from '../../core-react/internal/hooks/useIcon'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; -import { useIcon } from '../../core-react/internal/hooks/useIcon'; import { Skeleton } from '../../internal/components/Skeleton'; +import { cn, color, pressable, text } from '../../styles/theme'; import type { FundCardAmountInputTypeSwitchPropsReact } from '../types'; export const FundCardAmountInputTypeSwitch = ({ @@ -38,7 +38,7 @@ export const FundCardAmountInputTypeSwitch = ({ text.label2, color.foregroundMuted, 'font-normal', - 'pl-1' + 'pl-1', )} > ({formatUSD('1')} = {exchangeRate?.toFixed(8)} {selectedAsset}) @@ -62,8 +62,8 @@ export const FundCardAmountInputTypeSwitch = ({ ); }, [formatUSD, fundAmountFiat, componentTheme]); - if(isLoading || !exchangeRate) { - return + if (isLoading || !exchangeRate) { + return ; } return ( @@ -73,8 +73,9 @@ export const FundCardAmountInputTypeSwitch = ({ aria-label="amount type switch" className={cn( pressable.default, - 'mr-1 rounded-full p-1 opacity-50 transition-opacity hover:opacity-100' + 'mr-1 rounded-full p-1 opacity-50 transition-opacity hover:opacity-100', )} + data-testid="ockAmountTypeSwitch" onClick={handleToggle} >
{iconSvg}
diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/fund/components/FundCardCurrencyLabel.test.tsx index 289ec6cb8f..79bbb0aef6 100644 --- a/src/fund/components/FundCardCurrencyLabel.test.tsx +++ b/src/fund/components/FundCardCurrencyLabel.test.tsx @@ -17,7 +17,7 @@ describe('FundCardCurrencyLabel', () => { render(); const spanElement = screen.getByText('$'); expect(spanElement).toHaveClass( - 'flex items-center justify-center bg-transparent text-[60px] leading-none outline-none' + 'flex items-center justify-center bg-transparent text-[60px] leading-none outline-none', ); }); }); diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx index 80553ce771..7fd7f50e62 100644 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -16,7 +16,7 @@ export const FundCardCurrencyLabel = forwardRef< componentTheme, text.body, 'flex items-center justify-center bg-transparent', - 'text-[60px] leading-none outline-none' + 'text-[60px] leading-none outline-none', )} data-testid="currencySpan" > diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index 4bb199133f..fa62ee80a4 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -14,7 +14,9 @@ describe('FundCardHeader', () => { it('renders the provided headerText', () => { render(); - expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Custom Header'); + expect(screen.getByTestId('fundCardHeader')).toHaveTextContent( + 'Custom Header', + ); }); it('renders the default header text when headerText is not provided', () => { @@ -26,4 +28,4 @@ describe('FundCardHeader', () => { render(); expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Buy USDT'); }); -}); \ No newline at end of file +}); diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index b801a9465c..cef036e307 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -2,7 +2,10 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { cn } from '../../styles/theme'; import type { FundCardHeaderPropsReact } from '../types'; -export function FundCardHeader({ headerText, assetSymbol }: FundCardHeaderPropsReact) { +export function FundCardHeader({ + headerText, + assetSymbol, +}: FundCardHeaderPropsReact) { const componentTheme = useTheme(); const defaultHeaderText = `Buy ${assetSymbol.toUpperCase()}`; @@ -11,7 +14,7 @@ export function FundCardHeader({ headerText, assetSymbol }: FundCardHeaderPropsR className={cn( componentTheme, 'font-display text-[16px]', - 'leading-none outline-none' + 'leading-none outline-none', )} data-testid="fundCardHeader" > diff --git a/src/fund/components/FundCardPaymentMethodImage.test.tsx b/src/fund/components/FundCardPaymentMethodImage.test.tsx index 37b6bf0e3a..31e109f435 100644 --- a/src/fund/components/FundCardPaymentMethodImage.test.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.test.tsx @@ -1,8 +1,8 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; -import { describe, expect, it, type Mock, vi } from 'vitest'; -import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; +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/useTheme', () => ({ useTheme: vi.fn(), @@ -15,16 +15,40 @@ vi.mock('../../core-react/internal/hooks/useIcon', () => ({ describe('FundCardPaymentMethodImage', () => { it('renders the icon when iconSvg is available', () => { (useIcon as Mock).mockReturnValue(() => ); - render(); - expect(screen.queryByTestId('fundCardPaymentMethodImage__iconContainer')).toBeInTheDocument(); - expect(screen.queryByTestId('fundCardPaymentMethodImage__noImage')).not.toBeInTheDocument(); + render( + , + ); + expect( + screen.queryByTestId('fundCardPaymentMethodImage__iconContainer'), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('fundCardPaymentMethodImage__noImage'), + ).not.toBeInTheDocument(); }); it('renders the placeholder when iconSvg is not available', () => { (useIcon as Mock).mockReturnValue(null); - render(); - expect(screen.queryByTestId('fundCardPaymentMethodImage__noImage')).toBeInTheDocument(); + render( + , + ); + expect( + screen.queryByTestId('fundCardPaymentMethodImage__noImage'), + ).toBeInTheDocument(); }); it('applies primary color when the icon is coinbasePay', () => { @@ -34,12 +58,14 @@ describe('FundCardPaymentMethodImage', () => { + />, ); - expect(screen.getByTestId('fundCardPaymentMethodImage__iconContainer')).toBeInTheDocument(); + expect( + screen.getByTestId('fundCardPaymentMethodImage__iconContainer'), + ).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/src/fund/components/FundCardPaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx index bd300b6ba4..e6e3231d40 100644 --- a/src/fund/components/FundCardPaymentMethodImage.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -1,9 +1,13 @@ import { useMemo } from 'react'; -import { cn, icon as iconTheme} from '../../styles/theme'; import { useIcon } from '../../core-react/internal/hooks/useIcon'; +import { cn, icon as iconTheme } from '../../styles/theme'; import type { FundCardPaymentMethodImagePropsReact } from '../types'; -export function FundCardPaymentMethodImage({ className, size = 24, paymentMethod }: FundCardPaymentMethodImagePropsReact) { +export function FundCardPaymentMethodImage({ + className, + size = 24, + paymentMethod, +}: FundCardPaymentMethodImagePropsReact) { const { icon } = paymentMethod; // Special case for coinbasePay icon color @@ -41,7 +45,14 @@ export function FundCardPaymentMethodImage({ className, size = 24, paymentMethod } return ( -
+
{iconSvg}
); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx new file mode 100644 index 0000000000..c46409682b --- /dev/null +++ b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { PaymentMethodReact } from '../types'; +import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; +import { FundCardProvider } from './FundCardProvider'; + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +const paymentMethods = [ + { + icon: 'sampleIcon', + id: 'ACH_BANK_ACCOUNT', + name: 'Bank account', + description: 'Up to $500', + }, + { + icon: 'anotherIcon', + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500', + }, +] as PaymentMethodReact[]; + +describe('FundCardPaymentMethodSelectRow', () => { + it('renders payment method name and description', () => { + render( + + + , + ); + expect(screen.getByText('Bank account')).toBeInTheDocument(); + expect(screen.getByText('Up to $500')).toBeInTheDocument(); + }); + + it('calls onClick with payment method when clicked', () => { + const handleClick = vi.fn(); + render( + + + , + ); + const button = screen.getByTestId( + 'ockFundCardPaymentMethodSelectRow__button', + ); + act(() => { + button.click(); + }); + expect(handleClick).toHaveBeenCalledWith(paymentMethods[1]); + }); +}); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx index dcc797c5bd..d1f6fde532 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -1,41 +1,48 @@ import { memo } from 'react'; -import { cn, color, pressable, text } from '../../styles/theme'; -import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import { cn, color, pressable, text } from '../../styles/theme'; import type { FundCardPaymentMethodSelectRowPropsReact } from '../types'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; -export const FundCardPaymentMethodSelectRow = memo(({ - className, - paymentMethod, - onClick, - hideImage, - hideDescription, -}: FundCardPaymentMethodSelectRowPropsReact) => { - const componentTheme = useTheme(); +export const FundCardPaymentMethodSelectRow = memo( + ({ + className, + paymentMethod, + onClick, + hideImage, + hideDescription, + }: FundCardPaymentMethodSelectRowPropsReact) => { + const componentTheme = useTheme(); - return ( - - ); -}); + + ); + }, +); diff --git a/src/fund/components/FundCardPaymentMethodSelectorDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodSelectorDropdown.test.tsx index 8619f96448..b18dc3fe6b 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorDropdown.test.tsx @@ -1,10 +1,10 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; +import { act } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import type { PaymentMethodReact } from '../types'; +import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import { FundCardProvider } from './FundCardProvider'; -import { act } from 'react'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: vi.fn(), @@ -29,7 +29,7 @@ const renderComponent = () => render( - + , ); describe('FundCardPaymentMethodSelectorDropdown', () => { @@ -41,40 +41,40 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { renderComponent(); expect( screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName' - ) + 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', + ), ).toBeInTheDocument(); }); it('toggles the dropdown when the toggle button is clicked', () => { renderComponent(); const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle' + 'ockFundCardPaymentMethodSelectorToggle', ); // Initially closed expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).not.toBeInTheDocument(); // Click to open act(() => { toggleButton.click(); }); expect( - screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).toBeInTheDocument(); // Click to close act(() => { toggleButton.click(); }); expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).not.toBeInTheDocument(); }); it('selects a payment method and updates the selection', () => { renderComponent(); const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle' + 'ockFundCardPaymentMethodSelectorToggle', ); act(() => { toggleButton.click(); @@ -85,14 +85,14 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); expect(screen.getByText('Apple Pay')).toBeInTheDocument(); expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).not.toBeInTheDocument(); }); it('closes the dropdown when clicking outside', () => { renderComponent(); const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle' + 'ockFundCardPaymentMethodSelectorToggle', ); // Open the dropdown @@ -101,7 +101,7 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); expect( - screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).toBeInTheDocument(); // Click outside @@ -110,7 +110,7 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); // Assert dropdown is closed expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown') + screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), ).not.toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx b/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx index 883ac19c1c..207152da4d 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx @@ -1,25 +1,30 @@ -import { useCallback, useRef, useState, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { background, border, cn } from '../../styles/theme'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; +import type { + FundCardPaymentMethodSelectorDropdownPropsReact, + PaymentMethodReact, +} from '../types'; import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; import { FundCardPaymentMethodSelectorToggle } from './FundCardPaymentMethodSelectorToggle'; import { useFundContext } from './FundCardProvider'; -import { useTheme } from '../../core-react/internal/hooks/useTheme'; -import type { FundCardPaymentMethodSelectorDropdownPropsReact, PaymentMethodReact } from '../types'; -export function FundCardPaymentMethodSelectorDropdown({ paymentMethods }: FundCardPaymentMethodSelectorDropdownPropsReact) { +export function FundCardPaymentMethodSelectorDropdown({ + paymentMethods, +}: FundCardPaymentMethodSelectorDropdownPropsReact) { const componentTheme = useTheme(); const [isOpen, setIsOpen] = useState(false); - const { - selectedPaymentMethod, - setSelectedPaymentMethod, - } = useFundContext(); + const { selectedPaymentMethod, setSelectedPaymentMethod } = useFundContext(); - const handlePaymentMethodSelect = useCallback((paymentMethod: PaymentMethodReact) => { - setSelectedPaymentMethod(paymentMethod); - setIsOpen(false); - }, [setSelectedPaymentMethod]); + const handlePaymentMethodSelect = useCallback( + (paymentMethod: PaymentMethodReact) => { + setSelectedPaymentMethod(paymentMethod); + setIsOpen(false); + }, + [setSelectedPaymentMethod], + ); const handleToggle = useCallback(() => { setIsOpen(!isOpen); @@ -70,7 +75,7 @@ export function FundCardPaymentMethodSelectorDropdown({ paymentMethods }: FundCa componentTheme, border.radius, 'absolute right-0 z-10 mt-1 flex max-h-80 w-full flex-col overflow-y-hidden', - 'ock-scrollbar' + 'ock-scrollbar', )} >
diff --git a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx index bf877390a4..d1343a6579 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -2,8 +2,8 @@ import { type ForwardedRef, forwardRef } from 'react'; import { caretDownSvg } from '../../internal/svg/caretDownSvg'; import { caretUpSvg } from '../../internal/svg/caretUpSvg'; import { border, cn, color, pressable, text } from '../../styles/theme'; -import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; import type { FundCardPaymentMethodSelectorTogglePropsReact } from '../types'; +import { FundCardPaymentMethodImage } from './FundCardPaymentMethodImage'; export const FundCardPaymentMethodSelectorToggle = forwardRef( ( @@ -13,7 +13,7 @@ export const FundCardPaymentMethodSelectorToggle = forwardRef( isOpen, className, }: FundCardPaymentMethodSelectorTogglePropsReact, - ref: ForwardedRef + ref: ForwardedRef, ) => { return ( ); - } + }, ); diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 9f85da4e34..a6a7ba8d3b 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -1,9 +1,13 @@ import { createContext, useContext, useState } from 'react'; import { useValue } from '../../core-react/internal/hooks/useValue'; -import type { FundButtonStateReact, FundCardProviderReact, PaymentMethodReact } from '../types'; +import type { + FundButtonStateReact, + FundCardProviderReact, + PaymentMethodReact, +} from '../types'; type FundCardContextType = { - selectedAsset?: string; + selectedAsset: string; setSelectedAsset: (asset: string) => void; selectedPaymentMethod?: PaymentMethodReact; setSelectedPaymentMethod: (paymentMethod: PaymentMethodReact) => void; @@ -24,14 +28,21 @@ type FundCardContextType = { const FundContext = createContext(undefined); export function FundCardProvider({ children, asset }: FundCardProviderReact) { - const [selectedAsset, setSelectedAsset] = useState(asset); - const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(); - const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>('fiat'); + const [selectedAsset, setSelectedAsset] = useState(asset); + const [selectedPaymentMethod, setSelectedPaymentMethod] = useState< + PaymentMethodReact | undefined + >(); + const [selectedInputType, setSelectedInputType] = useState<'fiat' | 'crypto'>( + 'fiat', + ); const [fundAmountFiat, setFundAmountFiat] = useState(''); const [fundAmountCrypto, setFundAmountCrypto] = useState(''); const [exchangeRate, setExchangeRate] = useState(); - const [exchangeRateLoading, setExchangeRateLoading] = useState(); - const [submitButtonState, setSubmitButtonState] = useState('default'); + const [exchangeRateLoading, setExchangeRateLoading] = useState< + boolean | undefined + >(); + const [submitButtonState, setSubmitButtonState] = + useState('default'); const value = useValue({ selectedAsset, diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index f8970fe115..70f8071d6e 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; -import { FundCardProvider, useFundContext } from './FundCardProvider'; -import { describe, expect, it } from 'vitest'; import { act } from 'react'; +import { describe, expect, it } from 'vitest'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; const TestComponent = () => { const context = useFundContext(); @@ -17,7 +17,7 @@ describe('FundCardProvider', () => { render( - + , ); expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); }); @@ -28,7 +28,9 @@ describe('FundCardProvider', () => { return (
{selectedAsset} - +
); }; @@ -36,12 +38,12 @@ describe('FundCardProvider', () => { render( - + , ); - + expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); act(() => { - screen.getByText('Change Asset').click(); + screen.getByText('Change Asset').click(); }); expect(screen.getByTestId('selected-asset').textContent).toBe('ETH'); }); @@ -53,7 +55,7 @@ describe('FundCardProvider', () => { }; expect(() => render()).toThrow( - 'useFundContext must be used within a FundCardProvider' + 'useFundContext must be used within a FundCardProvider', ); }); }); diff --git a/src/fund/constants.ts b/src/fund/constants.ts index 251372a0c5..f345e65cc1 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -8,4 +8,4 @@ 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; \ No newline at end of file +export const FUND_BUTTON_RESET_TIMEOUT = 3000; diff --git a/src/fund/hooks/useExchangeRate.test.ts b/src/fund/hooks/useExchangeRate.test.ts index d630e0ee37..2bdc1a6361 100644 --- a/src/fund/hooks/useExchangeRate.test.ts +++ b/src/fund/hooks/useExchangeRate.test.ts @@ -1,8 +1,8 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import { renderHook } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useExchangeRate } from './useExchangeRate'; import { useFundContext } from '../components/FundCardProvider'; -import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { useExchangeRate } from './useExchangeRate'; const mockResponseData = { payment_total: { value: '100.00', currency: 'USD' }, @@ -16,7 +16,7 @@ const mockResponseData = { global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve(mockResponseData), - }) + }), ) as Mock; vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ diff --git a/src/fund/hooks/useExchangeRate.ts b/src/fund/hooks/useExchangeRate.ts index 4642245c4a..54c84c784b 100644 --- a/src/fund/hooks/useExchangeRate.ts +++ b/src/fund/hooks/useExchangeRate.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; -import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { useDebounce } from '../../core-react/internal/hooks/useDebounce'; import { useFundContext } from '../components/FundCardProvider'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; export const useExchangeRate = (assetSymbol: string) => { const { setExchangeRate, exchangeRateLoading, setExchangeRateLoading } = @@ -22,8 +22,9 @@ export const useExchangeRate = (assetSymbol: string) => { }); setExchangeRateLoading(false); + setExchangeRate( - Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value) + Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), ); }, 1000); diff --git a/src/fund/hooks/useFundCardFundingUrl.test.ts b/src/fund/hooks/useFundCardFundingUrl.test.ts new file mode 100644 index 0000000000..8adfd4c9c0 --- /dev/null +++ b/src/fund/hooks/useFundCardFundingUrl.test.ts @@ -0,0 +1,143 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { renderHook } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFundContext } from '../components/FundCardProvider'; +import { useExchangeRate } from './useExchangeRate'; +import { useFundCardFundingUrl } from './useFundCardFundingUrl'; +import { useOnchainKit } from '@/core-react/useOnchainKit'; +import { useAccount } from 'wagmi'; + +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', + selectedAsset: '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', + selectedAsset: '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', + selectedAsset: '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', + selectedAsset: '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', + selectedAsset: 'ETH', + }); + + const { result } = renderHook(() => useFundCardFundingUrl()); + expect(result.current).toContain('addresses={"0x123":["base"]}'); + }); +}); diff --git a/src/fund/hooks/useFundCardFundingUrl.ts b/src/fund/hooks/useFundCardFundingUrl.ts new file mode 100644 index 0000000000..63c28a250f --- /dev/null +++ b/src/fund/hooks/useFundCardFundingUrl.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { useFundContext } from '../components/FundCardProvider'; +import { useOnchainKit } from '@/core-react/useOnchainKit'; +import { useAccount } from 'wagmi'; +import { getOnrampBuyUrl } from '../utils/getOnrampBuyUrl'; + +export const useFundCardFundingUrl = () => { + const { projectId, chain: defaultChain } = useOnchainKit(); + const { address, chain: accountChain } = useAccount(); + const { + selectedPaymentMethod, + selectedInputType, + fundAmountFiat, + fundAmountCrypto, + selectedAsset, + } = useFundContext(); + + const chain = accountChain || defaultChain; + + return useMemo(() => { + if (projectId === null || address === undefined) { + return undefined; + } + + const fundAmount = + selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; + + return getOnrampBuyUrl({ + projectId, + assets: [selectedAsset], + presetFiatAmount: + selectedInputType === 'fiat' ? Number(fundAmount) : undefined, + presetCryptoAmount: + selectedInputType === 'crypto' ? Number(fundAmount) : undefined, + defaultPaymentMethod: selectedPaymentMethod?.id, + addresses: { [address]: [chain.name.toLowerCase()] }, + }); + }, [ + selectedAsset, + fundAmountFiat, + fundAmountCrypto, + selectedPaymentMethod, + selectedInputType, + projectId, + address, + chain, + ]); +}; diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.ts b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.ts new file mode 100644 index 0000000000..39699d8484 --- /dev/null +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.ts @@ -0,0 +1,130 @@ +import { renderHook } from '@testing-library/react'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { useFundContext } from '../components/FundCardProvider'; +import { FUND_BUTTON_RESET_TIMEOUT } from '../constants'; +import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; +import { useFundCardSetupOnrampEventListeners } from './useFundCardSetupOnrampEventListeners'; + +vi.mock('../components/FundCardProvider', () => ({ + useFundContext: vi.fn(), +})); + +vi.mock('../utils/setupOnrampEventListeners', () => ({ + setupOnrampEventListeners: vi.fn(), +})); + +describe('useFundCardSetupOnrampEventListeners', () => { + const mockSetSubmitButtonState = vi.fn(); + let unsubscribeMock = vi.fn(); + + beforeEach(() => { + unsubscribeMock = vi.fn(); + + (useFundContext as Mock).mockReturnValue({ + setSubmitButtonState: mockSetSubmitButtonState, + }); + + // Mock setupOnrampEventListeners to return unsubscribe + (setupOnrampEventListeners as Mock).mockReturnValue(unsubscribeMock); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should subscribe to events on mount and unsubscribe on unmount', () => { + const { unmount } = renderHook(() => + useFundCardSetupOnrampEventListeners(), + ); + + // Verify setupOnrampEventListeners is called + expect(setupOnrampEventListeners).toHaveBeenCalledWith( + expect.objectContaining({ + onEvent: expect.any(Function), + onExit: expect.any(Function), + onSuccess: expect.any(Function), + }), + ); + + // Verify unsubscribe is called on unmount + unmount(); + expect(unsubscribeMock).toHaveBeenCalled(); + }); + + it('should set button state to "error" and reset after timeout on error event', () => { + vi.useFakeTimers(); // Use fake timers to test timeout behavior + + let onEventCallback = vi.fn(); + (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { + onEventCallback = onEvent; + return unsubscribeMock; + }); + + renderHook(() => useFundCardSetupOnrampEventListeners()); + + // Simulate error event + onEventCallback({ eventName: 'error' }); + + expect(mockSetSubmitButtonState).toHaveBeenCalledWith('error'); + + // Simulate timeout + vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); + + expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); + + vi.useRealTimers(); + }); + + it('should set button state to "success" and reset after timeout on success event', () => { + vi.useFakeTimers(); + + let onSuccessCallback = vi.fn(); + (setupOnrampEventListeners as Mock).mockImplementation(({ onSuccess }) => { + onSuccessCallback = onSuccess; + return unsubscribeMock; + }); + + renderHook(() => useFundCardSetupOnrampEventListeners()); + + // Simulate success event + onSuccessCallback(); + + expect(mockSetSubmitButtonState).toHaveBeenCalledWith('success'); + + // Simulate timeout + vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); + + expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); + + vi.useRealTimers(); + }); + + it('should set button state to "default" and log on exit event', () => { + const consoleSpy = vi.spyOn(console, 'log'); + let onExitCallback = vi.fn(); + + (setupOnrampEventListeners as Mock).mockImplementation(({ onExit }) => { + onExitCallback = onExit; + return unsubscribeMock; + }); + + renderHook(() => useFundCardSetupOnrampEventListeners()); + + // Simulate exit event + const mockEvent = { reason: 'user_cancelled' }; + onExitCallback(mockEvent); + + expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); + expect(consoleSpy).toHaveBeenCalledWith('onExit', mockEvent); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts new file mode 100644 index 0000000000..5df0c2893d --- /dev/null +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useFundContext } from '../components/FundCardProvider'; +import { FUND_BUTTON_RESET_TIMEOUT } from '../constants'; +import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; + +export const useFundCardSetupOnrampEventListeners = () => { + const { setSubmitButtonState } = useFundContext(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to run this effect once + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: (event) => { + if (event.eventName === 'error') { + setSubmitButtonState('error'); + + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); + } + }, + onExit: (event) => { + setSubmitButtonState('default'); + console.log('onExit', event); + }, + onSuccess: () => { + setSubmitButtonState('success'); + + setTimeout(() => { + setSubmitButtonState('default'); + }, FUND_BUTTON_RESET_TIMEOUT); + }, + }); + + return () => { + unsubscribe(); + }; + }, []); +}; diff --git a/src/fund/types.ts b/src/fund/types.ts index 224cac11bf..63d4e2c67d 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from 'react'; /** * Props used to get an Onramp buy URL by directly providing a CDP project ID. @@ -90,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. */ @@ -286,16 +291,18 @@ export type FundCardHeaderPropsReact = { export type FundCardPaymentMethodImagePropsReact = { className?: string; size?: number; - paymentMethod: PaymentMethodReact + paymentMethod: PaymentMethodReact; }; +export type PaymentAccountReact = + | 'CRYPTO_ACCOUNT' + | 'FIAT_WALLET' + | 'CARD' + | 'ACH_BANK_ACCOUNT' + | 'APPLE_PAY'; + export type PaymentMethodReact = { - id: - | 'CRYPTO_ACCOUNT' - | 'FIAT_WALLET' - | 'CARD' - | 'ACH_BANK_ACCOUNT' - | 'APPLE_PAY'; + id: PaymentAccountReact; name: string; description: string; icon: string; @@ -305,7 +312,6 @@ export type FundCardPaymentMethodSelectorDropdownPropsReact = { paymentMethods: PaymentMethodReact[]; }; - export type FundCardCurrencyLabelPropsReact = { currencySign: string; }; @@ -349,8 +355,8 @@ export type FundCardPaymentMethodSelectorTogglePropsReact = { className?: string; isOpen: boolean; // Determines carot icon direction onClick: () => void; // Button on click handler - paymentMethod: PaymentMethodReact -} + paymentMethod: PaymentMethodReact; +}; export type FundCardPaymentMethodSelectRowPropsReact = { className?: string; @@ -358,7 +364,7 @@ export type FundCardPaymentMethodSelectRowPropsReact = { onClick?: (paymentMethod: PaymentMethodReact) => void; hideImage?: boolean; hideDescription?: boolean; -} +}; export type FundCardProviderReact = { children: ReactNode; diff --git a/src/fund/utils/fetchOnrampQuote.test.ts b/src/fund/utils/fetchOnrampQuote.test.ts index a0424b3cc1..477913f736 100644 --- a/src/fund/utils/fetchOnrampQuote.test.ts +++ b/src/fund/utils/fetchOnrampQuote.test.ts @@ -24,7 +24,7 @@ const mockResponseData = { global.fetch = vi.fn(() => Promise.resolve({ json: () => Promise.resolve(mockResponseData), - }) + }), ) as Mock; describe('fetchOnrampQuote', () => { @@ -60,7 +60,7 @@ describe('fetchOnrampQuote', () => { headers: { Authorization: `Bearer ${mockApiKey}`, }, - } + }, ); expect(result).toEqual({ paymentSubtotal: { amount: '100.00', currency: 'USD' }, @@ -74,7 +74,7 @@ describe('fetchOnrampQuote', () => { it('should throw an error if fetch fails', async () => { global.fetch = vi.fn(() => - Promise.reject(new Error('Fetch failed')) + Promise.reject(new Error('Fetch failed')), ) as Mock; await expect( @@ -86,7 +86,7 @@ describe('fetchOnrampQuote', () => { paymentAmount: mockPaymentAmount, country: mockCountry, subdivision: mockSubdivision, - }) + }), ).rejects.toThrow('Fetch failed'); }); }); diff --git a/src/fund/utils/setupOnrampEventListeners.test.ts b/src/fund/utils/setupOnrampEventListeners.test.ts index 8e76ec565d..8b08f23aa1 100644 --- a/src/fund/utils/setupOnrampEventListeners.test.ts +++ b/src/fund/utils/setupOnrampEventListeners.test.ts @@ -43,7 +43,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onSuccess).toHaveBeenCalled(); @@ -66,7 +66,7 @@ describe('setupOnrampEventListeners', () => { }, }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onExit).toHaveBeenCalledWith(eventMetadata.error); @@ -84,7 +84,7 @@ describe('setupOnrampEventListeners', () => { const eventMetadata: EventMetadata = { eventName: 'success' }; vi.mocked(subscribeToWindowMessage).mock.calls[0][0].onMessage( - eventMetadata + eventMetadata, ); expect(onEvent).toHaveBeenCalledWith(eventMetadata); diff --git a/src/internal/components/Skeleton.tsx b/src/internal/components/Skeleton.tsx index 2f422f6605..daba1c1ba9 100644 --- a/src/internal/components/Skeleton.tsx +++ b/src/internal/components/Skeleton.tsx @@ -11,8 +11,7 @@ export function Skeleton({ className }: SkeletonReact) { 'animate-pulse bg-opacity-50', background.alternate, 'rounded', - className - + className, )} data-testid="ockSkeleton" /> diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index 13cc96b098..dd01b3b2dc 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -46,7 +46,7 @@ export function TextInput({ } } }, - [onChange, handleDebounce, delayMs, setValue, inputValidator] + [onChange, handleDebounce, delayMs, setValue, inputValidator], ); return ( diff --git a/src/internal/hooks/useIcon.test.tsx b/src/internal/hooks/useIcon.test.tsx index ba3173deec..32d70ae387 100644 --- a/src/internal/hooks/useIcon.test.tsx +++ b/src/internal/hooks/useIcon.test.tsx @@ -1,13 +1,13 @@ import { renderHook } from '@testing-library/react'; -import { useIcon } from './useIcon'; -import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { describe, expect, it } from 'vitest'; import { toggleSvg } from '../../internal/svg/toggleSvg'; import { applePaySvg } from '../svg/applePaySvg'; +import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { creditCardSvg } from '../svg/creditCardSvg'; import { fundWalletSvg } from '../svg/fundWallet'; import { swapSettingsSvg } from '../svg/swapSettings'; import { walletSvg } from '../svg/walletSvg'; -import { creditCardSvg } from '../svg/creditCardSvg'; -import { describe, expect, it } from 'vitest'; +import { useIcon } from './useIcon'; describe('useIcon', () => { it('returns CoinbasePaySvg when icon is "coinbasePay"', () => { diff --git a/src/internal/hooks/useIcon.tsx b/src/internal/hooks/useIcon.tsx index 498d372a02..6cfb13413f 100644 --- a/src/internal/hooks/useIcon.tsx +++ b/src/internal/hooks/useIcon.tsx @@ -1,13 +1,16 @@ import { isValidElement, useMemo } from 'react'; -import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; import { toggleSvg } from '../../internal/svg/toggleSvg'; import { applePaySvg } from '../svg/applePaySvg'; +import { CoinbasePaySvg } from '../svg/coinbasePaySvg'; +import { creditCardSvg } from '../svg/creditCardSvg'; import { fundWalletSvg } from '../svg/fundWallet'; import { swapSettingsSvg } from '../svg/swapSettings'; import { walletSvg } from '../svg/walletSvg'; -import { creditCardSvg } from '../svg/creditCardSvg'; -export const useIcon = ({ icon, className }: { icon?: React.ReactNode, className?: string }) => { +export const useIcon = ({ + icon, + className, +}: { icon?: React.ReactNode; className?: string }) => { return useMemo(() => { if (icon === undefined) { return null; diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx index e8b5eb5b24..c675f36487 100644 --- a/src/internal/svg/addSvg.tsx +++ b/src/internal/svg/addSvg.tsx @@ -1,6 +1,6 @@ import { cn, icon } from '../../styles/theme'; -export const AddSvg = ({className = cn(icon.inverse)}) => ( +export const AddSvg = ({ className = cn(icon.inverse) }) => ( ( +export const CoinbasePaySvg = ({ className = cn(icon.foreground) }) => ( ( +export const ErrorSvg = ({ className = cn(icon.error) }) => ( ( +export const SuccessSvg = ({ className = cn(icon.success) }) => ( -
+
+ +

Successful

From 6b9c6ae2eeb1026507796de0ea472a1b6757c9d2 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:15:13 -0800 Subject: [PATCH 25/91] Organize code --- playground/nextjs-app-router/components/Demo.tsx | 2 +- src/fund/components/FundCard.test.tsx | 6 +++--- src/fund/hooks/useFundCardFundingUrl.test.ts | 6 ++---- src/fund/hooks/useFundCardFundingUrl.ts | 4 ++-- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/playground/nextjs-app-router/components/Demo.tsx b/playground/nextjs-app-router/components/Demo.tsx index a230c4574f..40cb8ee71c 100644 --- a/playground/nextjs-app-router/components/Demo.tsx +++ b/playground/nextjs-app-router/components/Demo.tsx @@ -7,6 +7,7 @@ import { useContext, useEffect, useState } from 'react'; import DemoOptions from './DemoOptions'; import CheckoutDemo from './demo/Checkout'; 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'; @@ -19,7 +20,6 @@ import TransactionDemo from './demo/Transaction'; import TransactionDefaultDemo from './demo/TransactionDefault'; import WalletDemo from './demo/Wallet'; import WalletDefaultDemo from './demo/WalletDefault'; -import FundCardDemo from './demo/FundCard'; const activeComponentMapping: Record = { [OnchainKitComponent.FundButton]: FundButtonDemo, diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index b96fd6d927..1fca5a0e94 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -1,4 +1,5 @@ import '@testing-library/jest-dom'; +import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import { act, @@ -8,13 +9,12 @@ import { waitFor, } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import type { FundCardPropsReact } from '../types'; +import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; import { FundCardProvider } from './FundCardProvider'; -import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; -import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; -import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: () => 'mocked-theme-class', diff --git a/src/fund/hooks/useFundCardFundingUrl.test.ts b/src/fund/hooks/useFundCardFundingUrl.test.ts index 8adfd4c9c0..62d3f3a174 100644 --- a/src/fund/hooks/useFundCardFundingUrl.test.ts +++ b/src/fund/hooks/useFundCardFundingUrl.test.ts @@ -1,11 +1,9 @@ -import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +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 { useExchangeRate } from './useExchangeRate'; import { useFundCardFundingUrl } from './useFundCardFundingUrl'; -import { useOnchainKit } from '@/core-react/useOnchainKit'; -import { useAccount } from 'wagmi'; vi.mock('../components/FundCardProvider', () => ({ useFundContext: vi.fn(), diff --git a/src/fund/hooks/useFundCardFundingUrl.ts b/src/fund/hooks/useFundCardFundingUrl.ts index 63c28a250f..de201c5b25 100644 --- a/src/fund/hooks/useFundCardFundingUrl.ts +++ b/src/fund/hooks/useFundCardFundingUrl.ts @@ -1,7 +1,7 @@ -import { useMemo } from 'react'; -import { useFundContext } from '../components/FundCardProvider'; 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 = () => { From 4dc16e14f1ecf6e853281013a5f1d845ab006a74 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:18:29 -0800 Subject: [PATCH 26/91] Update --- src/.eslintrc.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/.eslintrc.json diff --git a/src/.eslintrc.json b/src/.eslintrc.json deleted file mode 100644 index 11cf32a42b..0000000000 --- a/src/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "plugins": ["react-hooks"], - "rules": { - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn" - } -} From 8991cdb0d7af69df063833b403d2150cda972ac1 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:33:29 -0800 Subject: [PATCH 27/91] Update --- src/fund/components/FundCard.test.tsx | 27 +++++++++++++++++++++++++++ src/fund/components/FundCard.tsx | 6 +++--- vitest.config.ts | 1 + 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index 1fca5a0e94..d729b31125 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -15,6 +15,7 @@ import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; import { FundCardProvider } from './FundCardProvider'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: () => 'mocked-theme-class', @@ -195,4 +196,30 @@ describe('FundCard', () => { ).toBeInTheDocument(); expect(screen.getByTestId('submitButtonComponent')).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 input = screen.getByTestId('ockFundCardAmountInput') as HTMLInputElement; + act(() => { + fireEvent.change(input, { target: { value: '100' } }); + }); + + // 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/FundCard.tsx b/src/fund/components/FundCard.tsx index 226034d942..980c49ac5d 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -10,7 +10,7 @@ import { FundCardHeader } from './FundCardHeader'; import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import { FundCardProvider } from './FundCardProvider'; import { useFundContext } from './FundCardProvider'; -//import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; const defaultPaymentMethods: PaymentMethodReact[] = [ { @@ -109,7 +109,7 @@ export function FundCardContent({ setSubmitButtonState, } = useFundContext(); - const fundingUrl = 'test'; //useFundCardFundingUrl(); + const fundingUrl = useFundCardFundingUrl(); // Setup event listeners for the onramp useFundCardSetupOnrampEventListeners(); @@ -149,7 +149,7 @@ export function FundCardContent({ fundingUrl={fundingUrl} state={submitButtonState} onClick={() => setSubmitButtonState('loading')} - //onPopupClose={() => setSubmitButtonState('default')} + onPopupClose={() => setSubmitButtonState('default')} /> ); diff --git a/vitest.config.ts b/vitest.config.ts index 694e3f9be7..0c0c1181e8 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: { From eb9a993a3a615501d09ef87048532fc7fbf1818f Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:36:59 -0800 Subject: [PATCH 28/91] Update --- playground/nextjs-app-router/onchainkit/package.json | 2 +- src/fund/components/FundCard.test.tsx | 6 ++++-- vitest.config.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/playground/nextjs-app-router/onchainkit/package.json b/playground/nextjs-app-router/onchainkit/package.json index 875ca5acb7..22fb179777 100644 --- a/playground/nextjs-app-router/onchainkit/package.json +++ b/playground/nextjs-app-router/onchainkit/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/onchainkit", - "version": "0.36.0", + "version": "0.36.1", "type": "module", "repository": "https://github.com/coinbase/onchainkit.git", "license": "MIT", diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index d729b31125..c446c1c1ca 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -200,12 +200,14 @@ describe('FundCard', () => { it('sets submit button state to default on popup close', () => { vi.useFakeTimers(); - (openPopup as Mock).mockImplementation(() => ({closed: true})); + (openPopup as Mock).mockImplementation(() => ({ closed: true })); renderComponent(); const button = screen.getByTestId('ockFundButton'); // Simulate entering a valid amount - const input = screen.getByTestId('ockFundCardAmountInput') as HTMLInputElement; + const input = screen.getByTestId( + 'ockFundCardAmountInput', + ) as HTMLInputElement; act(() => { fireEvent.change(input, { target: { value: '100' } }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 0c0c1181e8..d645aee1ea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ 'playground/**', 'site/**', 'create-onchain/**', - '**/**.test.tsx' + '**/**.test.tsx', ], reportOnFailure: true, thresholds: { From 4ffe5596c361a8f324eadaf5146bec885f8f246c Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:40:34 -0800 Subject: [PATCH 29/91] Update --- src/fund/components/FundCard.test.tsx | 2 +- src/fund/components/FundCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index c446c1c1ca..81bb2a02ab 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -1,6 +1,7 @@ import '@testing-library/jest-dom'; import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { act, fireEvent, @@ -15,7 +16,6 @@ import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; import { FundCardProvider } from './FundCardProvider'; -import { openPopup } from '@/ui-react/internal/utils/openPopup'; vi.mock('../../core-react/internal/hooks/useTheme', () => ({ useTheme: () => 'mocked-theme-class', diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 980c49ac5d..a93e011bdc 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,6 +1,7 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { background, border, cn, color, text } from '../../styles/theme'; import { useExchangeRate } from '../hooks/useExchangeRate'; +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; import type { FundCardPropsReact, PaymentMethodReact } from '../types'; import { FundButton } from './FundButton'; @@ -10,7 +11,6 @@ import { FundCardHeader } from './FundCardHeader'; import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; import { FundCardProvider } from './FundCardProvider'; import { useFundContext } from './FundCardProvider'; -import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; const defaultPaymentMethods: PaymentMethodReact[] = [ { From 1b3f1fc3ee4896d992e837f61e242fdee7e1394e Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:45:29 -0800 Subject: [PATCH 30/91] Update --- site/docs/components/landing/CheckoutDemo.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/docs/components/landing/CheckoutDemo.tsx b/site/docs/components/landing/CheckoutDemo.tsx index 9e84e262fe..5df8155915 100644 --- a/site/docs/components/landing/CheckoutDemo.tsx +++ b/site/docs/components/landing/CheckoutDemo.tsx @@ -2,7 +2,7 @@ import { Checkout } from '@coinbase/onchainkit/checkout'; import { useCallback, useState } from 'react'; import App from '../App.tsx'; import { closeSvg } from '../svg/closeSvg.tsx'; -import { CoinbasePaySvg } from '../svg/coinbasePaySvg.tsx'; +import { coinbasePaySvg } from '../svg/coinbasePaySvg.tsx'; export const checkoutDemoCode = ` import { @@ -90,7 +90,7 @@ function MockCheckoutButton({ onClick }: { onClick: () => void }) { >
- + {coinbasePaySvg}
From 423843a10d05b2f5618a2e71750216a6cd918e26 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 18:55:54 -0800 Subject: [PATCH 31/91] Update --- src/fund/hooks/useFundCardFundingUrl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/hooks/useFundCardFundingUrl.test.ts b/src/fund/hooks/useFundCardFundingUrl.test.ts index 62d3f3a174..615e46b723 100644 --- a/src/fund/hooks/useFundCardFundingUrl.test.ts +++ b/src/fund/hooks/useFundCardFundingUrl.test.ts @@ -136,6 +136,6 @@ describe('useFundCardFundingUrl', () => { }); const { result } = renderHook(() => useFundCardFundingUrl()); - expect(result.current).toContain('addresses={"0x123":["base"]}'); + expect(result.current).toContain(encodeURI('0x123')); }); }); From 2fabd85a24f2d3d28095334870d9aa0f9594b7ff Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 19:04:52 -0800 Subject: [PATCH 32/91] Update --- src/fund/components/FundCardAmountInput.test.tsx | 4 ++-- src/fund/components/FundCardAmountInput.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 2e169ac0c1..4f8dc59a4d 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -36,7 +36,7 @@ describe('FundCardAmountInput', () => { const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '10' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('10'); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('5'); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('20'); }); it('handles crypto input change', () => { @@ -59,7 +59,7 @@ describe('FundCardAmountInput', () => { const input = screen.getByTestId('ockFundCardAmountInput'); fireEvent.change(input, { target: { value: '01' } }); expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.1'); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.05'); + expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.2'); }); it('limits decimal places to two', () => { diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 913dddd06b..a306ce8cf4 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -22,7 +22,6 @@ export const FundCardAmountInput = ({ const value = inputType === 'fiat' ? fiatValue : cryptoValue; - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Simplifyed the function as much as possible const handleChange = useCallback( (e: ChangeEvent) => { let value = e.target.value; @@ -35,8 +34,9 @@ export const FundCardAmountInput = ({ // Calculate the crypto value based on the exchange rate const calculatedCryptoValue = String( - Number(value) / Number(exchangeRate), + Number(value) * Number(exchangeRate), ); + console.log('exchangerate', exchangeRate); setCryptoValue( calculatedCryptoValue === '0' ? '' : calculatedCryptoValue, ); From 9032c5e9f33e8ef3223a0464be7213c86b6ff213 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 19:12:04 -0800 Subject: [PATCH 33/91] Update --- src/fund/components/FundCardAmountInput.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index a306ce8cf4..e8648612c9 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -29,16 +29,17 @@ export const FundCardAmountInput = ({ value = formatDecimalInputValue(value); if (inputType === 'fiat') { - const fiatValue = limitToTwoDecimalPlaces(value); + const fiatValue = limitToDecimalPlaces(value, 2); setFiatValue(fiatValue); // Calculate the crypto value based on the exchange rate const calculatedCryptoValue = String( Number(value) * Number(exchangeRate), ); - console.log('exchangerate', exchangeRate); + + const resultCryptoValue = limitToDecimalPlaces(calculatedCryptoValue, 8); setCryptoValue( - calculatedCryptoValue === '0' ? '' : calculatedCryptoValue, + calculatedCryptoValue === '0' ? '' : resultCryptoValue, ); } else { setCryptoValue(value); @@ -47,7 +48,7 @@ export const FundCardAmountInput = ({ const calculatedFiatValue = String( Number(value) / Number(exchangeRate), ); - const resultFiatValue = limitToTwoDecimalPlaces(calculatedFiatValue); + const resultFiatValue = limitToDecimalPlaces(calculatedFiatValue, 2); setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); } }, @@ -182,13 +183,13 @@ const formatDecimalInputValue = (value: string) => { }; /** - * Limit the value to two decimal places + * Limit the value to N decimal places */ -const limitToTwoDecimalPlaces = (value: string) => { +const limitToDecimalPlaces = (value: string, decimalPlaces: number) => { const decimalIndex = value.indexOf('.'); let resultValue = value; if (decimalIndex !== -1 && value.length - decimalIndex - 1 > 2) { - resultValue = value.substring(0, decimalIndex + 3); + resultValue = value.substring(0, decimalIndex + decimalPlaces + 1); } return resultValue; From b14bc3c6643ebc473f1edfd5993c96cfdfcd3d12 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 21:21:41 -0800 Subject: [PATCH 34/91] Format --- src/fund/components/FundCardAmountInput.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index e8648612c9..18f65c78d4 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -37,10 +37,11 @@ export const FundCardAmountInput = ({ Number(value) * Number(exchangeRate), ); - const resultCryptoValue = limitToDecimalPlaces(calculatedCryptoValue, 8); - setCryptoValue( - calculatedCryptoValue === '0' ? '' : resultCryptoValue, + const resultCryptoValue = limitToDecimalPlaces( + calculatedCryptoValue, + 8, ); + setCryptoValue(calculatedCryptoValue === '0' ? '' : resultCryptoValue); } else { setCryptoValue(value); From c143dcb2234a5e6025f4907456f4fd71ead50322 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 21:51:14 -0800 Subject: [PATCH 35/91] Update naming --- src/fund/components/FundCard.test.tsx | 6 ++--- src/fund/components/FundCard.tsx | 22 +++++++++---------- ...=> FundCardPaymentMethodDropdown.test.tsx} | 18 +++++++-------- ....tsx => FundCardPaymentMethodDropdown.tsx} | 8 +++---- src/fund/types.ts | 4 ++-- 5 files changed, 29 insertions(+), 29 deletions(-) rename src/fund/components/{FundCardPaymentMethodSelectorDropdown.test.tsx => FundCardPaymentMethodDropdown.test.tsx} (78%) rename src/fund/components/{FundCardPaymentMethodSelectorDropdown.tsx => FundCardPaymentMethodDropdown.tsx} (92%) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index 81bb2a02ab..65da23460f 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -171,7 +171,7 @@ describe('FundCard', () => {
); const CustomPaymentMethodSelectorDropdownComponent = () => ( -
+
); const CustomSubmitButtonComponent = () => (
@@ -181,7 +181,7 @@ describe('FundCard', () => { amountInputComponent: CustomAmountInputComponent, headerComponent: CustomHeaderComponent, amountInputTypeSwithComponent: CustomAmountInputTypeSwitchComponent, - paymentMethodSelectorDropdownComponent: + paymentMethodDropdownComponent: CustomPaymentMethodSelectorDropdownComponent, submitButtonComponent: CustomSubmitButtonComponent, }); @@ -192,7 +192,7 @@ describe('FundCard', () => { screen.getByTestId('amountInputTypeSwitchComponent'), ).toBeInTheDocument(); expect( - screen.getByTestId('paymentMethodSelectorDropdownComponent'), + screen.getByTestId('paymentMethodDropdownComponent'), ).toBeInTheDocument(); expect(screen.getByTestId('submitButtonComponent')).toBeInTheDocument(); }); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index a93e011bdc..0cf0357073 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -8,7 +8,7 @@ import { FundButton } from './FundButton'; import FundCardAmountInput from './FundCardAmountInput'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; import { FundCardHeader } from './FundCardHeader'; -import { FundCardPaymentMethodSelectorDropdown } from './FundCardPaymentMethodSelectorDropdown'; +import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; import { FundCardProvider } from './FundCardProvider'; import { useFundContext } from './FundCardProvider'; @@ -40,7 +40,7 @@ export function FundCard({ amountInputComponent = FundCardAmountInput, headerComponent = FundCardHeader, amountInputTypeSwithComponent = FundCardAmountInputTypeSwitch, - paymentMethodSelectorDropdownComponent = FundCardPaymentMethodSelectorDropdown, + paymentMethodDropdownComponent = FundCardPaymentMethodDropdown, paymentMethods = defaultPaymentMethods, submitButtonComponent = FundButton, }: FundCardPropsReact) { @@ -66,8 +66,8 @@ export function FundCard({ amountInputComponent={amountInputComponent} headerComponent={headerComponent} amountInputTypeSwithComponent={amountInputTypeSwithComponent} - paymentMethodSelectorDropdownComponent={ - paymentMethodSelectorDropdownComponent + paymentMethodDropdownComponent={ + paymentMethodDropdownComponent } paymentMethods={paymentMethods} submitButtonComponent={submitButtonComponent} @@ -84,11 +84,11 @@ export function FundCardContent({ amountInputComponent: AmountInputComponent = FundCardAmountInput, headerComponent: HeaderComponent = FundCardHeader, amountInputTypeSwithComponent: - AmountInputTypeSwitch = FundCardAmountInputTypeSwitch, - paymentMethodSelectorDropdownComponent: - PaymentMethodSelectorDropdown = FundCardPaymentMethodSelectorDropdown, + AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, + paymentMethodDropdownComponent: + PaymentMethodSelectorDropdownComponent = FundCardPaymentMethodDropdown, paymentMethods = defaultPaymentMethods, - submitButtonComponent: SubmitButton = FundButton, + submitButtonComponent: SubmitButtonComponent = FundButton, }: FundCardPropsReact) { /** * Fetches and sets the exchange rate for the asset @@ -129,7 +129,7 @@ export function FundCardContent({ exchangeRate={exchangeRate} /> - - + - ({ @@ -28,11 +28,11 @@ const paymentMethods = [ const renderComponent = () => render( - + , ); -describe('FundCardPaymentMethodSelectorDropdown', () => { +describe('FundCardPaymentMethodDropdown', () => { afterEach(() => { vi.clearAllMocks(); }); @@ -53,21 +53,21 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { ); // Initially closed expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); // Click to open act(() => { toggleButton.click(); }); expect( - screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.getByTestId('ockFundCardPaymentMethodDropdown'), ).toBeInTheDocument(); // Click to close act(() => { toggleButton.click(); }); expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); }); @@ -85,7 +85,7 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); expect(screen.getByText('Apple Pay')).toBeInTheDocument(); expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); }); @@ -101,7 +101,7 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); expect( - screen.getByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.getByTestId('ockFundCardPaymentMethodDropdown'), ).toBeInTheDocument(); // Click outside @@ -110,7 +110,7 @@ describe('FundCardPaymentMethodSelectorDropdown', () => { }); // Assert dropdown is closed expect( - screen.queryByTestId('ockFundCardPaymentMethodSelectorDropdown'), + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx similarity index 92% rename from src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx rename to src/fund/components/FundCardPaymentMethodDropdown.tsx index 207152da4d..a31db5b107 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -3,16 +3,16 @@ import { background, border, cn } from '../../styles/theme'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; import type { - FundCardPaymentMethodSelectorDropdownPropsReact, + FundCardPaymentMethodDropdownPropsReact, PaymentMethodReact, } from '../types'; import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; import { FundCardPaymentMethodSelectorToggle } from './FundCardPaymentMethodSelectorToggle'; import { useFundContext } from './FundCardProvider'; -export function FundCardPaymentMethodSelectorDropdown({ +export function FundCardPaymentMethodDropdown({ paymentMethods, -}: FundCardPaymentMethodSelectorDropdownPropsReact) { +}: FundCardPaymentMethodDropdownPropsReact) { const componentTheme = useTheme(); const [isOpen, setIsOpen] = useState(false); @@ -70,7 +70,7 @@ export function FundCardPaymentMethodSelectorDropdown({ {isOpen && (
; + paymentMethodDropdownComponent?: React.ComponentType; /** * Custom component for the submit button From 302806ebe6b4aceb6695a0ddfc496f2ad5939df0 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 17 Dec 2024 22:12:56 -0800 Subject: [PATCH 36/91] Format --- src/fund/components/FundCard.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 0cf0357073..98f8ff4c1b 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -66,9 +66,7 @@ export function FundCard({ amountInputComponent={amountInputComponent} headerComponent={headerComponent} amountInputTypeSwithComponent={amountInputTypeSwithComponent} - paymentMethodDropdownComponent={ - paymentMethodDropdownComponent - } + paymentMethodDropdownComponent={paymentMethodDropdownComponent} paymentMethods={paymentMethods} submitButtonComponent={submitButtonComponent} /> From c99194fa4d8bc71760629ef840da38b58a27e2b6 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 18 Dec 2024 10:07:48 -0800 Subject: [PATCH 37/91] Address comments --- playground/nextjs-app-router/components/demo/Swap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/Swap.tsx b/playground/nextjs-app-router/components/demo/Swap.tsx index 81b16622b5..0e6b1b7f8b 100644 --- a/playground/nextjs-app-router/components/demo/Swap.tsx +++ b/playground/nextjs-app-router/components/demo/Swap.tsx @@ -45,9 +45,9 @@ function SwapComponent() { }; const usdcToken: Token = { - name: 'USD', + name: 'USDC', address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USD', + symbol: 'USDC', decimals: 6, image: 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/44/2b/442b80bd16af0c0d9b22e03a16753823fe826e5bfd457292b55fa0ba8c1ba213-ZWUzYjJmZGUtMDYxNy00NDcyLTg0NjQtMWI4OGEwYjBiODE2', From 950504eab858e8d0dc11002c9dba9ec89ac48246 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 18 Dec 2024 12:07:59 -0800 Subject: [PATCH 38/91] Address comments --- src/fund/components/FundButton.tsx | 2 +- src/fund/components/FundCard.tsx | 30 ++++--------------- src/fund/components/FundCardAmountInput.tsx | 4 +-- .../FundCardPaymentMethodDropdown.tsx | 5 ++-- .../FundCardPaymentMethodSelectRow.tsx | 5 ++-- src/fund/constants.ts | 23 ++++++++++++++ 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index db86abd7f2..56f7ea660e 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -80,7 +80,7 @@ export function FundButton({ const classNames = cn( componentTheme, buttonColorClass, - 'px-4 py-3 inline-flex items-center justify-center space-x-2 disabled', + 'px-4 py-3 inline-flex items-center justify-center space-x-2', isDisabled && pressable.disabled, text.headline, border.radius, diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 98f8ff4c1b..f2ac9b031c 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,9 +1,10 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { background, border, cn, color, text } from '../../styles/theme'; +import { DEFAULT_PAYMENT_METHODS } from '../constants'; import { useExchangeRate } from '../hooks/useExchangeRate'; import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; -import type { FundCardPropsReact, PaymentMethodReact } from '../types'; +import type { FundCardPropsReact } from '../types'; import { FundButton } from './FundButton'; import FundCardAmountInput from './FundCardAmountInput'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; @@ -12,27 +13,6 @@ import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; import { FundCardProvider } from './FundCardProvider'; import { useFundContext } from './FundCardProvider'; -const defaultPaymentMethods: PaymentMethodReact[] = [ - { - id: 'FIAT_WALLET', - name: 'Coinbase', - description: 'Buy with your Coinbase account', - icon: 'coinbasePay', - }, - { - id: 'APPLE_PAY', - name: 'Apple Pay', - description: 'Up to $500/week', - icon: 'applePay', - }, - { - id: 'ACH_BANK_ACCOUNT', - name: 'Debit Card', - description: 'Up to $500/week', - icon: 'creditCard', - }, -]; - export function FundCard({ assetSymbol, buttonText = 'Buy', @@ -41,7 +21,7 @@ export function FundCard({ headerComponent = FundCardHeader, amountInputTypeSwithComponent = FundCardAmountInputTypeSwitch, paymentMethodDropdownComponent = FundCardPaymentMethodDropdown, - paymentMethods = defaultPaymentMethods, + paymentMethods = DEFAULT_PAYMENT_METHODS, submitButtonComponent = FundButton, }: FundCardPropsReact) { const componentTheme = useTheme(); @@ -75,7 +55,7 @@ export function FundCard({ ); } -export function FundCardContent({ +function FundCardContent({ assetSymbol, buttonText = 'Buy', headerText, @@ -85,7 +65,7 @@ export function FundCardContent({ AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, paymentMethodDropdownComponent: PaymentMethodSelectorDropdownComponent = FundCardPaymentMethodDropdown, - paymentMethods = defaultPaymentMethods, + paymentMethods = DEFAULT_PAYMENT_METHODS, submitButtonComponent: SubmitButtonComponent = FundButton, }: FundCardPropsReact) { /** diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 18f65c78d4..739cd6ae18 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -110,7 +110,7 @@ export const FundCardAmountInput = ({ componentTheme, text.body, 'border-[none] bg-transparent', - 'text-[60px] leading-none outline-none', + 'text-[3.75rem] leading-none outline-none', )} type="number" value={value} @@ -144,7 +144,7 @@ export const FundCardAmountInput = ({ componentTheme, text.body, 'border-[none] bg-transparent', - 'text-[60px] leading-none outline-none', + 'text-[3.75rem] leading-none outline-none', )} style={{ position: 'absolute', diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index a31db5b107..2be85a3d82 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -74,11 +74,10 @@ export function FundCardPaymentMethodDropdown({ className={cn( componentTheme, border.radius, - 'absolute right-0 z-10 mt-1 flex max-h-80 w-full flex-col overflow-y-hidden', - 'ock-scrollbar', + 'ock-scrollbar absolute right-0 z-10 mt-1 flex max-h-80 w-full flex-col overflow-y-hidden', )} > -
+
{paymentMethods.map((paymentMethod) => ( onClick?.(paymentMethod)} diff --git a/src/fund/constants.ts b/src/fund/constants.ts index f345e65cc1..e9350d502d 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`; @@ -9,3 +11,24 @@ 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 DEFAULT_PAYMENT_METHODS: PaymentMethodReact[] = [ + { + id: 'FIAT_WALLET', + name: 'Coinbase', + description: 'Buy with your Coinbase account', + icon: 'coinbasePay', + }, + { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'applePay', + }, + { + id: 'ACH_BANK_ACCOUNT', + name: 'Debit Card', + description: 'Up to $500/week', + icon: 'creditCard', + }, +]; \ No newline at end of file From ba15289d1d9a95b794e2b813eaaacd98bc0a13f3 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 18 Dec 2024 17:59:01 -0800 Subject: [PATCH 39/91] Address comments --- .../internal/hooks/useIcon.test.tsx | 4 +- src/fund/components/FundButton.tsx | 4 +- .../components/FundCardAmountInput.test.tsx | 4 -- src/fund/components/FundCardAmountInput.tsx | 56 ++++--------------- .../FundCardAmountInputTypeSwitch.test.tsx | 4 -- .../FundCardAmountInputTypeSwitch.tsx | 21 ++----- .../components/FundCardCurrencyLabel.test.tsx | 6 +- src/fund/components/FundCardCurrencyLabel.tsx | 4 -- src/fund/components/FundCardHeader.test.tsx | 4 -- src/fund/components/FundCardHeader.tsx | 3 - .../FundCardPaymentMethodDropdown.test.tsx | 27 ++++++--- .../FundCardPaymentMethodDropdown.tsx | 48 ++++++++-------- .../FundCardPaymentMethodImage.test.tsx | 39 +++++++++++-- .../FundCardPaymentMethodSelectRow.test.tsx | 4 -- .../FundCardPaymentMethodSelectRow.tsx | 4 -- .../utils/formatDecimalInputValue.test.ts | 25 +++++++++ src/fund/utils/formatDecimalInputValue.ts | 21 +++++++ src/fund/utils/truncateDecimalPlaces.test.ts | 20 +++++++ src/fund/utils/truncateDecimalPlaces.ts | 12 ++++ src/internal/components/Skeleton.tsx | 7 ++- src/swap/components/SwapToast.tsx | 8 +-- 21 files changed, 185 insertions(+), 140 deletions(-) create mode 100644 src/fund/utils/formatDecimalInputValue.test.ts create mode 100644 src/fund/utils/formatDecimalInputValue.ts create mode 100644 src/fund/utils/truncateDecimalPlaces.test.ts create mode 100644 src/fund/utils/truncateDecimalPlaces.ts diff --git a/src/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index bdd406a4cf..702454c43d 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { applePaySvg } from '../../../internal/svg/applePaySvg'; -import { CoinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; import { creditCardSvg } from '../../../internal/svg/creditCardSvg'; import { fundWalletSvg } from '../../../internal/svg/fundWallet'; import { swapSettingsSvg } from '../../../internal/svg/swapSettings'; @@ -37,7 +37,7 @@ describe('useIcon', () => { it('should return coinbasePaySvg when icon is "coinbasePay"', () => { const { result } = renderHook(() => useIcon({ icon: 'coinbasePay' })); - expect(result.current?.type).toBe(CoinbasePaySvg); + expect(result.current).toBe(coinbasePaySvg); }); it('should return fundWalletSvg when icon is "fundWallet"', () => { diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 56f7ea660e..9fde730dfd 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -96,9 +96,9 @@ export function FundButton({ case 'loading': return ''; case 'success': - return ; + return case 'error': - return ; + return default: return ; } diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 4f8dc59a4d..4d5b04d833 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -4,10 +4,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { FundCardAmountInputPropsReact } from '../types'; import { FundCardAmountInput } from './FundCardAmountInput'; -vi.mock('../../core-react/internal/hooks/useTheme', () => ({ - useTheme: () => 'mocked-theme-class', -})); - describe('FundCardAmountInput', () => { const defaultProps = { fiatValue: '100', diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 739cd6ae18..0e2268bb50 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -1,8 +1,9 @@ import { type ChangeEvent, useCallback, useEffect, useRef } from 'react'; -import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { cn, text } from '../../styles/theme'; import type { FundCardAmountInputPropsReact } from '../types'; import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import { formatDecimalInputValue } from '../utils/formatDecimalInputValue'; +import { truncateDecimalPlaces } from '../utils/truncateDecimalPlaces'; export const FundCardAmountInput = ({ fiatValue, @@ -14,8 +15,6 @@ export const FundCardAmountInput = ({ inputType = 'fiat', exchangeRate = 1, }: FundCardAmountInputPropsReact) => { - const componentTheme = useTheme(); - const inputRef = useRef(null); const hiddenSpanRef = useRef(null); const currencySpanRef = useRef(null); @@ -29,7 +28,7 @@ export const FundCardAmountInput = ({ value = formatDecimalInputValue(value); if (inputType === 'fiat') { - const fiatValue = limitToDecimalPlaces(value, 2); + const fiatValue = truncateDecimalPlaces(value, 2); setFiatValue(fiatValue); // Calculate the crypto value based on the exchange rate @@ -37,7 +36,7 @@ export const FundCardAmountInput = ({ Number(value) * Number(exchangeRate), ); - const resultCryptoValue = limitToDecimalPlaces( + const resultCryptoValue = truncateDecimalPlaces( calculatedCryptoValue, 8, ); @@ -49,7 +48,7 @@ export const FundCardAmountInput = ({ const calculatedFiatValue = String( Number(value) / Number(exchangeRate), ); - const resultFiatValue = limitToDecimalPlaces(calculatedFiatValue, 2); + const resultFiatValue = truncateDecimalPlaces(calculatedFiatValue, 2); setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); } }, @@ -74,13 +73,17 @@ export const FundCardAmountInput = ({ // 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(); + }, [inputType]); + + const handleFocusInput = () => { if (inputRef.current) { inputRef.current.focus(); } - }, [inputType]); + }; return ( -
+
-
+
{/* Display the fiat currency sign before the input*/} {inputType === 'fiat' && currencySign && ( {!hideImage && ( - + )} {paymentMethod.name} diff --git a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx index fec8d70d8b..64ff9c8ab5 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -31,7 +31,7 @@ export const FundCardPaymentMethodSelectorToggle = forwardRef( data-testid="ockFundCardPaymentMethodSelectorToggle" >
- +
('default'); + const fetchExchangeRate = useDebounce(async () => { + setExchangeRateLoading(true); + const quote = await fetchOnrampQuote({ + purchaseCurrency: selectedAsset, + paymentCurrency: 'USD', + paymentAmount: '100', + paymentMethod: 'CARD', + country: 'US', + }); + + setExchangeRateLoading(false); + + setExchangeRate( + Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), + ); + }, 1000); + + // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect + useEffect(() => { + fetchExchangeRate(); + }, []); + const value = useValue({ selectedAsset, setSelectedAsset, diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index 70f8071d6e..4c75dba348 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -1,18 +1,47 @@ -import { render, screen } from '@testing-library/react'; +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { render, screen, waitFor } from '@testing-library/react'; import { act } from 'react'; -import { describe, expect, it } from 'vitest'; +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; + +vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); + const TestComponent = () => { const context = useFundContext(); return (
{context.selectedAsset} + {context.exchangeRate} + + {context.exchangeRateLoading ? 'loading' : 'not-loading'} +
); }; describe('FundCardProvider', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: '123456789' }); + vi.clearAllMocks(); + }); + it('provides default context values', () => { render( @@ -48,6 +77,34 @@ describe('FundCardProvider', () => { expect(screen.getByTestId('selected-asset').textContent).toBe('ETH'); }); + 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(); diff --git a/src/fund/hooks/useExchangeRate.test.ts b/src/fund/hooks/useExchangeRate.test.ts deleted file mode 100644 index 2bdc1a6361..0000000000 --- a/src/fund/hooks/useExchangeRate.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; -import { renderHook } from '@testing-library/react'; -import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useFundContext } from '../components/FundCardProvider'; -import { useExchangeRate } from './useExchangeRate'; - -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; - -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - -vi.mock('../components/FundCardProvider', () => ({ - useFundContext: vi.fn(), -})); - -let mockSetExchangeRate = vi.fn(); -let mockSetExchangeRateLoading = vi.fn(); - -describe('useExchangeRate', () => { - beforeEach(() => { - setOnchainKitConfig({ apiKey: '123456789' }); - mockSetExchangeRate = vi.fn(); - mockSetExchangeRateLoading = vi.fn(); - (useFundContext as Mock).mockReturnValue({ - exchangeRateLoading: false, - setExchangeRate: mockSetExchangeRate, - setExchangeRateLoading: mockSetExchangeRateLoading, - }); - }); - - it('should fetch and set exchange rate correctly', async () => { - // Mock dependencies - - renderHook(() => useExchangeRate('BTC')); - - // Assert loading state - expect(mockSetExchangeRateLoading).toHaveBeenCalledWith(true); - - // Wait for the exchange rate to be fetched - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Assert loading state is false and exchange rate is set correctly - expect(mockSetExchangeRateLoading).toHaveBeenCalledWith(false); - expect(mockSetExchangeRate).toHaveBeenCalledWith(0.0008333333333333334); - }); - - it('should not fetch exchange rate if already loading', () => { - // Mock exchangeRateLoading as true - (useFundContext as Mock).mockReturnValue({ - exchangeRateLoading: true, - setExchangeRate: mockSetExchangeRate, - setExchangeRateLoading: mockSetExchangeRateLoading, - }); - - // Render the hook - renderHook(() => useExchangeRate('BTC')); - - // Assert that setExchangeRateLoading was not called - expect(mockSetExchangeRateLoading).not.toHaveBeenCalled(); - - // Assert that setExchangeRate was not called - expect(mockSetExchangeRate).not.toHaveBeenCalled(); - }); -}); diff --git a/src/fund/hooks/useExchangeRate.ts b/src/fund/hooks/useExchangeRate.ts deleted file mode 100644 index 54c84c784b..0000000000 --- a/src/fund/hooks/useExchangeRate.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useDebounce } from '../../core-react/internal/hooks/useDebounce'; -import { useFundContext } from '../components/FundCardProvider'; -import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; - -export const useExchangeRate = (assetSymbol: string) => { - const { setExchangeRate, exchangeRateLoading, setExchangeRateLoading } = - useFundContext(); - - const fetchExchangeRate = useDebounce(async () => { - if (exchangeRateLoading) { - return; - } - - setExchangeRateLoading(true); - const quote = await fetchOnrampQuote({ - purchaseCurrency: assetSymbol, - paymentCurrency: 'USD', - paymentAmount: '100', - paymentMethod: 'CARD', - country: 'US', - }); - - setExchangeRateLoading(false); - - setExchangeRate( - Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), - ); - }, 1000); - - // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect - useEffect(() => { - fetchExchangeRate(); - }, []); - - return useMemo(() => ({ fetchExchangeRate }), [fetchExchangeRate]); -}; diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index dd01b3b2dc..198b2ae30e 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -13,7 +13,6 @@ type TextInputReact = { setValue: (s: string) => void; value: string; inputValidator?: (s: string) => boolean; - style?: React.CSSProperties; }; export function TextInput({ @@ -27,7 +26,6 @@ export function TextInput({ setValue, value, inputValidator = () => true, - style, }: TextInputReact) { const handleDebounce = useDebounce((value) => { onChange(value); @@ -51,7 +49,6 @@ export function TextInput({ return ( ( Date: Fri, 20 Dec 2024 15:29:30 -0800 Subject: [PATCH 48/91] Format --- .../FundCardPaymentMethodSelectRow.tsx | 5 ++- .../FundCardPaymentMethodSelectorToggle.tsx | 5 ++- src/fund/components/FundCardProvider.tsx | 42 +++++++++---------- src/fund/components/FundProvider.test.tsx | 12 ++++-- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx index 8b80024563..d5882dc918 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -25,7 +25,10 @@ export const FundCardPaymentMethodSelectRow = memo( > {!hideImage && ( - + )} {paymentMethod.name} diff --git a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx index 64ff9c8ab5..a291f95536 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -31,7 +31,10 @@ export const FundCardPaymentMethodSelectorToggle = forwardRef( data-testid="ockFundCardPaymentMethodSelectorToggle" >
- +
('default'); - const fetchExchangeRate = useDebounce(async () => { - setExchangeRateLoading(true); - const quote = await fetchOnrampQuote({ - purchaseCurrency: selectedAsset, - paymentCurrency: 'USD', - paymentAmount: '100', - paymentMethod: 'CARD', - country: 'US', - }); - - setExchangeRateLoading(false); - - setExchangeRate( - Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), - ); - }, 1000); - - // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect - useEffect(() => { - fetchExchangeRate(); - }, []); + const fetchExchangeRate = useDebounce(async () => { + setExchangeRateLoading(true); + const quote = await fetchOnrampQuote({ + purchaseCurrency: selectedAsset, + paymentCurrency: 'USD', + paymentAmount: '100', + paymentMethod: 'CARD', + country: 'US', + }); + + setExchangeRateLoading(false); + + setExchangeRate( + Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), + ); + }, 1000); + + // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect + useEffect(() => { + fetchExchangeRate(); + }, []); const value = useValue({ selectedAsset, diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index 4c75dba348..5c9da516d2 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -82,7 +82,7 @@ describe('FundCardProvider', () => { render( - , +
, ); }); @@ -91,8 +91,12 @@ describe('FundCardProvider', () => { // 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'); + 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 @@ -101,7 +105,7 @@ describe('FundCardProvider', () => { expect.objectContaining({ method: 'POST', body: expect.stringContaining('"purchase_currency":"BTC"'), - }) + }), ); }); From 98c055d15c85e871b55a1d75f8859806679b4a07 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 20 Dec 2024 15:33:00 -0800 Subject: [PATCH 49/91] Format --- src/fund/components/FundCardProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index dd5e44951c..76707ece38 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -1,3 +1,4 @@ +import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { createContext, useContext, useEffect, useState } from 'react'; import { useValue } from '../../core-react/internal/hooks/useValue'; import type { @@ -5,7 +6,6 @@ import type { FundCardProviderReact, PaymentMethodReact, } from '../types'; -import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; type FundCardContextType = { From 461e1368e9f447d219e41f1121aecd4938e61377 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 10:42:57 -0800 Subject: [PATCH 50/91] Address comments --- .../internal/hooks/useIcon.test.tsx | 4 +-- src/core-react/internal/hooks/useIcon.tsx | 14 +++++----- src/fund/components/FundButton.tsx | 8 +++--- src/fund/components/FundCard.tsx | 3 +-- src/fund/components/FundCardAmountInput.tsx | 27 +++---------------- .../components/FundCardPaymentMethodImage.tsx | 7 ++--- src/fund/constants.ts | 2 +- src/internal/svg/coinbasePaySvg.tsx | 6 ++--- src/internal/svg/errorSvg.tsx | 6 ++--- src/internal/svg/successSvg.tsx | 6 ++--- 10 files changed, 27 insertions(+), 56 deletions(-) diff --git a/src/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index e31d770f78..bf29f83bd9 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { applePaySvg } from '../../../internal/svg/applePaySvg'; -import { CoinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; import { creditCardSvg } from '../../../internal/svg/creditCardSvg'; import { fundWalletSvg } from '../../../internal/svg/fundWallet'; import { swapSettingsSvg } from '../../../internal/svg/swapSettings'; @@ -37,7 +37,7 @@ describe('useIcon', () => { it('should return CoinbasePaySvg when icon is "coinbasePay"', () => { const { result } = renderHook(() => useIcon({ icon: 'coinbasePay' })); - expect(result.current?.type).toBe(CoinbasePaySvg); + expect(result.current).toBe(coinbasePaySvg); }); it('should return fundWalletSvg when icon is "fundWallet"', () => { diff --git a/src/core-react/internal/hooks/useIcon.tsx b/src/core-react/internal/hooks/useIcon.tsx index 6f37c14888..b19cf034ae 100644 --- a/src/core-react/internal/hooks/useIcon.tsx +++ b/src/core-react/internal/hooks/useIcon.tsx @@ -1,23 +1,23 @@ import { isValidElement, useMemo } from 'react'; import { applePaySvg } from '../../../internal/svg/applePaySvg'; -import { CoinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; +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 { coinbaseLogoSvg } from '../../../internal/svg/coinbaseLogoSvg'; -export const useIcon = ({ - icon, - className, -}: { icon?: React.ReactNode; className?: string }) => { +export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { return useMemo(() => { if (icon === undefined) { return null; } switch (icon) { case 'coinbasePay': - return ; + return coinbasePaySvg; + case 'coinbaseLogo': + return coinbaseLogoSvg; case 'fundWallet': return fundWalletSvg; case 'swapSettings': @@ -34,5 +34,5 @@ export const useIcon = ({ if (isValidElement(icon)) { return icon; } - }, [icon, className]); + }, [icon]); }; diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 38e6e4c9dc..a7b0bfd69d 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -1,12 +1,12 @@ import { usePopupMonitor } from '@/buy/hooks/usePopupMonitor'; +import { ErrorSvg } from '@/internal/svg/errorSvg'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { useCallback, useMemo } from 'react'; import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { Spinner } from '../../internal/components/Spinner'; import { AddSvg } from '../../internal/svg/addSvg'; -import { ErrorSvg } from '../../internal/svg/errorSvg'; import { SuccessSvg } from '../../internal/svg/successSvg'; -import { border, cn, color, icon, pressable, text } from '../../styles/theme'; +import { border, cn, color, pressable, text } from '../../styles/theme'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; @@ -92,9 +92,9 @@ export function FundButton({ case 'loading': return ''; case 'success': - return ; + return ; case 'error': - return ; + return ; default: return ; } diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 7860cea5a1..713d60b2f6 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -11,8 +11,7 @@ import FundCardAmountInput from './FundCardAmountInput'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; import { FundCardHeader } from './FundCardHeader'; import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; -import { FundCardProvider } from './FundCardProvider'; -import { useFundContext } from './FundCardProvider'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; export function FundCard({ children, diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 3156635228..31af466f66 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -92,23 +92,7 @@ export const FundCardAmountInput = ({ onClick={handleFocusInput} onKeyUp={handleFocusInput} > - -
- {/* Display the fiat currency sign before the input*/} {inputType === 'fiat' && currencySign && ( - {/* Display the crypto asset symbol after the input*/} {inputType === 'crypto' && assetSymbol && ( {value ? `${value}.` : '0.'} diff --git a/src/fund/components/FundCardPaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx index 6b457b6c2d..703b601105 100644 --- a/src/fund/components/FundCardPaymentMethodImage.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -1,5 +1,5 @@ import { useIcon } from '../../core-react/internal/hooks/useIcon'; -import { cn, icon as iconTheme } from '../../styles/theme'; +import { cn } from '../../styles/theme'; import type { FundCardPaymentMethodImagePropsReact } from '../types'; export function FundCardPaymentMethodImage({ @@ -8,10 +8,7 @@ export function FundCardPaymentMethodImage({ }: FundCardPaymentMethodImagePropsReact) { const { icon } = paymentMethod; - // Special case for coinbasePay icon color - const iconColor = icon === 'coinbasePay' ? iconTheme.primary : undefined; - - const iconSvg = useIcon({ icon, className: `${iconColor}` }); + const iconSvg = useIcon({ icon }); return (
( +export const coinbasePaySvg = ( ( fillRule="evenodd" clipRule="evenodd" d="M10.0145 14.1666C7.82346 14.1666 6.04878 12.302 6.04878 9.99996C6.04878 7.69788 7.82346 5.83329 10.0145 5.83329C11.9776 5.83329 13.6069 7.33677 13.9208 9.30552H17.9163C17.5793 5.02774 14.172 1.66663 10.0145 1.66663C5.63568 1.66663 2.08301 5.39926 2.08301 9.99996C2.08301 14.6007 5.63568 18.3333 10.0145 18.3333C14.172 18.3333 17.5793 14.9722 17.9163 10.6944H13.9208C13.6069 12.6632 11.9776 14.1666 10.0145 14.1666Z" - className={className} + fill="#f9fafb" /> ); diff --git a/src/internal/svg/errorSvg.tsx b/src/internal/svg/errorSvg.tsx index 39ee877012..a4ad84f789 100644 --- a/src/internal/svg/errorSvg.tsx +++ b/src/internal/svg/errorSvg.tsx @@ -1,6 +1,4 @@ -import { cn, icon } from '../../styles/theme'; - -export const ErrorSvg = ({ className = cn(icon.error) }) => ( +export const ErrorSvg = ({ fill = '#E11D48' }) => ( ( Error SVG ); diff --git a/src/internal/svg/successSvg.tsx b/src/internal/svg/successSvg.tsx index 02bafb2f50..0103f5cc64 100644 --- a/src/internal/svg/successSvg.tsx +++ b/src/internal/svg/successSvg.tsx @@ -1,6 +1,4 @@ -import { cn, icon } from '../../styles/theme'; - -export const SuccessSvg = ({ className = cn(icon.success) }) => ( +export const SuccessSvg = ({ fill = '#65A30D' }) => ( ( Success SVG ); From 8f529c45e1380e5981b347a5f36c1230487396ca Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 10:46:09 -0800 Subject: [PATCH 51/91] Organize import statements --- src/core-react/internal/hooks/useIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core-react/internal/hooks/useIcon.tsx b/src/core-react/internal/hooks/useIcon.tsx index b19cf034ae..44c5f9a38b 100644 --- a/src/core-react/internal/hooks/useIcon.tsx +++ b/src/core-react/internal/hooks/useIcon.tsx @@ -1,12 +1,12 @@ import { isValidElement, useMemo } from 'react'; import { applePaySvg } from '../../../internal/svg/applePaySvg'; +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'; -import { coinbaseLogoSvg } from '../../../internal/svg/coinbaseLogoSvg'; export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { return useMemo(() => { From 9204930d547517115ddac29f80a456273bd79f45 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 10:54:14 -0800 Subject: [PATCH 52/91] Fix typo --- src/fund/components/FundCard.tsx | 10 +++++----- src/fund/types.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 713d60b2f6..94d2299878 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -25,7 +25,7 @@ export function FundCard({ const { amountInputComponent, headerComponent, - amountInputTypeSwithComponent, + amountInputTypeSwitchComponent, paymentMethodDropdownComponent, submitButtonComponent, } = useMemo(() => { @@ -36,7 +36,7 @@ export function FundCard({ findComponent(FundCardAmountInput), ), headerComponent: childrenArray.find(findComponent(FundCardHeader)), - amountInputTypeSwithComponent: childrenArray.find( + amountInputTypeSwitchComponent: childrenArray.find( findComponent(FundCardAmountInputTypeSwitch), ), paymentMethodDropdownComponent: childrenArray.find( @@ -65,7 +65,7 @@ export function FundCard({ headerText={headerText} amountInputComponent={amountInputComponent} headerComponent={headerComponent} - amountInputTypeSwithComponent={amountInputTypeSwithComponent} + amountInputTypeSwitchComponent={amountInputTypeSwitchComponent} paymentMethodDropdownComponent={paymentMethodDropdownComponent} paymentMethods={paymentMethods} submitButtonComponent={submitButtonComponent} @@ -81,7 +81,7 @@ function FundCardContent({ headerText, amountInputComponent, headerComponent, - amountInputTypeSwithComponent, + amountInputTypeSwitchComponent, paymentMethodDropdownComponent, paymentMethods = DEFAULT_PAYMENT_METHODS, submitButtonComponent, @@ -124,7 +124,7 @@ function FundCardContent({ /> )} - {amountInputTypeSwithComponent || ( + {amountInputTypeSwitchComponent || ( ; + amountInputTypeSwitchComponent?: React.ReactElement; /** * Custom component for the payment method selector dropdown From 0b1e28db5e767bd573ff03f363c1aa54bfeba6df Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 13:48:37 -0800 Subject: [PATCH 53/91] Add type annotations --- src/fund/components/FundCardAmountInput.test.tsx | 6 +++--- src/fund/components/FundCardPaymentMethodDropdown.test.tsx | 4 ++-- src/fund/components/FundCardPaymentMethodSelectRow.test.tsx | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 4d5b04d833..ddb17ac533 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -5,7 +5,7 @@ import type { FundCardAmountInputPropsReact } from '../types'; import { FundCardAmountInput } from './FundCardAmountInput'; describe('FundCardAmountInput', () => { - const defaultProps = { + const defaultProps: FundCardAmountInputPropsReact = { fiatValue: '100', setFiatValue: vi.fn(), cryptoValue: '0.05', @@ -13,8 +13,8 @@ describe('FundCardAmountInput', () => { currencySign: '$', assetSymbol: 'ETH', inputType: 'fiat', - exchangeRate: '2', - } as unknown as FundCardAmountInputPropsReact; + exchangeRate: 2, + }; it('renders correctly with fiat input type', () => { render(); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx index 8959d836e3..1874d7b25a 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -6,7 +6,7 @@ import type { PaymentMethodReact } from '../types'; import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; import { FundCardProvider } from './FundCardProvider'; -const paymentMethods = [ +const paymentMethods: PaymentMethodReact[] = [ { icon: 'sampleIcon', id: 'ACH_BANK_ACCOUNT', @@ -19,7 +19,7 @@ const paymentMethods = [ name: 'Apple Pay', description: 'Up to $500', }, -] as PaymentMethodReact[]; +]; const renderComponent = () => render( diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx index 33555fca7a..9d5c43eb7f 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx @@ -6,7 +6,7 @@ import type { PaymentMethodReact } from '../types'; import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; import { FundCardProvider } from './FundCardProvider'; -const paymentMethods = [ +const paymentMethods: PaymentMethodReact[] = [ { icon: 'sampleIcon', id: 'ACH_BANK_ACCOUNT', @@ -19,7 +19,7 @@ const paymentMethods = [ name: 'Apple Pay', description: 'Up to $500', }, -] as PaymentMethodReact[]; +]; describe('FundCardPaymentMethodSelectRow', () => { it('renders payment method name and description', () => { From 46ee540d8926d2156d5d4fa0a3fbe1f6f81e4220 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 13:50:06 -0800 Subject: [PATCH 54/91] Upadte className for FundCardHeader --- src/fund/components/FundCardHeader.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index 95b681bee7..e2b681a088 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -1,4 +1,3 @@ -import { cn } from '../../styles/theme'; import type { FundCardHeaderPropsReact } from '../types'; export function FundCardHeader({ @@ -9,7 +8,7 @@ export function FundCardHeader({ return (
{headerText || defaultHeaderText} From d11e78ab963984a1fe642a9da7f8394dde60f82e Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 13:52:17 -0800 Subject: [PATCH 55/91] Update css class --- src/fund/components/FundCardCurrencyLabel.test.tsx | 2 +- src/fund/components/FundCardCurrencyLabel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/fund/components/FundCardCurrencyLabel.test.tsx index 8c6b02b121..fff0b71581 100644 --- a/src/fund/components/FundCardCurrencyLabel.test.tsx +++ b/src/fund/components/FundCardCurrencyLabel.test.tsx @@ -13,7 +13,7 @@ describe('FundCardCurrencyLabel', () => { render(); const spanElement = screen.getByText('$'); expect(spanElement).toHaveClass( - 'flex items-center justify-center bg-transparent text-[60px] leading-none outline-none', + '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 index 05e6842c20..a4dce6ff32 100644 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -12,7 +12,7 @@ export const FundCardCurrencyLabel = forwardRef< className={cn( text.body, 'flex items-center justify-center bg-transparent', - 'text-[60px] leading-none outline-none', + 'text-6xl leading-none outline-none', )} data-testid="currencySpan" > From 52729429b45c31d8cedbd2cc4593bdbd8526a4fd Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 14:14:28 -0800 Subject: [PATCH 56/91] Address comments --- src/fund/components/FundButton.tsx | 1 - src/fund/components/FundCardHeader.tsx | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index a7b0bfd69d..f2a1b8b399 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -121,7 +121,6 @@ export function FundButton({ return ( <> {buttonIcon && ( - // h-6 is to match the icon height to the line-height set by text.headline +
{headerText || defaultHeaderText}
); From c0281378d17170d4a7b96de1ade0220ffba0938f Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 6 Jan 2025 14:36:55 -0800 Subject: [PATCH 57/91] Remove pressable.error --- src/fund/components/FundButton.tsx | 11 +++++++++-- src/styles/theme.ts | 3 +-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index f2a1b8b399..589211b58e 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -6,7 +6,14 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { Spinner } from '../../internal/components/Spinner'; import { AddSvg } from '../../internal/svg/addSvg'; import { SuccessSvg } from '../../internal/svg/successSvg'; -import { border, cn, color, pressable, text } from '../../styles/theme'; +import { + background, + border, + cn, + color, + pressable, + text, +} from '../../styles/theme'; import { useGetFundingUrl } from '../hooks/useGetFundingUrl'; import type { FundButtonReact } from '../types'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; @@ -64,7 +71,7 @@ export function FundButton({ const buttonColorClass = useMemo(() => { switch (buttonState) { case 'error': - return pressable.error; + return background.error; case 'loading': case 'success': return pressable.primary; diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 5832106b0e..1ab9d6b05d 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -1,5 +1,5 @@ -import { clsx } from 'clsx'; import type { ClassValue } from 'clsx'; +import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { @@ -26,7 +26,6 @@ export const pressable = { 'cursor-pointer ock-bg-inverse active:bg-[var(--ock-bg-inverse-active)] hover:bg-[var(--ock-bg-inverse-hover)]', primary: 'cursor-pointer ock-bg-primary active:bg-[var(--ock-bg-primary-active)] hover:bg-[var(--ock-bg-primary-hover)]', - error: 'cursor-pointer ock-bg-error', secondary: 'cursor-pointer ock-bg-secondary active:bg-[var(--ock-bg-secondary-active)] hover:bg-[var(--ock-bg-secondary-hover)]', coinbaseBranding: 'cursor-pointer bg-[#0052FF] hover:bg-[#0045D8]', From 492b911359bd72b28c758ed490d4fd14d3017c8f Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 7 Jan 2025 12:30:47 -0800 Subject: [PATCH 58/91] Address comments --- .../components/demo/FundCard.tsx | 20 ++- src/fund/components/FundCard.tsx | 148 +++++++++--------- src/fund/components/FundCardAmountInput.tsx | 1 + src/fund/types.ts | 15 +- 4 files changed, 98 insertions(+), 86 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx index d58e2ea384..c93e6dfc92 100644 --- a/playground/nextjs-app-router/components/demo/FundCard.tsx +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -3,7 +3,25 @@ import { FundCard } from '@coinbase/onchainkit/fund'; export default function FundCardDemo() { return (
- +
); } + +const CustomHeaderComponent = ({ + headerText, + assetSymbol, +}: { + headerText?: string; + assetSymbol?: string; +}) => { + return ( +
+ {headerText} ---- {assetSymbol} +
+ ); +}; diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 94d2299878..bf28a86c23 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,11 +1,9 @@ -import { findComponent } from '@/core-react/internal/utils/findComponent'; -import { Children, useMemo } 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 { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; -import type { FundCardContentPropsReact, FundCardPropsReact } from '../types'; +import type { FundCardPropsReact } from '../types'; import { FundButton } from './FundButton'; import FundCardAmountInput from './FundCardAmountInput'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; @@ -14,7 +12,11 @@ import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; import { FundCardProvider, useFundContext } from './FundCardProvider'; export function FundCard({ - children, + AmountInputComponent = FundCardAmountInput, + HeaderComponent = FundCardHeader, + AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, + PaymentMethodDropdownComponent = FundCardPaymentMethodDropdown, + ButtonComponent = FundButton, assetSymbol, buttonText = 'Buy', headerText, @@ -22,29 +24,29 @@ export function FundCard({ }: FundCardPropsReact) { const componentTheme = useTheme(); - const { - amountInputComponent, - headerComponent, - amountInputTypeSwitchComponent, - paymentMethodDropdownComponent, - submitButtonComponent, - } = useMemo(() => { - const childrenArray = Children.toArray(children); + // const { + // amountInputComponent, + // headerComponent, + // amountInputTypeSwitchComponent, + // paymentMethodDropdownComponent, + // submitButtonComponent, + // } = useMemo(() => { + // const childrenArray = Children.toArray(children); - return { - amountInputComponent: childrenArray.find( - findComponent(FundCardAmountInput), - ), - headerComponent: childrenArray.find(findComponent(FundCardHeader)), - amountInputTypeSwitchComponent: childrenArray.find( - findComponent(FundCardAmountInputTypeSwitch), - ), - paymentMethodDropdownComponent: childrenArray.find( - findComponent(FundCardPaymentMethodDropdown), - ), - submitButtonComponent: childrenArray.find(findComponent(FundButton)), - }; - }, [children]); + // return { + // amountInputComponent: childrenArray.find( + // findComponent(FundCardAmountInput), + // ), + // headerComponent: childrenArray.find(findComponent(FundCardHeader)), + // amountInputTypeSwitchComponent: childrenArray.find( + // findComponent(FundCardAmountInputTypeSwitch), + // ), + // paymentMethodDropdownComponent: childrenArray.find( + // findComponent(FundCardPaymentMethodDropdown), + // ), + // submitButtonComponent: childrenArray.find(findComponent(FundButton)), + // }; + // }, [children]); return ( @@ -63,12 +65,12 @@ export function FundCard({ assetSymbol={assetSymbol} buttonText={buttonText} headerText={headerText} - amountInputComponent={amountInputComponent} - headerComponent={headerComponent} - amountInputTypeSwitchComponent={amountInputTypeSwitchComponent} - paymentMethodDropdownComponent={paymentMethodDropdownComponent} paymentMethods={paymentMethods} - submitButtonComponent={submitButtonComponent} + AmountInputComponent={AmountInputComponent} + HeaderComponent={HeaderComponent} + AmountInputTypeSwitchComponent={AmountInputTypeSwitchComponent} + PaymentMethodDropdownComponent={PaymentMethodDropdownComponent} + ButtonComponent={ButtonComponent} />
@@ -79,13 +81,13 @@ function FundCardContent({ assetSymbol, buttonText = 'Buy', headerText, - amountInputComponent, - headerComponent, - amountInputTypeSwitchComponent, - paymentMethodDropdownComponent, paymentMethods = DEFAULT_PAYMENT_METHODS, - submitButtonComponent, -}: FundCardContentPropsReact) { + AmountInputComponent = FundCardAmountInput, + HeaderComponent = FundCardHeader, + AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, + PaymentMethodDropdownComponent = FundCardPaymentMethodDropdown, + ButtonComponent = FundButton, +}: FundCardPropsReact) { const { setFundAmountFiat, fundAmountFiat, @@ -107,51 +109,41 @@ function FundCardContent({ return (
- {headerComponent || ( - - )} + - {amountInputComponent || ( - - )} + - {amountInputTypeSwitchComponent || ( - - )} + - {paymentMethodDropdownComponent || ( - - )} + - {submitButtonComponent || ( - setSubmitButtonState('loading')} - onPopupClose={() => setSubmitButtonState('default')} - /> - )} + setSubmitButtonState('loading')} + onPopupClose={() => setSubmitButtonState('default')} + /> ); } diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 31af466f66..06cfc18ef7 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -141,6 +141,7 @@ export const FundCardAmountInput = ({ 'border-none bg-transparent', 'text-6xl leading-none outline-none', 'pointer-events-none absolute whitespace-nowrap opacity-0', + 'left-[-9999px]', // Hide the span from the DOM )} > {value ? `${value}.` : '0.'} diff --git a/src/fund/types.ts b/src/fund/types.ts index eb49a38fe8..5050eb0d1b 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -327,33 +327,34 @@ export type FundCardPropsReact = { * Payment methods to display in the dropdown */ paymentMethods?: PaymentMethodReact[]; -}; +} & FundCardContentPropsReact; export type FundCardContentPropsReact = { /** * Custom component for the amount input */ - amountInputComponent?: React.ReactElement; + AmountInputComponent?: React.ComponentType; + /** * Custom component for the header */ - headerComponent?: React.ReactElement; + HeaderComponent?: React.ComponentType; /** * Custom component for the amount input type switch */ - amountInputTypeSwitchComponent?: React.ReactElement; + AmountInputTypeSwitchComponent?: React.ComponentType; /** * Custom component for the payment method selector dropdown */ - paymentMethodDropdownComponent?: React.ReactElement; + PaymentMethodDropdownComponent?: React.ComponentType; /** * Custom component for the submit button */ - submitButtonComponent?: React.ReactElement; -} & FundCardPropsReact; + ButtonComponent?: React.ComponentType; +}; export type FundCardPaymentMethodSelectorTogglePropsReact = { className?: string; From a417a96e4b45112185676995eadc58d002305813 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 7 Jan 2025 16:43:51 -0800 Subject: [PATCH 59/91] Address comments... --- .../components/demo/FundCard.tsx | 20 +- src/fund/components/FundCard.tsx | 104 ++----- .../components/FundCardAmountInput.test.tsx | 267 +++++++++++++----- src/fund/components/FundCardAmountInput.tsx | 55 ++-- .../FundCardAmountInputTypeSwitch.test.tsx | 234 ++++++++++----- .../FundCardAmountInputTypeSwitch.tsx | 26 +- src/fund/components/FundCardHeader.test.tsx | 26 +- src/fund/components/FundCardHeader.tsx | 11 +- .../FundCardPaymentMethodDropdown.test.tsx | 18 +- .../FundCardPaymentMethodDropdown.tsx | 7 +- src/fund/components/FundCardProvider.tsx | 22 +- src/fund/types.ts | 57 +--- 12 files changed, 478 insertions(+), 369 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx index c93e6dfc92..d58e2ea384 100644 --- a/playground/nextjs-app-router/components/demo/FundCard.tsx +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -3,25 +3,7 @@ import { FundCard } from '@coinbase/onchainkit/fund'; export default function FundCardDemo() { return (
- +
); } - -const CustomHeaderComponent = ({ - headerText, - assetSymbol, -}: { - headerText?: string; - assetSymbol?: string; -}) => { - return ( -
- {headerText} ---- {assetSymbol} -
- ); -}; diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index bf28a86c23..43b976a1bb 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -3,7 +3,7 @@ import { background, border, cn, color, text } from '../../styles/theme'; import { DEFAULT_PAYMENT_METHODS } from '../constants'; import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; -import type { FundCardPropsReact } from '../types'; +import type { FundCardContentPropsReact, FundCardPropsReact } from '../types'; import { FundButton } from './FundButton'; import FundCardAmountInput from './FundCardAmountInput'; import FundCardAmountInputTypeSwitch from './FundCardAmountInputTypeSwitch'; @@ -12,44 +12,23 @@ import { FundCardPaymentMethodDropdown } from './FundCardPaymentMethodDropdown'; import { FundCardProvider, useFundContext } from './FundCardProvider'; export function FundCard({ - AmountInputComponent = FundCardAmountInput, - HeaderComponent = FundCardHeader, - AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, - PaymentMethodDropdownComponent = FundCardPaymentMethodDropdown, - ButtonComponent = FundButton, assetSymbol, buttonText = 'Buy', headerText, + currencySign = '$', paymentMethods = DEFAULT_PAYMENT_METHODS, + children, }: FundCardPropsReact) { const componentTheme = useTheme(); - // const { - // amountInputComponent, - // headerComponent, - // amountInputTypeSwitchComponent, - // paymentMethodDropdownComponent, - // submitButtonComponent, - // } = useMemo(() => { - // const childrenArray = Children.toArray(children); - - // return { - // amountInputComponent: childrenArray.find( - // findComponent(FundCardAmountInput), - // ), - // headerComponent: childrenArray.find(findComponent(FundCardHeader)), - // amountInputTypeSwitchComponent: childrenArray.find( - // findComponent(FundCardAmountInputTypeSwitch), - // ), - // paymentMethodDropdownComponent: childrenArray.find( - // findComponent(FundCardPaymentMethodDropdown), - // ), - // submitButtonComponent: childrenArray.find(findComponent(FundButton)), - // }; - // }, [children]); - return ( - +
- + {children}
); } -function FundCardContent({ - assetSymbol, - buttonText = 'Buy', - headerText, - paymentMethods = DEFAULT_PAYMENT_METHODS, - AmountInputComponent = FundCardAmountInput, - HeaderComponent = FundCardHeader, - AmountInputTypeSwitchComponent = FundCardAmountInputTypeSwitch, - PaymentMethodDropdownComponent = FundCardPaymentMethodDropdown, - ButtonComponent = FundButton, -}: FundCardPropsReact) { +function FundCardContent({ children }: FundCardContentPropsReact) { const { - setFundAmountFiat, fundAmountFiat, fundAmountCrypto, - setFundAmountCrypto, - selectedInputType, - exchangeRate, - setSelectedInputType, - selectedAsset, - exchangeRateLoading, submitButtonState, setSubmitButtonState, + buttonText, } = useFundContext(); const fundingUrl = useFundCardFundingUrl(); @@ -109,32 +62,19 @@ function FundCardContent({ return (
- + {children || ( + <> + - + - + - + + + )} - + Promise.resolve({ + json: () => Promise.resolve(mockResponseData), + }), +) as Mock; + +vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); describe('FundCardAmountInput', () => { - const defaultProps: FundCardAmountInputPropsReact = { - fiatValue: '100', - setFiatValue: vi.fn(), - cryptoValue: '0.05', - setCryptoValue: vi.fn(), - currencySign: '$', - assetSymbol: 'ETH', - inputType: 'fiat', - exchangeRate: 2, + beforeEach(() => { + 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', () => { - render(); + renderWithProvider(); expect(screen.getByTestId('ockFundCardAmountInput')).toBeInTheDocument(); expect(screen.getByTestId('currencySpan')).toHaveTextContent('$'); }); it('renders correctly with crypto input type', () => { - render(); + renderWithProvider({ inputType: 'crypto' }); expect(screen.getByTestId('currencySpan')).toHaveTextContent('ETH'); }); - it('handles fiat input change', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '10' } }); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('10'); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('20'); - }); + it('handles fiat input change', async () => { + act(() => { + renderWithProvider(); + }); - it('handles crypto input change', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '0.1' } }); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.1'); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.05'); - }); + await waitFor(() => { + const input = screen.getByTestId('ockFundCardAmountInput'); - it('formats input value correctly when starting with a dot', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '.5' } }); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.5'); + fireEvent.change(input, { target: { value: '10' } }); + const value = screen.getByTestId('test-value-fiat'); + expect(value.textContent).toBe('10'); + }); }); - it('formats input value correctly when starting with zero', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '01' } }); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('0.1'); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith('0.2'); - }); + it('formats input value correctly when starting with a dot', async () => { + act(() => { + renderWithProvider(); + }); - it('limits decimal places to two', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '123.456' } }); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('123.45'); - }); + await waitFor(() => { + const input = screen.getByTestId('ockFundCardAmountInput'); + + fireEvent.change(input, { target: { value: '.5' } }); - it('focuses input when input type changes', () => { - const { rerender } = render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - const focusSpy = vi.spyOn(input, 'focus'); - rerender(); - expect(focusSpy).toHaveBeenCalled(); + const valueFiat = screen.getByTestId('test-value-fiat'); + expect(valueFiat.textContent).toBe('0.5'); + }); }); - it('sets crypto value to empty string when input is "0"', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '0' } }); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith(''); + it('handles crypto input change', async () => { + act(() => { + renderWithProvider({ inputType: 'crypto' }); + }); + await waitFor(() => { + const input = screen.getByTestId('ockFundCardAmountInput'); + + fireEvent.change(input, { target: { value: '1' } }); + + const valueCrypto = screen.getByTestId('test-value-crypto'); + expect(valueCrypto.textContent).toBe('1'); + }); }); - it('sets fiat value to empty string when crypto input is "0"', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '0' } }); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith(''); + it('updates input width based on content', async () => { + act(() => { + const { rerender } = renderWithProvider(); + rerender( + + + , + ); + }); + await waitFor(() => { + const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + expect(hiddenSpan).toHaveTextContent('0.'); + }); }); - it('correctly handles when exchange rate is not available', () => { - render(); - const input = screen.getByTestId('ockFundCardAmountInput'); - fireEvent.change(input, { target: { value: '200' } }); - expect(defaultProps.setCryptoValue).toHaveBeenCalledWith(''); - expect(defaultProps.setFiatValue).toHaveBeenCalledWith('200'); + it('applies custom className', () => { + act(() => { + render( + + + , + ); + }); + + const container = screen.getByTestId('ockFundCardAmountInputContainer'); + expect(container).toHaveClass('custom-class'); }); - it('hidden span has correct text value when value exist', async () => { - render(); - const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + it('handles truncation of crypto decimals', async () => { + act(() => { + renderWithProvider({ inputType: 'crypto' }); + }); - expect(hiddenSpan).toHaveTextContent('100.'); + await waitFor(() => { + const input = screen.getByTestId('ockFundCardAmountInput'); + + // 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('hidden span has correct text value when value does not exist', async () => { - render(); - const hiddenSpan = screen.getByTestId('ockHiddenSpan'); + 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('ockFundCardAmountInput'); + 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(''); + }); + }); - expect(hiddenSpan).toHaveTextContent('0.'); + 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('ockFundCardAmountInput'); + 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(''); + expect(valueFiat.textContent).toBe('0'); + + // Test empty value + fireEvent.change(input, { target: { value: '' } }); + expect(valueCrypto.textContent).toBe(''); + expect(valueFiat.textContent).toBe(''); + }); }); }); diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 06cfc18ef7..0d58fb7458 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -4,32 +4,36 @@ import type { FundCardAmountInputPropsReact } from '../types'; import { formatDecimalInputValue } from '../utils/formatDecimalInputValue'; import { truncateDecimalPlaces } from '../utils/truncateDecimalPlaces'; import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; +import { useFundContext } from './FundCardProvider'; export const FundCardAmountInput = ({ - fiatValue, - setFiatValue, - cryptoValue, - setCryptoValue, - currencySign, - assetSymbol, - inputType = 'fiat', - exchangeRate = 1, + className, }: FundCardAmountInputPropsReact) => { + const currencySign = '$'; + const { + fundAmountFiat, + setFundAmountFiat, + fundAmountCrypto, + setFundAmountCrypto, + selectedAsset, + selectedInputType, + exchangeRate, + } = useFundContext(); + const inputRef = useRef(null); const hiddenSpanRef = useRef(null); const currencySpanRef = useRef(null); - const value = inputType === 'fiat' ? fiatValue : cryptoValue; + const value = + selectedInputType === 'fiat' ? fundAmountFiat : fundAmountCrypto; const handleChange = useCallback( (e: ChangeEvent) => { - let value = e.target.value; - - value = formatDecimalInputValue(value); + const value = formatDecimalInputValue(e.target.value); - if (inputType === 'fiat') { + if (selectedInputType === 'fiat') { const fiatValue = truncateDecimalPlaces(value, 2); - setFiatValue(fiatValue); + setFundAmountFiat(fiatValue); const truncatedValue = truncateDecimalPlaces(value, 2); @@ -42,21 +46,23 @@ export const FundCardAmountInput = ({ calculatedCryptoValue, 8, ); - setCryptoValue(calculatedCryptoValue === '0' ? '' : resultCryptoValue); + setFundAmountCrypto( + calculatedCryptoValue === '0' ? '' : resultCryptoValue, + ); } else { - setCryptoValue(value); - const truncatedValue = truncateDecimalPlaces(value, 8); + setFundAmountCrypto(truncatedValue); + // Calculate the fiat value based on the exchange rate const calculatedFiatValue = String( Number(truncatedValue) / Number(exchangeRate), ); const resultFiatValue = truncateDecimalPlaces(calculatedFiatValue, 2); - setFiatValue(resultFiatValue === '0' ? '' : resultFiatValue); + setFundAmountFiat(resultFiatValue === '0' ? '' : resultFiatValue); } }, - [exchangeRate, setFiatValue, setCryptoValue, inputType], + [exchangeRate, setFundAmountFiat, setFundAmountCrypto, selectedInputType], ); // biome-ignore lint/correctness/useExhaustiveDependencies: When value changes, we want to update the input width @@ -78,7 +84,7 @@ export const FundCardAmountInput = ({ useEffect(() => { // focus the input when the input type changes handleFocusInput(); - }, [inputType]); + }, [selectedInputType]); const handleFocusInput = () => { if (inputRef.current) { @@ -88,12 +94,13 @@ export const FundCardAmountInput = ({ return (
- {inputType === 'fiat' && currencySign && ( + {selectedInputType === 'fiat' && currencySign && ( - {inputType === 'crypto' && assetSymbol && ( + {selectedInputType === 'crypto' && selectedAsset && ( )}
diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx index ad9b8a2e6b..464ba25e7b 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx @@ -1,100 +1,190 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +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; + +vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); + +const mockContext: FundCardProviderReact = { + asset: 'ETH', + inputType: 'fiat', + children:
Test
, +}; describe('FundCardAmountInputTypeSwitch', () => { - it('renders crypto amount when selectedInputType is fiat', () => { + 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( - , + + + + , ); - expect(screen.getByText('200 ETH')).toBeInTheDocument(); + + 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 fiat amount when selectedInputType is crypto', () => { + it('renders crypto to fiat conversion', async () => { render( - , + + + + , ); - expect(screen.getByText('$100')).toBeInTheDocument(); + + 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 on button click', () => { - const setSelectedInputType = vi.fn(); + it('toggles input type when clicked', async () => { render( - , + + + + , ); - fireEvent.click(screen.getByLabelText(/amount type switch/i)); - expect(setSelectedInputType).toHaveBeenCalledWith('crypto'); + + 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 on button click', () => { - const setSelectedInputType = vi.fn(); + it('renders loading skeleton when exchange rate is loading', () => { render( - , + + + + , ); - fireEvent.click(screen.getByLabelText(/amount type switch/i)); - expect(setSelectedInputType).toHaveBeenCalledWith('fiat'); + + expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); }); - it('does not render fiat amount when fundAmountFiat is "0"', () => { + it('applies custom className', async () => { render( - , + + + + , ); - expect(screen.queryByText('$0.00')).not.toBeInTheDocument(); + + await waitFor(() => { + const container = screen.getByTestId('ockAmountTypeSwitch').parentElement; + expect(container).toHaveClass('custom-class'); + }); }); - it('renders Skeleton when exchangeRate does not exist', () => { + it('toggles input type from fiat to crypto when clicked', async () => { render( - , + + + + , ); - expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument(); + + 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 index 11699a3922..c3cd84994f 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -5,16 +5,21 @@ 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 = ({ - selectedInputType, - setSelectedInputType, - selectedAsset, - fundAmountFiat, - fundAmountCrypto, - exchangeRate, - isLoading, + className, }: FundCardAmountInputTypeSwitchPropsReact) => { + const { + selectedInputType, + setSelectedInputType, + selectedAsset, + fundAmountFiat, + fundAmountCrypto, + exchangeRate, + exchangeRateLoading, + } = useFundContext(); + const iconSvg = useIcon({ icon: 'toggle' }); const handleToggle = () => { @@ -36,6 +41,7 @@ export const FundCardAmountInputTypeSwitch = ({ const exchangeRateLine = useMemo(() => { return ( { return ( - + {selectedInputType === 'fiat' ? formatCrypto(fundAmountCrypto) : formatUSD(fundAmountFiat)} @@ -64,12 +70,12 @@ export const FundCardAmountInputTypeSwitch = ({ formatCrypto, ]); - if (isLoading || !exchangeRate) { + if (exchangeRateLoading || !exchangeRate) { return ; } return ( -
+
+ - , + ); +}; describe('FundCardPaymentMethodDropdown', () => { - afterEach(() => { - vi.clearAllMocks(); + beforeEach(() => { + vi.resetAllMocks(); + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + (isApplePaySupported as Mock).mockResolvedValue(true); // Default to supported + }); + + 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__FIAT_WALLET', + ); + expect(coinbaseButton).not.toBeDisabled(); }); - it('renders the first payment method by default', () => { - renderComponent(); + 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__FIAT_WALLET', + ); + + 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.getByText('Apple Pay')); + + // Verify Apple Pay is selected expect( screen.getByTestId( 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', ), - ).toBeInTheDocument(); + ).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('toggles the dropdown when the toggle button is clicked', () => { - renderComponent(); - const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle', + 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'), ); - // Initially closed + + // Check descriptions are original expect( - screen.queryByTestId('ockFundCardPaymentMethodDropdown'), - ).not.toBeInTheDocument(); - // Click to open - act(() => { - toggleButton.click(); - }); + screen.getByText('Buy with your Coinbase account'), + ).toBeInTheDocument(); + + expect(screen.getAllByText('Up to $500/week')).toHaveLength(2); + }); + + it('closes dropdown when clicking outside', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); expect( screen.getByTestId('ockFundCardPaymentMethodDropdown'), ).toBeInTheDocument(); - // Click to close - act(() => { - toggleButton.click(); - }); + + // Click outside + fireEvent.mouseDown(document.body); + + // Verify dropdown is closed expect( screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); }); - it('selects a payment method and updates the selection', () => { - renderComponent(); - const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle', + it('closes dropdown when pressing Escape key', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), ); - act(() => { - toggleButton.click(); - }); - const applePayOption = screen.getByText('Apple Pay'); - act(() => { - applePayOption.click(); - }); - expect(screen.getByText('Apple Pay')).toBeInTheDocument(); + 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('closes the dropdown when clicking outside', () => { - renderComponent(); - const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle', - ); + it('toggles dropdown visibility when clicking the toggle button', () => { + renderWithProvider({ amount: '5' }); - act(() => { - toggleButton.click(); - }); + // Initially dropdown should be closed + expect( + screen.queryByTestId('ockFundCardPaymentMethodDropdown'), + ).not.toBeInTheDocument(); + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); expect( screen.getByTestId('ockFundCardPaymentMethodDropdown'), ).toBeInTheDocument(); - act(() => { - document.body.click(); - }); - + // Close dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), + ); expect( screen.queryByTestId('ockFundCardPaymentMethodDropdown'), ).not.toBeInTheDocument(); }); - it('closes the dropdown when Escape key is pressed', () => { - renderComponent(); - const toggleButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectorToggle', + it('ignores non-Escape key presses', () => { + renderWithProvider({ amount: '5' }); + + // Open dropdown + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectorToggle'), ); + expect( + screen.getByTestId('ockFundCardPaymentMethodDropdown'), + ).toBeInTheDocument(); - act(() => { - toggleButton.click(); - }); + // Press a different key + fireEvent.keyUp( + screen.getByTestId('ockFundCardPaymentMethodDropdownContainer'), + { key: 'Enter' }, + ); + + // Verify dropdown is still open expect( screen.getByTestId('ockFundCardPaymentMethodDropdown'), ).toBeInTheDocument(); + }); - act(() => { - fireEvent.keyUp(screen.getByTestId('ockFundCardPaymentMethodDropdown'), { - key: 'Escape', - }); + 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('ockFundCardPaymentMethodDropdown'), + screen.queryByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'), ).not.toBeInTheDocument(); + + // Other payment methods should still be there + expect( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__FIAT_WALLET'), + ).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 index 08f17103de..8388fd2770 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -1,7 +1,7 @@ -import { useCallback, useRef, useState } from 'react'; -import { background, border, cn } from '../../styles/theme'; - +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, @@ -10,20 +10,65 @@ 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 } = - useFundContext(); + 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 + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if ( + selectedPaymentMethod && + isPaymentMethodDisabled(selectedPaymentMethod) + ) { + const coinbaseMethod = paymentMethods.find((m) => m.id === 'FIAT_WALLET'); + if (coinbaseMethod) { + setSelectedPaymentMethod(coinbaseMethod); + } + } + }, [ + fundAmountFiat, + selectedPaymentMethod, + paymentMethods, + setSelectedPaymentMethod, + isPaymentMethodDisabled, + ]); const handlePaymentMethodSelect = useCallback( (paymentMethod: PaymentMethodReact) => { - setSelectedPaymentMethod(paymentMethod); - setIsOpen(false); + if (!isPaymentMethodDisabled(paymentMethod)) { + setSelectedPaymentMethod(paymentMethod); + setIsOpen(false); + } }, - [setSelectedPaymentMethod], + [setSelectedPaymentMethod, isPaymentMethodDisabled], ); const handleToggle = useCallback(() => { @@ -60,7 +105,7 @@ export function FundCardPaymentMethodDropdown({ ref={buttonRef} onClick={handleToggle} isOpen={isOpen} - paymentMethod={selectedPaymentMethod || paymentMethods[0]} + paymentMethod={selectedPaymentMethod || filteredPaymentMethods[0]} /> {isOpen && (
- {paymentMethods.map((paymentMethod) => ( + {filteredPaymentMethods.map((paymentMethod) => ( ))}
diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx index 9d5c43eb7f..1aaa86bca7 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx @@ -1,56 +1,61 @@ import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { PaymentMethodReact } from '../types'; import { FundCardPaymentMethodSelectRow } from './FundCardPaymentMethodSelectRow'; -import { FundCardProvider } from './FundCardProvider'; - -const paymentMethods: PaymentMethodReact[] = [ - { - icon: 'sampleIcon', - id: 'ACH_BANK_ACCOUNT', - name: 'Bank account', - description: 'Up to $500', - }, - { - icon: 'anotherIcon', + +describe('FundCardPaymentMethodSelectRow', () => { + const mockPaymentMethod: PaymentMethodReact = { id: 'APPLE_PAY', name: 'Apple Pay', - description: 'Up to $500', - }, -]; + description: 'Up to $500/week', + icon: 'applePay', + }; -describe('FundCardPaymentMethodSelectRow', () => { - it('renders payment method name and description', () => { + it('renders disabled state correctly', () => { + const onClick = vi.fn(); render( - - - , + , ); - expect(screen.getByText('Bank account')).toBeInTheDocument(); - expect(screen.getByText('Up to $500')).toBeInTheDocument(); + + 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('calls onClick with payment method when clicked', () => { - const handleClick = vi.fn(); + it('does not call onClick when disabled', () => { + const onClick = vi.fn(); render( - - - , + , ); - const button = screen.getByTestId( - 'ockFundCardPaymentMethodSelectRow__button', + + fireEvent.click(screen.getByTestId('ockFundCardPaymentMethodSelectRow')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('shows original description when not disabled', () => { + render( + , ); - act(() => { - button.click(); - }); - expect(handleClick).toHaveBeenCalledWith(paymentMethods[1]); + + expect(screen.getByText('Up to $500/week')).toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx index d5882dc918..c4c395ea32 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -10,31 +10,37 @@ export const FundCardPaymentMethodSelectRow = memo( onClick, hideImage, hideDescription, + disabled, + disabledReason, + testId, }: FundCardPaymentMethodSelectRowPropsReact) => { return (
); } -function FundCardContent({ children }: FundCardContentPropsReact) { - const { - fundAmountFiat, - fundAmountCrypto, - submitButtonState, - setSubmitButtonState, - buttonText, - } = useFundContext(); - - const fundingUrl = useFundCardFundingUrl(); - - // Setup event listeners for the onramp - useFundCardSetupOnrampEventListeners(); - - return ( -
- {children} - - setSubmitButtonState('loading')} - onPopupClose={() => setSubmitButtonState('default')} - /> - - ); -} - function DefaultFundCardContent() { return ( <> @@ -87,6 +55,7 @@ function DefaultFundCardContent() { + ); } diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 19476fcdbf..a91c3bfae3 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -117,7 +117,6 @@ export const FundCardAmountInput = ({ onChange={handleChange} ref={inputRef} inputMode="decimal" - minLength={1} placeholder="0" data-testid="ockFundCardAmountInput" /> diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index 8388fd2770..8d3f6d3c07 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -42,7 +42,7 @@ export function FundCardPaymentMethodDropdown({ ); // If current selected method becomes disabled, switch to Coinbase - // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { if ( selectedPaymentMethod && @@ -54,7 +54,6 @@ export function FundCardPaymentMethodDropdown({ } } }, [ - fundAmountFiat, selectedPaymentMethod, paymentMethods, setSelectedPaymentMethod, diff --git a/src/fund/components/FundCardSubmitButton.test.tsx b/src/fund/components/FundCardSubmitButton.test.tsx new file mode 100644 index 0000000000..bb3c2ff647 --- /dev/null +++ b/src/fund/components/FundCardSubmitButton.test.tsx @@ -0,0 +1,174 @@ +import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; +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 { 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('../../core-react/internal/hooks/useDebounce', () => ({ + useDebounce: vi.fn((callback) => callback), +})); + +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, + } = 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); + (useDebounce as Mock).mockImplementation((callback) => callback); + (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 () => { + render( + + + + + , + ); + + const button = screen.getByTestId('set-fiat-amount'); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByTestId('ockFundButton')).not.toBeDisabled(); + }); + }); + + it('shows loading state when clicked', async () => { + render( + + + + , + ); + + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); + + 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(); + }); +}); diff --git a/src/fund/components/FundCardSubmitButton.tsx b/src/fund/components/FundCardSubmitButton.tsx new file mode 100644 index 0000000000..364f11f93c --- /dev/null +++ b/src/fund/components/FundCardSubmitButton.tsx @@ -0,0 +1,32 @@ +import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; +import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; +import { FundButton } from './FundButton'; +import { useFundContext } from './FundCardProvider'; + +export function FundCardSubmitButton() { + const { + fundAmountFiat, + fundAmountCrypto, + submitButtonState, + setSubmitButtonState, + buttonText, + } = useFundContext(); + + const fundingUrl = useFundCardFundingUrl(); + + // Setup event listeners for the onramp + useFundCardSetupOnrampEventListeners(); + + return ( + setSubmitButtonState('loading')} + onPopupClose={() => setSubmitButtonState('default')} + /> + ); +} From fdd28fbf130186e70950af8088c789308a09520d Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 10 Jan 2025 15:28:17 -0800 Subject: [PATCH 67/91] Update --- .../components/demo/FundCard.tsx | 4 +- src/fund/components/FundCard.test.tsx | 52 +++++++++++--- src/fund/components/FundCard.tsx | 6 +- .../components/FundCardAmountInput.test.tsx | 14 ++-- src/fund/components/FundCardAmountInput.tsx | 21 ++---- .../FundCardAmountInputTypeSwitch.test.tsx | 3 +- src/fund/components/FundCardCurrencyLabel.tsx | 7 +- src/fund/components/FundCardHeader.test.tsx | 4 +- .../FundCardPaymentMethodDropdown.test.tsx | 6 +- .../FundCardPaymentMethodDropdown.tsx | 33 +++++---- src/fund/components/FundCardProvider.tsx | 12 ++-- .../components/FundCardSubmitButton.test.tsx | 68 ++++++++++++++----- src/fund/types.ts | 8 ++- 13 files changed, 158 insertions(+), 80 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx index d58e2ea384..2ef168f5c0 100644 --- a/playground/nextjs-app-router/components/demo/FundCard.tsx +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -2,8 +2,8 @@ import { FundCard } from '@coinbase/onchainkit/fund'; export default function FundCardDemo() { return ( -
- +
+
); } diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index 602c1ebff2..ee50a62c0e 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -15,7 +15,7 @@ import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; -import { FundCardProvider, useFundContext } from './FundCardProvider'; +import { useFundContext } from './FundCardProvider'; const mockUpdateInputWidth = vi.fn(); vi.mock('../hooks/useInputResize', () => ({ @@ -84,6 +84,8 @@ const TestComponent = () => { fundAmountCrypto, exchangeRate, exchangeRateLoading, + setFundAmountFiat, + setSelectedInputType, } = useFundContext(); return ( @@ -94,16 +96,31 @@ const TestComponent = () => { {exchangeRateLoading ? 'loading' : 'not-loading'} +
); }; -const renderComponent = (inputType: 'fiat' | 'crypto' = 'fiat') => +const renderComponent = () => render( - - + <> + - , + , ); describe('FundCard', () => { @@ -167,19 +184,29 @@ describe('FundCard', () => { }); it('disables the submit button when fund amount is zero and type is fiat', () => { - renderComponent('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('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('fiat'); + renderComponent(); + const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); + fireEvent.click(setFiatAmountButton); await waitFor(() => { expect(screen.getByTestId('loading-state').textContent).toBe( @@ -192,8 +219,13 @@ describe('FundCard', () => { expect(button).not.toBeDisabled(); }); }); + it('enables the submit button when fund amount is greater than zero and type is crypto', async () => { - renderComponent('crypto'); + renderComponent(); + const setCryptoInputTypeButton = screen.getByTestId( + 'set-crypto-input-type', + ); + fireEvent.click(setCryptoInputTypeButton); await waitFor(() => { expect(screen.getByTestId('loading-state').textContent).toBe( @@ -259,7 +291,7 @@ describe('FundCard', () => { it('renders custom children instead of default children', () => { render( - +
Custom Content
, ); diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 7ce5d7c3e8..3f7a0ff986 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -13,7 +13,8 @@ export function FundCard({ assetSymbol, buttonText = 'Buy', headerText, - currencySign = '$', + country = 'US', + subdivision, paymentMethods = DEFAULT_PAYMENT_METHODS, children = , className, @@ -26,7 +27,8 @@ export function FundCard({ paymentMethods={paymentMethods} headerText={headerText} buttonText={buttonText} - currencySign={currencySign} + country={country} + subdivision={subdivision} >
{ initialProps: Partial = {}, ) => { return render( - + , @@ -131,7 +131,7 @@ describe('FundCardAmountInput', () => { it('applies custom className', () => { act(() => { render( - + , ); @@ -173,7 +173,7 @@ describe('FundCardAmountInput', () => { it('handles zero and empty values in crypto mode', async () => { act(() => { render( - + , @@ -204,7 +204,7 @@ describe('FundCardAmountInput', () => { it('handles zero and empty values in fiat mode', async () => { act(() => { render( - + , @@ -230,7 +230,7 @@ describe('FundCardAmountInput', () => { it('handles non zero values in fiat mode', async () => { act(() => { render( - + , @@ -270,7 +270,7 @@ describe('FundCardAmountInput', () => { }); render( - + , ); @@ -320,7 +320,7 @@ describe('FundCardAmountInput', () => { act(() => { render( - + , diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index a91c3bfae3..163b698312 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -10,7 +10,8 @@ import { useFundContext } from './FundCardProvider'; export const FundCardAmountInput = ({ className, }: FundCardAmountInputPropsReact) => { - const currencySign = '$'; + // TODO: Get currency label from country + const currencyLabel = 'USD'; const { fundAmountFiat, setFundAmountFiat, @@ -96,13 +97,6 @@ export const FundCardAmountInput = ({ onKeyUp={handleFocusInput} >
- {selectedInputType === 'fiat' && currencySign && ( - - )} - - {selectedInputType === 'crypto' && selectedAsset && ( - - )} + +
{/* Hidden span for measuring text width diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx index 464ba25e7b..da73e660eb 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx @@ -27,6 +27,7 @@ vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ const mockContext: FundCardProviderReact = { asset: 'ETH', + country: 'US', inputType: 'fiat', children:
Test
, }; @@ -132,7 +133,7 @@ describe('FundCardAmountInputTypeSwitch', () => { it('applies custom className', async () => { render( - + , diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx index a4dce6ff32..871a9b4ac2 100644 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -1,22 +1,23 @@ import { forwardRef } from 'react'; -import { cn, text } from '../../styles/theme'; +import { cn, color, text } from '../../styles/theme'; import type { FundCardCurrencyLabelPropsReact } from '../types'; export const FundCardCurrencyLabel = forwardRef< HTMLSpanElement, FundCardCurrencyLabelPropsReact ->(({ currencySign }, ref) => { +>(({ label }, ref) => { return ( - {currencySign} + {label} ); }); diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index 403e5b3307..5cdd5184b6 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -7,7 +7,7 @@ import { FundCardProvider } from './FundCardProvider'; describe('FundCardHeader', () => { it('renders the provided headerText', () => { render( - + , ); @@ -18,7 +18,7 @@ describe('FundCardHeader', () => { it('renders the default header text when headerText is not provided', () => { render( - + , ); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx index ef01a82c8c..a3a0a5e77e 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -62,7 +62,11 @@ describe('FundCardPaymentMethodDropdown', () => { const renderWithProvider = ({ amount = '5' }: { amount?: string }) => { return render( - + , ); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index 8d3f6d3c07..6d5a98dc5c 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -116,21 +116,24 @@ export function FundCardPaymentMethodDropdown({ )} >
- {filteredPaymentMethods.map((paymentMethod) => ( - - ))} + {filteredPaymentMethods.map((paymentMethod) => { + const isDisabled = isPaymentMethodDisabled(paymentMethod); + return ( + + ); + })}
)} diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 6a153e4148..3b57648c06 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -29,7 +29,8 @@ type FundCardContextType = { paymentMethods: PaymentMethodReact[]; headerText?: string; buttonText?: string; - currencySign?: string; + country: string; + subdivision?: string; inputType?: 'fiat' | 'crypto'; }; @@ -41,7 +42,8 @@ export function FundCardProvider({ paymentMethods, headerText, buttonText, - currencySign, + country, + subdivision, inputType, }: FundCardProviderReact) { const [selectedAsset, setSelectedAsset] = useState(asset); @@ -67,7 +69,8 @@ export function FundCardProvider({ paymentCurrency: 'USD', paymentAmount: '100', paymentMethod: 'CARD', - country: 'US', + country, + subdivision, }); setExchangeRateLoading(false); @@ -102,7 +105,8 @@ export function FundCardProvider({ paymentMethods: paymentMethods || DEFAULT_PAYMENT_METHODS, headerText, buttonText, - currencySign, + country, + subdivision, }); return {children}; } diff --git a/src/fund/components/FundCardSubmitButton.test.tsx b/src/fund/components/FundCardSubmitButton.test.tsx index bb3c2ff647..cfc8bfdc8f 100644 --- a/src/fund/components/FundCardSubmitButton.test.tsx +++ b/src/fund/components/FundCardSubmitButton.test.tsx @@ -1,7 +1,14 @@ import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +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'; @@ -95,6 +102,11 @@ const TestHelperComponent = () => { data-testid="set-fiat-amount" onClick={() => setFundAmountFiat('100')} /> +
); }; @@ -117,7 +129,8 @@ describe('FundCardSubmitButton', () => { const renderComponent = () => { return render( - + + , ); @@ -129,14 +142,7 @@ describe('FundCardSubmitButton', () => { }); it('enables when fiat amount is set', async () => { - render( - - - - - , - ); - + renderComponent(); const button = screen.getByTestId('set-fiat-amount'); fireEvent.click(button); @@ -145,13 +151,19 @@ describe('FundCardSubmitButton', () => { }); }); + 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('shows loading state when clicked', async () => { - render( - - - - , - ); + renderComponent(); const setFiatAmountButton = screen.getByTestId('set-fiat-amount'); fireEvent.click(setFiatAmountButton); @@ -171,4 +183,28 @@ describe('FundCardSubmitButton', () => { ).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); + + // 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/types.ts b/src/fund/types.ts index bcbec9a45a..cade9e0e98 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -300,7 +300,7 @@ export type FundCardPaymentMethodDropdownPropsReact = { }; export type FundCardCurrencyLabelPropsReact = { - currencySign: string; + label: string; }; export type FundCardPropsReact = { @@ -309,7 +309,8 @@ export type FundCardPropsReact = { placeholder?: string | React.ReactNode; headerText?: string; buttonText?: string; - currencySign?: string; + country: string; + subdivision?: string; /** * Payment methods to display in the dropdown */ @@ -344,6 +345,7 @@ export type FundCardProviderReact = { paymentMethods?: PaymentMethodReact[]; headerText?: string; buttonText?: string; - currencySign?: string; + country: string; + subdivision?: string; inputType?: 'fiat' | 'crypto'; }; From d8f256124f0b29016d6efb81de508a089b8c91ee Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 10 Jan 2025 15:41:31 -0800 Subject: [PATCH 68/91] Update --- src/fund/components/FundCardAmountInput.tsx | 4 +-- .../FundCardAmountInputTypeSwitch.tsx | 10 +++--- .../components/FundCardCurrencyLabel.test.tsx | 4 +-- src/fund/components/FundCardHeader.tsx | 4 +-- src/fund/components/FundCardProvider.tsx | 9 ++---- src/fund/components/FundProvider.test.tsx | 32 ++----------------- src/fund/hooks/useFundCardFundingUrl.test.ts | 10 +++--- src/fund/hooks/useFundCardFundingUrl.ts | 6 ++-- 8 files changed, 25 insertions(+), 54 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 163b698312..e8f4932b9e 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -17,7 +17,7 @@ export const FundCardAmountInput = ({ setFundAmountFiat, fundAmountCrypto, setFundAmountCrypto, - selectedAsset, + asset, selectedInputType, exchangeRate, } = useFundContext(); @@ -117,7 +117,7 @@ export const FundCardAmountInput = ({
diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx index c3cd84994f..237b965cf9 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -13,7 +13,7 @@ export const FundCardAmountInputTypeSwitch = ({ const { selectedInputType, setSelectedInputType, - selectedAsset, + asset, fundAmountFiat, fundAmountCrypto, exchangeRate, @@ -33,9 +33,9 @@ export const FundCardAmountInputTypeSwitch = ({ const formatCrypto = useCallback( (amount: string) => { - return `${truncateDecimalPlaces(amount || '0', 8)} ${selectedAsset}`; + return `${truncateDecimalPlaces(amount || '0', 8)} ${asset}`; }, - [selectedAsset], + [asset], ); const exchangeRateLine = useMemo(() => { @@ -49,10 +49,10 @@ export const FundCardAmountInputTypeSwitch = ({ 'pl-1', )} > - ({formatUSD('1')} = {exchangeRate?.toFixed(8)} {selectedAsset}) + ({formatUSD('1')} = {exchangeRate?.toFixed(8)} {asset})
); - }, [formatUSD, exchangeRate, selectedAsset]); + }, [formatUSD, exchangeRate, asset]); const amountLine = useMemo(() => { return ( diff --git a/src/fund/components/FundCardCurrencyLabel.test.tsx b/src/fund/components/FundCardCurrencyLabel.test.tsx index fff0b71581..383cb68ef0 100644 --- a/src/fund/components/FundCardCurrencyLabel.test.tsx +++ b/src/fund/components/FundCardCurrencyLabel.test.tsx @@ -5,12 +5,12 @@ import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; describe('FundCardCurrencyLabel', () => { it('renders the currency sign', () => { - render(); + render(); expect(screen.getByText('$')).toBeInTheDocument(); }); it('applies the correct classes', () => { - render(); + 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/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index 5cce6b4e40..6a0292ee40 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -3,8 +3,8 @@ import type { FundCardHeaderPropsReact } from '../types'; import { useFundContext } from './FundCardProvider'; export function FundCardHeader({ className }: FundCardHeaderPropsReact) { - const { headerText, selectedAsset } = useFundContext(); - const defaultHeaderText = `Buy ${selectedAsset.toUpperCase()}`; + const { headerText, asset } = useFundContext(); + const defaultHeaderText = `Buy ${asset.toUpperCase()}`; return (
diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 3b57648c06..3e63bd3eb9 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -10,8 +10,7 @@ import type { import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; type FundCardContextType = { - selectedAsset: string; - setSelectedAsset: (asset: string) => void; + asset: string; selectedPaymentMethod?: PaymentMethodReact; setSelectedPaymentMethod: (paymentMethod: PaymentMethodReact) => void; selectedInputType?: 'fiat' | 'crypto'; @@ -46,7 +45,6 @@ export function FundCardProvider({ subdivision, inputType, }: FundCardProviderReact) { - const [selectedAsset, setSelectedAsset] = useState(asset); const [selectedPaymentMethod, setSelectedPaymentMethod] = useState< PaymentMethodReact | undefined >(); @@ -65,7 +63,7 @@ export function FundCardProvider({ const fetchExchangeRate = useDebounce(async () => { setExchangeRateLoading(true); const quote = await fetchOnrampQuote({ - purchaseCurrency: selectedAsset, + purchaseCurrency: asset, paymentCurrency: 'USD', paymentAmount: '100', paymentMethod: 'CARD', @@ -86,8 +84,7 @@ export function FundCardProvider({ }, []); const value = useValue({ - selectedAsset, - setSelectedAsset, + asset, selectedPaymentMethod, setSelectedPaymentMethod, fundAmountFiat, diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index 5c9da516d2..64d9d9b769 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -27,7 +27,7 @@ const TestComponent = () => { const context = useFundContext(); return (
- {context.selectedAsset} + {context.asset} {context.exchangeRate} {context.exchangeRateLoading ? 'loading' : 'not-loading'} @@ -44,43 +44,17 @@ describe('FundCardProvider', () => { it('provides default context values', () => { render( - + , ); expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); }); - it('updates selectedAsset when setSelectedAsset is called', () => { - const TestUpdateComponent = () => { - const { selectedAsset, setSelectedAsset } = useFundContext(); - return ( -
- {selectedAsset} - -
- ); - }; - - render( - - - , - ); - - expect(screen.getByTestId('selected-asset').textContent).toBe('BTC'); - act(() => { - screen.getByText('Change Asset').click(); - }); - expect(screen.getByTestId('selected-asset').textContent).toBe('ETH'); - }); - it('fetches and sets exchange rate on mount', async () => { act(() => { render( - + , ); diff --git a/src/fund/hooks/useFundCardFundingUrl.test.ts b/src/fund/hooks/useFundCardFundingUrl.test.ts index 615e46b723..756d0b57aa 100644 --- a/src/fund/hooks/useFundCardFundingUrl.test.ts +++ b/src/fund/hooks/useFundCardFundingUrl.test.ts @@ -38,7 +38,7 @@ describe('useFundCardFundingUrl', () => { selectedInputType: 'fiat', fundAmountFiat: '100', fundAmountCrypto: '0', - selectedAsset: 'ETH', + asset: 'ETH', }); const { result } = renderHook(() => useFundCardFundingUrl()); @@ -61,7 +61,7 @@ describe('useFundCardFundingUrl', () => { selectedInputType: 'fiat', fundAmountFiat: '100', fundAmountCrypto: '0', - selectedAsset: 'ETH', + asset: 'ETH', }); const { result } = renderHook(() => useFundCardFundingUrl()); @@ -84,7 +84,7 @@ describe('useFundCardFundingUrl', () => { selectedInputType: 'fiat', fundAmountFiat: '100', fundAmountCrypto: '0', - selectedAsset: 'ETH', + asset: 'ETH', }); const { result } = renderHook(() => useFundCardFundingUrl()); @@ -108,7 +108,7 @@ describe('useFundCardFundingUrl', () => { selectedInputType: 'crypto', fundAmountFiat: '0', fundAmountCrypto: '1.5', - selectedAsset: 'ETH', + asset: 'ETH', }); const { result } = renderHook(() => useFundCardFundingUrl()); @@ -132,7 +132,7 @@ describe('useFundCardFundingUrl', () => { selectedInputType: 'fiat', fundAmountFiat: '100', fundAmountCrypto: '0', - selectedAsset: 'ETH', + asset: 'ETH', }); const { result } = renderHook(() => useFundCardFundingUrl()); diff --git a/src/fund/hooks/useFundCardFundingUrl.ts b/src/fund/hooks/useFundCardFundingUrl.ts index de201c5b25..09dd1e82f2 100644 --- a/src/fund/hooks/useFundCardFundingUrl.ts +++ b/src/fund/hooks/useFundCardFundingUrl.ts @@ -12,7 +12,7 @@ export const useFundCardFundingUrl = () => { selectedInputType, fundAmountFiat, fundAmountCrypto, - selectedAsset, + asset, } = useFundContext(); const chain = accountChain || defaultChain; @@ -27,7 +27,7 @@ export const useFundCardFundingUrl = () => { return getOnrampBuyUrl({ projectId, - assets: [selectedAsset], + assets: [asset], presetFiatAmount: selectedInputType === 'fiat' ? Number(fundAmount) : undefined, presetCryptoAmount: @@ -36,7 +36,7 @@ export const useFundCardFundingUrl = () => { addresses: { [address]: [chain.name.toLowerCase()] }, }); }, [ - selectedAsset, + asset, fundAmountFiat, fundAmountCrypto, selectedPaymentMethod, From 1200f79580729244d667ba6474af0cff7008705f Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 10 Jan 2025 16:09:05 -0800 Subject: [PATCH 69/91] Remove debounce --- src/fund/components/FundCard.test.tsx | 6 ------ src/fund/components/FundCardAmountInput.test.tsx | 4 ---- .../FundCardAmountInputTypeSwitch.test.tsx | 4 ---- src/fund/components/FundCardHeader.tsx | 5 ++--- .../components/FundCardPaymentMethodDropdown.tsx | 1 - src/fund/components/FundCardProvider.tsx | 15 ++++++++++----- src/fund/components/FundCardSubmitButton.test.tsx | 6 ------ src/fund/components/FundProvider.test.tsx | 4 ---- 8 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index ee50a62c0e..9e2952832d 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -1,4 +1,3 @@ -import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; import '@testing-library/jest-dom'; @@ -30,10 +29,6 @@ vi.mock('../hooks/useGetFundingUrl', () => ({ useGetFundingUrl: vi.fn(), })); -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - vi.mock('../hooks/useFundCardFundingUrl', () => ({ useFundCardFundingUrl: vi.fn(), })); @@ -133,7 +128,6 @@ describe('FundCard', () => { width: 100, })); (useFundCardFundingUrl as Mock).mockReturnValue('mock-funding-url'); - (useDebounce as Mock).mockImplementation((callback) => callback); (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); (useAccount as Mock).mockReturnValue({ address: '0x123', diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 40e9a814cc..73df2b90a8 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -22,10 +22,6 @@ global.fetch = vi.fn(() => }), ) as Mock; -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - // Mock ResizeObserver class ResizeObserverMock { observe() {} diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx index da73e660eb..3ddc13eaeb 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.test.tsx @@ -21,10 +21,6 @@ global.fetch = vi.fn(() => }), ) as Mock; -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - const mockContext: FundCardProviderReact = { asset: 'ETH', country: 'US', diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index 6a0292ee40..47f3fe6fdf 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -3,12 +3,11 @@ import type { FundCardHeaderPropsReact } from '../types'; import { useFundContext } from './FundCardProvider'; export function FundCardHeader({ className }: FundCardHeaderPropsReact) { - const { headerText, asset } = useFundContext(); - const defaultHeaderText = `Buy ${asset.toUpperCase()}`; + const { headerText } = useFundContext(); return (
- {headerText || defaultHeaderText} + {headerText}
); } diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index 6d5a98dc5c..ab24227ad1 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -42,7 +42,6 @@ export function FundCardPaymentMethodDropdown({ ); // If current selected method becomes disabled, switch to Coinbase - useEffect(() => { if ( selectedPaymentMethod && diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 3e63bd3eb9..9493998e2e 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -1,5 +1,10 @@ -import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; -import { createContext, useContext, useEffect, useState } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { useValue } from '../../core-react/internal/hooks/useValue'; import { DEFAULT_PAYMENT_METHODS } from '../constants'; import type { @@ -39,7 +44,7 @@ export function FundCardProvider({ children, asset, paymentMethods, - headerText, + headerText = `Buy ${asset.toUpperCase()}`, buttonText, country, subdivision, @@ -60,7 +65,7 @@ export function FundCardProvider({ const [submitButtonState, setSubmitButtonState] = useState('default'); - const fetchExchangeRate = useDebounce(async () => { + const fetchExchangeRate = useCallback(async () => { setExchangeRateLoading(true); const quote = await fetchOnrampQuote({ purchaseCurrency: asset, @@ -76,7 +81,7 @@ export function FundCardProvider({ setExchangeRate( Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value), ); - }, 1000); + }, [asset, country, subdivision]); // biome-ignore lint/correctness/useExhaustiveDependencies: One time effect useEffect(() => { diff --git a/src/fund/components/FundCardSubmitButton.test.tsx b/src/fund/components/FundCardSubmitButton.test.tsx index cfc8bfdc8f..94fb435c2c 100644 --- a/src/fund/components/FundCardSubmitButton.test.tsx +++ b/src/fund/components/FundCardSubmitButton.test.tsx @@ -1,4 +1,3 @@ -import { useDebounce } from '@/core-react/internal/hooks/useDebounce'; import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; import '@testing-library/jest-dom'; @@ -25,10 +24,6 @@ vi.mock('../hooks/useGetFundingUrl', () => ({ useGetFundingUrl: vi.fn(), })); -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - vi.mock('../hooks/useFundCardFundingUrl', () => ({ useFundCardFundingUrl: vi.fn(), })); @@ -121,7 +116,6 @@ describe('FundCardSubmitButton', () => { })); (useFundCardFundingUrl as Mock).mockReturnValue('mock-funding-url'); (fetchOnrampQuote as Mock).mockResolvedValue(mockResponseData); - (useDebounce as Mock).mockImplementation((callback) => callback); (useAccount as Mock).mockReturnValue({ address: '0x123', }); diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundProvider.test.tsx index 64d9d9b769..f0ff575c44 100644 --- a/src/fund/components/FundProvider.test.tsx +++ b/src/fund/components/FundProvider.test.tsx @@ -19,10 +19,6 @@ global.fetch = vi.fn(() => }), ) as Mock; -vi.mock('../../core-react/internal/hooks/useDebounce', () => ({ - useDebounce: vi.fn((callback) => callback), -})); - const TestComponent = () => { const context = useFundContext(); return ( From 2df11f68f062f53cd881dd41d79910fd2bb240a7 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Fri, 10 Jan 2025 16:57:16 -0800 Subject: [PATCH 70/91] Add Lifecycle methods --- src/fund/components/FundCard.tsx | 22 ++- .../FundCardPaymentMethodDropdown.tsx | 1 - .../FundCardPaymentMethodSelectRow.tsx | 14 +- .../FundCardPaymentMethodSelectorToggle.tsx | 13 +- src/fund/components/FundCardProvider.tsx | 11 ++ src/fund/components/FundCardSubmitButton.tsx | 4 - ...eFundCardSetupOnrampEventListeners.test.ts | 130 --------------- ...FundCardSetupOnrampEventListeners.test.tsx | 150 ++++++++++++++++++ .../useFundCardSetupOnrampEventListeners.ts | 6 +- src/fund/types.ts | 7 +- src/fund/utils/truncateDecimalPlaces.test.ts | 31 ++-- src/fund/utils/truncateDecimalPlaces.ts | 18 ++- 12 files changed, 244 insertions(+), 163 deletions(-) delete mode 100644 src/fund/hooks/useFundCardSetupOnrampEventListeners.test.ts create mode 100644 src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx diff --git a/src/fund/components/FundCard.tsx b/src/fund/components/FundCard.tsx index 3f7a0ff986..56cbdaca9e 100644 --- a/src/fund/components/FundCard.tsx +++ b/src/fund/components/FundCard.tsx @@ -1,6 +1,8 @@ +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'; @@ -18,6 +20,9 @@ export function FundCard({ paymentMethods = DEFAULT_PAYMENT_METHODS, children = , className, + onError, + onStatus, + onSuccess, }: FundCardPropsReact) { const componentTheme = useTheme(); @@ -29,6 +34,9 @@ export function FundCard({ buttonText={buttonText} country={country} subdivision={subdivision} + onError={onError} + onStatus={onStatus} + onSuccess={onSuccess} >
-
- {children} -
+ {children}
); } +function FundCardContent({ children }: { children: ReactNode }) { + // Setup event listeners for the onramp + useFundCardSetupOnrampEventListeners(); + return ( +
+ {children} +
+ ); +} + function DefaultFundCardContent() { return ( <> diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index ab24227ad1..986b4aa776 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -119,7 +119,6 @@ export function FundCardPaymentMethodDropdown({ const isDisabled = isPaymentMethodDisabled(paymentMethod); return ( !disabled && onClick?.(paymentMethod)} disabled={disabled} diff --git a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx index a291f95536..b4fca4ca2d 100644 --- a/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectorToggle.tsx @@ -1,5 +1,4 @@ import { type ForwardedRef, forwardRef } from 'react'; -import { caretDownSvg } from '../../internal/svg/caretDownSvg'; import { caretUpSvg } from '../../internal/svg/caretUpSvg'; import { border, cn, color, pressable, text } from '../../styles/theme'; import type { FundCardPaymentMethodSelectorTogglePropsReact } from '../types'; @@ -43,10 +42,14 @@ export const FundCardPaymentMethodSelectorToggle = forwardRef( {paymentMethod.name}
-
-
- {isOpen ? caretUpSvg : caretDownSvg} -
+ + {caretUpSvg} + ); }, diff --git a/src/fund/components/FundCardProvider.tsx b/src/fund/components/FundCardProvider.tsx index 9493998e2e..aa1a2204e8 100644 --- a/src/fund/components/FundCardProvider.tsx +++ b/src/fund/components/FundCardProvider.tsx @@ -8,8 +8,10 @@ import { import { useValue } from '../../core-react/internal/hooks/useValue'; import { DEFAULT_PAYMENT_METHODS } from '../constants'; import type { + EventMetadata, FundButtonStateReact, FundCardProviderReact, + OnrampError, PaymentMethodReact, } from '../types'; import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; @@ -36,6 +38,9 @@ type FundCardContextType = { country: string; subdivision?: string; inputType?: 'fiat' | 'crypto'; + onError?: (e: OnrampError | undefined) => void; + onStatus?: (lifecycleStatus: EventMetadata) => void; + onSuccess?: () => void; }; const FundContext = createContext(undefined); @@ -49,6 +54,9 @@ export function FundCardProvider({ country, subdivision, inputType, + onError, + onStatus, + onSuccess, }: FundCardProviderReact) { const [selectedPaymentMethod, setSelectedPaymentMethod] = useState< PaymentMethodReact | undefined @@ -109,6 +117,9 @@ export function FundCardProvider({ buttonText, country, subdivision, + onError, + onStatus, + onSuccess, }); return {children}; } diff --git a/src/fund/components/FundCardSubmitButton.tsx b/src/fund/components/FundCardSubmitButton.tsx index 364f11f93c..1e6657e91c 100644 --- a/src/fund/components/FundCardSubmitButton.tsx +++ b/src/fund/components/FundCardSubmitButton.tsx @@ -1,5 +1,4 @@ import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; -import { useFundCardSetupOnrampEventListeners } from '../hooks/useFundCardSetupOnrampEventListeners'; import { FundButton } from './FundButton'; import { useFundContext } from './FundCardProvider'; @@ -14,9 +13,6 @@ export function FundCardSubmitButton() { const fundingUrl = useFundCardFundingUrl(); - // Setup event listeners for the onramp - useFundCardSetupOnrampEventListeners(); - return ( ({ - useFundContext: vi.fn(), -})); - -vi.mock('../utils/setupOnrampEventListeners', () => ({ - setupOnrampEventListeners: vi.fn(), -})); - -describe('useFundCardSetupOnrampEventListeners', () => { - const mockSetSubmitButtonState = vi.fn(); - let unsubscribeMock = vi.fn(); - - beforeEach(() => { - unsubscribeMock = vi.fn(); - - (useFundContext as Mock).mockReturnValue({ - setSubmitButtonState: mockSetSubmitButtonState, - }); - - // Mock setupOnrampEventListeners to return unsubscribe - (setupOnrampEventListeners as Mock).mockReturnValue(unsubscribeMock); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should subscribe to events on mount and unsubscribe on unmount', () => { - const { unmount } = renderHook(() => - useFundCardSetupOnrampEventListeners(), - ); - - // Verify setupOnrampEventListeners is called - expect(setupOnrampEventListeners).toHaveBeenCalledWith( - expect.objectContaining({ - onEvent: expect.any(Function), - onExit: expect.any(Function), - onSuccess: expect.any(Function), - }), - ); - - // Verify unsubscribe is called on unmount - unmount(); - expect(unsubscribeMock).toHaveBeenCalled(); - }); - - it('should set button state to "error" and reset after timeout on error event', () => { - vi.useFakeTimers(); // Use fake timers to test timeout behavior - - let onEventCallback = vi.fn(); - (setupOnrampEventListeners as Mock).mockImplementation(({ onEvent }) => { - onEventCallback = onEvent; - return unsubscribeMock; - }); - - renderHook(() => useFundCardSetupOnrampEventListeners()); - - // Simulate error event - onEventCallback({ eventName: 'error' }); - - expect(mockSetSubmitButtonState).toHaveBeenCalledWith('error'); - - // Simulate timeout - vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); - - expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); - - vi.useRealTimers(); - }); - - it('should set button state to "success" and reset after timeout on success event', () => { - vi.useFakeTimers(); - - let onSuccessCallback = vi.fn(); - (setupOnrampEventListeners as Mock).mockImplementation(({ onSuccess }) => { - onSuccessCallback = onSuccess; - return unsubscribeMock; - }); - - renderHook(() => useFundCardSetupOnrampEventListeners()); - - // Simulate success event - onSuccessCallback(); - - expect(mockSetSubmitButtonState).toHaveBeenCalledWith('success'); - - // Simulate timeout - vi.advanceTimersByTime(FUND_BUTTON_RESET_TIMEOUT); - - expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); - - vi.useRealTimers(); - }); - - it('should set button state to "default" and log on exit event', () => { - const consoleSpy = vi.spyOn(console, 'log'); - let onExitCallback = vi.fn(); - - (setupOnrampEventListeners as Mock).mockImplementation(({ onExit }) => { - onExitCallback = onExit; - return unsubscribeMock; - }); - - renderHook(() => useFundCardSetupOnrampEventListeners()); - - // Simulate exit event - const mockEvent = { reason: 'user_cancelled' }; - onExitCallback(mockEvent); - - expect(mockSetSubmitButtonState).toHaveBeenCalledWith('default'); - expect(consoleSpy).toHaveBeenCalledWith('onExit', mockEvent); - - consoleSpy.mockRestore(); - }); -}); diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx new file mode 100644 index 0000000000..9bce6f682b --- /dev/null +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.test.tsx @@ -0,0 +1,150 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; +import { 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 onError when exit event occurs', () => { + let exitHandler: (error?: OnrampError) => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onExit }) => { + exitHandler = onExit; + return () => {}; + }); + + const onError = vi.fn(); + renderHookWithProvider({ onError }); + + exitHandler(mockError); + expect(onError).toHaveBeenCalledWith(mockError); + }); + + 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 }); + + eventHandler(mockEvent); + expect(onStatus).toHaveBeenCalledWith(mockEvent); + }); + + it('calls onSuccess when success event occurs', () => { + let successHandler: () => void = () => {}; + + (setupOnrampEventListeners as Mock).mockImplementation(({ onSuccess }) => { + successHandler = onSuccess; + return () => {}; + }); + + const onSuccess = vi.fn(); + renderHookWithProvider({ onSuccess }); + + 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(); + }); +}); diff --git a/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts index 5df0c2893d..b32cc84a35 100644 --- a/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts +++ b/src/fund/hooks/useFundCardSetupOnrampEventListeners.ts @@ -4,12 +4,14 @@ import { FUND_BUTTON_RESET_TIMEOUT } from '../constants'; import { setupOnrampEventListeners } from '../utils/setupOnrampEventListeners'; export const useFundCardSetupOnrampEventListeners = () => { - const { setSubmitButtonState } = useFundContext(); + const { setSubmitButtonState, onError, onStatus, onSuccess } = + useFundContext(); // biome-ignore lint/correctness/useExhaustiveDependencies: Only want to run this effect once useEffect(() => { const unsubscribe = setupOnrampEventListeners({ onEvent: (event) => { + onStatus?.(event); if (event.eventName === 'error') { setSubmitButtonState('error'); @@ -19,10 +21,12 @@ export const useFundCardSetupOnrampEventListeners = () => { } }, onExit: (event) => { + onError?.(event); setSubmitButtonState('default'); console.log('onExit', event); }, onSuccess: () => { + onSuccess?.(); setSubmitButtonState('success'); setTimeout(() => { diff --git a/src/fund/types.ts b/src/fund/types.ts index cade9e0e98..cff4089ebf 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -316,6 +316,9 @@ export type FundCardPropsReact = { */ paymentMethods?: PaymentMethodReact[]; className?: string; + onError?: (e: OnrampError | undefined) => void; + onStatus?: (lifecycleStatus: EventMetadata) => void; + onSuccess?: () => void; }; export type FundCardContentPropsReact = { children?: ReactNode; @@ -329,7 +332,6 @@ export type FundCardPaymentMethodSelectorTogglePropsReact = { }; export type FundCardPaymentMethodSelectRowPropsReact = { - className?: string; paymentMethod: PaymentMethodReact; onClick?: (paymentMethod: PaymentMethodReact) => void; hideImage?: boolean; @@ -348,4 +350,7 @@ export type FundCardProviderReact = { country: string; subdivision?: string; inputType?: 'fiat' | 'crypto'; + onError?: (e: OnrampError | undefined) => void; + onStatus?: (lifecycleStatus: EventMetadata) => void; + onSuccess?: () => void; }; diff --git a/src/fund/utils/truncateDecimalPlaces.test.ts b/src/fund/utils/truncateDecimalPlaces.test.ts index 95f59a8866..6063bac3a8 100644 --- a/src/fund/utils/truncateDecimalPlaces.test.ts +++ b/src/fund/utils/truncateDecimalPlaces.test.ts @@ -1,20 +1,33 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { truncateDecimalPlaces } from './truncateDecimalPlaces'; describe('truncateDecimalPlaces', () => { - test('should limit to specified decimal places', () => { - expect(truncateDecimalPlaces('123.4567', 2)).toBe('123.45'); + 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'); }); - test('should return original value if decimal places are less than or equal to specified', () => { - expect(truncateDecimalPlaces('123.45', 2)).toBe('123.45'); + 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'); }); - test('should handle values without a decimal point', () => { - expect(truncateDecimalPlaces('123', 2)).toBe('123'); + it('handles edge cases', () => { + expect(truncateDecimalPlaces('', 2)).toBe(''); + expect(truncateDecimalPlaces('.123', 2)).toBe('.12'); + expect(truncateDecimalPlaces(0, 2)).toBe('0'); + expect(truncateDecimalPlaces('.', 2)).toBe('.'); }); - test('should handle negative numbers', () => { - expect(truncateDecimalPlaces('-123.4567', 3)).toBe('-123.456'); + 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 index 379c5dd017..8bb03ab678 100644 --- a/src/fund/utils/truncateDecimalPlaces.ts +++ b/src/fund/utils/truncateDecimalPlaces.ts @@ -1,11 +1,19 @@ /** * Limit the value to N decimal places */ -export const truncateDecimalPlaces = (value: string, decimalPlaces: number) => { - const decimalIndex = value.indexOf('.'); - let resultValue = value; - if (decimalIndex !== -1 && value.length - decimalIndex - 1 > 2) { - resultValue = value.substring(0, decimalIndex + decimalPlaces + 1); +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; From 5a3552e787013125c0160d15cf8ac33da6f8f9b6 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 10:09:42 -0800 Subject: [PATCH 71/91] Default payment methods --- .../FundCardPaymentMethodDropdown.tsx | 2 +- src/fund/constants.ts | 52 ++++++++++++------- src/fund/index.ts | 14 +++-- src/fund/types.ts | 3 +- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/fund/components/FundCardPaymentMethodDropdown.tsx b/src/fund/components/FundCardPaymentMethodDropdown.tsx index 986b4aa776..78a60c6a26 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.tsx @@ -47,7 +47,7 @@ export function FundCardPaymentMethodDropdown({ selectedPaymentMethod && isPaymentMethodDisabled(selectedPaymentMethod) ) { - const coinbaseMethod = paymentMethods.find((m) => m.id === 'FIAT_WALLET'); + const coinbaseMethod = paymentMethods.find((m) => m.id === ''); if (coinbaseMethod) { setSelectedPaymentMethod(coinbaseMethod); } diff --git a/src/fund/constants.ts b/src/fund/constants.ts index d145401def..8565fdb6f4 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -12,23 +12,35 @@ export const ONRAMP_API_BASE_URL = // 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 DEFAULT_PAYMENT_METHODS: PaymentMethodReact[] = [ - { - id: 'FIAT_WALLET', - name: 'Coinbase', - description: 'Buy with your Coinbase account', - icon: 'coinbaseLogo', - }, - { - id: 'APPLE_PAY', - name: 'Apple Pay', - description: 'Up to $500/week', - icon: 'applePay', - }, - { - id: 'ACH_BANK_ACCOUNT', - name: 'Debit Card', - description: 'Up to $500/week', - icon: 'creditCard', - }, -]; +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: 'applePay', +}; + +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/index.ts b/src/fund/index.ts index 921e6b076d..8a795da65d 100644 --- a/src/fund/index.ts +++ b/src/fund/index.ts @@ -1,15 +1,19 @@ export { FundButton } from './components/FundButton'; -export { FundCardProvider } from './components/FundCardProvider'; export { FundCard } from './components/FundCard'; -export { getCoinbaseSmartWalletFundUrl } from './utils/getCoinbaseSmartWalletFundUrl'; -export { getOnrampBuyUrl } from './utils/getOnrampBuyUrl'; -export { setupOnrampEventListeners } from './utils/setupOnrampEventListeners'; -export { fetchOnrampTransactionStatus } from './utils/fetchOnrampTransactionStatus'; +export { FundCardProvider } from './components/FundCardProvider'; 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'; export type { + EventMetadata, + FundCardPropsReact, GetOnrampUrlWithProjectIdParams, GetOnrampUrlWithSessionTokenParams, + OnrampError, + PaymentMethodReact, } from './types'; diff --git a/src/fund/types.ts b/src/fund/types.ts index cff4089ebf..611a95a32d 100644 --- a/src/fund/types.ts +++ b/src/fund/types.ts @@ -286,7 +286,8 @@ export type PaymentAccountReact = | 'FIAT_WALLET' | 'CARD' | 'ACH_BANK_ACCOUNT' - | 'APPLE_PAY'; + | 'APPLE_PAY' + | ''; // Empty string represents Coinbase default payment method export type PaymentMethodReact = { id: PaymentAccountReact; From 394c34ec9ebc3b7cae332ffa4d902aaa1af7e025 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 10:16:04 -0800 Subject: [PATCH 72/91] Rename fundProvider to fundCardProvider --- .../{FundProvider.test.tsx => FundCardProvider.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/fund/components/{FundProvider.test.tsx => FundCardProvider.test.tsx} (100%) diff --git a/src/fund/components/FundProvider.test.tsx b/src/fund/components/FundCardProvider.test.tsx similarity index 100% rename from src/fund/components/FundProvider.test.tsx rename to src/fund/components/FundCardProvider.test.tsx From 4439b28c79195014980836a52c612e7498cad0ca Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 10:44:57 -0800 Subject: [PATCH 73/91] Fix tests --- src/fund/components/FundCardAmountInput.tsx | 2 +- .../FundCardPaymentMethodDropdown.test.tsx | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index e8f4932b9e..69d897e5de 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -71,7 +71,7 @@ export const FundCardAmountInput = ({ ); // Update width when value changes - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to update the input width when the value changes useEffect(() => { updateInputWidth(); }, [value, updateInputWidth]); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx index a3a0a5e77e..e2aaa0b012 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -102,7 +102,7 @@ describe('FundCardPaymentMethodDropdown', () => { // Check Coinbase is not disabled const coinbaseButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectRow__FIAT_WALLET', + 'ockFundCardPaymentMethodSelectRow__', ); expect(coinbaseButton).not.toBeDisabled(); }); @@ -126,7 +126,7 @@ describe('FundCardPaymentMethodDropdown', () => { 'ockFundCardPaymentMethodSelectRow__ACH_BANK_ACCOUNT', ); const coinbaseButton = screen.getByTestId( - 'ockFundCardPaymentMethodSelectRow__FIAT_WALLET', + 'ockFundCardPaymentMethodSelectRow__', ); expect(applePayButton).not.toBeDisabled(); @@ -181,10 +181,12 @@ describe('FundCardPaymentMethodDropdown', () => { // Check descriptions are original expect( - screen.getByText('Buy with your Coinbase account'), + screen.getByText('ACH, cash, crypto and balance'), ).toBeInTheDocument(); - expect(screen.getAllByText('Up to $500/week')).toHaveLength(2); + expect( + screen.getAllByText('Up to $500/week. No sign up required.'), + ).toHaveLength(2); }); it('closes dropdown when clicking outside', () => { @@ -297,7 +299,7 @@ describe('FundCardPaymentMethodDropdown', () => { // Other payment methods should still be there expect( - screen.getByTestId('ockFundCardPaymentMethodSelectRow__FIAT_WALLET'), + screen.getByTestId('ockFundCardPaymentMethodSelectRow__'), ).toBeInTheDocument(); expect( screen.getByTestId('ockFundCardPaymentMethodSelectRow__ACH_BANK_ACCOUNT'), From 226d5cac36983cda639bd6aa59c9d89428ddd5de Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 10:52:10 -0800 Subject: [PATCH 74/91] Fix test --- src/fund/components/FundCardHeader.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index 5cdd5184b6..508f17e0de 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -1,10 +1,15 @@ +import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { FundCardHeader } from './FundCardHeader'; import { FundCardProvider } from './FundCardProvider'; describe('FundCardHeader', () => { + beforeEach(() => { + setOnchainKitConfig({ apiKey: 'mock-api-key' }); + }); + it('renders the provided headerText', () => { render( From 4ceb0ac756c6d3dad46e4902a6065c133738bed5 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 11:05:00 -0800 Subject: [PATCH 75/91] Fix tests --- src/fund/components/FundCard.test.tsx | 6 +++--- src/fund/components/FundCardAmountInput.test.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index 9e2952832d..f3d4ddbde4 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -14,7 +14,7 @@ import { useFundCardFundingUrl } from '../hooks/useFundCardFundingUrl'; import { fetchOnrampQuote } from '../utils/fetchOnrampQuote'; import { getFundingPopupSize } from '../utils/getFundingPopupSize'; import { FundCard } from './FundCard'; -import { useFundContext } from './FundCardProvider'; +import { FundCardProvider, useFundContext } from './FundCardProvider'; const mockUpdateInputWidth = vi.fn(); vi.mock('../hooks/useInputResize', () => ({ @@ -112,10 +112,10 @@ const TestComponent = () => { const renderComponent = () => render( - <> + - , + , ); describe('FundCard', () => { diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 73df2b90a8..5469379d44 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -71,7 +71,7 @@ describe('FundCardAmountInput', () => { it('renders correctly with fiat input type', () => { renderWithProvider(); expect(screen.getByTestId('ockFundCardAmountInput')).toBeInTheDocument(); - expect(screen.getByTestId('currencySpan')).toHaveTextContent('$'); + expect(screen.getByTestId('currencySpan')).toHaveTextContent('USD'); }); it('renders correctly with crypto input type', () => { From 928cfa64c6a20a8da11140e416126b4c43cfd922 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 13:23:49 -0800 Subject: [PATCH 76/91] Update testIds --- src/fund/components/FundCard.test.tsx | 10 ++++++---- src/fund/components/FundCardAmountInput.test.tsx | 6 +++--- src/fund/components/FundCardCurrencyLabel.tsx | 2 +- src/fund/components/FundCardHeader.test.tsx | 6 ++++-- src/fund/components/FundCardHeader.tsx | 5 ++++- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/fund/components/FundCard.test.tsx b/src/fund/components/FundCard.test.tsx index f3d4ddbde4..5613a1b4d3 100644 --- a/src/fund/components/FundCard.test.tsx +++ b/src/fund/components/FundCard.test.tsx @@ -136,13 +136,15 @@ describe('FundCard', () => { it('renders without crashing', () => { renderComponent(); - expect(screen.getByTestId('fundCardHeader')).toBeInTheDocument(); + expect(screen.getByTestId('ockFundCardHeader')).toBeInTheDocument(); expect(screen.getByTestId('ockFundButtonTextContent')).toBeInTheDocument(); }); it('displays the correct header text', () => { renderComponent(); - expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Buy BTC'); + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( + 'Buy BTC', + ); }); it('displays the correct button text', () => { @@ -174,7 +176,7 @@ describe('FundCard', () => { fireEvent.click(switchButton); }); - expect(screen.getByTestId('currencySpan')).toHaveTextContent('BTC'); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('BTC'); }); it('disables the submit button when fund amount is zero and type is fiat', () => { @@ -291,6 +293,6 @@ describe('FundCard', () => { ); expect(screen.getByTestId('custom-child')).toBeInTheDocument(); - expect(screen.queryByTestId('fundCardHeader')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ockFundCardHeader')).not.toBeInTheDocument(); }); }); diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 5469379d44..7755894a3f 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -71,12 +71,12 @@ describe('FundCardAmountInput', () => { it('renders correctly with fiat input type', () => { renderWithProvider(); expect(screen.getByTestId('ockFundCardAmountInput')).toBeInTheDocument(); - expect(screen.getByTestId('currencySpan')).toHaveTextContent('USD'); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('USD'); }); it('renders correctly with crypto input type', () => { renderWithProvider({ inputType: 'crypto' }); - expect(screen.getByTestId('currencySpan')).toHaveTextContent('ETH'); + expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('ETH'); }); it('handles fiat input change', async () => { @@ -280,7 +280,7 @@ describe('FundCardAmountInput', () => { configurable: true, }); - const currencyLabel = screen.getByTestId('currencySpan'); + const currencyLabel = screen.getByTestId('ockCurrencySpan'); Object.defineProperty(currencyLabel, 'getBoundingClientRect', { value: () => ({ width: 20 }), configurable: true, diff --git a/src/fund/components/FundCardCurrencyLabel.tsx b/src/fund/components/FundCardCurrencyLabel.tsx index 871a9b4ac2..7b0fcc7ed1 100644 --- a/src/fund/components/FundCardCurrencyLabel.tsx +++ b/src/fund/components/FundCardCurrencyLabel.tsx @@ -15,7 +15,7 @@ export const FundCardCurrencyLabel = forwardRef< 'flex items-center justify-center bg-transparent', 'text-6xl leading-none outline-none', )} - data-testid="currencySpan" + data-testid="ockCurrencySpan" > {label} diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index 508f17e0de..e779fd8461 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -16,7 +16,7 @@ describe('FundCardHeader', () => { , ); - expect(screen.getByTestId('fundCardHeader')).toHaveTextContent( + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( 'Custom header', ); }); @@ -27,6 +27,8 @@ describe('FundCardHeader', () => { , ); - expect(screen.getByTestId('fundCardHeader')).toHaveTextContent('Buy ETH'); + expect(screen.getByTestId('ockFundCardHeader')).toHaveTextContent( + 'Buy ETH', + ); }); }); diff --git a/src/fund/components/FundCardHeader.tsx b/src/fund/components/FundCardHeader.tsx index 47f3fe6fdf..ceb2f28be2 100644 --- a/src/fund/components/FundCardHeader.tsx +++ b/src/fund/components/FundCardHeader.tsx @@ -6,7 +6,10 @@ export function FundCardHeader({ className }: FundCardHeaderPropsReact) { const { headerText } = useFundContext(); return ( -
+
{headerText}
); From 9d473688bc08fb358d6580d598272974937fdc8a Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 15:38:03 -0800 Subject: [PATCH 77/91] Address comments --- src/fund/components/FundCardAmountInput.tsx | 3 ++- src/internal/svg/addSvg.tsx | 1 + src/internal/svg/applePaySvg.tsx | 1 + src/internal/svg/creditCardSvg.tsx | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index 69d897e5de..aba4aca982 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -10,8 +10,9 @@ import { useFundContext } from './FundCardProvider'; export const FundCardAmountInput = ({ className, }: FundCardAmountInputPropsReact) => { - // TODO: Get currency label from country + // TODO: Get currency label from country (This is coming in the follow up PRs) const currencyLabel = 'USD'; + const { fundAmountFiat, setFundAmountFiat, diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx index c675f36487..6e3651b585 100644 --- a/src/internal/svg/addSvg.tsx +++ b/src/internal/svg/addSvg.tsx @@ -11,6 +11,7 @@ export const AddSvg = ({ className = cn(icon.inverse) }) => ( fill="none" xmlns="http://www.w3.org/2000/svg" > + Add SVG + Apple Pay + Credit Card SVG Date: Mon, 13 Jan 2025 19:09:55 -0800 Subject: [PATCH 78/91] Update --- src/buy/components/BuyOnrampItem.tsx | 4 +-- .../internal/hooks/useIcon.test.tsx | 6 ++-- src/core-react/internal/hooks/useIcon.tsx | 5 ++- .../FundCardPaymentMethodSelectRow.test.tsx | 2 +- src/fund/constants.ts | 2 +- src/internal/svg/applePaySvg.tsx | 33 +++++++++++-------- src/internal/svg/appleSvg.tsx | 33 ++++++++----------- 7 files changed, 44 insertions(+), 41 deletions(-) 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 bf29f83bd9..05fffd9859 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,6 +1,6 @@ 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'; @@ -25,9 +25,9 @@ describe('useIcon', () => { expect(result.current).toBe(toggleSvg); }); - it('should return applePaySvg when icon is "applePay"', () => { + it('should return appleSvg when icon is "applePay"', () => { const { result } = renderHook(() => useIcon({ icon: 'applePay' })); - expect(result.current).toBe(applePaySvg); + expect(result.current).toBe(appleSvg); }); it('should return creditCardSvg when icon is "creditCard"', () => { diff --git a/src/core-react/internal/hooks/useIcon.tsx b/src/core-react/internal/hooks/useIcon.tsx index 44c5f9a38b..e76136f79d 100644 --- a/src/core-react/internal/hooks/useIcon.tsx +++ b/src/core-react/internal/hooks/useIcon.tsx @@ -1,5 +1,6 @@ +import { applePaySvg } from '@/internal/svg/applePaySvg'; import { isValidElement, useMemo } from 'react'; -import { applePaySvg } from '../../../internal/svg/applePaySvg'; +import { appleSvg } from '../../../internal/svg/appleSvg'; import { coinbaseLogoSvg } from '../../../internal/svg/coinbaseLogoSvg'; import { coinbasePaySvg } from '../../../internal/svg/coinbasePaySvg'; import { creditCardSvg } from '../../../internal/svg/creditCardSvg'; @@ -28,6 +29,8 @@ export const useIcon = ({ icon }: { icon?: React.ReactNode }) => { return toggleSvg; case 'applePay': return applePaySvg; + case 'apple': + return appleSvg; case 'creditCard': return creditCardSvg; } diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx index 1aaa86bca7..3ff3944241 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.test.tsx @@ -9,7 +9,7 @@ describe('FundCardPaymentMethodSelectRow', () => { id: 'APPLE_PAY', name: 'Apple Pay', description: 'Up to $500/week', - icon: 'applePay', + icon: 'apple', }; it('renders disabled state correctly', () => { diff --git a/src/fund/constants.ts b/src/fund/constants.ts index 8565fdb6f4..be1555c8f9 100644 --- a/src/fund/constants.ts +++ b/src/fund/constants.ts @@ -30,7 +30,7 @@ export const APPLE_PAY: PaymentMethodReact = { id: 'APPLE_PAY', name: 'Apple Pay', description: 'Up to $500/week. No sign up required.', - icon: 'applePay', + icon: 'apple', }; export const ALL_PAYMENT_METHODS = [COINBASE, DEBIT_CARD, APPLE_PAY]; diff --git a/src/internal/svg/applePaySvg.tsx b/src/internal/svg/applePaySvg.tsx index beb0c4d9af..01d8ffaa1c 100644 --- a/src/internal/svg/applePaySvg.tsx +++ b/src/internal/svg/applePaySvg.tsx @@ -1,24 +1,29 @@ -import { icon } from '../../styles/theme'; - export const applePaySvg = ( - Apple Pay + Apple Pay Onramp + + + + + + + ); diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index c67a88fdf2..8369589de5 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 SVG - - - - - - - ); From 1852142d8d9ccd03b2c50bd5d9b52a84edc4e8bf Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 19:17:03 -0800 Subject: [PATCH 79/91] Update --- src/buy/components/BuyOnrampItem.test.tsx | 2 +- src/core-react/internal/hooks/useIcon.test.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/buy/components/BuyOnrampItem.test.tsx b/src/buy/components/BuyOnrampItem.test.tsx index 3b72fb3aad..35013645d2 100644 --- a/src/buy/components/BuyOnrampItem.test.tsx +++ b/src/buy/components/BuyOnrampItem.test.tsx @@ -38,7 +38,7 @@ describe('BuyOnrampItem', () => { expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByText('Apple Pay')).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/core-react/internal/hooks/useIcon.test.tsx b/src/core-react/internal/hooks/useIcon.test.tsx index 05fffd9859..50459a8710 100644 --- a/src/core-react/internal/hooks/useIcon.test.tsx +++ b/src/core-react/internal/hooks/useIcon.test.tsx @@ -1,5 +1,6 @@ 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'; @@ -25,11 +26,16 @@ describe('useIcon', () => { expect(result.current).toBe(toggleSvg); }); - it('should return appleSvg when icon is "applePay"', () => { - const { result } = renderHook(() => useIcon({ icon: 'applePay' })); + 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); From e47fac5624b28e4c17ca1282ce036a1684d6fd29 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Mon, 13 Jan 2025 22:20:26 -0800 Subject: [PATCH 80/91] Address more comments --- src/fund/components/FundButton.tsx | 15 ++++++--------- src/internal/svg/addSvg.tsx | 2 +- src/internal/svg/applePaySvg.tsx | 2 +- src/internal/svg/appleSvg.tsx | 2 +- src/internal/svg/creditCardSvg.tsx | 2 +- src/internal/svg/errorSvg.tsx | 2 +- src/styles/theme.ts | 3 +-- src/swap/components/SwapAmountInput.tsx | 2 +- 8 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/fund/components/FundButton.tsx b/src/fund/components/FundButton.tsx index 61c20f30b6..1f08c6c122 100644 --- a/src/fund/components/FundButton.tsx +++ b/src/fund/components/FundButton.tsx @@ -73,22 +73,19 @@ export function FundButton({ ); const buttonColorClass = useMemo(() => { - switch (buttonState) { - case 'error': - return background.error; - case 'loading': - case 'success': - return pressable.primary; - default: - return pressable.primary; + if (buttonState === 'error') { + return background.error; } + return pressable.primary; }, [buttonState]); const classNames = cn( componentTheme, buttonColorClass, 'px-4 py-3 inline-flex items-center justify-center space-x-2', - isDisabled && pressable.disabled, + { + [pressable.disabled]: isDisabled, + }, text.headline, border.radius, color.inverse, diff --git a/src/internal/svg/addSvg.tsx b/src/internal/svg/addSvg.tsx index 6e3651b585..a399b497cc 100644 --- a/src/internal/svg/addSvg.tsx +++ b/src/internal/svg/addSvg.tsx @@ -11,7 +11,7 @@ export const AddSvg = ({ className = cn(icon.inverse) }) => ( fill="none" xmlns="http://www.w3.org/2000/svg" > - Add SVG + Add - Apple Pay Onramp + Apple Pay - Apple SVG + Apple - Credit Card SVG + Credit Card ( xmlns="http://www.w3.org/2000/svg" data-testid="ock-errorSvg" > - Error SVG + Error Date: Mon, 13 Jan 2025 22:46:52 -0800 Subject: [PATCH 81/91] Fix tests --- src/buy/components/BuyOnrampItem.test.tsx | 2 +- src/fund/components/FundCardPaymentMethodDropdown.test.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/buy/components/BuyOnrampItem.test.tsx b/src/buy/components/BuyOnrampItem.test.tsx index 35013645d2..41eb0ed232 100644 --- a/src/buy/components/BuyOnrampItem.test.tsx +++ b/src/buy/components/BuyOnrampItem.test.tsx @@ -36,7 +36,7 @@ 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('ock-applePaySvg')).toBeInTheDocument(); }); diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx index e2aaa0b012..55e66d5e0f 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -146,7 +146,9 @@ describe('FundCardPaymentMethodDropdown', () => { 'ockFundCardPaymentMethodSelectorToggle__paymentMethodName', ), ); - fireEvent.click(screen.getByText('Apple Pay')); + fireEvent.click( + screen.getByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'), + ); // Verify Apple Pay is selected expect( From f4269a823123c17ec483103cb72d786ae2c2b915 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 14 Jan 2025 15:08:29 -0800 Subject: [PATCH 82/91] Fix tests --- src/fund/components/FundCardHeader.test.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/fund/components/FundCardHeader.test.tsx b/src/fund/components/FundCardHeader.test.tsx index e779fd8461..5d160f7b62 100644 --- a/src/fund/components/FundCardHeader.test.tsx +++ b/src/fund/components/FundCardHeader.test.tsx @@ -1,13 +1,26 @@ import { setOnchainKitConfig } from '@/core/OnchainKitConfig'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; +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', () => { From e152292f0182748f6d6ef3d30bc51e007333ff77 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 14 Jan 2025 15:42:07 -0800 Subject: [PATCH 83/91] Update width of amount type swith --- src/fund/components/FundCardAmountInputTypeSwitch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fund/components/FundCardAmountInputTypeSwitch.tsx b/src/fund/components/FundCardAmountInputTypeSwitch.tsx index 237b965cf9..ddba734376 100644 --- a/src/fund/components/FundCardAmountInputTypeSwitch.tsx +++ b/src/fund/components/FundCardAmountInputTypeSwitch.tsx @@ -88,7 +88,7 @@ export const FundCardAmountInputTypeSwitch = ({ >
{iconSvg}
-
+
{amountLine} {exchangeRateLine}
From 84a281b0af4f6d9565b0769ee6d929d30dd2c6c1 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Tue, 14 Jan 2025 15:50:36 -0800 Subject: [PATCH 84/91] Fix flaky tests --- .../FundCardPaymentMethodDropdown.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx index 55e66d5e0f..9f1ddc4744 100644 --- a/src/fund/components/FundCardPaymentMethodDropdown.test.tsx +++ b/src/fund/components/FundCardPaymentMethodDropdown.test.tsx @@ -3,9 +3,21 @@ 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) => { @@ -58,6 +70,7 @@ describe('FundCardPaymentMethodDropdown', () => { 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 }) => { From 871cf5d29dae93e7065d45070eb696221bd37cd5 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 15 Jan 2025 09:25:44 -0800 Subject: [PATCH 85/91] Add lifecycle hooks to demo --- .../components/demo/FundCard.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx index 2ef168f5c0..3787dd020b 100644 --- a/playground/nextjs-app-router/components/demo/FundCard.tsx +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -1,9 +1,20 @@ import { FundCard } from '@coinbase/onchainkit/fund'; - export default function FundCardDemo() { return (
- + { + console.log('error', error); + }} + onStatus={(status) => { + console.log('status', status); + }} + onSuccess={() => { + console.log('success'); + }} + />
); } From 9a842d1b1c57707eb36d7a08c0d3d8fb422fd969 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 15 Jan 2025 14:42:13 -0800 Subject: [PATCH 86/91] Address comments --- .../FundCardPaymentMethodImage.test.tsx | 8 ++-- .../components/FundCardPaymentMethodImage.tsx | 2 +- .../components/FundCardSubmitButton.test.tsx | 39 +++++++++++++++++-- src/fund/components/FundCardSubmitButton.tsx | 5 ++- .../utils/formatDecimalInputValue.test.ts | 4 -- src/fund/utils/formatDecimalInputValue.ts | 5 --- 6 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/fund/components/FundCardPaymentMethodImage.test.tsx b/src/fund/components/FundCardPaymentMethodImage.test.tsx index 4bb375f4cc..ede4039df0 100644 --- a/src/fund/components/FundCardPaymentMethodImage.test.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.test.tsx @@ -22,7 +22,7 @@ describe('FundCardPaymentMethodImage', () => { />, ); expect( - screen.queryByTestId('fundCardPaymentMethodImage__iconContainer'), + screen.queryByTestId('ockFundCardPaymentMethodImage__iconContainer'), ).toBeInTheDocument(); }); @@ -40,7 +40,7 @@ describe('FundCardPaymentMethodImage', () => { />, ); expect( - screen.getByTestId('fundCardPaymentMethodImage__iconContainer'), + screen.getByTestId('ockFundCardPaymentMethodImage__iconContainer'), ).toBeInTheDocument(); }); @@ -59,7 +59,7 @@ describe('FundCardPaymentMethodImage', () => { />, ); const container = screen.getByTestId( - 'fundCardPaymentMethodImage__iconContainer', + 'ockFundCardPaymentMethodImage__iconContainer', ); expect(container).toHaveClass('custom-class'); }); @@ -77,7 +77,7 @@ describe('FundCardPaymentMethodImage', () => { />, ); const container = screen.getByTestId( - 'fundCardPaymentMethodImage__iconContainer', + 'ockFundCardPaymentMethodImage__iconContainer', ); expect(container).not.toHaveClass('primary'); }); diff --git a/src/fund/components/FundCardPaymentMethodImage.tsx b/src/fund/components/FundCardPaymentMethodImage.tsx index 703b601105..a387ce3a37 100644 --- a/src/fund/components/FundCardPaymentMethodImage.tsx +++ b/src/fund/components/FundCardPaymentMethodImage.tsx @@ -12,7 +12,7 @@ export function FundCardPaymentMethodImage({ return (
({ vi.mock('@/ui-react/internal/utils/openPopup', () => ({ openPopup: vi.fn(), })); -///// + vi.mock('../hooks/useFundCardFundingUrl', () => ({ useFundCardFundingUrl: vi.fn(), })); @@ -82,6 +82,7 @@ const TestHelperComponent = () => { exchangeRate, exchangeRateLoading, setFundAmountFiat, + setFundAmountCrypto, } = useFundContext(); return ( @@ -97,10 +98,20 @@ const TestHelperComponent = () => { data-testid="set-fiat-amount" onClick={() => setFundAmountFiat('100')} /> +
); @@ -137,8 +148,11 @@ describe('FundCardSubmitButton', () => { it('enables when fiat amount is set', async () => { renderComponent(); - const button = screen.getByTestId('set-fiat-amount'); - fireEvent.click(button); + 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(); @@ -156,12 +170,26 @@ describe('FundCardSubmitButton', () => { }); }); + 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); @@ -189,6 +217,9 @@ describe('FundCardSubmitButton', () => { 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); diff --git a/src/fund/components/FundCardSubmitButton.tsx b/src/fund/components/FundCardSubmitButton.tsx index 1e6657e91c..19200d30d8 100644 --- a/src/fund/components/FundCardSubmitButton.tsx +++ b/src/fund/components/FundCardSubmitButton.tsx @@ -15,7 +15,10 @@ export function FundCardSubmitButton() { return ( { - it('adds a leading zero if the value starts with a dot', () => { - expect(formatDecimalInputValue('.1')).toBe('0.1'); - }); - it('adds a decimal point if the value starts with zero and is not decimal', () => { expect(formatDecimalInputValue('01')).toBe('0.1'); }); diff --git a/src/fund/utils/formatDecimalInputValue.ts b/src/fund/utils/formatDecimalInputValue.ts index b32fa1c9d3..fc7b79b7ab 100644 --- a/src/fund/utils/formatDecimalInputValue.ts +++ b/src/fund/utils/formatDecimalInputValue.ts @@ -3,11 +3,6 @@ */ export const formatDecimalInputValue = (value: string) => { let resultValue = value; - // Add a leading zero if the value starts with a dot. (i.e. ".1" -> "0.1") - if (resultValue[0] === '.') { - resultValue = `0${resultValue}`; - } - // Add a leading zero if the value starts with a zero and is not a decimal. (i.e. "01" -> "0.1") if ( resultValue.length === 2 && From 3383b143b8a79ab281ed0f93788351750cb84a09 Mon Sep 17 00:00:00 2001 From: Rustam Goygov Date: Wed, 15 Jan 2025 23:46:17 -0800 Subject: [PATCH 87/91] Address comments --- .../components/demo/FundCard.tsx | 6 +- .../components/FundCardAmountInput.test.tsx | 29 ++-- src/fund/components/FundCardAmountInput.tsx | 71 +++++---- .../FundCardAmountInputTypeSwitch.tsx | 4 +- .../FundCardPaymentMethodSelectRow.tsx | 9 +- src/fund/components/FundCardProvider.tsx | 21 ++- src/fund/components/FundCardSubmitButton.tsx | 28 +++- src/fund/hooks/useEmitLifecycleStatus.test.ts | 133 +++++++++++++++++ src/fund/hooks/useEmitLifecycleStatus.ts | 40 +++++ ...FundCardSetupOnrampEventListeners.test.tsx | 126 ++++++++++++++-- .../useFundCardSetupOnrampEventListeners.ts | 77 ++++++---- src/fund/hooks/useLifecycleStatus.test.ts | 137 ++++++++++++++++++ src/fund/hooks/useLifecycleStatus.ts | 38 +++++ src/fund/types.ts | 81 ++++++++++- .../utils/formatDecimalInputValue.test.ts | 21 --- src/fund/utils/formatDecimalInputValue.ts | 16 -- src/fund/utils/setupOnrampEventListeners.ts | 6 +- src/internal/components/TextInput.tsx | 106 ++++++++------ 18 files changed, 753 insertions(+), 196 deletions(-) create mode 100644 src/fund/hooks/useEmitLifecycleStatus.test.ts create mode 100644 src/fund/hooks/useEmitLifecycleStatus.ts create mode 100644 src/fund/hooks/useLifecycleStatus.test.ts create mode 100644 src/fund/hooks/useLifecycleStatus.ts delete mode 100644 src/fund/utils/formatDecimalInputValue.test.ts delete mode 100644 src/fund/utils/formatDecimalInputValue.ts diff --git a/playground/nextjs-app-router/components/demo/FundCard.tsx b/playground/nextjs-app-router/components/demo/FundCard.tsx index 3787dd020b..261379f570 100644 --- a/playground/nextjs-app-router/components/demo/FundCard.tsx +++ b/playground/nextjs-app-router/components/demo/FundCard.tsx @@ -6,13 +6,13 @@ export default function FundCardDemo() { assetSymbol="ETH" country="US" onError={(error) => { - console.log('error', error); + console.log('FundCard onError', error); }} onStatus={(status) => { - console.log('status', status); + console.log('FundCard onStatus', status); }} onSuccess={() => { - console.log('success'); + console.log('FundCard onSuccess'); }} />
diff --git a/src/fund/components/FundCardAmountInput.test.tsx b/src/fund/components/FundCardAmountInput.test.tsx index 7755894a3f..83d61e9e4c 100644 --- a/src/fund/components/FundCardAmountInput.test.tsx +++ b/src/fund/components/FundCardAmountInput.test.tsx @@ -70,7 +70,7 @@ describe('FundCardAmountInput', () => { it('renders correctly with fiat input type', () => { renderWithProvider(); - expect(screen.getByTestId('ockFundCardAmountInput')).toBeInTheDocument(); + expect(screen.getByTestId('ockTextInput_Input')).toBeInTheDocument(); expect(screen.getByTestId('ockCurrencySpan')).toHaveTextContent('USD'); }); @@ -85,7 +85,7 @@ describe('FundCardAmountInput', () => { }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); fireEvent.change(input, { target: { value: '10' } }); const valueFiat = screen.getByTestId('test-value-fiat'); @@ -100,7 +100,7 @@ describe('FundCardAmountInput', () => { renderWithProvider({ inputType: 'crypto' }); }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); fireEvent.change(input, { target: { value: '1' } }); @@ -109,18 +109,18 @@ describe('FundCardAmountInput', () => { }); }); - it('formats input value correctly when starting with a dot', async () => { + it('does not allow non-numeric input', async () => { act(() => { renderWithProvider(); }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); - fireEvent.change(input, { target: { value: '.5' } }); + fireEvent.change(input, { target: { value: 'ABC' } }); const valueFiat = screen.getByTestId('test-value-fiat'); - expect(valueFiat.textContent).toBe('0.5'); + expect(valueFiat.textContent).toBe(''); }); }); @@ -143,7 +143,7 @@ describe('FundCardAmountInput', () => { }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); // Test decimal truncation fireEvent.change(input, { target: { value: '0.123456789' } }); @@ -159,7 +159,7 @@ describe('FundCardAmountInput', () => { }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + 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'); @@ -181,7 +181,7 @@ describe('FundCardAmountInput', () => { 'not-loading', ); - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); const valueFiat = screen.getByTestId('test-value-fiat'); const valueCrypto = screen.getByTestId('test-value-crypto'); @@ -207,7 +207,7 @@ describe('FundCardAmountInput', () => { ); }); - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); fireEvent.change(input, { target: { value: '0' } }); @@ -237,7 +237,7 @@ describe('FundCardAmountInput', () => { expect(screen.getByTestId('loading-state').textContent).toBe( 'not-loading', ); - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); fireEvent.change(input, { target: { value: '400' } }); @@ -271,7 +271,7 @@ describe('FundCardAmountInput', () => { , ); - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); const container = screen.getByTestId('ockFundCardAmountInputContainer'); // Mock getBoundingClientRect for container and currency label @@ -286,7 +286,6 @@ describe('FundCardAmountInput', () => { configurable: true, }); - //const input = screen.getByTestId('ockFundCardAmountInput'); // Trigger width update act(() => { fireEvent.change(input, { target: { value: '10' } }); @@ -324,7 +323,7 @@ describe('FundCardAmountInput', () => { }); await waitFor(() => { - const input = screen.getByTestId('ockFundCardAmountInput'); + const input = screen.getByTestId('ockTextInput_Input'); const valueFiat = screen.getByTestId('test-value-fiat'); const valueCrypto = screen.getByTestId('test-value-crypto'); diff --git a/src/fund/components/FundCardAmountInput.tsx b/src/fund/components/FundCardAmountInput.tsx index aba4aca982..b6d318c6f3 100644 --- a/src/fund/components/FundCardAmountInput.tsx +++ b/src/fund/components/FundCardAmountInput.tsx @@ -1,8 +1,9 @@ -import { type ChangeEvent, useCallback, useEffect, useRef } from 'react'; +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 { formatDecimalInputValue } from '../utils/formatDecimalInputValue'; import { truncateDecimalPlaces } from '../utils/truncateDecimalPlaces'; import { FundCardCurrencyLabel } from './FundCardCurrencyLabel'; import { useFundContext } from './FundCardProvider'; @@ -38,37 +39,46 @@ export const FundCardAmountInput = ({ currencySpanRef, ); - const handleChange = useCallback( - (e: ChangeEvent) => { - const value = formatDecimalInputValue(e.target.value); + 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], + ); - if (selectedInputType === 'fiat') { - const fiatValue = truncateDecimalPlaces(value, 2); - setFundAmountFiat(fiatValue); - - const calculatedCryptoValue = String( - Number(fiatValue) * Number(exchangeRate), - ); - const resultCryptoValue = truncateDecimalPlaces( - calculatedCryptoValue, - 8, - ); - setFundAmountCrypto( - calculatedCryptoValue === '0' ? '' : resultCryptoValue, - ); - } else { - const truncatedValue = truncateDecimalPlaces(value, 8); - setFundAmountCrypto(truncatedValue); + const handleCryptoChange = useCallback( + (value: string) => { + const truncatedValue = truncateDecimalPlaces(value, 8); + setFundAmountCrypto(truncatedValue); - const calculatedFiatValue = String( - Number(truncatedValue) / Number(exchangeRate), - ); + const calculatedFiatValue = String( + Number(truncatedValue) / Number(exchangeRate), + ); - const resultFiatValue = truncateDecimalPlaces(calculatedFiatValue, 2); - setFundAmountFiat(resultFiatValue === '0' ? '' : resultFiatValue); + 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); } }, - [exchangeRate, setFundAmountFiat, setFundAmountCrypto, selectedInputType], + [handleFiatChange, handleCryptoChange, selectedInputType], ); // Update width when value changes @@ -98,7 +108,7 @@ export const FundCardAmountInput = ({ onKeyUp={handleFocusInput} >
- { + const handleToggle = useCallback(() => { setSelectedInputType(selectedInputType === 'fiat' ? 'crypto' : 'fiat'); - }; + }, [selectedInputType, setSelectedInputType]); const formatUSD = useCallback((amount: string) => { const roundedAmount = Number(getRoundedAmount(amount || '0', 2)); diff --git a/src/fund/components/FundCardPaymentMethodSelectRow.tsx b/src/fund/components/FundCardPaymentMethodSelectRow.tsx index 1dfcba6314..295a48287d 100644 --- a/src/fund/components/FundCardPaymentMethodSelectRow.tsx +++ b/src/fund/components/FundCardPaymentMethodSelectRow.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useCallback } from 'react'; import { background, border, @@ -20,6 +20,11 @@ export const FundCardPaymentMethodSelectRow = memo( disabledReason, testId, }: FundCardPaymentMethodSelectRowPropsReact) => { + const handleOnClick = useCallback( + () => !disabled && onClick?.(paymentMethod), + [disabled, onClick, paymentMethod], + ); + return (