UNPKG

@stablecoin.xyz/react

Version:

React hooks and components for SBC Account Abstraction

400 lines (392 loc) 17.7 kB
import { createContext, useState, useCallback, useEffect, useContext } from 'react'; import { jsx, jsxs } from 'react/jsx-runtime'; import { SbcAppKit } from '@stablecoin.xyz/core'; const SbcContext = createContext(undefined); function SbcProvider({ config, children, onError }) { const [sbcAppKit, setSbcAppKit] = useState(null); const [isInitialized, setIsInitialized] = useState(false); const [error, setError] = useState(null); const initialize = useCallback(async () => { try { setError(null); setIsInitialized(false); const appKit = new SbcAppKit(config); setSbcAppKit(appKit); setIsInitialized(true); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to initialize SBC App Kit'); setError(error); setSbcAppKit(null); setIsInitialized(false); onError?.(error); } }, [config, onError]); // Initialize on mount and config changes useEffect(() => { initialize(); }, [initialize]); // Cleanup on unmount useEffect(() => { return () => { }; }, [sbcAppKit]); const contextValue = { sbcAppKit, isInitialized, error, }; return (jsx(SbcContext.Provider, { value: contextValue, children: children })); } function useSbcContext() { const context = useContext(SbcContext); if (context === undefined) { throw new Error('useSbcContext must be used within a SbcProvider'); } return context; } /** * Main hook for accessing SBC App Kit functionality */ function useSbcApp() { const { sbcAppKit, isInitialized, error } = useSbcContext(); const [account, setAccount] = useState(null); const [isLoadingAccount, setIsLoadingAccount] = useState(false); const [accountError, setAccountError] = useState(null); const [ownerAddress, setOwnerAddress] = useState(null); const refreshAccount = useCallback(async () => { if (!sbcAppKit || !isInitialized) { setAccount(null); setOwnerAddress(null); return; } try { setIsLoadingAccount(true); setAccountError(null); // First try to get owner address to check if wallet is connected try { const owner = sbcAppKit.getOwnerAddress(); setOwnerAddress(owner); // Only try to get account if wallet is connected const accountInfo = await sbcAppKit.getAccount(); setAccount(accountInfo); } catch (ownerError) { // No wallet connected yet - this is normal for 'auto' wallet config setOwnerAddress(null); setAccount(null); // Don't set this as an error - it's expected behavior return; } } catch (err) { const error = err instanceof Error ? err : new Error('Failed to load account'); setAccountError(error); setAccount(null); setOwnerAddress(null); } finally { setIsLoadingAccount(false); } }, [sbcAppKit, isInitialized]); // Disconnect wallet and clear state const disconnectWallet = useCallback(() => { if (sbcAppKit) { try { sbcAppKit.disconnectWallet(); } catch { } } setAccount(null); setOwnerAddress(null); }, [sbcAppKit]); // Load account when SDK is initialized useEffect(() => { if (isInitialized && sbcAppKit) { refreshAccount(); } else { setAccount(null); setAccountError(null); setOwnerAddress(null); } }, [isInitialized, sbcAppKit, refreshAccount]); return { sbcAppKit, isInitialized, error, account, isLoadingAccount, accountError, refreshAccount, ownerAddress, disconnectWallet, }; } /** * Hook for sending user operations with automatic state management */ function useUserOperation(options = {}) { const { sbcAppKit, isInitialized } = useSbcContext(); const { refreshAccount } = useSbcApp(); const { onSuccess, onError, refreshAccount: shouldRefreshAccount = true } = options; const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [isError, setIsError] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState(null); const reset = useCallback(() => { setIsLoading(false); setIsSuccess(false); setIsError(false); setError(null); setData(null); }, []); const sendUserOperation = useCallback(async (params) => { if (!sbcAppKit || !isInitialized) { const error = new Error('SBC App Kit is not initialized'); setError(error); setIsError(true); onError?.(error); return; } try { setIsLoading(true); setIsError(false); setError(null); setIsSuccess(false); const result = await sbcAppKit.sendUserOperation(params); setData(result); setIsSuccess(true); onSuccess?.(result); // Auto-refresh account if enabled if (shouldRefreshAccount) { refreshAccount(); } return result; } catch (err) { const error = err instanceof Error ? err : new Error('Failed to send user operation'); setError(error); setIsError(true); setData(null); onError?.(error); return; } finally { setIsLoading(false); } }, [sbcAppKit, isInitialized, onSuccess, onError, shouldRefreshAccount, refreshAccount]); const estimateUserOperation = useCallback(async (params) => { if (!sbcAppKit || !isInitialized) { const error = new Error('SBC App Kit is not initialized'); setError(error); setIsError(true); onError?.(error); return; } try { return await sbcAppKit.estimateUserOperation(params); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to estimate user operation'); setError(error); setIsError(true); onError?.(error); return; } }, [sbcAppKit, isInitialized, onError]); return { sendUserOperation, estimateUserOperation, isLoading, isSuccess, isError, error, data, reset, }; } /** * WalletConnect component - placeholder for future wallet integration * * This component will be expanded in the future to handle: * - Multiple wallet providers (MetaMask, WalletConnect, Coinbase Wallet, etc.) * - Wallet switching * - Connection state management * - Network switching */ function WalletConnect({ className, onConnectionChange }) { return (jsx("div", { className: className, children: jsxs("div", { style: { padding: '12px 16px', border: '1px solid #e0e0e0', borderRadius: '8px', textAlign: 'center', backgroundColor: '#f8f9fa' }, children: [jsx("p", { style: { margin: '0 0 8px 0', fontSize: '14px', color: '#666' }, children: "\uD83D\uDEA7 Wallet Connection Coming Soon" }), jsx("p", { style: { margin: 0, fontSize: '12px', color: '#999' }, children: "This component will support multiple wallet providers and connection management." })] }) })); } /** * WalletButton - Simple button component for connecting to wallets * * Automatically detects available wallets and connects with one click */ function WalletButton({ walletType = 'auto', className = '', onConnect, onError, children, showLoading = true, disabled = false, render, }) { const { sbcAppKit, refreshAccount } = useSbcApp(); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); const handleConnect = async () => { if (!sbcAppKit || isConnecting || disabled) return; try { setIsConnecting(true); setError(null); const result = await sbcAppKit.connectWallet(walletType); // Refresh app state after connection await refreshAccount(); onConnect?.(result); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to connect wallet'); setError(error.message); onError?.(error); } finally { setIsConnecting(false); } }; const getButtonText = () => { if (children) return children; if (isConnecting) return 'Connecting...'; if (walletType === 'metamask') return 'Connect MetaMask'; if (walletType === 'coinbase') return 'Connect Coinbase Wallet'; if (walletType === 'walletconnect') return 'Connect WalletConnect'; return 'Connect Wallet'; }; const baseStyles = ` px-4 py-2 rounded-md font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed `.trim(); const stateStyles = isConnecting || disabled ? 'bg-gray-300 text-gray-600 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800'; const finalClassName = `${baseStyles} ${stateStyles} ${className}`.trim(); // If render prop is provided, use it for rendering if (render) { return (jsxs("div", { className: "space-y-2", children: [render({ onClick: handleConnect, isConnecting, disabled: isConnecting || disabled || !sbcAppKit, children: getButtonText(), className: finalClassName, }), error && (jsx("div", { className: "text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2", children: error }))] })); } return (jsxs("div", { className: "space-y-2", children: [jsxs("button", { type: "button", className: finalClassName, onClick: handleConnect, disabled: isConnecting || disabled || !sbcAppKit, children: [showLoading && isConnecting && (jsx("span", { className: "inline-block w-4 h-4 mr-2 animate-spin rounded-full border-2 border-transparent border-t-current" })), getButtonText()] }), error && (jsx("div", { className: "text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2", children: error }))] })); } /** * WalletSelector - Component that displays available wallets and allows selection * * Automatically detects installed wallets and shows connection options */ function WalletSelector({ className = '', onConnect, onError, showOnlyAvailable = true, wallets: customWallets, }) { const { sbcAppKit } = useSbcContext(); const [availableWallets, setAvailableWallets] = useState([]); const [isLoading, setIsLoading] = useState(true); const [connectingWallet, setConnectingWallet] = useState(null); // Load available wallets on mount useEffect(() => { const loadWallets = async () => { if (!sbcAppKit) return; try { setIsLoading(true); // TODO: Replace with sbcAppKit.getAvailableWallets() once new version is published if (sbcAppKit.getAvailableWallets) { const wallets = await sbcAppKit.getAvailableWallets(); setAvailableWallets(wallets); } else { // Fallback: Use hardcoded wallet list const fallbackWallets = [ { type: 'metamask', name: 'MetaMask', available: typeof window !== 'undefined' && !!window.ethereum, icon: 'https://docs.metamask.io/img/metamask-fox.svg', }, { type: 'coinbase', name: 'Coinbase Wallet', available: typeof window !== 'undefined' && !!(window.ethereum || window.coinbaseWalletExtension), icon: 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', }, { type: 'walletconnect', name: 'WalletConnect', available: true, icon: 'https://registry.walletconnect.com/api/v1/logo/sm/walletconnect.png', }, ]; setAvailableWallets(fallbackWallets); } } catch (error) { console.error('Failed to load available wallets:', error); setAvailableWallets([]); } finally { setIsLoading(false); } }; loadWallets(); }, [sbcAppKit]); const handleWalletConnect = async (walletType) => { if (!sbcAppKit || connectingWallet) return; try { setConnectingWallet(walletType); // TODO: Replace with sbcAppKit.connectWallet() once new version is published if (sbcAppKit.connectWallet) { const result = await sbcAppKit.connectWallet(walletType); onConnect?.(result); } else { throw new Error('Wallet integration coming soon! Use the dev:local script to test with the latest SDK features.'); } } catch (err) { const error = err instanceof Error ? err : new Error('Failed to connect wallet'); onError?.(error); } finally { setConnectingWallet(null); } }; // Use custom wallets if provided, otherwise use detected wallets const walletsToShow = customWallets || availableWallets; const filteredWallets = showOnlyAvailable ? walletsToShow.filter(w => w.available) : walletsToShow; if (isLoading) { return (jsx("div", { className: `space-y-4 ${className}`, children: jsxs("div", { className: "text-center text-gray-600", children: [jsx("div", { className: "inline-block w-6 h-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" }), jsx("p", { className: "mt-2", children: "Detecting available wallets..." })] }) })); } if (filteredWallets.length === 0) { return (jsxs("div", { className: `text-center p-6 bg-gray-50 border border-gray-200 rounded-lg ${className}`, children: [jsx("p", { className: "text-gray-600 mb-4", children: "No compatible wallets found." }), jsx("p", { className: "text-sm text-gray-500", children: "Please install MetaMask, Coinbase Wallet, or use WalletConnect to continue." })] })); } return (jsxs("div", { className: `space-y-3 ${className}`, children: [jsx("h3", { className: "text-lg font-medium text-gray-900 mb-4", children: "Connect Wallet" }), filteredWallets.map((wallet) => (jsxs("button", { type: "button", className: ` w-full flex items-center justify-between p-4 border rounded-lg transition-colors ${wallet.available ? 'border-gray-200 hover:border-blue-300 hover:bg-blue-50 focus:border-blue-500 focus:ring-2 focus:ring-blue-200' : 'border-gray-100 bg-gray-50 cursor-not-allowed opacity-60'} ${connectingWallet === wallet.type ? 'border-blue-500 bg-blue-50' : ''} `, onClick: () => handleWalletConnect(wallet.type), disabled: !wallet.available || connectingWallet !== null, children: [jsxs("div", { className: "flex items-center space-x-3", children: [wallet.icon && (jsx("img", { src: wallet.icon, alt: `${wallet.name} icon`, className: "w-8 h-8 rounded", onError: (e) => { // Hide broken images e.target.style.display = 'none'; } })), jsxs("div", { className: "text-left", children: [jsx("p", { className: "font-medium text-gray-900", children: wallet.name }), jsx("p", { className: "text-sm text-gray-500", children: wallet.available ? 'Ready to connect' : 'Not installed' })] })] }), jsx("div", { className: "flex items-center", children: connectingWallet === wallet.type ? (jsx("div", { className: "w-5 h-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" })) : wallet.available ? (jsx("svg", { className: "w-5 h-5 text-gray-400", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) })) : (jsx("svg", { className: "w-5 h-5 text-gray-300", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })) })] }, wallet.type)))] })); } export { SbcProvider, WalletButton, WalletConnect, WalletSelector, useSbcApp, useSbcContext, useUserOperation };