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>(new Map()); @@ -30,6 +35,10 @@ export default function MarketplacePage() { const [toggling, setToggling] = useState(null); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); + const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const fetchModules = useCallback(async () => { @@ -72,6 +81,37 @@ export default function MarketplacePage() { } }, [isAdmin, fetchModules]); + const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de'; + + const filteredModules = useMemo(() => { + return modules.filter((mod) => { + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const desc = mod.description[locale] || mod.description.en || mod.description.de || ''; + if (!mod.name.toLowerCase().includes(q) && !desc.toLowerCase().includes(q)) { + return false; + } + } + if (statusFilter === 'active' && !activations.has(mod.id)) return false; + if (statusFilter === 'available' && activations.has(mod.id)) return false; + if (categoryFilter !== 'all' && mod.category !== categoryFilter) return false; + return true; + }); + }, [modules, activations, searchQuery, statusFilter, categoryFilter, locale]); + + const categories = useMemo( + () => [...new Set(modules.map((m) => m.category))].sort(), + [modules], + ); + + const statusCounts = useMemo(() => ({ + all: modules.length, + active: modules.filter((m) => activations.has(m.id)).length, + available: modules.filter((m) => !activations.has(m.id)).length, + }), [modules, activations]); + + const hasFiltersApplied = searchQuery !== '' || statusFilter !== 'all' || categoryFilter !== 'all'; + const toggleModule = async (moduleId: string, currentlyActive: boolean) => { setToggling(moduleId); setError(null); @@ -99,9 +139,11 @@ export default function MarketplacePage() { return next; }); bumpSidebarRefresh(); + addToast('success', currentlyActive ? t('toastDeactivated') : t('toastActivated')); } else { const body = await res.text().catch(() => ''); setError(`${res.status}: ${body}`); + addToast('error', currentlyActive ? t('toastDeactivateError') : t('toastActivateError')); } } catch (err) { setError(String(err)); @@ -157,23 +199,40 @@ export default function MarketplacePage() {

{t('emptyBody')}

) : ( -
- {modules.map((mod) => ( - - ))} -
+ <> +
+ + + +
+ + {filteredModules.length === 0 && hasFiltersApplied ? ( +
+

{t('filteredEmptyTitle')}

+

{t('filteredEmptyBody')}

+
+ ) : ( +
+ {filteredModules.map((mod) => ( + + ))} +
+ )} + )} + + ); }