Files
tessera-ctl/.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md
T
2026-06-22 14:20:01 +02:00

423 lines
14 KiB
Markdown

# Phase 4: Marketplace & Portal Navigation - Pattern Map
**Mapped:** 2026-06-22
**Files analyzed:** 12 new/modified files
**Analogs found:** 12 / 12
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/web/src/app/(portal)/marketplace/page.tsx` | component (page) | request-response | `apps/web/src/app/(portal)/admin/modules/page.tsx` | exact |
| `apps/web/src/app/(portal)/marketplace/[slug]/page.tsx` | component (page) | request-response | `apps/web/src/app/(portal)/modules/[category]/page.tsx` | role-match |
| `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` | component | CRUD | `apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx` | exact |
| `apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx` | component | event-driven | (no direct analog) | -- |
| `apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx` | component | event-driven | (no direct analog) | -- |
| `apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx` | component | event-driven | (no direct analog) | -- |
| `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx` | component | request-response | `apps/web/src/app/(portal)/admin/tenants/page.tsx` | partial |
| `apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx` | component | event-driven | `apps/web/src/app/(portal)/admin/tenants/page.tsx` (delete confirm modal) | role-match |
| `apps/web/src/app/(portal)/marketplace/components/Toast.tsx` | component | event-driven | (no direct analog) | -- |
| `apps/web/src/lib/stores/marketplace-store.ts` | store | event-driven | `apps/web/src/lib/stores/sidebar-store.ts` | role-match |
| `apps/web/src/components/layout/sidebar.tsx` | component (MODIFY) | request-response | self | exact |
| `apps/web/src/components/layout/sidebar-search.tsx` | component | event-driven | (no direct analog) | -- |
## Pattern Assignments
### `apps/web/src/app/(portal)/marketplace/page.tsx` (page, request-response)
**Analog:** `apps/web/src/app/(portal)/admin/modules/page.tsx`
**Imports pattern** (lines 1-6):
```typescript
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
```
**API fetch pattern** (lines 39-64) -- parallel fetch of all modules + active modules, building activation map:
```typescript
const fetchModules = useCallback(async () => {
try {
const [allRes, activeRes] = await Promise.all([
fetch(`${API_URL}/modules`, { credentials: 'include' }),
fetch(`${API_URL}/modules/active`, { credentials: 'include' }),
]);
if (allRes.ok) {
const allModules: Module[] = await allRes.json();
setModules(allModules);
}
if (activeRes.ok) {
const activeModules: (Module & { activations?: TenantActivation[] })[] = await activeRes.json();
const map = new Map<string, boolean>();
for (const mod of activeModules) {
map.set(mod.id, true);
}
setActivations(map);
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
```
**Toggle activation pattern** (lines 74-103):
```typescript
const toggleModule = async (moduleId: string, currentlyActive: boolean) => {
setToggling(moduleId);
setError(null);
try {
const action = currentlyActive ? 'deactivate' : 'activate';
const res = await fetch(`${API_URL}/modules/${moduleId}/${action}`, {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
setActivations((prev) => {
const next = new Map(prev);
if (currentlyActive) {
next.delete(moduleId);
} else {
next.set(moduleId, true);
}
return next;
});
} else {
const body = await res.text().catch(() => '');
setError(`${res.status}: ${body}`);
}
} catch (err) {
setError(String(err));
} finally {
setToggling(null);
}
};
```
**Role-gated access pattern** (lines 37, 110-116):
```typescript
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
if (!isAdmin) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
```
**Error display pattern** (lines 126-129):
```typescript
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
```
**Page layout pattern** (lines 118-124):
```typescript
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
{/* content */}
</div>
```
---
### `apps/web/src/app/(portal)/marketplace/[slug]/page.tsx` (detail page, request-response)
**Analog:** `apps/web/src/app/(portal)/modules/[category]/page.tsx`
**Dynamic params pattern** (lines 1-5):
```typescript
'use client';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
```
**Loading + empty state pattern** (lines 104-143):
```typescript
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{!isLoading && modules.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
{/* SVG icon */}
</div>
<p className="text-lg text-muted-foreground">{t('noModules')}</p>
</div>
)}
```
---
### `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` (component, CRUD)
**Analog:** `apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx`
**Card structure pattern** (lines 70-107):
```typescript
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 (
<Link href={`/modules/${category}/${slug}`} className="group block">
<div className="rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30 group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2">
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
<ModuleIcon icon={icon} />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-base leading-tight truncate">{name}</h3>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">{localizedDescription}</p>
</div>
</Link>
);
}
```
**Locale extraction pattern** (line 72-73):
```typescript
const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de';
const localizedDescription = description[locale] || description.en || description.de || '';
```
**Responsive grid pattern** (from category page, line 112):
```typescript
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
```
---
### `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx` (component, request-response)
**Analog:** `apps/web/src/app/(portal)/admin/tenants/page.tsx`
**Tenant fetch pattern** (lines 44-57):
```typescript
const fetchTenants = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/tenants`, {
credentials: 'include',
});
if (res.ok) {
setTenants(await res.json());
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
```
**Tenant interface** (lines 9-16):
```typescript
interface Tenant {
id: string;
name: string;
slug: string;
isActive: boolean;
createdAt: string;
userCount: number;
}
```
**Super-Admin guard pattern** (line 42):
```typescript
const hasAccess = currentUser?.role === 'SUPER_ADMIN';
```
---
### `apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx` (component, event-driven)
**Analog:** `apps/web/src/app/(portal)/admin/tenants/page.tsx` (delete confirm modal)
**Modal pattern** (lines 301-323):
```typescript
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
onClick={() => handleDelete(deleteConfirm)}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
>
{tCommon('delete')}
</button>
</div>
</div>
</div>
)}
```
---
### `apps/web/src/lib/stores/marketplace-store.ts` (store, event-driven)
**Analog:** `apps/web/src/lib/stores/sidebar-store.ts`
**Zustand store pattern** (lines 1-21):
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface SidebarState {
isCollapsed: boolean;
isMobileOpen: boolean;
toggle: () => void;
setMobileOpen: (open: boolean) => void;
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
isCollapsed: false,
isMobileOpen: false,
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
setMobileOpen: (open) => set({ isMobileOpen: open }),
}),
{ name: 'tessera-sidebar' }
)
);
```
**Non-persisted store pattern** (from auth-store, lines 22-26):
```typescript
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}));
```
---
### `apps/web/src/components/layout/sidebar.tsx` (MODIFY -- add search, Link, usePathname)
**Self-analog.** Key modification points:
**Replace `<a>` with `<Link>`** -- current pattern (lines 54-56, 81-82, etc.):
```typescript
// CURRENT (replace all instances):
<a href="/" className="...">
// REPLACE WITH:
<Link href="/" className="...">
```
**Add usePathname for active state** -- insert after existing imports:
```typescript
import { usePathname } from 'next/navigation';
import Link from 'next/link';
// Inside component:
const pathname = usePathname();
const isActive = (href: string) => {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
};
```
**Sidebar link CSS classes** -- current active pattern (line 57):
```typescript
// Active: 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
// Inactive: 'text-sidebar-foreground hover:bg-muted'
```
**Categories accordion structure** (lines 106-176) -- extend with SidebarSearch component insertion point before the accordion.
**Module items in categories** (lines 156-165) -- already renders per-module links under each category. These `<a>` tags need `<Link>` replacement.
---
## Shared Patterns
### API Fetch Convention
**Source:** All page components
**Apply to:** All new page/component files that fetch data
```typescript
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
// Always use credentials: 'include' for cookie-based auth
fetch(`${API_URL}/endpoint`, { credentials: 'include' });
// For Super-Admin tenant context, add x-tenant-id header:
headers: selectedTenantId ? { 'x-tenant-id': selectedTenantId } : {}
```
### Translations Convention
**Source:** All components
**Apply to:** All new components
```typescript
import { useTranslations } from 'next-intl';
const t = useTranslations('marketplace'); // namespace key
const tCommon = useTranslations('common');
```
### CSS Token Convention
**Source:** All components
**Apply to:** All new components
```
// Semantic color tokens (NOT raw colors):
text-foreground, text-muted-foreground, bg-card, bg-muted, border-border,
bg-primary, text-primary-foreground, bg-destructive, text-destructive,
bg-sidebar, text-sidebar-foreground, bg-sidebar-accent, text-sidebar-accent-foreground
// Status colors (only exception to semantic tokens):
// Active: 'text-green-600 dark:text-green-400' or 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
```
### Role Check Convention
**Source:** `auth-store.ts` + all admin pages
**Apply to:** TenantContextSelector, marketplace activation controls
```typescript
const currentUser = useAuthStore((s) => s.user);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
```
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `MarketplaceSearch.tsx` | component | event-driven | No search input components exist yet. Use standard `<input>` with debounced `onChange` + project CSS conventions from form inputs in tenants page (line 259: `flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm`). |
| `CategoryFilter.tsx` | component | event-driven | No chip/filter-row components exist. Build as horizontal button row using badge styling from admin/modules (line 157-159: `rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground`). |
| `StatusFilter.tsx` | component | event-driven | No tab-bar components exist. Build as segmented button row following same badge/button conventions. |
| `Toast.tsx` | component | event-driven | No toast system exists. Build with Zustand store pattern (see RESEARCH.md Pattern 4). |
| `sidebar-search.tsx` | component | event-driven | No sidebar search exists. Use input styling from tenants page form fields. |
## Metadata
**Analog search scope:** `apps/web/src/`
**Files scanned:** 30 source files (excluding node_modules, .next, dist)
**Pattern extraction date:** 2026-06-22