# 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(); 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 (

{tCommon('accessDenied')}

); } ``` **Error display pattern** (lines 126-129): ```typescript {error && (
{error}
)} ``` **Page layout pattern** (lines 118-124): ```typescript

{t('title')}

{t('description')}

{/* content */}
``` --- ### `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 && (
)} {!isLoading && modules.length === 0 && (
{/* SVG icon */}

{t('noModules')}

)} ``` --- ### `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 (

{name}

{localizedDescription}

); } ``` **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
``` --- ### `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 && (

{t('deleteConfirm')}

)} ``` --- ### `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()( 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()((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 `` with ``** -- current pattern (lines 54-56, 81-82, etc.): ```typescript // CURRENT (replace all instances): // REPLACE WITH: ``` **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 `` tags need `` 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 `` 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