diff --git a/apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx b/apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx
new file mode 100644
index 0000000..5fa9c6e
--- /dev/null
+++ b/apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+
+interface CategoryFilterProps {
+ categories: string[];
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function CategoryFilter({ categories, value, onChange }: CategoryFilterProps) {
+ const t = useTranslations('marketplace');
+
+ return (
+
+
+ {categories.map((cat) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx b/apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx
new file mode 100644
index 0000000..bbec58a
--- /dev/null
+++ b/apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+
+interface MarketplaceSearchProps {
+ onChange: (value: string) => void;
+}
+
+export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
+ const t = useTranslations('marketplace');
+ const [value, setValue] = useState('');
+ const timeoutRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => {
+ onChange(value);
+ }, 300);
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, [value, onChange]);
+
+ return (
+
+
setValue(e.target.value)}
+ placeholder={t('searchPlaceholder')}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+ {value && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx b/apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx
new file mode 100644
index 0000000..33565ab
--- /dev/null
+++ b/apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+
+export type StatusValue = 'all' | 'active' | 'available';
+
+interface StatusFilterProps {
+ value: StatusValue;
+ counts: { all: number; active: number; available: number };
+ onChange: (value: StatusValue) => void;
+}
+
+const tabs: { key: StatusValue; label: string }[] = [
+ { key: 'all', label: 'statusAll' },
+ { key: 'active', label: 'statusActive' },
+ { key: 'available', label: 'statusAvailable' },
+];
+
+export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
+ const t = useTranslations('marketplace');
+
+ return (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/app/(portal)/marketplace/components/Toast.tsx b/apps/web/src/app/(portal)/marketplace/components/Toast.tsx
new file mode 100644
index 0000000..5246f7e
--- /dev/null
+++ b/apps/web/src/app/(portal)/marketplace/components/Toast.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { create } from 'zustand';
+
+interface Toast {
+ id: string;
+ type: 'success' | 'error';
+ message: string;
+}
+
+interface ToastState {
+ toasts: Toast[];
+ addToast: (type: 'success' | 'error', message: string) => void;
+ removeToast: (id: string) => void;
+}
+
+export const useToastStore = create()((set) => ({
+ toasts: [],
+ addToast: (type, message) => {
+ const id = crypto.randomUUID();
+ set((state) => ({ toasts: [...state.toasts, { id, type, message }] }));
+ setTimeout(() => {
+ set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
+ }, 4000);
+ },
+ removeToast: (id) => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
+}));
+
+export function ToastContainer() {
+ const toasts = useToastStore((s) => s.toasts);
+ const removeToast = useToastStore((s) => s.removeToast);
+
+ if (toasts.length === 0) return null;
+
+ return (
+
+ {toasts.map((toast) => (
+
+ {toast.type === 'success' ? (
+
+ ) : (
+
+ )}
+
{toast.message}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
new file mode 100644
index 0000000..f46be29
--- /dev/null
+++ b/apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx
@@ -0,0 +1,237 @@
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, params?: Record) => {
+ const translations: Record = {
+ title: 'Marktplatz',
+ subtitle: 'Entdecken und aktivieren Sie Module fuer Ihren Mandanten',
+ activate: 'Modul aktivieren',
+ deactivate: 'Deaktivieren',
+ searchPlaceholder: 'Module suchen...',
+ statusAll: 'Alle',
+ statusActive: 'Aktiviert',
+ statusAvailable: 'Verfuegbar',
+ categoryAll: 'Alle',
+ emptyTitle: 'Noch keine Module verfuegbar',
+ emptyBody: 'Module werden vom Systemadministrator bereitgestellt.',
+ filteredEmptyTitle: 'Keine Ergebnisse',
+ filteredEmptyBody: 'Versuchen Sie einen anderen Suchbegriff oder aendern Sie die Filter.',
+ errorLoad: 'Module konnten nicht geladen werden.',
+ toastActivated: 'Modul erfolgreich aktiviert',
+ accessDenied: 'Zugriff verweigert',
+ tenantContextLabel: 'Mandanten-Kontext',
+ };
+ let result = translations[key] ?? key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ result = result.replace(`{${k}}`, v);
+ }
+ }
+ return result;
+ },
+}));
+
+const mockAuthStore = vi.fn();
+vi.mock('@/lib/stores/auth-store', () => ({
+ useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
+}));
+
+vi.mock('@/lib/stores/marketplace-store', () => ({
+ useMarketplaceStore: () => ({
+ sidebarRefreshKey: 0,
+ bumpSidebarRefresh: vi.fn(),
+ selectedTenantId: null,
+ setSelectedTenantId: vi.fn(),
+ }),
+}));
+
+const mockModules = [
+ {
+ id: 'mod-1',
+ slug: 'domaincheck',
+ name: 'Domaincheck',
+ version: '1.0.0',
+ category: 'Domain-Tools',
+ description: { de: 'Domain pruefen', en: 'Check domain' },
+ icon: 'Globe',
+ isSystem: false,
+ },
+ {
+ id: 'mod-2',
+ slug: 'converter',
+ name: 'Converter',
+ version: '1.0.0',
+ category: 'Utilities',
+ description: { de: 'Dateien konvertieren', en: 'Convert files' },
+ icon: null,
+ isSystem: false,
+ },
+ {
+ id: 'mod-3',
+ slug: 'email-tool',
+ name: 'Email Tool',
+ version: '1.0.0',
+ category: 'Domain-Tools',
+ description: { de: 'E-Mails verwalten', en: 'Manage emails' },
+ icon: null,
+ isSystem: false,
+ },
+];
+
+const mockActiveModules = [
+ mockModules[0], // Domaincheck is active
+];
+
+function stubFetch() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn((url: string) => {
+ if (url.includes('/modules/active')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
+ }
+ if (url.includes('/modules')) {
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
+ }
+ return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
+ }),
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+async function importPage() {
+ const mod = await import('./page');
+ return mod.default;
+}
+
+describe('Marketplace Filters', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockAuthStore.mockImplementation(
+ (selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
+ selector({
+ user: { id: 'u1', username: 'admin', displayName: 'Admin', role: 'ADMIN', tenantId: 't1' },
+ }),
+ );
+ stubFetch();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('typing in search filters the grid to only matching modules after debounce', async () => {
+ const Page = await importPage();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+ expect(screen.getByText('Converter')).toBeInTheDocument();
+ expect(screen.getByText('Email Tool')).toBeInTheDocument();
+
+ const searchInput = screen.getByPlaceholderText('Module suchen...');
+ await userEvent.type(searchInput, 'Domain');
+
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Converter')).not.toBeInTheDocument();
+ });
+
+ it('selecting the Aktiviert status tab shows only activated modules', async () => {
+ const Page = await importPage();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ const activeTab = screen.getByRole('tab', { name: /Aktiviert/i });
+ await userEvent.click(activeTab);
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ expect(screen.queryByText('Converter')).not.toBeInTheDocument();
+ expect(screen.queryByText('Email Tool')).not.toBeInTheDocument();
+ });
+ });
+
+ it('selecting a category chip shows only modules of that category; Alle shows all', async () => {
+ const Page = await importPage();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ const utilitiesChip = screen.getByRole('button', { name: 'Utilities' });
+ await userEvent.click(utilitiesChip);
+
+ await waitFor(() => {
+ expect(screen.getByText('Converter')).toBeInTheDocument();
+ expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
+ });
+
+ const allChip = screen.getByRole('button', { name: 'Alle' });
+ await userEvent.click(allChip);
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ expect(screen.getByText('Converter')).toBeInTheDocument();
+ });
+ });
+
+ it('search + status + category filters compose together (AND)', async () => {
+ const Page = await importPage();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ // Filter to Domain-Tools category
+ const domainChip = screen.getByRole('button', { name: 'Domain-Tools' });
+ await userEvent.click(domainChip);
+
+ // Filter to available only
+ const availableTab = screen.getByRole('tab', { name: /Verfuegbar/i });
+ await userEvent.click(availableTab);
+
+ // Domain-Tools + Available = only Email Tool (Domaincheck is active)
+ await waitFor(() => {
+ expect(screen.getByText('Email Tool')).toBeInTheDocument();
+ expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
+ expect(screen.queryByText('Converter')).not.toBeInTheDocument();
+ });
+ });
+
+ it('renders filtered-empty heading when filters produce zero results', async () => {
+ const Page = await importPage();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Domaincheck')).toBeInTheDocument();
+ });
+
+ const searchInput = screen.getByPlaceholderText('Module suchen...');
+ await userEvent.type(searchInput, 'zzz-nonexistent');
+
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Keine Ergebnisse')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/src/app/(portal)/marketplace/page.tsx b/apps/web/src/app/(portal)/marketplace/page.tsx
index b124197..c81b008 100644
--- a/apps/web/src/app/(portal)/marketplace/page.tsx
+++ b/apps/web/src/app/(portal)/marketplace/page.tsx
@@ -1,10 +1,14 @@
'use client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { MarketplaceCard } from './components/MarketplaceCard';
+import { MarketplaceSearch } from './components/MarketplaceSearch';
+import { StatusFilter, type StatusValue } from './components/StatusFilter';
+import { CategoryFilter } from './components/CategoryFilter';
+import { ToastContainer, useToastStore } from './components/Toast';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -23,6 +27,7 @@ export default function MarketplacePage() {
const t = useTranslations('marketplace');
const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
+ const addToast = useToastStore((s) => s.addToast);
const [modules, setModules] = useState([]);
const [activations, setActivations] = useState