feat(04-02): add module detail page at /marketplace/[slug]

Compact detail view with back link, full description (no line-clamp),
status indicator, and activation controls. Reuses ActivationDialog for
deactivation. 3 tests pass, 21 total green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:25:00 +02:00
parent 0f3379f263
commit fdf2c38f67
2 changed files with 394 additions and 0 deletions
@@ -0,0 +1,172 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
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',
activate: 'Modul aktivieren',
deactivate: 'Deaktivieren',
backToMarketplace: 'Zurueck zum Marktplatz',
detailVersion: 'Version {version}',
detailStatusActive: 'Aktiviert fuer diesen Mandanten',
detailStatusInactive: 'Nicht aktiviert',
emptyTitle: 'Noch keine Module verfuegbar',
errorLoad: 'Module konnten nicht geladen werden.',
toastActivated: 'Modul erfolgreich aktiviert',
deactivateDialogTitle: 'Modul deaktivieren',
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
deactivateDialogConfirm: 'Deaktivieren',
deactivateDialogCancel: 'Abbrechen',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
let mockSlug = 'domaincheck';
vi.mock('next/navigation', () => ({
useParams: () => ({ slug: mockSlug }),
}));
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
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-Verfuegbarkeit pruefen fuer alle gaengigen TLDs', en: 'Check domain availability' },
icon: 'Globe',
isSystem: false,
},
{
id: 'mod-2',
slug: 'converter',
name: 'Converter',
version: '2.0.0',
category: 'Utilities',
description: { de: 'Dateien konvertieren', en: 'Convert files' },
icon: null,
isSystem: false,
},
];
const mockActiveModules = [mockModules[0]];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
async function importDetailPage() {
const mod = await import('./page');
return mod.default;
}
describe('Module Detail Page', () => {
beforeEach(() => {
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' },
}),
);
});
it('fetches modules and renders the matching module name and full description', async () => {
mockSlug = 'domaincheck';
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('') });
}),
);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
expect(screen.getByText('Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs')).toBeInTheDocument();
});
it('shows active status and deactivate button when module is active', async () => {
mockSlug = 'domaincheck';
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('') });
}),
);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => {
expect(screen.getByText('Aktiviert fuer diesen Mandanten')).toBeInTheDocument();
});
expect(screen.getByText('Deaktivieren')).toBeInTheDocument();
});
it('shows not-found state when slug does not match any module', async () => {
mockSlug = 'nonexistent-module';
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url.includes('/modules/active')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
}
if (url.includes('/modules')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
}
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
}),
);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => {
expect(screen.getByText('notFound')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,222 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast';
import { ActivationDialog } from '../components/ActivationDialog';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface Module {
id: string;
slug: string;
name: string;
version: string;
category: string;
description: Record<string, string>;
icon: string | null;
isSystem: boolean;
}
export default function ModuleDetailPage() {
const { slug } = useParams<{ slug: string }>();
const t = useTranslations('marketplace');
const currentUser = useAuthStore((s) => s.user);
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
const addToast = useToastStore((s) => s.addToast);
const [module, setModule] = useState<Module | null>(null);
const [isActive, setIsActive] = useState(false);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState(false);
const [notFound, setNotFound] = useState(false);
const [pendingDeactivation, setPendingDeactivation] = useState(false);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
const fetchModule = useCallback(async () => {
try {
const headers: Record<string, string> = {};
if (selectedTenantId) {
headers['x-tenant-id'] = selectedTenantId;
}
const [allRes, activeRes] = await Promise.all([
fetch(`${API_URL}/modules`, { credentials: 'include' }),
fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }),
]);
if (allRes.ok) {
const allModules: Module[] = await allRes.json();
const found = allModules.find((m) => m.slug === slug);
if (found) {
setModule(found);
if (activeRes.ok) {
const activeModules: Module[] = await activeRes.json();
setIsActive(activeModules.some((m) => m.id === found.id));
}
} else {
setNotFound(true);
}
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, [slug, selectedTenantId]);
useEffect(() => {
if (isAdmin) {
fetchModule();
} else {
setLoading(false);
}
}, [isAdmin, fetchModule]);
const handleToggle = () => {
if (isActive) {
setPendingDeactivation(true);
} else {
performToggle();
}
};
const performToggle = async () => {
if (!module) return;
setToggling(true);
try {
const action = isActive ? 'deactivate' : 'activate';
const headers: Record<string, string> = {};
if (selectedTenantId) {
headers['x-tenant-id'] = selectedTenantId;
}
const res = await fetch(`${API_URL}/modules/${module.id}/${action}`, {
method: 'POST',
credentials: 'include',
headers,
});
if (res.ok) {
setIsActive(!isActive);
bumpSidebarRefresh();
addToast('success', isActive ? t('toastDeactivated') : t('toastActivated'));
} else {
addToast('error', isActive ? t('toastDeactivateError') : t('toastActivateError'));
}
} catch {
// silently fail
} finally {
setToggling(false);
}
};
const confirmDeactivation = () => {
setPendingDeactivation(false);
performToggle();
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<span className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!isAdmin) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
</div>
);
}
if (notFound || !module) {
return (
<div className="max-w-2xl mx-auto space-y-4">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<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="m15 18-6-6 6-6" />
</svg>
{t('backToMarketplace')}
</Link>
<p className="text-lg text-muted-foreground">{t('notFound')}</p>
</div>
);
}
const localizedDescription = module.description[locale] || module.description.en || module.description.de || '';
return (
<div className="max-w-2xl mx-auto space-y-6">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<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="m15 18-6-6 6-6" />
</svg>
{t('backToMarketplace')}
</Link>
<div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0">
{module.icon === 'Globe' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
)}
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1>
<div className="flex items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span>
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">{localizedDescription}</p>
<div className="flex items-center justify-between border-t border-border pt-4">
<span className={`text-sm font-medium ${isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
{isActive ? t('detailStatusActive') : t('detailStatusInactive')}
</span>
<button
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
isActive
? 'border border-border text-foreground hover:bg-muted'
: 'bg-primary text-primary-foreground'
}`}
>
{toggling ? (
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : isActive ? (
t('deactivate')
) : (
t('activate')
)}
</button>
</div>
<ActivationDialog
open={pendingDeactivation}
moduleName={module.name}
onConfirm={confirmDeactivation}
onCancel={() => setPendingDeactivation(false)}
/>
</div>
);
}