diff --git a/frontend/src/__tests__/hooks/useWalletManager.test.ts b/frontend/src/__tests__/hooks/useWalletManager.test.ts new file mode 100644 index 0000000..b6666c3 --- /dev/null +++ b/frontend/src/__tests__/hooks/useWalletManager.test.ts @@ -0,0 +1,115 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useWalletManager } from '../../hooks/useWalletManager'; +import { StellarWalletsKit } from '@creit.tech/stellar-wallets-kit'; + +vi.mock('@creit.tech/stellar-wallets-kit', () => ({ + StellarWalletsKit: vi.fn(function () { + return {}; + }), + WalletNetwork: { TESTNET: 'TESTNET', PUBLIC: 'PUBLIC' }, + FreighterModule: vi.fn(function () { + return {}; + }), + xBullModule: vi.fn(function () { + return {}; + }), + LobstrModule: vi.fn(function () { + return {}; + }), + FREIGHTER_ID: 'freighter', + LOBSTR_ID: 'lobstr', +})); + +vi.mock('../../hooks/useNotification', () => ({ + useNotification: () => ({ + notifyWalletEvent: vi.fn(), + }), +})); + +interface MockKitInstance { + setWallet: ReturnType; + getAddress: ReturnType; + getSupportedWallets: ReturnType; + disconnect: ReturnType; + signTransaction: ReturnType; +} + +describe('useWalletManager', () => { + let mockKitInstance: MockKitInstance; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockKitInstance = { + setWallet: vi.fn(), + getAddress: vi.fn(), + getSupportedWallets: vi.fn().mockResolvedValue([]), + disconnect: vi.fn(), + signTransaction: vi.fn(), + }; + vi.mocked(StellarWalletsKit).mockImplementation( + () => mockKitInstance as unknown as StellarWalletsKit + ); + }); + + it('initializes and attempts silent reconnect if wallet in localStorage', async () => { + localStorage.setItem('payd:last_wallet_name', 'freighter'); + mockKitInstance.getAddress.mockResolvedValue({ address: 'G123' }); + + const { result } = renderHook(() => useWalletManager()); + + // Initially connecting + expect(result.current.isConnecting).toBe(true); + expect(result.current.isInitialized).toBe(false); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.address).toBe('G123'); + expect(result.current.walletName).toBe('freighter'); + expect(result.current.isConnecting).toBe(false); + }); + + it('handles manual connect sequence appropriately', async () => { + mockKitInstance.getSupportedWallets.mockResolvedValue([ + { id: 'freighter', name: 'Freighter', isAvailable: true }, + ]); + + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + await result.current.connect(); + }); + + await waitFor(() => { + expect(result.current.walletModalOpen).toBe(true); + }); + expect(result.current.walletOptions.length).toBe(1); + + mockKitInstance.getAddress.mockResolvedValue({ address: 'G456' }); + + await act(async () => { + await result.current.connectWithWallet('freighter'); + }); + + await waitFor(() => { + expect(result.current.walletModalOpen).toBe(false); + }); + expect(result.current.address).toBe('G456'); + expect(result.current.walletName).toBe('freighter'); + }); + + it('handles disconnect', () => { + const { result } = renderHook(() => useWalletManager()); + + act(() => { + result.current.disconnect(); + }); + + expect(result.current.address).toBeNull(); + expect(result.current.walletName).toBeNull(); + expect(localStorage.getItem('payd:last_wallet_name')).toBeNull(); + }); +}); diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index bd2cb62..192bce2 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -9,8 +9,7 @@ import { Breadcrumb } from './Breadcrumb'; import { NetworkSwitcher } from './NetworkSwitcher'; import { useNetworkStore } from '../stores/networkStore'; -const APP_VERSION = - (import.meta.env.PUBLIC_APP_VERSION as string | undefined)?.trim() ?? '0.0.1'; +const APP_VERSION = (import.meta.env.PUBLIC_APP_VERSION as string | undefined)?.trim() ?? '0.0.1'; const APP_ENV = import.meta.env.MODE; // ── Page Wrapper ─────────────────────── diff --git a/frontend/src/components/Breadcrumb.tsx b/frontend/src/components/Breadcrumb.tsx index 3472fa2..2d9aac5 100644 --- a/frontend/src/components/Breadcrumb.tsx +++ b/frontend/src/components/Breadcrumb.tsx @@ -34,8 +34,7 @@ export function buildCrumbs(pathname: string): Crumb[] { let accumulated = ''; for (const segment of segments) { accumulated += `/${segment}`; - const label = - ROUTE_LABELS[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1); + const label = ROUTE_LABELS[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1); crumbs.push({ label, href: accumulated }); } @@ -61,15 +60,9 @@ export const Breadcrumb: React.FC = () => { const isLast = i === crumbs.length - 1; return ( - {i > 0 && ( - - )} + {i > 0 && } {isLast ? ( - + {crumb.label} ) : ( diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index c0d98a0..75645bd 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -255,9 +255,7 @@ export const EmployeeList: React.FC = ({ {isLoading ? ( - Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ( - - )) + Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ) ) : sortedEmployees.length === 0 ? ( @@ -266,10 +264,7 @@ export const EmployeeList: React.FC = ({ ) : ( sortedEmployees.map((employee) => ( - +
({ })); // Dynamic import so mocks are registered first -const importNav = () => - import('../AppNav').then((m) => m.default); +const importNav = () => import('../AppNav').then((m) => m.default); describe('AppNav — mobile drawer', () => { test('hamburger button is present and drawer is hidden initially', async () => { diff --git a/frontend/src/components/__tests__/Breadcrumb.test.tsx b/frontend/src/components/__tests__/Breadcrumb.test.tsx index a473620..3539329 100644 --- a/frontend/src/components/__tests__/Breadcrumb.test.tsx +++ b/frontend/src/components/__tests__/Breadcrumb.test.tsx @@ -97,10 +97,7 @@ describe('Breadcrumb component', () => { ); expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/'); - expect(screen.getByRole('link', { name: /employer/i })).toHaveAttribute( - 'href', - '/employer' - ); + expect(screen.getByRole('link', { name: /employer/i })).toHaveAttribute('href', '/employer'); expect(screen.getByText('Payroll')).toHaveAttribute('aria-current', 'page'); }); }); diff --git a/frontend/src/components/__tests__/EmployeeListHover.test.tsx b/frontend/src/components/__tests__/EmployeeListHover.test.tsx index c2b0bb0..78fbee3 100644 --- a/frontend/src/components/__tests__/EmployeeListHover.test.tsx +++ b/frontend/src/components/__tests__/EmployeeListHover.test.tsx @@ -23,9 +23,7 @@ const employee = { describe('EmployeeList row hover effects', () => { test('data rows include hover background class', () => { - const { container } = render( - - ); + const { container } = render(); const rows = container.querySelectorAll('tbody tr'); expect(rows.length).toBeGreaterThan(0); @@ -36,9 +34,7 @@ describe('EmployeeList row hover effects', () => { }); test('data rows include transition class for smooth hover animation', () => { - const { container } = render( - - ); + const { container } = render(); const rows = container.querySelectorAll('tbody tr'); rows.forEach((row) => { diff --git a/frontend/src/components/__tests__/NetworkSwitcher.test.tsx b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx index e68dc46..33d7b5a 100644 --- a/frontend/src/components/__tests__/NetworkSwitcher.test.tsx +++ b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx @@ -25,9 +25,7 @@ describe('NetworkSwitcher', () => { test('renders a select element with an accessible label', () => { render(); - expect( - screen.getByRole('combobox', { name: /select stellar network/i }) - ).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /select stellar network/i })).toBeInTheDocument(); }); test('shows MAINNET as the default selected option', () => { @@ -66,8 +64,6 @@ describe('NetworkSwitcher', () => { test('wraps select in a group with an accessible label', () => { render(); - expect( - screen.getByRole('group', { name: /stellar network selector/i }) - ).toBeInTheDocument(); + expect(screen.getByRole('group', { name: /stellar network selector/i })).toBeInTheDocument(); }); }); diff --git a/frontend/src/hooks/useWalletManager.ts b/frontend/src/hooks/useWalletManager.ts new file mode 100644 index 0000000..491855b --- /dev/null +++ b/frontend/src/hooks/useWalletManager.ts @@ -0,0 +1,201 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { + StellarWalletsKit, + WalletNetwork, + FreighterModule, + FREIGHTER_ID, + xBullModule, + LobstrModule, + LOBSTR_ID, +} from '@creit.tech/stellar-wallets-kit'; +import { useNotification } from './useNotification'; + +const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name'; +const SUPPORTED_MODAL_WALLETS = [FREIGHTER_ID, LOBSTR_ID] as const; + +export type SelectableWallet = { + id: string; + name: string; + icon?: string; + isAvailable: boolean; +}; + +function hasAnyWalletExtension(): boolean { + if (typeof window === 'undefined') return true; + const extendedWindow = window as Window & + typeof globalThis & { + freighterApi?: unknown; + xBullSDK?: unknown; + lobstr?: unknown; + }; + + return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr); +} + +export function useWalletManager() { + const [address, setAddress] = useState(null); + const [walletName, setWalletName] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [walletExtensionAvailable, setWalletExtensionAvailable] = useState(true); + const [network, setNetwork] = useState<'TESTNET' | 'PUBLIC'>('TESTNET'); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const [walletOptions, setWalletOptions] = useState([]); + const kitRef = useRef(null); + + const { notifyWalletEvent } = useNotification(); + + useEffect(() => { + setWalletExtensionAvailable(hasAnyWalletExtension()); + + const newKit = new StellarWalletsKit({ + network: network === 'TESTNET' ? WalletNetwork.TESTNET : WalletNetwork.PUBLIC, + modules: [new FreighterModule(), new xBullModule(), new LobstrModule()], + }); + kitRef.current = newKit; + + const attemptSilentReconnect = async () => { + const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY); + if (!lastWalletName) { + setIsInitialized(true); + return; + } + + setWalletName(lastWalletName); + setIsConnecting(true); + + try { + newKit.setWallet(lastWalletName); + const account = await newKit.getAddress(); + if (account?.address) { + setAddress(account.address); + notifyWalletEvent( + 'reconnected', + `${account.address.slice(0, 6)}...${account.address.slice(-4)} via ${lastWalletName}` + ); + } + } catch { + // Silent reconnection should not block app flow. + } finally { + setIsConnecting(false); + setIsInitialized(true); + } + }; + + void attemptSilentReconnect(); + }, [notifyWalletEvent, network]); + + const loadWalletOptions = useCallback(async (): Promise => { + const kit = kitRef.current; + if (!kit) return []; + const supported = await kit.getSupportedWallets(); + const options = supported + .filter((wallet) => + SUPPORTED_MODAL_WALLETS.includes(wallet.id as (typeof SUPPORTED_MODAL_WALLETS)[number]) + ) + .map((wallet) => ({ + id: wallet.id, + name: wallet.name, + icon: wallet.icon, + isAvailable: wallet.isAvailable, + })); + setWalletOptions(options); + setWalletExtensionAvailable(options.some((wallet) => wallet.isAvailable)); + return options; + }, []); + + const connectWithWallet = useCallback( + async (selectedWalletId: string): Promise => { + const kit = kitRef.current; + if (!kit) return null; + + setIsConnecting(true); + try { + kit.setWallet(selectedWalletId); + + let timeoutId: ReturnType; + const timeoutPromise = new Promise<{ address: string }>((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Connection timed out after 15 seconds.')), + 15000 + ); + }); + + const { address: newAddress } = await Promise.race([kit.getAddress(), timeoutPromise]); + clearTimeout(timeoutId!); + + setAddress(newAddress); + setWalletName(selectedWalletId); + localStorage.setItem(LAST_WALLET_STORAGE_KEY, selectedWalletId); + notifyWalletEvent( + 'connected', + `${newAddress.slice(0, 6)}...${newAddress.slice(-4)} via ${selectedWalletId}` + ); + return newAddress; + } catch (error) { + console.error('Failed to connect wallet:', error); + notifyWalletEvent( + 'connection_failed', + error instanceof Error ? error.message : 'Please try again.' + ); + return null; + } finally { + setIsConnecting(false); + setWalletModalOpen(false); + } + }, + [notifyWalletEvent] + ); + + const connect = useCallback(async (): Promise => { + const options = await loadWalletOptions(); + if (options.length === 0) { + notifyWalletEvent('connection_failed', 'No supported wallet providers were found.'); + return null; + } + setWalletModalOpen(true); + return null; + }, [loadWalletOptions, notifyWalletEvent]); + + const requireWallet = useCallback(async (): Promise => { + if (address) return address; + notifyWalletEvent('required', 'Connect your wallet to continue with this contract action.'); + return connect(); + }, [address, connect, notifyWalletEvent]); + + const disconnect = useCallback(() => { + const kit = kitRef.current; + if (kit) { + void kit.disconnect(); + } + setAddress(null); + setWalletName(null); + localStorage.removeItem(LAST_WALLET_STORAGE_KEY); + notifyWalletEvent('disconnected'); + }, [notifyWalletEvent]); + + const signTransaction = useCallback(async (xdr: string) => { + const kit = kitRef.current; + if (!kit) throw new Error('Wallet kit not initialized'); + const result = await kit.signTransaction(xdr); + return result.signedTxXdr; + }, []); + + return { + address, + walletName, + network, + setNetwork, + isConnecting, + isInitialized, + walletExtensionAvailable, + connect, + requireWallet, + disconnect, + signTransaction, + walletModalOpen, + setWalletModalOpen, + walletOptions, + connectWithWallet, + }; +} diff --git a/frontend/src/providers/WalletProvider.tsx b/frontend/src/providers/WalletProvider.tsx index a93fb8b..2115f5a 100644 --- a/frontend/src/providers/WalletProvider.tsx +++ b/frontend/src/providers/WalletProvider.tsx @@ -1,205 +1,37 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { - StellarWalletsKit, - WalletNetwork, - FreighterModule, - FREIGHTER_ID, - xBullModule, - LobstrModule, - LOBSTR_ID, -} from '@creit.tech/stellar-wallets-kit'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNotification } from '../hooks/useNotification'; import { WalletContext } from '../hooks/useWallet'; +import { useWalletManager } from '../hooks/useWalletManager'; -const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name'; -const SUPPORTED_MODAL_WALLETS = [FREIGHTER_ID, LOBSTR_ID] as const; -const WALLET_CONNECTION_TIMEOUT_MS = 15000; -const WALLET_CONNECTION_TIMEOUT_MESSAGE = - 'Wallet connection timed out after 15 seconds. Confirm the request in your wallet and try again.'; - -type SelectableWallet = { - id: string; - name: string; - icon?: string; - isAvailable: boolean; -}; - -function withWalletConnectionTimeout(promise: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timeoutId = window.setTimeout(() => { - reject(new Error(WALLET_CONNECTION_TIMEOUT_MESSAGE)); - }, timeoutMs); - - promise.then( - (value) => { - window.clearTimeout(timeoutId); - resolve(value); - }, - (error: unknown) => { - window.clearTimeout(timeoutId); - reject(error instanceof Error ? error : new Error(String(error))); - } - ); - }); -} - -function hasAnyWalletExtension(): boolean { - if (typeof window === 'undefined') return true; - const extendedWindow = window as Window & - typeof globalThis & { - freighterApi?: unknown; - xBullSDK?: unknown; - lobstr?: unknown; - }; - - return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr); -} - -export const WalletProvider: React.FC<{ - children: React.ReactNode; - connectionTimeoutMs?: number; -}> = ({ children, connectionTimeoutMs = WALLET_CONNECTION_TIMEOUT_MS }) => { - const [address, setAddress] = useState(null); - const [walletName, setWalletName] = useState(null); - const [isConnecting, setIsConnecting] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const [walletExtensionAvailable, setWalletExtensionAvailable] = useState(true); - const [network, setNetwork] = useState<'TESTNET' | 'PUBLIC'>('TESTNET'); - const [walletModalOpen, setWalletModalOpen] = useState(false); - const [walletOptions, setWalletOptions] = useState([]); - const [connectionError, setConnectionError] = useState(null); - const kitRef = useRef(null); +export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { t } = useTranslation(); - const { notifyWalletEvent } = useNotification(); - - useEffect(() => { - setWalletExtensionAvailable(hasAnyWalletExtension()); - - const newKit = new StellarWalletsKit({ - network: network === 'TESTNET' ? WalletNetwork.TESTNET : WalletNetwork.PUBLIC, - modules: [new FreighterModule(), new xBullModule(), new LobstrModule()], - }); - kitRef.current = newKit; - - const attemptSilentReconnect = async () => { - const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY); - if (!lastWalletName) { - setIsInitialized(true); - return; - } - - setWalletName(lastWalletName); - setIsConnecting(true); - - try { - newKit.setWallet(lastWalletName); - const account = await withWalletConnectionTimeout(newKit.getAddress(), connectionTimeoutMs); - if (account?.address) { - setAddress(account.address); - notifyWalletEvent( - 'reconnected', - `${account.address.slice(0, 6)}...${account.address.slice(-4)} via ${lastWalletName}` - ); - } - } catch { - // Silent reconnection should not block app flow. - } finally { - setIsConnecting(false); - setIsInitialized(true); - } - }; - - void attemptSilentReconnect(); - }, [connectionTimeoutMs, notifyWalletEvent, network]); - - const loadWalletOptions = async (): Promise => { - const kit = kitRef.current; - if (!kit) return []; - const supported = await kit.getSupportedWallets(); - const options = supported - .filter((wallet) => - SUPPORTED_MODAL_WALLETS.includes(wallet.id as (typeof SUPPORTED_MODAL_WALLETS)[number]) - ) - .map((wallet) => ({ - id: wallet.id, - name: wallet.name, - icon: wallet.icon, - isAvailable: wallet.isAvailable, - })); - setWalletOptions(options); - setWalletExtensionAvailable(options.some((wallet) => wallet.isAvailable)); - return options; - }; - - const connectWithWallet = async (selectedWalletId: string): Promise => { - const kit = kitRef.current; - if (!kit) return null; - - setConnectionError(null); - setIsConnecting(true); - try { - kit.setWallet(selectedWalletId); - const { address } = await withWalletConnectionTimeout(kit.getAddress(), connectionTimeoutMs); - - setAddress(address); - setWalletName(selectedWalletId); - localStorage.setItem(LAST_WALLET_STORAGE_KEY, selectedWalletId); - setConnectionError(null); - setWalletModalOpen(false); - notifyWalletEvent( - 'connected', - `${address.slice(0, 6)}...${address.slice(-4)} via ${selectedWalletId}` - ); - return address; - } catch (error) { - console.error('Failed to connect wallet:', error); - const message = - error instanceof Error ? error.message : 'Unable to connect to the selected wallet.'; - setConnectionError(message); - notifyWalletEvent( - 'connection_failed', - message - ); - return null; - } finally { - setIsConnecting(false); - } - }; - - const connect = async (): Promise => { - const options = await loadWalletOptions(); - if (options.length === 0) { - notifyWalletEvent('connection_failed', 'No supported wallet providers were found.'); - return null; - } + const [connectionError, setConnectionError] = useState(null); + const walletManager = useWalletManager(); + const { + address, + walletName, + network, + setNetwork, + isConnecting, + isInitialized, + walletExtensionAvailable, + connect, + requireWallet, + disconnect, + signTransaction, + walletModalOpen, + setWalletModalOpen, + walletOptions, + connectWithWallet: baseConnectWithWallet, + } = walletManager; + + const connectWithWallet = async (walletId: string) => { setConnectionError(null); - setWalletModalOpen(true); - return null; - }; - - const requireWallet = async (): Promise => { - if (address) return address; - notifyWalletEvent('required', 'Connect your wallet to continue with this contract action.'); - return connect(); - }; - - const disconnect = () => { - const kit = kitRef.current; - if (kit) { - void kit.disconnect(); + const result = await baseConnectWithWallet(walletId); + if (!result) { + setConnectionError('Unable to connect to the selected wallet. Please try again.'); } - setAddress(null); - setWalletName(null); - localStorage.removeItem(LAST_WALLET_STORAGE_KEY); - notifyWalletEvent('disconnected'); - }; - - const signTransaction = async (xdr: string) => { - const kit = kitRef.current; - if (!kit) throw new Error('Wallet kit not initialized'); - const result = await kit.signTransaction(xdr); - return result.signedTxXdr; + return result; }; return ( diff --git a/frontend/src/providers/__tests__/WalletProvider.test.tsx b/frontend/src/providers/__tests__/WalletProvider.test.tsx index 4e6e9ac..6b6837c 100644 --- a/frontend/src/providers/__tests__/WalletProvider.test.tsx +++ b/frontend/src/providers/__tests__/WalletProvider.test.tsx @@ -145,10 +145,7 @@ describe('WalletProvider', () => { }); expect(screen.queryByRole('dialog', { name: /connect to payd/i })).not.toBeInTheDocument(); - expect(mockNotifyWalletEvent).toHaveBeenCalledWith( - 'connected', - 'GABCD1...LLET via freighter' - ); + expect(mockNotifyWalletEvent).toHaveBeenCalledWith('connected', 'GABCD1...LLET via freighter'); }); it('finishes initialization when silent reconnect hangs', async () => { diff --git a/frontend/src/stores/networkStore.ts b/frontend/src/stores/networkStore.ts index 875295c..764749e 100644 --- a/frontend/src/stores/networkStore.ts +++ b/frontend/src/stores/networkStore.ts @@ -4,9 +4,7 @@ import { persist } from 'zustand/middleware'; export type StellarNetwork = 'TESTNET' | 'MAINNET'; function getDefaultNetwork(): StellarNetwork { - const env = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined) - ?.toUpperCase() - ?.trim(); + const env = (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase()?.trim(); return env === 'TESTNET' ? 'TESTNET' : 'MAINNET'; }