presseportale/dev/optimize/entwicklungsplan.md
Kevin Adametz a25355ede3 Plan: Phase 5 vollstaendig abgeschlossen (5.2/5.3/5.5 auf erledigt)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:44:15 +00:00

136 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Entwicklungsplan: System-Optimierung Pressekonto
> Konsolidiert aus `system-optimierung-analyse-codex.md` und `system-optimierungen-analyse-claude.md` (Stand 18.06.2026).
> Reihenfolge nach **Risiko/Nutzen**: erst Korrektheit & Security, dann risikoarme Deduplikation, dann größere Extraktionen.
---
## Stand & Wiedereinstieg (zuletzt: 02.07.2026)
**Phase 5 vollständig abgeschlossen.** Fertig: Phasen **0–5**.
- **5.1** · **5.2** (Form-Rules-Concern + `User::syncCompaniesWithRoles()` + Feldraster-Components `<x-portal.user-role-fields>`/`<x-portal.user-billing-fields>` in create+edit) · **5.3** (`Contact::displayName()`, `Category::localizedName()`, `PressReleaseStatus::badgeClass()` aus 7 divergenten match-Blöcken; toter `portalBadgeColor()`- und `statusColor`-Code entfernt) · **5.4** (page-header 33 Seiten, empty-state 5 States, table-pagination 13 Wrapper) · **5.5** (`<x-portal.alert>`, `<x-session-status>`, `<x-password-input>` — **funktional gegengetestet**).
- Zusätzlich: Backfill-Command `users:backfill-portal-role` + Import-Nachlauf; **Perf-Fix** `/admin/me/press-releases` (107k Company-Hydration weg via `toBase()`-pluck + Memoisierung, `accessibleBy()`-Subquery, Status-Counts via UNION; neue `PressRelease::accessibleStatusCountsFor()`); Fix fehlendes `.search-wrap`-CSS.
Pint sauber, betroffene Test-Filter grün.
**Bewusst NICHT gemacht (dokumentierte Entscheidungen, kein offener Rest):**
- `UserUpsertService`/`SaveUserAction` — als Over-Engineering verworfen (Anlegen vs. Bearbeiten divergieren stark); nur der geteilte Kern `syncCompaniesWithRoles()` gezogen.
- `company-link-picker` + `contact-fields` bleiben inline — company-picker ist zwischen create/edit optisch divergiert (Clear-Button, flux:text vs. CSS-Vars, Grid-Spalten), contact-fields existiert nur in edit → keine Duplizierung zu eliminieren.
- Rate-Limit-Trait (`throttleKey()`/`ensureNotRateLimited()`) — login vs. magic-link teilen zu wenig Fläche.
- `flux:badge :color` in admin/users behält sein eigenes Flux-Farbvokabular (Einzel-Call-Site).
- `<x-portal.money>`/`<x-portal.datetime>` — geringer Hebel, nicht umgesetzt.
**Nächster echter Block: Phase 6** (Web-Frontend) — separat, dem User gehörend, vor jedem Löschen bestätigen lassen.
**Wichtig beim Wiedereinstieg:** `resources/views/components/layouts/app/sidebar.blade.php` gehört dem User und bleibt aus allen Commits ausgeschlossen (`git reset -q -- …` vor jedem `git add`). `resources/views/web/**` und `components/web/**` erst in Phase 6.
---
## Arbeitsregeln (für jeden Schritt)
- **Test-First bei Korrektheits-/Verhaltensänderungen** (Phase 0): erst einen Pest-Test schreiben, der das Soll-Verhalten festschreibt, dann ändern.
- **Test-After bei reiner Extraktion** (Phasen 1–5): bestehende Feature-Tests vor und nach dem Schritt laufen lassen — sie müssen grün bleiben.
- Tests gezielt laufen: `vendor/bin/sail artisan test --compact --filter=<Name>`.
- Nach PHP-Änderungen: `vendor/bin/sail bin pint --dirty --format agent`.
- **Web-Frontend** (`resources/views/web/**`, `components/web/**`) gehört dem User → Phase 6, separat committen, vor jedem Löschen bestätigen lassen.
- Jeder Schritt = ein eigener, fokussierter Commit.
---
## Phase 0 — Korrektheit & Security (höchste Dringlichkeit, Test-First)
Latente Fehlerquellen und Sicherheitslücken. Klein, aber wichtig.
- [x] **0.1 BasicAuth absichern** — `!=` → `hash_equals()` (timing-sicher, beide Felder ohne Short-Circuit). Enabled-Check von `env()` (brach unter `config:cache`/im Bootstrap) in die Middleware zur Laufzeit via `config('auth.basic.*')` verlagert; Config-Block in `config/auth.php` + `.env.example` ergänzt. **Fund:** `auth.basic.user/password` waren nie als Config-Key definiert → Gate war faktisch nicht konfigurierbar; jetzt nur erzwungen, wenn aktiviert UND Zugangsdaten gesetzt. Test: `tests/Feature/BasicAuthMiddlewareTest.php`.
- [x] **0.2 Legacy-Invoice-„latest"-Abfrage zentralisiert** — `LegacyInvoice::scopeLatestFirst()` als Single Source of Truth + `User::latestLegacyInvoice()`; `ApiAccessEligibilityService` und `LegacyApiCustomerReporter` (N+1-frei via Eager-Load-Scope) nutzen sie. **Bewusst erhalten:** unterschiedliches `null`-Verhalten (Gate=deny vs. Report=needs_review) ist kontextuell korrekt, keine Vereinheitlichung. Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`.
- [x] **0.3 Zahlungsoption-Eligibility vereinheitlicht (period-aware, strikt)** — Entscheidung des Users: überall period-aware. Neue Single Source of Truth: `UserPaymentOption::scopeActiveNow()` (Active in laufender Periode ODER laufender Bestandsschutz) + `scopeGrandfatheredNow()`; `User::hasActivePaymentOption()`. Umgestellt: `User::hasUnlimitedPressReleaseQuota`, `User::hasActiveBooking`, `ApiAccessEligibilityService`. Mislabel behoben: `CustomerStatusSummary::$hasGrandfathered` nutzt jetzt `grandfatheredNow()` (spezifisch Bestandsschutz statt „Active ODER Grandfathered"). Test: `tests/Feature/ApiAccessEligibilityServiceTest.php`; 5 bestehende Tests auf gültiges `grandfathered_until` angepasst (waren via Factory-`optional()` nicht-deterministisch). **Folge der strikten Wahl (geklärt 19.06.):** Ein Grandfathered-User **ohne** `grandfathered_until` zählt nicht mehr als aktiv — User-Entscheidung: `NULL = abgelaufen/ungültig`, Verhalten beibehalten (konsistent mit altem API-Gate), kein weiterer Code-Change.
- [x] **0.4 Theme→Portal-Vokabular vereinheitlicht** — **Fund:** `ThemeMiddleware` war **nirgends registriert** und referenzierte projektfremde Themes (`b2in/b2a/stileigentum/style2own` aus einem anderen Projekt) → ersatzlos gelöscht. Theme→Portal-Mapping zentralisiert in `Portal::fromTheme()` (genutzt von `SetCurrentPortal`). Fremden `$defaults`-Fallback im `ThemeServiceProvider` (`b2in`/`build/b2in`) auf echten Portal-Default (`portal`/`build/portal`) korrigiert. Test: `tests/Unit/PortalFromThemeTest.php`.
- [x] **0.5 Race-Conditions abgesichert** — **Fund:** Die empfohlenen Unique-Indizes existieren bereits (`credit_wallets.user_id`, `invoice_number_sequences.circle`) → keine Migration nötig. `CreditWalletService::post` ist damit bereits race-sicher (Unique-Index + `firstOrCreate`/`createOrFirst` fängt Unique-Violation; Saldo-Update unter `lockForUpdate` in Transaktion) → kein Change. `InvoiceNumberGenerator::next()` gehärtet: Erst-Insert-Pfad (kein Seeder → produktiv getroffen) crashte bei parallelem Erstaufruf an der Unique-Violation; jetzt `insertOrIgnore` (race-sicher, null-Branch eliminiert). Test ergänzt: `tests/Feature/Billing/InvoiceNumberGeneratorTest.php`.
---
## Phase 1 — Aufräumen / Toter Code (risikoarm, Paket A)
Keine Fachlogik-Änderung. Reduziert erreichbare Demo-Komponenten und Altlasten.
- [x] **1.1 Prototyp `admin/users/table` entfernt** — Route aus `routes/admin.php` (war ohnehin durch `admin/users/{id}` unerreichbar) + `resources/views/livewire/admin/users/table.blade.php` gelöscht; Eintrag aus `routes/ADMIN_ROUTES.md` entfernt. Regressions-Test in `PanelConsolidationTest` (`Route::has('admin.users.table')` ist false).
- [x] **1.2 Tote Admin-Wrapper-Views entfernt** — verifiziert: kein `view()`/`@include`/`@extends`/`@livewire` rendert `resources/views/admin/*` außer `admin.dashboard` (via `DashboardController`); Wrapper nutzten alte Route-Parameter (`request()->route('company')`). 16 tote Wrapper-Blades gelöscht; `dashboard.blade.php` + die `.md`-Doku behalten.
- [x] **1.3 Tote Helfer entfernt** — `EnsureUserIsAdmin` redundantes `loadMissing('roles')` (Spatie `hasAnyRole()` lädt ohnehin); `statusColor`-Key aus `with()` in admin `edit`/`show` (Blade nutzt nur lokales `$statusClass`); `LegacyInvoicePdfController` Laufzeit-`Schema::hasColumn` (Spalte existiert seit Migration 2026_05_04) + unbenutzter `Schema`-Import.
---
## Phase 2 — Press-Release-Editoren entdoppeln (größter Einzel-Hebel, Paket B)
4 Dateien, ~5.151 Zeilen → **3.920 Zeilen (−1.231, ~24 %)**. In 9 kleinen Commits, jeder mit grüner Suite.
- [x] **2.1 Admin-Editoren nutzen `InteractsWithPressReleaseTags`** — `use`-Statement + ~48 Zeilen je Admin-Datei gelöscht (byte-identische Tag-Logik).
- [x] **2.2 `InteractsWithPressReleaseScheduling`-Trait** — 8 Methoden (updatedPublishMode/ScheduledDate/ScheduledTime, updated, syncScheduledAt, resolveScheduledAt, validateScheduledAtWhenReady, notifyValidationError) aus allen vier extrahiert (~85 Zeilen je Datei).
- [x] **2.3 `formRules()` via `ProvidesPressReleaseFormRules`** — `baseFormRules()` (Inhalt+Scheduling+Embargo); Editoren ergänzen nur `portal`/`companyId`.
- [x] **2.4 `presubmitChecks` kanonisiert** — `ProvidesPressReleasePresubmitChecks` mit der besseren Customer-Variante (Firmenname); Admin erhielt dafür `selectedCompany()`.
- [x] **2.5 Kontakt-Helfer + `tagSuggestionsFor()` zentralisiert** — Fund: Admin-Inline-Query == `Contact::forCompany()`-Scope → `InteractsWithPressReleaseContacts`; `tagSuggestionsFor` in Tags-Trait.
- [x] **2.6 `reprocessAfterContentChange()` im `PressReleaseService`** — 3× `contentChanged→fresh→reclassify→rescore` zentralisiert. Vollständige createDraft/updateContent **bewusst nicht erzwungen** (divergente Admin/Customer-Attribute, große Testfläche).
- [x] **2.7 `Company::scopeSearchByTerm()`** — Admin-Combobox-Suche + `supportsFullTextSearch()` (2×) zentralisiert; Customer behält `CustomerCompanyContext` (join-/user-gescopt).
- [x] **2.8 `<x-portal.pr-presubmit-check>`** — der presentational (wire:model-freie) Checklisten-Block extrahiert + Render-Test. **Konservativ:** interaktive Panels (Tag-Input/Publish-Schedule/Selects) blieben (wire:model/Alpine-Risiko ohne visuelle QA).
- [x] **2.9 `HandlesAdminPressReleaseActions::findAdminPR()`** — ~15× `withoutGlobalScopes()->findOrFail()` zentralisiert. **Status-Badge-Enum bewusst nicht vereinheitlicht** (index `archived/draft→muted` vs. edit/show `→hub` divergieren real — Design-Entscheidung).
**Neue Bausteine:** Concerns `InteractsWithPressReleaseScheduling`, `ProvidesPressReleaseFormRules`, `ProvidesPressReleasePresubmitChecks`, `InteractsWithPressReleaseContacts`, `HandlesAdminPressReleaseActions`; `PressReleaseService::reprocessAfterContentChange()`; `Company::scopeSearchByTerm()`; `<x-portal.pr-presubmit-check>`.
**Offen (Design-Entscheidungen nötig):** kanonische Status-Badge-Farben (2.9); restliche Markup-Component-Extraktion mit visueller QA (2.8).
---
## Phase 3 — Backend-Querschnittslogik zentralisieren (Paket D + Domain)
- [x] **3.1 `Company::scopeAccessibleBy()`** — `owner_user_id == me OR whereHas('users', me)` als Scope (Single Source of Truth); `CompanyController` (2×) und `CustomerCompanyContext::accessibleCompanyQuery()` nutzen ihn. Test: `CompanySearchByTermTest` (accessibleBy).
- [x] **3.2 API-PressRelease-Zugriff + Eager-Loads zentralisieren** — `PressRelease::scopeOwnedByApiUser()` (bewusst enger als `accessibleBy`: nur Autorschaft) für `findOwnedPressRelease` in PressRelease- und Image-Controller; `apiResourceRelations()` bündelt die 5× wiederholte Relation-Liste. Test: `PressReleaseAccessScopeTest`. **Bewusst nicht ersetzt:** Unique-Slug-Reimplementierung — der API-Fallback (`Str::random(8)`) divergiert vom Trait-`slugFallback()`.
- [x] **3.3 `DomainContext` / toter Code** — **Fund:** `SetDomainUrl` war nirgends registriert (toter Code) → gelöscht; die Host→Config-Auflösung läuft real nur noch zentral über `DomainAssetContext::resolve()` (`ThemeServiceProvider`/`SetCurrentPortal`), keine 4-fache Dopplung mehr. **Bewusst nicht angefasst:** `CurrentPortalContext` mutable static → request-scoped Binding (test-gekoppelt, separater Schritt).
- [x] **3.4 Performance** — `LogApiUsage` ist jetzt terminable (INSERT nach gesendeter Antwort, kein Queue-Worker nötig; Startzeit über Request-Attribut). Offene Single-Purchase über `User::openSinglePurchaseCount()` per-Request memoisiert (vorher 3× Count + 1× exists pro Status-Widget-Render → jetzt 1× Count), genutzt von Quota-Total/-Remaining, `hasActiveBooking` und `CustomerStatusSummary`. `CustomerCompanyContext::selectedCompany()` nutzt eine einzige `first()`-Query statt exists()+first() (Verwerfen bei Nichterreichbarkeit bleibt). `LegacySubscriptionService::cancelBundleFor` ist ein einziges Bulk-Update (UserPaymentOption ohne Observer → sicher).
- [x] **3.5 Kleinere Zentralisierungen** — `VatResolver::normalizeCountryCode()`/`isEuCountry()` als SSOT, genutzt von `resolve()`, `VatIdValidationService` und `StripeCheckoutService` (3×). `User::findByEmail()` bündelt die `LOWER(email)`-Lookups (ContactAccessService 2×, SocialAuthService 1×); bleibt bewusst case-insensitiv, weil E-Mails nicht normalisiert gespeichert werden — der indexfreundliche Direktvergleich braucht erst eine Schreib-Normalisierung (offen). Währung über `config('billing.default_currency')` ('EUR'; für Stripe-Calls `strtolower(...)`), ersetzt 8 verstreute `'EUR'`/`'eur'`-Literale und vereinheitlicht nebenbei die uneinheitliche `SinglePurchase.currency`. Neuer `CustomerAccountProvisioner::provisionVerifiedCustomer()` ist die gemeinsame Provisioning-Methode für ContactAccessService und SocialAuthService (verifiziert + aktiv + customer-Rolle). Tests: `UserFindByEmailTest`, erweiterte `VatResolverTest`.
- [x] **3.6 Auth-Gates** — `Gate::define('access-admin'|'access-customer')` als SSOT (AppServiceProvider), nutzbar via `@can`/Policies. `EnsureUserIsAdmin`/`EnsureUserIsCustomer` prüfen jetzt über diese Gates (`Gate::denies(...)`). Der Impersonation-Redirect ist in die eigene Middleware `BlockAdminDuringImpersonation` ausgelagert (Sitzungs-Bedingung statt Rollenfrage), sie läuft vor der Zugriffsprüfung und ist zusätzlich als persistente Livewire-Middleware registriert. Tests: `Auth/AccessGatesTest`, bestehende `UserImpersonationTest` (Redirect bleibt grün).
- **Bewusst NICHT auf `can:`-Routen-Alias umgestellt:** `EnsureUserIsAdmin` ist persistente Livewire-Middleware (`Livewire::addPersistentMiddleware`), die konkrete Klassen erwartet — der parametrisierte `can:access-admin`-Alias kann dort nicht stehen. Die Gates sind die SSOT, die Middleware der konkrete Träger.
- **`EnsureApiUserIsActive` bleibt eigenständig:** Es ist eine `is_active`-Prüfung (keine Rollenfrage) und liefert eine spezifische JSON-Antwort (`'API access is disabled for inactive users.'`, im API-Contract getestet) statt einer generischen 403 — ein `can:`-Gate würde Vertrag und Meldung ändern.
---
## Phase 4 — Web-Routen entflechten (Paket C)
`routes/web.php` > 1100 Zeilen mit kompletter Datenaufbereitung. Schrittweise, gut absicherbar.
- [x] **4.1 Portal-/Domain-Kontext extrahieren** — Die je Route-Closure ~6× wiederholte Host→Portal-Ableitung liegt jetzt in `WebPortalContext::fromRequest()` (liefert `portal`, `language`, `portalValues`, `domainConfig`). Die Domain-/Theme-Anwendung (config + `View::share` + Vite) ist als `DomainAssetContext::applyWebDomainConfig()` zentralisiert (vorher die `$applyWebDomainConfig`-Closure in `routes/web.php`, jetzt ein dünner Delegations-Wrapper, damit Bare-Root- und Edition-Home-Route unverändert bleiben). Abgesichert durch die 92 `Web/`-Tests, die jede Seite über den neuen Kontext laufen lassen.
- [x] **4.2 Published-Release-Query zentralisieren** — vier komponierbare Scopes auf `PressRelease`: `scopePublished()` (Status + gesetztes Datum, ohne Zeitschranke → für Statistik-Fenster), `scopeVisibleInArchive()` (`published()` + `published_at <= now()`), `scopeForPortalValues(array)`, `scopeForLanguage(string)`. ~19 dupliziierte Query-Blöcke in `routes/web.php` (Home, Kategorie, Detail, Veröffentlichen, Suche, Newsrooms, Newsroom-Detail, Legacy-Redirect) darüber zentralisiert; jedes `withoutGlobalScope(PortalScope)` an seiner Stelle erhalten (Klausel-Reihenfolge ist für das Ergebnis irrelevant, da reine AND-Verknüpfung). Test: `Web/PublishedReleaseScopeTest`; die 89 bestehenden `Web/`-Tests bleiben grün.
- [x] **4.3 Page-Data in Services** — acht `App\Services\Web\*PageData`-Klassen (`HomePageData`, `CategoryPageData`, `ReleaseDetailPageData`, `VeroeffentlichenPageData`, `SuchePageData`, `NewsroomsPageData`, `NewsroomDetailPageData`, `PreisePageData`) bündeln die komplette Datenaufbereitung; sie bekommen den `WebPortalContext` (bzw. Portal+Sprache) und liefern das View-Daten-Array.
- [x] **4.4 Routen auf schlanke (Invokable) Controller umstellen** — acht `App\Http\Controllers\Web\*Controller` (je Seite ein Commit) lösen den `WebPortalContext` auf, rufen die Page-Data und geben die View zurück. `routes/web.php` von ~1080 auf 233 Zeilen geschrumpft (nur noch Routing + Legacy-301-Redirects + Bare-Root/Hub). **Hinweis:** Bei `{slug}`-Routen führt die Controller-Signatur `string $edition` vor `string $slug` (positionsgebundene Route-Parameter-Auflösung in Laravel). Verhalten unverändert; die 92 `Web/`-Tests bleiben grün.
---
## Phase 5 — Admin-CRUD & Auth: Concerns + Blade-Components (risikoarm)
- [x] **5.1 Tabellen-Concerns** — `HasSortableTable::sort()` (5 byte-identische Kopien zentralisiert; `$sortBy`/`$sortDir`-Defaults bleiben in der Komponente, da sie divergieren) und `HasFullTextSearch::supportsFullTextSearch()` (9 byte-identische Kopien — 4 Index-Listen + 5 Firmen-Comboboxen; nicht mehr genutzte `DB`-Imports entfernt). `AdminPerformanceCache::activeInactiveStats(Builder)` bündelt den identischen `SUM(CASE WHEN is_active)`-Block der Benutzer- und Firmenliste. Tests: erweiterte `AdminPerformanceCacheTest`; abgesichert durch 99 `Admin/`-Tests. **Bewusst zurückgestellt:** `HasResettableFilters` (die `updatedXxxFilter()` divergieren — manche normalisieren zusätzlich Werte; ein generischer `updated()`-Hook wäre eine Verhaltensänderung) und eine gemeinsame `hydrateRelationCounts()` (Count-Hydration unterscheidet sich je Tabelle real).
- [x] **5.2 User create/edit entdoppeln** — **Erledigt:** `ProvidesUserFormRules`-Concern bündelt die identischen Validierungsblöcke (`userIdentityRules(?int $ignore)`, `userRolesAndCompaniesRules()`, `userBillingRules()`); `User::syncCompaniesWithRoles()` als geteilter Persistenz-Kern; Feldraster-Components `<x-portal.user-role-fields>` + `<x-portal.user-billing-fields>` (nur das innere Grid, Wrapper bleibt pro Seite) in create+edit — via 26 UserManagement-Feature-Tests end-to-end abgedeckt; Nebeneffekt: create zeigt jetzt den zuvor verschluckten `billing.name`-Fehler. **Bewusst verworfen:** gemeinsamer `UserUpsertService`/`SaveUserAction` (Over-Engineering — Bearbeiten trägt zusätzlich Profil/Kontakte/Passwort/2FA); `company-link-picker` (create/edit optisch divergiert) und `contact-fields` (nur in edit) bleiben inline.
- [x] **5.3 Formatierungs-Components/Enum** — **Erledigt (Model-/Enum-Methoden, getestet):** `Contact::displayName()` (6 Stellen; `ContactDisplayNameTest`), `Category::localizedName()` (4 Stellen; `CategoryLocalizedNameTest`), `PressReleaseStatus::badgeClass()` (7 divergente `match`-Blöcke in customer/admin index+show+edit+dashboard zusammengeführt; `PressReleaseStatusBadgeClassTest`). Archiviert/Entwurf jetzt konsistent `muted` (User-Entscheidung 02.07.). Toter Code entfernt: `portalBadgeColor()` (3×) und `statusColor` (Flux-Farben, customer/show). **Bewusst nicht zentralisiert:** `flux:badge :color` in admin/users (eigenes Flux-Farbvokabular, Einzel-Call-Site); `<x-portal.money>`/`<x-portal.datetime>` (geringer Hebel).
- [x] **5.4 Seiten-Struktur-Components** — **erledigt.** `<x-portal.page-header>` (Props size sm/md/lg + descriptionWidth, Slots :meta/:actions) über **33 Seiten** ausgerollt; `<x-portal.empty-state>` (Icon-Slot, variant warm/err, Default-Slot für Onboarding) über **5 States**; `<x-portal.table-pagination>` (:paginator) über **13 Wrapper**. **Bewusst nicht gebaut:** `<x-portal.table-panel>` (nur triviales `<article class="panel">`, keine wiederholte Innenstruktur → Over-Engineering) und `<x-portal.searchable-select>` (existiert nirgends — keine `flux:combobox`). **Bewusst bespoke:** companies/show + press-releases/show (Sonder-Header mit Logo/Untertitel).
- [x] **5.5 Flash/Auth-Components** — **Erledigt:** `<x-portal.alert variant=success|error>` (6 Flash-Blöcke: categories/create+edit, footer-codes/index, presets/index) und `<x-session-status>` (login, forgot-password, register, magic-link). **Bewusst bespoke:** companies/show (icon-loses Flash-Design + info-Variante), verify-email/settings-profile (eigene Status-Logik). **Erledigt (funktional gegengetestet 02.07.2026):** `<x-password-input>` über login/register/reset/confirm — Alpine-`show`-State in die Component verlagert, `:placeholder`-Prop, optionaler `labelAction`-Slot für den „Passwort vergessen?"-Link, `show-error`-Schalter für Bestätigungsfelder. Login/Register/Reset/Confirm im Browser durchgetestet (Show/Hide-Toggle + Placeholder + Fehler). `ensureNotRateLimited()/throttleKey()`-Trait bleibt zurückgestellt (login: 1 Key/5 Versuche/Lockout-Event/clear-on-success vs. magic-link: 2 Keys/3+15 Versuche/Stunden-Decay/kein Event — zu wenig gemeinsame Fläche). `<x-form-field>`/`<x-email-input>`/`<x-auth.divider-actions>` optional, geringer Hebel.
---
## Phase 6 — Web-Frontend (separat, mit User abstimmen)
> `resources/views/web/**` + `components/web/**` gehören dem User. Beratend; eigener Commit. „unused"-Zählungen stammen aus `grep` — dynamische Auflösung vor Löschen prüfen.
- [ ] **6.1 Pricing-Cards** — `web/pressekonto.blade.php` codet 3× ~67-Zeilen-`<article>`s, obwohl datengetriebenes `<x-web.pricing-teaser>` (iteriert `Plan::active()`) existiert. → einsetzen (~200 Zeilen → eine Schleife).
- [ ] **6.2 Release-Cards vereinheitlichen** — `press-release-card` ≈ `presseecho-release-card` (~95 %, letztere 0 Verwendungen). → eine Komponente mit `variant`-Prop oder löschen. Industrie-Icon-Map (3×) → `CategoryService`/Config; Release-Normalisierung (3×) → `ReleaseCardData::from()`.
- [ ] **6.3 Footer/Header/Toter Code** — `site-footer` ≈ `hub/site-footer`, Top-Utility-Bar; tote Legacy-Cards in `web/examples/` nach Bestätigung entfernen; Inline-SVGs → `components/web/icons/`-Set; `<x-web.container>` für `max-w-layout`-Shell.
---
## Reihenfolge-Empfehlung (TL;DR)
1. **Phase 0** zuerst — Korrektheit/Security, Test-First.
2. **Phase 1** — schnelles Aufräumen, kein Risiko.
3. **Phase 2** — größter Dedup-Gewinn (PR-Editoren), inkrementell.
4. **Phase 3 + 4** — Backend/Web-Architektur (mittlerer Aufwand, hoher struktureller Gewinn).
5. **Phase 5** — Admin/Auth-Components.
6. **Phase 6** — Web-Frontend separat mit dem User.
**Beibehalten (gute Muster):** `PressRelease::accessibleBy`, `PortalScope`, `HasUniqueSlug`, Form Requests + Eloquent Resources, `AdminPerformanceCache`, `InteractsWithPressReleaseTags`, `<x-portal.stat-card>`/`components.portal.pagination`.
</content>
</invoke>