Skip to content

Commit 498a271

Browse files
committed
Add error handling
1 parent 9f62504 commit 498a271

8 files changed

+207
-21
lines changed

playground/nextjs-app-router/components/demo/FundCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default function FundCardDemo() {
99
<FundCard
1010
assetSymbol="ETH"
1111
country="US"
12-
currency="GBP"
12+
currency="USD"
1313
presetAmountInputs={presetAmountInputs}
1414
onError={(error) => {
1515
console.log('FundCard onError', error);

src/fund/components/FundCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function FundCard({
1717
headerText,
1818
country = 'US',
1919
subdivision,
20-
currency,
20+
currency = 'USD',
2121
presetAmountInputs,
2222
children = <DefaultFundCardContent />,
2323
className,

src/fund/components/FundCardPaymentMethodDropdown.test.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -426,4 +426,14 @@ describe('FundCardPaymentMethodDropdown', () => {
426426
screen.getByTestId('ockFundCardPaymentMethodSelectRow__APPLE_PAY'),
427427
).toBeInTheDocument();
428428
});
429+
430+
it('shows loading state when there are no payment methods', async () => {
431+
(fetchOnrampOptions as Mock).mockResolvedValue({
432+
paymentCurrencies: [],
433+
purchaseCurrencies: [],
434+
});
435+
renderWithProvider({ amount: '5' });
436+
437+
expect(screen.getByTestId('ockSkeleton')).toBeInTheDocument();
438+
});
429439
});

src/fund/components/FundCardPaymentMethodDropdown.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -119,21 +119,23 @@ export function FundCardPaymentMethodDropdown({
119119
[],
120120
);
121121

122+
const paymentMethod = selectedPaymentMethod || filteredPaymentMethods[0];
123+
122124
return (
123125
<div
124126
className={cn('relative py-4', className)}
125127
ref={dropdownContainerRef}
126128
data-testid="ockFundCardPaymentMethodDropdownContainer"
127129
onKeyUp={handleEscKeyPress}
128130
>
129-
{isPaymentMethodsLoading ? (
131+
{isPaymentMethodsLoading || !paymentMethod ? (
130132
<Skeleton className="h-12 w-full" />
131133
) : (
132134
<FundCardPaymentMethodSelectorToggle
133135
ref={buttonRef}
134136
onClick={handleToggle}
135137
isOpen={isOpen}
136-
paymentMethod={selectedPaymentMethod || filteredPaymentMethods[0]}
138+
paymentMethod={paymentMethod}
137139
/>
138140
)}
139141
{isOpen && (

src/fund/components/FundCardProvider.test.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,24 @@ describe('FundCardProvider', () => {
8181
'useFundContext must be used within a FundCardProvider',
8282
);
8383
});
84+
85+
it('handles exchange rate fetch error', async () => {
86+
const mockError = new Error('Failed to fetch exchange rate');
87+
const mockOnError = vi.fn();
88+
global.fetch = vi.fn(() => Promise.reject(mockError)) as Mock;
89+
90+
render(
91+
<FundCardProvider asset="ETH" country="US" onError={mockOnError}>
92+
<TestComponent />
93+
</FundCardProvider>,
94+
);
95+
96+
await waitFor(() => {
97+
expect(mockOnError).toHaveBeenCalledWith({
98+
errorType: 'handled_error',
99+
code: 'EXCHANGE_RATE_ERROR',
100+
debugMessage: 'Failed to fetch exchange rate',
101+
});
102+
});
103+
});
84104
});

src/fund/components/FundCardProvider.tsx

+28-15
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,33 @@ export function FundCardProvider({
9595
const fetchExchangeRate = useCallback(async () => {
9696
setExchangeRateLoading(true);
9797

98-
const quote = await fetchOnrampQuote({
99-
purchaseCurrency: asset,
100-
paymentCurrency: currency,
101-
paymentAmount: '100',
102-
paymentMethod: 'CARD',
103-
country,
104-
subdivision,
105-
});
106-
107-
setExchangeRateLoading(false);
108-
109-
setExchangeRate(
110-
Number(quote.purchaseAmount.value) / Number(quote.paymentSubtotal.value),
111-
);
112-
}, [asset, country, subdivision, currency]);
98+
try {
99+
const quote = await fetchOnrampQuote({
100+
purchaseCurrency: asset,
101+
paymentCurrency: currency,
102+
paymentAmount: '100',
103+
paymentMethod: 'CARD',
104+
country,
105+
subdivision,
106+
});
107+
108+
setExchangeRate(
109+
Number(quote.purchaseAmount.value) /
110+
Number(quote.paymentSubtotal.value),
111+
);
112+
} catch (err) {
113+
if (err instanceof Error) {
114+
console.error('Error fetching exchange rate:', err);
115+
onError?.({
116+
errorType: 'handled_error',
117+
code: 'EXCHANGE_RATE_ERROR',
118+
debugMessage: err.message,
119+
});
120+
}
121+
} finally {
122+
setExchangeRateLoading(false);
123+
}
124+
}, [asset, country, subdivision, currency, onError]);
113125

114126
// biome-ignore lint/correctness/useExhaustiveDependencies: One time effect
115127
useEffect(() => {
@@ -123,6 +135,7 @@ export function FundCardProvider({
123135
currency,
124136
setPaymentMethods,
125137
setIsPaymentMethodsLoading,
138+
onError,
126139
});
127140

128141
const value = useValue<FundCardContextType>({
+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { optionsResponseDataMock } from '../mocks';
4+
import type { OnrampError } from '../types';
5+
import { fetchOnrampOptions } from '../utils/fetchOnrampOptions';
6+
import { usePaymentMethods } from './usePaymentMethods';
7+
8+
vi.mock('../utils/fetchOnrampOptions');
9+
10+
describe('usePaymentMethods', () => {
11+
const mockSetPaymentMethods = vi.fn();
12+
const mockSetIsPaymentMethodsLoading = vi.fn();
13+
const mockOnError = vi.fn();
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
(fetchOnrampOptions as Mock).mockResolvedValue(optionsResponseDataMock);
18+
});
19+
20+
it('fetches and sets payment methods on mount', async () => {
21+
renderHook(() =>
22+
usePaymentMethods({
23+
country: 'US',
24+
currency: 'USD',
25+
setPaymentMethods: mockSetPaymentMethods,
26+
setIsPaymentMethodsLoading: mockSetIsPaymentMethodsLoading,
27+
}),
28+
);
29+
30+
// Should start loading
31+
expect(mockSetIsPaymentMethodsLoading).toHaveBeenCalledWith(true);
32+
33+
// Wait for async operations
34+
await vi.waitFor(() => {
35+
expect(mockSetPaymentMethods).toHaveBeenCalled();
36+
expect(mockSetIsPaymentMethodsLoading).toHaveBeenCalledWith(false);
37+
});
38+
39+
// Verify payment methods were set
40+
const paymentMethods = mockSetPaymentMethods.mock.calls[0][0];
41+
expect(paymentMethods).toHaveLength(3); // Coinbase, Apple Pay, Card
42+
expect(paymentMethods[0].name).toBe('Coinbase');
43+
});
44+
45+
it('handles API errors', async () => {
46+
const error = new Error('API Error');
47+
(fetchOnrampOptions as Mock).mockRejectedValue(error);
48+
49+
renderHook(() =>
50+
usePaymentMethods({
51+
country: 'US',
52+
currency: 'USD',
53+
setPaymentMethods: mockSetPaymentMethods,
54+
setIsPaymentMethodsLoading: mockSetIsPaymentMethodsLoading,
55+
onError: mockOnError,
56+
}),
57+
);
58+
59+
await vi.waitFor(() => {
60+
// Should call onError with correct error object
61+
expect(mockOnError).toHaveBeenCalledWith({
62+
errorType: 'handled_error',
63+
code: 'PAYMENT_METHODS_ERROR',
64+
debugMessage: 'API Error',
65+
} satisfies OnrampError);
66+
67+
// Should finish loading
68+
expect(mockSetIsPaymentMethodsLoading).toHaveBeenCalledWith(false);
69+
});
70+
});
71+
72+
it('handles empty payment methods', async () => {
73+
// Mock API to return empty payment methods
74+
(fetchOnrampOptions as Mock).mockResolvedValue({
75+
paymentCurrencies: [],
76+
purchaseCurrencies: [],
77+
});
78+
79+
renderHook(() =>
80+
usePaymentMethods({
81+
country: 'US',
82+
currency: 'USD',
83+
setPaymentMethods: mockSetPaymentMethods,
84+
setIsPaymentMethodsLoading: mockSetIsPaymentMethodsLoading,
85+
onError: mockOnError,
86+
}),
87+
);
88+
89+
await vi.waitFor(() => {
90+
// Should call onError with NO_PAYMENT_METHODS code
91+
expect(mockOnError).toHaveBeenCalledWith({
92+
errorType: 'handled_error',
93+
code: 'NO_PAYMENT_METHODS',
94+
debugMessage:
95+
'No payment methods found for the selected country and currency. See docs for more information: https://docs.cdp.coinbase.com/onramp/docs/api-configurations',
96+
} satisfies OnrampError);
97+
98+
// Should set empty payment methods array
99+
expect(mockSetPaymentMethods).toHaveBeenCalledWith([]);
100+
});
101+
});
102+
103+
it('includes subdivision in API call when provided', async () => {
104+
renderHook(() =>
105+
usePaymentMethods({
106+
country: 'US',
107+
subdivision: 'CA',
108+
currency: 'USD',
109+
setPaymentMethods: mockSetPaymentMethods,
110+
setIsPaymentMethodsLoading: mockSetIsPaymentMethodsLoading,
111+
}),
112+
);
113+
114+
expect(fetchOnrampOptions).toHaveBeenCalledWith({
115+
country: 'US',
116+
subdivision: 'CA',
117+
});
118+
});
119+
});

src/fund/hooks/usePaymentMethods.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect } from 'react';
2-
import type { PaymentMethod } from '../types';
2+
import type { OnrampError, PaymentMethod } from '../types';
33
import { buildPaymentMethods } from '../utils/buildPaymentMethods';
44
import { fetchOnrampOptions } from '../utils/fetchOnrampOptions';
55

@@ -9,12 +9,14 @@ export const usePaymentMethods = ({
99
currency,
1010
setPaymentMethods,
1111
setIsPaymentMethodsLoading,
12+
onError,
1213
}: {
1314
country: string;
1415
subdivision?: string;
1516
currency: string;
1617
setPaymentMethods: (paymentMethods: PaymentMethod[]) => void;
1718
setIsPaymentMethodsLoading: (loading: boolean) => void;
19+
onError?: (e: OnrampError | undefined) => void;
1820
}) => {
1921
const handleFetchPaymentMethods = useCallback(async () => {
2022
setIsPaymentMethodsLoading(true);
@@ -31,9 +33,28 @@ export const usePaymentMethods = ({
3133
country,
3234
);
3335

36+
if (paymentMethods.length === 0) {
37+
console.error(
38+
'No payment methods found for the selected country and currency. See docs for more information: https://docs.cdp.coinbase.com/onramp/docs/api-configurations',
39+
);
40+
onError?.({
41+
errorType: 'handled_error',
42+
code: 'NO_PAYMENT_METHODS',
43+
debugMessage:
44+
'No payment methods found for the selected country and currency. See docs for more information: https://docs.cdp.coinbase.com/onramp/docs/api-configurations',
45+
});
46+
}
47+
3448
setPaymentMethods(paymentMethods);
3549
} catch (error) {
36-
console.error('Error fetching payment options:', error);
50+
if (error instanceof Error) {
51+
console.error('Error fetching payment options:', error);
52+
onError?.({
53+
errorType: 'handled_error',
54+
code: 'PAYMENT_METHODS_ERROR',
55+
debugMessage: error.message,
56+
});
57+
}
3758
} finally {
3859
setIsPaymentMethodsLoading(false);
3960
}
@@ -43,6 +64,7 @@ export const usePaymentMethods = ({
4364
currency,
4465
setPaymentMethods,
4566
setIsPaymentMethodsLoading,
67+
onError,
4668
]);
4769

4870
// biome-ignore lint/correctness/useExhaustiveDependencies: initial effect

0 commit comments

Comments
 (0)