diff --git a/playground/nextjs-app-router/components/demo/Earn.tsx b/playground/nextjs-app-router/components/demo/Earn.tsx index d30287de4f..32f80f9120 100644 --- a/playground/nextjs-app-router/components/demo/Earn.tsx +++ b/playground/nextjs-app-router/components/demo/Earn.tsx @@ -1,13 +1,37 @@ import { AppContext } from '@/components/AppProvider'; -import { Earn } from '@coinbase/onchainkit/earn'; -import { useContext } from 'react'; +import { Earn, type LifecycleStatus } from '@coinbase/onchainkit/earn'; +import type { TransactionError } from '@coinbase/onchainkit/transaction'; +import { useCallback, useContext } from 'react'; +import type { TransactionReceipt } from 'viem'; export function EarnDemo() { const { vaultAddress } = useContext(AppContext); + const handleOnSuccess = useCallback( + (transactionReceipt?: TransactionReceipt) => { + console.log('Success: ', transactionReceipt); + }, + [], + ); + + const handleOnError = useCallback((earnError: TransactionError) => { + console.log('Error:', earnError); + }, []); + + const handleOnStatus = useCallback((lifecycleStatus: LifecycleStatus) => { + console.log('Status:', lifecycleStatus); + }, []); + if (!vaultAddress) { return
Please set a vault address
; } - return ; + return ( + + ); } diff --git a/site/docs/pages/earn/types.mdx b/site/docs/pages/earn/types.mdx index 36d3ee274c..4d437c4d35 100644 --- a/site/docs/pages/earn/types.mdx +++ b/site/docs/pages/earn/types.mdx @@ -40,6 +40,11 @@ export type EarnReact = { className?: string; vaultAddress: Address; isSponsored?: boolean; + onError?: (error: TransactionError) => void; + /** An optional callback function that exposes the component lifecycle state */ + onStatus?: (lifecycleStatus: LifecycleStatus) => void; + /** An optional callback function that exposes the transaction receipt */ + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; }; ``` @@ -50,6 +55,11 @@ export type EarnProviderReact = { children: React.ReactNode; vaultAddress: Address; isSponsored?: boolean; + onError?: (error: TransactionError) => void; + /** An optional callback function that exposes the component lifecycle state */ + onStatus?: (lifecycleStatus: LifecycleStatus) => void; + /** An optional callback function that exposes the transaction receipt */ + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; }; ``` diff --git a/src/earn/components/Earn.tsx b/src/earn/components/Earn.tsx index 34922bde40..81e2c2eb13 100644 --- a/src/earn/components/Earn.tsx +++ b/src/earn/components/Earn.tsx @@ -54,10 +54,19 @@ export function Earn({ className, vaultAddress, isSponsored, + onError, + onStatus, + onSuccess, }: EarnReact) { const componentTheme = useTheme(); return ( - +
( +const TestComponent = () => { + const context = useEarnContext(); + const handleStatusError = async () => { + context.updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'code', + error: 'error_long_messages', + message: 'error_long_messages', + }, + }); + }; + const handleStatusSuccess = async () => { + context.updateLifecycleStatus({ + statusName: 'success', + statusData: { + transactionReceipts: [ + { hash: '0x1235' } as unknown as TransactionReceipt, + ], + }, + }); + }; + + return ( +
+ + {context.lifecycleStatus.statusName} + + + +
+ ); +}; + +const wrapper = ({ + children, +}: { + children?: React.ReactNode; +}) => ( {children} @@ -58,6 +101,54 @@ describe('EarnProvider', () => { }); }); + it('should emit onError when setLifecycleStatus is called with error', async () => { + const onErrorMock = vi.fn(); + const onStatusMock = vi.fn(); + (useMorphoVault as Mock).mockReturnValue({ + asset: DUMMY_ADDRESS, + assetDecimals: 18, + assetSymbol: 'TEST', + balance: '100', + totalApy: '0.05', + }); + + render( + + + , + ); + + const button = screen.getByText('setLifecycleStatus.error'); + fireEvent.click(button); + expect(onErrorMock).toHaveBeenCalled(); + expect(onStatusMock).toHaveBeenCalled(); + }); + + it('should emit onSuccess when setLifecycleStatus is called with success', async () => { + const onSuccessMock = vi.fn(); + (useMorphoVault as Mock).mockReturnValue({ + asset: DUMMY_ADDRESS, + assetDecimals: 18, + assetSymbol: 'TEST', + balance: '100', + totalApy: '0.05', + }); + + render( + + + , + ); + + const button = screen.getByText('setLifecycleStatus.success'); + fireEvent.click(button); + expect(onSuccessMock).toHaveBeenCalledWith({ hash: '0x1235' }); + }); + it('throws an error when vaultAddress is not provided', () => { expect(() => renderHook(() => useEarnContext(), { diff --git a/src/earn/components/EarnProvider.tsx b/src/earn/components/EarnProvider.tsx index 00d76c7869..c144345450 100644 --- a/src/earn/components/EarnProvider.tsx +++ b/src/earn/components/EarnProvider.tsx @@ -7,6 +7,7 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, useState, } from 'react'; @@ -25,6 +26,9 @@ export function EarnProvider({ vaultAddress, children, isSponsored, + onError, + onStatus, + onSuccess, }: EarnProviderReact) { if (!vaultAddress) { throw new Error( @@ -43,6 +47,16 @@ export function EarnProvider({ const [withdrawAmount, setWithdrawAmount] = useState(''); const [depositAmount, setDepositAmount] = useState(''); + useEffect(() => { + if (lifecycleStatus.statusName === 'error') { + onError?.(lifecycleStatus.statusData); + } + if (lifecycleStatus?.statusName === 'success') { + onSuccess?.(lifecycleStatus?.statusData?.transactionReceipts?.[0]); + } + onStatus?.(lifecycleStatus); + }, [lifecycleStatus, onStatus, onError, onSuccess]); + const { asset, balance: depositedBalance, diff --git a/src/earn/types.ts b/src/earn/types.ts index a7d224840d..02a4f37f84 100644 --- a/src/earn/types.ts +++ b/src/earn/types.ts @@ -1,10 +1,11 @@ +import type { TransactionError } from '@/api/types'; import type { UseMorphoVaultReturnType } from '@/earn/hooks/useMorphoVault'; import type { LifecycleStatusUpdate } from '@/internal/types'; import type { Token } from '@/token'; import type { Call } from '@/transaction/types'; import type { LifecycleStatus as TransactionLifecycleStatus } from '@/transaction/types'; import type React from 'react'; -import type { Address } from 'viem'; +import type { Address, TransactionReceipt } from 'viem'; /** * Note: exported as public Type @@ -14,6 +15,12 @@ export type EarnReact = { className?: string; vaultAddress: Address; isSponsored?: boolean; + /** An optional callback function that handles errors within the provider. */ + onError?: (error: TransactionError) => void; + /** An optional callback function that exposes the component lifecycle state */ + onStatus?: (lifecycleStatus: LifecycleStatus) => void; + /** An optional callback function that exposes the transaction receipt */ + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; }; /** @@ -23,6 +30,12 @@ export type EarnProviderReact = { children: React.ReactNode; vaultAddress: Address; isSponsored?: boolean; + /** An optional callback function that handles errors within the provider. */ + onError?: (error: TransactionError) => void; + /** An optional callback function that exposes the component lifecycle state */ + onStatus?: (lifecycleStatus: LifecycleStatus) => void; + /** An optional callback function that exposes the transaction receipt */ + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; }; /**