diff --git a/frontend/__tests__/wallet-connect.test.tsx b/frontend/__tests__/wallet-connect.test.tsx new file mode 100644 index 00000000..71c435bd --- /dev/null +++ b/frontend/__tests__/wallet-connect.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WalletConnect } from '../src/components/wallet-connect'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; + +jest.mock('framer-motion', () => { + const React = require('react'); + return { + ...jest.requireActual('framer-motion'), + motion: new Proxy( + {}, + { + get: (_, tag) => + React.forwardRef(function MotionComponent(props: any, ref: any) { + const { initial, animate, exit, transition, whileHover, whileTap, layout, ...rest } = props; + return React.createElement(tag, { ...rest, ref }); + }), + }, + ), + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +jest.mock('@stellar/freighter-api', () => ({ + __esModule: true, + default: { + isConnected: jest.fn(), + getPublicKey: jest.fn(), + }, +})); + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +// Reset the mocked freighter module state between tests +const mockFreighter = require('@stellar/freighter-api').default as { + isConnected: jest.Mock; + getPublicKey: jest.Mock; +}; + +describe('WalletConnect', () => { + beforeEach(() => { + mockFreighter.isConnected.mockReset(); + mockFreighter.getPublicKey.mockReset(); + (global as any).window.freighter = { getPublicKey: jest.fn() }; + (global as any).window.albedo = undefined; + (global as any).window.lobstr = undefined; + }); + + afterEach(() => { + delete (global as any).window.freighter; + delete (global as any).window.albedo; + delete (global as any).window.lobstr; + }); + + it('renders the connect button', () => { + renderWithProviders( {}} />); + expect(screen.getByLabelText('Connect wallet')).toBeInTheDocument(); + }); + + it('connects via Freighter and calls onConnect with the address', async () => { + mockFreighter.isConnected.mockResolvedValue(true); + mockFreighter.getPublicKey.mockResolvedValue('GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U'); + const onConnect = jest.fn(); + + renderWithProviders(); + fireEvent.click(screen.getByLabelText('Connect wallet')); + + await waitFor(() => { + expect(mockFreighter.getPublicKey).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith('GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U'); + }); + }); + + it('displays a truncated wallet address after connecting', async () => { + mockFreighter.isConnected.mockResolvedValue(true); + mockFreighter.getPublicKey.mockResolvedValue('GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U'); + const onConnect = jest.fn(); + + renderWithProviders(); + fireEvent.click(screen.getByLabelText('Connect wallet')); + + await waitFor(() => { + expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument(); + }); + // Truncated address should be present (GC4CQ...U7U7U7U) + expect(screen.getByText(/GC4CQ/)).toBeInTheDocument(); + const mono = screen.getByText(/U7U7U7U/); + expect(mono).toBeInTheDocument(); + expect(mono.textContent).toBe('GC4CQ...U7U7U7U'); + }); + + it('calls onDisconnect when the disconnect button is clicked', async () => { + mockFreighter.isConnected.mockResolvedValue(true); + mockFreighter.getPublicKey.mockResolvedValue('GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U'); + const onDisconnect = jest.fn(); + + renderWithProviders( + {}} onDisconnect={onDisconnect} />, + ); + fireEvent.click(screen.getByLabelText('Connect wallet')); + + await waitFor(() => { + expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByLabelText('Disconnect wallet')); + + await waitFor(() => { + expect(onDisconnect).toHaveBeenCalled(); + }); + // Should return to connect state + expect(screen.getByLabelText('Connect wallet')).toBeInTheDocument(); + }); + + it('shows an error message when Freighter is not installed', async () => { + mockFreighter.isConnected.mockRejectedValue(new Error('Freighter not found')); + (global as any).window.freighter = undefined; + + renderWithProviders( {}} />); + fireEvent.click(screen.getByLabelText('Connect wallet')); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert.textContent).toMatch(/Freighter/); + }); + }); + + it('opens the wallet selector when multiple wallets are available', () => { + (global as any).window.freighter = { getPublicKey: jest.fn() }; + (global as any).window.albedo = { publicKey: jest.fn() }; + + renderWithProviders( {}} />); + // Button should indicate a selector + const button = screen.getByLabelText('Select wallet to connect'); + fireEvent.click(button); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getByText('Freighter')).toBeInTheDocument(); + expect(screen.getByText('Albedo')).toBeInTheDocument(); + }); + + it('shows copy button and Stellar Expert link when connected', async () => { + mockFreighter.isConnected.mockResolvedValue(true); + mockFreighter.getPublicKey.mockResolvedValue('GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U'); + + renderWithProviders( {}} />); + fireEvent.click(screen.getByLabelText('Connect wallet')); + + await waitFor(() => { + expect(screen.getByLabelText('Copy wallet address')).toBeInTheDocument(); + expect(screen.getByLabelText('View on Stellar Expert')).toBeInTheDocument(); + }); + const link = screen.getByLabelText('View on Stellar Expert'); + expect(link).toHaveAttribute( + 'href', + 'https://stellar.expert/explorer/public/account/GC4CQK3WXU7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U7U', + ); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e4133efd..598707e9 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -84,7 +84,7 @@ export default function Home() {
- + setWalletAddress(null)} />
diff --git a/frontend/src/components/wallet-connect.tsx b/frontend/src/components/wallet-connect.tsx index a8146307..9f338571 100644 --- a/frontend/src/components/wallet-connect.tsx +++ b/frontend/src/components/wallet-connect.tsx @@ -1,88 +1,256 @@ 'use client'; import { useState, useCallback } from 'react'; -import { Wallet, Check } from 'lucide-react'; +import { Wallet, Check, LogOut, ChevronDown, Copy, ExternalLink } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { SuccessToast } from './animations'; import { useAccessibility } from '@/contexts/AccessibilityContext'; +type WalletProvider = 'freighter' | 'albedo' | 'lobstr'; + +interface WalletInfo { + provider: WalletProvider; + address: string; +} + interface WalletConnectProps { onConnect: (address: string) => void; + onDisconnect?: () => void; } -export function WalletConnect({ onConnect }: WalletConnectProps) { +const WALLET_NAMES: Record = { + freighter: 'Freighter', + albedo: 'Albedo', + lobstr: 'LOBSTR', +}; + +const WALLET_ICONS: Record = { + freighter: '🔐', + albedo: '🌐', + lobstr: '🦞', +}; + +/** + * Truncate a Stellar public key for display. + * e.g. "GABCDEF1234567890XYZ" → "GABCD...7890XYZ" + */ +function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 5)}...${address.slice(-7)}`; +} + +/** + * Detect available wallet providers in the browser. + */ +function detectAvailableWallets(): WalletProvider[] { + if (typeof window === 'undefined') return []; + const available: WalletProvider[] = []; + if ((window as any).freighter?.getPublicKey) available.push('freighter'); + if ((window as any).albedo) available.push('albedo'); + if ((window as any).lobstr) available.push('lobstr'); + return available; +} + +/** + * Connect to a wallet and return the public key. + */ +async function connectWalletProvider( + provider: WalletProvider, +): Promise { + switch (provider) { + case 'freighter': { + // Use @stellar/freighter-api if available, otherwise fallback to window.freighter + try { + const { default: freighter } = await import('@stellar/freighter-api'); + const isConnected = await freighter.isConnected(); + if (!isConnected) { + throw new Error('Freighter is not connected. Please open Freighter and unlock your wallet.'); + } + const publicKey = await freighter.getPublicKey(); + return publicKey; + } catch { + // Fallback to window.freighter for older versions + if ((window as any).freighter?.getPublicKey) { + return (window as any).freighter.getPublicKey(); + } + throw new Error('Freighter is not available. Please install the Freighter browser extension.'); + } + } + case 'albedo': { + if (!(window as any).albedo) { + throw new Error('Albedo is not available. Please install the Albedo wallet extension.'); + } + const result = await (window as any).albedo.publicKey(); + return result.publicKey; + } + case 'lobstr': { + if (!(window as any).lobstr) { + throw new Error('LOBSTR is not available. Please install the LOBSTR wallet extension.'); + } + const publicKey = await (window as any).lobstr.getPublicKey(); + return publicKey; + } + default: + throw new Error('Unsupported wallet provider'); + } +} + +export function WalletConnect({ onConnect, onDisconnect }: WalletConnectProps) { + const [wallet, setWallet] = useState(null); const [isConnecting, setIsConnecting] = useState(false); - const [isConnected, setIsConnected] = useState(false); const [showToast, setShowToast] = useState(false); + const [showWalletMenu, setShowWalletMenu] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [copied, setCopied] = useState(false); const { announceToScreenReader } = useAccessibility(); - const connectWallet = useCallback(async () => { - if (isConnected) return; - setIsConnecting(true); - announceToScreenReader('Connecting to wallet...'); - try { - if (typeof window !== 'undefined' && (window as any).freighter) { - const address = await (window as any).freighter.getPublicKey(); + const availableWallets = detectAvailableWallets(); + + const handleConnect = useCallback( + async (provider: WalletProvider) => { + if (wallet) return; + setIsConnecting(true); + setErrorMessage(null); + announceToScreenReader(`Connecting to ${WALLET_NAMES[provider]}...`); + setShowWalletMenu(false); + + try { + const address = await connectWalletProvider(provider); + const newWallet: WalletInfo = { provider, address }; + setWallet(newWallet); onConnect(address); - setIsConnected(true); setShowToast(true); - announceToScreenReader('Wallet connected successfully'); + announceToScreenReader(`${WALLET_NAMES[provider]} wallet connected successfully`); setTimeout(() => setShowToast(false), 3000); - } else { - announceToScreenReader('Please install Freighter wallet extension', 'assertive'); - alert('Please install Freighter wallet extension'); - } - } catch (error) { - console.error('Failed to connect wallet:', error); - announceToScreenReader('Failed to connect wallet', 'assertive'); - alert('Failed to connect wallet'); - } finally { - setIsConnecting(false); - } - }, [isConnected, onConnect, announceToScreenReader]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - connectWallet(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to connect wallet'; + setErrorMessage(message); + announceToScreenReader(message, 'assertive'); + console.error('Failed to connect wallet:', error); + } finally { + setIsConnecting(false); } }, - [connectWallet] + [wallet, onConnect, announceToScreenReader], ); + const handleDisconnect = useCallback(() => { + if (!wallet) return; + const providerName = WALLET_NAMES[wallet.provider]; + setWallet(null); + setErrorMessage(null); + setCopied(false); + onDisconnect?.(); + announceToScreenReader(`${providerName} wallet disconnected`); + }, [wallet, onDisconnect, announceToScreenReader]); + + const handleCopyAddress = useCallback(async () => { + if (!wallet) return; + try { + await navigator.clipboard.writeText(wallet.address); + setCopied(true); + announceToScreenReader('Wallet address copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } catch { + announceToScreenReader('Failed to copy address', 'assertive'); + } + }, [wallet, announceToScreenReader]); + + // Connected state: show wallet info + if (wallet) { + return ( + <> +
+
+ + + {truncateAddress(wallet.address)} + + + + +
+ +
+ + + + ); + } + + // Disconnected state: show connect button or wallet selector return ( <> - - - {isConnected ? ( +
+ { + if (availableWallets.length > 1) { + setShowWalletMenu(!showWalletMenu); + } else if (availableWallets.length === 1) { + handleConnect(availableWallets[0]); + } else { + setErrorMessage('No wallet extensions detected. Please install Freighter, Albedo, or LOBSTR.'); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (availableWallets.length > 1) { + setShowWalletMenu(!showWalletMenu); + } else if (availableWallets.length === 1) { + handleConnect(availableWallets[0]); + } + } + }} + disabled={isConnecting} + aria-label={ + isConnecting + ? 'Connecting wallet' + : availableWallets.length > 1 + ? 'Select wallet to connect' + : 'Connect wallet' + } + aria-expanded={showWalletMenu ? true : undefined} + aria-haspopup={availableWallets.length > 1 ? 'listbox' : undefined} + aria-busy={isConnecting} + className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-colors disabled:opacity-50 ${ + isConnecting + ? 'bg-blue-600 text-white cursor-wait' + : 'bg-blue-600 hover:bg-blue-700 text-white' + }`} + whileHover={isConnecting ? {} : { scale: 1.03 }} + whileTap={isConnecting ? {} : { scale: 0.97 }} + > + - - ) : ( - + + + + {/* Wallet selector dropdown */} + + {showWalletMenu && ( + + {availableWallets.map((provider) => ( + + ))} )} - +
- + {/* Error message */} + + {errorMessage && ( + +
+ Error: + {errorMessage} +
+ +
+ )} +
); -} +} \ No newline at end of file