feat(marketplace): UI fuer Plugin-Marketplace — Modul 5/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,8 +29,26 @@ class MarketplacePlugin(BasePlugin):
|
||||
events=[],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["marketplace:read", "marketplace:admin"],
|
||||
menu_items=[],
|
||||
page_routes=[],
|
||||
# UI-Backlog Modul 5 (2026-09-13): marketplace browse/install page,
|
||||
# registered via the manifest (Phase Q pattern).
|
||||
menu_items=[
|
||||
FrontendMenuItem(
|
||||
label_key="nav.marketplace",
|
||||
label="Marketplace",
|
||||
path="/marketplace",
|
||||
icon="Store",
|
||||
order=85,
|
||||
permission="marketplace:read",
|
||||
),
|
||||
],
|
||||
page_routes=[
|
||||
FrontendPageRoute(
|
||||
path="/marketplace",
|
||||
component="@/pages/Marketplace",
|
||||
protected=True,
|
||||
permission="marketplace:read",
|
||||
),
|
||||
],
|
||||
settings_pages=[],
|
||||
detail_tabs=[],
|
||||
author="LeoCRM",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user