Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand All @@ -15,6 +22,7 @@ const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
const AppLayout: React.FC = () => {
const location = useLocation();
useTranslation();
const { network } = useNetworkStore();

return (
<div
Expand Down Expand Up @@ -46,6 +54,7 @@ const AppLayout: React.FC = () => {
<div className="flex items-center gap-6 ml-auto">
<AppNav />
<div className="ml-4 flex items-center gap-3">
<NetworkSwitcher />
<LanguageSelector />
<ThemeToggle />
<ConnectAccount />
Expand All @@ -57,6 +66,7 @@ const AppLayout: React.FC = () => {
<main className="flex flex-col flex-1 pt-(--header-h)">
<PageWrapper>
<div key={location.pathname} className="flex flex-col flex-1 px-6 py-8">
<Breadcrumb />
<Outlet />
</div>
</PageWrapper>
Expand All @@ -78,9 +88,32 @@ const AppLayout: React.FC = () => {
Apache License 2.0
</a>
</span>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-(--accent) shadow-[0_0_6px_var(--accent)]" />
STELLAR NETWORK · MAINNET
<div className="flex items-center gap-2 flex-wrap">
<span
className="px-1.5 py-0.5 rounded border text-[10px] uppercase tracking-widest"
style={{ borderColor: 'var(--border-hi)' }}
aria-label={`App version ${APP_VERSION}`}
>
v{APP_VERSION}
</span>
<span
className={`px-1.5 py-0.5 rounded border text-[10px] uppercase tracking-widest ${
APP_ENV === 'production'
? 'border-green-500/40 text-green-500'
: 'border-yellow-500/40 text-yellow-500'
}`}
aria-label={`Environment: ${APP_ENV}`}
>
{APP_ENV === 'production' ? 'production' : APP_ENV === 'staging' ? 'staging' : 'dev'}
</span>
<div className="flex items-center gap-1.5" aria-label={`Connected to Stellar ${network}`}>
<div
className={`w-1.5 h-1.5 rounded-full shadow-[0_0_6px_var(--accent)] ${
network === 'TESTNET' ? 'bg-yellow-500' : 'bg-(--accent)'
}`}
/>
STELLAR · {network}
</div>
</div>
</footer>
</div>
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';

const ROUTE_LABELS: Record<string, string> = {
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 (
<nav
aria-label="Breadcrumb"
className="flex items-center gap-1 text-xs"
style={{ color: 'var(--muted)' }}
>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<React.Fragment key={crumb.href}>
{i > 0 && (
<ChevronRight className="h-3 w-3 shrink-0 opacity-50" aria-hidden />
)}
{isLast ? (
<span
className="font-medium"
style={{ color: 'var(--text)' }}
aria-current="page"
>
{crumb.label}
</span>
) : (
<Link
to={crumb.href}
className="transition-colors hover:underline"
style={{ color: 'var(--muted)' }}
>
{crumb.label}
</Link>
)}
</React.Fragment>
);
})}
</nav>
);
};
5 changes: 4 additions & 1 deletion frontend/src/components/EmployeeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ export const EmployeeList: React.FC<EmployeeListProps> = ({
</tr>
) : (
sortedEmployees.map((employee) => (
<tr key={employee.id} className="cursor-pointer transition">
<tr
key={employee.id}
className="cursor-pointer transition-colors hover:bg-white/5"
>
<td className="p-6">
<div className="flex items-center gap-3">
<Avatar
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/EmployerLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { LanguageSelector } from './LanguageSelector';
import { ThemeToggle } from './ThemeToggle';
import ErrorBoundary from './ErrorBoundary';
import ErrorFallback from './ErrorFallback';
import { Breadcrumb } from './Breadcrumb';
import { NetworkSwitcher } from './NetworkSwitcher';
import { useNativeXlmBalance } from '../hooks/useNativeXlmBalance';
import { useWallet } from '../hooks/useWallet';

Expand Down Expand Up @@ -156,9 +158,7 @@ const EmployerLayout: React.FC = () => {
<Heading as="h1" size="md" weight="bold" addlClassName="truncate tracking-tight">
{ORG_NAME}
</Heading>
<Text as="p" size="xs" addlClassName="text-[var(--muted)] truncate">
Employer dashboard
</Text>
<Breadcrumb />
</div>
</div>

Expand All @@ -179,6 +179,7 @@ const EmployerLayout: React.FC = () => {
{!address ? 'Connect wallet' : balanceLoading ? '…' : formatXlm(xlmBalance ?? null)}
</span>
</div>
<NetworkSwitcher />
<LanguageSelector />
<ThemeToggle />
<ConnectAccount />
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/NetworkSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSelectElement>) => {
setNetwork(e.target.value as StellarNetwork);
};

const isTestnet = network === 'TESTNET';

return (
<div role="group" aria-label="Stellar network selector">
<select
value={network}
onChange={handleChange}
aria-label="Select Stellar network"
title="Switch Stellar network"
className={[
'text-[10px] font-mono font-bold uppercase tracking-widest',
'px-2 py-1 rounded border bg-transparent cursor-pointer',
'focus:outline-none transition-colors',
isTestnet
? 'border-yellow-500/50 text-yellow-500 hover:border-yellow-400'
: 'border-(--accent)/50 text-(--accent) hover:border-(--accent)',
].join(' ')}
>
<option value="TESTNET" className="bg-(--bg) text-(--text)">
Testnet
</option>
<option value="MAINNET" className="bg-(--bg) text-(--text)">
Mainnet
</option>
</select>
</div>
);
};
68 changes: 68 additions & 0 deletions frontend/src/components/__tests__/AppLayoutFooter.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Connect</div> }));
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(
<MemoryRouter initialEntries={['/']}>
<AppLayout />
</MemoryRouter>
);

// 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(
<MemoryRouter initialEntries={['/']}>
<AppLayout />
</MemoryRouter>
);

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(
<MemoryRouter initialEntries={['/']}>
<AppLayout />
</MemoryRouter>
);

const networkIndicator = screen.getByLabelText(/connected to stellar/i);
expect(networkIndicator).toBeInTheDocument();
expect(networkIndicator.textContent).toContain('MAINNET');
});

test('renders the Apache License link', () => {
render(
<MemoryRouter initialEntries={['/']}>
<AppLayout />
</MemoryRouter>
);

expect(screen.getByRole('link', { name: /apache license/i })).toBeInTheDocument();
});
});
Loading
Loading