diff --git a/site/docs/components/TransactionWrapper.tsx b/site/docs/components/TransactionWrapper.tsx new file mode 100644 index 0000000000..dbe9f5cf74 --- /dev/null +++ b/site/docs/components/TransactionWrapper.tsx @@ -0,0 +1,58 @@ +import { useAccount } from 'wagmi'; +import type { Config } from 'wagmi'; +import type { ReactNode } from 'react'; +import type { + UseSendCallsParameters, + UseSendCallsReturnType, +} from 'wagmi/experimental'; + +type TransactionWrapperChildren = UseSendCallsReturnType< + Config, + unknown +>['sendCalls']['arguments'] & { + mutation?: UseSendCallsParameters['mutation']; +} & { address: string }; + +type TransactionWrapperReact = { + children: (props: TransactionWrapperChildren) => ReactNode; +}; + +const myNFTABI = [ + { + stateMutability: 'nonpayable', + type: 'function', + inputs: [{ name: 'to', type: 'address' }], + name: 'safeMint', + outputs: [], + }, +] as const; + +const myNFTAddress = '0x119Ea671030FBf79AB93b436D2E20af6ea469a19'; + +export default function TransactionWrapper({ + children, +}: TransactionWrapperReact) { + const { address } = useAccount(); + + const contracts = [ + { + address: myNFTAddress, + abi: myNFTABI, + functionName: 'safeMint', + args: [address], + }, + { + address: myNFTAddress, + abi: myNFTABI, + functionName: 'safeMint', + args: [address], + }, + ]; + return ( +
+
+ {children({ address, contracts })} +
+
+ ); +} diff --git a/site/docs/pages/transaction/transaction.mdx b/site/docs/pages/transaction/transaction.mdx new file mode 100644 index 0000000000..95ef26b224 --- /dev/null +++ b/site/docs/pages/transaction/transaction.mdx @@ -0,0 +1,51 @@ +import App from '../../components/App'; +import { Avatar, Name } from '@coinbase/onchainkit/identity'; +import { Transaction } from '../../../../src/transaction/components/Transaction'; +import { TransactionGasFee } from '../../../../src/transaction/components/TransactionGasFee'; +import { TransactionGasFeeLabel } from '../../../../src/transaction/components/TransactionGasFeeLabel'; +import { TransactionGasFeeEstimate } from '../../../../src/transaction/components/TransactionGasFeeEstimate'; +import { TransactionGasFeeSponsoredBy } from '../../../../src/transaction/components/TransactionGasFeeSponsoredBy'; +import { TransactionButton } from '../../../../src/transaction/components/TransactionButton'; +import { TransactionStatus } from '../../../../src/transaction/components/TransactionStatus'; +import { TransactionStatusLabel } from '../../../../src/transaction/components/TransactionStatusLabel'; +import { TransactionStatusAction } from '../../../../src/transaction/components/TransactionStatusAction'; +import TransactionWrapper from '../../components/TransactionWrapper'; +import { Wallet, ConnectWallet } from '../../../../src/wallet'; + +# `` + +:::warning +Component is actively in development. Stay tuned for upcoming releases. +::: + + + + {({ address, contracts }) => { + if (address) { + return ( + + + + + + + + + + + + + ) + } else { + return ( + + + + + + + ) + } + }} + + \ No newline at end of file diff --git a/site/sidebar.ts b/site/sidebar.ts index af5e4d9d91..65bdd3a808 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -229,6 +229,21 @@ export const sidebar = [ }, ], }, + { + text: 'Transaction', + items: [ + { + text: 'Components', + items: [ + { + text: 'Transaction', + link: '/transaction/transaction', + }, + ], + }, + ], + link: '/transaction/transaction', + }, { text: 'Wallet', items: [ diff --git a/src/transaction/components/Transaction.tsx b/src/transaction/components/Transaction.tsx index 67ff312e6f..369153abfd 100644 --- a/src/transaction/components/Transaction.tsx +++ b/src/transaction/components/Transaction.tsx @@ -1,6 +1,18 @@ import { TransactionProvider } from './TransactionProvider'; +import { cn } from '../../styles/theme'; import type { TransactionReact } from '../types'; -export function Transaction({ children }: TransactionReact) { - return {children}; +export function Transaction({ + address, + className, + children, + contracts, +}: TransactionReact) { + return ( + +
+ {children} +
+
+ ); } diff --git a/src/transaction/components/TransactionButton.stories.tsx b/src/transaction/components/TransactionButton.stories.tsx new file mode 100644 index 0000000000..3e91181660 --- /dev/null +++ b/src/transaction/components/TransactionButton.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { TransactionProvider } from './TransactionProvider'; +import { WagmiProvider, createConfig, http } from 'wagmi'; +import { baseSepolia } from 'viem/chains'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { TransactionButton } from './TransactionButton'; + +const wagmiConfig = createConfig({ + chains: [baseSepolia], + transports: { + [baseSepolia.id]: http(), + }, +}); + +const meta = { + title: 'Transaction/Button', + decorators: [ + (Story) => ( + + + + + + + + ), + ], + component: TransactionButton, + tags: ['autodocs'], + argTypes: { + className: { + control: 'text', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = {}; diff --git a/src/transaction/components/TransactionButton.tsx b/src/transaction/components/TransactionButton.tsx new file mode 100644 index 0000000000..977d978975 --- /dev/null +++ b/src/transaction/components/TransactionButton.tsx @@ -0,0 +1,35 @@ +import { background, cn, pressable, text } from '../../styles/theme'; +import { useTransactionContext } from './TransactionProvider'; +import { Spinner } from '../../internal/loading/Spinner'; +import type { TransactionButtonReact } from '../types'; + +export function TransactionButton({ + className, + text: buttonText = 'Transact', +}: TransactionButtonReact) { + const { address, contracts, isLoading, onSubmit, transactionId } = + useTransactionContext(); + + // TODO: should disable if transactionId exists ? + const isDisabled = isLoading || !contracts || !address || transactionId; + + return ( + + ); +} diff --git a/src/transaction/components/TransactionGasFee.tsx b/src/transaction/components/TransactionGasFee.tsx new file mode 100644 index 0000000000..4817524d3a --- /dev/null +++ b/src/transaction/components/TransactionGasFee.tsx @@ -0,0 +1,11 @@ +import { cn } from '../../styles/theme'; +import type { TransactionGasFeeReact } from '../types'; + +export function TransactionGasFee({ + children, + className, +}: TransactionGasFeeReact) { + return ( +
{children}
+ ); +} diff --git a/src/transaction/components/TransactionGasFeeEstimate.tsx b/src/transaction/components/TransactionGasFeeEstimate.tsx new file mode 100644 index 0000000000..d43b316c47 --- /dev/null +++ b/src/transaction/components/TransactionGasFeeEstimate.tsx @@ -0,0 +1,15 @@ +import { cn, color, text } from '../../styles/theme'; +import { useTransactionContext } from './TransactionProvider'; +import type { TransactionGasFeeEstimateReact } from '../types'; + +export function TransactionGasFeeEstimate({ + className, +}: TransactionGasFeeEstimateReact) { + const { gasFee } = useTransactionContext(); + + return ( +
+ {gasFee &&

{`${gasFee} ETH`}

} +
+ ); +} diff --git a/src/transaction/components/TransactionGasFeeLabel.tsx b/src/transaction/components/TransactionGasFeeLabel.tsx new file mode 100644 index 0000000000..fec55f03c3 --- /dev/null +++ b/src/transaction/components/TransactionGasFeeLabel.tsx @@ -0,0 +1,12 @@ +import { cn, color, text } from '../../styles/theme'; +import type { TransactionGasFeeLabelReact } from '../types'; + +export function TransactionGasFeeLabel({ + className, +}: TransactionGasFeeLabelReact) { + return ( +
+

Gas fee

+
+ ); +} diff --git a/src/transaction/components/TransactionGasFeeSponsoredBy.tsx b/src/transaction/components/TransactionGasFeeSponsoredBy.tsx new file mode 100644 index 0000000000..4627e90a4e --- /dev/null +++ b/src/transaction/components/TransactionGasFeeSponsoredBy.tsx @@ -0,0 +1,18 @@ +import { cn, color, text } from '../../styles/theme'; +import type { TransactionGasFeeSponsoredByReact } from '../types'; + +export function TransactionGasFeeSponsoredBy({ + className, +}: TransactionGasFeeSponsoredByReact) { + // TODO: replace with actual value + const sponsoredBy = 'Coinbase'; + + return ( +
+

+ •{' '}Sponsored by{' '} + {sponsoredBy} +

+
+ ); +} diff --git a/src/transaction/components/TransactionProvider.tsx b/src/transaction/components/TransactionProvider.tsx index cf9db9af8f..a9bceb0f23 100644 --- a/src/transaction/components/TransactionProvider.tsx +++ b/src/transaction/components/TransactionProvider.tsx @@ -1,6 +1,16 @@ -import { createContext, useContext } from 'react'; -import type { TransactionContextType } from '../types'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { useValue } from '../../internal/hooks/useValue'; +import { useWriteContracts } from '../core/useWriteContracts'; +import type { + TransactionContextType, + TransactionProviderReact, +} from '../types'; const emptyContext = {} as TransactionContextType; @@ -18,13 +28,46 @@ export function useTransactionContext() { } export function TransactionProvider({ + address, children, -}: { - children: React.ReactNode; -}) { + contracts, +}: TransactionProviderReact) { + const [errorMessage, setErrorMessage] = useState(''); + const [transactionId, setTransactionId] = useState(''); + const [gasFee, setGasFee] = useState(''); + + const { status, writeContracts } = useWriteContracts({ + setErrorMessage, + setTransactionId, + }); + + const handleSubmit = useCallback(async () => { + try { + setErrorMessage(''); + await writeContracts({ + contracts, + }); + } catch (err) { + console.log({ err }); + } + }, [contracts, writeContracts]); + + useEffect(() => { + // TODO: replace with gas estimation call + setGasFee('0.03'); + }, []); + const value = useValue({ + address, + contracts, error: undefined, - loading: false, + errorMessage, + gasFee, + isLoading: status === 'pending', + onSubmit: handleSubmit, + setErrorMessage, + transactionId, + setTransactionId, }); return ( diff --git a/src/transaction/components/TransactionStatus.tsx b/src/transaction/components/TransactionStatus.tsx new file mode 100644 index 0000000000..cbc71f2da8 --- /dev/null +++ b/src/transaction/components/TransactionStatus.tsx @@ -0,0 +1,11 @@ +import { cn } from '../../styles/theme'; +import type { TransactionStatusReact } from '../types'; + +export function TransactionStatus({ + children, + className, +}: TransactionStatusReact) { + return ( +
{children}
+ ); +} diff --git a/src/transaction/components/TransactionStatusAction.tsx b/src/transaction/components/TransactionStatusAction.tsx new file mode 100644 index 0000000000..c57fe8d0ec --- /dev/null +++ b/src/transaction/components/TransactionStatusAction.tsx @@ -0,0 +1,11 @@ +import { cn, text } from '../../styles/theme'; +import { useGetTransactionStatus } from '../core/useGetTransactionStatus'; +import type { TransactionStatusActionReact } from '../types'; + +export function TransactionStatusAction({ + className, +}: TransactionStatusActionReact) { + const { actionElement } = useGetTransactionStatus(); + + return
{actionElement}
; +} diff --git a/src/transaction/components/TransactionStatusLabel.tsx b/src/transaction/components/TransactionStatusLabel.tsx new file mode 100644 index 0000000000..042d01c584 --- /dev/null +++ b/src/transaction/components/TransactionStatusLabel.tsx @@ -0,0 +1,15 @@ +import { cn, text } from '../../styles/theme'; +import { useGetTransactionStatus } from '../core/useGetTransactionStatus'; +import type { TransactionStatusLabelReact } from '../types'; + +export function TransactionStatusLabel({ + className, +}: TransactionStatusLabelReact) { + const { label, labelClassName } = useGetTransactionStatus(); + + return ( +
+

{label}

+
+ ); +} diff --git a/src/transaction/core/getChainExplorer.ts b/src/transaction/core/getChainExplorer.ts new file mode 100644 index 0000000000..425e8babe0 --- /dev/null +++ b/src/transaction/core/getChainExplorer.ts @@ -0,0 +1,8 @@ +import { baseSepolia } from 'viem/chains'; + +export function getChainExplorer(chainId: number) { + if (chainId === baseSepolia.id) { + return 'https://sepolia.basescan.org'; + } + return 'https://basescan.org'; +} diff --git a/src/transaction/core/useGetTransactionStatus.tsx b/src/transaction/core/useGetTransactionStatus.tsx new file mode 100644 index 0000000000..29b09ff177 --- /dev/null +++ b/src/transaction/core/useGetTransactionStatus.tsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import { useTransactionContext } from '../components/TransactionProvider'; +import { cn, color, text } from '../../styles/theme'; +import { useOnchainKit } from '../../useOnchainKit'; +import { getChainExplorer } from './getChainExplorer'; +import type { ReactNode } from 'react'; + +export function useGetTransactionStatus() { + const { errorMessage, isLoading, transactionId } = useTransactionContext(); + const { chain } = useOnchainKit(); + + return useMemo(() => { + const chainExplorer = getChainExplorer(chain.id); + + let actionElement: ReactNode = null; + let label: string = ''; + let labelClassName: string = color.foregroundMuted; + + if (isLoading) { + label = 'Transaction in progress...'; + actionElement = ( + + + View on explorer + + + ); + } + if (transactionId) { + label = 'Successful!'; + actionElement = ( + + + View transaction + + + ); + } + if (errorMessage) { + label = 'Something went wrong. Please try again.'; + labelClassName = color.error; + actionElement = ( + + ); + } + + return { actionElement, label, labelClassName }; + }, [chain, errorMessage, isLoading, transactionId]); +} diff --git a/src/transaction/core/useWriteContracts.ts b/src/transaction/core/useWriteContracts.ts new file mode 100644 index 0000000000..892bd4a757 --- /dev/null +++ b/src/transaction/core/useWriteContracts.ts @@ -0,0 +1,38 @@ +import { useWriteContracts as useWriteContractsWagmi } from 'wagmi/experimental'; +import type { TransactionExecutionError } from 'viem'; + +type UseWriteContractsParams = { + setErrorMessage: (error: string) => void; + setTransactionId: (id: string) => void; +}; + +export function useWriteContracts({ + setErrorMessage, + setTransactionId, +}: UseWriteContractsParams) { + try { + const { status, writeContracts } = useWriteContractsWagmi({ + mutation: { + onError: (e) => { + if ( + (e as TransactionExecutionError).cause.name === + 'UserRejectedRequestError' + ) { + setErrorMessage('User rejected request'); + } else { + setErrorMessage(e.message); + } + }, + onSuccess: (id) => { + setTransactionId(id); + // do we need this? + // mutation.onSuccess(id); + }, + }, + }); + return { status, writeContracts }; + } catch (err) { + console.log({ err }); + return { status: 'error', writeContracts: () => {} }; + } +} diff --git a/src/transaction/types.ts b/src/transaction/types.ts index 853c5a477e..f0ab73886f 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -1,12 +1,40 @@ // 🌲☀🌲 import type { ReactNode } from 'react'; +import type { Abi, Address, ContractFunctionName, Hex } from 'viem'; +import type { Config } from 'wagmi'; +import type { + UseSendCallsParameters, + UseSendCallsReturnType, +} from 'wagmi/experimental'; + +export type Contract = { + address: Address; + abi: Abi; + functionName: ContractFunctionName; + args?: { to: Hex; data?: Hex; value?: bigint }[]; +}; + +export type TransactionButtonReact = UseSendCallsReturnType< + Config, + unknown +>['sendCalls']['arguments'] & { + mutation?: UseSendCallsParameters['mutation']; +} & { className?: string; text?: string }; /** * Note: exported as public Type */ export type TransactionContextType = { + address: Address; + contracts: Contract[]; error?: TransactionErrorState; - loading: boolean; + errorMessage?: string; + isLoading: boolean; + gasFee?: string; + onSubmit: () => void; + setErrorMessage: (error: string) => void; + setTransactionId: (id: string) => void; + transactionId?: string; }; /** @@ -21,9 +49,52 @@ export type TransactionErrorState = { TransactionError?: TransactionError; }; +export type TransactionGasFeeEstimateReact = { + className?: string; +}; + +export type TransactionGasFeeLabelReact = { + className?: string; +}; + +export type TransactionGasFeeReact = { + children: ReactNode; + className?: string; +}; + +export type TransactionGasFeeSponsoredByReact = { + className?: string; +}; + +export type TransactionMessageReact = { + className?: string; +}; + +export type TransactionProviderReact = { + address: Address; + children: ReactNode; + contracts: Contract[]; +}; + /** * Note: exported as public Type */ export type TransactionReact = { + address: Address; + children: ReactNode; + className?: string; + contracts: Contract[]; +}; + +export type TransactionStatusActionReact = { + className?: string; +}; + +export type TransactionStatusLabelReact = { + className?: string; +}; + +export type TransactionStatusReact = { children: ReactNode; + className?: string; };