diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index b2e3306..5b16e98 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -5,6 +5,13 @@ import AppNav from './AppNav'; import { LanguageSelector } from './LanguageSelector'; import { ThemeToggle } from './ThemeToggle'; import { useTranslation } from 'react-i18next'; +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_ENV = import.meta.env.MODE; // ── Page Wrapper ─────────────────────── const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -15,6 +22,7 @@ const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( const AppLayout: React.FC = () => { const location = useLocation(); useTranslation(); + const { network } = useNetworkStore(); return (
{
+ @@ -57,6 +66,7 @@ const AppLayout: React.FC = () => {
+
@@ -78,9 +88,32 @@ const AppLayout: React.FC = () => { Apache License 2.0 -
-
- STELLAR NETWORK · MAINNET +
+ + v{APP_VERSION} + + + {APP_ENV === 'production' ? 'production' : APP_ENV === 'staging' ? 'staging' : 'dev'} + +
+
+ STELLAR · {network} +
diff --git a/frontend/src/components/Breadcrumb.tsx b/frontend/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..3472fa2 --- /dev/null +++ b/frontend/src/components/Breadcrumb.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { ChevronRight } from 'lucide-react'; + +const ROUTE_LABELS: Record = { + employer: 'Employer', + payroll: 'Payroll', + employee: 'Employees', + analytics: 'Analytics', + reports: 'Reports', + 'bulk-upload': 'Bulk Upload', + 'cross-asset-payment': 'Cross-Asset Payment', + transactions: 'Transactions', + 'revenue-split': 'Revenue Split', + settings: 'Settings', + help: 'Help Center', + debug: 'Debugger', + admin: 'Admin', + portal: 'Employee Portal', + rewards: 'Rewards', +}; + +const EXCLUDED_PREFIXES = ['/login', '/auth-callback']; + +interface Crumb { + label: string; + href: string; +} + +export function buildCrumbs(pathname: string): Crumb[] { + const segments = pathname.split('/').filter(Boolean); + const crumbs: Crumb[] = [{ label: 'Home', href: '/' }]; + + let accumulated = ''; + for (const segment of segments) { + accumulated += `/${segment}`; + const label = + ROUTE_LABELS[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1); + crumbs.push({ label, href: accumulated }); + } + + return crumbs; +} + +export const Breadcrumb: React.FC = () => { + const { pathname } = useLocation(); + + if (EXCLUDED_PREFIXES.some((p) => pathname.startsWith(p))) return null; + + const crumbs = buildCrumbs(pathname); + + if (crumbs.length <= 1) return null; + + return ( + + ); +}; diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index 72ff3df..77d0078 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -197,7 +197,10 @@ export const EmployeeList: React.FC = ({ ) : ( sortedEmployees.map((employee) => ( - +
{ {ORG_NAME} - - Employer dashboard - +
@@ -179,6 +179,7 @@ const EmployerLayout: React.FC = () => { {!address ? 'Connect wallet' : balanceLoading ? '…' : formatXlm(xlmBalance ?? null)}
+ diff --git a/frontend/src/components/NetworkSwitcher.tsx b/frontend/src/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..85d3d62 --- /dev/null +++ b/frontend/src/components/NetworkSwitcher.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useNetworkStore, type StellarNetwork } from '../stores/networkStore'; + +export const NetworkSwitcher: React.FC = () => { + const { network, setNetwork } = useNetworkStore(); + + const handleChange = (e: React.ChangeEvent) => { + setNetwork(e.target.value as StellarNetwork); + }; + + const isTestnet = network === 'TESTNET'; + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/components/__tests__/AppLayoutFooter.test.tsx b/frontend/src/components/__tests__/AppLayoutFooter.test.tsx new file mode 100644 index 0000000..3701818 --- /dev/null +++ b/frontend/src/components/__tests__/AppLayoutFooter.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import AppLayout from '../AppLayout'; + +// Stub out all heavy dependencies so we can test just the footer markup +vi.mock('../ConnectAccount', () => ({ default: () =>
Connect
})); +vi.mock('./AppNav', () => ({ default: () => null })); +vi.mock('../AppNav', () => ({ default: () => null })); +vi.mock('../LanguageSelector', () => ({ LanguageSelector: () => null })); +vi.mock('../ThemeToggle', () => ({ ThemeToggle: () => null })); +vi.mock('../NetworkSwitcher', () => ({ NetworkSwitcher: () => null })); +vi.mock('../Breadcrumb', () => ({ Breadcrumb: () => null })); +vi.mock('../../stores/networkStore', () => ({ + useNetworkStore: () => ({ network: 'MAINNET', setNetwork: vi.fn() }), +})); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k, i18n: { language: 'en' } }), +})); + +describe('AppLayout footer', () => { + test('renders a version badge starting with "v"', () => { + render( + + + + ); + + // The version badge text matches /^v\d+\.\d+\.\d+/ (e.g. "v0.0.1") + const badge = screen.getByLabelText(/app version/i); + expect(badge.textContent).toMatch(/^v\d/); + }); + + test('renders an environment badge', () => { + render( + + + + ); + + const envBadge = screen.getByLabelText(/environment/i); + expect(envBadge).toBeInTheDocument(); + // In test mode, MODE is 'test' which resolves to 'dev' label + expect(envBadge.textContent).toBeTruthy(); + }); + + test('renders Stellar network indicator with current network', () => { + render( + + + + ); + + const networkIndicator = screen.getByLabelText(/connected to stellar/i); + expect(networkIndicator).toBeInTheDocument(); + expect(networkIndicator.textContent).toContain('MAINNET'); + }); + + test('renders the Apache License link', () => { + render( + + + + ); + + expect(screen.getByRole('link', { name: /apache license/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/Breadcrumb.test.tsx b/frontend/src/components/__tests__/Breadcrumb.test.tsx new file mode 100644 index 0000000..a473620 --- /dev/null +++ b/frontend/src/components/__tests__/Breadcrumb.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; +import { Breadcrumb, buildCrumbs } from '../Breadcrumb'; + +// ── pure-function unit tests (no DOM needed) ────────────────────────────── + +describe('buildCrumbs', () => { + test('returns only Home for root path', () => { + const crumbs = buildCrumbs('/'); + expect(crumbs).toHaveLength(1); + expect(crumbs[0]).toEqual({ label: 'Home', href: '/' }); + }); + + test('builds two crumbs for a single-segment path', () => { + const crumbs = buildCrumbs('/settings'); + expect(crumbs).toHaveLength(2); + expect(crumbs[1]).toEqual({ label: 'Settings', href: '/settings' }); + }); + + test('builds three crumbs for nested employer route', () => { + const crumbs = buildCrumbs('/employer/payroll'); + expect(crumbs).toHaveLength(3); + expect(crumbs[1]).toEqual({ label: 'Employer', href: '/employer' }); + expect(crumbs[2]).toEqual({ label: 'Payroll', href: '/employer/payroll' }); + }); + + test('uses slug as label for unknown segments', () => { + const crumbs = buildCrumbs('/unknown-page'); + expect(crumbs[1].label).toBe('Unknown-page'); + }); + + test('maps all known route labels correctly', () => { + const cases: Array<[string, string]> = [ + ['/employer/employee', 'Employees'], + ['/employer/analytics', 'Analytics'], + ['/employer/bulk-upload', 'Bulk Upload'], + ['/employer/cross-asset-payment', 'Cross-Asset Payment'], + ['/employer/transactions', 'Transactions'], + ['/employer/revenue-split', 'Revenue Split'], + ['/employer/reports', 'Reports'], + ['/help', 'Help Center'], + ]; + for (const [path, label] of cases) { + const crumbs = buildCrumbs(path); + expect(crumbs[crumbs.length - 1].label).toBe(label); + } + }); +}); + +// ── component rendering tests ───────────────────────────────────────────── + +describe('Breadcrumb component', () => { + test('renders nothing on root path', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + test('renders nothing on excluded login path', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + test('renders breadcrumb nav for /settings', () => { + render( + + + + ); + expect(screen.getByRole('navigation', { name: /breadcrumb/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /home/i })).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + test('current page segment has aria-current="page"', () => { + render( + + + + ); + const current = screen.getByText('Settings'); + expect(current).toHaveAttribute('aria-current', 'page'); + }); + + test('renders correct links for /employer/payroll', () => { + render( + + + + ); + expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute('href', '/'); + 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 new file mode 100644 index 0000000..c2b0bb0 --- /dev/null +++ b/frontend/src/components/__tests__/EmployeeListHover.test.tsx @@ -0,0 +1,48 @@ +import { render } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { EmployeeList } from '../EmployeeList'; + +vi.mock('../Avatar', () => ({ + Avatar: ({ name }: { name: string }) =>
{name}
, +})); +vi.mock('../AvatarUpload', () => ({ AvatarUpload: () => null })); +vi.mock('../CSVUploader', () => ({ CSVUploader: () => null })); +vi.mock('../EmployeeRemovalConfirmModal', () => ({ + EmployeeRemovalConfirmModal: () => null, +})); + +const employee = { + id: 'emp-hover-1', + name: 'Jane Doe', + email: 'jane@example.com', + position: 'Engineer', + wallet: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDE', + salary: 5000, + status: 'Active' as const, +}; + +describe('EmployeeList row hover effects', () => { + test('data rows include hover background class', () => { + const { container } = render( + + ); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBeGreaterThan(0); + + rows.forEach((row) => { + expect(row.className).toContain('hover:bg-white/5'); + }); + }); + + test('data rows include transition class for smooth hover animation', () => { + const { container } = render( + + ); + + const rows = container.querySelectorAll('tbody tr'); + rows.forEach((row) => { + expect(row.className).toMatch(/transition/); + }); + }); +}); diff --git a/frontend/src/components/__tests__/NetworkSwitcher.test.tsx b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx new file mode 100644 index 0000000..e68dc46 --- /dev/null +++ b/frontend/src/components/__tests__/NetworkSwitcher.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { NetworkSwitcher } from '../NetworkSwitcher'; + +// Use a closure variable so we can change the mocked network between tests +// without hitting Zustand's persist middleware (which requires localStorage). +const mockSetNetwork = vi.fn(); +let mockedNetwork: 'MAINNET' | 'TESTNET' = 'MAINNET'; + +vi.mock('../../stores/networkStore', () => ({ + useNetworkStore: () => ({ + get network() { + return mockedNetwork; + }, + setNetwork: mockSetNetwork, + }), +})); + +describe('NetworkSwitcher', () => { + beforeEach(() => { + mockSetNetwork.mockClear(); + mockedNetwork = 'MAINNET'; + }); + + test('renders a select element with an accessible label', () => { + render(); + expect( + screen.getByRole('combobox', { name: /select stellar network/i }) + ).toBeInTheDocument(); + }); + + test('shows MAINNET as the default selected option', () => { + render(); + const select = screen.getByRole('combobox', { + name: /select stellar network/i, + }); + expect(select.value).toBe('MAINNET'); + }); + + test('shows both Testnet and Mainnet options', () => { + render(); + expect(screen.getByRole('option', { name: /testnet/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /mainnet/i })).toBeInTheDocument(); + }); + + test('calls setNetwork with TESTNET when user switches to Testnet', async () => { + const user = userEvent.setup(); + render(); + await user.selectOptions( + screen.getByRole('combobox', { name: /select stellar network/i }), + 'TESTNET' + ); + expect(mockSetNetwork).toHaveBeenCalledOnce(); + expect(mockSetNetwork).toHaveBeenCalledWith('TESTNET'); + }); + + test('reflects TESTNET selection when store returns TESTNET', () => { + mockedNetwork = 'TESTNET'; + render(); + const select = screen.getByRole('combobox', { + name: /select stellar network/i, + }); + expect(select.value).toBe('TESTNET'); + }); + + test('wraps select in a group with an accessible label', () => { + render(); + expect( + screen.getByRole('group', { name: /stellar network selector/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/stores/networkStore.ts b/frontend/src/stores/networkStore.ts new file mode 100644 index 0000000..875295c --- /dev/null +++ b/frontend/src/stores/networkStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +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(); + return env === 'TESTNET' ? 'TESTNET' : 'MAINNET'; +} + +interface NetworkState { + network: StellarNetwork; + setNetwork: (network: StellarNetwork) => void; +} + +export const useNetworkStore = create()( + persist( + (set) => ({ + network: getDefaultNetwork(), + setNetwork: (network) => set({ network }), + }), + { name: 'payd-network' } + ) +);