From a191628fa5ebf0a5d249fd85f8386c67a095e79b Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 19 Jun 2026 13:36:09 +0200 Subject: [PATCH] feat(03-03): module loader utility with category page and lazy cards - Create module-loader.ts with MODULE_REGISTRY mapping slugs to dynamic imports (ssr:false) - Create [category] page fetching active modules from API, filtering by category - Create ModuleCard component with icon, name, description, and link to expanded view - Add i18n keys for modules namespace (de + en) --- .../[category]/components/ModuleCard.tsx | 107 +++++++++++++ .../app/(portal)/modules/[category]/page.tsx | 145 ++++++++++++++++++ apps/web/src/lib/module-loader.ts | 66 ++++++++ apps/web/src/messages/de.json | 8 + apps/web/src/messages/en.json | 8 + 5 files changed, 334 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx create mode 100644 apps/web/src/app/(portal)/modules/[category]/page.tsx create mode 100644 apps/web/src/lib/module-loader.ts diff --git a/apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx b/apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx new file mode 100644 index 0000000..d270fef --- /dev/null +++ b/apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import Link from 'next/link'; + +/** + * Module metadata passed to the card for rendering. + */ +export interface ModuleCardProps { + name: string; + slug: string; + description: Record; + icon?: string; + category: string; +} + +/** + * Default icon mapping for known module icons. + * Renders inline SVG to avoid external icon library dependency. + */ +function ModuleIcon({ icon }: { icon?: string }) { + // Globe icon for domain-related modules + if (icon === 'Globe') { + return ( + + + + + + ); + } + + // Default puzzle-piece icon for generic modules + return ( + + + + ); +} + +/** + * Module card for the category grid view. + * + * Displays module name, localized description, icon, and a link + * to the expanded module view. Per D-05a: uniform card appearance, + * compact, looks good even with a single module. + */ +export function ModuleCard({ name, slug, description, icon, category }: ModuleCardProps) { + const t = useTranslations('modules'); + const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'; + const localizedDescription = description[locale] || description.en || description.de || ''; + + return ( + +
+ {/* Header: icon + name */} +
+
+ +
+
+

+ {name} +

+
+
+ + {/* Description */} +

+ {localizedDescription} +

+ + {/* Footer: open link */} +
+ + {t('openModule')} + +
+
+ + ); +} diff --git a/apps/web/src/app/(portal)/modules/[category]/page.tsx b/apps/web/src/app/(portal)/modules/[category]/page.tsx new file mode 100644 index 0000000..697d0f9 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/[category]/page.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { ModuleCard, type ModuleCardProps } from './components/ModuleCard'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +/** + * Module data shape from the API response. + */ +interface ApiModule { + id: string; + name: string; + slug: string; + category: string; + description: Record | string; + icon?: string; + version: string; +} + +/** + * Format a category slug for display. + * Replaces hyphens with spaces and capitalizes each word. + */ +function formatCategoryName(category: string): string { + return category + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** + * Category page — displays activated module cards in a grid. + * + * Fetches active modules for the current tenant from the API, + * filters by the category from the URL param, and renders a + * responsive card grid. Per D-04: category page groups all + * modules of that category. + * + * T-03-10: API filters by tenant's active modules — inactive + * modules are never returned, cards never rendered. + */ +export default function CategoryPage() { + const params = useParams<{ category: string }>(); + const category = params.category; + const t = useTranslations('modules'); + + const [modules, setModules] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchModules() { + try { + const response = await fetch(`${API_URL}/modules/active`, { + credentials: 'include', + }); + + if (!response.ok) { + setModules([]); + return; + } + + const data: ApiModule[] = await response.json(); + + // Filter by category and map to card props + const categoryModules = data + .filter((mod) => mod.category === category) + .map((mod): ModuleCardProps => ({ + name: mod.name, + slug: mod.slug, + description: + typeof mod.description === 'string' + ? { en: mod.description } + : (mod.description ?? {}), + icon: mod.icon, + category: mod.category, + })); + + setModules(categoryModules); + } catch { + setModules([]); + } finally { + setIsLoading(false); + } + } + + fetchModules(); + }, [category]); + + const categoryTitle = formatCategoryName(category); + + return ( +
+ {/* Header */} +
+

+ {t('categoryTitle', { category: categoryTitle })} +

+
+ + {/* Loading state */} + {isLoading && ( +
+
+
+ )} + + {/* Module grid */} + {!isLoading && modules.length > 0 && ( +
+ {modules.map((mod) => ( + + ))} +
+ )} + + {/* Empty state */} + {!isLoading && modules.length === 0 && ( +
+
+ + + +
+

+ {t('noModules')} +

+
+ )} +
+ ); +} diff --git a/apps/web/src/lib/module-loader.ts b/apps/web/src/lib/module-loader.ts new file mode 100644 index 0000000..a678faf --- /dev/null +++ b/apps/web/src/lib/module-loader.ts @@ -0,0 +1,66 @@ +/** + * Module Loader — Lazy-loading registry for Tessera modules. + * + * Maps module slugs to dynamically imported components. + * Only modules listed in MODULE_REGISTRY will have their code loaded; + * inactive/unregistered modules never trigger an import (MOD-04). + * + * Security: Only whitelisted slugs are resolved — arbitrary slugs + * from URL params return null (T-03-09). + */ + +import dynamic from 'next/dynamic'; +import type { ComponentType } from 'react'; + +/** + * Registry entry for a module's lazy-loaded components. + */ +interface ModuleRegistryEntry { + /** Main module page component (loaded on demand) */ + component: ComponentType; + /** Optional card component for category grid view */ + cardComponent?: ComponentType; +} + +/** + * Module registry mapping slugs to lazy-loaded components. + * + * To register a new module, add its slug and dynamic imports here. + * Components are only fetched when React renders them (code-splitting). + */ +export const MODULE_REGISTRY: Record = { + domaincheck: { + component: dynamic( + () => import('@/app/(portal)/modules/domaincheck/page'), + { ssr: false }, + ), + }, +}; + +/** + * Load the main component for a module by slug. + * + * @param slug - Module slug (e.g., 'domaincheck') + * @returns The dynamic component, or null if the slug is not registered + */ +export function loadModuleComponent( + slug: string, +): ComponentType | null { + const entry = MODULE_REGISTRY[slug]; + return entry?.component ?? null; +} + +/** + * Load the card component for a module by slug. + * + * Falls back to null if no card component is registered. + * + * @param slug - Module slug (e.g., 'domaincheck') + * @returns The card component, or null if not available + */ +export function loadModuleCard( + slug: string, +): ComponentType | null { + const entry = MODULE_REGISTRY[slug]; + return entry?.cardComponent ?? null; +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 10c8283..c075b04 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -135,6 +135,14 @@ "de": "Deutsch", "en": "English" }, + "modules": { + "categoryTitle": "Module: {category}", + "openModule": "Oeffnen", + "noModules": "Keine aktiven Module in dieser Kategorie", + "notFound": "Modul nicht gefunden", + "notFoundDescription": "Das angeforderte Modul existiert nicht oder ist nicht aktiviert.", + "backToCategory": "Zurueck zur Kategorie" + }, "domaincheck": { "title": "Domaincheck", "description": "Pruefe ob eine Domain verfuegbar ist", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 14762c8..dcfce12 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -134,6 +134,14 @@ "de": "Deutsch", "en": "English" }, + "modules": { + "categoryTitle": "Modules: {category}", + "openModule": "Open", + "noModules": "No active modules in this category", + "notFound": "Module not found", + "notFoundDescription": "The requested module does not exist or is not activated.", + "backToCategory": "Back to category" + }, "domaincheck": { "title": "Domain Check", "description": "Check if a domain is available",