feat(03-03): expanded module view with dynamic routing and API client
- Create [moduleSlug] page that loads module component via MODULE_REGISTRY whitelist - Only registered slugs trigger dynamic imports; unknown slugs show not-found state (T-03-09) - Create api.ts with getActiveModules and getModuleBySlug utility functions - Back-link navigation from expanded view to category page
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { loadModuleComponent, MODULE_REGISTRY } from '@/lib/module-loader';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded module view — renders the full module UI via lazy loading.
|
||||||
|
*
|
||||||
|
* Per D-05b: expanded/full view when user opens a specific module.
|
||||||
|
* Uses loadModuleComponent to get the dynamically imported component.
|
||||||
|
*
|
||||||
|
* Security (T-03-09): Only slugs whitelisted in MODULE_REGISTRY are loaded.
|
||||||
|
* Arbitrary slugs from URL params result in a not-found state — no
|
||||||
|
* arbitrary imports are ever triggered.
|
||||||
|
*/
|
||||||
|
export default function ExpandedModulePage() {
|
||||||
|
const params = useParams<{ category: string; moduleSlug: string }>();
|
||||||
|
const { category, moduleSlug } = params;
|
||||||
|
const t = useTranslations('modules');
|
||||||
|
|
||||||
|
// Only load from the whitelist registry (T-03-09)
|
||||||
|
const isRegistered = moduleSlug in MODULE_REGISTRY;
|
||||||
|
const ModuleComponent = isRegistered
|
||||||
|
? loadModuleComponent(moduleSlug)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Not-found state for unknown/unregistered slugs
|
||||||
|
if (!ModuleComponent) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6 p-6">
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="rounded-lg bg-muted p-4 mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="m15 9-6 6" />
|
||||||
|
<path d="m9 9 6 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{t('notFoundDescription')}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/modules/${category}`}
|
||||||
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t('backToCategory')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the lazily loaded module component
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Back navigation */}
|
||||||
|
<div className="px-6 pt-4">
|
||||||
|
<Link
|
||||||
|
href={`/modules/${category}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<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="m12 19-7-7 7-7" />
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
</svg>
|
||||||
|
{t('backToCategory')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Module content — loaded on demand (MOD-04) */}
|
||||||
|
<ModuleComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* API client utilities for the Tessera frontend.
|
||||||
|
*
|
||||||
|
* Provides typed fetch wrappers for common API endpoints.
|
||||||
|
* All functions accept a cookie string for server-side auth forwarding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module data shape from the API.
|
||||||
|
*/
|
||||||
|
export interface ApiModule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
category: string;
|
||||||
|
description: Record<string, string> | string;
|
||||||
|
icon?: string;
|
||||||
|
version: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all active modules for the current tenant.
|
||||||
|
*
|
||||||
|
* @param cookie - Cookie header value for auth forwarding
|
||||||
|
* @returns Array of active modules
|
||||||
|
*/
|
||||||
|
export async function getActiveModules(cookie: string): Promise<ApiModule[]> {
|
||||||
|
const response = await fetch(`${API_URL}/modules/active`, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific module by its slug.
|
||||||
|
*
|
||||||
|
* Fetches all modules and filters by slug.
|
||||||
|
*
|
||||||
|
* @param slug - Module slug (e.g., 'domaincheck')
|
||||||
|
* @param cookie - Cookie header value for auth forwarding
|
||||||
|
* @returns The module if found, or null
|
||||||
|
*/
|
||||||
|
export async function getModuleBySlug(
|
||||||
|
slug: string,
|
||||||
|
cookie: string,
|
||||||
|
): Promise<ApiModule | null> {
|
||||||
|
const response = await fetch(`${API_URL}/modules`, {
|
||||||
|
headers: { Cookie: cookie },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: ApiModule[] = await response.json();
|
||||||
|
return modules.find((m) => m.slug === slug) ?? null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user