diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8829c91..cae9a01 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -130,7 +130,21 @@ Decimal phases appear between their surrounding integers in numeric order. 4. User can select a module from the sidebar and it opens in the main content area 5. User can search and filter modules in the sidebar -**Plans**: TBD +**Plans**: 4 plans + +**Wave 1** + +- [ ] 04-01-PLAN.md -- Test infra (Vitest) + marketplace i18n + Marketplace page slice: card grid browse + activate (MRKT-01/02/04) + +**Wave 2** *(blocked on Wave 1 completion)* + +- [ ] 04-02-PLAN.md -- Marketplace refinements: search/category/status filters, deactivation dialog, toast, Super-Admin tenant context, module detail page +- [ ] 04-03-PLAN.md -- Sidebar enhancement: Next.js Link + usePathname active state, per-module links, sidebar search, live refresh (PRTAL-02/03/05, MRKT-03) + +**Wave 3** *(blocked on Wave 2 completion)* + +- [ ] 04-04-PLAN.md -- Visual Verification: Human confirms marketplace, activation, tenant context, and sidebar navigation + **UI hint**: yes ### Phase 5: Dashboard & Calendar @@ -174,6 +188,6 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 | 1. Foundation & Portal Shell | 2/3 | In Progress | - | | 2. Authentication & Multi-Tenancy | 2/5 | In Progress| | | 3. Module System & Domaincheck | 3/4 | In Progress| | -| 4. Marketplace & Portal Navigation | 0/TBD | Not started | - | +| 4. Marketplace & Portal Navigation | 0/4 | Not started | - | | 5. Dashboard & Calendar | 0/TBD | Not started | - | | 6. Desktop Client & CI/CD | 0/TBD | Not started | - | diff --git a/.planning/phases/04-marketplace-portal-navigation/04-01-PLAN.md b/.planning/phases/04-marketplace-portal-navigation/04-01-PLAN.md new file mode 100644 index 0000000..b0f5efc --- /dev/null +++ b/.planning/phases/04-marketplace-portal-navigation/04-01-PLAN.md @@ -0,0 +1,226 @@ +--- +phase: 04-marketplace-portal-navigation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - apps/web/package.json + - apps/web/vitest.config.ts + - apps/web/src/test/setup.ts + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json + - apps/web/src/lib/stores/marketplace-store.ts + - apps/web/src/app/(portal)/marketplace/page.tsx + - apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx + - apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx + - apps/web/src/app/(portal)/marketplace/marketplace.test.tsx +autonomous: true +requirements: [MRKT-01, MRKT-02, MRKT-04] +must_haves: + truths: + - "User can navigate to /marketplace and see a card grid of all available modules with name, description and category badge" + - "Each card shows whether the module is activated (green status badge) or available for the current tenant" + - "Admin can click the activate button on an available module card and it becomes activated (POST /modules/:id/activate)" + - "Modules are grouped/labelled by category via a category badge on each card" + artifacts: + - path: "apps/web/src/app/(portal)/marketplace/page.tsx" + provides: "Marketplace grid page with parallel fetch of /modules + /modules/active, activation map, role gate" + min_lines: 60 + - path: "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx" + provides: "Module card with icon, name, localized description, category badge, status badge, activate button" + min_lines: 40 + - path: "apps/web/src/lib/stores/marketplace-store.ts" + provides: "Zustand store with sidebarRefreshKey + bumpSidebarRefresh() to signal sidebar re-fetch after activation" + contains: "bumpSidebarRefresh" + - path: "apps/web/vitest.config.ts" + provides: "Vitest config for Next.js with jsdom environment" + contains: "jsdom" + - path: "apps/web/src/messages/de.json" + provides: "marketplace + sidebar.search i18n namespace (DE)" + contains: "marketplace" + key_links: + - from: "apps/web/src/app/(portal)/marketplace/page.tsx" + to: "/modules and /modules/active" + via: "Promise.all parallel fetch with credentials: include" + pattern: "modules/active" + - from: "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx" + to: "/modules/:id/activate" + via: "POST fetch on activate click" + pattern: "modules/\\$\\{?.*\\}?/(activate|deactivate)" +--- + + +Stand up the test infrastructure (Vitest), the marketplace i18n namespace, and the first end-to-end marketplace slice: a user opens `/marketplace`, sees a responsive card grid of all modules with category and activation status, and an admin can activate an available module. + +Purpose: Delivers the core marketplace browsing + activation capability (MRKT-01, MRKT-02 base, MRKT-04). This is the foundational vertical slice that Plan 02 (filters/detail/tenant-context) and Plan 03 (sidebar) build on. +Output: Vitest config + setup, marketplace + sidebar.search translations, marketplace-store (sidebar refresh signal), `/marketplace` page, MarketplaceCard component, and unit tests. + + +## Phase Goal + +**As a** Tessera user (admin), **I want to** browse all available modules in a categorized marketplace and activate them for my tenant, **so that** activated modules become usable in the portal. + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md +@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md +@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md +@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md + + + + + + Task 1: Install and configure Vitest test infrastructure for apps/web + + - apps/web/package.json (current scripts + dependencies — no test runner exists yet) + - package.json (root turbo scripts — note no `test` pipeline yet) + - apps/web/src/messages/de.json (existing namespace structure for mocking next-intl in tests) + - .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Validation Architecture section: Wave 0 gaps, quick run command) + + + Install dev dependencies in `apps/web` via `pnpm --filter @tessera/web add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react` (use the actual package name from apps/web/package.json `name` field for the --filter target). All five packages are mainstream, high-download dev tooling — see Package Legitimacy Audit in 04-RESEARCH.md which records zero new runtime packages; these are dev-only test tooling. + Create `apps/web/vitest.config.ts` configuring the `@vitejs/plugin-react` plugin, `test.environment` set to `jsdom`, `test.globals` true, `test.setupFiles` pointing to `./src/test/setup.ts`, and a resolve alias mapping `@` to `./src` (matching the tsconfig path alias used across the app). + Create `apps/web/src/test/setup.ts` importing `@testing-library/jest-dom/vitest` so DOM matchers are registered. + Add a `"test": "vitest run"` script to `apps/web/package.json` scripts. Add a `"test": "turbo test"` pipeline entry to root `package.json` scripts and a `test` task to turbo.json if a turbo config exists. + Do NOT add any runtime dependencies — this phase ships zero new runtime packages. + + + cd apps/web && pnpm vitest run --reporter=verbose 2>&1 | grep -qiE "no test files|passed|0 passed" && echo VITEST_OK + + + - apps/web/package.json devDependencies contains `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react` + - apps/web/vitest.config.ts exists and contains the string `jsdom` + - apps/web/src/test/setup.ts contains `@testing-library/jest-dom` + - apps/web/package.json scripts contains `"test"` + - `cd apps/web && pnpm vitest run` exits 0 (no failing tests; "no test files" is acceptable at this point) + + Vitest runs in apps/web with jsdom + jest-dom matchers; `pnpm vitest run` exits 0. + + + + Task 2: Add marketplace i18n namespace and build MarketplaceCard component + + - apps/web/src/messages/de.json (existing namespaces: common, sidebar, modules, adminModules — add new `marketplace` namespace and `sidebar.search` key) + - apps/web/src/messages/en.json (mirror structure for EN) + - apps/web/src/app/(portal)/modules/[category]/components/ModuleCard.tsx (analog: card structure, ModuleIcon inline SVG, locale extraction `description[locale] || description.en || description.de`) + - .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (MarketplaceCard Anatomy section: padding p-5, status badge classes, activate/deactivate button classes; Copywriting Contract for all DE/EN strings) + - .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (MarketplaceCard pattern, CSS Token Convention, status color exception) + + + - Test 1: MarketplaceCard renders the module name and the localized description (de locale) as text content + - Test 2: MarketplaceCard renders a category badge showing the module category + - Test 3: When `isActive` is false, the card shows the activate button with the "Modul aktivieren" label and an "available" status badge + - Test 4: When `isActive` is true, the card shows a green "Aktiviert" status badge and the deactivate label "Deaktivieren" + - Test 5: Clicking the activate button calls the `onToggle` prop with the module id and the current active state + + + Add a `marketplace` namespace to both `apps/web/src/messages/de.json` and `en.json` containing every key from the 04-UI-SPEC Copywriting Contract: `title`, `subtitle`, `activate`, `deactivate`, `searchPlaceholder`, status tab keys (`statusAll`, `statusActive`, `statusAvailable`), `categoryAll`, empty-state keys (`emptyTitle`, `emptyBody`, `filteredEmptyTitle`, `filteredEmptyBody`), `errorLoad`, toast keys (`toastActivated`, `toastDeactivated`, `toastActivateError`, `toastDeactivateError`), deactivation dialog keys (`deactivateDialogTitle`, `deactivateDialogBody` with `{moduleName}` placeholder, `deactivateDialogConfirm`, `deactivateDialogCancel`), `tenantContextLabel`, `backToMarketplace`, detail keys (`detailVersion` with `{version}`, `detailStatusActive`, `detailStatusInactive`). Use the exact DE/EN strings from the Copywriting Contract. Also add a `search` key under the existing `sidebar` namespace (DE "Module suchen...", EN "Search modules...") and `sidebar.noResults` (DE "Keine Module gefunden", EN "No modules found"). + Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` as a `'use client'` component. Props: `{ id: string; name: string; slug: string; description: Record; icon?: string; category: string; isActive: boolean; isToggling: boolean; onToggle: (id: string, currentlyActive: boolean) => void }`. Reuse the ModuleIcon inline-SVG pattern and locale extraction from ModuleCard.tsx (`const locale = (typeof window !== 'undefined' && document.documentElement.lang) || 'de'`). Render: icon container `rounded-md bg-muted p-2.5`, module name `font-semibold text-base`, a category badge `rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground`, a status badge (active: `bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400`; available: `bg-muted text-muted-foreground`) with `role="status"`, the localized description with `line-clamp-2`, the version text, and an action button. Active card shows the deactivate button (`border border-border text-foreground hover:bg-muted`), inactive shows the activate button (`bg-primary text-primary-foreground`). When `isToggling`, disable the button and show a spinner. Button click calls `onToggle(id, isActive)`. Use `useTranslations('marketplace')`. Card container classes per UI-SPEC: `rounded-lg border border-border bg-card p-5 shadow-sm transition-all hover:shadow-md hover:border-primary/30`, min-height 180px. + Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx` implementing the five tests in . Mock `next-intl`'s `useTranslations` to return the key (or a minimal lookup) so labels are assertable. + Do NOT inline-render `description` directly (it is a JSON object — would render `[object Object]`); always extract the locale value. + + + cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx" 2>&1 | grep -qiE "passed" && echo CARD_TESTS_OK + + + - apps/web/src/messages/de.json contains a `marketplace` object with key `activate` equal to "Modul aktivieren" + - apps/web/src/messages/en.json contains a `marketplace` object with key `activate` equal to "Activate Module" + - apps/web/src/messages/de.json `sidebar` object contains key `search` equal to "Module suchen..." + - apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx contains `role="status"` and `line-clamp-2` + - MarketplaceCard.tsx extracts localized description (contains `description.en` or `description[locale]` fallback chain) + - `pnpm vitest run` on MarketplaceCard.test.tsx reports all 5 tests passed + + MarketplaceCard renders name, localized description, category + status badges, and activate/deactivate button; clicking calls onToggle; all 5 unit tests pass. + + + + Task 3: Build the /marketplace page and marketplace-store, wiring browse + activate end-to-end + + - apps/web/src/app/(portal)/admin/modules/page.tsx (analog: parallel Promise.all fetch of /modules + /modules/active, activation Map, toggleModule, role gate `isAdmin`, error banner) + - apps/web/src/lib/stores/sidebar-store.ts (Zustand persist pattern) and apps/web/src/lib/stores/auth-store.ts (non-persisted store pattern, AuthUser.role) + - apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx (created Task 2 — the card this page renders) + - apps/web/src/app/(portal)/modules/[category]/page.tsx (loading spinner + empty-state pattern, responsive grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`) + - .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Marketplace Page layout, grid breakpoints 1/2/3/4 cols, States Matrix) + - .planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md (marketplace/page.tsx pattern, API Fetch Convention) + + + - Test 1: When fetch returns a list of modules, the page renders one MarketplaceCard per module (assert by module name text) + - Test 2: When /modules/active returns one of the modules, that module's card shows the activated status (green badge / deactivate label) + - Test 3: When a non-admin user is in the auth store, the page renders the access-denied message and no cards + - Test 4: When the modules list is empty, the page renders the empty-state heading from the marketplace namespace + + + Create `apps/web/src/lib/stores/marketplace-store.ts` as a non-persisted Zustand store: `{ sidebarRefreshKey: number; bumpSidebarRefresh: () => void; selectedTenantId: string | null; setSelectedTenantId: (id: string | null) => void }`. `bumpSidebarRefresh` increments `sidebarRefreshKey`. This store is the signal Plan 03's sidebar subscribes to for re-fetch after activation (avoids the race-condition pitfall in 04-RESEARCH.md Pitfall 2), and holds the Super-Admin tenant context used by Plan 02. Export `useMarketplaceStore`. + Create `apps/web/src/app/(portal)/marketplace/page.tsx` as a `'use client'` component following the admin/modules/page.tsx pattern: `const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'`. State: `modules`, `activations` (Map), `loading`, `toggling`, `error`. `fetchModules` does `Promise.all([fetch('/modules'), fetch('/modules/active')])` with `credentials: 'include'`; read `selectedTenantId` from useMarketplaceStore and pass `{ 'x-tenant-id': selectedTenantId }` header on the `/modules/active` call when set (header omitted when null). Build the activation Map from the active response. `toggleModule(id, currentlyActive)` POSTs to `/modules/${id}/${currentlyActive ? 'deactivate' : 'activate'}` with `credentials: 'include'` and the same optional x-tenant-id header; on success update the activation Map optimistically and call `bumpSidebarRefresh()` so the sidebar re-fetches. Role-gate with `isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'` (from auth-store) — non-admin renders the common `accessDenied` message. Render page header (h1 `text-2xl font-bold` title, subtitle), error banner (`border-destructive/50 bg-destructive/10`), loading spinner, empty-state (illustration + `emptyTitle`/`emptyBody` from marketplace namespace), and the responsive card grid `grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4` mapping modules to `` passing `isActive={activations.has(mod.id)}` and `onToggle={toggleModule}`. Filters and tenant selector are added in Plan 02 — keep this page rendering all modules for now. + Create `apps/web/src/app/(portal)/marketplace/marketplace.test.tsx` implementing the four tests in . Mock global `fetch`, the auth-store (`useAuthStore`), and `next-intl`. + Do NOT fetch activation status per-card; use one parallel fetch and a client-side Map (04-RESEARCH.md anti-pattern). + + + cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/marketplace.test.tsx" 2>&1 | grep -qiE "passed" && echo PAGE_TESTS_OK + + + - apps/web/src/lib/stores/marketplace-store.ts contains `bumpSidebarRefresh` and `selectedTenantId` + - apps/web/src/app/(portal)/marketplace/page.tsx contains `Promise.all` and `modules/active` + - page.tsx calls `bumpSidebarRefresh` after a successful toggle + - page.tsx renders the access-denied message for non-admin (`role` check against ADMIN/SUPER_ADMIN) + - `pnpm vitest run` on marketplace.test.tsx reports all 4 tests passed + - `cd apps/web && pnpm type-check` exits 0 + + Visiting /marketplace as an admin shows a card grid of all modules with correct activation status; activating a module POSTs to the activate endpoint, updates the card, and bumps the sidebar refresh signal; non-admins see access denied; all 4 page tests pass. + + + + + +Symbols created by this plan (excluded from drift verification by downstream review): +- `MarketplaceCard` (React component) — apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx +- `MarketplacePage` default export — apps/web/src/app/(portal)/marketplace/page.tsx +- `useMarketplaceStore` (Zustand) with fields `sidebarRefreshKey`, `bumpSidebarRefresh`, `selectedTenantId`, `setSelectedTenantId` — apps/web/src/lib/stores/marketplace-store.ts +- i18n namespace `marketplace` and keys `sidebar.search`, `sidebar.noResults` in de.json / en.json +- New file paths: apps/web/vitest.config.ts, apps/web/src/test/setup.ts, marketplace.test.tsx, MarketplaceCard.test.tsx +- New npm dev scripts: `test` in apps/web/package.json and root package.json + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → NestJS API | Authenticated user actions (browse, activate) cross from untrusted client to backend | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-04-01 | Elevation of Privilege | POST /modules/:id/activate | mitigate | Backend RolesGuard already requires ADMIN/SUPER_ADMIN (Phase 3, VERIFIED in module-registry.controller.ts). Frontend additionally role-gates the page with `isAdmin` check; non-admins get accessDenied. Defense-in-depth — backend is authoritative. | +| T-04-02 | Tampering (XSS) | MarketplaceCard description | mitigate | Module description rendered as React text content (not dangerouslySetInnerHTML); React auto-escapes. Locale value extracted as string. | +| T-04-03 | Spoofing | x-tenant-id header on activate | mitigate | TenantMiddleware only honors x-tenant-id when `user.role === 'SUPER_ADMIN'` (VERIFIED tenant.middleware.ts L33). Regular admin's header is ignored; their own JWT tenantId is used. | +| T-04-SC | Tampering | npm dev-dependency installs (vitest et al.) | mitigate | All five installs are mainstream test tooling with millions of weekly downloads; 04-RESEARCH Package Legitimacy Audit records zero new runtime packages. Dev-only, not shipped to production bundle. No [ASSUMED]/[SUS] packages introduced. | + + + +- `cd apps/web && pnpm vitest run` exits 0 with all marketplace tests passing +- `cd apps/web && pnpm type-check` exits 0 +- Manual: visit `/marketplace` logged in as admin — card grid renders, activation toggles status and persists across page reload + + + +- Vitest test infra runs in apps/web +- `/marketplace` route shows all modules as cards with category + status badges (MRKT-01, MRKT-04) +- Admin can activate a module from a card (MRKT-02 base) +- marketplace-store signals sidebar refresh and holds tenant context for downstream plans +- All unit tests green, type-check clean + + + +Create `.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md` when done + diff --git a/.planning/phases/04-marketplace-portal-navigation/04-02-PLAN.md b/.planning/phases/04-marketplace-portal-navigation/04-02-PLAN.md new file mode 100644 index 0000000..7c80980 --- /dev/null +++ b/.planning/phases/04-marketplace-portal-navigation/04-02-PLAN.md @@ -0,0 +1,245 @@ +--- +phase: 04-marketplace-portal-navigation +plan: 02 +type: execute +wave: 2 +depends_on: ["04-01"] +files_modified: + - apps/web/src/app/(portal)/marketplace/page.tsx + - apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx + - apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx + - apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx + - apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx + - apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx + - apps/web/src/app/(portal)/marketplace/components/Toast.tsx + - apps/web/src/app/(portal)/marketplace/[slug]/page.tsx + - apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx +autonomous: true +requirements: [MRKT-01, MRKT-02, MRKT-04] +must_haves: + truths: + - "User can type in the marketplace search box and the card grid live-filters by module name and description (debounced)" + - "User can filter the grid by category chip and by status tab (Alle / Aktiviert / Verfuegbar)" + - "Super-Admin sees a tenant-context dropdown; switching tenant re-fetches activation status for that tenant and activation targets that tenant (D-04)" + - "Deactivating a module shows a confirmation dialog before the deactivate call; activation shows a success toast (no dialog)" + - "User can open a module detail page at /marketplace/[slug] showing the full description, status, and an activation button" + artifacts: + - path: "apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx" + provides: "Debounced (300ms) search input with clear button" + min_lines: 20 + - path: "apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx" + provides: "Tab bar: Alle / Aktiviert / Verfuegbar with counts" + min_lines: 20 + - path: "apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx" + provides: "Horizontal category chip row including 'Alle'" + min_lines: 20 + - path: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx" + provides: "Super-Admin-only tenant dropdown writing selectedTenantId to marketplace-store (D-04)" + contains: "SUPER_ADMIN" + - path: "apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx" + provides: "Deactivation confirmation modal with focus trap" + min_lines: 20 + - path: "apps/web/src/app/(portal)/marketplace/components/Toast.tsx" + provides: "Hand-rolled toast system (Zustand store + toast container)" + contains: "useToastStore" + - path: "apps/web/src/app/(portal)/marketplace/[slug]/page.tsx" + provides: "Compact module detail page" + min_lines: 40 + key_links: + - from: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx" + to: "/tenants" + via: "fetch tenant list (SUPER_ADMIN)" + pattern: "/tenants" + - from: "apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx" + to: "marketplace-store selectedTenantId" + via: "setSelectedTenantId on change" + pattern: "setSelectedTenantId" + - from: "apps/web/src/app/(portal)/marketplace/page.tsx" + to: "useMemo filtered modules" + via: "search AND status AND category composition" + pattern: "filter" +--- + + +Refine the marketplace slice from Plan 01 with the full filtering experience (live search, status tabs, category chips), the Super-Admin tenant-context selector (D-04), asymmetric activation UX (immediate activate + toast, confirm dialog for deactivate), and the compact module detail page at `/marketplace/[slug]`. + +Purpose: Completes MRKT-01 (full discovery with filters), MRKT-02 (Super-Admin per-tenant activation via context switch, D-04/D-05), and MRKT-04 (category filtering). Honors all Claude's-Discretion decisions locked in 04-UI-SPEC. +Output: Search/CategoryFilter/StatusFilter/TenantContextSelector/ActivationDialog/Toast components, the detail page, and the filtered marketplace page. + + +## Phase Goal + +**As a** Tessera admin (or Super-Admin), **I want to** search, filter, and inspect modules — and as a Super-Admin choose which tenant I am managing — **so that** I can confidently activate the right modules for the right tenant. + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md +@.planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md +@.planning/phases/04-marketplace-portal-navigation/04-PATTERNS.md +@.planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md +@.planning/phases/04-marketplace-portal-navigation/04-01-SUMMARY.md + + + + + + Task 1: Build filter components and Toast, wire client-side filtering into the marketplace page + + - apps/web/src/app/(portal)/marketplace/page.tsx (created Plan 01 — extend with filter state + useMemo) + - apps/web/src/lib/stores/marketplace-store.ts (created Plan 01 — selectedTenantId, bumpSidebarRefresh) + - apps/web/src/app/(portal)/admin/tenants/page.tsx (form input styling `flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm`; badge styling for chips) + - .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Interaction Contracts: Status Tabs, Category Chips, Search; Toast Notifications; Copywriting Contract) + - .planning/phases/04-marketplace-portal-navigation/04-RESEARCH.md (Pattern 1 client-side multi-dimension filter; Pattern 4 toast store) + - apps/web/src/messages/de.json (marketplace namespace added in Plan 01) + + + - Test 1: Given modules A and B, typing "A" in MarketplaceSearch filters the grid to only module A (after debounce) + - Test 2: Selecting the "Aktiviert" status tab shows only modules present in the activation map + - Test 3: Selecting a specific category chip shows only modules of that category; "Alle" shows all + - Test 4: Search + status + category filters compose (AND) together + - Test 5: When filters produce zero results, the page renders the filtered-empty heading (`filteredEmptyTitle`), distinct from the no-modules empty state + + + Create `apps/web/src/app/(portal)/marketplace/components/MarketplaceSearch.tsx`: controlled `` with 300ms debounce (simple setTimeout/clearTimeout, no lodash per 04-RESEARCH), placeholder from `marketplace.searchPlaceholder`, a clear (X) button visible when text present, calling an `onChange(value: string)` prop with the debounced value. Use input styling from tenants page. + Create `apps/web/src/app/(portal)/marketplace/components/StatusFilter.tsx`: three tabs "Alle"/"Aktiviert"/"Verfuegbar" (labels from marketplace namespace), props `{ value: 'all'|'active'|'available'; counts: {all:number;active:number;available:number}; onChange }`. Active tab `text-foreground border-b-2 border-primary`, inactive `text-muted-foreground hover:text-foreground`, inline count badge. + Create `apps/web/src/app/(portal)/marketplace/components/CategoryFilter.tsx`: horizontal pill row, first chip "Alle" then one per unique category, props `{ categories: string[]; value: string; onChange }`. Selected `bg-primary text-primary-foreground`, unselected `bg-muted text-foreground hover:bg-muted/80`, `rounded-full px-3 py-1 text-xs font-medium`, container `overflow-x-auto`. + Create `apps/web/src/app/(portal)/marketplace/components/Toast.tsx`: a Zustand `useToastStore` (per 04-RESEARCH Pattern 4 — toasts array, `addToast(type, message)` auto-removing after 4000ms via setTimeout, `removeToast`) AND a `ToastContainer` component rendering bottom-right stacked toasts (`bg-card border border-border shadow-lg rounded-lg p-4`, success green check / error red X, `motion-safe:` slide animation). Export both `useToastStore` and `ToastContainer`. + Modify `apps/web/src/app/(portal)/marketplace/page.tsx`: add state `searchQuery`, `statusFilter` ('all'), `categoryFilter` ('all'). Compute `filteredModules` via `useMemo` composing all three dimensions (search matches name + localized description; status against activation Map; category against `mod.category`) exactly per 04-RESEARCH Pattern 1. Derive unique categories and per-status counts. Render ``, ``, `` above the grid; map `filteredModules` instead of all modules. Add a filtered-empty branch (`filteredEmptyTitle`/`filteredEmptyBody`) distinct from the no-modules empty state. Mount `` and call `addToast('success', t('toastActivated'))` / `addToast('error', t('toastActivateError'))` in toggleModule for the activate path. + Create `apps/web/src/app/(portal)/marketplace/marketplace-filters.test.tsx` implementing the five tests in (use fake timers for debounce). + + + cd apps/web && pnpm vitest run "src/app/(portal)/marketplace/marketplace-filters.test.tsx" 2>&1 | grep -qiE "passed" && echo FILTER_TESTS_OK + + + - MarketplaceSearch.tsx contains a debounce (setTimeout/clearTimeout) and a clear button + - StatusFilter.tsx renders three tabs and uses `border-b-2 border-primary` for the active tab + - CategoryFilter.tsx includes an "Alle" chip and renders `rounded-full` + - Toast.tsx exports `useToastStore` and `ToastContainer`; auto-dismiss uses 4000 + - page.tsx contains `useMemo` composing search, status, and category filters + - page.tsx renders a distinct filtered-empty branch using `filteredEmptyTitle` + - All 5 filter tests pass; `pnpm type-check` exits 0 + + Search, status tabs, and category chips live-filter the grid (composing AND); zero-result filtering shows the filtered-empty state; activation fires a success toast; all 5 tests pass. + + + + Task 2: Add Super-Admin TenantContextSelector and deactivation confirmation dialog (D-04, D-05) + + - apps/web/src/app/(portal)/marketplace/page.tsx (extended in Task 1 — add selector + dialog wiring) + - apps/web/src/lib/stores/marketplace-store.ts (selectedTenantId / setSelectedTenantId — created Plan 01) + - apps/web/src/lib/stores/auth-store.ts (role: SUPER_ADMIN / ADMIN / USER) + - apps/web/src/app/(portal)/admin/tenants/page.tsx (Tenant interface {id,name,slug,isActive,createdAt,userCount}; GET /tenants fetch; delete-confirm modal pattern with overlay bg-black/50, max-w-sm card, cancel/confirm buttons) + - .planning/phases/04-marketplace-portal-navigation/04-UI-SPEC.md (Tenant Context Selector section; Deactivation flow + dialog copy; Accessibility focus management) + - .planning/phases/04-marketplace-portal-navigation/04-CONTEXT.md (D-04 Super-Admin context switch, D-05 regular admin direct, deferred FEAT-V2-04 full impersonation) + + + - Test 1: For a SUPER_ADMIN user, TenantContextSelector fetches /tenants and renders the tenant options; for a non-super-admin it renders nothing + - Test 2: Changing the selector value calls setSelectedTenantId with the chosen tenant id + - Test 3: Clicking deactivate on an active module opens ActivationDialog with the module name in the body (does NOT immediately call the API) + - Test 4: Confirming the dialog calls the deactivate handler; cancelling closes the dialog with no API call + + + Per D-04, build ONLY a lightweight tenant-context selector — NOT a full impersonation system (FEAT-V2-04 is deferred, out of scope). Create `apps/web/src/app/(portal)/marketplace/components/TenantContextSelector.tsx`: `'use client'`, render nothing unless `currentUser?.role === 'SUPER_ADMIN'` (from auth-store). Fetch `GET /tenants` with `credentials: 'include'` (Tenant interface per tenants page). Container `bg-muted/50 rounded-lg p-3 mb-4 border border-border`, label `tenantContextLabel` (12px semibold muted), native ``, props `{ value: string; onChange: (v: string) => void }`, placeholder from `useTranslations('sidebar')('search')`, height 36px, `border border-border rounded-md`, focus `focus:ring-2 focus:ring-ring focus:border-transparent`, `px-3` within the sidebar nav padding. Include an accessible label (`aria-label` = placeholder). No debounce needed (client-side, tiny dataset) — filter immediately. + Modify `apps/web/src/components/layout/sidebar.tsx`: + 1. Add imports `import Link from 'next/link'` and `import { usePathname } from 'next/navigation'`. Add `const pathname = usePathname()` and an `isActive(href)` helper: for "/" return `pathname === '/'`, otherwise `pathname.startsWith(href)`. + 2. Replace EVERY `` (Dashboard, Marketplace, category links, per-module links, all admin links) with ``. Remove the hardcoded Dashboard active class; instead apply active classes conditionally via `isActive(href)`: active = `bg-sidebar-accent text-sidebar-accent-foreground font-medium`, inactive = `text-sidebar-foreground hover:bg-muted` (for top-level) / muted variants for nested module links. Per-module links use `/modules/{category}/{slug}` and get active highlight when `isActive` matches that path. + 3. Insert `` between the main-nav `