feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

ERSTES Modul ueber die Phase-Q-Plugin-Architektur: Registrierung komplett
ueber das Plugin-Manifest (page_routes + menu_items) — routes/index.tsx und
Sidebar.tsx wurden NICHT angefasst. Der Komponenten-Map-Generator wired den
lazy import automatisch (38 Komponenten).

Backend existierte vollstaendig (listings mit search/tags/pagination,
listing-detail, install mit Ed25519-Signatur-Verify, verify, categories;
marketplace:read/admin + require_admin fuer Install), Frontend hatte 0%
Abdeckung.

- api/marketplace.ts: TanStack-Hooks (useMarketplaceListings mit
  search/tags/pagination, useMarketplaceListing, useMarketplaceCategories,
  useInstallFromMarketplace, useVerifyMarketplacePlugin)
- pages/Marketplace.tsx: Suche, Tag-Filter-Chips, Listing-Karten (Name,
  Version, Author, Beschreibung, Tags, Download-Counter, Verified-Badge,
  Preis/Kostenlos), Install mit Confirm (Admin-only via is_system_admin),
  Signatur-Verify, Ergebnis-Banner, Pagination — No-Access-Card ohne
  marketplace:read
- Manifest: page_route /marketplace + menu_item (Store-Icon, order 85,
  permission marketplace:read)
- i18n marketplace.* + nav.marketplace de/en

Verifikation: Vitest 10/10 (Rendering, No-Access, Empty/Error, Karten,
  Search+Tags, Admin-Gating, Install-Flow mit+ohne Confirm, Verify-Flow) ·
  tsc exit 0 · production build exit 0 · Backend-Regressionen
  (route-order, m5-miniapps) 10/10 · compileall sauber · Manifest-Check:
  page_route + menu_item korrekt.
This commit is contained in:
Agent Zero
2026-09-13 09:44:28 +02:00
parent a3201ae221
commit 289dfc8230
7 changed files with 741 additions and 5 deletions
@@ -0,0 +1,196 @@
/**
* Marketplace page tests — browse, search, install plugins (module 5/16).
*
* Covers: rendering, search input, tag filter buttons, listing cards with
* verified badge, install flow (admin only) with confirmation, verify
* signature flow, permission gating, empty and error states.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MarketplacePage } from '@/pages/Marketplace';
import type { MarketplaceListing } from '@/api/marketplace';
const installMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({
success: true, name: 'demo-plugin', display_name: 'Demo Plugin',
version: '1.0.0', installed: true, activated: true,
message: 'ok', error: null,
});
return Promise.resolve({});
});
const verifyMut = vi.fn().mockImplementation((_payload, opts) => {
opts?.onSuccess?.({
name: 'demo-plugin', version: '1.0.0',
signature_valid: true, message: 'Signature valid',
});
return Promise.resolve({});
});
const makeListing = (overrides: Partial<MarketplaceListing> = {}): MarketplaceListing => ({
id: 'mk-1',
name: 'demo-plugin',
display_name: 'Demo Plugin',
description: 'A demo plugin for testing',
version: '1.0.0',
author: 'LeoCRM',
homepage: '',
download_url: 'https://example.com/demo.zip',
icon: '',
screenshots: [],
tags: ['crm', 'demo'],
price: 0,
is_verified: true,
download_count: 42,
min_app_version: '1.0.0',
license: 'MIT',
created_at: null,
updated_at: null,
...overrides,
});
let mockListings: MarketplaceListing[] = [];
let mockCategories: string[] = [];
let mockCanBrowse = true;
let mockIsAdmin = true;
let mockError = false;
vi.mock('@/api/marketplace', () => ({
useMarketplaceListings: () => ({
data: { listings: mockListings, total: mockListings.length, page: 1, page_size: 12 },
isLoading: false,
isError: mockError,
isFetching: false,
refetch: vi.fn(),
}),
useMarketplaceCategories: () => ({
data: { categories: mockCategories, total: mockCategories.length },
isLoading: false,
}),
useInstallFromMarketplace: () => ({
mutate: installMut,
isPending: false,
}),
useVerifyMarketplacePlugin: () => ({
mutate: verifyMut,
isPending: false,
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) => mockCanBrowse || perm !== 'marketplace:read',
}),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: (selector: (s: { user: { id: string; is_system_admin: boolean } }) => unknown) =>
selector({ user: { id: 'u-1', is_system_admin: mockIsAdmin } }),
}));
function renderPage() {
return render(
<MemoryRouter>
<MarketplacePage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockListings = [];
mockCategories = [];
mockCanBrowse = true;
mockIsAdmin = true;
mockError = false;
});
describe('MarketplacePage', () => {
it('renders the page with title', () => {
renderPage();
expect(screen.getByTestId('marketplace-page')).toBeInTheDocument();
});
it('shows no-access card without marketplace:read', () => {
mockCanBrowse = false;
renderPage();
expect(screen.getByTestId('marketplace-no-access')).toBeInTheDocument();
});
it('shows empty state when no listings exist', () => {
renderPage();
expect(screen.getByTestId('marketplace-empty')).toBeInTheDocument();
});
it('shows error state on load failure', () => {
mockError = true;
renderPage();
expect(screen.getByTestId('marketplace-error')).toBeInTheDocument();
});
it('renders listing cards with name, version, verified badge and tags', () => {
mockListings = [makeListing()];
renderPage();
expect(screen.getByTestId('marketplace-card-demo-plugin')).toBeInTheDocument();
expect(screen.getByText('Demo Plugin')).toBeInTheDocument();
expect(screen.getByText(/v1\.0\.0/)).toBeInTheDocument();
expect(screen.getByTestId('marketplace-verified-demo-plugin')).toBeInTheDocument();
expect(screen.getByText('crm')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('renders the search input and tag filter buttons', () => {
mockCategories = ['crm', 'mail'];
renderPage();
expect(screen.getByTestId('marketplace-search')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-all')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-crm')).toBeInTheDocument();
expect(screen.getByTestId('marketplace-tag-mail')).toBeInTheDocument();
});
it('hides the install button for non-admin users', () => {
mockListings = [makeListing()];
mockIsAdmin = false;
renderPage();
expect(screen.queryByTestId('marketplace-install-demo-plugin')).not.toBeInTheDocument();
// verify button is still there (marketplace:read)
expect(screen.getByTestId('marketplace-verify-demo-plugin')).toBeInTheDocument();
});
it('installs a plugin after confirmation', async () => {
mockListings = [makeListing()];
window.confirm = vi.fn(() => true);
renderPage();
fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin'));
await waitFor(() => {
expect(installMut).toHaveBeenCalledWith(
{ name: 'demo-plugin', activate: true },
expect.anything(),
);
});
// success result banner appears
expect(await screen.findByTestId('marketplace-result')).toBeInTheDocument();
});
it('does not install when the confirmation is rejected', () => {
mockListings = [makeListing()];
window.confirm = vi.fn(() => false);
renderPage();
fireEvent.click(screen.getByTestId('marketplace-install-demo-plugin'));
expect(installMut).not.toHaveBeenCalled();
});
it('verifies a plugin signature and shows the result', async () => {
mockListings = [makeListing()];
renderPage();
fireEvent.click(screen.getByTestId('marketplace-verify-demo-plugin'));
await waitFor(() => {
expect(verifyMut).toHaveBeenCalledWith(
{ name: 'demo-plugin' },
expect.anything(),
);
});
expect(await screen.findByTestId('marketplace-result')).toHaveTextContent(/valid/i);
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* Marketplace API client — browse, search, verify and install plugins.
*
* Backend: /api/v1/marketplace (listings, listing detail, install, verify,
* categories). Permissions: marketplace:read (browse/verify), admin
* (install).
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from '@/api/client';
export interface MarketplaceListing {
id: string;
name: string;
display_name: string;
description: string;
version: string;
author: string;
homepage: string;
download_url: string;
icon: string;
screenshots: string[];
tags: string[];
price: number;
is_verified: boolean;
download_count: number;
min_app_version: string;
license: string;
created_at: string | null;
updated_at: string | null;
}
export interface MarketplaceListResponse {
listings: MarketplaceListing[];
total: number;
page: number;
page_size: number;
}
export interface MarketplaceCategoriesResponse {
categories: string[];
total: number;
}
export interface MarketplaceInstallResponse {
success: boolean;
name: string;
display_name: string;
version: string;
installed: boolean;
activated: boolean;
message: string;
error: string | null;
}
export interface MarketplaceVerifyResponse {
name: string;
version: string;
signature_valid: boolean;
message: string;
}
export interface MarketplaceListParams {
search?: string;
tags?: string[];
page?: number;
pageSize?: number;
}
// ─── Query hooks ─────────────────────────────────────────────
export function useMarketplaceListings(params: MarketplaceListParams = {}) {
const searchParams = new URLSearchParams();
if (params.search) searchParams.set('search', params.search);
if (params.tags && params.tags.length > 0) searchParams.set('tags', params.tags.join(','));
if (params.page) searchParams.set('page', String(params.page));
if (params.pageSize) searchParams.set('page_size', String(params.pageSize));
const qs = searchParams.toString();
return useQuery<MarketplaceListResponse>({
queryKey: ['marketplace-listings', params.search, params.tags, params.page, params.pageSize],
queryFn: () => apiGet<MarketplaceListResponse>(`/marketplace/listings${qs ? `?${qs}` : ''}`),
});
}
export function useMarketplaceListing(name: string | null) {
return useQuery<MarketplaceListing>({
queryKey: ['marketplace-listings', name],
queryFn: () => apiGet<MarketplaceListing>(`/marketplace/listings/${name}`),
enabled: !!name,
});
}
export function useMarketplaceCategories() {
return useQuery<MarketplaceCategoriesResponse>({
queryKey: ['marketplace-categories'],
queryFn: () => apiGet<MarketplaceCategoriesResponse>('/marketplace/categories'),
});
}
// ─── Mutation hooks ──────────────────────────────────────────
export function useInstallFromMarketplace() {
const qc = useQueryClient();
return useMutation<
MarketplaceInstallResponse,
Error,
{ name: string; signature?: string; publicKey?: string; activate?: boolean }
>({
mutationFn: ({ name, signature, publicKey, activate }) =>
apiPost<MarketplaceInstallResponse>(`/marketplace/install/${name}`, {
name,
signature: signature ?? null,
public_key: publicKey ?? null,
activate: activate ?? false,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['marketplace-listings'] });
},
});
}
export function useVerifyMarketplacePlugin() {
return useMutation<
MarketplaceVerifyResponse,
Error,
{ name: string; signature?: string; publicKey?: string }
>({
mutationFn: ({ name, signature, publicKey }) =>
apiPost<MarketplaceVerifyResponse>(`/marketplace/verify/${name}`, {
name,
signature: signature ?? null,
public_key: publicKey ?? null,
}),
});
}
@@ -45,6 +45,7 @@ export const PLUGIN_COMPONENT_MAP: Record<string, LazyComponentFactory> = {
'@/pages/ImportExport': () => import('@/pages/ImportExport').then((m) => ({ default: m.ImportExportPage })),
'@/pages/Mail': () => import('@/pages/Mail').then((m) => ({ default: m.MailPage })),
'@/pages/MailSettings': () => import('@/pages/MailSettings').then((m) => ({ default: m.MailSettingsPage })),
'@/pages/Marketplace': () => import('@/pages/Marketplace').then(normalizeModule),
'@/pages/ProactiveAISettings': () => import('@/pages/ProactiveAISettings').then((m) => ({ default: m.ProactiveAISettings })),
'@/pages/Reports': () => import('@/pages/Reports').then((m) => ({ default: m.ReportsPage })),
'@/pages/SettingsGroups': () => import('@/pages/SettingsGroups').then((m) => ({ default: m.SettingsGroupsPage })),
+19 -1
View File
@@ -23,7 +23,8 @@
"wiki": "Wiki",
"systemDashboard": "System Dashboard",
"approvals": "Freigaben",
"delegations": "Delegationen"
"delegations": "Delegationen",
"marketplace": "Marketplace"
},
"auth": {
"login": "Anmelden",
@@ -1705,5 +1706,22 @@
"defaultBadge": "Standard",
"empty": "Keine Mandanten vorhanden.",
"loadError": "Mandanten konnten nicht geladen werden."
},
"marketplace": {
"title": "Marketplace",
"searchPlaceholder": "Plugins durchsuchen...",
"categories": "Kategorien",
"allTags": "Alle",
"free": "Kostenlos",
"verified": "Verifiziert",
"install": "Installieren",
"installConfirm": "Plugin '{{name}}' wirklich installieren und aktivieren?",
"installSuccess": "Plugin '{{name}}' erfolgreich installiert.",
"installFailed": "Installation fehlgeschlagen",
"verify": "Signatur pruefen",
"verifyFailed": "Signaturpruefung fehlgeschlagen",
"empty": "Keine Plugins gefunden.",
"loadError": "Marketplace konnte nicht geladen werden.",
"noAccess": "Kein Zugriff auf den Marketplace (marketplace:read noetig)."
}
}
+19 -1
View File
@@ -23,7 +23,8 @@
"wiki": "Wiki",
"systemDashboard": "System Dashboard",
"approvals": "Approvals",
"delegations": "Delegations"
"delegations": "Delegations",
"marketplace": "Marketplace"
},
"auth": {
"login": "Sign In",
@@ -1705,5 +1706,22 @@
"defaultBadge": "Default",
"empty": "No tenants yet.",
"loadError": "Failed to load tenants."
},
"marketplace": {
"title": "Marketplace",
"searchPlaceholder": "Search plugins...",
"categories": "Categories",
"allTags": "All",
"free": "Free",
"verified": "Verified",
"install": "Install",
"installConfirm": "Really install and activate plugin '{{name}}'?",
"installSuccess": "Plugin '{{name}}' installed successfully.",
"installFailed": "Installation failed",
"verify": "Verify signature",
"verifyFailed": "Signature verification failed",
"empty": "No plugins found.",
"loadError": "Failed to load the marketplace.",
"noAccess": "No marketplace access (marketplace:read required)."
}
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Marketplace page — browse, search, verify and install plugins
* (UI-Backlog module 5/16).
*
* Backend: /api/v1/marketplace. Install requires admin,
* browse/verify requires marketplace:read.
*
* NOTE (Phase Q): this page is registered via the marketplace PLUGIN
* MANIFEST (page_routes + menu_items) — routes/index.tsx and Sidebar.tsx
* are NOT touched. The component map generator wires the lazy import.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Store,
Search,
ShieldCheck,
Download,
Package,
BadgeCheck,
ChevronLeft,
ChevronRight,
AlertTriangle,
Inbox,
} from 'lucide-react';
import {
useMarketplaceListings,
useMarketplaceCategories,
useInstallFromMarketplace,
useVerifyMarketplacePlugin,
type MarketplaceListing,
} from '@/api/marketplace';
import { usePermission } from '@/hooks/usePermission';
import { useAuthStore } from '@/store/authStore';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
const PAGE_SIZE = 12;
function ListingCard({
listing,
isAdmin,
onInstall,
onVerify,
isMutating,
}: {
listing: MarketplaceListing;
isAdmin: boolean;
onInstall: (listing: MarketplaceListing) => void;
onVerify: (listing: MarketplaceListing) => void;
isMutating: boolean;
}) {
const { t } = useTranslation();
return (
<Card className="p-4 flex flex-col gap-3" data-testid={`marketplace-card-${listing.name}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="w-5 h-5 text-primary-600 flex-shrink-0" aria-hidden="true" />
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-secondary-900 dark:text-secondary-100">
{listing.display_name}
</span>
<span className="text-xs text-secondary-400">v{listing.version}</span>
{listing.is_verified && (
<span data-testid={`marketplace-verified-${listing.name}`}>
<BadgeCheck className="w-4 h-4 text-success-600" aria-label={t('marketplace.verified')} />
</span>
)}
</div>
<div className="text-xs text-secondary-500">{listing.author}</div>
</div>
</div>
{listing.price > 0 ? (
<Badge variant="primary">{listing.price.toFixed(2)} </Badge>
) : (
<Badge variant="success">{t('marketplace.free')}</Badge>
)}
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 line-clamp-3" data-testid={`marketplace-desc-${listing.name}`}>
{listing.description || '—'}
</p>
<div className="flex flex-wrap gap-1">
{listing.tags.map((tag) => (
<Badge key={tag} variant="secondary">{tag}</Badge>
))}
</div>
<div className="flex items-center justify-between gap-2 mt-auto pt-2 border-t border-secondary-100 dark:border-secondary-800">
<span className="text-xs text-secondary-400 flex items-center gap-1">
<Download className="w-3.5 h-3.5" aria-hidden="true" />
{listing.download_count}
</span>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onVerify(listing)}
disabled={isMutating}
data-testid={`marketplace-verify-${listing.name}`}
aria-label={t('marketplace.verify')}
>
<ShieldCheck className="w-4 h-4" aria-hidden="true" />
</Button>
{isAdmin && (
<Button
size="sm"
onClick={() => onInstall(listing)}
disabled={isMutating}
data-testid={`marketplace-install-${listing.name}`}
>
{t('marketplace.install')}
</Button>
)}
</div>
</div>
</Card>
);
}
export function MarketplacePage() {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [activeTag, setActiveTag] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [resultMessage, setResultMessage] = useState<{ ok: boolean; text: string } | null>(null);
const { data, isLoading, isError } = useMarketplaceListings({
search: search || undefined,
tags: activeTag ? [activeTag] : undefined,
page,
pageSize: PAGE_SIZE,
});
const { data: categoriesData } = useMarketplaceCategories();
const installMut = useInstallFromMarketplace();
const verifyMut = useVerifyMarketplacePlugin();
const { hasPermission } = usePermission();
const user = useAuthStore((s) => s.user);
const canBrowse = hasPermission('marketplace:read');
const isAdmin = !!user?.is_system_admin;
const isMutating = installMut.isPending || verifyMut.isPending;
const handleInstall = (listing: MarketplaceListing) => {
if (
!window.confirm(
t('marketplace.installConfirm', { name: listing.display_name }),
)
) {
return;
}
installMut.mutate(
{ name: listing.name, activate: true },
{
onSuccess: (result) => {
setResultMessage({
ok: result.success,
text: result.success
? t('marketplace.installSuccess', { name: result.display_name })
: `${t('marketplace.installFailed')}: ${result.error ?? result.message}`,
});
},
onError: (err) => {
setResultMessage({ ok: false, text: `${t('marketplace.installFailed')}: ${err.message}` });
},
},
);
};
const handleVerify = (listing: MarketplaceListing) => {
verifyMut.mutate(
{ name: listing.name },
{
onSuccess: (result) => {
setResultMessage({
ok: result.signature_valid,
text: `${listing.display_name}: ${result.message}`,
});
},
onError: (err) => {
setResultMessage({ ok: false, text: `${t('marketplace.verifyFailed')}: ${err.message}` });
},
},
);
};
const listings = data?.listings ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (!canBrowse) {
return (
<div className="max-w-4xl mx-auto p-6" data-testid="marketplace-no-access">
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500">
<AlertTriangle className="w-10 h-10" aria-hidden="true" />
<p>{t('marketplace.noAccess')}</p>
</Card>
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-4" data-testid="marketplace-page">
<div className="flex items-center gap-3">
<Store className="w-6 h-6 text-primary-600" aria-hidden="true" />
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{t('marketplace.title')}
</h1>
</div>
{/* Search + tag filter */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400"
aria-hidden="true"
/>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('marketplace.searchPlaceholder')}
className="pl-9"
aria-label={t('marketplace.searchPlaceholder')}
data-testid="marketplace-search"
/>
</div>
</div>
{(categoriesData?.categories ?? []).length > 0 && (
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t('marketplace.categories')}>
<button
onClick={() => {
setActiveTag(null);
setPage(1);
}}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors min-h-touch ${
activeTag === null
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300'
}`}
data-testid="marketplace-tag-all"
>
{t('marketplace.allTags')}
</button>
{(categoriesData?.categories ?? []).map((tag) => (
<button
key={tag}
onClick={() => {
setActiveTag(activeTag === tag ? null : tag);
setPage(1);
}}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors min-h-touch ${
activeTag === tag
? 'bg-primary-600 text-white'
: 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-800 dark:text-secondary-300'
}`}
data-testid={`marketplace-tag-${tag}`}
>
{tag}
</button>
))}
</div>
)}
{resultMessage && (
<div role="status">
<Card
className={`p-3 text-sm ${resultMessage.ok ? 'text-success-700' : 'text-danger-600'}`}
data-testid="marketplace-result"
>
{resultMessage.text}
</Card>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center min-h-[40vh]" role="status" data-testid="marketplace-loading">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500" aria-hidden="true" />
</div>
)}
{isError && (
<Card className="p-6 flex items-center gap-3 text-danger-600" data-testid="marketplace-error">
<AlertTriangle className="w-5 h-5" aria-hidden="true" />
<span>{t('marketplace.loadError')}</span>
</Card>
)}
{!isLoading && !isError && listings.length === 0 && (
<Card className="p-8 flex flex-col items-center gap-3 text-secondary-500" data-testid="marketplace-empty">
<Inbox className="w-10 h-10" aria-hidden="true" />
<p>{t('marketplace.empty')}</p>
</Card>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{listings.map((listing) => (
<ListingCard
key={listing.id}
listing={listing}
isAdmin={isAdmin}
onInstall={handleInstall}
onVerify={handleVerify}
isMutating={isMutating}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
aria-label={t('common.previous')}
data-testid="marketplace-prev"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
</Button>
<span className="text-sm text-secondary-500" data-testid="marketplace-page-info">
{page} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
aria-label={t('common.next')}
data-testid="marketplace-next"
>
<ChevronRight className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
)}
</div>
);
}
export default MarketplacePage;