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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -156,9 +198,22 @@ export default function MarketplacePage() {
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
|
||||
</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">
|
||||
{modules.map((mod) => (
|
||||
{filteredModules.map((mod) => (
|
||||
<MarketplaceCard
|
||||
key={mod.id}
|
||||
id={mod.id}
|
||||
@@ -174,6 +229,10 @@ export default function MarketplacePage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user