Files
2026-06-22 14:20:01 +02:00

14 KiB

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):

'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:

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):

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):

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):

{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):

<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):

'use client';

import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';

Loading + empty state pattern (lines 104-143):

{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):

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):

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):

<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):

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):

interface Tenant {
  id: string;
  name: string;
  slug: string;
  isActive: boolean;
  createdAt: string;
  userCount: number;
}

Super-Admin guard pattern (line 42):

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):

{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):

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):

export const useAuthStore = create<AuthState>()((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null }),
}));

Self-analog. Key modification points:

Replace <a> with <Link> -- current pattern (lines 54-56, 81-82, etc.):

// CURRENT (replace all instances):
<a href="/" className="...">
// REPLACE WITH:
<Link href="/" className="...">

Add usePathname for active state -- insert after existing imports:

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):

// 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

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

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

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