feat(04-02): add search, status tabs, category chips, toast, and client-side filtering

Build MarketplaceSearch (debounced 300ms), StatusFilter (3 tabs with
counts), CategoryFilter (horizontal chip row), and Toast (Zustand store
+ container). Wire useMemo filtering into page with filtered-empty state.
All 14 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:20:08 +02:00
parent 00a01b5b19
commit 06fb52da82
6 changed files with 514 additions and 17 deletions
@@ -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 (
<div className="flex gap-2 overflow-x-auto pb-1">
<button
role="button"
onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
>
{t('categoryAll')}
</button>
{categories.map((cat) => (
<button
key={cat}
role="button"
onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === cat
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/80'
}`}
>
{cat}
</button>
))}
</div>
);
}
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="relative">
<input
type="text"
value={value}
onChange={(e) => 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 && (
<button
onClick={() => setValue('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
);
}
@@ -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 (
<div className="flex gap-1 border-b border-border" role="tablist">
{tabs.map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={value === tab.key}
onClick={() => onChange(tab.key)}
className={`px-3 py-2 text-sm font-medium transition-colors ${
value === tab.key
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(tab.label)}
<span className="ml-1.5 text-xs text-muted-foreground">
{counts[tab.key]}
</span>
</button>
))}
</div>
);
}
@@ -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<ToastState>()((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 (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
>
{toast.type === 'success' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-destructive shrink-0">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)}
<span className="text-sm text-foreground">{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
))}
</div>
);
}
@@ -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<string, string>) => {
const translations: Record<string, string> = {
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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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();
});
});
});
+76 -17
View File
@@ -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<Module[]>([]);
const [activations, setActivations] = useState<Map<string, boolean>>(new Map());
@@ -30,6 +35,10 @@ export default function MarketplacePage() {
const [toggling, setToggling] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusValue>('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() {
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{modules.map((mod) => (
<MarketplaceCard
key={mod.id}
id={mod.id}
name={mod.name}
slug={mod.slug}
description={mod.description}
icon={mod.icon ?? undefined}
category={mod.category}
isActive={activations.has(mod.id)}
isToggling={toggling === mod.id}
onToggle={toggleModule}
/>
))}
</div>
<>
<div className="space-y-4">
<MarketplaceSearch onChange={setSearchQuery} />
<StatusFilter value={statusFilter} counts={statusCounts} onChange={setStatusFilter} />
<CategoryFilter categories={categories} value={categoryFilter} onChange={setCategoryFilter} />
</div>
{filteredModules.length === 0 && hasFiltersApplied ? (
<div className="flex flex-col items-center justify-center min-h-[30vh] text-center">
<h2 className="text-lg font-semibold text-foreground">{t('filteredEmptyTitle')}</h2>
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('filteredEmptyBody')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filteredModules.map((mod) => (
<MarketplaceCard
key={mod.id}
id={mod.id}
name={mod.name}
slug={mod.slug}
description={mod.description}
icon={mod.icon ?? undefined}
category={mod.category}
isActive={activations.has(mod.id)}
isToggling={toggling === mod.id}
onToggle={toggleModule}
/>
))}
</div>
)}
</>
)}
<ToastContainer />
</div>
);
}