|
|
|
@@ -0,0 +1,422 @@
|
|
|
|
|
# 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
|