diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx new file mode 100644 index 0000000..c8dfced --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/[slug]/detail.test.tsx @@ -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) => { + const translations: Record = { + 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 }) => ( + {children} + ), +})); + +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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('notFound')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx new file mode 100644 index 0000000..0a471b9 --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/[slug]/page.tsx @@ -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; + 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(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 = {}; + 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 = {}; + 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 ( +
+ +
+ ); + } + + if (!isAdmin) { + return ( +
+

{t('accessDenied')}

+
+ ); + } + + if (notFound || !module) { + return ( +
+ + + + + {t('backToMarketplace')} + +

{t('notFound')}

+
+ ); + } + + const localizedDescription = module.description[locale] || module.description.en || module.description.de || ''; + + return ( +
+ + + + + {t('backToMarketplace')} + + +
+
+ {module.icon === 'Globe' ? ( + + + + + + ) : ( + + + + )} +
+
+

{module.name}

+
+ {module.category} + {t('detailVersion', { version: module.version })} +
+
+
+ +

{localizedDescription}

+ +
+ + {isActive ? t('detailStatusActive') : t('detailStatusInactive')} + + +
+ + setPendingDeactivation(false)} + /> +
+ ); +}