diff --git a/apps/web/src/components/layout/sidebar-search.tsx b/apps/web/src/components/layout/sidebar-search.tsx
new file mode 100644
index 0000000..0582e9f
--- /dev/null
+++ b/apps/web/src/components/layout/sidebar-search.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+
+interface SidebarSearchProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function SidebarSearch({ value, onChange }: SidebarSearchProps) {
+ const t = useTranslations('sidebar');
+
+ return (
+
+ onChange(e.target.value)}
+ placeholder={t('search')}
+ aria-label={t('search')}
+ className="flex h-9 w-full rounded-md border border-border bg-background px-3 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
+ />
+
+ );
+}
diff --git a/apps/web/src/components/layout/sidebar.test.tsx b/apps/web/src/components/layout/sidebar.test.tsx
new file mode 100644
index 0000000..a04d556
--- /dev/null
+++ b/apps/web/src/components/layout/sidebar.test.tsx
@@ -0,0 +1,173 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+let mockPathname = '/';
+vi.mock('next/navigation', () => ({
+ usePathname: () => mockPathname,
+}));
+
+vi.mock('next/link', () => ({
+ default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock('next-intl', () => ({
+ useTranslations: (ns: string) => (key: string) => {
+ const map: Record> = {
+ sidebar: {
+ dashboard: 'Dashboard',
+ marketplace: 'Marktplatz',
+ collapse: 'Einklappen',
+ expand: 'Ausklappen',
+ search: 'Module suchen...',
+ noResults: 'Keine Module gefunden',
+ 'categories.label': 'Kategorien',
+ 'categories.empty': 'Keine Module',
+ admin: 'Verwaltung',
+ users: 'Benutzer',
+ tenants: 'Mandanten',
+ modules: 'Module',
+ ldap: 'LDAP',
+ },
+ common: {
+ appName: 'Tessera',
+ },
+ };
+ return map[ns]?.[key] ?? key;
+ },
+}));
+
+vi.mock('@/lib/stores/sidebar-store', () => ({
+ useSidebarStore: () => ({
+ isCollapsed: false,
+ isMobileOpen: false,
+ toggle: vi.fn(),
+ setMobileOpen: vi.fn(),
+ }),
+}));
+
+const mockAuthStore = vi.fn();
+vi.mock('@/lib/stores/auth-store', () => ({
+ useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
+}));
+
+let mockRefreshKey = 0;
+vi.mock('@/lib/stores/marketplace-store', () => ({
+ useMarketplaceStore: (selector: (state: { sidebarRefreshKey: number }) => unknown) =>
+ selector({ sidebarRefreshKey: mockRefreshKey }),
+}));
+
+vi.mock('@/components/layout/sidebar-footer', () => ({
+ SidebarFooter: () => ,
+}));
+
+const mockActiveModules = [
+ { id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
+ { id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
+];
+
+function stubFetch() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() =>
+ Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
+ ),
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ mockPathname = '/';
+ mockRefreshKey = 0;
+});
+
+async function importSidebar() {
+ const mod = await import('./sidebar');
+ return mod.Sidebar;
+}
+
+describe('Sidebar', () => {
+ beforeEach(() => {
+ mockAuthStore.mockImplementation(
+ (selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
+ selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }),
+ );
+ stubFetch();
+ });
+
+ it('renders Dashboard and Marketplace as Link elements with correct hrefs', async () => {
+ const Sidebar = await importSidebar();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Dashboard')).toBeInTheDocument();
+ });
+
+ const dashLink = screen.getByText('Dashboard').closest('a');
+ const marketLink = screen.getByText('Marktplatz').closest('a');
+ expect(dashLink).toHaveAttribute('href', '/');
+ expect(marketLink).toHaveAttribute('href', '/marketplace');
+ });
+
+ it('highlights Marketplace link when pathname is /marketplace', async () => {
+ mockPathname = '/marketplace';
+ const Sidebar = await importSidebar();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Dashboard')).toBeInTheDocument();
+ });
+
+ const dashLink = screen.getByText('Dashboard').closest('a');
+ const marketLink = screen.getByText('Marktplatz').closest('a');
+ expect(marketLink?.className).toContain('bg-sidebar-accent');
+ expect(dashLink?.className).not.toContain('bg-sidebar-accent');
+ });
+
+ it('renders search input with correct placeholder', async () => {
+ const Sidebar = await importSidebar();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument();
+ });
+ });
+
+ it('shows noResults text when search matches no modules', async () => {
+ const Sidebar = await importSidebar();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ const searchInput = screen.getByPlaceholderText('Module suchen...');
+ await userEvent.type(searchInput, 'zzz-nothing');
+
+ await waitFor(() => {
+ expect(screen.getByText('Keine Module gefunden')).toBeInTheDocument();
+ });
+ });
+
+ it('re-fetches modules when sidebarRefreshKey changes', async () => {
+ const Sidebar = await importSidebar();
+ const { rerender } = render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ const initialCallCount = (globalThis.fetch as ReturnType).mock.calls.length;
+
+ mockRefreshKey = 1;
+ rerender();
+
+ await waitFor(() => {
+ const newCallCount = (globalThis.fetch as ReturnType).mock.calls.length;
+ expect(newCallCount).toBeGreaterThan(initialCallCount);
+ });
+ });
+});
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx
index 8e6aea4..da26556 100644
--- a/apps/web/src/components/layout/sidebar.tsx
+++ b/apps/web/src/components/layout/sidebar.tsx
@@ -1,10 +1,14 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
+import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarFooter } from '@/components/layout/sidebar-footer';
+import { SidebarSearch } from '@/components/layout/sidebar-search';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -21,7 +25,22 @@ export function Sidebar() {
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [categoriesOpen, setCategoriesOpen] = useState(true);
const [activeModules, setActiveModules] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
const user = useAuthStore((s) => s.user);
+ const pathname = usePathname();
+ const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
+
+ const isActive = (href: string) => {
+ if (href === '/') return pathname === '/';
+ return pathname.startsWith(href);
+ };
+
+ const linkClass = (href: string) =>
+ `flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
+ isActive(href)
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
+ : 'text-sidebar-foreground hover:bg-muted'
+ }`;
const fetchActiveModules = useCallback(async () => {
try {
@@ -38,144 +57,131 @@ export function Sidebar() {
useEffect(() => {
fetchActiveModules();
- }, [fetchActiveModules]);
+ }, [fetchActiveModules, sidebarRefreshKey]);
- // Admin section visibility based on role
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
+ const filteredCategories = useMemo(() => {
+ const categories = new Map();
+ for (const mod of activeModules) {
+ const cat = mod.category || 'other';
+ if (!categories.has(cat)) categories.set(cat, []);
+ categories.get(cat)!.push(mod);
+ }
+
+ if (!searchQuery) return Array.from(categories.entries());
+
+ const q = searchQuery.toLowerCase();
+ const result: [string, SidebarModule[]][] = [];
+ for (const [cat, mods] of categories.entries()) {
+ if (cat.toLowerCase().includes(q)) {
+ result.push([cat, mods]);
+ } else {
+ const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
+ if (filtered.length > 0) result.push([cat, filtered]);
+ }
+ }
+ return result;
+ }, [activeModules, searchQuery]);
+
const sidebarContent = (
- {/* Navigation items */}
)}
- {/* Collapse/Expand toggle */}
- {/* Sidebar footer (D-06) */}
);
return (
<>
- {/* Desktop sidebar */}