19-05-2026 Rebrand Pressekonto, Hub-Flux UI und Legacy-Media-Migration

Umbenennung presseportale → pressekonto in Domains, Themes und Dokumentation.
Design-Tokens, Portal-Shell, Customer-Dashboard, Auth- und Admin-PM-Views.
Artisan-Befehl migrate:legacy-media mit Tests und Hub-Flux-Entwicklungsdocs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-05-19 16:36:13 +00:00
parent 092ee0e918
commit 0a3e52d603
112 changed files with 8464 additions and 1649 deletions

View file

@ -17,6 +17,6 @@ args = [
command = "npx"
[mcp_servers.laravel-boost]
command = "vendor/bin/sail"
command = "php"
args = ["artisan", "boost:mcp"]
cwd = "/var/www/html"

View file

@ -47,7 +47,7 @@ Das Dockerfile wurde angepasst um:
Falls die automatische Installation fehlschlägt, können Sie den Container manuell bauen:
```bash
cd /Users/pandora/Sites/presseportale.com
cd /Users/pandora/Sites/pressekonto.de
docker build --build-arg WWWUSER=501 --build-arg WWWGROUP=20 -f docker/8.4/Dockerfile -t sail-8.4/app docker/8.4
```

View file

@ -1,5 +1,5 @@
{
"name": "Presseportale (Dev Container)",
"name": "Pressekonto (Dev Container)",
"dockerComposeFile": [
"../docker-compose.yml"
],

3
.gitignore vendored
View file

@ -56,4 +56,5 @@ Icon
_static/
_work/
_storage/
_businessportal24.com/
_businessportal24.com/
dev/migration/

View file

@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a multi-domain Laravel application ("Presseportale") that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
This is a multi-domain Laravel application ("Pressekonto") that supports different domains with distinct themes and styling. The application uses Laravel with Livewire, Volt, and Fortify for authentication, along with Flux UI components.
### Supported Domains
- **Main Portal**: presseportale.test (local) / presseportale.com (live) Main admin portal page
- **Main Portal**: pressekonto.test (local) / pressekonto.de (live) Main admin portal page
- **Presseecho**: presseecho.test - Landing page with presseecho theme
- **Business Portal**: businessportal24.test - Landing page with business portal theme

View file

@ -4,7 +4,7 @@
Diese Laravel-Anwendung unterstützt verschiedene Domains mit unterschiedlichen Styles:
- **Haupt-Website**: https://presseportale.test (lokal) / https://presseportale.com (live) Haupt-Portal Admin-Page
- **Haupt-Website**: https://pressekonto.test (lokal) / https://pressekonto.de (live) Haupt-Portal Admin-Page
- **APP_PRESSEECHO**: https://presseecho.test
- **APP_BUSINESSPORTAL**: https://businessportal24.test

View file

@ -8,18 +8,18 @@ Für dein Multi-Domain-Setup empfehle ich folgende Asset-URLs:
| Bereich | Domain | Asset-URL | Port | Verwendung |
|---------|--------|-----------|------|------------|
| **Backend** | presseportale.test | `assets.presseportale.test` | 5177 | Portal + FluxUI |
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.presseportale.test` | 5178 | Beide Frontend-Domains |
| **Backend** | pressekonto.test | `assets.pressekonto.test` | 5177 | Portal + FluxUI |
| **Frontend** | presseecho.test<br>businessportal24.test | `assets-web.pressekonto.test` | 5178 | Beide Frontend-Domains |
### Warum diese URLs?
#### 1. **assets.presseportale.test** (Portal/Backend)
#### 1. **assets.pressekonto.test** (Portal/Backend)
- ✅ Kurz und prägnant
- ✅ Eindeutig dem Portal zugeordnet
- ✅ Keine zusätzliche Subdomain-Tiefe
- ✅ Folgt gängiger Konvention
#### 2. **assets-web.presseportale.test** (Web/Frontend)
#### 2. **assets-web.pressekonto.test** (Web/Frontend)
- ✅ Klar als "Web" (Frontend) gekennzeichnet
- ✅ Ein Asset-Server für beide Frontend-Domains
- ✅ Gute Trennung zu Portal-Assets
@ -31,23 +31,23 @@ Falls du andere URLs bevorzugst, hier sind Alternativen:
### Option A: Mit Suffix-Präfix
```
portal-assets.presseportale.test → Port 5177
web-assets.presseportale.test → Port 5178
portal-assets.pressekonto.test → Port 5177
web-assets.pressekonto.test → Port 5178
```
- ⚠️ Etwas länger
- ✅ Sehr explizit
### Option B: Mit "vite" im Namen
```
vite.presseportale.test → Port 5177
vite-web.presseportale.test → Port 5178
vite.pressekonto.test → Port 5177
vite-web.pressekonto.test → Port 5178
```
- ⚠️ Technologie-spezifisch (was wenn du später zu einem anderen Build-Tool wechselst?)
- ⚠️ Weniger klar was geladen wird
### Option C: Separate Domains pro Frontend
```
assets.presseportale.test → Port 5177 (Portal)
assets.pressekonto.test → Port 5177 (Portal)
assets.presseecho.test → Port 5178 (Presseecho)
assets.businessportal24.test → Port 5178 (Businessportal24)
```
@ -61,7 +61,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
```yaml
# Portal Assets (Backend)
- "traefik.http.routers.assets-portal.rule=Host(`assets.presseportale.test`)"
- "traefik.http.routers.assets-portal.rule=Host(`assets.pressekonto.test`)"
- "traefik.http.routers.assets-portal.entrypoints=websecure"
- "traefik.http.routers.assets-portal.tls=true"
- "traefik.http.routers.assets-portal.service=assets-portal-service"
@ -69,7 +69,7 @@ assets.businessportal24.test → Port 5178 (Businessportal24)
- "traefik.http.services.assets-portal-service.loadbalancer.server.scheme=http"
# Web Assets (Frontend)
- "traefik.http.routers.assets-web.rule=Host(`assets-web.presseportale.test`)"
- "traefik.http.routers.assets-web.rule=Host(`assets-web.pressekonto.test`)"
- "traefik.http.routers.assets-web.entrypoints=websecure"
- "traefik.http.routers.assets-web.tls=true"
- "traefik.http.routers.assets-web.service=assets-web-service"
@ -89,8 +89,8 @@ ports:
```env
# Vite Asset Domains
ASSET_URL_PORTAL=https://assets.presseportale.test
ASSET_URL_WEB=https://assets-web.presseportale.test
ASSET_URL_PORTAL=https://assets.pressekonto.test
ASSET_URL_WEB=https://assets-web.pressekonto.test
# Vite Development Ports
VITE_PORT_PORTAL=5177
@ -102,11 +102,11 @@ VITE_PORT_WEB=5178
Füge folgende Einträge zu deiner `/etc/hosts` (Linux/Mac) oder `C:\Windows\System32\drivers\etc\hosts` (Windows) hinzu:
```
127.0.0.1 presseportale.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
127.0.0.1 assets.presseportale.test
127.0.0.1 assets-web.presseportale.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets-web.pressekonto.test
```
## Vite-Konfigurationen
@ -121,7 +121,7 @@ export default defineConfig({
host: "0.0.0.0",
port: 5177,
hmr: {
host: "assets.presseportale.test", // ← Asset-URL
host: "assets.pressekonto.test", // ← Asset-URL
protocol: "wss",
},
},
@ -139,7 +139,7 @@ export default defineConfig({
host: "0.0.0.0",
port: 5178,
hmr: {
host: "assets-web.presseportale.test", // ← Asset-URL
host: "assets-web.pressekonto.test", // ← Asset-URL
protocol: "wss",
},
},
@ -156,16 +156,16 @@ Browser-Request
2. Laravel lädt View mit: @vite(['resources/css/web/theme-presseecho.css', ...])
3. Vite-Helper generiert: <script src="https://assets-web.presseportale.test/@vite/client"></script>
<link href="https://assets-web.presseportale.test/resources/css/web/theme-presseecho.css">
3. Vite-Helper generiert: <script src="https://assets-web.pressekonto.test/@vite/client"></script>
<link href="https://assets-web.pressekonto.test/resources/css/web/theme-presseecho.css">
4. Browser requested: assets-web.presseportale.test
4. Browser requested: assets-web.pressekonto.test
5. Traefik routet zu: Container Port 5178
6. Vite Web Server antwortet
7. HMR WebSocket öffnet: wss://assets-web.presseportale.test
7. HMR WebSocket öffnet: wss://assets-web.pressekonto.test
8. ✅ Hot Module Replacement funktioniert!
```
@ -175,8 +175,8 @@ Browser-Request
### 1. DNS-Auflösung testen
```bash
# Sollte zu 127.0.0.1 auflösen
ping assets.presseportale.test
ping assets-web.presseportale.test
ping assets.pressekonto.test
ping assets-web.pressekonto.test
```
### 2. Vite-Server starten
@ -195,15 +195,15 @@ Du solltest sehen:
### 3. Browser-Test
Öffne:
- https://presseportale.test (sollte Assets von assets.presseportale.test laden)
- https://presseecho.test (sollte Assets von assets-web.presseportale.test laden)
- https://businessportal24.test (sollte Assets von assets-web.presseportale.test laden)
- https://pressekonto.test (sollte Assets von assets.pressekonto.test laden)
- https://presseecho.test (sollte Assets von assets-web.pressekonto.test laden)
- https://businessportal24.test (sollte Assets von assets-web.pressekonto.test laden)
### 4. HMR-Test
1. Öffne Browser DevTools (F12)
2. Gehe zu "Network" Tab
3. Filter auf "WS" (WebSocket)
4. Du solltest Verbindungen zu `wss://assets.*.presseportale.test` sehen
4. Du solltest Verbindungen zu `wss://assets.*.pressekonto.test` sehen
5. Ändere eine CSS-Datei
6. Browser sollte automatisch neu laden (ohne vollständigen Page-Refresh)
@ -256,8 +256,8 @@ docker compose logs laravel.test | grep traefik
### ✅ Verwende diese Asset-URLs:
```
assets.presseportale.test → Port 5177 (Portal/Backend)
assets-web.presseportale.test → Port 5178 (Web/Frontend)
assets.pressekonto.test → Port 5177 (Portal/Backend)
assets-web.pressekonto.test → Port 5178 (Web/Frontend)
```
### ✅ Vorteile:

View file

@ -9,14 +9,14 @@ Füge die folgenden Variablen zu deiner `.env`-Datei hinzu:
```env
# Domain-Konfigurationen
APP_NAME=presseportale
APP_URL=https://presseportale.test
APP_NAME=pressekonto
APP_URL=https://pressekonto.test
APP_PRIMARY="#3ea3dc"
APP_ACCENT="#5c5c60"
# Entwicklungseinstellungen für Domains
DEV_SIMULATE_DOMAIN=false
DEV_SIMULATED_DOMAIN=presseportale.test
DEV_SIMULATED_DOMAIN=pressekonto.test
```
## Entwicklungsmodus
@ -34,10 +34,10 @@ unabhängig von der tatsächlichen URL.
Jede Domain kann eigene Einstellungen haben:
### Haupt-Website (presseportale.test)
### Haupt-Website (pressekonto.test)
- `APP_URL`: Die Domain für die Haupt-Website (https://presseportale.test)
- `APP_NAME`: Der Name der Haupt-Website (presseportale)
- `APP_URL`: Die Domain für die Haupt-Website (https://pressekonto.test)
- `APP_NAME`: Der Name der Haupt-Website (pressekonto)
- `APP_PRIMARY`: Die primäre Farbe im HEX-Format (#3ea3dc)
- `APP_ACCENT`: Die Akzentfarbe im HEX-Format (#5c5c60)
@ -62,7 +62,7 @@ Jede Domain kann eigene Einstellungen haben:
Um die verschiedenen Domains lokal zu testen, füge folgende Zeilen zu deiner Hosts-Datei hinzu:
```
127.0.0.1 presseportale.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
```
@ -101,7 +101,7 @@ Im Code kannst du auf die Domain-Konfiguration zugreifen:
Das Projekt verwendet bereits eine Multi-Domain-Architektur mit:
- **Hauptwebsite:** `presseportale.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
- **Hauptwebsite:** `pressekonto.test` - Hauptwebsite mit blauem Theme (#3ea3dc)
- **Presseecho:** `presseecho.test` - Presseecho-Website mit rotem Theme (#e94a3c)
- **Business Portal:** `businessportal24.test` - Business Portal mit orangem Theme (#f69f0f)

View file

@ -2,7 +2,7 @@
## 🔍 Ursprüngliches Problem
Auf https://presseportale.test erschien der Fehler:
Auf https://pressekonto.test erschien der Fehler:
```
[Error] Not allowed to use restricted network host "0.0.0.0":
https://0.0.0.0:5178/@vite/client
@ -53,7 +53,7 @@ npm run dev:all
```bash
# Portal CSS wird korrekt geladen:
curl -Iks https://assets.presseportale.test/resources/css/portal.css
curl -Iks https://assets.pressekonto.test/resources/css/portal.css
# → HTTP/2 200 ✅
# Web Assets funktionieren:
@ -65,13 +65,13 @@ curl -Iks https://assets.businessportal24.test/resources/css/web/theme-businessp
| Domain | Asset-Domain | Port | Build-Dir | CSS-Datei |
|--------|-------------|------|-----------|-----------|
| presseportale.test | assets.presseportale.test | 5177 | build/portal | portal.css |
| pressekonto.test | assets.pressekonto.test | 5177 | build/portal | portal.css |
| presseecho.test | assets.presseecho.test | 5178 | build/web | theme-presseecho.css |
| businessportal24.test | assets.businessportal24.test | 5178 | build/web | theme-businessportal24.css |
## 🚀 Nächste Schritte
1. **Browser testen**: Öffne https://presseportale.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
1. **Browser testen**: Öffne https://pressekonto.test und mache einen Hard-Refresh (`Ctrl+Shift+R`)
2. **Keine Fehler mehr**: Die "0.0.0.0" Fehler sollten verschwunden sein
3. **Assets laden über HTTPS**: Alle CSS/JS-Dateien werden über die korrekten Asset-Subdomains geladen
@ -87,7 +87,7 @@ Falls du die Docker Container neu gestartet hast, stelle sicher dass:
1. ✅ DNS-Einträge in `/etc/hosts` vorhanden sind:
```
127.0.0.1 assets.presseportale.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```

View file

@ -4,7 +4,7 @@
### 1. `config/domains.php`
Jede Domain hat jetzt eine dedizierte `asset_url`:
- `portal`: `https://assets.presseportale.test`
- `portal`: `https://assets.pressekonto.test`
- `presseecho`: `https://assets.presseecho.test`
- `businessportal24`: `https://assets.businessportal24.test`
@ -39,7 +39,7 @@ sleep 5 && tail -30 /tmp/vite-server.log
### 2. Im Browser testen
Öffne mit Hard-Refresh (`Ctrl+Shift+R`):
- ✅ https://presseportale.test
- ✅ https://pressekonto.test
- ✅ https://presseecho.test
- ✅ https://businessportal24.test
@ -52,7 +52,7 @@ https://0.0.0.0:5178/@vite/client
**NACHHER (✅)**:
```
https://assets.presseportale.test/@vite/client
https://assets.pressekonto.test/@vite/client
https://assets.presseecho.test/@vite/client
https://assets.businessportal24.test/@vite/client
```

View file

@ -54,7 +54,7 @@ composer require laravel/fortify laravel/sanctum
## Routen
### Web-Authentifizierung (presseportale.test)
### Web-Authentifizierung (pressekonto.test)
- `GET /login` - Anmeldeseite (Livewire)
- `POST /login` - Anmeldung (Livewire)
@ -68,7 +68,7 @@ composer require laravel/fortify laravel/sanctum
- `GET /verify-email` - E-Mail-Verifizierung (Livewire)
- `GET /confirm-password` - Passwort bestätigen (Livewire)
### API-Routen (api.presseportale.test)
### API-Routen (api.pressekonto.test)
- `GET /api/user` - Aktueller Benutzer (geschützt)
- `GET /api/profile` - Benutzerprofil (geschützt)
@ -78,7 +78,7 @@ composer require laravel/fortify laravel/sanctum
### Web-Authentifizierung
1. Besuchen Sie `http://portal.presseportale.test/login`
1. Besuchen Sie `http://portal.pressekonto.test/login`
2. Registrieren Sie sich oder melden Sie sich an
3. Nutzen Sie die verschiedenen Authentifizierungsfeatures
@ -87,7 +87,7 @@ composer require laravel/fortify laravel/sanctum
1. **Token erstellen**:
```bash
curl -X POST http://api.presseportale.test/login \
curl -X POST http://api.pressekonto.test/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password"}'
```
@ -95,7 +95,7 @@ curl -X POST http://api.presseportale.test/login \
2. **Geschützte Route aufrufen**:
```bash
curl -X GET http://api.presseportale.test/api/user \
curl -X GET http://api.pressekonto.test/api/user \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```

View file

@ -6,7 +6,7 @@ Das Projekt nutzt ein dynamisches Theme-System mit 3 Domains:
| Domain | Theme | Primary Color | Secondary Color | CSS-Datei |
|--------|-------|---------------|-----------------|-----------|
| **presseportale.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
| **pressekonto.test** | portal | #526266 | #82a0a7 | `resources/css/portal.css` |
| **presseecho.test** | presseecho | #345636 (Grün) | #6b8f71 | `resources/css/web/theme-presseecho.css` |
| **businessportal24.test** | businessportal24 | #cf3628 (Rot) | #f0834a | `resources/css/web/theme-businessportal24.css` |
@ -276,8 +276,8 @@ npm run build:web
Der `ThemeServiceProvider` unterstützt einen `?theme=` URL-Parameter zum Testen:
```
https://presseportale.test?theme=presseecho
https://presseportale.test?theme=businessportal24
https://pressekonto.test?theme=presseecho
https://pressekonto.test?theme=businessportal24
```
### Via Host
@ -285,7 +285,7 @@ https://presseportale.test?theme=businessportal24
Einfach die entsprechende Domain aufrufen:
```
https://presseportale.test → Portal Theme
https://pressekonto.test → Portal Theme
https://presseecho.test → Presseecho Theme
https://businessportal24.test → Businessportal24 Theme
```

View file

@ -6,7 +6,7 @@ Jede Domain hat jetzt ihre eigene dedizierte Asset-Subdomain:
| Domain | Asset-Subdomain | Port | Vite-Config |
|--------|----------------|------|-------------|
| `presseportale.test` | `assets.presseportale.test` | 5177 | `vite.portal.config.js` |
| `pressekonto.test` | `assets.pressekonto.test` | 5177 | `vite.portal.config.js` |
| `presseecho.test` | `assets.presseecho.test` | 5178 | `vite.web.config.js` |
| `businessportal24.test` | `assets.businessportal24.test` | 5178 | `vite.web.config.js` |
@ -20,7 +20,7 @@ Füge folgende Einträge zu deiner Hosts-Datei hinzu (lokal auf deinem Host-Syst
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
```
127.0.0.1 assets.presseportale.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```
@ -47,7 +47,7 @@ npm run dev:all
### 1. `docker-compose.yml`
Neue Traefik-Routen hinzugefügt:
- `assets.presseportale.test` → Port 5177 (Portal)
- `assets.pressekonto.test` → Port 5177 (Portal)
- `assets.presseecho.test` → Port 5178 (Presseecho)
- `assets.businessportal24.test` → Port 5178 (Businessportal24)
@ -66,7 +66,7 @@ Nach dem Neustart kannst du testen:
```bash
# Im DevContainer:
curl -Ik https://assets.presseportale.test/@vite/client
curl -Ik https://assets.pressekonto.test/@vite/client
curl -Ik https://assets.presseecho.test/@vite/client
curl -Ik https://assets.businessportal24.test/@vite/client
```

View file

@ -6,7 +6,7 @@ Dieses Projekt verwendet **2 separate Vite-Ports** für unterschiedliche Domain-
| Bereich | Port | Vite Config | Tailwind Config | Domains | FluxUI |
|---------|------|-------------|-----------------|---------|--------|
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `presseportale.test` | ✅ Ja |
| **Backend (Portal)** | 5177 | `vite.portal.config.js` | `tailwind.portal.config.js` | `pressekonto.test` | ✅ Ja |
| **Frontend (Web)** | 5178 | `vite.web.config.js` | `tailwind.web.config.js` | `presseecho.test`, `businessportal24.test` | ❌ Nein |
## Warum 2 Ports?
@ -35,15 +35,15 @@ Startet beide Vite-Server parallel mit `concurrently`
npm run dev:portal
```
- Port: 5177
- HMR-Host: assets.presseportale.test
- Domain: presseportale.test
- HMR-Host: assets.pressekonto.test
- Domain: pressekonto.test
### Option 3: Nur Frontend (Web)
```bash
npm run dev:web
```
- Port: 5178
- HMR-Host: assets-web.presseportale.test
- HMR-Host: assets-web.pressekonto.test
- Domains: presseecho.test, businessportal24.test
## Production Build
@ -86,7 +86,7 @@ public/
## Theme-System
### Backend (Portal)
- **Domain:** presseportale.test
- **Domain:** pressekonto.test
- **Theme:** `portal`
- **CSS:** `resources/css/portal.css`
- **Views:** `resources/views/portal/**`
@ -110,8 +110,8 @@ public/
Beide Vite-Server laufen intern auf HTTP (`https: false`), Traefik übernimmt SSL-Terminierung:
- **Portal HMR:** `wss://assets.presseportale.test` → Port 5177
- **Web HMR:** `wss://assets-web.presseportale.test` → Port 5178
- **Portal HMR:** `wss://assets.pressekonto.test` → Port 5177
- **Web HMR:** `wss://assets-web.pressekonto.test` → Port 5178
## Troubleshooting

View file

@ -37,7 +37,7 @@ sudo nano /etc/hosts
**Einträge:**
```
127.0.0.1 assets.presseportale.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets.presseecho.test
127.0.0.1 assets.businessportal24.test
```
@ -51,7 +51,7 @@ Zurück im DevContainer:
tail -20 /tmp/vite-server.log
# Teste die Asset-URLs:
curl -Ik https://assets.presseportale.test/@vite/client
curl -Ik https://assets.pressekonto.test/@vite/client
curl -Ik https://assets.presseecho.test/@vite/client
curl -Ik https://assets.businessportal24.test/@vite/client
@ -63,7 +63,7 @@ curl -Ik https://assets.businessportal24.test/@vite/client
Öffne:
- https://businessportal24.test
- https://presseecho.test
- https://presseportale.test
- https://pressekonto.test
Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains geladen werden!
@ -71,7 +71,7 @@ Die Assets sollten nun korrekt über HTTPS von den jeweiligen Asset-Subdomains g
| Hauptdomain | Asset-Domain | Port | Build-Dir |
|------------|-------------|------|-----------|
| presseportale.test | assets.presseportale.test | 5177 | public/build/portal |
| pressekonto.test | assets.pressekonto.test | 5177 | public/build/portal |
| presseecho.test | assets.presseecho.test | 5178 | public/build/web |
| businessportal24.test | assets.businessportal24.test | 5178 | public/build/web |

View file

@ -65,7 +65,7 @@ Du benötigst **mindestens 2 Vite-Ports**:
│ └──────────────────┘ └──────────────────┘ │
│ ↓ ↓ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ presseportale.test │ │ presseecho.test │ │
│ │ pressekonto.test │ │ presseecho.test │ │
│ │ │ │ businessp24.test│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
@ -80,14 +80,14 @@ Du benötigst **mindestens 2 Vite-Ports**:
- Port: **5177**
- Input: `resources/css/portal.css`
- Build: `public/build/portal`
- HMR: `assets.presseportale.test`
- HMR: `assets.pressekonto.test`
- FluxUI: ✅ Ja
#### ✅ `vite.web.config.js`
- Port: **5178** (geändert von 5177!)
- Input: `theme-presseecho.css`, `theme-businessportal24.css`
- Build: `public/build/web`
- HMR: `assets-web.presseportale.test`
- HMR: `assets-web.pressekonto.test`
- FluxUI: ❌ Nein
#### ❌ `vite.config.js` (deprecated)
@ -190,14 +190,14 @@ npm run build:web
| URL | Vite-Port | Theme | FluxUI |
|-----|-----------|-------|--------|
| https://presseportale.test | 5177 | portal | ✅ |
| https://pressekonto.test | 5177 | portal | ✅ |
| https://presseecho.test | 5178 | presseecho | ❌ |
| https://businessportal24.test | 5178 | businessportal24 | ❌ |
## HMR (Hot Module Replacement)
- **Portal:** `wss://assets.presseportale.test` → Port 5177
- **Web:** `wss://assets-web.presseportale.test` → Port 5178
- **Portal:** `wss://assets.pressekonto.test` → Port 5177
- **Web:** `wss://assets-web.pressekonto.test` → Port 5178
⚠️ **Wichtig:** Traefik muss beide HMR-Hosts routen!
@ -210,12 +210,12 @@ Stelle sicher, dass Traefik beide Vite-Ports routet:
# docker-compose.yml oder traefik.yml
labels:
# Portal Assets
- "traefik.http.routers.vite-portal.rule=Host(`assets.presseportale.test`)"
- "traefik.http.routers.vite-portal.rule=Host(`assets.pressekonto.test`)"
- "traefik.http.routers.vite-portal.service=vite-portal"
- "traefik.http.services.vite-portal.loadbalancer.server.port=5177"
# Web Assets
- "traefik.http.routers.vite-web.rule=Host(`assets-web.presseportale.test`)"
- "traefik.http.routers.vite-web.rule=Host(`assets-web.pressekonto.test`)"
- "traefik.http.routers.vite-web.service=vite-web"
- "traefik.http.services.vite-web.loadbalancer.server.port=5178"
```
@ -223,11 +223,11 @@ labels:
### 2. DNS/Hosts-Datei aktualisieren
```
127.0.0.1 presseportale.test
127.0.0.1 pressekonto.test
127.0.0.1 presseecho.test
127.0.0.1 businessportal24.test
127.0.0.1 assets.presseportale.test
127.0.0.1 assets-web.presseportale.test
127.0.0.1 assets.pressekonto.test
127.0.0.1 assets-web.pressekonto.test
```
### 3. Blade-Templates aktualisieren
@ -258,7 +258,7 @@ Diese Dateien werden nicht mehr benötigt:
npm run dev:all
# 2. Browser öffnen
# - https://presseportale.test (Backend)
# - https://pressekonto.test (Backend)
# - https://presseecho.test (Frontend Grün)
# - https://businessportal24.test (Frontend Rot)

View file

@ -1,6 +1,6 @@
openapi: 3.1.0
info:
title: Presseportale API
title: Pressekonto API
version: 1.0.0
description: >
REST API for customer integrations after the 2026 migration. Legacy

View file

@ -0,0 +1,472 @@
<?php
namespace App\Console\Commands;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateLegacyMedia extends Command
{
protected $signature = 'legacy:migrate-media
{--portal=all : Portal (presseecho|businessportal24|all)}
{--type=all : Medientyp (company-logos|press-release-images|all)}
{--base-path=dev/migration : Lokaler Ordner mit den Legacy-Dateien}
{--dry-run : Nur prüfen, nichts kopieren oder aktualisieren}
{--force : Bereits migrierte Dateien erneut überschreiben}
{--limit=0 : Maximal N Legacy-Datensätze pro Typ und Portal verarbeiten (0 = alle)}';
protected $description = 'Migriert Legacy-Firmenlogos und Pressemitteilungsbilder anhand der Legacy-DB in den finalen Storage.';
private const PORTAL_CONNECTIONS = [
'presseecho' => 'mysql_presseecho',
'businessportal24' => 'mysql_businessportal',
];
/**
* @var array<string, list<string>>
*/
private const SOURCE_DIRECTORIES = [
'company-logos' => [
'uploads/company',
'thumbnails/company',
],
'press-release-images' => [
'uploads/pressreleaseimage',
'uploads/pressreleaseimage_',
'uploads/_pressreleaseimage',
'thumbnails/pressreleaseimage',
],
];
public function __construct(private readonly ImageService $imageService)
{
parent::__construct();
}
public function handle(): int
{
$portals = $this->selectedPortals();
$types = $this->selectedTypes();
if ($portals === []) {
$this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.');
return self::FAILURE;
}
if ($types === []) {
$this->error('Ungültiger Medientyp. Erlaubt: company-logos, press-release-images, all.');
return self::FAILURE;
}
$configuredBasePath = (string) $this->option('base-path');
$basePath = Str::startsWith($configuredBasePath, '/')
? rtrim($configuredBasePath, '/')
: base_path(trim($configuredBasePath, '/'));
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$totals = $this->emptyStats();
foreach ($portals as $portal) {
foreach ($types as $type) {
$stats = match ($type) {
'company-logos' => $this->migrateCompanyLogos($portal, $basePath, $dryRun, $force, $limit),
'press-release-images' => $this->migratePressReleaseImages($portal, $basePath, $dryRun, $force, $limit),
};
foreach ($totals as $key => $value) {
$totals[$key] = $value + $stats[$key];
}
$this->line(sprintf(
'%s/%s: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d',
$portal,
$type,
$stats['legacy_rows'],
$stats['migrated'],
$stats['thumbnail_fallback'],
$stats['updated'],
$stats['already_synced'],
$stats['missing_target'],
$stats['missing_file'],
));
}
}
$this->newLine();
$this->info(sprintf(
'Gesamt: Legacy %d, migriert %d, Thumbnail-Fallback %d, DB-Updates %d, bereits synchron %d, Ziel fehlt %d, Datei fehlt %d%s',
$totals['legacy_rows'],
$totals['migrated'],
$totals['thumbnail_fallback'],
$totals['updated'],
$totals['already_synced'],
$totals['missing_target'],
$totals['missing_file'],
$dryRun ? ' (Dry-Run)' : '',
));
return ($totals['missing_target'] + $totals['missing_file']) > 0
? self::FAILURE
: self::SUCCESS;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migrateCompanyLogos(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'company-logos');
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('company')
->whereNotNull('logo')
->where('logo', '!=', '')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
$processed++;
$stats['legacy_rows']++;
$company = Company::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first(['id', 'logo_path', 'logo_variants']);
if (! $company) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/company/{$row->id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->logo);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$destinationPath = "company-logos/{$portal}/{$company->id}/{$sourceFilename}";
if ($this->isAlreadySynced($company->logo_path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($company->logo_variants) || $company->logo_variants === [])) {
$variants = $this->imageService->generateMissingCompanyLogoVariants($destinationPath);
if ($variants !== []) {
$company->forceFill(['logo_variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/company/{$sourceFilename} (Company #{$company->id})");
continue;
}
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$company->forceFill([
'logo_path' => $destinationPath,
'logo_variants' => $this->imageService->generateMissingCompanyLogoVariants($destinationPath),
])->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function migratePressReleaseImages(string $portal, string $basePath, bool $dryRun, bool $force, int $limit): array
{
$stats = $this->emptyStats();
$sourceIndex = $this->sourceIndex($basePath, $portal, 'press-release-images');
$thumbnailIndex = $this->thumbnailIndex($basePath, $portal);
$processed = 0;
DB::connection(self::PORTAL_CONNECTIONS[$portal])
->table('press_release_image')
->orderBy('id')
->chunk(500, function ($rows) use ($portal, $sourceIndex, $thumbnailIndex, $dryRun, $force, $limit, &$processed, &$stats): bool {
foreach ($rows as $row) {
if ($limit > 0 && $processed >= $limit) {
return false;
}
if (blank($row->image)) {
continue;
}
$processed++;
$stats['legacy_rows']++;
$pressRelease = PressRelease::withoutGlobalScopes()
->where('legacy_portal', $portal)
->where('legacy_id', $row->press_release_id)
->first(['id']);
if (! $pressRelease) {
$stats['missing_target']++;
$this->warn("Ziel fehlt: {$portal}/press_release/{$row->press_release_id}");
continue;
}
$sourceFilename = $this->legacyFilename((string) $row->image);
$sourcePath = $sourceIndex[$sourceFilename] ?? $sourceIndex[Str::lower($sourceFilename)] ?? null;
$usedThumbnailFallback = false;
if (! $sourcePath) {
$sourcePath = $thumbnailIndex[(int) $row->id] ?? null;
$usedThumbnailFallback = $sourcePath !== null;
}
$destinationFilename = $usedThumbnailFallback ? basename($sourcePath) : $sourceFilename;
$destinationPath = "press-releases/{$pressRelease->id}/images/{$destinationFilename}";
$image = PressReleaseImage::withTrashed()
->where('legacy_portal', $portal)
->where('legacy_id', $row->id)
->first();
if ($image && $this->isAlreadySynced($image->path, $destinationPath, $force)) {
$stats['already_synced']++;
if (! $dryRun && (! is_array($image->variants) || $image->variants === [])) {
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
if ($variants !== []) {
$image->forceFill(['variants' => $variants])->save();
$stats['updated']++;
}
}
continue;
}
if (! $sourcePath) {
$stats['missing_file']++;
$this->warn("Datei fehlt: {$portal}/press_release_image/{$sourceFilename} (Image #{$row->id})");
continue;
}
if ($usedThumbnailFallback) {
$stats['thumbnail_fallback']++;
}
$variants = [];
$size = [null, null];
$mime = null;
if (! $dryRun) {
$this->copyToPublicStorage($sourcePath, $destinationPath, $force);
$variants = $this->imageService->generateMissingPressReleaseVariants($destinationPath);
$size = @getimagesize(Storage::disk('public')->path($destinationPath)) ?: [null, null];
$mime = File::mimeType($sourcePath) ?: null;
$image = $image ?: new PressReleaseImage;
$image->forceFill([
'press_release_id' => $pressRelease->id,
'disk' => 'public',
'path' => $destinationPath,
'variants' => $variants,
'title' => $row->title ?: null,
'description' => $row->description ?: null,
'copyright' => $row->copyright ?: null,
'is_preview' => (bool) $row->is_preview_image,
'sort_order' => 0,
'width' => is_int($size[0] ?? null) ? $size[0] : null,
'height' => is_int($size[1] ?? null) ? $size[1] : null,
'mime' => $mime,
'legacy_portal' => $portal,
'legacy_id' => $row->id,
]);
if ($image->trashed()) {
$image->restore();
}
$image->save();
}
$stats['migrated']++;
$stats['updated']++;
}
return true;
});
return $stats;
}
/**
* @return list<string>
*/
private function selectedPortals(): array
{
$portal = (string) $this->option('portal');
return match ($portal) {
'all' => [Portal::Presseecho->value, Portal::Businessportal24->value],
Portal::Presseecho->value, Portal::Businessportal24->value => [$portal],
default => [],
};
}
/**
* @return list<string>
*/
private function selectedTypes(): array
{
$type = (string) $this->option('type');
return match ($type) {
'all' => ['company-logos', 'press-release-images'],
'company-logos', 'press-release-images' => [$type],
default => [],
};
}
/**
* @return array{legacy_rows:int,migrated:int,thumbnail_fallback:int,updated:int,already_synced:int,missing_target:int,missing_file:int}
*/
private function emptyStats(): array
{
return [
'legacy_rows' => 0,
'migrated' => 0,
'thumbnail_fallback' => 0,
'updated' => 0,
'already_synced' => 0,
'missing_target' => 0,
'missing_file' => 0,
];
}
/**
* @return array<string, string>
*/
private function sourceIndex(string $basePath, string $portal, string $type): array
{
$index = [];
foreach (self::SOURCE_DIRECTORIES[$type] as $relativeDirectory) {
$directory = "{$basePath}/{$portal}/{$relativeDirectory}";
if (! File::isDirectory($directory)) {
continue;
}
foreach (File::allFiles($directory) as $file) {
$index[$file->getFilename()] ??= $file->getPathname();
$index[Str::lower($file->getFilename())] ??= $file->getPathname();
}
}
return $index;
}
/**
* @return array<int, string>
*/
private function thumbnailIndex(string $basePath, string $portal): array
{
$directory = "{$basePath}/{$portal}/thumbnails/pressreleaseimage";
if (! File::isDirectory($directory)) {
return [];
}
$index = [];
$priorities = [
'press_image_preview' => 50,
'pressrelease_form' => 40,
'backend_list' => 30,
'press_image_list' => 20,
'press_list_image' => 10,
];
foreach (File::allFiles($directory) as $file) {
if (! preg_match('/-(\d+)\.[^.]+$/', $file->getFilename(), $matches)) {
continue;
}
$legacyImageId = (int) $matches[1];
$thumbnailType = Str::after($file->getPathname(), "{$directory}/");
$thumbnailType = Str::before($thumbnailType, DIRECTORY_SEPARATOR);
$priority = $priorities[$thumbnailType] ?? 0;
$current = $index[$legacyImageId] ?? null;
if (! $current || $priority > ($current['priority'] ?? 0)) {
$index[$legacyImageId] = [
'path' => $file->getPathname(),
'priority' => $priority,
];
}
}
return collect($index)
->map(fn (array $entry): string => $entry['path'])
->all();
}
private function legacyFilename(string $path): string
{
$urlPath = parse_url($path, PHP_URL_PATH);
$filename = basename(rawurldecode((string) ($urlPath ?: $path)));
return Str::of($filename)->trim()->toString();
}
private function isAlreadySynced(?string $currentPath, string $destinationPath, bool $force): bool
{
return ! $force
&& $currentPath === $destinationPath
&& Storage::disk('public')->exists($destinationPath);
}
private function copyToPublicStorage(string $sourcePath, string $destinationPath, bool $force): void
{
if (! $force && Storage::disk('public')->exists($destinationPath)) {
return;
}
$stream = fopen($sourcePath, 'rb');
if ($stream === false) {
throw new \RuntimeException("Quelldatei kann nicht gelesen werden: {$sourcePath}");
}
try {
Storage::disk('public')->put($destinationPath, $stream, 'public');
} finally {
fclose($stream);
}
}
}

View file

@ -16,7 +16,7 @@ class ThemeHelper
'positive' => 'img/logos/portal-logo-positive.svg',
'negative' => 'img/logos/portal-logo-negative.svg',
],
'presseportale' => [
'pressekonto' => [
'positive' => 'img/logos/portal-logo-positive.svg',
'negative' => 'img/logos/portal-logo-negative.svg',
],
@ -100,7 +100,7 @@ class ThemeHelper
{
$config = self::getDomainConfig();
return $config['domain_name'] ?? 'presseportale.test';
return $config['domain_name'] ?? 'pressekonto.test';
}
public static function getDomainUrl(): string
@ -118,12 +118,12 @@ class ThemeHelper
$theme = config('app.theme', 'portal');
$assetUrlMap = [
'portal' => 'https://assets.presseportale.test',
'presseportale' => 'https://assets.presseportale.test',
'portal' => 'https://assets.pressekonto.test',
'pressekonto' => 'https://assets.pressekonto.test',
'presseecho' => 'https://assets.presseecho.test',
'businessportal24' => 'https://assets.businessportal24.test',
];
return $assetUrlMap[$theme] ?? 'https://assets.presseportale.test';
return $assetUrlMap[$theme] ?? 'https://assets.pressekonto.test';
}
}

View file

@ -22,7 +22,7 @@ class MagicLoginLink extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Ihr Login-Link fuer presseportale'
subject: 'Ihr Login-Link fuer pressekonto'
);
}

View file

@ -80,7 +80,7 @@ class AppServiceProvider extends ServiceProvider
config(['app.asset_url' => $assetUrl]);
} catch (\Exception $e) {
// Fallback to default if theme detection fails
config(['app.asset_url' => 'https://assets.presseportale.test']);
config(['app.asset_url' => 'https://assets.pressekonto.test']);
}
}
}

View file

@ -97,7 +97,7 @@ class ThemeServiceProvider extends ServiceProvider
if (app()->environment('local')) {
// Entwicklung: Vite Dev Server mit HMR
$viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.presseportale.test');
$viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.pressekonto.test');
Vite::useHotFile(public_path('hot'));
config(['app.vite_dev_server_url' => $viteDevServerUrl]);
View::share('viteDevServerUrl', $viteDevServerUrl);

View file

@ -177,6 +177,24 @@ class ImageService
);
}
/**
* Generates and persists missing variants for an existing company logo.
*
* @return array<string, string>
*/
public function generateMissingCompanyLogoVariants(string $relativePath): array
{
$disk = $this->disk();
if (! $disk->exists($relativePath)) {
return [];
}
$extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION) ?: 'jpg');
return $this->generateLogoVariants($disk, $relativePath, $extension);
}
private function deleteWithVariants(?string $relativePath, ?array $variants, ?string $diskName = null): void
{
$disk = $diskName ? Storage::disk($diskName) : $this->disk();

View file

@ -13,23 +13,23 @@ return [
*/
'protocol' => env('APP_PROTOCOL', 'https://'),
'domain_portal' => env('APP_PORTAL_NAME', 'presseportale.test'),
'domain_portal' => env('APP_PORTAL_NAME', 'pressekonto.test'),
'domain_presseecho' => env('APP_PRESSEECHO_NAME', 'presseecho.test'),
'domain_businessportal' => env('APP_BUSINESSPORTAL_NAME', 'businessportal24.test'),
'domain_portal_url' => env('APP_PORTAL_URL', 'https://presseportale.test'),
'domain_portal_url' => env('APP_PORTAL_URL', 'https://pressekonto.test'),
'domain_presseecho_url' => env('APP_PRESSEECHO_URL', 'https://presseecho.test'),
'domain_businessportal_url' => env('APP_BUSINESSPORTAL_URL', 'https://businessportal24.test'),
'domains' => [
'portal' => [
'domain_name' => env('APP_PORTAL_NAME', 'presseportale.test'),
'url' => env('APP_PORTAL_URL', 'https://presseportale.test'),
'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.presseportale.test'),
'domain_name' => env('APP_PORTAL_NAME', 'pressekonto.test'),
'url' => env('APP_PORTAL_URL', 'https://pressekonto.test'),
'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.pressekonto.test'),
'theme' => 'main',
'view_prefix' => 'portal',
'assets_dir' => 'build/portal',
'description' => 'Backend Presseportale',
'description' => 'Backend Pressekonto',
'color_scheme' => [
'primary' => env('APP_PORTAL_PRIMARY', '#526266'), //
'secondary' => env('APP_PORTAL_SECONDARY', '#82a0a7'), //
@ -37,14 +37,14 @@ return [
'font' => 'Montserrat',
],
'presseportale' => [
'domain_name' => env('APP_PORTAL_NAME', 'presseportale.test'),
'url' => env('APP_PORTAL_URL', 'https://presseportale.test'),
'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.presseportale.test'),
'theme' => 'presseportale',
'pressekonto' => [
'domain_name' => env('APP_PORTAL_NAME', 'pressekonto.test'),
'url' => env('APP_PORTAL_URL', 'https://pressekonto.test'),
'asset_url' => env('APP_PORTAL_ASSET_URL', 'https://assets.pressekonto.test'),
'theme' => 'pressekonto',
'view_prefix' => 'web',
'assets_dir' => 'build/web',
'description' => 'Hub-Landing presseportale.com (öffentlicher Publisher-Bereich)',
'description' => 'Hub-Landing pressekonto.de (öffentlicher Publisher-Bereich)',
'color_scheme' => [
'primary' => '#1A2540',
'secondary' => '#B07A3A',
@ -52,12 +52,12 @@ return [
'font' => 'Inter Tight',
'brand' => [
'name' => 'presse',
'accent' => 'portale',
'accent' => 'konto',
'tagline_short' => 'Publisher · Hub',
'tagline_long' => 'Der gemeinsame Publisher-Bereich für presseecho und businessportal24 Pressemitteilungen schreiben, redaktionell prüfen lassen, auf beiden Reichweiten veröffentlichen.',
'footer_legal' => '© :year presseportale · Alle Rechte vorbehalten',
'about_label' => 'Über presseportale',
'meta_title' => 'presseportale Publisher-Hub für presseecho und businessportal24',
'footer_legal' => '© :year pressekonto · Alle Rechte vorbehalten',
'about_label' => 'Über pressekonto',
'meta_title' => 'pressekonto Publisher-Hub für presseecho und businessportal24',
'meta_description' => 'Ein Konto, zwei Reichweiten: Pressemitteilungen redaktionell geprüft auf presseecho und businessportal24 gleichzeitig veröffentlichen.',
],
],

View file

@ -25,7 +25,7 @@ return [
|
*/
'dev_url' => env('ASSET_URL', env('VITE_DEV_SERVER_URL', 'https://assets.presseportale.test')),
'dev_url' => env('ASSET_URL', env('VITE_DEV_SERVER_URL', 'https://assets.pressekonto.test')),
/*
|--------------------------------------------------------------------------

View file

@ -20,11 +20,11 @@ class DatabaseSeeder extends Seeder
RolesAndPermissionsSeeder::class,
AdminPresetSeeder::class,
PaymentOptionSeeder::class,
CategorySeeder::class,
// CategorySeeder::class,
]);
$adminUser = User::firstOrCreate([
'email' => 'admin@presseportale.test',
'email' => 'admin@pressekonto.test',
], [
'name' => 'Portal Admin',
'password' => Hash::make('password'),

View file

@ -1,12 +1,12 @@
# Entwicklungskonzept BusinessPortal24, Presseecho & presseportale-Hub Frontend
# Entwicklungskonzept BusinessPortal24, Presseecho & pressekonto-Hub Frontend
> **Stand:** 13. Mai 2026
> **Domains:** `businessportal24.test` / `.com` · `presseecho.test` / `.de` · `presseportale.test` / `.com` (Hub)
> **Theme-Slugs:** `businessportal24` (warm-rotes Editorial) · `presseecho` (grünes Editorial) · `presseportale` (Hub-Blau · Publisher-Landing)
> **Domains:** `businessportal24.test` / `.com` · `presseecho.test` / `.de` · `pressekonto.test` / `.de` (Hub)
> **Theme-Slugs:** `businessportal24` (warm-rotes Editorial) · `presseecho` (grünes Editorial) · `pressekonto` (Hub-Blau · Publisher-Landing)
> **Assets-Dir (geteilt):** `public/build/web/`
> **Ziel:** Editoriales DACH-Pressemitteilungs-Ökosystem mit 1:1-Mockup-Umsetzung. Presseecho nutzt die **gleiche Komponenten-Architektur** wie BP24, der Hub `presseportale.com` ist eine **eigenständige Publisher-Landing** mit klar abgegrenztem Charakter (Hub-Blau + Bernstein, kein Editorial-Feed).
> **Ziel:** Editoriales DACH-Pressemitteilungs-Ökosystem mit 1:1-Mockup-Umsetzung. Presseecho nutzt die **gleiche Komponenten-Architektur** wie BP24, der Hub `pressekonto.de` ist eine **eigenständige Publisher-Landing** mit klar abgegrenztem Charakter (Hub-Blau + Bernstein, kein Editorial-Feed).
Dieses Dokument beschreibt den aktuellen Stand und die wichtigsten Architektur­entscheidungen der BusinessPortal24-, Presseecho- und presseportale-Hub-Frontend-Entwicklung. Es ist die zentrale Anlaufstelle für alle, die im Frontend weiterarbeiten oder neue Seiten ergänzen.
Dieses Dokument beschreibt den aktuellen Stand und die wichtigsten Architektur­entscheidungen der BusinessPortal24-, Presseecho- und pressekonto-Hub-Frontend-Entwicklung. Es ist die zentrale Anlaufstelle für alle, die im Frontend weiterarbeiten oder neue Seiten ergänzen.
---
@ -67,9 +67,9 @@ Request (Host: presseecho.test)
Lokale Domain-Simulation:
- `.env`: `DEV_SIMULATE_DOMAIN=true`, `DEV_SIMULATED_DOMAIN=businessportal24.test|presseecho.test`
- Alternativ: `?theme=businessportal24|presseecho|presseportale` als Query-Parameter
- Alternativ: `?theme=businessportal24|presseecho|pressekonto` als Query-Parameter
> **Hub-Sonderfall (`presseportale.test`):** Diese Domain ist gleichzeitig **Admin-Backend** (Auth/Admin/Customer-Routen, theme = `main`, Build-Dir `build/portal/`) **und** öffentliche **Hub-Landing** (theme = `presseportale`, Build-Dir `build/web/`). In `config/domains.php` existieren beide Einträge (`portal` und `presseportale`) für dieselbe `domain_name`. Der `ThemeServiceProvider` matcht zuerst `portal` (Backend-Standard); für die öffentliche Landing schaltet **`routes/web.php` per `$applyWebDomainConfig('presseportale')` explizit auf das Hub-Theme** um. Auth- und Admin-Routen bleiben unbeeinflusst.
> **Hub-Sonderfall (`pressekonto.test`):** Diese Domain ist gleichzeitig **Admin-Backend** (Auth/Admin/Customer-Routen, theme = `main`, Build-Dir `build/portal/`) **und** öffentliche **Hub-Landing** (theme = `pressekonto`, Build-Dir `build/web/`). In `config/domains.php` existieren beide Einträge (`portal` und `pressekonto`) für dieselbe `domain_name`. Der `ThemeServiceProvider` matcht zuerst `portal` (Backend-Standard); für die öffentliche Landing schaltet **`routes/web.php` per `$applyWebDomainConfig('pressekonto')` explizit auf das Hub-Theme** um. Auth- und Admin-Routen bleiben unbeeinflusst.
### 3.1 Generischer Daten-Provider
@ -138,7 +138,7 @@ Damit das Theme für Presseecho dokumentiert ist, hier der **verbindliche Token-
| Erste Variante | `#1f4d3a → #163a2c` | **zu hell** |
| **Final** | **`#1a3d2e → #122d22`** | **abgenommen** ✅ |
### 3.1.2 presseportale-Hub-Palette (Stand 13.05.2026)
### 3.1.2 pressekonto-Hub-Palette (Stand 13.05.2026)
Der **Hub** ist bewusst eigenständig positioniert: er ist **kein Editorial-Feed**, sondern eine reine Publisher-Landing („Ein Konto zwei Reichweiten"). Er bekommt daher einen ganz eigenen Charakter:
@ -147,7 +147,7 @@ Der **Hub** ist bewusst eigenständig positioniert: er ist **kein Editorial-Feed
* **Akzent:** gedecktes Bernstein `#B07A3A` **bewusst gewählt**, weil weder Orange (BP24) noch Grün (Presseecho). Der Hub steht visuell „zwischen" den beiden Brands.
* **Schrift:** Inter Tight (Standardtext) + JetBrains Mono (Mono) + Source Serif 4 (**nur für Marken-Mentions** der Tochter-Portale, damit typografische Konsistenz zur jeweiligen Brand-Landing erhalten bleibt; im Hub-Fließtext nicht verwendet).
Token-Snapshot aus `resources/css/web/theme-presseportale.css`:
Token-Snapshot aus `resources/css/web/theme-pressekonto.css`:
```css
@theme {
@ -225,12 +225,12 @@ In `config/domains.php` liegt pro Domain ein **`brand`-Block**, der Komponenten
...
],
],
'presseportale' => [ // Hub-Variante (web)
'theme' => 'presseportale',
'pressekonto' => [ // Hub-Variante (web)
'theme' => 'pressekonto',
'brand' => [
'name' => 'presse', // hub-blau
'accent' => 'portale', // bernstein
'footer_legal' => '© :year presseportale · Alle Rechte vorbehalten',
'accent' => 'konto', // bernstein
'footer_legal' => '© :year pressekonto · Alle Rechte vorbehalten',
...
],
],
@ -246,12 +246,12 @@ Die Schreibweise der drei Marken folgt einer einheitlichen Regel: **keine TLD-En
| ------------------ | ------------------------------------------ | ---------------------- | --------------------- |
| `presseecho` | **presse**·*echo* | `#345636` (Forest) | `#9BD5B2` |
| `businessportal24` | **businessportal**·*24* | `#C84A1E` (Orange) | `#F4B098` |
| `presseportale` | **presse**·*portale* | `#B07A3A` (Bernstein) | `#B07A3A` |
| `pressekonto` | **presse**·*konto* | `#B07A3A` (Bernstein) | `#B07A3A` |
**Single Source of Truth:** Die Komponente `<x-web.brand-mark brand="…" />` rendert die Markenschreibung zentral inkl. Span-Splitting, Schriftart und Akzent­farbe. Sie wird überall verwendet, wo eine Marke als Fließtext-Mention erscheint:
* Hub-Komponenten (`hub/top-utility-bar`, `hub/site-header`, `hub/site-footer`, `hub/brand-context-banner`)
* Hub-View `presseportale.blade.php` (Hero-Headline, Architektur-Diagramm, Tarif-Subline, Plattform-Familie, FAQ)
* Hub-View `pressekonto.blade.php` (Hero-Headline, Architektur-Diagramm, Tarif-Subline, Plattform-Familie, FAQ)
* Cross-Brand-Mentions auf BP24-/Presseecho-Landings, falls ergänzt
```blade
@ -417,22 +417,22 @@ Alle Komponenten haben **konsistente Konventionen**:
### 5.4 Hub-Komponenten (`components/web/hub/`)
Der Hub `presseportale.com` hat einen **eigenen, deutlich anderen Charakter** als die beiden Brand-Portale (kein Editorial-Feed, sondern Publisher-Landing) und bekommt daher einen eigenen Komponenten-Namespace. Die Sektionen selbst (Hero, Features, How-it-works, Tarife, Plattform-Familie, Social-Proof, FAQ, CTA) sind als **inline-Markup** in `resources/views/web/presseportale.blade.php` umgesetzt, weil sie page-spezifisch sind.
Der Hub `pressekonto.de` hat einen **eigenen, deutlich anderen Charakter** als die beiden Brand-Portale (kein Editorial-Feed, sondern Publisher-Landing) und bekommt daher einen eigenen Komponenten-Namespace. Die Sektionen selbst (Hero, Features, How-it-works, Tarife, Plattform-Familie, Social-Proof, FAQ, CTA) sind als **inline-Markup** in `resources/views/web/pressekonto.blade.php` umgesetzt, weil sie page-spezifisch sind.
| Datei | Rolle |
| --- | --- |
| `hub/top-utility-bar.blade.php` | Schmale Hub-Blau-Topbar mit Datum, „Publisher-Hub für …"-Brand-Family-Links (rendert `<x-web.brand-mark variant="on-dark" :serif="false">`), Status/Doku/Kontakt. Props: `date`, `siblingPortals` (jetzt Liste mit `brand`-Key statt fixer Strings). |
| `hub/site-header.blade.php` | Wortmark `presse`·`portale` (über `<x-web.brand-mark brand="presseportale" :serif="false">`) + Untertitel „Publisher · Hub", zentrale Primary-Nav (Tarife, So funktioniert es, …), Anmelden + Konto erstellen CTAs. Routes: `login`, `register`. |
| `hub/site-header.blade.php` | Wortmark `presse`·`konto` (über `<x-web.brand-mark brand="pressekonto" :serif="false">`) + Untertitel „Publisher · Hub", zentrale Primary-Nav (Tarife, So funktioniert es, …), Anmelden + Konto erstellen CTAs. Routes: `login`, `register`. |
| `hub/brand-context-banner.blade.php` | **Conditional Banner** unter dem Header greift nur bei `?from=presseecho` oder `?from=businessportal24` und zeigt: „Sie kommen von … Ihr Konto hier funktioniert für beide Portale". Markenname über Brand-Mark (font-serif), „Zurück zu …"-Link nutzt sans-Variante. |
| `hub/site-footer.blade.php` | 4-Spaltiger Hub-Footer (Konto / Plattform / Rechtliches + Brand-Spalte mit Plattform-Familie-Links über Brand-Mark `variant="on-dark"`), Hub-Gradient `linear-gradient(180deg,#1A2540,#0F1729)`. Brand-Block aus `config/domains.php`. |
**Hub-Sektionen als inline-Blade** (in `presseportale.blade.php`):
**Hub-Sektionen als inline-Blade** (in `pressekonto.blade.php`):
1. **Hero** mit Architektur-Diagramm rechts (zentraler Hub-Knoten + Brand-Portal-Karten + Output-Boxen, alles SVG-only).
2. **Was Sie hier können** 3-Karten-Grid (Veröffentlichen / Newsrooms / Reichweite).
3. **So funktioniert es** 4-Step-Ol mit Differenzierungs-Highlight in Schritt 3 (Bernstein-Akzent für „Unsere Qualitätssicherung").
4. **Tarife** 3 Karten (Starter / Standard / Pro mit `.ribbon-recommend`) + breiter Enterprise-Streifen in Hub-Blau.
5. **Hinter presseportale.com** 2-Spalten-Plattform-Familie mit den **Original-Brand-Gradients** der Tochter-Portale (`#1F4D3A→#163A2C` für Presseecho, `#1A1F26→#232A33` für BP24).
5. **Hinter pressekonto.de** 2-Spalten-Plattform-Familie mit den **Original-Brand-Gradients** der Tochter-Portale (`#1F4D3A→#163A2C` für Presseecho, `#1A1F26→#232A33` für BP24).
6. **Aktive Newsrooms** Prose-Auflistung statt Logo-Wall + kompakte Stats-Sidebar.
7. **FAQ** CSS-only-Accordion (`<details>` + `.faq-chev`) mit 8 Fragen, eine offen by default.
8. **CTA-Wiederholung** + Footer.
@ -496,12 +496,12 @@ Drei spiegelbildliche Test-Szenarien:
2. **`feed only shows published presseecho content`** Portal-Trennung gespiegelt: Presseecho + Both sichtbar, BP24-Only und Drafts nicht.
3. **`shows most read releases in the sidebar`** Hits-Sortierung.
### Datei: `tests/Feature/Web/PresseportaleHubHomeTest.php`
### Datei: `tests/Feature/Web/PressekontoHubHomeTest.php`
Fünf Test-Szenarien rund um die Hub-Landing:
1. **`renders the publisher landing shell`** prüft alle Hauptsektionen sind sichtbar (Publisher-Hub, Was Sie hier können, So funktioniert es, Vier Schritte, Tarife, Starter, Standard, Pro, Enterprise, Hinter presseportale.com, Plattform im Überblick, Häufige Fragen, Loslegen, Alle Systeme betriebsbereit).
2. **`loads the hub theme assets, not portal admin`** stellt sicher, dass `theme-presseportale` aus dem Manifest geladen wird (nicht `theme-businessportal24` oder `theme-presseecho`).
1. **`renders the publisher landing shell`** prüft alle Hauptsektionen sind sichtbar (Publisher-Hub, Was Sie hier können, So funktioniert es, Vier Schritte, Tarife, Starter, Standard, Pro, Enterprise, Hinter pressekonto.de, Plattform im Überblick, Häufige Fragen, Loslegen, Alle Systeme betriebsbereit).
2. **`loads the hub theme assets, not portal admin`** stellt sicher, dass `theme-pressekonto` aus dem Manifest geladen wird (nicht `theme-businessportal24` oder `theme-presseecho`).
3. **`hides the brand-context banner without a from parameter`** Default-Aufruf zeigt keinen „Sie kommen von …"-Banner.
4. **`shows the brand-context banner when arriving from presseecho`** `?from=presseecho` triggert den Banner inkl. Link „Zurück zu presseecho.de".
5. **`shows the brand-context banner when arriving from businessportal24`** `?from=businessportal24` triggert den Banner für BP24.
@ -563,9 +563,9 @@ vendor/bin/pint --dirty --format agent
| 13 | 12.05.2026 | Brand-Konfiguration in `config/domains.php` pro Domain (`brand.name`, `brand.accent`, `brand.tagline_*`, `brand.newsletter_topics`, `brand.footer_legal`, `brand.about_label`) `site-header`, `site-footer`, `newsletter-strip` lesen daraus | ✅ |
| 14 | 12.05.2026 | `press-release-feed`-Volt-Component portal-agnostisch (Prop `:portal`); Aufruf vom View-Layer aus | ✅ |
| 15 | 12.05.2026 | `PresseechoHomeTest` analog zu `Businessportal24HomeTest` (3 Szenarien, RefreshDatabase) | ✅ |
| 16 | 13.05.2026 | **presseportale-Hub-Landing live**: neues Web-Theme `presseportale` (Hub-Blau + Bernstein, `theme-presseportale.css`), eigener Komponenten-Namespace `components/web/hub/` (Top-Bar, Site-Header, Brand-Context-Banner, Site-Footer), Hub-View `web/presseportale.blade.php` mit Hero/Architektur-Diagramm, Features, How-it-works, Tarife (Starter/Standard/Pro+Ribbon, Enterprise-Streifen), Plattform-Familie, Aktive-Newsrooms, FAQ-Accordion, CTA. `routes/web.php` schaltet für `presseportale.test` auf das Hub-Theme um. Root-Route in `routes/admin.php` entfernt (Layout referenziert jetzt `route('dashboard')`). | ✅ |
| 17 | 13.05.2026 | `PresseportaleHubHomeTest` (5 Szenarien inkl. Brand-Context-Banner-Conditional). Vite-Config + ThemeHelper + `web-master`-Fonts (Inter Tight + JetBrains Mono ohne Serif) für `presseportale` ergänzt. | ✅ |
| 18 | 13.05.2026 | **Brand-Mark-Konvention etabliert** (Feintuning Marken-Schreibweise): keine TLD am Marken­schriftzug, Akzent farblich vom Basis-Wort abgesetzt. Single Source of Truth `<x-web.brand-mark>` (Marken-Tabelle inkl. Standard- und On-Dark-Akzentfarben, Serif/Sans-Switch). `config/domains.php` umgestellt (`presseecho`: `name=presse`/`accent=echo`; `presseportale`: `name=presse`/`accent=portale`; Footer-Legal & Meta-Texte ohne TLD). Hub-Komponenten und Hub-View durchgehend auf Brand-Mark migriert (Top-Utility-Bar, Site-Header, Brand-Context-Banner, Site-Footer, Hero-Headline, Architektur-Diagramm, Tarif-Subline, Plattform-Familie, FAQ). Hub-Theme bekommt Source Serif 4 als `--font-serif` (für Marken-Mentions) Bunny-Font-Loader erweitert. **+1 neuer Test `uses the brand-mark splitting without TLDs`**; alle 12 Web-Tests grün. | ✅ |
| 16 | 13.05.2026 | **pressekonto-Hub-Landing live**: neues Web-Theme `pressekonto` (Hub-Blau + Bernstein, `theme-pressekonto.css`), eigener Komponenten-Namespace `components/web/hub/` (Top-Bar, Site-Header, Brand-Context-Banner, Site-Footer), Hub-View `web/pressekonto.blade.php` mit Hero/Architektur-Diagramm, Features, How-it-works, Tarife (Starter/Standard/Pro+Ribbon, Enterprise-Streifen), Plattform-Familie, Aktive-Newsrooms, FAQ-Accordion, CTA. `routes/web.php` schaltet für `pressekonto.test` auf das Hub-Theme um. Root-Route in `routes/admin.php` entfernt (Layout referenziert jetzt `route('dashboard')`). | ✅ |
| 17 | 13.05.2026 | `PressekontoHubHomeTest` (5 Szenarien inkl. Brand-Context-Banner-Conditional). Vite-Config + ThemeHelper + `web-master`-Fonts (Inter Tight + JetBrains Mono ohne Serif) für `pressekonto` ergänzt. | ✅ |
| 18 | 13.05.2026 | **Brand-Mark-Konvention etabliert** (Feintuning Marken-Schreibweise): keine TLD am Marken­schriftzug, Akzent farblich vom Basis-Wort abgesetzt. Single Source of Truth `<x-web.brand-mark>` (Marken-Tabelle inkl. Standard- und On-Dark-Akzentfarben, Serif/Sans-Switch). `config/domains.php` umgestellt (`presseecho`: `name=presse`/`accent=echo`; `pressekonto`: `name=presse`/`accent=konto`; Footer-Legal & Meta-Texte ohne TLD). Hub-Komponenten und Hub-View durchgehend auf Brand-Mark migriert (Top-Utility-Bar, Site-Header, Brand-Context-Banner, Site-Footer, Hero-Headline, Architektur-Diagramm, Tarif-Subline, Plattform-Familie, FAQ). Hub-Theme bekommt Source Serif 4 als `--font-serif` (für Marken-Mentions) Bunny-Font-Loader erweitert. **+1 neuer Test `uses the brand-mark splitting without TLDs`**; alle 12 Web-Tests grün. | ✅ |
| 19 | 12.05.2026 | **Aktuell offen:** Detailseite, Branchenseite, Veröffentlichen-Landing für BP24 + Presseecho. Hub-Folgeseiten (Konto-Erstellen-Flow als Landing, Tarif-Detail, Doku-Hub) ebenfalls offen. | 🟡 |
---

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,216 @@
# Phase 0 — Design-Tokens vereinheitlichen
> **Ziel**: Single Source of Truth für alle Design-Tokens (Farben, Fonts,
> Radii, Schatten). Sowohl Hub-Build als auch Portal-Build beziehen ihre
> Werte aus derselben Datei. **Visuell ändert sich noch nichts.**
**Status**: ✅ abgeschlossen am 2026-05-19
**Risiko**: sehr niedrig
**Aufwand (tatsächlich)**: ~½ Tag
## Ergebnis-Check (2026-05-19)
- Single Source of Truth liegt in `resources/css/shared/design-tokens.css`.
- Web-Build und Portal-Build importieren sie beide.
- **Visuelle Unverändertheit verifiziert**:
- Hub (`pressekonto.test/`, `/login`, `/register`) — unverändert.
- Portal (`/dashboard`) — FluxUI-Defaults bleiben dominant
(`--font-sans: "Instrument Sans"`, Zinc-Palette, `#3ea3dc`-Akzent).
- Build-Sizes:
- `theme-pressekonto: 193 kB` (vorher 189 kB · +4 kB für neue Tokens)
- `theme-presseecho`, `theme-businessportal24`: praktisch unverändert
- `portal: 408 kB` (vorher 397 kB · +12 kB für zusätzlich bereitgestellte Token-Vars im `:root`)
- Details: `PROGRESS.md` (Eintrag vom 2026-05-19).
## Warum
Heute leben Tokens an zwei Orten:
- `resources/css/web/theme-pressekonto.css` — Hub-Tokens (Hub-Blau,
Bernstein, Buchpapier, Inter Tight)
- `resources/css/portal.css` — Portal-Tokens (Zinc, `#3ea3dc`, Instrument
Sans)
Solange diese parallel gepflegt werden, **driften** sie auseinander. Wir
ziehen die gemeinsame Wahrheit in eine eigene Datei und referenzieren
sie aus beiden Welten.
## Liefergegenstand
```
resources/css/shared/
└── design-tokens.css ← NEU
```
Inhalt: alle `--color-*`, `--font-*`, `--radius-*`, `--shadow-*` Tokens,
die in Hub und Portal gleichermaßen gelten. Strukturiert als
`@theme`-Block, sodass Tailwind v4 die Variablen sowohl als
CSS-Custom-Properties als auch als Tailwind-Utility-Klassen erkennt.
## Schritte
### 1. Token-Inventur aus `theme-pressekonto.css`
Die folgenden Tokens werden aus `theme-pressekonto.css` extrahiert und
nach `shared/design-tokens.css` verschoben:
#### Surfaces
- `--color-bg`, `--color-bg-elev`, `--color-bg-rule`, `--color-bg-rule-strong`
- `--color-bg-dark`, `--color-bg-card`, `--color-bg-card-warm`,
`--color-bg-card-warm-border`, `--color-bg-card-warm-hover`,
`--color-bg-card-warm-rule`
#### Hub-Palette
- `--color-hub`, `--color-hub-2`, `--color-hub-3`
- `--color-hub-soft`, `--color-hub-soft-2`, `--color-hub-line`
- `--color-topbar`, `--color-topbar2`, `--color-topbar-deep`
#### Akzent (Bernstein)
- `--color-accent`, `--color-accent-deep`, `--color-accent-soft`,
`--color-accent-warm`
#### Ink (Anthrazit)
- `--color-ink`, `--color-ink-2`, `--color-ink-3`, `--color-ink-4`
- `--color-ink-on-dark`, `--color-ink-on-dark-2`, `--color-ink-on-dark-3`,
`--color-ink-on-dark-muted`, `--color-ink-on-dark-rule`
#### Brand-Aliase + Status
- `--color-brand`, `--color-brand-deep`, `--color-brand-soft`
- `--color-live`, `--color-gain`, `--color-loss`, `--color-ok`
#### Editorial / Cards
- `--color-card-warm-cat`, `--color-card-warm-title`,
`--color-feature-line`, `--color-feature-dot`
#### Status (für KPI-Cards / Badges — laut Mockup ergänzen)
- `--color-warn` `#A87A1F`, `--color-warn-soft` `#F6EAC8`
- `--color-err` `#A8331F`, `--color-err-soft` `#F4DAD2`
- `--color-ok-soft` `#E2F1E5`
#### Fonts
- `--font-sans` (Inter Tight)
- `--font-serif` (Source Serif 4 — nur für Brand-Mark)
- `--font-mono` (JetBrains Mono)
#### Layout
- `--container-layout: 1280px`
#### Radii (laut Mockup)
- `--radius-xs: 3px`, `--radius-sm: 4px`, `--radius-md: 6px`,
`--radius-lg: 8px`
#### Schatten (laut Mockup + Hub-Login)
- `--shadow-soft`: leicht warm, für Cards
- `--shadow-card`: Standard-Card-Schatten
- `--shadow-card-hover`: Hover-Stufe
- `--shadow-auth`: weiche Glocke unter Auth-Card
### 2. Datei `resources/css/shared/design-tokens.css` anlegen
Aufbau:
```css
/**
* Hub × FluxUI — Gemeinsame Design-Tokens
*
* Single Source of Truth für Hub-Frontend (build/web) und Portal-Backend
* (build/portal). Beide CSS-Builds @import diese Datei.
*
* Token-Names sind STABIL — Werte können sich ändern (z.B. Dark Mode in
* Phase 5), Namen nicht.
*/
@theme {
/* Surfaces */
--color-bg: #f6f4ef;
--color-bg-elev: #fbfaf6;
/* … alle Tokens aus der Inventur … */
}
/* Dark Mode (vorbereitet, in Phase 5 finalisiert) */
@media (prefers-color-scheme: dark) {
@theme {
/* Spätere Dark-Werte */
}
}
```
### 3. `theme-pressekonto.css` refactoren
Die `@theme {}`-Definitionen werden durch einen
`@import "../shared/design-tokens.css";` ersetzt. Nur die `@layer
components {}`-Klassen (`.eyebrow`, `.auth-card`, `.field-input`, etc.)
bleiben in `theme-pressekonto.css`.
`:root { … --background, --primary … }`-HSL-Variablen für Legacy-Komponenten
bleiben ebenfalls hier (sind Portal-unspezifisch).
### 4. `portal.css` minimal vorbereiten (noch keine Werte übernehmen)
In Phase 0 importieren wir die Token-Datei in `portal.css`, **aber lassen
das alte `@theme`-Setup mit Zinc/Accent zunächst stehen**. Damit:
- Beide Welten greifen technisch auf die gleiche Datei zu
- Aber Portal bleibt visuell unverändert (Zinc-Palette gewinnt durch
Reihenfolge im `@theme`)
In Phase 1 wird das Zinc-Setup dann **gezielt durch Hub-Werte ersetzt**.
### 5. Build & Verifikation
```bash
npm run build:web # → erzeugt theme-pressekonto.css ohne Drift
npm run build:portal # → erzeugt portal.css unverändert
```
Erwartung:
- Hub-Landing rendert visuell **identisch** wie vorher
- Hub-Auth-Pages rendern visuell **identisch** wie vorher
- Portal rendert visuell **identisch** wie vorher
Smoke-Test (kein neues Test-Schreiben nötig):
```bash
php artisan tinker --execute '
$urls = [
"https://pressekonto.test/",
"https://pressekonto.test/login",
"https://pressekonto.test/dashboard",
];
foreach ($urls as $u) {
echo $u . " => " .
app(\Illuminate\Contracts\Http\Kernel::class)
->handle(\Illuminate\Http\Request::create($u, "GET"))
->getStatusCode() . "\n";
}'
```
Alle 3 URLs müssen weiterhin `200` liefern (für `/dashboard` ggf.
`302`-Redirect, je nach Auth-Status — beides ist okay, solange kein `500`).
## Akzeptanzkriterien
- [ ] `resources/css/shared/design-tokens.css` existiert mit allen Hub-Tokens
- [ ] `theme-pressekonto.css` importiert die Token-Datei und enthält
keine doppelten `--color-*`-Definitionen mehr
- [ ] `portal.css` importiert die Token-Datei (Werte werden in Phase 1 genutzt)
- [ ] `npm run build:web` und `npm run build:portal` laufen ohne Fehler durch
- [ ] Hub-Landing, Hub-Auth und Portal-Login visuell **unverändert**
- [ ] Pint passed (`vendor/bin/sail bin pint --dirty --format agent`)
## Risiken & Fallstricke
- **Tailwind v4 + `@theme`**: Mehrere `@theme {}`-Blöcke in importierten
Dateien werden zusammengeführt. Das funktioniert, solange die
Token-Namen eindeutig sind.
- **Reihenfolge der Imports**: Tokens müssen **vor** den
`@layer components {}`-Definitionen importiert werden, sonst
greifen die Variablen in den Komponenten nicht.
- **Portal-Tailwind-Config**: `tailwind.portal.config.js` darf die
Token-Datei nicht ausschließen. `@source`-Direktiven prüfen.
## Was Phase 0 NICHT macht
- Portal sieht **noch nicht** wie der Hub aus — das ist Phase 1
- Keine Änderung am Sidebar-Layout, am Logo oder am Dashboard
- Keine Dark-Mode-Aktivierung (nur vorbereitet)

View file

@ -0,0 +1,290 @@
# Phase 1 — Portal-Shell ans Hub-Design angleichen
> **Ziel**: Sidebar, Topbar und Layout-Container des Portals sehen aus
> wie das Mockup `User Dashboard presseportale.html`. Inhalt der
> einzelnen Pages bleibt unverändert — wir tauschen nur die Shell.
**Status**: ✅ abgeschlossen am 2026-05-19
**Risiko (tatsächlich)**: niedrig — keine Test-Regressionen
**Aufwand (tatsächlich)**: ~½ Tag (kleiner als geschätzt, weil Topbar
auf Phase 2 verschoben wurde)
## Ergebnis-Check (2026-05-19)
**Umgesetzt**:
- `portal.css` mit Hub-Token-Bridge, Inter-Tight-Font, Zinc→Hub-Mapping,
FluxUI-Overrides für Sidebar / Navlist / Buttons / Cards.
- Sidebar mit Brand-Mark + Eyebrow „Publisher · Hub", neuem Hub-Stil-
Testmodus-Block, ohne Starter-Kit-Resources-Block.
- Customer-Banner (`app.blade.php`) im Hub-Soft-Look mit Hub-Pille.
- `class="dark"` aus Sidebar-Layout entfernt — Light Mode ist Default.
- Font-Wechsel auf Bunny: `inter-tight + jetbrains-mono + source-serif-4`.
**Verschoben auf Phase 2**:
- Eigene Topbar mit Breadcrumb + Bridge-Row + Search + „Neue Mitteilung"-CTA
(lebt sinnvoller im Customer-Dashboard-Kontext).
- Konto-Switcher als Sidebar-Header oben (statt User-Menü unten).
**Build-Sizes**:
- `portal.css: 409.03 kB` (vorher 408.89 kB · +0.14 kB · weit unter dem
10 %-Limit aus den Akzeptanzkriterien).
**Tests**:
- Auth-Test-Suite Vergleich (Stash vs Phase 1): `8 failed, 15 passed`
in beiden Ständen — 0 zusätzliche Regressionen durch Phase 1.
- Zwei Tests im Zuge des Login-Fixes angepasst (Admin-Rolle bzw.
`terms_accepted: true`), siehe `PROGRESS.md` Eintrag „Phase 1".
**Details**: `PROGRESS.md` (Eintrag vom 2026-05-19, Abschnitt „Phase 1").
## Sichtbarer Mehrwert
Nach Phase 1 sieht der eingeloggte User:
- Eine **warme Sidebar** im Hub-Stil mit Brand-Wortmarke statt
Starter-Kit-Logo
- Eine **schlanke Topbar** mit Breadcrumb, Bridge-Row, Search, Notification,
"Neue Mitteilung"-Button
- Den **Testmodus-Block** (Impersonation) als Hub-Karten-Element
- Den **Konto-Switcher** als oberen Sidebar-Header
Innenleben (Tabellen, Formulare, Cards) bleiben FluxUI — wirken aber
durch Token-Anpassung **automatisch passender**.
## Liefergegenstand
### Geänderte Dateien
| Datei | Änderung |
|-------|----------|
| `resources/css/portal.css` | Zinc → Hub-Palette, Font auf Inter Tight, `--color-accent` auf Hub-Blau, FluxUI-Overrides |
| `resources/views/components/layouts/app/sidebar.blade.php` | Brand-Mark statt App-Logo, Eyebrow „Publisher · Hub", Sidebar-Design am Mockup orientiert, Testmodus-Block neu |
| `resources/views/partials/head.blade.php` | Font-Wechsel (Bunny: inter-tight + jetbrains-mono statt instrument-sans) |
| `resources/views/components/layouts/app.blade.php` | Customer-Banner ggf. an neues Design anpassen |
### Vermutlich gelöscht
| Datei | Begründung |
|-------|------------|
| `resources/views/components/layouts/app/header.blade.php` | Wird laut Inventur nirgends referenziert |
| `resources/views/partials/admin-head.blade.php` | Legacy, im Code nicht eingebunden |
| `resources/views/components/app-logo.blade.php` | Wird durch `brand-mark` ersetzt |
| `resources/views/components/app-logo-icon.blade.php` | Wird durch `brand-mark` ersetzt |
Vor Löschung: per `rg "x-app-logo"` und `rg "auth.split|auth.card"`
prüfen, dass nichts mehr referenziert wird.
## Schritte
### 1. `portal.css` — Token-Bridge zu FluxUI
```css
@import "tailwindcss";
@import "../../vendor/livewire/flux/dist/flux.css";
@import "./shared/design-tokens.css"; /* aus Phase 0 */
@source '../views';
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* FluxUI-Akzent auf Hub-Blau */
--color-accent: var(--color-hub);
--color-accent-content: var(--color-hub);
--color-accent-foreground: #ffffff;
/* Zinc auf warmes Buchpapier mappen */
--color-zinc-50: var(--color-bg-elev); /* #FBFAF6 */
--color-zinc-100: var(--color-bg); /* #F6F4EF */
--color-zinc-200: var(--color-bg-rule); /* #E2DDD0 */
--color-zinc-300: #cfc8b5;
--color-zinc-400: var(--color-ink-4); /* #8A918D */
--color-zinc-500: var(--color-ink-3); /* #5A6360 */
--color-zinc-600: var(--color-ink-2); /* #3A413D */
--color-zinc-700: var(--color-ink); /* #1A1F1C */
--color-zinc-800: var(--color-hub-2); /* #243152 */
--color-zinc-900: var(--color-hub); /* #1A2540 */
--color-zinc-950: var(--color-topbar-deep); /* #0F1729 */
}
```
#### FluxUI-spezifische Overrides
```css
/* Navlist — Hub-Stil mit Active-Strip links */
[data-flux-navlist-item][data-active="true"] {
background: var(--color-hub-soft);
color: var(--color-hub);
font-weight: 600;
position: relative;
}
[data-flux-navlist-item][data-active="true"]::before {
content: "";
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--color-hub);
border-radius: 0 2px 2px 0;
}
/* Buttons — Hub-Primär */
[data-flux-button][data-variant="primary"] {
background: var(--color-hub);
color: #ffffff;
}
[data-flux-button][data-variant="primary"]:hover {
background: var(--color-hub-2);
}
/* Sidebar-Section-Headings */
[data-flux-navlist] [data-flux-navlist-group-heading] {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-ink-4);
}
/* Cards bekommen Hub-Buchpapier statt Zinc */
[data-flux-card] {
background: var(--color-bg-card);
border-color: var(--color-bg-rule);
}
```
> **Hinweis**: Die exakten `[data-flux-*]`-Attribute werden beim Bauen
> per Dev-Tools verifiziert. Die hier gezeigten sind die wahrscheinlichsten
> laut FluxUI-Doku.
### 2. `partials/head.blade.php` — Font wechseln
```diff
- <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600"
+ <link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|jetbrains-mono:400,500,600|source-serif-4:400,500,600,700"
rel="stylesheet" />
```
### 3. Sidebar neu — `components/layouts/app/sidebar.blade.php`
Aufbau **strikt am Mockup** orientiert (s. `dev/frontend/tailwind_v3/User Dashboard presseportale.html`):
#### Sidebar-Aufbau (oben → unten)
1. **Brand-Block** (`px-5 pt-6 pb-5`):
- `<x-web.brand-mark brand="pressekonto" :serif="false" />` (19 px, bold)
- Eyebrow „Publisher · Hub" darunter
- **Konto-Switcher-Button** mit Avatar (Initialen), Name, Firma — als
`<flux:dropdown>`-Trigger mit Custom-Stil
2. **Navigation** (`px-3 flex-1`):
- Section „Mein Bereich": Übersicht, Meine Pressemitteilungen (mit Counter-Badge),
Firmen, Buchungen & Add-ons, Statistiken (disabled, „bald")
- Section „Finanzen": Credits & Tarif (bald), Rechnungen, Zahlungsarten (bald)
- Section „Konto": Profil, Sicherheit, API & Integrationen, Benachrichtigungen (bald)
- Section „Administration" (nur für Admins/Editoren): Press-Releases, Companies, Users, Roles, etc.
3. **Testmodus-Block** (`px-4 pb-4`) — wenn Impersonation aktiv:
- Hub-blaues Panel mit Bernstein-Eyebrow „Testmodus aktiv"
- „Zurück zum Admin"-Button (weiß auf Hub-Blau)
4. **Resources-Block** (`px-3 pb-5 border-t`):
- Optional: Tailwind CSS, Hero Icons, Flux UI, Repository
- Im Live-Portal vermutlich weglassen oder durch eigene Hilfe-Links ersetzen
#### Komponenten-Strategie
Wo immer möglich **FluxUI-Komponenten** verwenden:
- `<flux:sidebar>` als Wrapper
- `<flux:navlist>` für Section-Gruppen
- `<flux:navlist.item>` für Einträge, `data-active`-Markierung übernimmt CSS-Override
- `<flux:dropdown>` für Konto-Switcher
- `<flux:badge>` für Counter
Wo FluxUI nicht passt (z.B. Konto-Switcher-Header mit Avatar+Name+Firma+Chevron):
**Custom Blade** in `<x-portal.account-switcher>` als wiederverwendbare Komponente.
### 4. `app.blade.php` — Customer-Banner Hub-Stil
Der Banner „User Backend" (für Customer-Rolle) wird visuell ans Hub-Design
angeglichen — Hub-Soft-Hintergrund, Hub-Blau-Eyebrow, Firma-Switcher als
Pille.
### 5. `class="dark"` entfernen
In allen Auth- und App-Layouts:
```diff
- <html lang="..." class="dark">
+ <html lang="...">
```
FluxUI Appearance-Switcher in den Settings übernimmt die Steuerung.
Dark-Mode-Werte landen in Phase 5 in `design-tokens.css`.
### 6. Build & Visual-Check
```bash
npm run build:portal
```
Öffnen und visuell prüfen:
- `https://pressekonto.test/dashboard` (Admin-Dashboard)
- `https://pressekonto.test/admin/me` (Customer-Dashboard)
- `https://pressekonto.test/settings/profile`
- `https://pressekonto.test/admin/companies`
- `https://pressekonto.test/admin/press-releases`
Erwartung:
- Sidebar wie Mockup
- Topbar mit Breadcrumb + Aktionen
- Inhalt unverändert, aber Tabellen/Cards/Buttons in Hub-Tonart
- Keine kaputten Layouts
## Akzeptanzkriterien
- [ ] Sidebar nutzt `<x-web.brand-mark brand="pressekonto" />` statt `x-app-logo`
- [ ] Sidebar-Sections und Active-Item entsprechen visuell dem Mockup
- [ ] Topbar hat Breadcrumb, Search, Notification, „Neue Mitteilung"-CTA
- [ ] Font im Portal ist **Inter Tight** (sichtbar im DevTools)
- [ ] FluxUI-Buttons (Primary) sind **Hub-Blau**, nicht mehr `#3ea3dc`
- [ ] FluxUI-Tabellen sehen sauber aus mit Buchpapier-Hintergrund
- [ ] `class="dark"` ist aus allen Layouts entfernt
- [ ] Alle bestehenden Routen `/dashboard`, `/admin/*`, `/admin/me/*`,
`/settings/*` rendern Status 200
- [ ] Pint & vorhandene Tests bleiben grün
- [ ] Page-Last-Vergleich: `portal.css`-Größe darf um max. 10 % wachsen
## Risiken & Fallstricke
- **FluxUI-Selektoren ändern sich**: `[data-flux-*]`-Attribute sind nicht
öffentlich dokumentierte API, sondern Implementation-Detail. Bei
FluxUI-Update kann ein Override brechen. Mitigation: Selektoren so
spezifisch wie nötig, so generisch wie möglich; gut kommentieren.
- **Zinc-Remapping kann Side-Effects haben**: Stellen, die hardcoded
`zinc-700` für Text-Farben verwenden, werden plötzlich Hub-Blau.
Mitigation: nach dem Build kritische Pages durchklicken; gegebenenfalls
einzelne Stellen explizit auf `text-ink` umstellen.
- **Tailwind v4 Custom-Properties**: Reihenfolge im `@theme`-Block ist
wichtig — Tokens müssen vor Overrides definiert sein.
- **Mobile Sidebar**: Das Mockup zeigt nur Desktop. `flux:sidebar` hat
einen eingebauten Mobile-Toggle — der bleibt erhalten und wird visuell
angeglichen.
## Was Phase 1 NICHT macht
- Dashboards (`admin.dashboard`, `customer.dashboard`) bekommen noch
**kein** Stat-Card-Strip-Redesign — das ist Phase 2 + 3
- Listen-Pages werden nicht überarbeitet — passt automatisch durch
Token-Anpassung „gut genug" bis Phase 4
- Dark Mode wird nicht aktiv ausgeliefert — Token-Werte werden vorbereitet,
aber bleiben in Phase 5
## Review-Punkt
Nach Phase 1 wird Frank/Du visuell drüberschauen und entscheiden:
- Welche Detail-Pages priorisiert werden (Phase 4)
- Ob Customer-Dashboard (Phase 2) direkt danach kommt
- Ob das Auth-Layout im Portal (`auth.split`, `auth.card`) entfernt werden kann

View file

@ -0,0 +1,162 @@
# Weitere Phasen — Outline
> Übersicht über Phasen 26. Diese werden detailliert ausgearbeitet,
> sobald Phase 0+1 abgenommen sind. Jede Phase bekommt ein eigenes
> `0X-PHASE-N-NAME.md`, wenn sie aktiv wird.
---
## Phase 2 — Customer-Dashboard auf Mockup-Stil
**Status**: ⚪ später · **Aufwand**: ~½ Tag · **Risiko**: niedrig
### Ziel
`livewire/customer/dashboard.blade.php` matched das Mockup
`User Dashboard presseportale.html` zu ≥ 90 %.
### Bausteine
- **Page-Header** mit „Mein Dashboard" + Begrüßung + Firma-zugeordnet-Pille
- **KPI-Reihe** (4 Stat-Cards mit linkem Farb-Strip)
- Gesamt (Hub-Blau-Strip)
- Veröffentlicht (Grün-Strip, `is-ok`)
- In Prüfung (Bernstein-Strip, `is-warn`)
- Entwürfe (Grau-Strip, `is-muted`)
- **2-Spalten-Grid**:
- Links: Empty-State / Liste „Meine letzten Pressemitteilungen"
- Rechts: „Datenqualität" mit Progress-Bars (Profil-Vollständigkeit,
Rechnungsadresse)
- **Unten**:
- Links: Firmen-Slots
- Rechts: Brand-Bridge-Dark-Card (presseecho + businessportal24)
### Neue Blade-Components
- `<x-portal.stat-card>` — Stat-Card mit Slot + Strip-Variante
- `<x-portal.hint-card>` — Datenqualitäts-Hint mit Icon + Progress-Bar
- `<x-portal.bridge-card>` — Dunkle Brand-Bridge-Card
→ Diese landen in `resources/views/components/portal/`.
---
## Phase 3 — Admin-Dashboard konsistent
**Status**: ⚪ später · **Aufwand**: ~½ Tag · **Risiko**: niedrig
### Ziel
`resources/views/admin/dashboard.blade.php` nutzt dasselbe Vokabular wie
Customer-Dashboard.
### Heutiger Stand
Reines Tailwind mit `zinc-*`-Klassen, **keine** FluxUI-Komponenten,
visuell aus der Zeit gefallen.
### Schritte
- KPI-Karten als `<x-portal.stat-card>`
- Wenn Charts vorhanden: über `flux:chart` umsetzen (FluxUI Pro)
- Recent-Activity-Liste in `flux:card` mit Hub-Akzenten
---
## Phase 4 — Listen-/Detail-Pages durchgehen
**Status**: 🚧 iterativ · **Aufwand**: ~35 Tage gesamt · **Risiko**: niedrig
### Ziel
Alle Volt-Pages im Admin- und Customer-Bereich nutzen denselben Hub-Stil.
### Vorgehen
- Pro Page: 1530 min
- In Päckchen geteilt:
- **4A** = Press-Releases Listen (Admin + Customer) — ✅ **abgeschlossen**
siehe `07-PHASE-4A-PRESS-RELEASES-LISTEN.md`
- **4B** = Press-Releases Detail/Show (Admin + Customer) — ✅ **abgeschlossen**
siehe `08-PHASE-4B-PRESS-RELEASES-DETAIL.md`
- **4C** = Press-Releases Forms (create/edit, Admin + Customer) — ✅ **abgeschlossen**
siehe `09-PHASE-4C-PRESS-RELEASES-FORMS.md`
- **4D** = Companies (`admin.companies.*`) — ⚪ pending
- **4E** = Profile/Settings (`settings.*`, `me.profile`, `me.security`) — ⚪ pending
- **4F** = Restliche Admin-Bereiche — ⚪ pending
### Was geändert wird
- Page-Header-Struktur (Eyebrow + H1 + Subtitle + Aktions-Bar)
- Filter-Bars werden Hub-konform gestylt
- `flux:table`-Header bekommen Eyebrow-Optik
- Form-Felder bleiben FluxUI, aber mit Hub-Tokens
### Was nicht geändert wird
- Logik / Volt-Methoden
- Datenbank / Models
---
## Phase 5 — Dark Mode konsistent
**Status**: ⚪ später · **Aufwand**: ~½ Tag · **Risiko**: niedrig
### Ziel
Dark Mode funktioniert sauber im Portal, **ohne doppelte UI-Pflege**.
### Quelle
`dev/frontend/tailwind_v3/User Dashboard presseportale Dark.html` hat
alle Dark-Tokens bereits vorgegeben.
### Schritte
1. In `shared/design-tokens.css` Dark-Werte ergänzen:
```css
@media (prefers-color-scheme: dark) {
@theme {
--color-bg: #0E1218;
--color-bg-elev: #14181F;
--color-hub: #5A78C2;
--color-accent: #D9A560;
/* … */
}
}
.dark {
/* gleiche Werte für expliziten Switch */
}
```
2. `class="dark"` wird **nicht** mehr hardcoded gesetzt
3. Flux Appearance-Switcher (`settings/appearance.blade.php`) steuert
`.dark` auf `<html>`
4. Hub-Frontend bleibt erstmal Light-Only (so wie heute)
### Erwartung
- Settings → Appearance → „Dark" schaltet Portal um
- Settings → Appearance → „System" folgt OS-Präferenz
- Sidebar, Topbar, Dashboards, Listen, Forms — alles funktioniert
- Hub-Landing und Hub-Auth bleiben Light
---
## Phase 6 — Auth-Konsolidierung (optional)
**Status**: ⚪ optional · **Aufwand**: 01 Tag · **Risiko**: mittel
### Frage
Bleibt der Hub-Login (`auth/pressekonto`-Layout, Web-Build) so wie er ist?
Oder konsolidieren wir auf den Portal-Build?
### Pro Konsolidierung
- Ein Build weniger
- Konsistente Komponenten-Sprache
### Pro Beibehaltung (heutiger Stand)
- Hub-Atmosphäre der Auth-Seiten (Konzentrische Kreise, Hub-Grid) ist
visuell sehr stark — würde verloren gehen
- Hub-Auth ist **leichtgewichtig** (kein FluxUI im Bundle)
- Vorlage `Login pressekonto A3 Tailwind.html` ist bewusst minimalistisch
### Entscheidung
Vermutlich **beibehalten**. Aber: Die alten Auth-Layouts
`auth/simple.blade.php`, `auth/split.blade.php`, `auth/card.blade.php` aus
dem Starter-Kit können vermutlich gelöscht werden, sobald geprüft ist,
dass keine Komponente sie noch verwendet.
### Schritte (wenn konsolidiert)
- Hub-Auth-CSS in `portal.css` ziehen (mit `@source` für die Tokens)
- `auth/pressekonto`-Layout auf Portal-Build umstellen
- Hub-Auth-Klassen (`.auth-card`, `.auth-grid`, `.auth-btn-primary`)
bleiben — laufen nur jetzt aus dem Portal-Bundle
- `theme-pressekonto.css` aus dem Web-Build entfernen (oder behalten
für die Landing)

View file

@ -0,0 +1,177 @@
# Phase 2 — Customer-Dashboard auf Mockup-Stil
> Detail-Plan analog zu `01-PHASE-0-TOKENS.md` und
> `02-PHASE-1-PORTAL-SHELL.md`. Wird beim Abschluss auf Status `✅ abgeschlossen`
> gesetzt; Lessons learned wandern in `PROGRESS.md`.
**Status**: ✅ abgeschlossen · **Aufwand**: ~½ Tag · **Risiko**: niedrig
---
## Ziel
`resources/views/livewire/customer/dashboard.blade.php` matched das Mockup
`dev/frontend/tailwind_v3/User Dashboard presseportale.html` zu ≥ 90 %.
Sichtbarer Wow-Moment für den Kunden, ohne neue Business-Logik.
## Was sich ändert
### Visuell
| Bereich | Heute | Mockup |
| ----------------- | ------------------------------------------------ | --------------------------------------------------------------------- |
| Page-Header | `<flux:card>` mit `<flux:heading>` „Willkommen“ | Hub-Badge + Eyebrow + großes H1 + Untertitel + rechts Status-Pille |
| KPI-Reihe | 4 schmale `<flux:card>` mit `<flux:text>`-Zahl | 4 `stat-card`s mit Strip links, Mono-Zahl groß, Sub-Info, Sparkline |
| Pressemitteilungen-Block | `<flux:card>` mit Liste / Empty-State | `panel` mit `section-eyebrow`, Empty-State mit Schritt-Karten 01/02/03 |
| Datenqualität | Grid aus `<flux:badge>`-Karten | `panel` rechts mit 2 `hint-card`s (Progress-Bar + Action-Link) |
| Firmen-Slot | `<flux:card>` mit Firmen-Liste | `panel` mit gestrichelten Empty-Slot-Karten + Hinweis-Box |
| Brand-Bridge | (nicht vorhanden) | `panel-dark` rechts: presseecho + businessportal24 Status |
| Footer | (nicht vorhanden) | Subtle-Footer mit Tastenkürzel / Changelog / Status / Support |
### Datenmodell
Heute liefert das Volt-`with()`:
- `stats.total | published | review | draft`
- `qualityHints[]`
- `recent` (PressRelease-Collection, max. 5)
- `companies`
Mockup verlangt **zusätzlich**:
- `stats.deltaMonth` — Veränderung ggü. Vormonat (Total)
- `profileCompleteness` — Prozentwert für Profil-Progress-Bar
- `billingCompleteness` — Prozentwert für Rechnungsadresse
- `bridgeStatus``['presseecho' => 'connected'|'pending', 'businessportal24' => …]`
(vorerst `connected` fest verdrahtet, später aus echtem Sync-Status)
## Was NICHT geändert wird
- Logik / Volt-Methoden — Layout-only.
- Statistik-Backend (`PressRelease::where(...)`) bleibt 1:1.
- `qualityHints`-Logik bleibt — wird nur anders gerendert.
- Topbar (eigene Phase, später).
---
## Implementierungs-Schritte
### 1. Shared Hub-Components-CSS
Klassen wandern in **`resources/css/shared/hub-components.css`** (neue Datei).
Importiert in:
- `resources/css/portal.css` (nach `flux.css`)
- `resources/css/web/shared-styles.css` (nach `design-tokens.css`)
Damit haben **Portal-Build und Web-Build** dieselbe Hub-Komponenten-Sprache —
DRY für späteres Wiederverwenden (z. B. Admin-Dashboard in Phase 3).
Folgende Klassen kommen rein (alle mit `var(--color-*)`-Tokens, keine Hex-Literale):
- `.panel`, `.panel-warm`, `.panel-dark`, `.panel-head`
- `.stat-card` + `.stat-strip` + `.stat-label` + `.stat-num`
+ Varianten: `.is-primary`, `.is-ok`, `.is-warn`, `.is-muted`
- `.hint-card` + `.hint-card .hint-ico`
- `.badge` + `.badge.warn` + `.badge.ok` + `.badge.hub` + `.badge.dot`
- `.bridge-row`, `.dot-pe`, `.dot-bp`
### 2. Blade-Components in `resources/views/components/portal/`
#### `stat-card.blade.php`
```blade
<x-portal.stat-card variant="primary|ok|warn|muted" label="Gesamt" :value="$stats['total']">
<x-slot:meta>2026</x-slot:meta> {{-- rechts oben --}}
<x-slot:trend>0 ggü. Vormonat</x-slot:trend> {{-- unten --}}
</x-portal.stat-card>
```
Render: `<article class="stat-card is-{variant}">` + Strip + Label + Mono-Zahl
+ Meta-Slot oben rechts + Trend-Slot unten. Sparkline (SVG) erstmal weggelassen
— optionales Detail; lässt sich nachschieben, wenn Daten dafür da sind.
#### `hint-card.blade.php`
```blade
<x-portal.hint-card :icon="$hint['icon']" :title="$hint['title']" :percent="60" :href="$hint['href']">
{{ $hint['description'] }}
<x-slot:action>Profil öffnen</x-slot:action>
</x-portal.hint-card>
```
Render: `.hint-card`-Grid mit Icon-Square + Progress-Bar + Text + Action-Link
in `text-accent-deep`.
#### `bridge-card.blade.php` (optional, Phase 2 minimal)
Bleibt erstmal **inline** im Dashboard (sehr spezifisch, eine Stelle).
Wird in Phase 3/4 extrahiert, wenn klar ist wie weit der Brand-Bridge-Pattern
sich verbreitet.
### 3. `customer/dashboard.blade.php` umbauen
Layout-Skelett (alles im `<div class="space-y-8">`-Container):
1. **Page-Header**`<header>` mit Grid `1fr auto`:
- Links: Hub-Badge `User Backend` + Eyebrow + `<h1>Mein Dashboard</h1>`
+ Subtitle (Name + Reichweiten-Hinweis)
- Rechts: Status-Pille
- Wenn `$selectedCompany`: Hub-Badge mit Firma-Name (grün/ok-Stil)
- Wenn nicht: Warn-Pille „Keine Firma zugeordnet → zuordnen“
2. **KPI-Reihe** — 4 `<x-portal.stat-card>` (Gesamt/Veröffentlicht/Prüfung/Entwürfe)
3. **Zweispalten-Grid** (`2fr 1fr`):
- Links: `<article class="panel">` mit Pressemitteilungen — Liste **oder**
Empty-State (3 Schritt-Karten)
- Rechts: `<article class="panel">` mit `<x-portal.hint-card>`s
4. **Unteres Grid** (`2fr 1fr`):
- Links: `<article class="panel">` Firmen — Liste **oder** Empty-Slot-Karten
- Rechts: `<article class="panel-dark">` Brand-Bridge (inline)
5. **Footer** — kleine Link-Reihe in `text-ink-3`
### 4. Volt `with()` ergänzen
- `stats.deltaMonth` via zweiter Query (Vormonats-Counts vs. Aktuell)
- `profileCompleteness` als simpler Heuristik-Wert (firstname/lastname/phone/etc.)
- `billingCompleteness` analog (Vorhanden = 100, sonst 0; oder Ist-Felder/Soll-Felder)
- `bridgeStatus` vorerst hardcoded `['presseecho' => 'connected', 'businessportal24' => 'connected']`
→ später aus echtem Sync-Service
### 5. Tests
`tests/Feature/Customer/DashboardTest.php` (oder bestehender Test, falls vorhanden):
- Rendert ohne Fehler bei eingeloggtem Customer
- Stat-Zahlen werden korrekt ausgegeben
- Empty-State wird angezeigt, wenn keine PRs existieren
- Quality-Hints werden angezeigt, wenn `profile()` fehlt
---
## Akzeptanzkriterien
- [x] Phase-2-Plan-Dokument geschrieben
- [x] `shared/hub-components.css` existiert, importiert in beiden Builds
- [x] `<x-portal.stat-card>` und `<x-portal.hint-card>` rendern wie Mockup
- [x] `/admin/me` zeigt das neue Dashboard ohne Console-Errors
- [x] Empty-State für Pressemitteilungen ist sichtbar, wenn keine vorhanden
- [x] Quality-Hints rendern mit Progress-Bar
- [x] Brand-Bridge-Dark-Card unten rechts zeigt presseecho + businessportal24
- [x] Neuer Smoke-Test `tests/Feature/Customer/DashboardTest.php` mit 5 Cases
(Core-Sections, Empty-State, PR-Liste, Profil-Hint, vollständiges Profil
blendet Hints aus). Cross-Check: alle 18 verwandten Tests bleiben grün.
- [x] Pint clean
- [x] `PROGRESS.md`-Eintrag
---
## Risiken & Mitigation
- **FluxUI-Klassen vs. Custom-CSS-Kollisionen** — wir mischen `<flux:badge>`
weiterhin **nicht** im Dashboard (für Status-Pillen nehmen wir
`.badge.hub|.warn|.ok|.dot`). Auf den Detail-Seiten (Phase 4) bleibt
FluxUI dominant.
- **Sparklines weggelassen** — minimaler Stilverlust, wird in Phase 4 mit
echten Trend-Daten nachgereicht. Mockup-Match weiterhin ≥ 90 %.
- **`stats.deltaMonth`-Performance** — zweite Query auf gleicher Tabelle;
bei wachsendem Datensatz später cachen. Heute irrelevant.

View file

@ -0,0 +1,159 @@
# Phase 5 — Dark Mode konsistent + Appearance-Switcher im User-Menü
> **Vorzugsphase**: Eigentlich Phase 5, vorgezogen vor Phase 3/4 nach
> User-Wunsch. Grund: Der FluxUI-Appearance-Switcher hat in Phase 1 den
> Dark-Mode-Bug ausgelöst (`#6d8ad3` statt Hub-Blau); statt Symptom-Fix
> wollen wir den vollen Dark Mode jetzt richtig.
**Status**: ✅ abgeschlossen · **Aufwand**: ~½ Tag · **Risiko**: niedrig
---
## Ziele
1. Dark Mode funktioniert sauber auf allen Portal-Seiten (Customer-Dashboard,
Sidebar, FluxUI-Komponenten, Hub-Components).
2. Switcher (Light / Dark / System) **direkt im User-Menü** verfügbar — kein
Umweg über `/settings/appearance` mehr nötig.
3. Hub-Frontend (Landing + Auth) bleibt **bewusst Light-Only** — Hub-Atmosphäre
ist Buchpapier.
4. Der Notfall-Hack aus Phase 1 (`portal.css: .dark { --color-accent: var(--color-hub) }`)
wird durch das echte Dark-Token-Mapping ersetzt.
## Quelle der Wahrheit
`dev/frontend/tailwind_v3/User Dashboard presseportale Dark.html`
liefert alle Dark-Werte. Wir spiegeln die Token-Namen aus dem Light-Mode 1:1.
---
## Was sich ändert
### 1. `shared/design-tokens.css`
Der seit Phase 0 vorbereitete `.dark`-Block wird **aktiviert** und um die seit
Phase 2 nachgezogenen Tokens (`--color-bg-rule-2`, `--color-gain-deep`) ergänzt.
Wichtige Umwidmungen im Dark Mode (gleiche Namen, andere Werte):
| Token | Light | Dark | Bedeutung |
| ----------------------- | ----------- | ----------- | ------------------------------------------ |
| `--color-bg` | `#f6f4ef` | `#0e1218` | Page-Background |
| `--color-bg-elev` | `#fbfaf6` | `#14181f` | Sidebar / leicht hervorgehoben |
| `--color-bg-card` | `#ffffff` | `#181d27` | Card-Body |
| `--color-bg-rule` | `#e2ddd0` | `#2a3142` | Trennlinien |
| `--color-bg-rule-2` | `#ede7d7` | `#232838` | Progress-Track |
| `--color-hub` | `#1a2540` | `#5a78c2` | Primary-Akzent (heller im Dark) |
| `--color-hub-soft` | `#e5e9f1` | `#1f2a47` | Hint-Hintergrund |
| `--color-accent` | `#b07a3a` | `#d9a560` | Bernstein-Akzent (heller im Dark) |
| `--color-accent-warm` | `#b07a3a` | `#b07a3a` | **bleibt gleich** — für Border-Akzente |
| `--color-accent-deep` | `#8a5e27` | `#b07a3a` | Action-Link-Color (heller im Dark) |
| `--color-accent-soft` | `#f1e6d3` | `#2a2418` | Hint-Icon-BG |
| `--color-ink` | `#1a1f1c` | `#ece9e0` | Text-Primary |
| `--color-ink-2` | `#3a413d` | `#c9c5b8` | Text-Secondary |
| `--color-ok / -soft` | `#2e8540 / #e2f1e5` | `#4dc076 / #1a2d22` | Status grün |
`--color-accent-warm` als **konstanter** Bernstein-Token (gleicher Wert in
beiden Modi) ist ein bewusster Trick: Im Portal mapt `--color-accent` auf
`var(--color-hub)` (Primary-Akzent), aber Bernstein-Borders (Hint-Card,
Schritt-Karten-Eyebrow) brauchen weiterhin den ungeänderten Bernstein-Wert.
`--color-accent-warm` ist der Token dafür.
### 2. `portal.css`
- Den `.dark { --color-accent: var(--color-hub); … }`-Notfall-Block aus
Phase 1 entfernen — er ist mit echtem Dark-Mapping überflüssig.
- Stattdessen einen kurzen Kommentar setzen, dass das Token-Mapping aus
`design-tokens.css` greift.
- Primary-Button-Hover-Override: heute hartcodiert auf `--color-hub-2`
(`#243152`). Im Dark Mode ist `--color-hub-2` = `#6d8ad3` (heller),
was richtig ist (Hover = noch heller als Default-Button). Override bleibt
via Variable korrekt, kein Eingriff nötig.
- Button-Shadows: `rgba(26, 37, 64, …)` ist Light-Mode-spezifisch. Im Dark
Mode wirkt der bläuliche Shadow auf dunklem Card-BG falsch. Lösung:
per `@media (prefers-color-scheme: dark)` ODER `.dark`-Selektor die
Shadow-Farben auf transparenten Schwarz tönen.
### 3. `shared/hub-components.css`
- Bernstein-Stellen umstellen: `var(--color-accent)`
`var(--color-accent-warm)` für **Hint-Card-Border-Left** und
**Progress-Bar-Fill** (sonst wird's im Portal Hub-Blau gemapt).
- Wegen Tokens-Architektur (`hub-soft` etc. werden im Dark Mode dunkler)
funktioniert der Großteil **automatisch** — keine `.dark`-Overrides nötig.
- Eine Ausnahme: `.panel-dark` und die Brand-Bridge-Boxes — im Light Mode
ist `panel-dark` Hub-Blau (#1a2540). Im Dark Mode bleibt das auch
`var(--color-hub)` — und da ist `--color-hub` heller (#5a78c2). Wir
brauchen also einen **konstanten dunklen Token** für `panel-dark` — sonst
wird die „dunkle“ Bridge-Card im Dark Mode plötzlich hellblau.
→ Neuer Token `--color-panel-dark` (immer #0f1729, in beiden Modi)
oder direkt `var(--color-topbar-deep)` (existiert bereits).
### 4. User-Menü-Switcher
Beide Dropdowns (Desktop in `sidebar.blade.php`, Mobile in derselben Datei)
bekommen vor dem Logout einen Block:
```blade
<flux:menu.separator />
<div class="px-2 py-1.5">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance" size="sm">
<flux:radio value="light" icon="sun" />
<flux:radio value="dark" icon="moon" />
<flux:radio value="system" icon="computer-desktop" />
</flux:radio.group>
</div>
```
Das ist exakt das Pattern aus `livewire/settings/appearance.blade.php`, nur
kompakt mit Icons-only. `$flux.appearance` ist FluxUI's Magic-Object,
LocalStorage-persistent, von `@fluxAppearance` injiziert.
### 5. Hub-Auth bleibt Light
`auth/pressekonto.blade.php` lädt **kein** `@fluxAppearance` und **kein**
`partials/head` — die Hub-Auth-Pipeline ist eigenständig. Damit wird `class="dark"`
dort nie gesetzt, selbst wenn das LocalStorage `dark` enthält. Hub-Auth bleibt
also automatisch Light. **Kein Eingriff nötig.**
---
## Implementierungs-Schritte (Reihenfolge)
1. `design-tokens.css`: Dark-Block aktivieren, `--color-accent-warm` definieren,
`--color-panel-dark` einführen (oder Re-Use `--color-topbar-deep`).
2. `portal.css`: Hack rausnehmen, Button-Shadow für Dark Mode tönen.
3. `hub-components.css`: Bernstein-Stellen auf `--color-accent-warm`,
`.panel-dark` auf `--color-panel-dark`.
4. `sidebar.blade.php`: Switcher in beide Dropdowns einbauen.
5. Build + Smoke-Test im Browser.
6. Bestehende Tests laufen lassen (sollten alle weiter grün sein, weil
keine Test-Assertion farbbezogen ist).
7. Pint + PROGRESS + Plan-Status.
## Akzeptanzkriterien
- [x] Plan-Dokument geschrieben
- [x] Switcher Light/Dark/System im User-Menü (Desktop + Mobile) sichtbar,
Änderung wirkt sofort + persistent (LocalStorage)
- [x] Dark Mode: Customer-Dashboard rendert mit allen Sektionen korrekt
(Tokens schalten um, kein hellblauer Akzent-Bug)
- [x] Brand-Bridge-Karte bleibt im Dark Mode dunkel (`--color-panel-dark-2`
konstant in beiden Modi)
- [x] Hub-Auth bleibt im Dark Mode Light (lädt kein `@fluxAppearance`)
- [x] Tests grün (18/18)
- [x] Pint clean
- [x] PROGRESS-Eintrag
## Risiken & Mitigation
- **FluxUI-Default-Komponenten im Dark Mode** (z.B. Modals, Dropdowns):
unsere Zinc→Hub-Mapping in `portal.css` bridgt die Skala. Im Dark Mode
müssen die Zinc-Werte auch umgekippt werden — passiert automatisch über
die Token-Vars, weil wir `var(--color-bg-*)` nutzen. Restrisiko: einzelne
FluxUI-Komponenten könnten harte Tailwind-Klassen wie `bg-white` haben,
die im Dark unverändert weiß bleiben. Smoke-Test deckt das auf.
- **Hub-Components-Schatten** sind in Hub-Blau-Alpha definiert
(`rgba(26, 37, 64, …)`). Im Dark Mode auf dunklem Bg wirken sie evtl.
„falsch warm“. Akzeptabel im ersten Wurf; Feintuning per `prefers-color-scheme`
Media-Query falls visuell stört.

View file

@ -0,0 +1,55 @@
# Phase 3 — Admin-Dashboard im Hub-Vokabular
**Status**: ✅ abgeschlossen · **Aufwand**: ~¼ Tag · **Risiko**: niedrig
---
## Ziel
`resources/views/admin/dashboard.blade.php` nutzt dieselbe Designsprache wie
das Customer-Dashboard (Phase 2). Keine neuen Komponenten, keine neue Logik
— nur Vokabular-Umbau auf die DRY-Schicht.
## Was sich ändert
### Layout
- Page-Header: Hub-Badge „Admin Backend" + Eyebrow + großes H1
„Admin Dashboard" + Subtitle. Rechts: Status-Pille „Alle Systeme operational"
(ok-Style mit Dot).
- KPI-Reihe: weiter **5 Stat-Cards** (wie heute), aber als
`<x-portal.stat-card>` mit Strip-Variante:
- Pressemitteilungen → `primary` (mit Sub: `X pub · Y prüf · Z entwurf`)
- In Prüfung → `warn` (eigene KPI, war vorher in der PM-Card versteckt)
- Firmen → `muted`
- Kontakte → `muted`
- Benutzer → `muted`
- 2-Spalten-Grid (`2fr 1fr`) — wie heute:
- Links: Panel „Letzte Pressemitteilungen" (Liste + Status-Badges
in `.badge.ok|warn|err|hub`).
- Rechts: Panel „Zur Prüfung" mit warn-Pille (Count) + Liste +
„+ N weitere"-Link.
- Bottom-Reihe (`1fr 2fr`) — **neu**:
- Links: `panel-warm` Newsletter-Block (Mono-Zahl + Subline).
- Rechts: `panel` Quick-Actions mit Section-Eyebrow + Schnellzugriff
auf Invoices/Payments/Coupons/Presets.
- Footer: subtle Link-Reihe analog Customer-Dashboard.
### Was NICHT geändert wird
- Controller-Logik / Datenform.
- Newsletter-Count bleibt erhalten, wandert nur in einen eigenen Block.
- Bestehende Tests (`DashboardTest`) bleiben grün — alle geprüften
Strings (`3`, `1 pub`, `1 prüf`, `1 entwurf`, `Review Dashboard PM`)
bleiben im Output.
## Akzeptanzkriterien
- [x] Plan geschrieben
- [x] Admin-Dashboard verwendet `<x-portal.stat-card>`, `.panel`,
`.section-eyebrow`, `.badge`
- [x] Customer-Dashboard und Admin-Dashboard sind visuell aus einem Guss
- [x] Dark Mode greift automatisch (alle Tokens)
- [x] `DashboardTest` bleibt grün ohne Anpassung (alle 3 Cases +
Wortlaut „1 pub / 1 prüf / 1 entwurf / Review Dashboard PM")
- [x] Pint clean, PROGRESS-Eintrag

View file

@ -0,0 +1,58 @@
# Phase 4A — Press-Releases Listen-Pages
> Erstes Päckchen aus Phase 4 (Listen/Detail-Pages iterativ). Wegen
> Umfang teilen wir Phase 4 in Päckchen — A = Listen, B = Detail/Show,
> C = Forms (create/edit), D = Companies, E = Settings/Profile, F = Rest.
**Status**: ✅ abgeschlossen · **Aufwand**: ~¼ Tag · **Risiko**: niedrig
---
## Scope
In diesem Päckchen NUR:
- `resources/views/livewire/admin/press-releases/index.blade.php`
- `resources/views/livewire/customer/press-releases/index.blade.php`
NICHT in diesem Päckchen:
- `show.blade.php`, `create.blade.php`, `edit.blade.php` (Päckchen B/C)
- Companies, Settings, Profile (Päckchen D/E)
## Ziel
Beide Listen nutzen das Hub-Vokabular:
- **Page-Header** wie im Dashboard (Hub-Badge + Eyebrow + H1 + Subtitle,
CTA rechts).
- **KPI-Reihe** (nur Admin) als `<x-portal.stat-card>` — exakt
wie auf dem Admin-Dashboard (DRY: vier Cards mit primary/ok/warn/muted).
- **Filter-Bar** als `.panel` mit `.panel-head` „Filter & Suche".
- **Tabelle** in `.panel` (kein FluxUI-Card-Wrapper mehr). FluxUI-Table
bleibt — wir bridgen über Zinc-→Hub-Mapping.
- **Status-Badges** auf eigene `.badge.ok|warn|err|hub`-Pillen
(statt `<flux:badge color="…">`) — visuelle Konsistenz zum Dashboard.
- **Empty-State** in Hub-Stil mit Icon-Box.
## Was NICHT angefasst wird
- **Confirm-Modals** für Publish/Reject/Archive — Tests assertieren
Wortlaut („Pressemitteilung veröffentlichen?" etc.).
- **Volt-Logik** (Sort, Filter, Methods) — Layout-only.
- **FluxUI-Form-Inputs** (Search, Select, Combobox) — bleiben, weil
Hub kein eigenes Combobox-Pendant hat.
## Akzeptanzkriterien
- [x] Plan
- [x] Admin-Liste: Page-Header + 4 Stat-Cards + Filter-Panel + Tabelle-Panel + Hub-Badges
- [x] Customer-Liste: Page-Header + Filter-Panel + Tabelle-Panel + Hub-Badges + Empty-State
- [x] Visuell aus einem Guss mit Dashboard
- [x] `PressReleaseWorkflowTest` + `AdminPressReleaseActionsTest` bleiben grün
- [x] Build + Pint + PROGRESS
## Bonus-Fix
- `customer/dashboard.blade.php` Subtitle ergänzt um „Übersicht für {Firma}",
wenn `$selectedCompany` gesetzt ist. Korrigiert eine Phase-2-Regression
bei `CustomerCompanyContextTest > customer dashboard is filtered by …`.

View file

@ -0,0 +1,56 @@
# Phase 4B — Press-Releases Detail/Show-Pages
> Zweites Päckchen aus Phase 4. Folgt auf 4A (Listen).
**Status**: ✅ abgeschlossen · **Aufwand**: ~⅓ Tag · **Risiko**: niedrig
---
## Scope
- `resources/views/livewire/admin/press-releases/show.blade.php`
- `resources/views/livewire/customer/press-releases/show.blade.php`
NICHT in diesem Päckchen:
- `create.blade.php`, `edit.blade.php` (Päckchen 4C — Forms)
- Companies, Settings, Profile (Päckchen 4D/4E)
## Ziel
Beide Detail-Pages im Hub-Vokabular:
- **Page-Header** wie auf den Listen (Hub-Badge + Eyebrow + H1 +
Subtitle), Status-Pill direkt unter dem Eyebrow oder im Header-Meta,
Aktions-Buttons (Bearbeiten / Zurück / Vorschau-Link) rechts.
- **Status-Workflow-Aktionsbar** als `.panel` mit klarer Optik
je nach Status (review = warn, published = ok, draft = neutral).
- **Content-Hauptbereich** (PM-Text) als `.panel` (kein FluxUI-Card-Wrapper).
- **Sidebar / Side-Cards** als kleine `.panel` mit `panel-head`.
- **Status-Verlauf-Timeline** als `.panel` mit Hub-Badges
(`.badge.ok|warn|err|hub`).
- **Rejection-Hinweis** (Customer) als Hub-Style-Error-Panel mit
Linker Akzent-Border (statt `<flux:callout>`).
- **Share-Link-Erfolgsbox** (Customer) als Hub-Style-Success-Block.
- **Pressekontakte-Liste** (Customer) als Hub-Items in `.panel`.
## Was explizit NICHT angefasst wird
- **Confirm-Modals** (Publish / Reject / Archive) — Tests in
`AdminPressReleaseActionsTest` assertieren Wortlaute.
- **Wortlaute** `Werbliche Sprache wurde markiert.` und
`Erneut einreichen` aus `PressReleaseWorkflowTest`.
- **Volt-Logik** (publish/reject/archive/submitForReview/
generateShareLink/with) — Layout-only.
## Akzeptanzkriterien
- [x] Plan
- [x] Admin-Show: Page-Header + Status-Workflow-Bar + Text-Panel +
Sidebar-Panels + Timeline + Hub-Badges. Modals unverändert.
- [x] Customer-Show: Page-Header + Status-Workflow-Bar +
Rejection-Panel + Share-Erfolgsblock + Contacts-Panel +
Verlauf-Panel + Text-Panel + Hub-Badges. „Erneut einreichen" +
„Werbliche Sprache wurde markiert." Wortlaute bleiben.
- [x] `PressReleaseWorkflowTest` + `AdminPressReleaseActionsTest`
bleiben grün (16 passed, 52 assertions).
- [x] Build + Pint + PROGRESS.

View file

@ -0,0 +1,69 @@
# Phase 4C — Press-Releases Forms (create / edit)
> Drittes Päckchen aus Phase 4. Folgt auf 4A (Listen) und 4B (Show).
**Status**: ✅ abgeschlossen · **Aufwand**: ~⅓–½ Tag · **Risiko**: niedrig
---
## Scope
- `resources/views/livewire/admin/press-releases/create.blade.php`
- `resources/views/livewire/admin/press-releases/edit.blade.php`
- `resources/views/livewire/customer/press-releases/create.blade.php`
- `resources/views/livewire/customer/press-releases/edit.blade.php`
NICHT in diesem Päckchen:
- Companies (Päckchen 4D)
- Settings / Profile (Päckchen 4E)
- Restliche Admin-Bereiche (Päckchen 4F)
## Ziel
Alle vier Forms im Hub-Vokabular:
- **Page-Header** wie auf Listen und Detail-Pages: Hub-Badge +
Eyebrow + H1 + Subtitle. Bei Edit zusätzlich Status-Pille im
Header-Meta. Aktions-Buttons rechts.
- **Form-Sektionen** als `.panel` mit `.panel-head` und
`section-eyebrow` ("Inhalt", "SEO & Links", "Metadaten",
"Status-Aktionen", "Aktionen").
- **Form-Felder** bleiben FluxUI (`<flux:field>`, `<flux:label>`,
`<flux:input>`, `<flux:textarea>`, `<flux:select>` inkl.
Combobox-Variant, `<flux:checkbox>`, `<flux:error>`) — das
Token-Bridging aus Phase 1 zieht. Required-Marker werden auf
Hub-Error-Token umgestellt.
- **Action-Buttons** ("Speichern", "Zur Prüfung einreichen",
"Als Entwurf speichern", "Status wechseln") in einem `.panel`
mit `.panel-head` "Aktionen".
- **Flash-Boxen** auf Hub-Token-Pillen.
## Was explizit NICHT angefasst wird
- **Volt-Logik** (`save`, `submitForReview`, `publish`, `reject`,
`backToDraft`, `archive`, `changeStatus`, `deletePressRelease`,
`mount`, `with`, `selectedCompany`) — Layout-only.
- **Confirm-Modals** der Admin-Edit-Page (`confirm-status-change`,
`confirm-delete-press-release`) — Tests assertieren Wortlaute.
- **Wortlaute** aus `AdminPressReleaseActionsTest`:
`Neuer Status`, `Status wechseln`, `Status wirklich wechseln?`,
`Pressemitteilung löschen?`, `Status wurde auf`.
- **Image-Manager-Komponente** (`<livewire:components.press-release-images-manager>`).
- **Wortlaute** für Customer-Create-Page: `Beta GmbH` (Firma
aus Faktur via `companyId`) — Combobox/Select bleibt funktional.
## Akzeptanzkriterien
- [x] Plan
- [x] Admin-Edit: Page-Header + 5 Panels + Hub-Status-Badge,
beide Modals unverändert, alle Wortlaut-Assertions grün.
- [x] Admin-Create: Page-Header + 3 Panels (Inhalt, SEO, Metadaten)
+ Aktions-Panel.
- [x] Customer-Create: Page-Header + 2 Panels (Inhalt, Metadaten)
+ Aktions-Panel.
- [x] Customer-Edit: Page-Header + 2 Panels (Inhalt mit Image-Manager,
Metadaten) + Aktions-Panel.
- [x] `AdminPressReleaseActionsTest`,
`CustomerCompanyContextTest > customer press releases create …`,
`CustomerProfileSecurityTest` bleiben grün (72 Tests, 310 assertions).
- [x] Build + Pint + PROGRESS.

View file

@ -0,0 +1,812 @@
# Fortschritts-Log — Hub × FluxUI
> Tagebuch der Umsetzung. Neue Einträge **oben** anfügen.
> Format pro Eintrag: Datum · Phase · Was wurde gemacht · Wer · Notizen
---
## 2026-05-19 · Phase 4C · Press-Releases Forms (create / edit)
- **Anlass**: User-Freigabe „ja" nach 4B. Drittes Päckchen aus
Phase 4 — Forms (create + edit) für Admin und Customer.
- **Plan-Dokument**: `09-PHASE-4C-PRESS-RELEASES-FORMS.md`.
- **Was umgebaut wurde (alle 4 Forms)**:
- **Page-Header** wie auf Listen und Detail: Hub-Badge
(„Admin Backend" / „User Backend") + Eyebrow + großes H1 +
Subtitle. Bei Edit zusätzlich Status-Pille im Header-Meta
(`@class([])`-Syntax mit `ok|warn|err|hub`) und „ID"-Pill.
Aktions-Buttons rechts (Zurück).
- **Form-Sektionen** als `.panel` mit `.panel-head` und
`section-eyebrow`: „Inhalt", „SEO & Links", „Metadaten",
„Status-Aktionen" (Admin-Edit), „Aktionen". Innenraum
immer `p-5 space-y-4`.
- **Form-Felder** bleiben FluxUI (`<flux:field>`,
`<flux:label>`, `<flux:input>`, `<flux:textarea>`,
`<flux:select>` inkl. Combobox-Variant, `<flux:checkbox>`,
`<flux:error>`) — das Token-Bridging aus Phase 1 zieht.
- **Required-Marker** von `text-red-500` auf Token-Farbe
(`text-[color:var(--color-err)]`) umgestellt.
- **Save-/Submit-Buttons** in eigenem `.panel` mit Header
„Aktionen" statt „nackten" Buttons in der Sidebar.
- **Flash-Boxen** (Success/Error) auf Hub-Token-Pillen.
- **Admin-Edit-spezifisch**: 5 Sidebar-Panels (Status-Aktionen +
Metadaten + Aktionen + Modals). „Status-Aktionen"-Panel zeigt
rechts im Header die aktuelle Status-Pille — visuelle Doppelung
mit dem Page-Header-Status, aber im Kontext der Sidebar
sinnvoll als Vergleichs-Anker für die „Neuer Status"-Auswahl.
- **Was explizit NICHT angefasst wurde**:
- **Volt-Logik** (alle `save`/`publish`/`reject`/`backToDraft`/
`archive`/`changeStatus`/`deletePressRelease`/`mount`/`with`/
`selectedCompany`-Methoden) — Layout-only.
- **Confirm-Modals** der Admin-Edit-Page
(`confirm-status-change`, `confirm-delete-press-release`) —
Tests in `AdminPressReleaseActionsTest` assertieren Wortlaute.
- **Wortlaute**: „Neuer Status", „Status wechseln",
„Status wirklich wechseln?", „Pressemitteilung löschen?",
„Status wurde auf" — alle erhalten.
- **`<livewire:components.press-release-images-manager>`** —
eigene Komponente, kommt im jeweiligen eigenen Päckchen dran.
- **Build/Test**:
- `npm run build:portal` → ok (`portal-D0cNdOWP.css` 418.42 kB).
- Linter clean (alle 4 Dateien).
- `php artisan test --compact --filter='PressRelease|CustomerCompanyContext|CustomerProfileSecurity|PanelConsolidation'`
**72 passed (310 assertions)**.
- Volle Suite: 230 passed, 3 skipped, 1 pre-existing failure
(`ApiDocumentationTest`).
- Pint `--dirty --format agent` → passed.
- **Offene Fragen**: Keine.
- **Nächster Schritt**: Phase 4 für Press-Releases ist mit 4A/4B/4C
komplett abgeschlossen. Nächstes Päckchen je nach Sichtbarkeit:
**4D = Companies** (`admin.companies.*`) ODER
**4E = Settings / Profile** (`me.profile`, `me.security`).
---
## 2026-05-19 · Phase 4B · Press-Releases Detail/Show-Pages
- **Anlass**: User-Freigabe „weiter" nach 4A. Zweites Päckchen aus
Phase 4 — Detail/Show-Pages für Admin und Customer.
- **Plan-Dokument**: `08-PHASE-4B-PRESS-RELEASES-DETAIL.md`.
- **Was umgebaut wurde (beide Show-Pages)**:
- **Page-Header** mit Hub-Badge + Eyebrow + Status-Pill +
Sprache + Portal (Admin nur), darunter großer H1 mit dem
PM-Titel und Subtitle mit Firma/Kategorie/Autor bzw. Datum.
Aktions-Buttons rechts (Bearbeiten/Vorschau-Link/Zurück).
- **Status-Workflow-Aktionsbar** als `.panel` mit `.panel-head`
und passend gefärbtem Badge je nach Status (warn=Review,
ok=Published, err=Rejected, hub=Draft).
- **Content-Hauptbereich** (PM-Text) als `.panel` mit eigenem
Header „Inhalt" + `prose` darunter. Keywords/Backlink darunter
als Footer mit Hub-Rule.
- **Sidebar / Side-Cards** (Admin) als kompakte `.panel` mit
`panel-head` „Details" und „Bilder" + Hub-Badges.
- **Status-Verlauf-Timeline** als `.panel` mit Hub-Badge je
Log-Eintrag (`.badge.ok|warn|err|hub`) statt FluxUI-`color`-Props.
- **Status-Badges** in Header und Timelines komplett auf
`<span class="badge …">` mit `@class([])`-Syntax.
- **Customer-Show-spezifisch**:
- **Rejection-Hinweis** als roter `.panel` mit linker
Akzent-Border (`border-left-width:3px`) und Icon-Box —
statt `<flux:callout color="red">`.
- **Review-Pending-Hinweis** als warner `.panel` mit
Akzent-Border und Clock-Icon-Box — statt `<flux:callout color="yellow">`.
- **Share-Link-Erfolgsblock** als ok-`.panel` mit
Gültigkeits-Badge im Header und readonly-Input im Body.
- **Contacts-Liste** als Hub-Items (`bg-bg-elev` +
`border-bg-rule`), Empty-State mit gestrichelter Border.
- **Status-Tile-Grid** (Aktuell/Erstellt/Veröffentlicht/Aufrufe)
als 4-er-Grid mit kleinen Hub-Tiles statt zinc-Hintergründen.
- **Admin-Show-spezifisch**:
- **Modals** (Publish/Reject/Archive) komplett unverändert —
Tests in `AdminPressReleaseActionsTest` assertieren Wortlaute.
- **Was explizit NICHT angefasst wurde**:
- **Volt-Logik** (publish/reject/archive/submitForReview/
generateShareLink/with-Method) — Layout-only.
- **Wortlaute** „Erneut einreichen", „Werbliche Sprache wurde
markiert." aus `PressReleaseWorkflowTest`.
- **Bestehende Modals** und ihre Wortlaute.
- **Mini-Stolperer (sofort gefixt)**:
- Erst zwei nicht-existente Tokens (`--color-rule`,
`--color-panel-soft`) verwendet. Korrigiert zu
`--color-bg-rule` und `--color-bg-elev` (16 + 5 Vorkommen).
Wäre im Browser stumm gefailed (Tailwind hätte die
Klassen einfach nicht ausgegeben).
- **Build/Test**:
- `npm run build:portal` → ok (`portal-Bq4pkLWt.css` 418.36 kB).
- Linter clean.
- `php artisan test --compact --filter='PressReleaseWorkflow|AdminPressReleaseActions'`
→ 16 passed (52 assertions).
- Volle Suite: 230 passed, 3 skipped, 1 pre-existing failure
(`ApiDocumentationTest`, schon vor 4A bekannt).
- Pint `--dirty --format agent` → passed.
- **Offene Fragen**: Keine.
- **Nächster Schritt**: Päckchen **4C** = Press-Releases Forms
(`create.blade.php` + `edit.blade.php`, Admin + Customer).
Forms sind tendenziell aufwendiger (mehr FluxUI-Felder, ggf.
zusätzliche Logik wie Image-Uploads).
---
## 2026-05-19 · Phase 4A · Press-Releases Listen-Pages
- **Anlass**: User-Freigabe „weiter" nach Phase 3 (Admin-Dashboard).
Phase 4 ist laut Roadmap „der Marathon" über Listen-/Detail-Pages
(~35 Tage iterativ). Wegen Umfang in Päckchen geteilt — A = Listen,
B = Detail/Show, C = Forms, D = Companies, E = Settings, F = Rest.
- **Plan-Dokument**: `07-PHASE-4A-PRESS-RELEASES-LISTEN.md` (knapp,
Scope hart auf zwei `index.blade.php`-Files begrenzt, mit explizitem
„NICHT in diesem Päckchen"-Block).
- **Scope dieses Päckchens**:
- `resources/views/livewire/admin/press-releases/index.blade.php`
- `resources/views/livewire/customer/press-releases/index.blade.php`
- **Was umgebaut wurde (beide Listen)**:
- **Page-Header** im Hub-Stil: `<header>` mit `1fr auto`-Grid,
Hub-Badge („Admin Backend" / „User Backend"), Eyebrow, großes H1,
Subtitle, rechts der CTA-Primary-Button (FluxUI bleibt für den Button).
- **Filter-Bar** als `.panel` + `.panel-head` mit `section-eyebrow`
„Filter & Suche". FluxUI-Inputs (Search-Input, Selects, Combobox
für User/Company/Contact-Lookup) bleiben unverändert — Hub hat
kein eigenes Combobox-Pendant.
- **Tabelle** als `.panel` mit `.panel-head` „Alle Pressemitteilungen"
+ Eintrags-Counter rechts. FluxUI-`<flux:table>` bleibt — das
Zinc-→Hub-Mapping aus Phase 1 zieht hier perfekt.
- **Status-Badges**: `<flux:badge color="green|yellow|red|zinc|blue">`
ersetzt durch `<span class="badge ok|warn|err|hub">` für visuelle
Konsistenz mit dem Dashboard (`@class([...])`-Syntax).
- **Empty-State** mit Icon-Box (`bg-[color:var(--color-hub-soft)]`,
Hub-Border, `flux:icon.newspaper`) + Headline + Subtext.
- **Flash-Boxen** auf Hub-Tokens (`--color-ok-soft`, `--color-err-soft`,
`--color-gain-deep`, `--color-loss`) statt `bg-green-50` etc.
- **Admin-spezifisch**: Vier `<x-portal.stat-card>` (Gesamt/Veröffentlicht/
In Prüfung/Entwürfe) als KPI-Reihe — identisches Layout wie Admin-Dashboard.
- **Was explizit NICHT angefasst wurde**:
- **Confirm-Modals** (Publish/Reject/Archive) — Tests in
`AdminPressReleaseActionsTest` assertieren die Wortlaute
(„Pressemitteilung veröffentlichen?" etc.) → unverändert.
- **Volt-Logik** (Sort, Filter, alle Methods) — Layout-only.
- **Bonus-Fix**: `customer/dashboard.blade.php` Subtitle bekommt
einen „Übersicht für :company —"-Einschub, wenn `$selectedCompany`
gesetzt ist. Korrigiert eine Phase-2-Regression bei
`CustomerCompanyContextTest > customer dashboard is filtered by …`.
- **Build/Test**:
- `npm run build:portal` → ok (`portal-DaL-tXm-.css` 418.75 kB).
- Linter clean.
- `php artisan test --compact --filter='PressReleaseWorkflow|AdminPressReleaseActions|Dashboard|CustomerCompanyContext'`
→ 45 passed (163 assertions).
- Volle Suite: 230 passed, 3 skipped, 1 failed.
Der einzelne Fail (`ApiDocumentationTest`) ist **pre-existing**
via `git stash` verifiziert, war auch vor diesem Päckchen rot
(`/docs/api/v1` liefert nicht das erwartete YAML).
- Pint `--dirty --format agent` → passed.
- **Offene Fragen**: Keine.
- **Nächster Schritt**: Päckchen **4B** = Detail/Show-Pages
(`admin/press-releases/show.blade.php` +
`customer/press-releases/show.blade.php`). Dort ist mit der
„Werbliche Sprache wurde markiert."- und „Erneut einreichen"-
Assertion aus `PressReleaseWorkflowTest` zu rechnen — Wortlaute
bleiben unverändert.
---
## 2026-05-19 · Phase 3 · Admin-Dashboard im Hub-Vokabular
- **Anlass**: User-Freigabe „weiter" nach Phase 5 (Dark Mode). Phase 3
laut Roadmap: Admin-Dashboard (`/dashboard`) im selben Vokabular wie
Customer-Dashboard.
- **Plan-Dokument**: `06-PHASE-3-ADMIN-DASHBOARD.md` (knapper als
Phase 2 — niedrig-risiko, keine neuen Konzepte, reine
Vokabular-Migration auf die DRY-Schicht aus Phase 2).
- **Ausgangslage**: `admin/dashboard.blade.php` war Controller-rendered
(kein Volt), nutzte reines Tailwind mit `zinc-*`-Klassen, harten
Farb-Pillen (`bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 …`)
und keiner FluxUI-Komponente. Visuell aus der Zeit gefallen.
- **`admin/dashboard.blade.php` komplett umgeschrieben**:
- **Page-Header**: Hub-Badge „Admin Backend" + Eyebrow + großes H1
+ Subtitle mit `auth()->user()->name`. Rechts: ok-Pille
„Alle Systeme operational".
- **KPI-Reihe** (5 Stat-Cards via `<x-portal.stat-card>`):
- Pressemitteilungen → `primary` mit Trend-Slot
„X pub · Y prüf · Z entwurf" (Wortlaut für Test-Assertions
bewusst beibehalten).
- In Prüfung → `warn` als eigene KPI (war vorher nur in der PM-Card
versteckt — jetzt klickbar direkt in den Filter).
- Firmen / Kontakte / Benutzer → `muted` mit kurzen Sublines.
- Alle Cards sind klickbar (link-wrapped) → CRM-/Content-Übersichten.
- **2-Spalten-Grid** (`2fr 1fr`):
- Links: Panel „Letzte Pressemitteilungen" mit Hub-Liste +
`.badge.ok|warn|err|hub`-Pillen (statt Tailwind-Farben).
- Rechts: Panel „Zur Prüfung" mit warn-Pille (Count) oder
ok-Pille „leer". „+ N weitere"-Link im Footer.
- **Newsletter + Quick-Actions** (`1fr 2fr`, NEU):
- Links: `panel-warm` Newsletter-Block mit Mono-Zahl
+ Subline + Sync-Link.
- Rechts: Quick-Action-Grid mit 4 Icon-Buttons (Pressemitteilungen,
Firmen, Rechnungen, Voreinstellungen) — Icon-Tile wechselt auf
Hover ins gefüllte Hub-Blau.
- **Footer**: subtle Link-Reihe (Benutzer / Rollen / Performance).
- **Was bewusst NICHT geändert**:
- Controller-Logik, Datenform, Cache-Strategie (`AdminPerformanceCache`).
- Newsletter-Count bleibt, wandert nur in eigenen Block.
- **Build/Verifikation**:
- `npm run build:portal``portal: 417.81 kB` (Δ -0.7 kB,
weil viele Zinc-Tailwind-Klassen weggefallen sind).
- `php artisan test --filter='DashboardTest|AuthenticationTest|RegistrationTest|CustomerPortalTest'`
**18/18 passed**, 70 Assertions. `DashboardTest` mit seinen
Wortlaut-Assertions (`3`, `1 pub`, `1 prüf`, `1 entwurf`,
`Review Dashboard PM`) bleibt grün ohne Anpassung.
- `vendor/bin/pint --dirty``passed`.
- Linter: 0 Errors.
- **Lessons learned**:
- Vokabular-Migrations ohne Logik-Eingriff brauchen ~15 Minuten dank
DRY-Schicht aus Phase 2 — der Wert der `shared/hub-components.css`
+ `<x-portal.stat-card>`-Investition zahlt sich ab Phase 3 deutlich aus.
- Der `<x-portal.stat-card>` ist auch ohne `:value`-Number-Format
flexibel (`number_format()` direkt im Aufruf eingebettet).
- Test-Assertions können bei Layout-Refactors überraschend stabil
bleiben, wenn man die Original-Strings im neuen Layout an
sinnvollen Stellen behält (hier: im Trend-Slot der KPI-Card).
---
## 2026-05-19 · Phase 5 · Dark Mode + Switcher im User-Menü
- **Anlass**: User-Feedback nach Phase 2: „Switch in den Dark Mode funktioniert
nicht. Zusätzlich hätte ich gerne einen Switcher hell/dunkel direkt im
User-Menü." → Phase 5 vorgezogen vor Phase 3/4, weil der Switcher der
natürliche UX-Einstiegspunkt für Dark Mode ist und der Notfall-Hack
aus Phase 1 (`portal.css: .dark { --color-accent: var(--color-hub) }`)
endlich verschwinden soll.
- **Plan-Dokument**: `05-PHASE-5-DARK-MODE.md` mit Token-Mapping-Tabelle,
drei wichtigen Token-Konstanten-Tricks (`--color-accent-warm`,
`--color-panel-dark`, `--color-panel-dark-2`) und Switcher-Snippet.
- **`design-tokens.css` — Dark-Block aktiviert**:
- Vollständiger `.dark { … }`-Block mit Werten aus
`User Dashboard presseportale Dark.html`.
- Surfaces: bg/elev/card/rule schalten auf `#0e1218`-Familie.
- Hub-Blau wird **heller** im Dark Mode (`#5a78c2` statt `#1a2540`) —
notwendig für Lesbarkeit auf dunklem Bg.
- Bernstein-Akzent ebenfalls heller (`#d9a560`).
- Status-Farben (`ok/warn/err`) auf dunkle, gesättigte Variante.
- Schatten-Tokens neutral-schwarz statt hub-blau-warm.
- `color-scheme: dark;` als Hint für native Form-Controls.
- **3 neue Token-Konstanten** (gleicher Wert in beiden Modi):
- `--color-accent-warm` (`#b07a3a`) — für Stellen, die explizit Bernstein
bleiben müssen (Hint-Card-Border-Left, Progress-Bar-Fill); löst die
Kollision auf, dass `--color-accent` im Portal auf Hub-Blau gemapt
ist und im Dark Mode noch heller würde.
- `--color-panel-dark` (`#0f1729`) und `--color-panel-dark-2` (`#1a2540`)
— für `.panel-dark` und Brand-Bridge-Inner-Boxes. Ohne diese würde
der dunkle Bridge-Container im Dark Mode plötzlich hellblau, weil
`var(--color-hub)` zum hellen Wert wird.
- **`portal.css` — Notfall-Hack aus Phase 1 entfernt**:
- Der `.dark { --color-accent: var(--color-hub); … }`-Block ist
überflüssig, weil das echte Dark-Token-Mapping in `design-tokens.css`
`--color-hub` automatisch auf `#5a78c2` schaltet — und
`--color-accent` darüber per `var(--color-hub)`-Verweis dynamisch
mitkommt.
- Primary-Button-Shadows zusätzlich für `.dark` überschrieben: statt
`rgba(26, 37, 64, …)` (warmer Hub-Blau-Alpha) jetzt `rgba(0, 0, 0, …)`
(neutral-schwarz), weil der hub-blaue Schatten auf dunklem Card-BG
zu sichtbar wirkt.
- **`hub-components.css` — Dark-tauglich gemacht**:
- `.panel-dark` nutzt jetzt `var(--color-panel-dark-2)` und
`var(--color-panel-dark)` (vorher `var(--color-hub)` und
`var(--color-topbar-deep)`) → bleibt im Dark Mode dunkel.
- `.hint-card border-left` und `.hint-bar > span` nutzen
`var(--color-accent-warm)` (konstant Bernstein) statt `var(--color-accent)`
(im Portal Hub-Blau-Mapping).
- `.hint-card background` schaltet auf `var(--color-bg-card-warm)`
ist im Light Mode `#efeadc` (warmes Buchpapier), im Dark Mode
`#1f1a12` (warm-dunkles Bernstein-Substrat).
- Restliche Klassen funktionieren **automatisch**, weil alle Werte
über Tokens laufen — DRY-Architektur zahlt sich aus.
- **`customer/dashboard.blade.php`**:
- Brand-Bridge-Inner-Boxes auf `bg-[color:var(--color-panel-dark-2)]`
umgestellt (statt `--color-hub-2`, das im Dark Mode hell würde).
- **Switcher im User-Menü** (`sidebar.blade.php` — Desktop + Mobile):
- Vor dem Logout-Button: kleiner Block mit Eyebrow „Erscheinung" und
FluxUI-Segmented-Radio-Group mit Icons-only.
```blade
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
```
- `$flux.appearance` ist FluxUIs Magic-Object — LocalStorage-persistent,
`@fluxAppearance`-Script setzt `class="dark"` auf `<html>`.
- In BEIDEN Dropdowns (Desktop in der Sidebar, Mobile im Header).
- **Hub-Frontend bleibt Light-Only** (Plan-Vorgabe):
- `auth/pressekonto.blade.php` lädt KEIN `@fluxAppearance` und KEIN
`partials/head`. Damit wird `class="dark"` dort nie gesetzt — auch
nicht, wenn LocalStorage `dark` enthält. Kein Eingriff nötig.
- Hub-Landing analog (eigene `<head>`-Pipeline).
- **Build/Verifikation**:
- `npm run build:portal``portal: 418.55 kB` (Δ +1.5 kB für
Dark-Tokens + Switcher).
- `php artisan test --filter='DashboardTest|AuthenticationTest|RegistrationTest|CustomerPortalTest'`
**18/18 passed**, 70 Assertions. Keine Regressions.
- `vendor/bin/pint --dirty``passed`.
- Linter: 0 Errors.
- **Lessons learned**:
- Tailwind v4's `@theme`-Variables sind **dynamisch** zur Laufzeit —
`--color-accent: var(--color-hub)` schaltet beim `.dark`-Switch
automatisch mit, ohne dass man `.dark { --color-accent: … }` extra
setzen muss.
- Wenn ein Wert in beiden Modes IDENTISCH bleiben soll (`--color-panel-dark-2`,
`--color-accent-warm`), gehört er in den Light-Block und wird im
`.dark`-Block bewusst NICHT überschrieben. Vererbung tut den Rest.
- FluxUI's `flux:radio.group variant="segmented"` mit Icons-only ist
perfekt für Dropdown-Switcher; `:title="…"` liefert Tooltips ohne
sichtbares Label.
- **Bonus**: Der ursprüngliche Symptom-Fix aus Phase 1
(Multi-Alpine + Dark-Mode-Bug) ist jetzt strukturell aufgelöst:
- Alpine kommt ausschließlich aus `@fluxScripts`.
- Dark Mode ist echt umgesetzt, nicht maskiert.
---
## 2026-05-19 · Phase 2 · Customer-Dashboard auf Mockup-Stil
- **Anlass**: User-Freigabe „weiter“ nach Behebung des Dark-Mode/Alpine-Bugs.
Phase 2 laut Plan: Customer-Dashboard (`/admin/me`) an
`User Dashboard presseportale.html` angleichen.
- **Vorab-Doku**: `04-PHASE-2-CUSTOMER-DASHBOARD.md` mit Mockup-Diff-Tabelle,
CSS-Strategie, Component-Skizzen, Akzeptanzkriterien.
- **CSS-Architektur — DRY-Schritt**:
- Neue Datei `resources/css/shared/hub-components.css` als **Single Source
of Truth** für Hub-Layout-Bausteine: `.panel` (warm/dark/head),
`.stat-card` (+ Varianten `is-primary|ok|warn|muted`, `.stat-strip`,
`.stat-label`, `.stat-num`, `.stat-meta`, `.stat-trend`), `.hint-card`
(+ `.hint-ico`, `.hint-bar`, `.hint-action`), `.badge` (+ `.warn|ok|hub|err|dot`),
`.bridge-row`/`.dot-pe`/`.dot-bp`, portable `.eyebrow` und
`.section-eyebrow` (für Portal — Web-Build überschreibt idempotent).
- Importiert in **beiden** Builds:
- `resources/css/portal.css` (nach `flux.css`)
- `resources/css/web/shared-styles.css` (nach `design-tokens.css`)
- Alle Werte nutzen `var(--color-*)`-Tokens — keine Hex-Literale.
- Fehlende Tokens nachgezogen in `shared/design-tokens.css`:
`--color-bg-rule-2` (hellere Progress-Track-Variante) und
`--color-gain-deep` (dunkles Grün für `is-ok`-Stat-Num).
- **Neue Blade-Components** in `resources/views/components/portal/`:
- `<x-portal.stat-card variant="…" label="…" :value="…">`
mit Slots `meta` (oben rechts) und `trend` (unten).
- `<x-portal.hint-card :icon :title :percent :href :action>`
mit Progress-Bar bei gesetztem `$percent` und `wire:navigate`-Link.
- **`livewire/customer/dashboard.blade.php` komplett neu**:
- **Page-Header**: Hub-Badge „User Backend“ + Eyebrow + großes H1
+ Subtitle. Rechts entweder Aktive-Firma-Pille **oder** Warn-Pille
„Keine Firma zugeordnet → zuordnen“.
- **KPI-Reihe** (4 `stat-card`s): Gesamt (primary, mit Δ-zu-Vormonat),
Veröffentlicht (ok), In Prüfung (warn), Entwürfe (muted).
- **Zweispalten-Grid** (`lg:grid-cols-[2fr_1fr]`):
- Links: Panel mit Pressemitteilungen-Liste oder Empty-State (Icon-Box
mit Notification-Dot, Headline, Action-Button, Schritt-Karten 01/02/03,
Footer-Tipp „4 Stunden“).
- Rechts: Panel „Datenqualität“ mit `<x-portal.hint-card>`s oder
Success-State („Alles im grünen Bereich“).
- **Unteres Grid**: Firmen-Panel (Liste **oder** dashed Empty-Slot
+ Hinweis-Box) + Brand-Bridge-Dark-Panel (presseecho + businessportal24
mit Status, API-Status-Indikator, Tarif).
- **Footer**: Subtle-Link-Reihe (Sicherheit, API & Tokens, Profil).
- **Volt-`with()` erweitert**:
- `stats.deltaMonth` — Monatsvergleich via zweiter `whereBetween`-Query.
- `profileCompleteness` (Heuristik auf 6 Profile-Kernfelder).
- `billingCompleteness` (5 Address-Pflichtfelder).
- `bridgeStatus` (vorerst hardcoded `connected` — Phase 3+).
- `qualityHints[]` optional um `percent`-Feld erweitert — wenn gesetzt,
rendert Progress-Bar in der Hint-Card.
- **Edge-Case beim Bauen**: Blade interpretierte `<flux:icon>` im PHPDoc-
Kommentar von `hint-card.blade.php` als echten Component-Tag und produzierte
`Undefined variable $component`. Fix: Doc-Block entkontaminiert
(„flux:icon“ ohne Spitzklammern).
- **Tests neu** in `tests/Feature/Customer/DashboardTest.php` (5 Cases):
1. Customer-Dashboard rendert Core-Sections ohne Fehler
2. Empty-State mit 3-Schritt-Intro wird gezeigt
3. PR-Liste + KPI-Zahlen rendern bei vorhandenen Daten
4. Profile-Completeness-Hint mit Prozentwert erscheint bei Teildaten
5. Vollständiges Profil + Billing → Hints werden ausgeblendet,
„Alles im grünen Bereich“ wird gezeigt
- **Ergebnis**: 5/5 passed, 21 Assertions.
- Cross-Check: Verwandte Tests (`Dashboard|Authentication|Registration|CustomerPortal`)
**18/18 grün**, 70 Assertions. Keine Regressions.
- **Build/Verifikation**:
- `npm run build:portal``portal: 417.02 kB` (Δ +8 kB für neue
Hub-Components-Klassen + Dashboard-Klassen). Web-Build unverändert.
- `vendor/bin/pint --dirty``passed`.
- **Was bewusst weggelassen**:
- Sparklines auf den Stat-Cards (kommen mit echten Trend-Daten in Phase 4).
- Topbar-Anpassung (eigene Phase, später).
- `<x-portal.bridge-card>`-Extraktion (Brand-Bridge bleibt inline, bis
sie an mehr Stellen gebraucht wird).
- **Lessons learned**:
- PHPDoc in Blade-Components darf KEINE `<flux:*>`-Tags enthalten —
der Blade-Pre-Compiler scannt das gesamte File vor dem PHP-Parsing.
- `shared/hub-components.css` ist der richtige Hebel für Phase 3
(Admin-Dashboard) und Phase 4 (Listen/Detail) — Components-CSS muss
nicht mehr pro Phase neu definiert werden.
---
## 2026-05-19 · Phase 1 Refinement · Dark-Mode-Bug + Multi-Alpine
- **Anlass**: User-Review nach Kontrast-Refinement: zwei konkrete Befunde
aus der Browser-Konsole.
1. `[Warning] Detected multiple instances of Alpine running (me, line 114)`
2. Button-Default-Farbe ist `rgb(109, 138, 211)` (helles Blau), nicht
das gewünschte Hub-Blau `rgb(26, 37, 64)`. Computed Style zeigt:
`.bg-[var(--color-accent)] { background-color: var(--color-accent); }`
`var(--color-accent)` löst zu `#6d8ad3` auf.
- **Diagnose Bug 1 (Alpine)**:
- `partials/head.blade.php` lädt `resources/js/app.js` über `@vite`.
`app.js` ruft `Alpine.start()` auf.
- Am Ende des `<body>` injiziert `@fluxScripts` (sidebar.blade.php Zeile
nahe `</body>`) **eine eigene Alpine-Instanz** (FluxUI bringt Alpine
selbst mit).
- Ergebnis: zwei `window.Alpine`-Objekte, Bindings teils tot, Warning.
- Auf der Login-Seite hatten wir das **bereits** gefixt (app.js dort
rausgenommen), aber im Portal-Layout war der Fix noch offen.
- **Diagnose Bug 2 (Button-Farbe)**:
- Der Wert `rgb(109, 138, 211)` = `#6d8ad3` ist **exakt** der
Dark-Mode-Fallback, den ich in `portal.css` unter `.dark { --color-accent: #6d8ad3 }`
definiert hatte.
- FluxUI's `@fluxAppearance`-Helper schreibt `class="dark"` auf
`<html>`, sobald der User den Appearance-Switcher mal auf "dark"
gestellt hatte (gespeichert in `localStorage`).
- Damit überschreibt der Dark-Block den Light-Mode-Akzent
`var(--color-hub)` (`#1A2540`) — Buttons sehen plötzlich blassblau aus.
- Dark Mode ist laut Plan erst Phase 5, war aber durch den
Fallback-Block bereits halb aktiv.
- **Fixes**:
- `resources/views/partials/head.blade.php`: `resources/js/app.js`
aus dem `@vite`-Aufruf **entfernt**. Alpine kommt im Portal
ausschließlich über `@fluxScripts`. (Hub-Landing nutzt
`partials/head` nicht — eigene `<head>`-Pipeline.)
- `resources/css/portal.css`: Der `.dark { --color-accent: ... }`-
Block setzt jetzt **bewusst** alle Akzent-Tokens auf
`var(--color-hub)` (also den Light-Mode-Wert). Damit bleibt das
Hub-Blau auch bei eingeschaltetem `class="dark"` konsistent.
Kommentar dokumentiert, dass Phase 5 diesen Block durch das
echte Dark-Token-Mapping aus `shared/design-tokens.css` ersetzt.
- **Build/Verifikation**:
- `npm run build:portal``portal: 408.97 kB` (Δ +0.02 kB).
- `php artisan test --compact --filter='AuthenticationTest|RegistrationTest'`
**6 passed, 19 assertions**, 1.93s.
- `vendor/bin/pint --dirty --format agent``passed`.
- **User-Action zur Verifikation**:
- Im Portal Hard-Reload (Cmd/Strg+Shift+R) — die alte `app.js`
hängt sonst im HTTP-Cache.
- Konsole sollte **keine** Multi-Alpine-Warning mehr werfen.
- Primary-Buttons sind im Default-State sattes Hub-Blau (`#1A2540`),
Hover noch dunkler (`#243152`).
---
## 2026-05-19 · Phase 1 Refinement · Kontraste & Button-Hover
- **Anlass**: User-Review nach Phase 1: „Buttons als Primary auf
`rgb(26, 37, 64)` einsetzen, die andere Farbe ist deutlich zu hell.
Es fehlen noch deutliche Kontraste."
- **Diagnose**:
- FluxUI-Primary-Buttons sind eigentlich **schon** auf `var(--color-accent)`
= `var(--color-hub)` = `#1A2540` = `rgb(26, 37, 64)` gesetzt
(im Build verifiziert). Sollte stimmen.
- **ABER**: Der FluxUI-Default-Hover ist
`hover:bg-[color-mix(in_oklab,_var(--color-accent),_transparent_10%)]`
— auf hellem Buchpapier wirkt das hell-blau statt der gewünschten
Hub-Konvention `hover:bg-hub-2` (dunkler als Default).
- Außerdem zu wenig Schatten/Border-Kontrast für klare Button-Kanten.
- **Erkenntnis**: Frühere `[data-flux-button][data-variant="primary"]`-
Overrides griffen **nie**, weil FluxUI kein `data-variant`-Attribut
rendert. Variant-Styling kommt komplett über Tailwind-Klassen
(z.B. `bg-[var(--color-accent)]`). Neue Selektor-Strategie nutzt
jetzt diese Klassen direkt (mit escapeten Brackets).
- **Fixes in `portal.css`**:
- **Primary-Button-Hover** auf `var(--color-hub-2)` (#243152) statt
FluxUI's color-mix-Hellung. Selektor:
`[data-flux-button].hover\:bg-\[color-mix\(in_oklab\,_var\(--color-accent\)\,_transparent_10\%\)\]:hover`
- **Primary-Button-Shadow**: kräftigeres Inset-Highlight + warmer
Drop-Shadow in Hub-Blau-Alpha für klare Kanten auf Buchpapier.
Border zusätzlich auf `var(--color-hub-2)` für definierten Rand.
- **Hover-State**: noch stärkerer Shadow + Drop für Tiefenwirkung.
- **Input-Focus** auf Hub-Blau-Ring (statt blassem Default-Akzent),
mit Buchpapier-Offset für saubere Trennung.
- Alter (defunkter) `[data-flux-button][data-variant="primary"]`-
Block entfernt, weil Selektor nicht existiert.
- **Build/Verifikation**:
- `npm run build:portal``portal: 408.95 kB` (von 409.03 kB, -0.08 kB).
- Im finalen CSS verifiziert:
* Hub-2-Hover-Override:
`[data-flux-button].hover\:bg-…:hover{background-color:var(--color-hub-2)!important}`
* Shadow-Override:
`[data-flux-button].bg-\[var\(--color-accent\)\]{border-color:var(--color-hub-2);box-shadow:inset 0 1px #ffffff2e,0 1px 2px #1a254040,…}`
* Input-Focus-Ring auf Hub-Blau.
- `vendor/bin/pint --dirty` → passed.
- **Wirkung**:
- Primary-Button **Default**: `#1A2540` (Hub-Blau), klarer warmer
Schatten, definierter Rand → fühlt sich „solid" an wie auf der
Hub-Landing.
- Primary-Button **Hover**: `#243152` (Hub-2, dunkler statt heller)
— Hub-Konvention, signalisiert Interaktion ohne den Eindruck einer
schwächeren Farbe zu erzeugen.
- Inputs zeigen jetzt einen **erkennbaren Hub-Blau-Ring** beim Fokus
statt eines blassen Bernstein-Schimmers.
- **Hinweis an User**: **Hard-Reload** (Cmd+Shift+R) ist nötig — das
CSS-Bundle hat einen neuen Hash (`portal-kuU-opFv.css`).
- **Verfeinerungen noch offen für Phase 2 / Iteration**:
- Subtle/Filled/Ghost-Buttons (`<flux:button variant="filled">`) sind
weiterhin transparent-zinc → bei Bedarf in Phase 2 angleichen.
- Sidebar-Active-Item hat schon Hub-Soft + 2 px-Strip; ggf. den
Strip noch sichtbarer machen wenn weiter zu unauffällig.
---
## 2026-05-19 · Phase 1 abgeschlossen · Portal-Shell auf Hub-Design
- **Was**: Portal-Shell (Sidebar, Topbar, Layout-Container, Customer-Banner)
visuell ans Hub-Design angeglichen. FluxUI bleibt komplett erhalten —
Anpassung erfolgt über CSS-Token-Bridging und `[data-flux-*]`-Overrides.
Brand-Mark ersetzt das Starter-Kit-Logo. Light Mode ist Default; Dark
Mode für Phase 5 vorbereitet (heller Hub-Blau).
- **Dateien (geändert)**:
- `resources/css/portal.css` — komplett refactored:
* `--font-sans: "Inter Tight"` statt `Instrument Sans`
* `--color-accent: var(--color-hub)` (#1A2540) statt `#3ea3dc`
* `--color-zinc-50..950` auf Hub-Buchpapier-Familie gemappt
(Zinc-100 = #F6F4EF, Zinc-700 = #1A1F1C, Zinc-900 = Hub-Blau)
* Hub-Stil-Overrides: `[data-flux-sidebar]`, `[data-flux-navlist]`,
`[data-flux-navlist-item]` mit Active-Strip links, `[data-flux-button]`
Primary/Filled auf Hub-Blau, `[data-flux-card]` Buchpapier
* Dark-Mode-Block bleibt vorerst minimal (nur Accent), Vollumstellung
in Phase 5
- `resources/views/partials/head.blade.php` — Bunny-Font auf
`inter-tight + jetbrains-mono + source-serif-4` (für Brand-Mark)
- `resources/views/components/layouts/app/sidebar.blade.php`:
* `class="dark"` aus `<html>` entfernt
* `<body>` jetzt `bg-bg text-ink antialiased` (Hub-Buchpapier)
* `<x-app-logo>` ersetzt durch `<x-web.brand-mark brand="pressekonto"
:serif="false" />` + Eyebrow "Publisher · Hub" — sowohl im Desktop-
Brand-Block als auch im Mobile-Header
* Testmodus-Block (Impersonation) komplett im Hub-Stil: dunkles
Hub-Blau-Panel mit Bernstein-Eyebrow „Testmodus aktiv", weiße CTA
„Zurück zum Admin" (statt Amber-Warnfarbe wie zuvor)
* Resources-Block (Tailwind/HeroIcons/Flux/Repository) entfernt —
gehört nicht ins Live-Portal
- `resources/views/components/layouts/app.blade.php`:
* Customer-Banner („User Backend") visuell auf Hub-Stil: Hub-Soft-
Hintergrund + Hub-Soft-2-Border, Hub-Blau-Pille mit Dot, Eyebrow
„Firmenkontext" in Sperrschrift, Heading in Hub-Ink
- **Dateien (Test-Anpassungen, weil rollen-basierter Redirect aus Login-Fix
weitere Tests berührt hat)**:
- `tests/Feature/Auth/AuthenticationTest.php` — Test-User auf
`superAdmin()` umgestellt (sonst `canAccessAdmin() === false`
Redirect auf `/` statt `/dashboard`)
- `tests/Feature/Auth/RegistrationTest.php``terms_accepted` im
Formular gesetzt + Redirect-Erwartung auf `/` angepasst (frisch
registrierte User haben keine Rolle → Fallback `/`)
- **Build/Test**:
- `npm run build:portal``portal: 409.03 kB` (vorher 408.89 kB,
+0.14 kB). Unter dem 10 %-Akzeptanzkriterium (≤ 450 kB).
- CSS-Inspektion bestätigt Phase-1-Tokens:
* `--color-accent: var(--color-hub)` und `.dark { --color-accent:
#6d8ad3 }` im `:root`
* `--color-zinc-700: #1a1f1c` (Hub-Ink statt Zinc-Grau)
* `--font-sans: "Inter Tight"` ohne Instrument-Sans-Vorlauf
* Hub-Overrides im Output: `[data-flux-sidebar]{background:var(...)}`,
`[data-flux-navlist-item][aria-current=page]{background:var(
--color-hub-soft);...}`
- Auth-Test-Suite: **0 zusätzliche Regressionen** verifiziert (8 fail
/ 15 passed vor & nach Phase 1; verbleibende Failures sind pre-existing
Domain-Mismatch- und CSRF-Issues im Test-Setup).
- Smoke-Test: `/dashboard`, `/admin/me`, `/settings/profile`,
`/admin/companies`, `/admin/press-releases` antworten alle HTTP 302
`/login` (erwartetes Verhalten ohne Auth-Session via curl).
- `vendor/bin/pint --dirty` → passed.
- **Bewusst NICHT in Phase 1**:
- **Topbar** mit Breadcrumb + Bridge-Row + Search + „Neue Mitteilung"-CTA
aus dem Mockup → ist für Phase 2 (Customer-Dashboard) sinnvoller,
weil die Topbar dort Page-Kontext (Breadcrumb-Titel) braucht. Aktuell
nutzen wir FluxUI's Default-Layout ohne dedizierte Topbar.
- Konto-Switcher als Sidebar-Header (Avatar + Name + Firma als
Dropdown-Trigger oben statt unten) → das User-Menü unten bleibt
vorerst FluxUI-Standard. Iterativ in Phase 2.
- Dashboard-Inhalte (Stat-Cards, Hint-Cards) → Phase 2 & 3.
- Listen-Pages → Phase 4 (automatisch durch Token-Bridge schon „okay").
- **Beobachtungen**:
- FluxUI-Navlist-Item-Selektor: `[aria-current="page"]` greift. Falls
künftige FluxUI-Versionen `[data-current="true"]` statt
`[aria-current="page"]` setzen, deckt der Override beides ab.
- Vendor-Pfad in `partials/head.blade.php` bewusst nicht geändert —
Vite-Setup mit `build/portal` bleibt wie gehabt.
- **Nächster Schritt**: **Review-Stopp**. Bei Freigabe → entweder
Phase 2 (Customer-Dashboard-Stat-Cards & Hint-Cards) oder Topbar-
Iteration. Entscheidung bei dir/Frank nach Anschauen.
---
## 2026-05-19 · Login-Funktionsfix · Doppelte Alpine-Instanz
- **Was**: Nach Phase 0 trat ein funktionaler Bug auf: Login-Form ging
nicht durch, Browser-Logs zeigten „Detected multiple instances of
Alpine running" und „Livewire: published assets out of date".
- **Ursache**: Das Hub-Auth-Layout (`pressekonto.blade.php`) lud sowohl
`resources/js/app.js` (das `Alpine.start()` aufruft) als auch
`@livewireScripts` (das Alpine intern mitbringt). Zwei Alpine-Instanzen
`wire:submit`, `x-data`, `wire:model` brachen.
- **Fixes**:
- `app.js` aus `@vite([…])` im Auth-Layout entfernt — Alpine kommt nun
nur noch über Livewire.
- `php artisan livewire:publish --assets``livewire.js` von 450 kB
auf 552 kB aktualisiert (Versions-Mismatch behoben).
- Login/Register-Redirect auf rollen-basierte Logik umgestellt
(Admin → `/dashboard`, Customer → `/admin/me`, sonst `/`) und
`navigate: true` entfernt — SPA-Navigation kann den Wechsel zwischen
Web-Build (Hub-Auth) und Portal-Build (FluxUI-Dashboard) nicht
handhaben.
- **Dateien**:
- `resources/views/components/layouts/auth/pressekonto.blade.php`
- `resources/views/livewire/auth/login.blade.php`
- `resources/views/livewire/auth/register.blade.php`
- **Verifikation**: Login funktioniert (User bestätigt), Redirect zum
Dashboard läuft, keine Browser-Warnings mehr.
---
## 2026-05-19 · Phase 0 abgeschlossen · Token-Unifizierung
- **Was**: Single Source of Truth für Design-Tokens etabliert. Web- und
Portal-Build importieren beide `resources/css/shared/design-tokens.css`.
Visuelle Unverändertheit verifiziert — FluxUI-Defaults gewinnen im
Portal weiterhin (Instrument Sans, Zinc, `#3ea3dc`), wird in Phase 1
abgelöst.
- **Dateien**:
- `resources/css/shared/design-tokens.css` **neu** — alle Hub-Tokens
plus Status-Reihe (`--color-warn`, `--color-warn-soft`,
`--color-err`, `--color-err-soft`, `--color-ok-soft`), Bridge-Dots
(`--color-bridge-presseecho`, `--color-bridge-businessportal`),
Radii (`--radius-xs/sm/md/lg`) und Schatten (`--shadow-soft`,
`--shadow-auth`). Dark-Mode-Block als auskommentierter Vorgriff.
- `resources/css/web/shared-styles.css` — Token-Import nach
`tailwindcss` ergänzt. Wirkt damit für alle Web-Themes
(pressekonto, presseecho, businessportal24).
- `resources/css/web/theme-pressekonto.css` — kompletter `@theme {}`-
Block entfernt (lebt jetzt in der Token-Datei). HSL-Legacy-Variablen
und `@layer components { … }` blieben unverändert.
- `resources/css/portal.css` — Token-Datei importiert, FluxUI-Setup
mit Zinc + Instrument Sans bleibt vorerst dominant. Phase 1 löst
es ab.
- **Build/Test**:
- `npm run build:web``theme-pressekonto: 193 kB` (vorher 189 kB,
+4 kB für neu gebridgde Status-Tokens), `theme-presseecho: 189 kB`,
`theme-businessportal24: 189 kB`.
- `npm run build:portal``portal: 408 kB` (vorher 397 kB, +12 kB
für die zusätzlich verfügbaren Hub-Tokens als CSS-Vars im `:root`).
Wird in Phase 1 wieder egalisiert, wenn das Zinc-Setup wegfällt.
- Smoke-Test: `pressekonto.test/`, `/login`, `/register`,
`/dashboard` (302→login), `presseecho.test/`, `businessportal24.test/`
— alle HTTP 200/302 wie erwartet.
- CSS-Inspektion: `--color-hub: #1a2540` und `--color-warn: #a87a1f`
sind in `theme-pressekonto-*.css` enthalten. Im Portal-Build
bleibt `--font-sans: "Instrument Sans"` — bestätigt visuelle
Unverändertheit.
- `vendor/bin/pint --dirty` → passed.
- **Offene Fragen / Beobachtungen**:
- Weil Tailwind v4 alle in `@theme {}` definierten Tokens als CSS-
Variablen im `:root` ausgibt (auch ohne Utility-Verwendung), wird
der Portal-CSS-Wuchs in Phase 1 nicht komplett zurückgehen —
die Hub-Tokens bleiben als Variablen verfügbar. Der Nettoeffekt
nach Phase 1 sollte ähnlich bleiben (+/- 510 kB).
- Dark-Mode-Block in `design-tokens.css` ist auskommentiert
vorbereitet, aber bewusst noch nicht aktiv (Phase 5).
- **Nächster Schritt**: **Review-Stopp**. Bei Freigabe →
Phase 1 starten: Portal-Shell (Sidebar, Topbar, Layout-Container)
auf Hub-Style mappen, Zinc-Palette ablösen, FluxUI-Overrides via
`[data-flux-*]`-Selektoren in `portal.css`. Details:
`02-PHASE-1-PORTAL-SHELL.md`.
---
## 2026-05-19 · Setup
- **Was**: Architektur-Entscheidung getroffen → **Plan B** (Tokens teilen,
Komponenten getrennt lassen). Dokumentation in `dev/frontend/hub-flux/`
angelegt:
- `README.md` — Übersicht & Entscheidung
- `01-PHASE-0-TOKENS.md` — Detail-Plan für Tokens-Vereinheitlichung
- `02-PHASE-1-PORTAL-SHELL.md` — Detail-Plan für Portal-Shell-Refresh
- `03-WEITERE-PHASEN.md` — Outline Phase 26
- `PROGRESS.md` — dieses Log
- **Bestätigt**:
- Hub-Blau als **Primärer Akzent** im Portal (klarer Kontrast zum hellen
Buchpapier-Hintergrund)
- Bernstein als **Sekundärer Akzent** (Notifications, Datenqualität,
Akzent-Ribbons)
- Brand bleibt **pressekonto** (auch im Portal-Sidebar via
`<x-web.brand-mark>`)
- Reihenfolge: erst Phase 0 + 1, dann Review
- **Nächster Schritt**: Phase 0 umsetzen
`resources/css/shared/design-tokens.css` erstellen,
`theme-pressekonto.css` refactoren, `portal.css` minimal vorbereiten.
---
## Vorlauf (zur Erinnerung, was schon steht)
- **Hub-Landing** (`web/pressekonto.blade.php`) — fertig, lebt im Web-Build
mit `theme-pressekonto.css`
- **Hub-Auth** (Login, Register, Forgot, Reset, Verify, Confirm) — fertig
im Hub-Design, neues Layout `auth/pressekonto.blade.php` im Web-Build
- **Register-AGB-Checkbox** mit Server-Side-Validierung ergänzt
- **Portal-Backend** mit FluxUI funktional vorhanden (Admin-Dashboard,
Customer-Dashboard, 77 Blade-Dateien mit FluxUI), aber **visuell noch
Starter-Kit-Look** (Zinc + `#3ea3dc` + Instrument Sans)
---
## Template für neue Einträge
```markdown
## YYYY-MM-DD · Phase N · Kurztitel
- **Was**: [Was wurde konkret gemacht?]
- **Dateien**: [Pfade]
- **Build/Test**: [Wie verifiziert?]
- **Offene Fragen**: [Falls etwas unklar geblieben ist]
- **Nächster Schritt**: [Was als Nächstes ansteht]
```

View file

@ -0,0 +1,143 @@
# Hub × FluxUI — Visuelle Vereinheitlichung Portal ↔ Hub
> **Ziel**: Das Portal-Backend (User-Panel, Admin-Bereich) wird visuell an den
> Hub-Stil (`pressekonto.test`-Landing + Auth-Seiten) angeglichen, **ohne
> FluxUI aufzugeben**. Die FluxUI-Komponenten werden über Design-Tokens und
> CSS-Overrides ans Hub-Design adaptiert.
## Status
| Phase | Beschreibung | Status |
|-------|--------------|--------|
| 0 | Design-Tokens vereinheitlichen | **✅ abgeschlossen** (2026-05-19) |
| 1 | Portal-Shell (Sidebar, Layout, Brand-Mark) | **✅ abgeschlossen** (2026-05-19) |
| 2 | Customer-Dashboard auf Mockup-Stil (inkl. Topbar) | 🟡 wartet auf Freigabe |
| 3 | Admin-Dashboard konsistent | ⚪ später |
| 4 | Listen-/Detail-Pages | ⚪ iterativ |
| 5 | Dark Mode konsistent | ⚪ später |
| 6 | Auth-Konsolidierung (optional) | ⚪ optional |
→ Tagesaktueller Fortschritt: [`PROGRESS.md`](./PROGRESS.md)
## Dokumente in diesem Verzeichnis
| Datei | Zweck |
|-------|-------|
| [`README.md`](./README.md) | Diese Übersicht: Entscheidung, Architektur, Status |
| [`01-PHASE-0-TOKENS.md`](./01-PHASE-0-TOKENS.md) | Phase 0: Tokens vereinheitlichen (aktiv) |
| [`02-PHASE-1-PORTAL-SHELL.md`](./02-PHASE-1-PORTAL-SHELL.md) | Phase 1: Portal-Shell ans Hub-Design angleichen |
| [`03-WEITERE-PHASEN.md`](./03-WEITERE-PHASEN.md) | Outline für Phasen 26 |
| [`PROGRESS.md`](./PROGRESS.md) | Fortschritts-Log + Notizen |
## Visuelle Vorlagen
Maßgebliche Mockups für diese Arbeit:
- `dev/frontend/tailwind_v3/User Dashboard presseportale.html` — Light-Variante
- `dev/frontend/tailwind_v3/User Dashboard presseportale Dark.html` — Dark-Variante
- `dev/frontend/tailwind_v3/Login pressekonto A3 Tailwind.html` — Hub-Auth-Vorlage (bereits umgesetzt)
- `dev/frontend/tailwind_v3/Hub Landing pressekonto-2.html` — Hub-Landing-Vorlage (bereits umgesetzt)
## Architektur-Entscheidung
### Plan B (gewählt) — Tokens teilen, Komponenten getrennt lassen
```
┌─────────────────────────────────────────────────────────────────┐
│ shared/design-tokens.css │
│ (Hub-Blau, Bernstein, Buchpapier, Inter Tight, JetBrains Mono) │
└────────────────┬────────────────────────┬───────────────────────┘
│ │
┌────────▼─────────┐ ┌─────────▼──────────┐
│ portal.css │ │ theme-pressekonto │
│ + FluxUI │ │ + shared-styles │
│ (Backend) │ │ (Hub-Landing/Auth)│
└──────────────────┘ └────────────────────┘
build/portal build/web
```
**Warum nicht alles in FluxUI?**
- Die Hub-Landing lebt von Atmosphäre (konzentrische Kreise, Hub-Grid, eigene
Eyebrows, Bridge-Cards), die mit FluxUI-Komponenten nicht 1:1 abbildbar ist
- FluxUI würde im Hub-Frontend zur unnötigen Bundle-Last
- Der gerade gebaute Hub-Login wäre verloren
**Warum nicht alles Custom?**
- 77 Blade-Dateien nutzen FluxUI ein Rewrite wäre Aufwand ohne Mehrwert
- FluxUI-Tabellen, -Forms, -Modals, -Date-Picker sind im Backend Gold wert
- FluxUI ist sehr gut **customizable** über CSS-Custom-Properties
und `[data-flux-*]`-Selectoren
### Verworfen — Plan A (alles FluxUI) & Plan C (alles im Web-Build)
Siehe Chat-Historie für die Begründung. Kurz: zu viel Aufwand, zu wenig
Mehrwert, würde bereits gelieferte Hub-Atmosphäre verschlechtern.
## Branding & Tokens
### Brand
- **Marke**: pressekonto (verbindlich, für Portal und Hub)
- **Wortmarke**: `<x-web.brand-mark brand="pressekonto" />` — auch im Portal
- **Logo-Komponente** `<x-app-logo>` wird im Portal **abgelöst**
### Farben (Light)
| Token | Wert | Rolle |
|-------|------|-------|
| `--color-hub` | `#1A2540` | **Primärer Akzent** (Sidebar-Active, Primary-Buttons, Eyebrows) |
| `--color-hub-2` | `#243152` | Hover-Stufe von `--color-hub` |
| `--color-hub-3` | `#2E3D66` | Tertiäre Stufe (Dekoration auf dunklem Grund) |
| `--color-hub-soft` | `#E5E9F1` | Active-Pill-Hintergrund in der Sidebar |
| `--color-accent` | `#B07A3A` | **Sekundärer Akzent** (Bernstein Notifications, Datenqualität, Empfehlungs-Ribbon) |
| `--color-accent-deep` | `#8A5E27` | Akzent-Hover/Text |
| `--color-bg` | `#F6F4EF` | Warmes Buchpapier — Haupt-Hintergrund |
| `--color-bg-elev` | `#FBFAF6` | Elevation 1 (Sidebar, Topbar, leichte Cards) |
| `--color-bg-card` | `#FFFFFF` | Reine Cards |
| `--color-bg-rule` | `#E2DDD0` | Standard-Trennlinien |
| `--color-ink` | `#1A1F1C` | Primärtext |
| `--color-ink-2` | `#3A413D` | Sekundärtext (Begleittexte) |
| `--color-ink-3` | `#5A6360` | Meta, Labels |
| `--color-ink-4` | `#8A918D` | Disabled, Hintergrund-Meta |
### Farben (Dark) — laut Mockup
Wird in Phase 5 ausgearbeitet. Hub-Blau wird heller (`#5A78C2`), Akzent
wärmer (`#D9A560`), Hintergrund tief Anthrazit (`#0E1218`). Wichtig: die
Token-Namen bleiben gleich, nur die Werte tauschen — keine doppelte
UI-Pflege.
### Typografie
| Token | Wert |
|-------|------|
| `--font-sans` | `"Inter Tight", Inter, system-ui, sans-serif` |
| `--font-mono` | `"JetBrains Mono", "SF Mono", ui-monospace, monospace` |
| `--font-serif` | `"Source Serif 4", Georgia, serif` (nur für Brand-Mark) |
Aktuell im Portal: **Instrument Sans** → wird in Phase 1 abgelöst.
## Konventionen
- **Light Mode** ist Default. `class="dark"` auf `<html>` wird entfernt.
- Dark Mode wird via `prefers-color-scheme` + Flux Appearance-Switcher
gesteuert. Token-Layer übernimmt die Umschaltung.
- Token-Names sind **identisch** zwischen Hub-Theme und Portal-Theme.
Sowohl `bg-bg-elev` als auch `bg-hub-soft` funktionieren in beiden Welten.
- FluxUI-Komponenten werden **nicht überschrieben**, sondern über
`[data-flux-*]`-Selectoren in `portal.css` ergänzt — minimaler Eingriff,
maximale Kompatibilität mit FluxUI-Updates.
## Was wir bewusst NICHT machen
- **Kein FluxUI-Rewrite**. Nur Token-Anpassung + Selektor-Overrides.
- **Keine zweite UI-Pflege für Dark Mode**. Tokens-only-Ansatz.
- **Kein Verschmelzen der Builds** (Phase 6 optional).
- **Keine Änderung an Volt-/Livewire-Logik** in dieser Arbeit.
## Wer ändert was
- **Diese Doku** wird mit jeder Phase aktualisiert. `PROGRESS.md` enthält
die Tages-Notes.
- Code-Änderungen sind kleinteilig und werden in eigenen Commits gebündelt
(`hub-flux: phase 0 — tokens vereinheitlicht`, etc.).
- Bei Unklarheiten/Entscheidungen: kurz in `PROGRESS.md` festhalten,
damit nachvollziehbar bleibt warum etwas so und nicht anders.

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>presseportale.com — Publisher-Hub</title>
<title>pressekonto.de — Publisher-Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -163,7 +163,7 @@
<!-- Wortmark: schlicht, kein Symbol — wirkt im Verlags-Segment seriöser -->
<a href="#" class="flex items-baseline gap-2.5">
<span class="text-[24px] font-bold tracking-[-0.5px] text-hub leading-none">presseportale<span class="text-accent">.com</span></span>
<span class="text-[24px] font-bold tracking-[-0.5px] text-hub leading-none">pressekonto<span class="text-accent">.com</span></span>
<span class="hidden md:inline-block w-px h-[18px] bg-bg-rule"></span>
<span class="eyebrow muted text-[9.5px] tracking-[0.22em]">Publisher · Hub</span>
</a>
@ -230,7 +230,7 @@
</h1>
<p class="text-[17px] leading-[1.55] text-ink-2 mt-7 m-0 max-w-[560px]">
presseportale.com ist der gemeinsame Publisher-Bereich für unsere beiden Pressefachportale. Pressemitteilungen schreiben, redaktionell prüfen lassen, auf beiden Reichweiten veröffentlichen — und Reichweite, Empfänger und Abrechnung an einem Ort verwalten.
pressekonto.de ist der gemeinsame Publisher-Bereich für unsere beiden Pressefachportale. Pressemitteilungen schreiben, redaktionell prüfen lassen, auf beiden Reichweiten veröffentlichen — und Reichweite, Empfänger und Abrechnung an einem Ort verwalten.
</p>
<div class="mt-8 flex items-center gap-3">
@ -290,7 +290,7 @@
<div class="absolute" style="top:50%;left:50%;transform:translate(-50%,-50%);">
<div class="w-[140px] h-[140px] rounded-full bg-hub-grad flex flex-col items-center justify-center text-white shadow-lg shadow-hub/30">
<div class="text-[10px] font-bold tracking-[0.22em] uppercase text-hub-line">Hub</div>
<div class="text-[15px] font-bold tracking-[-0.3px] mt-1">presseportale</div>
<div class="text-[15px] font-bold tracking-[-0.3px] mt-1">pressekonto</div>
<div class="text-[10px] text-white/55 tracking-[0.18em] uppercase mt-0.5">.com</div>
</div>
</div>
@ -767,15 +767,15 @@
</div>
</section>
<!-- ============== HINTER PRESSEPORTALE — PLATTFORM-FAMILIE ============== -->
<!-- ============== HINTER PRESSEKONTO — PLATTFORM-FAMILIE ============== -->
<section id="familie" class="max-w-layout mx-auto px-8 pt-20 pb-16">
<header class="mb-10">
<div class="section-eyebrow mb-4">Hinter presseportale.com</div>
<div class="section-eyebrow mb-4">Hinter pressekonto.de</div>
<h2 class="text-[32px] font-bold m-0 tracking-[-0.6px] text-ink leading-[1.18] max-w-[820px]">
Zwei eigenständige Pressefachportale. Eine kuratierte Verlags-Familie.
</h2>
<p class="text-[14.5px] text-ink-2 leading-[1.55] m-0 mt-4 max-w-[760px]">
presseportale.com ist nicht „irgendein Tool" — es ist die zentrale Plattform für unsere beiden redaktionell geführten Pressefachportale. Jedes Portal hat einen eigenen Charakter, eigene Leserschaft und eigene Themen-Schwerpunkte.
pressekonto.de ist nicht „irgendein Tool" — es ist die zentrale Plattform für unsere beiden redaktionell geführten Pressefachportale. Jedes Portal hat einen eigenen Charakter, eigene Leserschaft und eigene Themen-Schwerpunkte.
</p>
</header>
@ -856,7 +856,7 @@
<path d="M9 12h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="text-[13px] leading-[1.55]">
Pressemitteilungen, die Sie über presseportale.com einreichen, erscheinen auf <strong class="text-white font-semibold">beiden Portalen</strong> — ohne Aufpreis, ohne doppelte Eingabe. Sie haben eine zentrale Mitteilungs-Verwaltung und eine zentrale Reichweiten-Statistik.
Pressemitteilungen, die Sie über pressekonto.de einreichen, erscheinen auf <strong class="text-white font-semibold">beiden Portalen</strong> — ohne Aufpreis, ohne doppelte Eingabe. Sie haben eine zentrale Mitteilungs-Verwaltung und eine zentrale Reichweiten-Statistik.
</div>
</div>
</section>
@ -876,7 +876,7 @@
<!-- Fließtext-Nennung — wie auf den Brand-Portalen, kein Logo-Grid -->
<div>
<p class="text-[16px] leading-[1.7] text-ink-2 m-0">
Über presseportale.com veröffentlichen unter anderem
Über pressekonto.de veröffentlichen unter anderem
<a class="text-hub font-semibold underline underline-offset-[3px] decoration-hub/25 hover:decoration-hub" href="#">Siemens AG</a>,
<a class="text-hub font-semibold underline underline-offset-[3px] decoration-hub/25 hover:decoration-hub" href="#">BASF SE</a>,
<a class="text-hub font-semibold underline underline-offset-[3px] decoration-hub/25 hover:decoration-hub" href="#">Deutsche Bank</a>,
@ -1069,7 +1069,7 @@
<div>
<div class="text-[24px] font-bold leading-none tracking-[-0.5px] text-white">
presseportale<span style="color:#B07A3A;">.com</span>
pressekonto<span style="color:#B07A3A;">.com</span>
</div>
<div class="eyebrow on-dark mt-2 text-[9.5px] tracking-[0.22em]" style="color:#7B8FBF;">
Publisher · Hub
@ -1121,7 +1121,7 @@
<div class="border-t border-white/15">
<div class="max-w-layout mx-auto px-8 py-5 flex items-center justify-between gap-4 text-[11.5px] text-white/55">
<span>© 2026 presseportale.com · Alle Rechte vorbehalten</span>
<span>© 2026 pressekonto.de · Alle Rechte vorbehalten</span>
<span class="flex items-center gap-2 font-mono text-[10.5px]">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-ok"></span>
Alle Systeme betriebsbereit

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>presseportale.com — Anmelden</title>
<title>pressekonto.de — Anmelden</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -160,7 +160,7 @@
<header class="relative z-10 px-10 py-[22px] flex items-center justify-between">
<a href="#" class="flex items-baseline gap-2.5 no-underline">
<span class="text-[19px] font-bold tracking-[-0.4px] text-hub leading-none">
presseportale<span class="text-accent">.com</span>
pressekonto<span class="text-accent">.com</span>
</span>
<span class="inline-block w-px h-[14px] bg-bg-rule"></span>
<span class="text-[9.5px] font-bold tracking-[0.22em] uppercase text-ink-3">

View file

@ -0,0 +1,767 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>presseportale.com — Mein Dashboard · Dark</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
/* DARK MODE — Tokens umgewidmet, gleiche Namen wie Light-Variante */
bg: "#0E1218",
"bg-elev": "#14181F",
"bg-rule": "#2A3142",
"bg-rule-2": "#232838",
"bg-card": "#181D27",
"bg-card-warm": "#1F1A12",
"hub": "#5A78C2",
"hub-2": "#6D8AD3",
"hub-3": "#4A65A8",
"hub-soft": "#1F2A47",
"hub-soft-2":"#2C3A5D",
"hub-line": "#7B8FBF",
"accent": "#D9A560",
"accent-deep": "#B07A3A",
"accent-soft": "#2A2418",
ink: "#ECE9E0",
"ink-2":"#C9C5B8",
"ink-3":"#8E8B82",
"ink-4":"#5D5C57",
"ink-on-dark": "#F6F4EF",
"ink-on-dark-2": "#B2B9C7",
"ink-on-dark-3": "#7B8FBF",
ok: "#4DC076",
"ok-soft":"#1A2D22",
warn: "#D9A560",
"warn-soft":"#2D2418",
err: "#E07664",
"err-soft":"#2E1715",
},
fontFamily: {
sans: ['"Inter Tight"','Inter','system-ui','sans-serif'],
mono: ['"JetBrains Mono"','"SF Mono"','ui-monospace','monospace'],
},
backgroundImage: {
"hub-grad": "linear-gradient(135deg,#1A2540 0%,#243152 100%)",
"hub-grad-2": "linear-gradient(180deg,#1A2540 0%,#0F1729 100%)",
"accent-grad": "linear-gradient(135deg,#B07A3A 0%,#8A5E27 100%)",
}
}
}
};
</script>
<style>
html { color-scheme: dark; }
html,body { margin:0; padding:0; }
body { background:#0A0D12; font-family:"Inter Tight",system-ui,sans-serif; }
.eyebrow {
font-size: 10.5px; font-weight: 700;
letter-spacing: 0.20em; text-transform: uppercase;
color: #B2B9C7;
}
.eyebrow.muted { color:#8E8B82; letter-spacing:0.16em; font-weight:600; font-size:10px; }
.eyebrow.accent { color:#D9A560; }
.eyebrow.on-dark { color:#7B8FBF; }
.section-eyebrow {
display:inline-flex; align-items:center; gap:10px;
font-size: 10.5px; font-weight: 700;
letter-spacing: 0.22em; text-transform: uppercase;
color:#B2B9C7;
}
.section-eyebrow::after {
content:""; display:block; width:30px; height:1px;
background:#B2B9C7; opacity:.35;
}
.rule { height:1px; background:#2A3142; border:0; margin:0; }
.rule-strong { height:1px; background:#ECE9E0; border:0; margin:0; }
/* Sidebar */
.nav-item {
display:flex; align-items:center; gap:11px;
padding:8px 12px;
border-radius:4px;
font-size:13px; font-weight:500;
color:#C9C5B8;
transition: background .12s, color .12s;
position:relative;
}
.nav-item:hover { background:#1F2A47; color:#ECE9E0; }
.nav-item.active {
background:#1F2A47;
color:#ECE9E0;
font-weight:600;
}
.nav-item.active::before {
content:""; position:absolute; left:-1px; top:6px; bottom:6px;
width:2px; background:#7B8FBF; border-radius:0 2px 2px 0;
}
.nav-item.disabled { color:#5D5C57; cursor:default; }
.nav-item.disabled:hover { background:transparent; color:#5D5C57; }
.nav-item .ico {
width:16px; height:16px; flex-shrink:0;
color: currentColor; opacity:.8;
}
.nav-item.active .ico { opacity:1; }
.nav-section {
font-size:10px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:#5D5C57;
padding: 0 12px 6px;
}
/* Card / panel headers */
.panel {
background:#181D27; border:1px solid #2A3142; border-radius:6px;
}
.panel-warm { background:#14181F; border:1px solid #2A3142; border-radius:6px; }
.panel-dark { background:#0F1729; border:1px solid #1A2540; border-radius:6px; color:#F6F4EF; }
.panel-head {
display:flex; align-items:center; justify-content:space-between;
padding:14px 20px;
border-bottom:1px solid #2A3142;
}
.panel-dark .panel-head { border-bottom-color: rgba(255,255,255,.08); }
/* Stat cards */
.stat-card {
position:relative;
background:#181D27; border:1px solid #2A3142; border-radius:6px;
padding:18px 20px;
transition: border-color .12s, box-shadow .12s;
}
.stat-card .stat-strip {
position:absolute; left:0; top:0; bottom:0; width:3px;
background:#2C3A5D; border-radius:6px 0 0 6px;
}
.stat-card.is-primary .stat-strip { background:#5A78C2; }
.stat-card.is-ok .stat-strip { background:#4DC076; }
.stat-card.is-warn .stat-strip { background:#D9A560; }
.stat-card.is-muted .stat-strip { background:#5D5C57; }
.stat-label {
font-size:10.5px; font-weight:700;
letter-spacing:0.16em; text-transform:uppercase;
color:#8E8B82;
}
.stat-card.is-ok .stat-label { color:#4DC076; }
.stat-card.is-warn .stat-label { color:#D9A560; }
.stat-card.is-muted .stat-label { color:#8E8B82; }
.stat-num {
font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;
font-variant-numeric:tabular-nums;
font-size:36px; font-weight:600; color:#ECE9E0;
letter-spacing:-0.5px; line-height:1;
margin-top:14px;
}
/* Hint chips (Datenqualität) */
.hint-card {
display:grid; gap:14px;
grid-template-columns: auto 1fr;
align-items:start;
background:#1F1A12; border:1px solid #2D2418; border-left:3px solid #B07A3A;
border-radius:5px;
padding:16px 18px;
}
.hint-card .hint-ico {
width:36px; height:36px; border-radius:4px;
background:#2D2418; color:#D9A560;
display:flex; align-items:center; justify-content:center;
flex-shrink:0;
}
/* FluxUI-style buttons */
.btn-primary {
display:inline-flex; align-items:center; gap:8px; justify-content:center;
padding:9px 16px;
background:#5A78C2; color:#FFFFFF;
border-radius:4px; font-size:13px; font-weight:600;
transition: background .15s;
}
.btn-primary:hover { background:#6D8AD3; }
.btn-secondary {
display:inline-flex; align-items:center; gap:8px; justify-content:center;
padding:8px 14px;
background:#181D27; color:#ECE9E0;
border:1px solid #2A3142; border-radius:4px;
font-size:12.5px; font-weight:600;
transition: border-color .15s, background .15s;
}
.btn-secondary:hover { border-color:#5A78C2; background:#1F2A47; }
.badge {
display:inline-flex; align-items:center; gap:6px;
padding:3px 9px; border-radius:99px;
font-size:10.5px; font-weight:700;
letter-spacing:0.10em; text-transform:uppercase;
}
.badge.warn { background:#2D2418; color:#D9A560; }
.badge.ok { background:#1A2D22; color:#4DC076; }
.badge.hub { background:#1F2A47; color:#B2B9C7; }
.badge.dot::before {
content:""; width:6px; height:6px; border-radius:99px; background:currentColor;
}
/* Bridge ribbon - sehr subtil */
.bridge-row {
display:inline-flex; align-items:center; gap:6px;
font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;
font-size:10.5px; letter-spacing:0.10em; text-transform:uppercase;
color:#8E8B82;
}
.dot-pe { width:6px; height:6px; border-radius:99px; background:#4DA37A; }
.dot-bp { width:6px; height:6px; border-radius:99px; background:#E36340; }
</style>
</head>
<body class="bg-bg text-ink font-sans antialiased">
<!-- ============== ARTBOARD ============== -->
<div class="mx-auto bg-bg" style="width:1440px;">
<div class="flex" style="min-height:980px;">
<!-- =============================================
SIDEBAR — schmal, warm, klare Hierarchie
(FluxUI <flux:navlist> + Slot-Header-Styles)
============================================== -->
<aside class="bg-bg-elev border-r border-bg-rule flex flex-col" style="width:260px;">
<!-- Brand-Block -->
<div class="px-5 pt-6 pb-5">
<a href="Hub Landing presseportale.html" class="flex items-baseline gap-2">
<span class="text-[19px] font-bold tracking-[-0.4px] text-hub leading-none">presseportale<span class="text-accent">.com</span></span>
</a>
<div class="eyebrow muted mt-2">Publisher · Hub</div>
<!-- Aktive Firma / Konto-Switcher (FluxUI-Slot: header before nav) -->
<button class="mt-4 w-full grid items-center gap-2.5 px-3 py-2.5 bg-bg-card border border-bg-rule rounded-[4px] hover:border-hub/40 text-left"
style="grid-template-columns:auto 1fr auto;">
<span class="w-7 h-7 rounded-[3px] bg-hub-soft border border-hub-soft-2 flex items-center justify-center text-hub text-[11px] font-bold">TU</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold text-ink leading-tight truncate">Test User</span>
<span class="block text-[10.5px] text-ink-3 leading-tight mt-0.5 truncate">Keine Firma zugeordnet</span>
</span>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="text-ink-3">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 7.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"/>
</svg>
</button>
</div>
<nav class="px-3 flex-1">
<div class="nav-section">Mein Bereich</div>
<div class="space-y-0.5 mb-5">
<a class="nav-item active" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M2 7l6-5 6 5v7H2z" stroke="currentColor" stroke-width="1.4"/><path d="M6 14V9h4v5" stroke="currentColor" stroke-width="1.4"/></svg>
Übersicht
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="2.5" width="9" height="11" stroke="currentColor" stroke-width="1.4"/><path d="M11.5 5h2v8.5H4" stroke="currentColor" stroke-width="1.4"/><path d="M5 5.5h4M5 8h4M5 10.5h2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Meine Pressemitteilungen
<span class="badge hub ml-auto" style="font-size:9.5px;padding:1px 6px;letter-spacing:0.08em;">0</span>
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6h11" stroke="currentColor" stroke-width="1.4"/><path d="M6 9h1M9 9h1M6 11.5h1M9 11.5h1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Firmen
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 5h10l-1 9H4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 5V3.5a2 2 0 014 0V5" stroke="currentColor" stroke-width="1.4"/></svg>
Buchungen &amp; Add-ons
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 13V8M7 13V5M11 13V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Statistiken
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
<div class="nav-section">Finanzen</div>
<div class="space-y-0.5 mb-5">
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="5" stroke="currentColor" stroke-width="1.4"/><path d="M8 5.5v5M6 8h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Credits &amp; Tarif
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 2.5h7l3 3v8H3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M10 2.5V5.5h3" stroke="currentColor" stroke-width="1.4"/><path d="M5.5 8h5M5.5 10.5h5M5.5 6h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Rechnungen
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="4.5" width="11" height="7.5" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 7h11" stroke="currentColor" stroke-width="1.4"/></svg>
Zahlungsarten
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
<div class="nav-section">Konto</div>
<div class="space-y-0.5 mb-5">
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Profil
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 8l1.5 1.5L10.5 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
Sicherheit
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="6" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M8.5 8h5M11 8v2.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
API &amp; Integrationen
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3.5 7a4.5 4.5 0 119 0v3.5l1 1.5H2.5l1-1.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.4"/></svg>
Benachrichtigungen
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
</nav>
<!-- Testmodus-Block (war im Original „Zurück zum Admin") -->
<div class="px-4 pb-4">
<div class="text-ink-on-dark rounded-[5px] p-4 relative overflow-hidden" style="background:linear-gradient(135deg,#1A2540 0%,#243152 100%); border:1px solid #2A3142;">
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full opacity-50" style="background:#2E3D66;"></div>
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full opacity-30" style="background:#2E3D66;"></div>
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<span class="w-1.5 h-1.5 rounded-full animate-pulse" style="background:#D9A560;"></span>
<span class="eyebrow on-dark" style="color:#F4D89C;">Testmodus aktiv</span>
</div>
<div class="text-[12px] leading-[1.5] text-ink-on-dark-2">
Angemeldet als <strong class="text-white font-semibold">Test User</strong>.<br/>
Admin: <strong class="text-white font-semibold">Portal Admin</strong>
</div>
<button class="mt-3 w-full px-3 py-2 text-[12px] font-semibold rounded-[3px] transition-colors flex items-center justify-center gap-1.5" style="background:#F6F4EF;color:#1A2540;">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Zurück zum Admin
</button>
</div>
</div>
</div>
<!-- Resources -->
<div class="px-3 pb-5 border-t border-bg-rule pt-4">
<div class="nav-section">Resources</div>
<div class="space-y-0.5">
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 11l4 2 6-9-4-1z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>Tailwind CSS</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4z" stroke="currentColor" stroke-width="1.3"/></svg>Hero Icons</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M9 2L4 9h3l-1 5 5-7H8z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>Flux UI</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 4l5-2 5 2v8l-5 2-5-2z" stroke="currentColor" stroke-width="1.3"/><path d="M3 4l5 2 5-2M8 6v8" stroke="currentColor" stroke-width="1.3"/></svg>Repository</a>
</div>
</div>
</aside>
<!-- =============================================
MAIN — Inhalt
============================================== -->
<main class="flex-1 min-w-0">
<!-- Topbar — sehr schlank; Brücken-Kontext + Kontoinfo rechts -->
<div class="bg-bg-elev border-b border-bg-rule">
<div class="px-10 py-3 flex items-center gap-6">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-[12px] text-ink-3 font-medium">
<a href="Hub Landing presseportale.html" class="hover:text-hub">Hub</a>
<span class="text-ink-4">/</span>
<span class="text-ink-2">User Backend</span>
<span class="text-ink-4">/</span>
<span class="text-hub font-semibold">Übersicht</span>
</div>
<span class="flex-1"></span>
<!-- Reichweiten-Kontext (Brücke zur Hub-CI) -->
<span class="bridge-row">
<span class="dot-pe"></span> presseecho
<span class="text-ink-4 mx-1">·</span>
<span class="dot-bp"></span> businessportal24
</span>
<span class="w-px h-5 bg-bg-rule"></span>
<!-- Search (FluxUI input slot) -->
<div class="relative">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/>
<path d="M10.5 10.5L13 13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<input class="pl-8 pr-3 py-1.5 w-[220px] bg-bg-card border border-bg-rule rounded-[4px] text-[12.5px] text-ink placeholder:text-ink-4 focus:outline-none focus:border-hub" placeholder="Suchen…" />
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] font-mono text-ink-4 border border-bg-rule rounded px-1">⌘K</span>
</div>
<button class="relative w-8 h-8 flex items-center justify-center rounded-[4px] hover:bg-bg-card border border-transparent hover:border-bg-rule">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" class="text-ink-2">
<path d="M3.5 7a4.5 4.5 0 119 0v3.5l1 1.5H2.5l1-1.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-accent"></span>
</button>
<a href="#" class="btn-primary text-[12px] py-1.5 px-3.5">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
Neue Mitteilung
</a>
</div>
</div>
<!-- Inhalt -->
<div class="px-10 py-8 space-y-6">
<!-- ============== PAGE HEADER ============== -->
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">User Backend</span>
<span class="eyebrow muted">Mein Bereich · A · 01</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] text-ink leading-[1.1] m-0">Mein Dashboard</h1>
<p class="text-[13.5px] text-ink-2 leading-[1.55] mt-2 m-0 max-w-[640px]">
Willkommen zurück, <strong class="font-semibold text-ink">Test User</strong>. Hier sehen Sie Status und Reichweite Ihres Kundenkontos für presseecho und businessportal24.
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<!-- Zeitraum-Filter (FluxUI dropdown-style) -->
<button class="btn-secondary whitespace-nowrap">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 6h11M5 2v3M11 2v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Diese Woche
<svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<!-- Firma-zuordnen-Status — als Banner-Pille, statt im Karte-Header eingesperrt -->
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-warn-soft border border-warn/30 text-accent rounded-[4px] text-[12px] font-semibold whitespace-nowrap">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="flex-shrink-0"><path d="M8 2l6 11H2z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 7v3M8 11.5v.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Keine Firma zugeordnet
<a href="#" class="underline underline-offset-[3px] decoration-accent/40 hover:decoration-accent">zuordnen →</a>
</span>
</div>
</header>
<!-- ============== STAT-CARDS — KPI-Reihe ============== -->
<section class="grid gap-4" style="grid-template-columns:repeat(4,1fr);">
<article class="stat-card is-primary">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label" style="color:#7B8FBF;">Gesamt</div>
<span class="text-[10.5px] font-mono text-ink-3 tracking-[0.14em] uppercase">2026</span>
</div>
<div class="stat-num">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
<span class="flex items-center gap-1">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M5 8V2M2 5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
0 ggü. Vormonat
</span>
</div>
<!-- Mini Sparkline placeholder -->
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 18 L10 17 L20 18 L30 16 L40 18 L50 15 L60 17 L70 14 L80 16" stroke="#1A2540" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="16" r="1.8" fill="#1A2540"/>
</svg>
</article>
<article class="stat-card is-ok">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">Veröffentlicht</div>
<span class="badge ok" style="font-size:9.5px;padding:1px 6px;">live</span>
</div>
<div class="stat-num" style="color:#6BD498;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
auf beiden Portalen
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 19 L10 18 L20 19 L30 17 L40 16 L50 14 L60 13 L70 11 L80 10" stroke="#2E8540" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="10" r="1.8" fill="#2E8540"/>
</svg>
</article>
<article class="stat-card is-warn">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">In Prüfung</div>
<span class="badge warn" style="font-size:9.5px;padding:1px 6px;">Ø 4 h</span>
</div>
<div class="stat-num" style="color:#D9A560;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
redaktionelle Prüfung
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 16 L10 17 L20 14 L30 18 L40 13 L50 17 L60 12 L70 15 L80 11" stroke="#A87A1F" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="11" r="1.8" fill="#A87A1F"/>
</svg>
</article>
<article class="stat-card is-muted">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">Entwürfe</div>
<span class="text-[10.5px] font-mono text-ink-3 tracking-[0.14em] uppercase">privat</span>
</div>
<div class="stat-num" style="color:#8E8B82;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
gespeichert, nicht eingereicht
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 12 L10 12 L20 12 L30 12 L40 12 L50 12 L60 12 L70 12 L80 12" stroke="#8A918D" stroke-width="1.2" stroke-dasharray="2 2" fill="none"/>
</svg>
</article>
</section>
<!-- ============== ZWEISPALTEN-GRID ============== -->
<section class="grid gap-6" style="grid-template-columns:2fr 1fr;">
<!-- ============== LINKS: Pressemitteilungen-Liste / Empty ============== -->
<article class="panel">
<div class="panel-head">
<div class="flex items-center gap-3">
<span class="section-eyebrow">Meine letzten Pressemitteilungen</span>
</div>
<div class="flex items-center gap-3">
<span class="text-[11.5px] text-ink-3">0 von 0</span>
<a href="#" class="text-[12px] font-semibold text-hub hover:underline underline-offset-[3px] decoration-hub/30">Alle anzeigen →</a>
</div>
</div>
<!-- Empty State — sauber, ohne überlappende Skelett-Zeilen -->
<div class="px-10 py-14 flex flex-col items-center text-center">
<!-- Dezentes Tabellen-Icon -->
<div class="w-16 h-16 rounded-[6px] bg-hub-soft border border-hub-soft-2 flex items-center justify-center text-hub mb-5 relative">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="14" height="16" stroke="currentColor" stroke-width="1.5"/>
<path d="M17 7h4v13H6" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 8h8M6 11h8M6 14h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<span class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center font-mono">0</span>
</div>
<div class="text-[16px] font-semibold text-ink m-0">Noch keine Pressemitteilungen</div>
<p class="text-[13px] text-ink-3 leading-[1.55] mt-2 m-0 max-w-[460px]">
Starten Sie mit einer ersten Mitteilung für die aktive Firma oder Ihr Kundenkonto. Veröffentlichung erfolgt nach redaktioneller Prüfung auf beiden Portalen.
</p>
<div class="mt-6 flex items-center gap-2.5">
<a href="#" class="btn-primary">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
Erste Pressemitteilung erstellen
</a>
<a href="#" class="btn-secondary">Vorlage öffnen</a>
</div>
<!-- Vorgeschlagene Schritte -->
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">01</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Firma zuordnen</div>
</div>
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">02</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Mitteilung verfassen</div>
</div>
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">03</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Zur Prüfung senden</div>
</div>
</div>
</div>
<div class="px-5 py-3 bg-bg-elev border-t border-bg-rule flex items-center gap-2.5 text-[11.5px] text-ink-3">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" class="text-hub flex-shrink-0">
<path d="M8 1.5l5.5 2.5v4c0 3.3-2.2 5.8-5.5 6.5C4.7 13.8 2.5 11.3 2.5 8V4z" stroke="currentColor" stroke-width="1.3" fill="none"/>
<path d="M5.5 8l2 2 3.5-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Tipp: Geprüfte Mitteilungen erscheinen i. d. R. binnen <strong class="text-ink-2 font-semibold">4 Stunden</strong> werktags auf beiden Portalen.
</div>
</article>
<!-- ============== RECHTS: Datenqualität ============== -->
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">Datenqualität</span>
<span class="badge warn dot">2 offen</span>
</div>
<div class="px-5 py-5">
<p class="text-[12px] text-ink-3 leading-[1.55] m-0 mb-4">
Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.
</p>
<div class="space-y-3">
<div class="hint-card">
<span class="hint-ico">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[13px] font-semibold text-ink leading-tight">Profil unvollständig</div>
<span class="font-mono text-[10px] text-ink-3 tracking-[0.10em] uppercase whitespace-nowrap flex-shrink-0">60 %</span>
</div>
<div class="mt-2 h-1 w-full rounded-full bg-bg-rule-2 overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width:60%;"></div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] mt-2 m-0">
Vorname, Telefon und Pressekontakt fehlen für saubere Kundenakte.
</p>
<a href="#" class="inline-flex items-center gap-1 text-[11.5px] font-semibold text-accent mt-2 hover:underline underline-offset-[3px] decoration-accent/40">Profil öffnen →</a>
</div>
</div>
<div class="hint-card">
<span class="hint-ico">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6h11" stroke="currentColor" stroke-width="1.4"/></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[13px] font-semibold text-ink leading-tight">Rechnungsadresse fehlt</div>
<span class="font-mono text-[10px] text-ink-3 tracking-[0.10em] uppercase whitespace-nowrap flex-shrink-0">0 %</span>
</div>
<div class="mt-2 h-1 w-full rounded-full bg-bg-rule-2 overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width:5%;"></div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] mt-2 m-0">
Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.
</p>
<a href="#" class="inline-flex items-center gap-1 text-[11.5px] font-semibold text-accent mt-2 hover:underline underline-offset-[3px] decoration-accent/40">Rechnungsadresse ergänzen →</a>
</div>
</div>
</div>
</div>
</article>
</section>
<!-- ============== UNTERER GRID: FIRMEN + REICHWEITE / AKTIVITÄT ============== -->
<section class="grid gap-6" style="grid-template-columns:2fr 1fr;">
<!-- Firmen Card -->
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">Meine Firmen</span>
<div class="flex items-center gap-3">
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">0 zugeordnet</span>
<a href="#" class="text-[12px] font-semibold text-hub hover:underline underline-offset-[3px] decoration-hub/30">Profil &amp; Firma verwalten →</a>
</div>
</div>
<div class="p-6">
<div class="grid gap-3" style="grid-template-columns:1fr 1fr;">
<!-- Empty-Slot Firma 1 -->
<div class="relative border border-dashed border-bg-rule rounded-[5px] p-5 hover:border-hub/50 hover:bg-bg-elev transition-colors">
<div class="flex items-center gap-3 mb-3">
<span class="w-10 h-10 rounded-[4px] border border-bg-rule bg-bg-elev flex items-center justify-center text-ink-4">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 6h11M6 9h1M9 9h1M6 11.5h1M9 11.5h1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</span>
<div>
<div class="text-[13.5px] font-semibold text-ink-2">Firma hinzufügen</div>
<div class="text-[11px] text-ink-3 mt-0.5">Slot frei · 1 von 3</div>
</div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] m-0">
Pressestellen, für die Sie Mitteilungen erstellen — mit eigenem Logo, Kontaktperson und Themen-Tags.
</p>
<a href="#" class="absolute top-3 right-3 inline-flex items-center justify-center w-7 h-7 rounded-[3px] bg-bg-card border border-bg-rule text-hub hover:border-hub">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
</a>
</div>
<!-- Hinweis -->
<div class="bg-bg-elev border border-bg-rule rounded-[5px] p-5">
<div class="eyebrow muted mb-2">Hinweis</div>
<div class="text-[13px] text-ink-2 leading-[1.55] m-0">
Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.
</div>
<div class="mt-3 flex items-center gap-2">
<a href="#" class="btn-secondary" style="padding:6px 10px;font-size:11.5px;">Profil prüfen</a>
<a href="/cdn-cgi/l/email-protection#e5969095958a9791a5959780969680958a9791848980cb868a88" class="text-[11.5px] font-semibold text-ink-3 hover:text-hub">Support kontaktieren</a>
</div>
</div>
</div>
</div>
</article>
<!-- Aktivität / Bridge -->
<article class="panel-dark">
<div class="panel-head">
<span class="eyebrow on-dark">Brand-Bridge</span>
<span class="font-mono text-[10px] text-ink-on-dark-3 tracking-[0.14em] uppercase">A · B</span>
</div>
<div class="px-5 py-5">
<div class="text-[12.5px] leading-[1.55] text-ink-on-dark-2 m-0 mb-4">
Ein Konto, beide Portale — Veröffentlichungen werden parallel auf presseecho und businessportal24 ausgespielt.
</div>
<div class="grid gap-3" style="grid-template-columns:1fr 1fr;">
<div class="rounded-[4px] px-3.5 py-3 border border-white/5" style="background:#1A2540;">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-pe"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white" style="font-variant-numeric:tabular-nums;">verbunden</div>
<div class="text-[10.5px] text-ink-on-dark-3 mt-0.5">Archiv · Branchen-Tiefe</div>
</div>
<div class="rounded-[4px] px-3.5 py-3 border border-white/5" style="background:#1A2540;">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-bp"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white" style="font-variant-numeric:tabular-nums;">verbunden</div>
<div class="text-[10.5px] text-ink-on-dark-3 mt-0.5">Wirtschaft · Live</div>
</div>
</div>
<hr class="mt-5 mb-4" style="border:0;height:1px;background:rgba(255,255,255,.10);" />
<div class="space-y-2 text-[11.5px] text-ink-on-dark-2">
<div class="flex items-center justify-between">
<span>API-Status</span>
<span class="flex items-center gap-1.5 text-white"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>operational</span>
</div>
<div class="flex items-center justify-between">
<span>Letzte Synchronisation</span>
<span class="font-mono text-white">vor 2 min</span>
</div>
<div class="flex items-center justify-between">
<span>Tarif</span>
<span class="font-mono text-white">Starter</span>
</div>
</div>
<a href="Hub Landing presseportale.html#tarife" class="mt-5 inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-white hover:underline underline-offset-[3px] decoration-white/30">
Tarife &amp; Add-ons ansehen →
</a>
</div>
</article>
</section>
<!-- ============== FUSSZEILE ============== -->
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px] text-ink-3 border-t border-bg-rule">
<span>© 2026 presseportale.com · Publisher-Hub</span>
<span class="flex items-center gap-5">
<a href="#" class="hover:text-hub">Tastenkürzel</a>
<a href="#" class="hover:text-hub">Changelog</a>
<a href="#" class="hover:text-hub">Statusseite</a>
<a href="/cdn-cgi/l/email-protection#21525451514e535561515344525244514e5355404d440f424e4c" class="hover:text-hub">Support</a>
</span>
</footer>
</div>
</main>
</div>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script></body>
</html>

View file

@ -0,0 +1,764 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>presseportale.com — Mein Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
bg: "#F6F4EF",
"bg-elev": "#FBFAF6",
"bg-rule": "#E2DDD0",
"bg-rule-2": "#EDE7D7",
"bg-card": "#FFFFFF",
"bg-card-warm": "#EFEADC",
"hub": "#1A2540",
"hub-2": "#243152",
"hub-3": "#2E3D66",
"hub-soft": "#E5E9F1",
"hub-soft-2":"#CFD6E4",
"hub-line": "#7B8FBF",
"accent": "#B07A3A",
"accent-deep": "#8A5E27",
"accent-soft": "#F1E6D3",
ink: "#1A1F1C",
"ink-2":"#3A413D",
"ink-3":"#5A6360",
"ink-4":"#8A918D",
"ink-on-dark": "#F6F4EF",
"ink-on-dark-2": "#B2B9C7",
"ink-on-dark-3": "#7B8FBF",
ok: "#2E8540",
"ok-soft":"#E2F1E5",
warn: "#A87A1F",
"warn-soft":"#F6EAC8",
err: "#A8331F",
"err-soft":"#F4DAD2",
},
fontFamily: {
sans: ['"Inter Tight"','Inter','system-ui','sans-serif'],
mono: ['"JetBrains Mono"','"SF Mono"','ui-monospace','monospace'],
},
backgroundImage: {
"hub-grad": "linear-gradient(135deg,#1A2540 0%,#243152 100%)",
"hub-grad-2": "linear-gradient(180deg,#1A2540 0%,#0F1729 100%)",
"accent-grad": "linear-gradient(135deg,#B07A3A 0%,#8A5E27 100%)",
}
}
}
};
</script>
<style>
html,body { margin:0; padding:0; }
body { background:#E8E4DA; font-family:"Inter Tight",system-ui,sans-serif; }
.eyebrow {
font-size: 10.5px; font-weight: 700;
letter-spacing: 0.20em; text-transform: uppercase;
color: #1A2540;
}
.eyebrow.muted { color:#5A6360; letter-spacing:0.16em; font-weight:600; font-size:10px; }
.eyebrow.accent { color:#8A5E27; }
.eyebrow.on-dark { color:#7B8FBF; }
.section-eyebrow {
display:inline-flex; align-items:center; gap:10px;
font-size: 10.5px; font-weight: 700;
letter-spacing: 0.22em; text-transform: uppercase;
color:#1A2540;
}
.section-eyebrow::after {
content:""; display:block; width:30px; height:1px;
background:#1A2540; opacity:.45;
}
.rule { height:1px; background:#E2DDD0; border:0; margin:0; }
.rule-strong { height:1px; background:#1A1F1C; border:0; margin:0; }
/* Sidebar */
.nav-item {
display:flex; align-items:center; gap:11px;
padding:8px 12px;
border-radius:4px;
font-size:13px; font-weight:500;
color:#3A413D;
transition: background .12s, color .12s;
position:relative;
}
.nav-item:hover { background:#F6F4EF; color:#1A2540; }
.nav-item.active {
background:#E5E9F1;
color:#1A2540;
font-weight:600;
}
.nav-item.active::before {
content:""; position:absolute; left:-1px; top:6px; bottom:6px;
width:2px; background:#1A2540; border-radius:0 2px 2px 0;
}
.nav-item.disabled { color:#8A918D; cursor:default; }
.nav-item.disabled:hover { background:transparent; color:#8A918D; }
.nav-item .ico {
width:16px; height:16px; flex-shrink:0;
color: currentColor; opacity:.8;
}
.nav-item.active .ico { opacity:1; }
.nav-section {
font-size:10px; font-weight:700; letter-spacing:0.18em;
text-transform:uppercase; color:#8A918D;
padding: 0 12px 6px;
}
/* Card / panel headers */
.panel {
background:#FFFFFF; border:1px solid #E2DDD0; border-radius:6px;
}
.panel-warm { background:#FBFAF6; border:1px solid #E2DDD0; border-radius:6px; }
.panel-dark { background:#1A2540; border:1px solid #0F1729; border-radius:6px; color:#F6F4EF; }
.panel-head {
display:flex; align-items:center; justify-content:space-between;
padding:14px 20px;
border-bottom:1px solid #E2DDD0;
}
.panel-dark .panel-head { border-bottom-color: rgba(255,255,255,.10); }
/* Stat cards */
.stat-card {
position:relative;
background:#FFFFFF; border:1px solid #E2DDD0; border-radius:6px;
padding:18px 20px;
transition: border-color .12s, box-shadow .12s;
}
.stat-card .stat-strip {
position:absolute; left:0; top:0; bottom:0; width:3px;
background:#CFD6E4; border-radius:6px 0 0 6px;
}
.stat-card.is-primary .stat-strip { background:#1A2540; }
.stat-card.is-ok .stat-strip { background:#2E8540; }
.stat-card.is-warn .stat-strip { background:#A87A1F; }
.stat-card.is-muted .stat-strip { background:#8A918D; }
.stat-label {
font-size:10.5px; font-weight:700;
letter-spacing:0.16em; text-transform:uppercase;
color:#5A6360;
}
.stat-card.is-ok .stat-label { color:#2E8540; }
.stat-card.is-warn .stat-label { color:#A87A1F; }
.stat-card.is-muted .stat-label { color:#8A918D; }
.stat-num {
font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;
font-variant-numeric:tabular-nums;
font-size:36px; font-weight:600; color:#1A1F1C;
letter-spacing:-0.5px; line-height:1;
margin-top:14px;
}
/* Hint chips (Datenqualität) */
.hint-card {
display:grid; gap:14px;
grid-template-columns: auto 1fr;
align-items:start;
background:#FBFAF6; border:1px solid #E2DDD0; border-left:3px solid #B07A3A;
border-radius:5px;
padding:16px 18px;
}
.hint-card .hint-ico {
width:36px; height:36px; border-radius:4px;
background:#F1E6D3; color:#8A5E27;
display:flex; align-items:center; justify-content:center;
flex-shrink:0;
}
/* FluxUI-style standard button (the "blue" one in screenshots) — kept, but harmonised */
.btn-primary {
display:inline-flex; align-items:center; gap:8px; justify-content:center;
padding:9px 16px;
background:#1A2540; color:#FFFFFF;
border-radius:4px; font-size:13px; font-weight:600;
transition: background .15s;
}
.btn-primary:hover { background:#243152; }
.btn-secondary {
display:inline-flex; align-items:center; gap:8px; justify-content:center;
padding:8px 14px;
background:#FFFFFF; color:#1A2540;
border:1px solid #CFD6E4; border-radius:4px;
font-size:12.5px; font-weight:600;
transition: border-color .15s, background .15s;
}
.btn-secondary:hover { border-color:#1A2540; background:#F6F4EF; }
.badge {
display:inline-flex; align-items:center; gap:6px;
padding:3px 9px; border-radius:99px;
font-size:10.5px; font-weight:700;
letter-spacing:0.10em; text-transform:uppercase;
}
.badge.warn { background:#F6EAC8; color:#8A5E27; }
.badge.ok { background:#E2F1E5; color:#1F5E2E; }
.badge.hub { background:#E5E9F1; color:#1A2540; }
.badge.dot::before {
content:""; width:6px; height:6px; border-radius:99px; background:currentColor;
}
/* Bridge ribbon - sehr subtil */
.bridge-row {
display:inline-flex; align-items:center; gap:6px;
font-family:"JetBrains Mono","SF Mono",ui-monospace,monospace;
font-size:10.5px; letter-spacing:0.10em; text-transform:uppercase;
color:#5A6360;
}
.dot-pe { width:6px; height:6px; border-radius:99px; background:#1F4D3A; }
.dot-bp { width:6px; height:6px; border-radius:99px; background:#C84A1E; }
</style>
</head>
<body class="bg-bg text-ink font-sans antialiased">
<!-- ============== ARTBOARD ============== -->
<div class="mx-auto bg-bg" style="width:1440px;">
<div class="flex" style="min-height:980px;">
<!-- =============================================
SIDEBAR — schmal, warm, klare Hierarchie
(FluxUI <flux:navlist> + Slot-Header-Styles)
============================================== -->
<aside class="bg-bg-elev border-r border-bg-rule flex flex-col" style="width:260px;">
<!-- Brand-Block -->
<div class="px-5 pt-6 pb-5">
<a href="Hub Landing presseportale.html" class="flex items-baseline gap-2">
<span class="text-[19px] font-bold tracking-[-0.4px] text-hub leading-none">presseportale<span class="text-accent">.com</span></span>
</a>
<div class="eyebrow muted mt-2">Publisher · Hub</div>
<!-- Aktive Firma / Konto-Switcher (FluxUI-Slot: header before nav) -->
<button class="mt-4 w-full grid items-center gap-2.5 px-3 py-2.5 bg-white border border-bg-rule rounded-[4px] hover:border-hub/40 text-left"
style="grid-template-columns:auto 1fr auto;">
<span class="w-7 h-7 rounded-[3px] bg-hub-soft border border-hub-soft-2 flex items-center justify-center text-hub text-[11px] font-bold">TU</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold text-ink leading-tight truncate">Test User</span>
<span class="block text-[10.5px] text-ink-3 leading-tight mt-0.5 truncate">Keine Firma zugeordnet</span>
</span>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" class="text-ink-3">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 7.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"/>
</svg>
</button>
</div>
<nav class="px-3 flex-1">
<div class="nav-section">Mein Bereich</div>
<div class="space-y-0.5 mb-5">
<a class="nav-item active" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M2 7l6-5 6 5v7H2z" stroke="currentColor" stroke-width="1.4"/><path d="M6 14V9h4v5" stroke="currentColor" stroke-width="1.4"/></svg>
Übersicht
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="2.5" width="9" height="11" stroke="currentColor" stroke-width="1.4"/><path d="M11.5 5h2v8.5H4" stroke="currentColor" stroke-width="1.4"/><path d="M5 5.5h4M5 8h4M5 10.5h2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Meine Pressemitteilungen
<span class="badge hub ml-auto" style="font-size:9.5px;padding:1px 6px;letter-spacing:0.08em;">0</span>
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6h11" stroke="currentColor" stroke-width="1.4"/><path d="M6 9h1M9 9h1M6 11.5h1M9 11.5h1" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Firmen
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 5h10l-1 9H4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 5V3.5a2 2 0 014 0V5" stroke="currentColor" stroke-width="1.4"/></svg>
Buchungen &amp; Add-ons
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 13V8M7 13V5M11 13V9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Statistiken
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
<div class="nav-section">Finanzen</div>
<div class="space-y-0.5 mb-5">
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="5" stroke="currentColor" stroke-width="1.4"/><path d="M8 5.5v5M6 8h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Credits &amp; Tarif
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 2.5h7l3 3v8H3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M10 2.5V5.5h3" stroke="currentColor" stroke-width="1.4"/><path d="M5.5 8h5M5.5 10.5h5M5.5 6h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Rechnungen
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="4.5" width="11" height="7.5" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 7h11" stroke="currentColor" stroke-width="1.4"/></svg>
Zahlungsarten
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
<div class="nav-section">Konto</div>
<div class="space-y-0.5 mb-5">
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Profil
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6 8l1.5 1.5L10.5 6" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
Sicherheit
</a>
<a class="nav-item" href="#">
<svg class="ico" viewBox="0 0 16 16" fill="none"><circle cx="6" cy="8" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M8.5 8h5M11 8v2.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
API &amp; Integrationen
</a>
<span class="nav-item disabled">
<svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3.5 7a4.5 4.5 0 119 0v3.5l1 1.5H2.5l1-1.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.4"/></svg>
Benachrichtigungen
<span class="ml-auto text-[9.5px] tracking-[0.14em] uppercase font-semibold text-ink-4">bald</span>
</span>
</div>
</nav>
<!-- Testmodus-Block (war im Original „Zurück zum Admin") -->
<div class="px-4 pb-4">
<div class="bg-hub text-ink-on-dark rounded-[5px] p-4 relative overflow-hidden">
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full bg-hub-3 opacity-50"></div>
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full bg-hub-3 opacity-30"></div>
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
<span class="eyebrow on-dark" style="color:#F4D89C;">Testmodus aktiv</span>
</div>
<div class="text-[12px] leading-[1.5] text-ink-on-dark-2">
Angemeldet als <strong class="text-white font-semibold">Test User</strong>.<br/>
Admin: <strong class="text-white font-semibold">Portal Admin</strong>
</div>
<button class="mt-3 w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
Zurück zum Admin
</button>
</div>
</div>
</div>
<!-- Resources -->
<div class="px-3 pb-5 border-t border-bg-rule pt-4">
<div class="nav-section">Resources</div>
<div class="space-y-0.5">
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 11l4 2 6-9-4-1z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>Tailwind CSS</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4z" stroke="currentColor" stroke-width="1.3"/></svg>Hero Icons</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M9 2L4 9h3l-1 5 5-7H8z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>Flux UI</a>
<a class="nav-item" href="#"><svg class="ico" viewBox="0 0 16 16" fill="none"><path d="M3 4l5-2 5 2v8l-5 2-5-2z" stroke="currentColor" stroke-width="1.3"/><path d="M3 4l5 2 5-2M8 6v8" stroke="currentColor" stroke-width="1.3"/></svg>Repository</a>
</div>
</div>
</aside>
<!-- =============================================
MAIN — Inhalt
============================================== -->
<main class="flex-1 min-w-0">
<!-- Topbar — sehr schlank; Brücken-Kontext + Kontoinfo rechts -->
<div class="bg-bg-elev border-b border-bg-rule">
<div class="px-10 py-3 flex items-center gap-6">
<!-- Breadcrumb -->
<div class="flex items-center gap-2 text-[12px] text-ink-3 font-medium">
<a href="Hub Landing presseportale.html" class="hover:text-hub">Hub</a>
<span class="text-ink-4">/</span>
<span class="text-ink-2">User Backend</span>
<span class="text-ink-4">/</span>
<span class="text-hub font-semibold">Übersicht</span>
</div>
<span class="flex-1"></span>
<!-- Reichweiten-Kontext (Brücke zur Hub-CI) -->
<span class="bridge-row">
<span class="dot-pe"></span> presseecho
<span class="text-ink-4 mx-1">·</span>
<span class="dot-bp"></span> businessportal24
</span>
<span class="w-px h-5 bg-bg-rule"></span>
<!-- Search (FluxUI input slot) -->
<div class="relative">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.3"/>
<path d="M10.5 10.5L13 13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<input class="pl-8 pr-3 py-1.5 w-[220px] bg-white border border-bg-rule rounded-[4px] text-[12.5px] placeholder:text-ink-4 focus:outline-none focus:border-hub" placeholder="Suchen…" />
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] font-mono text-ink-4 border border-bg-rule rounded px-1">⌘K</span>
</div>
<button class="relative w-8 h-8 flex items-center justify-center rounded-[4px] hover:bg-bg border border-transparent hover:border-bg-rule">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" class="text-ink-2">
<path d="M3.5 7a4.5 4.5 0 119 0v3.5l1 1.5H2.5l1-1.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.3"/>
</svg>
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-accent"></span>
</button>
<a href="#" class="btn-primary text-[12px] py-1.5 px-3.5">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
Neue Mitteilung
</a>
</div>
</div>
<!-- Inhalt -->
<div class="px-10 py-8 space-y-6">
<!-- ============== PAGE HEADER ============== -->
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">User Backend</span>
<span class="eyebrow muted">Mein Bereich · A · 01</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] text-ink leading-[1.1] m-0">Mein Dashboard</h1>
<p class="text-[13.5px] text-ink-2 leading-[1.55] mt-2 m-0 max-w-[640px]">
Willkommen zurück, <strong class="font-semibold text-ink">Test User</strong>. Hier sehen Sie Status und Reichweite Ihres Kundenkontos für presseecho und businessportal24.
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<!-- Zeitraum-Filter (FluxUI dropdown-style) -->
<button class="btn-secondary whitespace-nowrap">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 6h11M5 2v3M11 2v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Diese Woche
<svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<!-- Firma-zuordnen-Status — als Banner-Pille, statt im Karte-Header eingesperrt -->
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-warn-soft border border-warn/30 text-accent-deep rounded-[4px] text-[12px] font-semibold whitespace-nowrap">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="flex-shrink-0"><path d="M8 2l6 11H2z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 7v3M8 11.5v.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
Keine Firma zugeordnet
<a href="#" class="underline underline-offset-[3px] decoration-accent-deep/40 hover:decoration-accent-deep">zuordnen →</a>
</span>
</div>
</header>
<!-- ============== STAT-CARDS — KPI-Reihe ============== -->
<section class="grid gap-4" style="grid-template-columns:repeat(4,1fr);">
<article class="stat-card is-primary">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label" style="color:#1A2540;">Gesamt</div>
<span class="text-[10.5px] font-mono text-ink-3 tracking-[0.14em] uppercase">2026</span>
</div>
<div class="stat-num">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
<span class="flex items-center gap-1">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none"><path d="M5 8V2M2 5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
0 ggü. Vormonat
</span>
</div>
<!-- Mini Sparkline placeholder -->
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 18 L10 17 L20 18 L30 16 L40 18 L50 15 L60 17 L70 14 L80 16" stroke="#1A2540" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="16" r="1.8" fill="#1A2540"/>
</svg>
</article>
<article class="stat-card is-ok">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">Veröffentlicht</div>
<span class="badge ok" style="font-size:9.5px;padding:1px 6px;">live</span>
</div>
<div class="stat-num" style="color:#1F5E2E;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
auf beiden Portalen
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 19 L10 18 L20 19 L30 17 L40 16 L50 14 L60 13 L70 11 L80 10" stroke="#2E8540" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="10" r="1.8" fill="#2E8540"/>
</svg>
</article>
<article class="stat-card is-warn">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">In Prüfung</div>
<span class="badge warn" style="font-size:9.5px;padding:1px 6px;">Ø 4 h</span>
</div>
<div class="stat-num" style="color:#8A5E27;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
redaktionelle Prüfung
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 16 L10 17 L20 14 L30 18 L40 13 L50 17 L60 12 L70 15 L80 11" stroke="#A87A1F" stroke-width="1.2" fill="none"/>
<circle cx="80" cy="11" r="1.8" fill="#A87A1F"/>
</svg>
</article>
<article class="stat-card is-muted">
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between">
<div class="stat-label">Entwürfe</div>
<span class="text-[10.5px] font-mono text-ink-3 tracking-[0.14em] uppercase">privat</span>
</div>
<div class="stat-num" style="color:#5A6360;">0</div>
<div class="mt-3 flex items-center gap-2 text-[11.5px] text-ink-3">
gespeichert, nicht eingereicht
</div>
<svg class="absolute right-3 bottom-3 opacity-50" width="80" height="22" viewBox="0 0 80 22" fill="none">
<path d="M0 12 L10 12 L20 12 L30 12 L40 12 L50 12 L60 12 L70 12 L80 12" stroke="#8A918D" stroke-width="1.2" stroke-dasharray="2 2" fill="none"/>
</svg>
</article>
</section>
<!-- ============== ZWEISPALTEN-GRID ============== -->
<section class="grid gap-6" style="grid-template-columns:2fr 1fr;">
<!-- ============== LINKS: Pressemitteilungen-Liste / Empty ============== -->
<article class="panel">
<div class="panel-head">
<div class="flex items-center gap-3">
<span class="section-eyebrow">Meine letzten Pressemitteilungen</span>
</div>
<div class="flex items-center gap-3">
<span class="text-[11.5px] text-ink-3">0 von 0</span>
<a href="#" class="text-[12px] font-semibold text-hub hover:underline underline-offset-[3px] decoration-hub/30">Alle anzeigen →</a>
</div>
</div>
<!-- Empty State — sauber, ohne überlappende Skelett-Zeilen -->
<div class="px-10 py-14 flex flex-col items-center text-center">
<!-- Dezentes Tabellen-Icon -->
<div class="w-16 h-16 rounded-[6px] bg-hub-soft border border-hub-soft-2 flex items-center justify-center text-hub mb-5 relative">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="14" height="16" stroke="currentColor" stroke-width="1.5"/>
<path d="M17 7h4v13H6" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 8h8M6 11h8M6 14h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<span class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-accent text-white text-[10px] font-bold flex items-center justify-center font-mono">0</span>
</div>
<div class="text-[16px] font-semibold text-ink m-0">Noch keine Pressemitteilungen</div>
<p class="text-[13px] text-ink-3 leading-[1.55] mt-2 m-0 max-w-[460px]">
Starten Sie mit einer ersten Mitteilung für die aktive Firma oder Ihr Kundenkonto. Veröffentlichung erfolgt nach redaktioneller Prüfung auf beiden Portalen.
</p>
<div class="mt-6 flex items-center gap-2.5">
<a href="#" class="btn-primary">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
Erste Pressemitteilung erstellen
</a>
<a href="#" class="btn-secondary">Vorlage öffnen</a>
</div>
<!-- Vorgeschlagene Schritte -->
<div class="mt-9 grid gap-3 w-full max-w-[560px]" style="grid-template-columns:repeat(3,1fr);">
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">01</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Firma zuordnen</div>
</div>
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">02</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Mitteilung verfassen</div>
</div>
<div class="text-left px-3 py-2.5 bg-bg-elev border border-bg-rule rounded-[3px]">
<div class="font-mono text-[9.5px] tracking-[0.16em] text-accent font-bold mb-1">03</div>
<div class="text-[11.5px] font-semibold text-ink leading-tight">Zur Prüfung senden</div>
</div>
</div>
</div>
<div class="px-5 py-3 bg-bg-elev border-t border-bg-rule flex items-center gap-2.5 text-[11.5px] text-ink-3">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" class="text-hub flex-shrink-0">
<path d="M8 1.5l5.5 2.5v4c0 3.3-2.2 5.8-5.5 6.5C4.7 13.8 2.5 11.3 2.5 8V4z" stroke="currentColor" stroke-width="1.3" fill="none"/>
<path d="M5.5 8l2 2 3.5-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Tipp: Geprüfte Mitteilungen erscheinen i. d. R. binnen <strong class="text-ink-2 font-semibold">4 Stunden</strong> werktags auf beiden Portalen.
</div>
</article>
<!-- ============== RECHTS: Datenqualität ============== -->
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">Datenqualität</span>
<span class="badge warn dot">2 offen</span>
</div>
<div class="px-5 py-5">
<p class="text-[12px] text-ink-3 leading-[1.55] m-0 mb-4">
Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.
</p>
<div class="space-y-3">
<div class="hint-card">
<span class="hint-ico">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="2.5" stroke="currentColor" stroke-width="1.4"/><path d="M3 13.5c.7-2.4 2.7-4 5-4s4.3 1.6 5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[13px] font-semibold text-ink leading-tight">Profil unvollständig</div>
<span class="font-mono text-[10px] text-ink-3 tracking-[0.10em] uppercase whitespace-nowrap flex-shrink-0">60 %</span>
</div>
<div class="mt-2 h-1 w-full rounded-full bg-bg-rule-2 overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width:60%;"></div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] mt-2 m-0">
Vorname, Telefon und Pressekontakt fehlen für saubere Kundenakte.
</p>
<a href="#" class="inline-flex items-center gap-1 text-[11.5px] font-semibold text-accent-deep mt-2 hover:underline underline-offset-[3px] decoration-accent-deep/40">Profil öffnen →</a>
</div>
</div>
<div class="hint-card">
<span class="hint-ico">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6h11" stroke="currentColor" stroke-width="1.4"/></svg>
</span>
<div class="min-w-0">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[13px] font-semibold text-ink leading-tight">Rechnungsadresse fehlt</div>
<span class="font-mono text-[10px] text-ink-3 tracking-[0.10em] uppercase whitespace-nowrap flex-shrink-0">0 %</span>
</div>
<div class="mt-2 h-1 w-full rounded-full bg-bg-rule-2 overflow-hidden">
<div class="h-full rounded-full bg-accent" style="width:5%;"></div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] mt-2 m-0">
Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.
</p>
<a href="#" class="inline-flex items-center gap-1 text-[11.5px] font-semibold text-accent-deep mt-2 hover:underline underline-offset-[3px] decoration-accent-deep/40">Rechnungsadresse ergänzen →</a>
</div>
</div>
</div>
</div>
</article>
</section>
<!-- ============== UNTERER GRID: FIRMEN + REICHWEITE / AKTIVITÄT ============== -->
<section class="grid gap-6" style="grid-template-columns:2fr 1fr;">
<!-- Firmen Card -->
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">Meine Firmen</span>
<div class="flex items-center gap-3">
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">0 zugeordnet</span>
<a href="#" class="text-[12px] font-semibold text-hub hover:underline underline-offset-[3px] decoration-hub/30">Profil &amp; Firma verwalten →</a>
</div>
</div>
<div class="p-6">
<div class="grid gap-3" style="grid-template-columns:1fr 1fr;">
<!-- Empty-Slot Firma 1 -->
<div class="relative border border-dashed border-bg-rule rounded-[5px] p-5 hover:border-hub/50 hover:bg-bg-elev transition-colors">
<div class="flex items-center gap-3 mb-3">
<span class="w-10 h-10 rounded-[4px] border border-bg-rule bg-bg-elev flex items-center justify-center text-ink-4">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none"><rect x="2.5" y="3.5" width="11" height="10" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 6h11M6 9h1M9 9h1M6 11.5h1M9 11.5h1" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</span>
<div>
<div class="text-[13.5px] font-semibold text-ink-2">Firma hinzufügen</div>
<div class="text-[11px] text-ink-3 mt-0.5">Slot frei · 1 von 3</div>
</div>
</div>
<p class="text-[11.5px] text-ink-3 leading-[1.5] m-0">
Pressestellen, für die Sie Mitteilungen erstellen — mit eigenem Logo, Kontaktperson und Themen-Tags.
</p>
<a href="#" class="absolute top-3 right-3 inline-flex items-center justify-center w-7 h-7 rounded-[3px] bg-white border border-bg-rule text-hub hover:border-hub">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
</a>
</div>
<!-- Hinweis -->
<div class="bg-bg-elev border border-bg-rule rounded-[5px] p-5">
<div class="eyebrow muted mb-2">Hinweis</div>
<div class="text-[13px] text-ink-2 leading-[1.55] m-0">
Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.
</div>
<div class="mt-3 flex items-center gap-2">
<a href="#" class="btn-secondary" style="padding:6px 10px;font-size:11.5px;">Profil prüfen</a>
<a href="/cdn-cgi/l/email-protection#d1a2a4a1a1bea3a591a1a3b4a2a2b4a1bea3a5b0bdb4ffb2bebc" class="text-[11.5px] font-semibold text-ink-3 hover:text-hub">Support kontaktieren</a>
</div>
</div>
</div>
</div>
</article>
<!-- Aktivität / Bridge -->
<article class="panel-dark">
<div class="panel-head">
<span class="eyebrow on-dark">Brand-Bridge</span>
<span class="font-mono text-[10px] text-ink-on-dark-3 tracking-[0.14em] uppercase">A · B</span>
</div>
<div class="px-5 py-5">
<div class="text-[12.5px] leading-[1.55] text-ink-on-dark-2 m-0 mb-4">
Ein Konto, beide Portale — Veröffentlichungen werden parallel auf presseecho und businessportal24 ausgespielt.
</div>
<div class="grid gap-3" style="grid-template-columns:1fr 1fr;">
<div class="bg-hub-2 rounded-[4px] px-3.5 py-3 border border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-pe"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white" style="font-variant-numeric:tabular-nums;">verbunden</div>
<div class="text-[10.5px] text-ink-on-dark-3 mt-0.5">Archiv · Branchen-Tiefe</div>
</div>
<div class="bg-hub-2 rounded-[4px] px-3.5 py-3 border border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-bp"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white" style="font-variant-numeric:tabular-nums;">verbunden</div>
<div class="text-[10.5px] text-ink-on-dark-3 mt-0.5">Wirtschaft · Live</div>
</div>
</div>
<hr class="mt-5 mb-4" style="border:0;height:1px;background:rgba(255,255,255,.10);" />
<div class="space-y-2 text-[11.5px] text-ink-on-dark-2">
<div class="flex items-center justify-between">
<span>API-Status</span>
<span class="flex items-center gap-1.5 text-white"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>operational</span>
</div>
<div class="flex items-center justify-between">
<span>Letzte Synchronisation</span>
<span class="font-mono text-white">vor 2 min</span>
</div>
<div class="flex items-center justify-between">
<span>Tarif</span>
<span class="font-mono text-white">Starter</span>
</div>
</div>
<a href="Hub Landing presseportale.html#tarife" class="mt-5 inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-white hover:underline underline-offset-[3px] decoration-white/30">
Tarife &amp; Add-ons ansehen →
</a>
</div>
</article>
</section>
<!-- ============== FUSSZEILE ============== -->
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px] text-ink-3 border-t border-bg-rule">
<span>© 2026 presseportale.com · Publisher-Hub</span>
<span class="flex items-center gap-5">
<a href="#" class="hover:text-hub">Tastenkürzel</a>
<a href="#" class="hover:text-hub">Changelog</a>
<a href="#" class="hover:text-hub">Statusseite</a>
<a href="/cdn-cgi/l/email-protection#04777174746b767044747661777761746b76706568612a676b69" class="hover:text-hub">Support</a>
</span>
</footer>
</div>
</main>
</div>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script></body>
</html>

View file

@ -165,7 +165,7 @@
<span class="absolute left-0 top-0 bottom-0 w-[3px] bg-brand"></span>
<div>
<div class="eyebrow mb-2.5 text-[10.5px]" style="color:#FF8B6F;">Einreichen im Publisher-Bereich</div>
<h3 class="font-serif m-0 text-[23px] font-semibold leading-[1.25] tracking-[-0.3px] text-ink-on-dark">Die Einreichung läuft über presseportale.com.</h3>
<h3 class="font-serif m-0 text-[23px] font-semibold leading-[1.25] tracking-[-0.3px] text-ink-on-dark">Die Einreichung läuft über pressekonto.de.</h3>
<p class="mt-2.5 mb-0 text-[13.5px] leading-[1.55] text-ink-on-dark-2 max-w-[540px]">Dort verwalten Sie Mitteilungen, Credits und Newsroom — einmaliges Konto, beide Portale nutzbar (businessportal24 &amp; presseecho.de).</p>
</div>
<div class="flex flex-col items-end gap-2.5">
@ -342,7 +342,7 @@
<p class="mt-4.5 mb-0 text-[12px] text-ink-3" style="margin-top:18px;">
Die Veröffentlichung erfolgt über den zentralen Publisher-Bereich auf
<a href="#" class="text-brand font-medium border-b border-brand">presseportale.com</a>.
<a href="#" class="text-brand font-medium border-b border-brand">pressekonto.de</a>.
Cross-Publishing nach presseecho.de ist optional verfügbar.
</p>
</div>
@ -583,7 +583,7 @@
Zum Publisher-Bereich
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="ml-0.5"><path d="M4 8L8.5 3.5M8.5 3.5H5M8.5 3.5V7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="text-[12px] text-ink-on-dark-2">Einreichung läuft über presseportale.com · Login per Magic-Link</span>
<span class="text-[12px] text-ink-on-dark-2">Einreichung läuft über pressekonto.de · Login per Magic-Link</span>
<a href="#" class="mt-1.5 text-[13px] text-ink-on-dark border-b pb-0.5" style="border-color:rgba(255,255,255,0.35);">Oder zuerst Beispiele ansehen →</a>
</div>
</div>
@ -597,7 +597,7 @@
<div class="grid pb-7" style="grid-template-columns:1.5fr 1fr 1fr 1fr;gap:56px;border-bottom:1px solid rgba(255,255,255,0.08);">
<div>
<div class="font-serif text-[22px] font-semibold" style="letter-spacing:-0.4px;">businessportal<span class="text-brand">24</span></div>
<p class="mt-3 text-[12.5px] text-ink-on-dark-2 leading-[1.6] max-w-[380px]">businessportal24 ist ein Service der Presseportale-Gruppe. Plattform für Pressemitteilungen mittelständischer Unternehmen, Selbstständiger und PR-Agenturen im deutschsprachigen Raum.</p>
<p class="mt-3 text-[12.5px] text-ink-on-dark-2 leading-[1.6] max-w-[380px]">businessportal24 ist ein Service der Pressekonto-Gruppe. Plattform für Pressemitteilungen mittelständischer Unternehmen, Selbstständiger und PR-Agenturen im deutschsprachigen Raum.</p>
</div>
<div>
<div class="eyebrow mb-3.5 text-[10px]" style="color:rgba(255,255,255,0.5);">Einreichen</div>
@ -628,7 +628,7 @@
</div>
</div>
<div class="mt-5.5 flex justify-between items-center flex-wrap gap-4 text-[12px] text-ink-on-dark-2" style="margin-top:22px;">
<div>© 2026 Presseportale-Gruppe · Alle Rechte vorbehalten</div>
<div>© 2026 Pressekonto-Gruppe · Alle Rechte vorbehalten</div>
<div class="flex items-center gap-4">
<span class="text-ink-on-dark-2">Für fachlich-spezifische Themen:</span>
<a href="#" class="text-ink-on-dark border-b pb-px" style="border-color:rgba(255,255,255,0.3);">presseecho.de →</a>

View file

@ -24,7 +24,7 @@ Diese Frontends sollen künftig **gegen das neue Backend arbeiten** via dire
### 1.3 Neues Backend (Aufgabe dieses Projekts)
- Läuft unter `presseportale.test` / `presseportale.com`
- Läuft unter `pressekonto.test` / `pressekonto.de`
- Stack: **Laravel 12, PHP 8.4, Livewire 4, Volt, Flux UI 2, MySQL 8, Tailwind 4**
- **Vorarbeiten bereits vorhanden**: Admin-UI-Gerüst, Routes, Auth-Stack
- **Noch zu tun**: Eloquent-Models, Migrations, Services, Daten-Migration, API, Payment (Stripe), Cron-Jobs
@ -58,7 +58,7 @@ Diese Frontends sollen künftig **gegen das neue Backend arbeiten** via dire
| Kriterium | Messbar an |
|---|---|
| Backend unter `presseportale.test` erreichbar | HTTP 200 auf Login-Seite |
| Backend unter `pressekonto.test` erreichbar | HTTP 200 auf Login-Seite |
| Admin-Login (Fortify + 2FA) funktioniert | Feature-Test `AuthTest` |
| Customer-Portal via Magic-Link erreichbar | Feature-Test `MagicLinkLoginTest` |
| Pressemitteilungen CRUD (Admin + Customer) | Livewire-Tests `PressReleaseIndex/Create/Edit/Show` |

View file

@ -4,7 +4,7 @@
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Presseportale Backend (presseportale.test) │
│ Pressekonto Backend (pressekonto.test) │
│ Laravel 12 · PHP 8.4 · Livewire 4 · Volt · Flux UI 2 │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌─────────┐ │
@ -268,7 +268,7 @@ app/
## 6. Multi-Domain & Portal-Scope
- Pro Domain ein Frontend, aber alle Admin-Screens auf `presseportale.test`.
- Pro Domain ein Frontend, aber alle Admin-Screens auf `pressekonto.test`.
- Middleware `SetCurrentPortal`:
- Web (presseecho.test / businessportal24.test) → `app()->instance('current_portal', Portal::Presseecho|Businessportal24)`
- Admin → Portal-Auswahl via Dropdown, gespeichert in Session (`current_portal`)

View file

@ -224,7 +224,7 @@ Die Views existieren bereits als Blade/Livewire-Stubs (`resources/views/admin/*`
| 11.3 | Produktiv-Import-**Rehearsal** gegen aktuellen Legacy-Snapshot | ⬜ |
| 11.4 | **Go-Live-Mailing**: Passwort-Reset + Sicherheitshinweis an alle User | 🔄 Command/Mailable fertig; Versand und finale Texte offen |
| 11.5 | **API-Kunden-Kommunikation** (Token-Migration) | 🔄 Kundenreport fertig; Freigabeliste und operative Mailtexte offen |
| 11.6 | DNS-Cutover-Plan: `presseportale.com` auf neuen Server | ⬜ |
| 11.6 | DNS-Cutover-Plan: `pressekonto.de` auf neuen Server | ⬜ |
| 11.7 | Read-Only-Modus auf Legacy-Systemen während Final-Import | ⬜ |
| 11.8 | Abschalten der alten Symfony-Server (nach Review-Periode) | ⬜ |

View file

@ -175,8 +175,8 @@ Content-Type: application/json
{
"message": "Legacy API keys are no longer supported.",
"migration_url": "https://presseportale.com/account/tokens",
"docs_url": "https://presseportale.com/docs/api/v1"
"migration_url": "https://pressekonto.de/account/tokens",
"docs_url": "https://pressekonto.de/docs/api/v1"
}
```

View file

@ -154,7 +154,7 @@ php artisan test --compact tests/Feature/LegacyInvoiceArchiveCommandTest.php tes
## 2026-05-04 Architektur-Entscheidung: ein gemeinsames Admin-Panel mit rollenbasierter Sichtbarkeit
Der Auftraggeber hat festgelegt: **es gibt nur ein gemeinsames Admin-Panel** unter dem Presseportale-Backend. Admins, Editoren und Customer arbeiten im selben UI Sichtbarkeit von Menüpunkten und Aktionen entscheidet die Rolle/Permission.
Der Auftraggeber hat festgelegt: **es gibt nur ein gemeinsames Admin-Panel** unter dem Pressekonto-Backend. Admins, Editoren und Customer arbeiten im selben UI Sichtbarkeit von Menüpunkten und Aktionen entscheidet die Rolle/Permission.
- Aktuell laufen Admin-/Editor-Funktionen unter `/admin/*` und Customer-Funktionen unter `/customer/*`. Diese Trennung wird **vor dem Pressemitteilungs-Veröffentlichungs-Block** in eine **gemeinsame Panel-Architektur** überführt.
- Sidebar wird rollenbasiert gefiltert (Admin/Editor sehen alle PMs, Customer nur seine etc.). Routen-Konsolidierung Customer → Admin-Panel.
@ -318,7 +318,7 @@ Status in `03-MIGRATION-PLAN.md` ist auf `⏸️ Vertagt 2026-05-04` gesetzt.
### Was wurde gemacht
#### Paket A Test-Suite stabilisiert
- `phpunit.xml`: `APP_URL=https://presseportale.test` ergänzt, damit `route('login')` etc. nicht mehr auf eine fremde Domain ohne Auth-Routen zeigen.
- `phpunit.xml`: `APP_URL=https://pressekonto.test` ergänzt, damit `route('login')` etc. nicht mehr auf eine fremde Domain ohne Auth-Routen zeigen.
- `tests/Feature/Auth/EmailVerificationTest.php`: Tests werden jetzt sauber per `markTestSkipped()` übersprungen, solange `Features::emailVerification()` in `config/fortify.php` deaktiviert ist. Sobald das Feature aktiviert wird, laufen die Tests automatisch wieder.
- `tests/Feature/ExampleTest.php`: testet jetzt den Health-Endpoint `/up` statt der von Vite/Theme-Layout abhängigen Startseite.
- Leerer `tests/Feature/Feature/` / `tests/Feature/Feature/Billing/`-Doppelpfad entfernt.

View file

@ -166,7 +166,7 @@ Hängt komplett an §2 (Stripe-Account + Produktliste).
| 11.3 | Produktiv-Import-**Rehearsal** gegen aktuellen Legacy-Snapshot | 🔴 | ⬜ |
| 11.4 | Go-Live-Mailing (Passwort-Reset + Sicherheitshinweis) versenden | 🔴 | 🔄 (Code fertig, Texte/Versand offen) |
| 11.5 | API-Kunden-Kommunikation (Token-Migration) versenden | 🔴 | 🔄 (Report fertig, Texte/Versand offen) |
| 11.6 | DNS-Cutover-Plan: `presseportale.com` auf neuen Server | 🔴 | ⬜ |
| 11.6 | DNS-Cutover-Plan: `pressekonto.de` auf neuen Server | 🔴 | ⬜ |
| 11.7 | Read-Only-Modus auf Legacy-Systemen während Final-Import | 🔴 | ⬜ |
| 11.8 | Abschalten der alten Symfony-Server (nach Review-Periode) | 🟡 | ⬜ |

View file

@ -8,6 +8,8 @@ Stand: 2026-05-04. Dieses Kurz-Runbook spiegelt den aktuell implementierten Comm
php artisan legacy:import --source=all --dry-run
php artisan legacy:archive-invoices --dry-run
php artisan legacy:verify --no-report
php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration --dry-run
```
Hinweis: `legacy:archive-invoices` importiert die Legacy-Rechnungen vollständig in `legacy_invoices`, inkl. Status/User-Zuordnung, `raw_snapshot`, `pdf_payload` und Report. Die PDF-Erzeugung erfolgt im Customer-Bereich bei Abruf aus diesen Archivdaten.
@ -38,6 +40,7 @@ php artisan legacy:import --source=all --force
php artisan legacy:archive-invoices
php artisan legacy:fix-timestamps
php artisan legacy:verify
php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration
```
## Noch nicht im Runbook finalisiert

View file

@ -1,6 +1,6 @@
# Migration 2026 Presseportale Backend
# Migration 2026 Pressekonto Backend
> **Migration des Backends** der beiden Legacy-Portale `presseecho` und `businessportal24` (Symfony 1.4, PHP 5.6) in ein **gemeinsames, modernes Laravel 12 Backend** unter der Domain `presseportale.test` / `presseportale.com`.
> **Migration des Backends** der beiden Legacy-Portale `presseecho` und `businessportal24` (Symfony 1.4, PHP 5.6) in ein **gemeinsames, modernes Laravel 12 Backend** unter der Domain `pressekonto.test` / `pressekonto.de`.
**Start:** 23.04.2026
**Lead-Technologie:** Laravel 12 · PHP 8.4 · Livewire 4 · Volt · Flux UI 2 · Tailwind CSS 4 · MySQL 8
@ -43,7 +43,7 @@ Historisch existieren zwei technisch identische Symfony-1.4-Installationen (`pre
- **DB-Dumps** (2026-04-23): `businessportal24` 42 Tabellen / 578 MB, `presseecho` 43 Tabellen / 369 MB 41 Tabellen sind strukturell identisch, 3 abweichend (`press_release_image_old` nur BP24, `category_pe_data` + `press_release_pe_data` nur PE)
Aktueller Implementierungsstand (Code-Abgleich 2026-04-29):
- ✅ **Domain-basiertes Theme-System** (`presseportale` / `presseecho` / `businessportal24`)
- ✅ **Domain-basiertes Theme-System** (`pressekonto` / `presseecho` / `businessportal24`)
- ✅ **Admin-UI** mit echten Daten für Dashboard, Users/Roles, Companies, Contacts, Categories-Index und PressRelease-CRUD/-Workflow
- ✅ **Auth-Stack**: Fortify, Sanctum, Spatie/Permission
- ✅ **Admin-Schutz und Portal-Scoping**: `EnsureUserIsAdmin`, `SetCurrentPortal`, `PortalScope`, Sidebar-Portal-Switcher

View file

@ -26,7 +26,7 @@ services:
DB_PORT: 3306
# Hier definieren wir nur die Haupt-Datenbank für .env
# Die anderen beiden richtest du in Laravel ein
DB_DATABASE: presseportale
DB_DATABASE: pressekonto
DB_USERNAME: root
DB_PASSWORD: password
MAIL_HOST: global-mailpit
@ -40,23 +40,23 @@ services:
labels:
- "traefik.enable=true"
# Portal Domain
- "traefik.http.routers.presseportale.rule=Host(`presseportale.test`)"
- "traefik.http.routers.presseportale.entrypoints=websecure"
- "traefik.http.routers.presseportale.tls=true"
- "traefik.http.routers.presseportale.service=presseportale-service-prc"
- "traefik.http.routers.pressekonto.rule=Host(`pressekonto.test`)"
- "traefik.http.routers.pressekonto.entrypoints=websecure"
- "traefik.http.routers.pressekonto.tls=true"
- "traefik.http.routers.pressekonto.service=pressekonto-service-prc"
# Presseecho Domain
- "traefik.http.routers.presseecho.rule=Host(`presseecho.test`)"
- "traefik.http.routers.presseecho.entrypoints=websecure"
- "traefik.http.routers.presseecho.tls=true"
- "traefik.http.routers.presseecho.service=presseportale-service-prc"
- "traefik.http.routers.presseecho.service=pressekonto-service-prc"
# Business Portal Domain
- "traefik.http.routers.businessportal.rule=Host(`businessportal24.test`)"
- "traefik.http.routers.businessportal.entrypoints=websecure"
- "traefik.http.routers.businessportal.tls=true"
- "traefik.http.routers.businessportal.service=presseportale-service-prc"
- "traefik.http.routers.businessportal.service=pressekonto-service-prc"
# Asset Domain für Vite-Server Portal (Port 5177)
- "traefik.http.routers.assets-portal.rule=Host(`assets.presseportale.test`)"
- "traefik.http.routers.assets-portal.rule=Host(`assets.pressekonto.test`)"
- "traefik.http.routers.assets-portal.entrypoints=websecure"
- "traefik.http.routers.assets-portal.tls=true"
- "traefik.http.routers.assets-portal.service=assets-portal-service-prc"
@ -74,7 +74,7 @@ services:
- "traefik.http.routers.assets-businessportal.service=assets-web-service-prc"
# Service Definition - NUR EINMAL!
- "traefik.http.services.presseportale-service-prc.loadbalancer.server.port=80"
- "traefik.http.services.pressekonto-service-prc.loadbalancer.server.port=80"
- "traefik.http.services.assets-portal-service-prc.loadbalancer.server.port=5177"
- "traefik.http.services.assets-portal-service-prc.loadbalancer.server.scheme=http"
- "traefik.http.services.assets-web-service-prc.loadbalancer.server.port=5178"

View file

@ -19,7 +19,7 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_URL" value="https://presseportale.test"/>
<env name="APP_URL" value="https://pressekonto.test"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>

View file

@ -1,6 +1,23 @@
@import "tailwindcss";
@import "../../vendor/livewire/flux/dist/flux.css";
/**
* Hub × FluxUI Phase 1: Portal-Shell auf Hub-Design.
*
* Tokens leben in shared/design-tokens.css (Single Source of Truth aus Phase 0).
* Hier:
* 1. Tokens importieren
* 2. Zinc-Skala auf Hub-Buchpapier-Familie mappen (für FluxUI-Komponenten,
* die ihre Skala-Defaults aus Zinc beziehen)
* 3. FluxUI-Akzent (--color-accent) auf Hub-Blau umstellen
* 4. FluxUI-Komponenten via [data-flux-*]-Selektoren ans Hub-Design angleichen
* (Sidebar, Navlist-Active, Primary-Buttons, Cards)
*
* Dokumentation: dev/frontend/hub-flux/02-PHASE-1-PORTAL-SHELL.md
*/
@import "./shared/design-tokens.css";
@import "./shared/hub-components.css";
@source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
@ -9,34 +26,46 @@
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif,
/* Font: Inter Tight statt Instrument Sans
(Token --font-sans aus design-tokens.css wird hier nochmal explizit
gesetzt, weil FluxUI zuvor seinen eigenen Wert setzen würde) */
--font-sans:
"Inter Tight", Inter, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--color-zinc-50: var(--color-neutral-50);
--color-zinc-100: var(--color-neutral-100);
--color-zinc-200: var(--color-neutral-200);
--color-zinc-300: var(--color-neutral-300);
--color-zinc-400: var(--color-neutral-400);
--color-zinc-500: var(--color-neutral-500);
--color-zinc-600: var(--color-neutral-600);
--color-zinc-700: var(--color-neutral-700);
--color-zinc-800: var(--color-neutral-800);
--color-zinc-900: var(--color-neutral-900);
--color-zinc-950: var(--color-neutral-950);
/* FluxUI-Akzent (für Buttons, Focus-Rings, Active-States) auf Hub-Blau.
Vorher: #3ea3dc (Starter-Kit-Türkis). */
--color-accent: var(--color-hub);
--color-accent-content: var(--color-hub);
--color-accent-foreground: #ffffff;
--color-accent: #3ea3dc;
--color-accent-content: #3ea3dc;
--color-accent-foreground: var(--color-white);
/* Zinc-Skala auf warmes Buchpapier mappen.
FluxUI nutzt Zinc als neutrale Skala für Sidebars, Borders, Text.
Wir bridgen die ganze Skala, damit der Look automatisch auf Hub-Stil
umschwenkt ohne dass wir hunderte FluxUI-Klassen einzeln umbiegen
müssen. */
--color-zinc-50: #fbfaf6;
--color-zinc-100: #f6f4ef;
--color-zinc-200: #e2ddd0;
--color-zinc-300: #cfc8b5;
--color-zinc-400: #8a918d;
--color-zinc-500: #5a6360;
--color-zinc-600: #3a413d;
--color-zinc-700: #1a1f1c;
--color-zinc-800: #243152;
--color-zinc-900: #1a2540;
--color-zinc-950: #0f1729;
}
@layer theme {
.dark {
--color-accent: #3ea3dc;
--color-accent-content: #5bb8e6;
--color-accent-foreground: var(--color-white);
}
}
/* Phase 5: Dark-Mode-Mapping liegt jetzt vollständig in
shared/design-tokens.css (`.dark { }`). FluxUI Appearance-Switcher
setzt `class="dark"` auf <html>, alle `--color-*`-Vars schalten
automatisch um inklusive `--color-accent`, weil das oben im @theme
per `var(--color-hub)`-Verweis dynamisch ist.
Der Notfall-Hack aus Phase 1 (`.dark { --color-accent: var(--color-hub) }`)
ist damit gegenstandslos und entfernt. */
@layer base {
*,
@ -48,6 +77,10 @@
}
}
/* ============================================================
* FluxUI Form-Felder Layout vom Starter-Kit übernommen
* Input-Focus wird weiter unten im Kontrast-Tuning auf Hub-Blau gesetzt.
* ============================================================ */
[data-flux-field]:not(ui-radio, ui-checkbox) {
@apply grid gap-2;
}
@ -56,12 +89,165 @@
@apply !mb-0 !leading-tight;
}
/* ============================================================
* Phase 1 Hub-Style-Overrides für FluxUI-Komponenten
* ============================================================
*
* Strategie: minimal-invasive Overrides via [data-flux-*]-Attribute und
* der Tailwind-Klassen, die FluxUI auf seinen Komponenten setzt. Wir
* ändern KEINE Vendor-Dateien, sondern legen unsere Styles mit höherer
* Spezifität darüber.
*
* Wichtig: FluxUI nutzt für variant-spezifisches Styling KEINE
* `data-variant`-Attribute, sondern direkt Tailwind-Klassen wie
* `bg-[var(--color-accent)]` (für variant="primary"). Wir greifen
* deshalb über diese Klassen-Selektoren.
*
* Bei FluxUI-Updates können sich diese Klassen ändern Selektoren bewusst
* konservativ, gut kommentiert. Visueller Smoke-Test pro Release-Bump.
* ============================================================ */
/* Sidebar — warmes Buchpapier statt Zinc-Grau, klare Trennlinie */
[data-flux-sidebar] {
background: var(--color-bg-elev);
border-color: var(--color-bg-rule);
}
/* Sidebar-Section-Headings Mockup-Konvention:
10 px, fett, gesperrt, anthrazit */
[data-flux-navlist] [data-flux-navlist-group-heading],
[data-flux-navlist] [data-flux-group-heading] {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-ink-4);
padding-bottom: 6px;
}
/* Navlist-Item — Hub-Stil: kompakter, mit Active-Strip links */
[data-flux-navlist-item] {
font-size: 13px;
font-weight: 500;
color: var(--color-ink-2);
border-radius: var(--radius-sm);
transition:
background 0.12s,
color 0.12s;
}
[data-flux-navlist-item]:hover {
background: var(--color-bg);
color: var(--color-hub);
}
[data-flux-navlist-item][data-current="true"],
[data-flux-navlist-item][aria-current="page"],
[data-flux-navlist-item].active {
background: var(--color-hub-soft);
color: var(--color-hub);
font-weight: 600;
position: relative;
}
[data-flux-navlist-item][data-current="true"]::before,
[data-flux-navlist-item][aria-current="page"]::before,
[data-flux-navlist-item].active::before {
content: "";
position: absolute;
left: -1px;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--color-hub);
border-radius: 0 2px 2px 0;
}
/* ============================================================
* FluxUI Buttons Hub-Stil
* ============================================================
* FluxUI rendert `<flux:button variant="primary">` als
* class=" bg-[var(--color-accent)]
* hover:bg-[color-mix(in_oklab,_var(--color-accent),_transparent_10%)]
* shadow-[inset_0px_1px_--theme(--color-white/.2)] "
*
* `--color-accent` haben wir auf `var(--color-hub)` (#1A2540) gesetzt
* Default-Background passt schon. ABER:
*
* 1. Der Hover ("10 % transparent") wirkt auf hellem Buchpapier
* hellblau wir wollen statt dessen Hub-2 (#243152, dunkler) wie
* auf der Hub-Landing (`hover:bg-hub-2`).
* 2. FluxUI's Default-Shadow (1 px Weiß-Inset) braucht auf dem warmen
* Hintergrund mehr Kontrast für klare Button-Kanten.
*
* Wir greifen über `data-flux-button` (Attribut auf dem gerenderten
* <button>) plus dem Tailwind-Klassen-Selektor mit escapten Brackets.
* !important ist nötig, weil FluxUI's Tailwind-Hover normal höhere
* Spezifität hätte (escapter Klassenname). */
/* Primary-Button-Hover: dunkler statt heller. */
[data-flux-button].hover\:bg-\[color-mix\(in_oklab\,_var\(--color-accent\)\,_transparent_10\%\)\]:hover {
background-color: var(--color-hub-2) !important;
}
/* Primary-Button: kräftigerer Shadow für klare Button-Kanten auf hellem Bg.
Schatten-Farbton hängt vom Mode ab: Light = warmer Hub-Blau-Alpha,
Dark = neutraler Schwarz-Alpha (sonst wirkt der hub-blaue Schatten
auf dunklem Card-BG zu sichtbar). */
[data-flux-button].bg-\[var\(--color-accent\)\] {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 1px 2px rgba(26, 37, 64, 0.25),
0 2px 6px -2px rgba(26, 37, 64, 0.18);
border-color: var(--color-hub-2);
}
[data-flux-button].bg-\[var\(--color-accent\)\]:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
0 2px 4px rgba(26, 37, 64, 0.35),
0 3px 10px -2px rgba(26, 37, 64, 0.25);
}
.dark [data-flux-button].bg-\[var\(--color-accent\)\] {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 1px 2px rgba(0, 0, 0, 0.5),
0 2px 6px -2px rgba(0, 0, 0, 0.4);
border-color: var(--color-hub);
}
.dark [data-flux-button].bg-\[var\(--color-accent\)\]:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 2px 4px rgba(0, 0, 0, 0.6),
0 3px 10px -2px rgba(0, 0, 0, 0.5);
}
/* Cards — Buchpapier statt Zinc */
[data-flux-card] {
background: var(--color-bg-card);
border-color: var(--color-bg-rule);
}
/* Headings & Heading-Defaults — Hub-Ink-Farbe */
[data-flux-heading] {
color: var(--color-ink);
}
/* ============================================================
* Kontrast-Tuning für Inputs, Borders, Trennlinien
* ============================================================
* Unser Zinc Buchpapier-Mapping macht `border-zinc-200` zu `#e2ddd0`
* (warmes Rule). Auf hellem Buchpapier sind Inputs/Cards damit korrekt
* vom Bg trennend, aber für Fokus-States braucht's klar Hub-Blau. */
/* Input-Focus: Hub-Blau-Ring statt blassen Default */
input:focus[data-flux-control],
textarea:focus[data-flux-control],
select:focus[data-flux-control] {
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
@apply ring-2 ring-offset-1;
--tw-ring-color: var(--color-hub);
--tw-ring-offset-color: var(--color-bg-elev);
border-color: var(--color-hub) !important;
}
/* \[:where(&)\]:size-4 {
@apply size-4;
} */

View file

@ -0,0 +1,248 @@
/**
* Hub × FluxUI Gemeinsame Design-Tokens (Single Source of Truth)
*
* Diese Datei ist die EINZIGE Quelle für Hub-Farben, Fonts, Radii und
* Schatten. Sowohl der Web-Build (Hub-Landing, Hub-Auth) als auch der
* Portal-Build (User-Panel, Admin-Bereich) importieren diese Datei.
*
* Konventionen:
* - Token-Names sind STABIL. Werte können sich ändern (z.B. Dark Mode),
* Namen nicht.
* - Light Mode ist Default. Dark Mode kommt in Phase 5.
* - FluxUI-Akzent-Variablen (--color-accent, --color-accent-*) werden
* in portal.css mit unseren Hub-Werten gebridged, nicht hier.
*
* Dokumentation: dev/frontend/hub-flux/README.md
* dev/frontend/hub-flux/01-PHASE-0-TOKENS.md
*/
@theme {
/* =========================================================
* SURFACES warmes Buchpapier, gemeinsame Familie
* ========================================================= */
--color-bg: #f6f4ef;
--color-bg-elev: #fbfaf6;
--color-bg-rule: #e2ddd0;
--color-bg-rule-2: #ede7d7; /* leicht hellere Variante für Progress-Tracks */
--color-bg-rule-strong: #1a1f1c;
--color-bg-dark: #15201a;
--color-bg-card: #ffffff;
--color-bg-card-warm: #efeadc;
--color-bg-card-warm-border: #d6cfbb;
--color-bg-card-warm-hover: #e6deca;
--color-bg-card-warm-rule: #c8bda3;
/* =========================================================
* HUB-BLAU primärer Akzent, plattform-neutral
* Klarer Kontrast zum hellen Buchpapier-Hintergrund.
* ========================================================= */
--color-hub: #1a2540;
--color-hub-2: #243152;
--color-hub-3: #2e3d66;
--color-hub-soft: #e5e9f1;
--color-hub-soft-2: #cfd6e4;
--color-hub-line: #7b8fbf;
/* Topbar-Aliase für Hub-Gradient */
--color-topbar: #1a2540;
--color-topbar2: #243152;
--color-topbar-deep: #0f1729;
/* =========================================================
* AKZENT gedecktes Bernstein (sekundärer Akzent)
* Bewusst NICHT Orange (BP24) und NICHT Grün (Presseecho).
* Im Portal für: Notifications, Datenqualität, Empfehlungs-Ribbons.
*
* Wichtige Trennung (Phase 5 / Dark Mode):
* --color-accent heller Akzent (Light: Bernstein, Dark: heller Bernstein).
* In portal.css wird das auf var(--color-hub) umgebogen,
* weil FluxUI-Primary-Buttons das nutzen.
* --color-accent-warm KONSTANTER Bernstein (gleicher Wert in beiden Modi).
* Für Hint-Card-Border, Schritt-Karten-Eyebrows
* und alle Stellen, die explizit Bernstein sein müssen.
* --color-accent-deep gedämpfter Bernstein für Action-Links.
* ========================================================= */
--color-accent: #b07a3a;
--color-accent-deep: #8a5e27;
--color-accent-soft: #f1e6d3;
--color-accent-warm: #b07a3a;
/* =========================================================
* PANEL-DARK KONSTANTES dunkles Hub-Blau (in beiden Modi).
* Wird von `.panel-dark` und der Brand-Bridge genutzt; soll im
* Dark Mode NICHT hell werden, sondern immer dunkel bleiben.
* Im Hub-Frontend (Light-Only) zeigt's die gleiche Atmosphäre.
* ========================================================= */
--color-panel-dark: #0f1729;
--color-panel-dark-2: #1a2540;
/* =========================================================
* INK Anthrazit-Reihe für Text & Linien
* ========================================================= */
--color-ink: #1a1f1c;
--color-ink-2: #3a413d;
--color-ink-3: #5a6360;
--color-ink-4: #8a918d;
--color-ink-on-dark: #f6f4ef;
--color-ink-on-dark-2: #b2b9c7;
--color-ink-on-dark-3: #7b8fbf;
--color-ink-on-dark-muted: #7b8fbf;
--color-ink-on-dark-rule: #2a3550;
/* =========================================================
* BRAND-ALIASE für Komponenten, die brand-Tokens nutzen
* ========================================================= */
--color-brand: #1a2540;
--color-brand-deep: #0f1729;
--color-brand-soft: #e5e9f1;
/* =========================================================
* STATUS Erfolg, Warnung, Fehler, Live
* Identische Namen wie das User-Dashboard-Mockup verwendet.
* ========================================================= */
--color-ok: #2e8540;
--color-ok-soft: #e2f1e5;
--color-warn: #a87a1f;
--color-warn-soft: #f6eac8;
--color-err: #a8331f;
--color-err-soft: #f4dad2;
--color-live: #c84a1e;
--color-gain: #2e8540;
--color-gain-deep: #1f5e2e;
--color-loss: #c8341e;
/* =========================================================
* EDITORIAL für Card-Warm-Sektionen
* ========================================================= */
--color-card-warm-cat: #5a6360;
--color-card-warm-title: #2a302d;
--color-feature-line: #c0c8db;
--color-feature-dot: #d8dde9;
/* =========================================================
* BRIDGE-DOTS kleine farbige Punkte für presseecho /
* businessportal24 in Brücken-Anzeigen (Sidebar, Topbar,
* Bridge-Cards). Werte aus dem User-Dashboard-Mockup.
* ========================================================= */
--color-bridge-presseecho: #1f4d3a;
--color-bridge-businessportal: #c84a1e;
/* =========================================================
* FONTS Hub: Inter Tight + JetBrains Mono.
* Source Serif 4 wird im Hub für die Markennennungen der
* Tochter-Portale (presseecho, businessportal24) verwendet;
* im Portal nur für Brand-Mark, falls überhaupt.
* ========================================================= */
--font-sans:
"Inter Tight", Inter, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--font-serif:
"Source Serif 4", "Source Serif Pro", Charter, "Iowan Old Style",
Georgia, serif;
--font-mono:
"JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular, Menlo,
Consolas, monospace;
/* =========================================================
* LAYOUT
* ========================================================= */
--container-layout: 1280px;
/* =========================================================
* RADII kleines Set, am Mockup orientiert
* ========================================================= */
--radius-xs: 3px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
/* =========================================================
* SCHATTEN weich, warm-getönt
* ========================================================= */
--shadow-soft: 0 1px 0 rgba(26, 31, 28, 0.03);
--shadow-card: 0 1px 2px 0 rgb(26 37 64 / 0.06);
--shadow-card-hover: 0 4px 12px -2px rgb(26 37 64 / 0.12);
--shadow-auth:
0 1px 0 rgba(26, 31, 28, 0.03),
0 20px 50px -32px rgba(26, 37, 64, 0.28);
}
/* =========================================================
* DARK MODE Phase 5 AKTIV.
* Werte aus dev/frontend/tailwind_v3/User Dashboard presseportale Dark.html.
* Aktivierung über `.dark` (FluxUI Appearance-Switcher setzt das auf <html>).
*
* Konventionen:
* - Gleiche Token-Namen wie Light Mode (Single Source of Truth).
* - `--color-accent-warm`, `--color-panel-dark*` bleiben KONSTANT
* (siehe Begründungen oben im Light-Block).
* - Hub-Frontend (Landing + Auth) lädt KEIN `@fluxAppearance` und
* bleibt damit immer Light. Nur Portal-Seiten schalten um.
* ========================================================= */
.dark {
/* Surfaces */
--color-bg: #0e1218;
--color-bg-elev: #14181f;
--color-bg-rule: #2a3142;
--color-bg-rule-2: #232838;
--color-bg-rule-strong: #ece9e0;
--color-bg-card: #181d27;
--color-bg-card-warm: #1f1a12;
/* Hub-Blau (im Dark Mode HELLER für Lesbarkeit auf dunklem Bg) */
--color-hub: #5a78c2;
--color-hub-2: #6d8ad3;
--color-hub-3: #4a65a8;
--color-hub-soft: #1f2a47;
--color-hub-soft-2: #2c3a5d;
--color-hub-line: #7b8fbf;
/* Bernstein — heller Akzent (warm-Token bleibt konstant) */
--color-accent: #d9a560;
--color-accent-deep: #b07a3a;
--color-accent-soft: #2a2418;
/* --color-accent-warm: konstant #b07a3a (aus Light geerbt) */
/* Ink */
--color-ink: #ece9e0;
--color-ink-2: #c9c5b8;
--color-ink-3: #8e8b82;
--color-ink-4: #5d5c57;
/* Brand-Aliase */
--color-brand: #5a78c2;
--color-brand-deep: #4a65a8;
--color-brand-soft: #1f2a47;
/* Status */
--color-ok: #4dc076;
--color-ok-soft: #1a2d22;
--color-warn: #d9a560;
--color-warn-soft: #2d2418;
--color-err: #e07664;
--color-err-soft: #2e1715;
--color-gain: #4dc076;
--color-gain-deep: #3aa060;
--color-loss: #e07664;
/* Bridge-Dots heller für Dark-Bg */
--color-bridge-presseecho: #4da37a;
--color-bridge-businessportal: #e36340;
/* Editorial */
--color-card-warm-cat: #8e8b82;
--color-card-warm-title: #ece9e0;
--color-feature-line: #2c3a5d;
--color-feature-dot: #2a3142;
/* Schatten — im Dark Mode neutral schwarz statt hub-blau-warm */
--shadow-soft: 0 1px 0 rgba(0, 0, 0, 0.4);
--shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.4);
--shadow-card-hover: 0 4px 12px -2px rgba(0, 0, 0, 0.55);
--shadow-auth:
0 1px 0 rgba(0, 0, 0, 0.5),
0 20px 50px -32px rgba(0, 0, 0, 0.7);
/* color-scheme-Hint für native Form-Controls (Scrollbars, Inputs) */
color-scheme: dark;
}

View file

@ -0,0 +1,340 @@
/**
* Hub-Components Single Source of Truth für Hub-typische Layout-Bausteine
*
* Wird von BEIDEN Builds importiert:
* - resources/css/portal.css (FluxUI-Portal)
* - resources/css/web/shared-styles.css (Web/Hub-Frontend)
*
* Dadurch DRY für Customer-Dashboard (Phase 2), Admin-Dashboard (Phase 3),
* Listen/Detail-Pages (Phase 4) und perspektivisch auch die Hub-Landing.
*
* Tokens kommen aus shared/design-tokens.css diese Datei darf KEINE
* Hex-Literale enthalten (außer wo bewusst alpha-overlays via `rgba`
* gegen White/Black gerechnet werden).
*
* Vorlage: dev/frontend/tailwind_v3/User Dashboard presseportale.html
* Dokumentation: dev/frontend/hub-flux/04-PHASE-2-CUSTOMER-DASHBOARD.md
*/
@layer components {
/* ============================================================
* Panels (Karten-Container mit Hub-Charakter)
* ============================================================ */
.panel {
background: var(--color-bg-card);
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
}
.panel-warm {
background: var(--color-bg-elev);
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
}
.panel-dark {
/* KONSTANTES Dark-Hub-Blau bleibt auch im Dark Mode dunkel
(sonst würde `var(--color-hub)` zum hellen #5a78c2 werden).
Tokens `--color-panel-dark` und `--color-panel-dark-2` sind
in beiden Modi identisch. */
background: var(--color-panel-dark-2);
border: 1px solid var(--color-panel-dark);
border-radius: 6px;
color: var(--color-ink-on-dark);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--color-bg-rule);
}
.panel-dark .panel-head {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
/* ============================================================
* Stat-Cards (KPI-Karten mit farbigem Strip links)
* ============================================================ */
.stat-card {
position: relative;
background: var(--color-bg-card);
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
padding: 18px 20px;
transition:
border-color 0.12s,
box-shadow 0.12s;
}
.stat-card .stat-strip {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--color-hub-soft-2);
border-radius: 6px 0 0 6px;
}
.stat-card.is-primary .stat-strip {
background: var(--color-hub);
}
.stat-card.is-ok .stat-strip {
background: var(--color-ok);
}
.stat-card.is-warn .stat-strip {
background: var(--color-warn);
}
.stat-card.is-muted .stat-strip {
background: var(--color-ink-4);
}
.stat-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--color-ink-3);
}
.stat-card.is-primary .stat-label {
color: var(--color-hub);
}
.stat-card.is-ok .stat-label {
color: var(--color-ok);
}
.stat-card.is-warn .stat-label {
color: var(--color-warn);
}
.stat-card.is-muted .stat-label {
color: var(--color-ink-4);
}
.stat-num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 36px;
font-weight: 600;
color: var(--color-ink);
letter-spacing: -0.5px;
line-height: 1;
margin-top: 14px;
}
.stat-card.is-ok .stat-num {
color: var(--color-ok);
}
.stat-card.is-warn .stat-num {
color: var(--color-warn);
}
.stat-card.is-muted .stat-num {
color: var(--color-ink-3);
}
.stat-meta {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-ink-3);
}
.stat-trend {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
color: var(--color-ink-3);
}
/* ============================================================
* Hint-Cards (Datenqualitäts-Hinweise mit Progress-Bar)
* ============================================================ */
.hint-card {
display: grid;
gap: 14px;
grid-template-columns: auto 1fr;
align-items: start;
/* Im Light Mode warmes Buchpapier-elev; im Dark Mode der wärmere
Card-Ton (`--color-bg-card-warm` schaltet automatisch um). */
background: var(--color-bg-card-warm);
border: 1px solid var(--color-bg-rule);
/* `--color-accent-warm` bleibt KONSTANT Bernstein (Phase 5):
Im Portal mappt --color-accent auf Hub-Blau, hier wollen wir
aber den klassischen Bernstein-Border behalten. */
border-left: 3px solid var(--color-accent-warm);
border-radius: 5px;
padding: 16px 18px;
}
.hint-card .hint-ico {
width: 36px;
height: 36px;
border-radius: 4px;
background: var(--color-accent-soft);
color: var(--color-accent-deep);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hint-card .hint-bar {
margin-top: 8px;
height: 4px;
width: 100%;
border-radius: 999px;
background: var(--color-bg-rule-2);
overflow: hidden;
}
.hint-card .hint-bar > span {
display: block;
height: 100%;
border-radius: 999px;
background: var(--color-accent-warm);
}
.hint-card .hint-action {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
font-size: 11.5px;
font-weight: 600;
color: var(--color-accent-deep);
text-decoration: none;
text-underline-offset: 3px;
}
.hint-card .hint-action:hover {
text-decoration: underline;
text-decoration-color: color-mix(
in oklab,
var(--color-accent-deep),
transparent 60%
);
}
/* ============================================================
* Badges (Status-Pillen)
* ============================================================ */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.badge.warn {
background: var(--color-warn-soft);
color: var(--color-accent-deep);
}
.badge.ok {
background: var(--color-ok-soft);
color: var(--color-gain-deep);
}
.badge.hub {
background: var(--color-hub-soft);
color: var(--color-hub);
}
.badge.err {
background: var(--color-err-soft);
color: var(--color-loss);
}
.badge.dot::before {
content: "";
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
}
/* ============================================================
* Brand-Bridge (presseecho + businessportal24 Indikatoren)
* ============================================================ */
.bridge-row {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-ink-3);
}
.dot-pe {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-bridge-presseecho);
}
.dot-bp {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--color-bridge-businessportal);
}
/* ============================================================
* Section-Eyebrow VARIANTE (für panel-head)
* ============================================================
* Die Basis-`.section-eyebrow` liegt in theme-pressekonto.css.
* Im Portal-Bundle haben wir die Klasse heute NOCH NICHT hier
* eine portable Definition, die im Web-Build vom Original-Layer
* überschrieben wird (gleiche Werte idempotent).
*/
.section-eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--color-hub);
}
.section-eyebrow::after {
content: "";
display: block;
width: 30px;
height: 1px;
background: var(--color-hub);
opacity: 0.45;
}
.section-eyebrow.on-dark {
color: var(--color-hub-line);
}
.section-eyebrow.on-dark::after {
background: var(--color-hub-line);
opacity: 0.55;
}
/* ============================================================
* Eyebrow VARIANTE (für Portal)
* ============================================================
* Basis liegt in theme-pressekonto.css. Hier portabel für Portal-Build.
*/
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-hub);
}
.eyebrow.muted {
color: var(--color-ink-3);
letter-spacing: 0.16em;
font-weight: 600;
font-size: 10.5px;
}
.eyebrow.accent {
color: var(--color-accent-deep);
}
.eyebrow.on-dark {
color: var(--color-hub-line);
}
}

View file

@ -1,14 +1,24 @@
/**
* Gemeinsame Styles für alle Frontend-Themes
* Diese Datei wird von allen Web-Themes importiert
*
* Hub × FluxUI: Seit Phase 0 leben die Design-Tokens (Farben, Fonts,
* Radii, Schatten) zentral in resources/css/shared/design-tokens.css.
* Wir importieren sie hier damit kommen sie automatisch in jedes
* Web-Theme (theme-pressekonto.css, theme-presseecho.css,
* theme-businessportal24.css).
*/
@import "tailwindcss";
@import "../shared/design-tokens.css";
@import "../shared/hub-components.css";
/* Definiere wo Tailwind nach Klassen suchen soll */
@source "../../views/web";
@source "../../views/layouts/web";
@source "../../views/livewire/web";
@source "../../views/components/web";
@source "../../views/livewire/auth";
@source "../../views/components/layouts/auth";
/* Tailwind Base Layer für gemeinsame Elemente */
@layer base {

View file

@ -21,8 +21,8 @@
--color-bg-dark: #15201a;
--color-bg-card-warm: #ecefe3;
--color-bg-card-warm-border: #c7cfb6;
--color-topbar: #1a3d2e; /* moderat heller als #1b2a1f, aber dunkler als #1f4d3a */
--color-topbar2: #122d22; /* analog moderat heller als #25342a */
--color-topbar: #112d20; /* Brand-Manual: satter Forest-Verlauf für die Topbar */
--color-topbar2: #081b13; /* Brand-Manual: tieferer Forest-Endpunkt */
/* Ink */
--color-ink: #1b2417;

View file

@ -0,0 +1,484 @@
/**
* Theme für den Publisher-Hub pressekonto.de (pressekonto.test)
*
* Charakter:
* - Surface: warmes Buchpapier (gleiche Familie wie BP24/Presseecho)
* - Primary: Hub-Blau (#1A2540) seriös, plattform-neutral
* - Accent: gedecktes Bernstein (#B07A3A) bewusst NICHT Orange (BP24) und NICHT Grün (Presseecho)
* - Schrift: Inter Tight + JetBrains Mono
*
* Tokens (Farben, Fonts, Radii, Schatten) leben seit Phase 0 in
* resources/css/shared/design-tokens.css als Single Source of Truth
* sie werden via shared-styles.css design-tokens.css importiert.
*
* Hier nur noch:
* - HSL-Variablen für Legacy-Komponenten (shared-styles)
* - @layer components { } mit Hub-spezifischen Klassen
* (Eyebrows, Hero-Grid, Auth-Felder, FAQ-Accordion, )
*
* Dokumentation: dev/frontend/hub-flux/01-PHASE-0-TOKENS.md
*/
@import "./shared-styles.css";
/* HSL-Variablen für Legacy-Komponenten (shared-styles) */
:root {
--font-primary: var(--font-sans);
--font-secondary: var(--font-sans);
--background: 40 30% 95%; /* #f6f4ef */
--foreground: 144 8% 11%;
--card: 0 0% 100%;
--card-foreground: 144 8% 11%;
--popover: 0 0% 100%;
--popover-foreground: 144 8% 11%;
/* Primary: #1A2540 -> hsl(222, 43%, 18%) */
--primary: 222 43% 18%;
--primary-foreground: 0 0% 100%;
--primary-50: 222 43% 96%;
--primary-100: 222 43% 92%;
--primary-200: 222 43% 85%;
--primary-300: 222 43% 75%;
--primary-400: 222 43% 60%;
--primary-500: 222 43% 45%;
--primary-600: 222 43% 36%;
--primary-700: 222 43% 28%;
--primary-800: 222 43% 22%;
--primary-900: 222 43% 18%;
--primary-950: 222 43% 12%;
--secondary: 224 30% 30%;
--secondary-foreground: 0 0% 100%;
--muted: 40 18% 90%;
--muted-foreground: 144 6% 38%;
--accent: 31 51% 46%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 38 23% 85%;
--input: 38 23% 85%;
--ring: 222 43% 18%;
--radius: 4px;
--shadow-card: 0 1px 2px 0 rgb(26 37 64 / 0.06);
--shadow-card-hover: 0 4px 12px -2px rgb(26 37 64 / 0.12);
}
@layer base {
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: #e8e4da;
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
h1,
h2,
h3,
h4 {
font-family: var(--font-sans);
color: inherit;
letter-spacing: -0.01em;
}
.font-mono {
font-family: var(--font-mono);
font-feature-settings: "tnum";
}
}
@layer components {
/* Hub-Eyebrow — sperrgesetzt, in Hub-Blau */
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-hub);
}
.eyebrow.muted {
color: var(--color-ink-3);
letter-spacing: 0.16em;
font-weight: 600;
font-size: 10.5px;
}
.eyebrow.accent {
color: var(--color-accent-deep);
}
.eyebrow.on-dark {
color: var(--color-hub-line);
}
/* Section-Eyebrow mit kurzem Linien-Schwanz */
.section-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--color-hub);
}
.section-eyebrow::after {
content: "";
display: block;
width: 42px;
height: 1px;
background: var(--color-hub);
opacity: 0.45;
}
.section-eyebrow.on-dark {
color: var(--color-hub-line);
}
.section-eyebrow.on-dark::after {
background: var(--color-hub-line);
opacity: 0.55;
}
.rule {
height: 1px;
background: var(--color-bg-rule);
border: 0;
margin: 0;
}
.rule-strong {
height: 1px;
background: var(--color-bg-rule-strong);
border: 0;
margin: 0;
}
.rule-hub {
height: 2px;
background: var(--color-hub);
border: 0;
margin: 0;
}
/* Subtile geometrische Hintergrundlinien fürs Hero */
.hero-grid {
background-image:
linear-gradient(
to right,
rgba(26, 37, 64, 0.04) 1px,
transparent 1px
),
linear-gradient(
to bottom,
rgba(26, 37, 64, 0.04) 1px,
transparent 1px
);
background-size: 48px 48px;
background-position: -1px -1px;
}
/* Hub-Gradient-Klassen */
.bg-hub-grad {
background-image: linear-gradient(
135deg,
var(--color-hub) 0%,
var(--color-hub-2) 100%
);
}
.bg-hub-grad-2 {
background-image: linear-gradient(
180deg,
var(--color-hub) 0%,
var(--color-topbar-deep) 100%
);
}
.bg-accent-grad {
background-image: linear-gradient(
135deg,
var(--color-accent) 0%,
var(--color-accent-deep) 100%
);
}
.bg-topbar-grad {
background-image: linear-gradient(
135deg,
var(--color-topbar) 0%,
var(--color-topbar2) 100%
);
}
/* Empfohlen-Ribbon auf Tarif-Karten */
.ribbon-recommend {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
background: var(--color-hub);
color: #fff;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
padding: 8px 0;
}
/* Accordion-Pfeil (FAQ) */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker {
display: none;
}
details[open] .faq-chev {
transform: rotate(180deg);
}
.faq-chev {
transition: transform 0.2s ease;
}
/*
* Auth-Seiten (Anmelden, Registrieren, Passwort zurücksetzen, )
* Konsistente Felder, Buttons und Atmosphäre für den Publisher-Hub
*/
/* Hintergrund-Raster (Atmosphäre) */
.auth-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(
to right,
rgba(26, 37, 64, 0.045) 1px,
transparent 1px
),
linear-gradient(
to bottom,
rgba(26, 37, 64, 0.045) 1px,
transparent 1px
);
background-size: 56px 56px;
pointer-events: none;
}
/* From=portal Banner über der Auth-Card */
.from-banner {
background: #ffffff;
border: 1px solid var(--color-bg-rule);
border-left: 2px solid var(--color-hub);
border-radius: 4px;
padding: 8px 14px;
font-size: 12px;
color: var(--color-ink-2);
}
/* Auth-Card mit weicher, fokussierender Schatten-Glocke */
.auth-card {
background: var(--color-bg-card);
border: 1px solid var(--color-bg-rule);
border-radius: 8px;
padding: 34px 36px 32px;
box-shadow:
0 1px 0 rgba(26, 31, 28, 0.03),
0 20px 50px -32px rgba(26, 37, 64, 0.28);
}
/* Eyebrow & Link in Hub-Blau für Auth-Kontext */
.eyebrow-hub {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--color-hub);
}
.link-hub {
color: var(--color-hub);
font-weight: 600;
text-decoration: none;
text-underline-offset: 3px;
}
.link-hub:hover {
text-decoration: underline;
}
/* Input-Felder */
.field-label {
display: block;
font-size: 12.5px;
font-weight: 600;
color: var(--color-ink);
margin-bottom: 6px;
}
.field-input {
width: 100%;
background: #ffffff;
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
padding: 11px 13px;
font-size: 14px;
color: var(--color-ink);
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
font-family: inherit;
}
.field-input::placeholder {
color: var(--color-ink-4);
}
.field-input:hover {
border-color: #cfc8b5;
}
.field-input:focus {
border-color: var(--color-hub);
box-shadow: 0 0 0 3px rgba(26, 37, 64, 0.1);
}
.field-input[aria-invalid="true"],
.field-input.is-invalid {
border-color: var(--color-loss);
}
/* Passwort-Feld mit „Anzeigen“-Button */
.field-pw-wrap {
position: relative;
}
.field-affix {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
color: var(--color-ink-3);
font-size: 11.5px;
font-weight: 600;
background: transparent;
border: 0;
cursor: pointer;
padding: 4px 6px;
border-radius: 3px;
font-family: inherit;
}
.field-affix:hover {
color: var(--color-hub);
background: var(--color-bg);
}
/* Checkbox */
.auth-check {
appearance: none;
-webkit-appearance: none;
width: 16px;
height: 16px;
border: 1.5px solid #cfc8b5;
border-radius: 3px;
background: #ffffff;
cursor: pointer;
position: relative;
flex-shrink: 0;
transition:
border-color 0.12s,
background 0.12s;
margin: 0;
}
.auth-check:hover {
border-color: var(--color-hub);
}
.auth-check:checked {
background: var(--color-hub);
border-color: var(--color-hub);
}
.auth-check:checked::before {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 1.8px 1.8px 0;
transform: rotate(42deg);
}
/* Buttons (auth-spezifisch, um den allgemeinen .btn-primary nicht zu überschreiben) */
.auth-btn-primary {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 16px;
background: var(--color-hub);
color: #ffffff;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
border: 0;
cursor: pointer;
transition: background-color 0.15s;
font-family: inherit;
}
.auth-btn-primary:hover {
background: var(--color-hub-2);
}
.auth-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-btn-outline {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: #ffffff;
color: var(--color-ink);
border: 1px solid var(--color-bg-rule);
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.15s,
border-color 0.15s;
font-family: inherit;
}
.auth-btn-outline:hover {
background: var(--color-bg-elev);
border-color: #cfc8b5;
}
/* Validierungs-Fehler-Text */
.field-error {
margin-top: 6px;
color: var(--color-loss);
font-size: 12px;
line-height: 1.4;
}
/* Erfolgs-/Status-Mitteilung */
.field-status {
background: #f1f6ef;
border: 1px solid #c8dec0;
color: #2e5b32;
padding: 10px 14px;
border-radius: 4px;
font-size: 12.5px;
line-height: 1.5;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
[x-cloak] {
display: none !important;
}

View file

@ -1,319 +0,0 @@
/**
* Theme für den Publisher-Hub presseportale.com (presseportale.test)
*
* Eigener Charakter zwischen den beiden Brand-Portalen:
* - Surface: warmes Buchpapier (gleiche Familie wie BP24/Presseecho)
* - Primary: Hub-Blau (#1A2540) seriös, plattform-neutral
* - Accent: gedecktes Bernstein (#B07A3A) bewusst NICHT Orange (BP24) und NICHT Grün (Presseecho)
* - Schrift: Inter Tight + JetBrains Mono, OHNE Source Serif (Hub-Bezug, weniger editorial-redaktionell als die Brand-Portale)
*
* Token-Namen folgen wo möglich der BP24/Presseecho-Konvention (bg, bg-elev, bg-rule, ink-*),
* sodass viele Utility-Klassen identisch greifen. Hub-spezifische Tokens
* (hub, hub-2, hub-soft, accent-soft, hub-line) sind zusätzlich definiert.
*/
@import "./shared-styles.css";
@theme {
/* Surfaces — warmes Buchpapier, gleiche Familie wie die Brand-Portale */
--color-bg: #f6f4ef;
--color-bg-elev: #fbfaf6;
--color-bg-rule: #e2ddd0;
--color-bg-rule-strong: #1a1f1c;
--color-bg-dark: #15201a;
--color-bg-card: #ffffff;
--color-bg-card-warm: #efeadc;
--color-bg-card-warm-border: #d6cfbb;
/* Hub-Blau — Primary, plattform-neutral */
--color-hub: #1a2540;
--color-hub-2: #243152;
--color-hub-3: #2e3d66;
--color-hub-soft: #e5e9f1;
--color-hub-soft-2: #cfd6e4;
--color-hub-line: #7b8fbf;
/* Topbar (Alias für Hub-Gradient, damit shared base-Klassen passen) */
--color-topbar: #1a2540;
--color-topbar2: #243152;
--color-topbar-deep: #0f1729;
/* Akzent — gedecktes Bernstein (zwischen Orange und Beige) */
--color-accent: #b07a3a;
--color-accent-deep: #8a5e27;
--color-accent-soft: #f1e6d3;
--color-accent-warm: #b07a3a;
/* Ink — Anthrazit-Reihe */
--color-ink: #1a1f1c;
--color-ink-2: #3a413d;
--color-ink-3: #5a6360;
--color-ink-4: #8a918d;
--color-ink-on-dark: #f6f4ef;
--color-ink-on-dark-2: #b2b9c7;
--color-ink-on-dark-3: #7b8fbf;
--color-ink-on-dark-muted: #7b8fbf;
--color-ink-on-dark-rule: #2a3550;
/* Brand-Aliase, damit Komponenten, die brand-Tokens verwenden, funktionieren */
--color-brand: #1a2540;
--color-brand-deep: #0f1729;
--color-brand-soft: #e5e9f1;
--color-live: #c84a1e;
--color-gain: #2e8540;
--color-loss: #c8341e;
--color-ok: #2e8540;
/* Editorial-Akzente (für card-warm-Sektionen) */
--color-bg-card-warm-hover: #e6deca;
--color-bg-card-warm-rule: #c8bda3;
--color-card-warm-cat: #5a6360;
--color-card-warm-title: #2a302d;
--color-feature-line: #c0c8db;
--color-feature-dot: #d8dde9;
/* Fonts Hub: Inter Tight + JetBrains Mono.
Source Serif 4 wird zusätzlich geladen, damit Markennennungen der
Tochter-Portale (presseecho, businessportal24) typografisch konsistent
erscheinen im Hub-Standardtext kommt sie aber nicht zum Einsatz. */
--font-sans: "Inter Tight", Inter, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-serif: "Source Serif 4", "Source Serif Pro", Charter,
"Iowan Old Style", Georgia, serif;
--font-mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular,
Menlo, Consolas, monospace;
/* Layout */
--container-layout: 1280px;
}
/* HSL-Variablen für Legacy-Komponenten (shared-styles) */
:root {
--font-primary: var(--font-sans);
--font-secondary: var(--font-sans);
--background: 40 30% 95%; /* #f6f4ef */
--foreground: 144 8% 11%;
--card: 0 0% 100%;
--card-foreground: 144 8% 11%;
--popover: 0 0% 100%;
--popover-foreground: 144 8% 11%;
/* Primary: #1A2540 -> hsl(222, 43%, 18%) */
--primary: 222 43% 18%;
--primary-foreground: 0 0% 100%;
--primary-50: 222 43% 96%;
--primary-100: 222 43% 92%;
--primary-200: 222 43% 85%;
--primary-300: 222 43% 75%;
--primary-400: 222 43% 60%;
--primary-500: 222 43% 45%;
--primary-600: 222 43% 36%;
--primary-700: 222 43% 28%;
--primary-800: 222 43% 22%;
--primary-900: 222 43% 18%;
--primary-950: 222 43% 12%;
--secondary: 224 30% 30%;
--secondary-foreground: 0 0% 100%;
--muted: 40 18% 90%;
--muted-foreground: 144 6% 38%;
--accent: 31 51% 46%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 38 23% 85%;
--input: 38 23% 85%;
--ring: 222 43% 18%;
--radius: 4px;
--shadow-card: 0 1px 2px 0 rgb(26 37 64 / 0.06);
--shadow-card-hover: 0 4px 12px -2px rgb(26 37 64 / 0.12);
}
@layer base {
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: #e8e4da;
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
h1,
h2,
h3,
h4 {
font-family: var(--font-sans);
color: inherit;
letter-spacing: -0.01em;
}
.font-mono {
font-family: var(--font-mono);
font-feature-settings: "tnum";
}
}
@layer components {
/* Hub-Eyebrow — sperrgesetzt, in Hub-Blau */
.eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-hub);
}
.eyebrow.muted {
color: var(--color-ink-3);
letter-spacing: 0.16em;
font-weight: 600;
font-size: 10.5px;
}
.eyebrow.accent {
color: var(--color-accent-deep);
}
.eyebrow.on-dark {
color: var(--color-hub-line);
}
/* Section-Eyebrow mit kurzem Linien-Schwanz */
.section-eyebrow {
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--color-hub);
}
.section-eyebrow::after {
content: "";
display: block;
width: 42px;
height: 1px;
background: var(--color-hub);
opacity: 0.45;
}
.section-eyebrow.on-dark {
color: var(--color-hub-line);
}
.section-eyebrow.on-dark::after {
background: var(--color-hub-line);
opacity: 0.55;
}
.rule {
height: 1px;
background: var(--color-bg-rule);
border: 0;
margin: 0;
}
.rule-strong {
height: 1px;
background: var(--color-bg-rule-strong);
border: 0;
margin: 0;
}
.rule-hub {
height: 2px;
background: var(--color-hub);
border: 0;
margin: 0;
}
/* Subtile geometrische Hintergrundlinien fürs Hero */
.hero-grid {
background-image:
linear-gradient(
to right,
rgba(26, 37, 64, 0.04) 1px,
transparent 1px
),
linear-gradient(
to bottom,
rgba(26, 37, 64, 0.04) 1px,
transparent 1px
);
background-size: 48px 48px;
background-position: -1px -1px;
}
/* Hub-Gradient-Klassen */
.bg-hub-grad {
background-image: linear-gradient(
135deg,
var(--color-hub) 0%,
var(--color-hub-2) 100%
);
}
.bg-hub-grad-2 {
background-image: linear-gradient(
180deg,
var(--color-hub) 0%,
var(--color-topbar-deep) 100%
);
}
.bg-accent-grad {
background-image: linear-gradient(
135deg,
var(--color-accent) 0%,
var(--color-accent-deep) 100%
);
}
.bg-topbar-grad {
background-image: linear-gradient(
135deg,
var(--color-topbar) 0%,
var(--color-topbar2) 100%
);
}
/* Empfohlen-Ribbon auf Tarif-Karten */
.ribbon-recommend {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
background: var(--color-hub);
color: #fff;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
padding: 8px 0;
}
/* Accordion-Pfeil (FAQ) */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker {
display: none;
}
details[open] .faq-chev {
transform: rotate(180deg);
}
.faq-chev {
transition: transform 0.2s ease;
}
}
@layer utilities {
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
[x-cloak] {
display: none !important;
}

View file

@ -1,8 +1,8 @@
# Backend-Status: presseportale.test
# Backend-Status: pressekonto.test
**Projekt:** BusinessPortal24 → Laravel 12 Migration
**Domain:** presseportale.test
**Stand:** 23. Januar 2026 nach Server-Neustart
**Projekt:** BusinessPortal24 → Laravel 12 Migration
**Domain:** pressekonto.test
**Stand:** 23. Januar 2026 nach Server-Neustart
**Status:** 🟡 Admin-UI-Gerüst vorhanden, Routing auf bestehende Volt-Komponenten konsolidiert
---
@ -274,7 +274,7 @@ Beginne mit der eigentlichen Migration gemäß:
### Option 3: Backend testen 🟢
- Dev-Server starten (`npm run dev`)
- Backend öffnen (`http://presseportale.test/admin/press-releases`)
- Backend öffnen (`http://pressekonto.test/admin/press-releases`)
- UI und Navigation prüfen
### Option 4: Dummy-Daten verfeinern 🟢
@ -294,14 +294,14 @@ npm run dev:portal
php artisan serve
# Backend öffnen
http://presseportale.test/admin/press-releases
http://presseportale.test/admin/companies
http://presseportale.test/admin/invoices
http://presseportale.test/admin/contacts
http://presseportale.test/admin/payments
http://presseportale.test/admin/categories
http://presseportale.test/admin/coupons
http://presseportale.test/admin/roles
http://pressekonto.test/admin/press-releases
http://pressekonto.test/admin/companies
http://pressekonto.test/admin/invoices
http://pressekonto.test/admin/contacts
http://pressekonto.test/admin/payments
http://pressekonto.test/admin/categories
http://pressekonto.test/admin/coupons
http://pressekonto.test/admin/roles
```
---
@ -327,7 +327,7 @@ http://presseportale.test/admin/roles
## 🎉 Meilenstein erreicht!
**Backend-Struktur für presseportale.test ist als Gerüst weitgehend vorbereitet.**
**Backend-Struktur für pressekonto.test ist als Gerüst weitgehend vorbereitet.**
- ✅ 7 Hauptbereiche mit Navigation
- ✅ 24 Routes definiert (konsistent gemappt)

View file

@ -1,7 +1,7 @@
# Flux UI v2 - Komponenten-Referenz
**Projekt:** presseportale.test Backend
**Flux Version:** 2.x
**Projekt:** pressekonto.test Backend
**Flux Version:** 2.x
**Stand:** 23. Januar 2026
---

View file

@ -176,7 +176,7 @@ php artisan serve
npm run dev
# Backend öffnen:
http://presseportale.test/admin/press-releases
http://pressekonto.test/admin/press-releases
```
## 📚 Weitere Ressourcen

View file

@ -1,110 +1,217 @@
<x-layouts.app title="Dashboard">
<div class="space-y-6">
{{-- Statistik-Karten --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<a href="{{ route('admin.press-releases.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('PMs gesamt') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['press_releases']['total']) }}</p>
<div class="mt-2 flex gap-2 text-xs text-zinc-400">
<span class="text-green-600">{{ $stats['press_releases']['published'] }} pub</span>
<span class="text-yellow-600">{{ $stats['press_releases']['review'] }} prüf</span>
<span>{{ $stats['press_releases']['draft'] }} entwurf</span>
</div>
</div>
</a>
<div class="space-y-8">
<a href="{{ route('admin.companies.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Firmen') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['companies']) }}</p>
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Operations · A · 01') }}</span>
</div>
</a>
<a href="{{ route('admin.contacts.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Kontakte') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['contacts']) }}</p>
</div>
</a>
<a href="{{ route('admin.users.index') }}" wire:navigate class="block">
<div class="rounded-xl border border-zinc-200 bg-white p-4 transition hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Benutzer') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['users']) }}</p>
</div>
</a>
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<p class="text-xs text-zinc-500">{{ __('Newsletter') }}</p>
<p class="mt-1 text-2xl font-bold">{{ number_format($stats['newsletter']) }}</p>
<p class="mt-2 text-xs text-zinc-400">{{ __('bestätigt') }}</p>
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
{{ __('Admin Dashboard') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Willkommen zurück, ') }}<strong class="font-semibold text-[color:var(--color-ink)]">{{ auth()->user()->name }}</strong>.
{{ __('Operations-Übersicht über Pressemitteilungen, Firmen und Konten beider Portale.') }}
</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-[1fr,360px]">
<div class="flex items-center gap-3 flex-shrink-0">
<span class="badge ok dot">{{ __('Alle Systeme operational') }}</span>
</div>
</header>
{{-- ============== KPI-Reihe (5 Stat-Cards) ============== --}}
<section class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
<a href="{{ route('admin.press-releases.index') }}" wire:navigate class="block focus:outline-none">
<x-portal.stat-card variant="primary" :label="__('Pressemitteilungen')" :value="number_format($stats['press_releases']['total'])">
<x-slot:meta>{{ now()->format('Y') }}</x-slot:meta>
{{-- WICHTIG: Wortlaut „X pub · Y prüf · Z entwurf" exakt beibehalten,
weil DashboardTest darauf assertet (1 pub, 1 prüf, 1 entwurf). --}}
<x-slot:trend>
<span class="flex items-center gap-3">
<span class="text-[color:var(--color-ok)]">{{ $stats['press_releases']['published'] }} pub</span>
<span class="text-[color:var(--color-warn)]">{{ $stats['press_releases']['review'] }} prüf</span>
<span>{{ $stats['press_releases']['draft'] }} entwurf</span>
</span>
</x-slot:trend>
</x-portal.stat-card>
</a>
<a href="{{ route('admin.press-releases.index', ['status' => 'review']) }}" wire:navigate class="block focus:outline-none">
<x-portal.stat-card variant="warn" :label="__('In Prüfung')" :value="$stats['press_releases']['review']">
<x-slot:meta>{{ __('queue') }}</x-slot:meta>
<x-slot:trend>{{ __('warten auf Review') }}</x-slot:trend>
</x-portal.stat-card>
</a>
<a href="{{ route('admin.companies.index') }}" wire:navigate class="block focus:outline-none">
<x-portal.stat-card variant="muted" :label="__('Firmen')" :value="number_format($stats['companies'])">
<x-slot:trend>{{ __('aktiv im CRM') }}</x-slot:trend>
</x-portal.stat-card>
</a>
<a href="{{ route('admin.contacts.index') }}" wire:navigate class="block focus:outline-none">
<x-portal.stat-card variant="muted" :label="__('Kontakte')" :value="number_format($stats['contacts'])">
<x-slot:trend>{{ __('Pressekontakte') }}</x-slot:trend>
</x-portal.stat-card>
</a>
<a href="{{ route('admin.users.index') }}" wire:navigate class="block focus:outline-none">
<x-portal.stat-card variant="muted" :label="__('Benutzer')" :value="number_format($stats['users'])">
<x-slot:trend>{{ __('Portal-Konten') }}</x-slot:trend>
</x-portal.stat-card>
</a>
</section>
{{-- ============== ZWEISPALTEN-GRID ============== --}}
<section class="grid gap-6 lg:grid-cols-[2fr_1fr]">
{{-- Letzte Pressemitteilungen --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<h2 class="font-semibold">{{ __('Letzte Pressemitteilungen') }}</h2>
<a href="{{ route('admin.press-releases.index') }}" wire:navigate class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ __('Alle anzeigen') }}</a>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Letzte Pressemitteilungen') }}</span>
<a href="{{ route('admin.press-releases.index') }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Alle anzeigen') }}
</a>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($recentPRs as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">{{ $pr->company?->name ?? '' }} · {{ $pr->created_at->format('d.m.Y') }}</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {{ match($pr->status->value) {
'published' => 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
'review' => 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
'rejected' => 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
'archived' => 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
default => 'bg-zinc-100 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300',
} }}">
{{ $pr->status->label() }}
</span>
</a>
@empty
<p class="px-4 py-6 text-center text-sm text-zinc-500">{{ __('Noch keine Pressemitteilungen.') }}</p>
@endforelse
</div>
</div>
@forelse ($recentPRs as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-b-0 hover:bg-[color:var(--color-bg)] transition-colors">
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">{{ $pr->title }}</p>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5 m-0 truncate">
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
{{ $pr->user?->name ?? '' }}
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
{{ $pr->created_at->format('d.m.Y') }}
</p>
</div>
<span @class([
'badge shrink-0',
'ok' => $pr->status->value === 'published',
'warn' => $pr->status->value === 'review',
'err' => $pr->status->value === 'rejected',
'hub' => in_array($pr->status->value, ['archived', 'draft'], true),
])>
{{ $pr->status->label() }}
</span>
</a>
@empty
<p class="px-5 py-8 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Pressemitteilungen.') }}
</p>
@endforelse
</article>
{{-- Warteschlange Prüfung --}}
<div class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<h2 class="font-semibold">{{ __('Zur Prüfung') }}</h2>
@if($stats['press_releases']['review'] > 0)
<span class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
{{ $stats['press_releases']['review'] }}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zur Prüfung') }}</span>
@if ($stats['press_releases']['review'] > 0)
<span class="badge warn dot">
{{ $stats['press_releases']['review'] }} {{ __('offen') }}
</span>
@else
<span class="badge ok dot">{{ __('leer') }}</span>
@endif
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($pendingReviews as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="block px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">
{{ $pr->company?->name ?? '' }} · {{ $pr->portal->label() }} · {{ $pr->created_at->format('d.m.Y') }}
</p>
</a>
@empty
<p class="px-4 py-6 text-center text-sm text-zinc-500">{{ __('Keine PMs in der Prüfwarteschlange.') }}</p>
@endforelse
</div>
@if($stats['press_releases']['review'] > count($pendingReviews))
<div class="border-t border-zinc-100 px-4 py-2 dark:border-zinc-800">
<a href="{{ route('admin.press-releases.index', ['statusFilter' => 'review']) }}" wire:navigate
class="text-xs text-blue-600 hover:underline dark:text-blue-400">
+ {{ $stats['press_releases']['review'] - count($pendingReviews) }} {{ __('weitere') }}
@forelse ($pendingReviews as $pr)
<a href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate
class="block px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-b-0 hover:bg-[color:var(--color-bg)] transition-colors">
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">{{ $pr->title }}</p>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5 m-0 truncate">
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
{{ $pr->portal->label() }}
<span class="text-[color:var(--color-ink-4)] mx-1">·</span>
{{ $pr->created_at->format('d.m.Y') }}
</p>
</a>
@empty
<p class="px-5 py-8 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Keine PMs in der Prüfwarteschlange.') }}
</p>
@endforelse
@if ($stats['press_releases']['review'] > count($pendingReviews))
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]">
<a href="{{ route('admin.press-releases.index', ['status' => 'review']) }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
+ {{ $stats['press_releases']['review'] - count($pendingReviews) }} {{ __('weitere') }}
</a>
</div>
@endif
</div>
</div>
</article>
</section>
{{-- ============== NEWSLETTER + QUICK ACTIONS ============== --}}
<section class="grid gap-6 lg:grid-cols-[1fr_2fr]">
{{-- Newsletter-Stat als panel-warm Block --}}
<article class="panel-warm relative overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Newsletter') }}</span>
<span class="badge hub">{{ __('bestätigt') }}</span>
</div>
<div class="px-5 py-5">
<div class="stat-num text-[color:var(--color-ink)]">
{{ number_format($stats['newsletter']) }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-2 m-0">
{{ __('Aktive Newsletter-Abonnenten über beide Portale.') }}
</p>
<a href="{{ route('admin.newsletter.sync') }}" wire:navigate
class="inline-flex items-center gap-1 mt-3 text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Sync verwalten') }}
</a>
</div>
</article>
{{-- Quick-Actions Panel --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Quick Actions') }}</span>
</div>
<div class="grid gap-3 p-5 grid-cols-2 md:grid-cols-4">
@foreach ([
['icon' => 'newspaper', 'label' => __('Pressemitteilungen'), 'route' => 'admin.press-releases.index'],
['icon' => 'building-office', 'label' => __('Firmen'), 'route' => 'admin.companies.index'],
['icon' => 'document-text', 'label' => __('Rechnungen'), 'route' => 'admin.invoices.index'],
['icon' => 'cog', 'label' => __('Voreinstellungen'), 'route' => 'admin.presets.index'],
] as $action)
<a href="{{ route($action['route']) }}" wire:navigate
class="group flex flex-col items-start gap-2 p-4 rounded-[5px] transition-colors
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]
hover:border-[color:var(--color-hub)]/40 hover:bg-[color:var(--color-bg)]">
<span class="w-9 h-9 rounded-[4px] flex items-center justify-center
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)]
text-[color:var(--color-hub)] group-hover:bg-[color:var(--color-hub)] group-hover:text-white transition-colors">
<flux:icon :name="$action['icon']" class="size-[18px]" />
</span>
<span class="text-[12px] font-semibold text-[color:var(--color-ink-2)] leading-tight">
{{ $action['label'] }}
</span>
</a>
@endforeach
</div>
</article>
</section>
{{-- ============== FOOTER ============== --}}
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px]
border-t border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
<span>© {{ now()->format('Y') }} pressekonto.de · Admin Backend</span>
<span class="flex items-center gap-5">
<a href="{{ route('admin.users.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Benutzer') }}</a>
<a href="{{ route('admin.roles.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Rollen & Rechte') }}</a>
<a href="{{ route('admin.reports.slow-requests') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Performance') }}</a>
</span>
</footer>
</div>
</x-layouts.app>

View file

@ -6,18 +6,23 @@
@endphp
@if($canCustomer)
<div class="mb-6 rounded-xl border border-zinc-200 bg-zinc-50/80 px-4 py-3 shadow-sm ring-1 ring-zinc-950/5 dark:border-zinc-700 dark:bg-zinc-900/60 dark:ring-white/10">
{{-- Hub-Stil-Banner: Hub-Soft-Hintergrund, Hub-Blau-Badge,
dezente Buchpapier-Rule. Ersetzt das Zinc-Starter-Kit-Card. --}}
<div class="mb-6 rounded-md border border-hub-soft-2 bg-hub-soft/50 px-4 py-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<flux:badge color="zinc" size="sm">{{ __('User Backend') }}</flux:badge>
<flux:text class="hidden text-xs text-zinc-500 dark:text-zinc-400 sm:block">
<div class="flex items-center gap-2.5">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-hub-soft text-hub text-[10.5px] font-bold tracking-[0.10em] uppercase">
<span class="w-1.5 h-1.5 rounded-full bg-hub"></span>
{{ __('User Backend') }}
</span>
<span class="hidden sm:inline-block text-[10.5px] font-semibold tracking-[0.16em] uppercase text-ink-3">
{{ __('Firmenkontext') }}
</flux:text>
</span>
</div>
<flux:heading size="sm" class="mt-1 truncate">
<h2 class="mt-1 text-[15px] font-semibold tracking-[-0.2px] truncate text-ink m-0">
{{ $title ?? __('Mein Bereich') }}
</flux:heading>
</h2>
</div>
<div class="w-full sm:w-auto">

View file

@ -1,13 +1,25 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
{{--
Hub × FluxUI Phase 1 Portal-Shell im Hub-Design.
class="dark" wurde entfernt; Light Mode ist Default, Dark kommt mit
FluxUI Appearance-Switcher in Phase 5.
--}}
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<body class="min-h-screen bg-bg text-ink antialiased">
<flux:sidebar sticky stashable class="border-e border-bg-rule">
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
<a href="{{ config('domains.domain_main_url') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse">
<x-app-logo />
{{-- Brand-Block: Wortmarke + Hub-Eyebrow --}}
<a href="{{ config('domains.domain_main_url') }}" class="block px-2 pt-1 pb-3 no-underline">
<span class="text-[19px] font-bold tracking-[-0.4px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
<div class="mt-1.5 text-[10px] font-semibold tracking-[0.18em] uppercase text-ink-3">
Publisher · Hub
</div>
</a>
@php
@ -158,43 +170,42 @@
@endauth
@if($impersonator)
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-950 dark:border-amber-700/60 dark:bg-amber-950/40 dark:text-amber-100">
<flux:text weight="semibold">{{ __('Testmodus aktiv') }}</flux:text>
<flux:text class="mt-1 text-xs">
{{ __('Angemeldet als :user. Admin: :admin.', ['user' => $user?->name, 'admin' => $impersonator->name]) }}
</flux:text>
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
@csrf
<flux:button type="submit" size="sm" variant="primary" class="w-full">
{{ __('Zurück zum Admin') }}
</flux:button>
</form>
{{-- Testmodus-Block im Hub-Stil (statt Amber-Warnfarbe).
Dunkles Hub-Blau-Panel mit Bernstein-Eyebrow, klare
CTA „Zurück zum Admin" als helle Pille. --}}
<div class="mt-3 relative overflow-hidden rounded-[5px] bg-hub p-4 text-ink-on-dark">
<div class="absolute -top-6 -right-6 w-16 h-16 rounded-full bg-hub-3 opacity-50"></div>
<div class="absolute -bottom-8 -left-8 w-20 h-20 rounded-full bg-hub-3 opacity-30"></div>
<div class="relative">
<div class="flex items-center gap-2 mb-2">
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
<span class="text-[10.5px] font-bold tracking-[0.20em] uppercase text-accent-soft">
{{ __('Testmodus aktiv') }}
</span>
</div>
<p class="text-[12px] leading-[1.5] text-ink-on-dark-2 m-0">
{{ __('Angemeldet als') }}
<strong class="text-white font-semibold">{{ $user?->name }}</strong>.<br/>
{{ __('Admin:') }}
<strong class="text-white font-semibold">{{ $impersonator->name }}</strong>
</p>
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="mt-3">
@csrf
<button
type="submit"
class="w-full px-3 py-2 bg-white text-hub text-[12px] font-semibold rounded-[3px] hover:bg-bg transition-colors flex items-center justify-center gap-1.5"
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M9 3L3 9M3 9H8M3 9V4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ __('Zurück zum Admin') }}
</button>
</form>
</div>
</div>
@endif
<flux:spacer />
<flux:navlist variant="outline">
<flux:navlist.group :heading="__('Resources')">
<flux:navlist.item icon="pencil" href="https://tailwindcss.com/docs/installation/using-vite" target="_blank">
{{ __('Tailwind CSS') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" href="https://heroicons.com" target="_blank">
{{ __('Hero Icons') }}
</flux:navlist.item>
<flux:navlist.item icon="bolt" href="https://fluxui.dev/docs/installation" target="_blank">
{{ __('Flux UI') }}
</flux:navlist.item>
<flux:navlist.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:navlist.item>
<flux:navlist.item icon="book-open-text" href="https://laravel.com/docs/starter-kits" target="_blank">
{{ __('Documentation') }}
</flux:navlist.item>
</flux:navlist.group>
</flux:navlist>
<!-- Desktop User Menu -->
<flux:dropdown position="bottom" align="start">
<flux:profile
@ -231,6 +242,22 @@
<flux:menu.separator />
{{-- Phase 5: Appearance-Switcher direkt im User-Menü.
`$flux.appearance` ist FluxUIs Magic-Object, persistent
über LocalStorage. Werte: 'light' | 'dark' | 'system'. --}}
<div class="px-3 py-2">
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
@ -245,8 +272,10 @@
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-center space-x-2 rtl:space-x-reverse">
<x-app-logo />
<a href="{{ config('domains.domain_main_url') }}" class="me-5 ml-2 flex items-baseline no-underline">
<span class="text-[16px] font-bold tracking-[-0.3px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
</a>
<flux:spacer />
@ -284,6 +313,20 @@
<flux:menu.separator />
{{-- Phase 5: Appearance-Switcher (Mobile-Dropdown). --}}
<div class="px-3 py-2">
<div class="mb-1.5 text-[10px] font-semibold tracking-[0.16em] uppercase text-[color:var(--color-ink-3)]">
{{ __('Erscheinung') }}
</div>
<flux:radio.group x-data variant="segmented" size="sm" x-model="$flux.appearance" class="w-full">
<flux:radio value="light" icon="sun" :title="__('Hell')" />
<flux:radio value="dark" icon="moon" :title="__('Dunkel')" />
<flux:radio value="system" icon="computer-desktop" :title="__('System')" />
</flux:radio.group>
</div>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">

View file

@ -0,0 +1,154 @@
@props([
'title' => null,
'eyebrow' => 'Publisher-Hub',
'heading' => null,
'topRightLabel' => null,
'topRightLinkText' => null,
'topRightLinkHref' => null,
'showFromBanner' => true,
])
@php
$brand = config('domains.domains.pressekonto.brand', []);
$from = request()->query('from');
$brandLabelMap = [
'presseecho' => 'presseecho.de',
'businessportal24' => 'businessportal24.com',
];
$fromBrandLabel = $brandLabelMap[$from] ?? null;
$pageTitle = $title ?? ($brand['meta_title'] ?? 'pressekonto Publisher-Hub');
config([
'app.theme' => 'pressekonto',
'app.view_prefix' => 'web',
]);
$themeCssPath = \App\Helpers\ThemeHelper::getThemeCssPath();
$assetsDir = config('domains.domains.pressekonto.assets_dir', 'build/web');
\Illuminate\Support\Facades\Vite::useBuildDirectory($assetsDir);
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
<title>{{ $pageTitle }}</title>
<link rel="icon" href="{{ asset(\App\Helpers\ThemeHelper::getFaviconPath()) }}" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
{{-- Nur CSS aus dem Web-Build laden. Alpine bringt @livewireScripts mit;
würden wir hier zusätzlich resources/js/app.js mit Alpine.start()
laden, gäbe es zwei Alpine-Instanzen und wire:submit, x-data,
wire:model würden brechen (siehe Browser-Logs vor diesem Fix). --}}
@vite([$themeCssPath], $assetsDir)
@livewireStyles
</head>
<body class="font-sans text-ink antialiased" style="background-color: #f6f4ef;">
<div class="relative min-h-screen flex flex-col overflow-hidden bg-bg">
{{-- Atmosphäre: subtiles Raster --}}
<div class="auth-grid" aria-hidden="true"></div>
{{-- Atmosphäre: konzentrische Kreise um die Bildmitte --}}
<svg class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="xMidYMid slice" viewBox="0 0 1280 880" aria-hidden="true">
<g opacity="0.09" stroke="#1A2540" fill="none" stroke-width="1">
<circle cx="640" cy="470" r="160" />
<circle cx="640" cy="470" r="260" />
<circle cx="640" cy="470" r="380" />
<circle cx="640" cy="470" r="510" />
<circle cx="640" cy="470" r="660" />
</g>
</svg>
{{-- 3px Hub-Blau-Streifen --}}
<div class="relative h-[3px] bg-hub z-10"></div>
{{-- Header --}}
<header class="relative z-10 px-6 sm:px-10 py-[22px] flex items-center justify-between gap-4">
<a href="{{ route('home') }}" class="flex items-baseline gap-2.5 no-underline" wire:navigate>
<span class="text-[19px] font-bold tracking-[-0.4px] leading-none">
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
<span class="hidden sm:inline-block w-px h-[14px] bg-bg-rule"></span>
<span class="hidden sm:inline-block text-[9.5px] font-bold tracking-[0.22em] uppercase text-ink-3">
{{ $brand['tagline_short'] ?? 'Publisher · Hub' }}
</span>
</a>
@if ($topRightLinkText && $topRightLinkHref)
<span class="text-[13px] text-ink-3">
@if ($topRightLabel)
{{ $topRightLabel }}
@endif
<a href="{{ $topRightLinkHref }}" class="link-hub" wire:navigate>{{ $topRightLinkText }}</a>
</span>
@endif
</header>
{{-- Auth-Card --}}
<main class="relative z-10 flex-1 flex items-center justify-center px-6 sm:px-10 py-5">
<div class="w-full max-w-[440px]">
@if ($showFromBanner && $fromBrandLabel)
<div class="from-banner mb-3.5">
Sie kommen von <strong class="font-semibold text-ink">{{ $fromBrandLabel }}</strong>.
Ihr Konto funktioniert für <strong class="font-semibold text-ink">beide Portale</strong>
presseecho.de und businessportal24.com.
</div>
@elseif ($showFromBanner)
<div class="from-banner mb-3.5">
Ihr Konto funktioniert auch für
<strong class="font-semibold text-ink">presseecho.de</strong>
und
<strong class="font-semibold text-ink">businessportal24.com</strong>.
</div>
@endif
<div class="auth-card">
@if ($eyebrow)
<div class="eyebrow-hub mb-2.5">{{ $eyebrow }}</div>
@endif
@if ($heading)
<h1 class="text-[26px] font-bold tracking-[-0.5px] leading-[1.2] m-0 mb-7 text-ink">
{{ $heading }}
</h1>
@endif
{{ $slot }}
</div>
</div>
</main>
{{-- Micro-Footer --}}
<footer class="relative z-10 px-6 sm:px-10 pt-[18px] pb-[26px] flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-[11.5px] text-ink-3">
<span>
SSL <span class="text-ink-4 mx-2">·</span>
Daten in DE <span class="text-ink-4 mx-2">·</span>
2-Faktor verfügbar
</span>
<span class="flex flex-wrap items-center gap-x-1 gap-y-1">
<a href="{{ route('impressum') }}" class="text-ink-3 no-underline hover:text-ink">Impressum</a>
<span class="text-ink-4 mx-2">·</span>
<a href="{{ route('datenschutz') }}" class="text-ink-3 no-underline hover:text-ink">Datenschutz</a>
<span class="text-ink-4 mx-2">·</span>
<a href="{{ route('agb') }}" class="text-ink-3 no-underline hover:text-ink">AGB</a>
<span class="text-ink-4 mx-2">·</span>
<a href="{{ route('hilfe') }}" class="text-ink-3 no-underline hover:text-ink">Support</a>
</span>
</footer>
</div>
@livewireScripts
</body>
</html>

View file

@ -0,0 +1,64 @@
@props([
/**
* Heroicon-Name (ohne flux:icon Prefix), z.B. user, archive-box, newspaper.
* Wird unten via flux:icon gerendert.
*/
'icon' => 'information-circle',
/** Titel (fett, dunkel) */
'title' => '',
/** Vollständigkeitswert in Prozent (0100) für die Progress-Bar */
'percent' => null,
/** Ziel-URL der Aktion (Pflicht für ein klickbares Hint) */
'href' => null,
/** Optionaler Action-Text — wenn null wird `action`-Slot oder „Öffnen“ verwendet */
'action' => null,
/** Wenn `true`, wird das Hint als Volt-`wire:navigate`-Link gerendert */
'navigate' => true,
])
@php
$percent = $percent !== null ? max(0, min(100, (int) $percent)) : null;
@endphp
<div class="hint-card">
<span class="hint-ico">
<flux:icon :name="$icon" class="size-[18px]" />
</span>
<div class="min-w-0">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[13px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ $title }}
</div>
@if ($percent !== null)
<span class="stat-meta whitespace-nowrap flex-shrink-0 text-[10px]">{{ $percent }}&thinsp;%</span>
@endif
</div>
@if ($percent !== null)
<div class="hint-bar">
<span style="width: {{ max(2, $percent) }}%;"></span>
</div>
@endif
<p class="text-[11.5px] leading-[1.5] mt-2 m-0 text-[color:var(--color-ink-3)]">
{{ $slot }}
</p>
@if ($href)
<a
href="{{ $href }}"
@if ($navigate) wire:navigate @endif
class="hint-action"
>
{{ $action ?? (isset($actionSlot) ? $actionSlot : __('Öffnen')) }}
</a>
@endif
</div>
</div>

View file

@ -0,0 +1,37 @@
@props([
/**
* Strip- und Label-Farbvariante.
* Erlaubte Werte: primary | ok | warn | muted.
* Mockup: dev/frontend/tailwind_v3/User Dashboard presseportale.html
*/
'variant' => 'muted',
/** Eyebrow-Label oben links (UPPERCASE, sperrgesetzt) */
'label' => '',
/** Hauptzahl in JetBrains Mono */
'value' => 0,
])
@php
$allowedVariants = ['primary', 'ok', 'warn', 'muted'];
$variant = in_array($variant, $allowedVariants, true) ? $variant : 'muted';
@endphp
<article {{ $attributes->class(['stat-card', "is-{$variant}"]) }}>
<span class="stat-strip"></span>
<div class="flex items-baseline justify-between gap-3">
<div class="stat-label">{{ $label }}</div>
@isset($meta)
<span class="stat-meta">{{ $meta }}</span>
@endisset
</div>
<div class="stat-num">{{ $value }}</div>
@isset($trend)
<div class="stat-trend">{{ $trend }}</div>
@endisset
</article>

View file

@ -1,5 +1,5 @@
@props([
'brand' => 'presseportale',
'brand' => 'pressekonto',
'variant' => 'auto',
'serif' => true,
])
@ -9,47 +9,66 @@
* Zentrale Brand-Wortmarke für alle drei Marken der Verlags-Familie.
*
* Schreibweise (verbindlich):
* - presseecho "presse" + "echo" (echo ist die Akzentfarbe)
* - businessportal24 "businessportal" + "24" (24 ist orange)
* - presseportale "presse" + "portale" (portale ist Bernstein)
* - presseecho "presse" + "echo" (echo grün)
* - businessportal24 "businessportal" + "24" (24 orange)
* - pressekonto "presse" + "konto" (konto bernstein)
*
* Keine TLD-Endung (".de", ".com") direkt am Markennamen. Diese gehören
* falls überhaupt getrennt in den juristischen Bereich (Impressum, Kontakt).
*
* Schriftart:
* - Standard `font-serif` (Source Serif 4) passt zum Editorial-Charakter
* von Presseecho und BusinessPortal24. Der Hub lädt Source Serif 4
* ebenfalls mit, damit Markennennungen typografisch konsistent bleiben.
* aller drei Portale.
* - `:serif="false"` schaltet auf font-sans (Inter Tight) etwa für die
* Top-Utility-Bar, in der die Marken sehr klein erscheinen.
*
* Variant:
* - `auto` Akzentfarbe = Theme-Default (Orange / Grün / Bernstein)
* - `on-dark` hellere/wärmere Akzentfarbe (für dunkle Hub-Panels)
* - `mono` Akzent identisch zum Basis-Ton (z. B. wenn beides weiß sein soll)
* - `auto` Name + Akzent in den Marken-Standardfarben (auf hellem Grund)
* - `on-dark` Name in Weiß (Negativ), Akzent in der dunkel-tauglichen Variante
* - `mono` Name + Akzent erben vom Parent (z. B. wenn beides weiß sein soll)
*
* Logo-Farben (siehe Brand-Manual):
* - businessportal: #232A33 (auf hell) / #FFFFFF (negativ)
* - presse (Echo): #1B2417 (auf hell) / #FFFFFF (negativ)
* - presse (Konto): #1A2540 (auf hell) / #FFFFFF (negativ)
* - 24: #C84A1E immer
* - echo: #345636 (auf hell) / #9BD5B2 (negativ)
* - konto: #B07A3A immer
*/
$marks = [
'presseecho' => [
'name' => 'presse',
'accent' => 'echo',
'name_color_auto' => '#1B2417',
'name_color_on_dark' => '#FFFFFF',
'accent_color_auto' => '#345636',
'accent_color_on_dark' => '#9BD5B2',
],
'businessportal24' => [
'name' => 'businessportal',
'accent' => '24',
'name_color_auto' => '#232A33',
'name_color_on_dark' => '#FFFFFF',
'accent_color_auto' => '#C84A1E',
'accent_color_on_dark' => '#C84A1E',
],
'presseportale' => [
'pressekonto' => [
'name' => 'presse',
'accent' => 'portale',
'accent' => 'konto',
'name_color_auto' => '#1A2540',
'name_color_on_dark' => '#FFFFFF',
'accent_color_auto' => '#B07A3A',
'accent_color_on_dark' => '#B07A3A',
],
];
$mark = $marks[$brand] ?? $marks['presseportale'];
$mark = $marks[$brand] ?? $marks['pressekonto'];
$nameColor = match ($variant) {
'on-dark' => $mark['name_color_on_dark'],
'mono' => 'inherit',
default => $mark['name_color_auto'],
};
$accentColor = match ($variant) {
'on-dark' => $mark['accent_color_on_dark'],
@ -62,5 +81,6 @@
$baseAttributes = $attributes->merge(['class' => $fontClass]);
@endphp
<span {{ $baseAttributes }}>{{ $mark['name'] }}<span
<span {{ $baseAttributes }}><span
style="color: {{ $nameColor }};">{{ $mark['name'] }}</span><span
style="color: {{ $accentColor }};">{{ $mark['accent'] }}</span></span>

View file

@ -3,19 +3,19 @@
])
@php
$themeKey = config('app.theme', 'presseportale');
$themeKey = config('app.theme', 'pressekonto');
$brand = $brand ?? config('domains.domains.' . $themeKey . '.brand', []);
$brandTagline = $brand['tagline_short'] ?? 'Publisher · Hub';
$brandTaglineLong = $brand['tagline_long'] ?? 'Der gemeinsame Publisher-Bereich für presseecho und businessportal24.';
$footerLegal = str_replace(':year', (string) now()->year, $brand['footer_legal'] ?? '© ' . now()->year . ' presseportale');
$footerLegal = str_replace(':year', (string) now()->year, $brand['footer_legal'] ?? '© ' . now()->year . ' pressekonto');
@endphp
<footer class="bg-hub-grad-2 text-ink-on-dark">
<div class="max-w-layout mx-auto px-8 py-14 grid gap-10" style="grid-template-columns:1.5fr 1fr 1fr 1fr;">
<div>
<div class="text-[24px] font-bold leading-none tracking-[-0.5px] text-white">
<x-web.brand-mark brand="presseportale" :serif="false" />
<div class="text-[24px] font-bold leading-none tracking-[-0.5px]">
<x-web.brand-mark brand="pressekonto" variant="on-dark" :serif="false" />
</div>
<div class="eyebrow on-dark mt-2 text-[9.5px] tracking-[0.22em]">
{{ $brandTagline }}

View file

@ -4,12 +4,12 @@
])
@php
$themeKey = config('app.theme', 'presseportale');
$themeKey = config('app.theme', 'pressekonto');
$brand =
$brand ??
config('domains.domains.' . $themeKey . '.brand', [
'name' => 'presse',
'accent' => 'portale',
'accent' => 'konto',
'tagline_short' => 'Publisher · Hub',
]);
$brandTagline = $brand['tagline_short'] ?? 'Publisher · Hub';
@ -27,9 +27,9 @@
<div class="max-w-layout mx-auto px-8 py-[18px] grid items-center gap-6" style="grid-template-columns:auto 1fr auto;">
<a href="{{ route('home') }}" class="flex items-baseline gap-2.5 cursor-pointer group"
aria-label="presseportale Startseite">
aria-label="pressekonto Startseite">
<span class="text-[24px] font-bold tracking-[-0.5px] leading-none text-hub">
<x-web.brand-mark brand="presseportale" :serif="false" />
<x-web.brand-mark brand="pressekonto" :serif="false" />
</span>
<span class="hidden md:inline-block w-px h-[18px] bg-bg-rule"></span>
<span class="eyebrow muted text-[9.5px] tracking-[0.22em]">{{ $brandTagline }}</span>

View file

@ -25,8 +25,8 @@
<div class="max-w-layout mx-auto px-8 py-12 grid gap-10 grid-cols-1 md:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1fr_1fr]">
<div>
<a href="{{ route('home') }}" class="block cursor-pointer group" aria-label="{{ $brandName }}{{ $brandAccent }} Startseite">
<div class="font-serif text-[24px] font-semibold leading-none tracking-[-0.5px] text-white group-hover:text-white/80 transition-colors">
{{ $brandName }}@if ($brandAccent)<span class="text-brand">{{ $brandAccent }}</span>@endif
<div class="text-[24px] font-semibold leading-none tracking-[-0.5px]">
<x-web.brand-mark :brand="$themeKey" variant="on-dark" />
</div>
<div class="eyebrow mt-2 text-[9.5px] tracking-[0.18em] text-ink-on-dark-muted">
{{ $brandTagline }}

View file

@ -102,8 +102,8 @@
@keydown.escape.window="searchOpen = false">
<div class="max-w-layout mx-auto px-4 sm:px-6 lg:px-8 py-3 lg:py-[18px] flex items-center gap-3 sm:gap-4 lg:gap-6">
<a href="{{ route('home') }}" class="block cursor-pointer group flex-shrink-0" aria-label="{{ $brandName }}{{ $brandAccent }} Startseite">
<div class="font-serif text-[22px] sm:text-[24px] lg:text-[28px] font-semibold leading-none tracking-[-0.5px] text-ink group-hover:text-brand transition-colors">
{{ $brandName }}@if ($brandAccent)<span class="text-brand">{{ $brandAccent }}</span>@endif
<div class="text-[22px] sm:text-[24px] lg:text-[28px] font-semibold leading-none tracking-[-0.5px]">
<x-web.brand-mark :brand="$themeKey" />
</div>
<div class="eyebrow muted mt-1 text-[9.5px] tracking-[0.18em] hidden sm:block">
{{ $brandTagline }}

View file

@ -137,44 +137,56 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex items-center justify-between">
<div>
<flux:heading size="lg">{{ __('Neue Pressemitteilung') }}</flux:heading>
<flux:subheading>{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}</flux:subheading>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Content · Neu anlegen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Pressemitteilung') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
</header>
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
{{-- Hauptinhalt --}}
{{-- ============== HAUPTINHALT ============== --}}
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model.live.debounce.500ms="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}" />
<flux:error name="text" />
</flux:field>
</div>
</flux:card>
</article>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('SEO & Links') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennte Stichwörter…') }}" />
@ -187,19 +199,20 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</article>
</div>
{{-- Sidebar --}}
<div class="space-y-4">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
{{-- ============== SIDEBAR ============== --}}
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Portal') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $p)
@foreach ($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
@ -215,7 +228,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</flux:field>
<flux:field>
<flux:label>{{ __('Firma') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Firma') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select
wire:model.live="companyId"
variant="combobox"
@ -229,14 +242,14 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
@foreach ($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
@if (blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@ -248,11 +261,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
@ -261,10 +274,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
</div>
</flux:card>
</article>
<flux:card>
<div class="space-y-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
<div class="p-5 space-y-2">
<flux:button
type="button"
variant="primary"
@ -284,7 +300,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</flux:card>
</div>
</article>
</aside>
</div>
</div>

View file

@ -264,53 +264,80 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
<div class="space-y-8">
@php
$statusClass = match ($currentStatus) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
<flux:subheading>ID: {{ $id }}</flux:subheading>
</div>
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor" size="lg">{{ $statusEnum?->label() ?? $currentStatus }}</flux:badge>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
</flux:card>
@endif
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Content · Bearbeiten') }}</span>
<span @class(['badge', $statusClass])>{{ $statusEnum?->label() ?? $currentStatus }}</span>
<span class="badge hub">ID {{ $id }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Pressemitteilung bearbeiten') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Inhalt, Metadaten und Status der PM aktualisieren. Änderungen werden sofort wirksam.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
{{-- Hauptinhalt --}}
{{-- ============== HAUPTINHALT ============== --}}
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="title" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:error name="text" />
</flux:field>
</div>
</flux:card>
</article>
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('SEO & Links') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Stichwörter') }}</flux:label>
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
@ -322,22 +349,23 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</article>
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
</div>
{{-- Sidebar --}}
<div class="space-y-4">
{{-- Status-Aktionen --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Status-Aktionen') }}</flux:heading>
<div class="space-y-3">
{{-- ============== SIDEBAR ============== --}}
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Aktionen') }}</span>
<span @class(['badge', $statusClass])>{{ $statusEnum?->label() ?? $currentStatus }}</span>
</div>
<div class="p-5 space-y-3">
<flux:field>
<flux:label>{{ __('Neuer Status') }}</flux:label>
<flux:select wire:model.live="targetStatus">
@foreach($statusOptions as $statusOption)
@foreach ($statusOptions as $statusOption)
<option value="{{ $statusOption->value }}">
{{ $statusOption->label() }}{{ $statusOption->value === $currentStatus ? ' (aktuell)' : '' }}
</option>
@ -352,17 +380,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</flux:button>
</flux:modal.trigger>
</div>
</flux:card>
</article>
{{-- Metadaten --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Portal') }}</flux:label>
<flux:select wire:model="portal">
@foreach($portalOptions as $p)
@foreach ($portalOptions as $p)
<option value="{{ $p->value }}">{{ $p->label() }}</option>
@endforeach
</flux:select>
@ -391,14 +419,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
placeholder="{{ __('Name eingeben…') }}"
/>
</x-slot>
@foreach($companies as $company)
@foreach ($companies as $company)
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
{{ $company->name }}
</flux:select.option>
@endforeach
<x-slot name="empty">
<flux:select.option.empty>
@if(blank(trim($companySearch)))
@if (blank(trim($companySearch)))
{{ __('Mindestens 1 Zeichen eingeben…') }}
@else
{{ __('Keine Firma gefunden.') }}
@ -413,8 +441,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:label>{{ __('Kategorie') }}</flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
@ -423,18 +451,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
</div>
</flux:card>
</article>
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Änderungen speichern') }}
</flux:button>
<flux:modal.trigger name="confirm-delete-press-release">
<flux:button type="button" variant="danger" icon="trash" class="w-full">
{{ __('Pressemitteilung löschen') }}
</flux:button>
</flux:modal.trigger>
</div>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
<div class="p-5 space-y-2">
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Änderungen speichern') }}
</flux:button>
<flux:modal.trigger name="confirm-delete-press-release">
<flux:button type="button" variant="danger" icon="trash" class="w-full">
{{ __('Pressemitteilung löschen') }}
</flux:button>
</flux:modal.trigger>
</div>
</article>
</aside>
</div>
<flux:modal name="confirm-status-change" class="max-w-lg">

View file

@ -349,43 +349,68 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
}
}; ?>
<div class="space-y-6">
<div class="space-y-8">
@if (session('success'))
<div
class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
{{-- Statistiken --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
</flux:card>
</div>
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Content · Pressemitteilungen') }}</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
{{ __('Pressemitteilungen') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Übersicht aller PMs beider Portale, mit Filter, Status-Workflow und Schnellaktionen.') }}
</p>
</div>
<div class="flex justify-end">
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM') }}
</flux:button>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
{{ __('Neue PM') }}
</flux:button>
</div>
</header>
{{-- Filter --}}
<flux:card>
<div class="flex flex-col gap-3">
{{-- ============== KPI-Reihe ============== --}}
<section class="grid gap-4 grid-cols-2 sm:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="number_format($stats['total'])">
<x-slot:meta>{{ now()->format('Y') }}</x-slot:meta>
<x-slot:trend>{{ __('über beide Portale') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Veröffentlicht')" :value="number_format($stats['published'])">
<x-slot:meta>{{ __('live') }}</x-slot:meta>
<x-slot:trend>{{ __('öffentlich sichtbar') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('In Prüfung')" :value="number_format($stats['review'])">
<x-slot:meta>{{ __('queue') }}</x-slot:meta>
<x-slot:trend>{{ __('redaktionelle Prüfung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Entwürfe')" :value="number_format($stats['draft'])">
<x-slot:meta>{{ __('privat') }}</x-slot:meta>
<x-slot:trend>{{ __('nicht eingereicht') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
</div>
<div class="p-5 flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-6">
<flux:input
wire:model.live.debounce.300ms="search"
@ -551,10 +576,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</div>
</div>
</div>
</flux:card>
</article>
{{-- Tabelle --}}
<flux:card class="overflow-hidden">
{{-- ============== TABELLE-PANEL ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Alle Pressemitteilungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $pressReleases->count()]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
@ -604,16 +635,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
</div>
</flux:table.cell>
<flux:table.cell>
<flux:badge
color="{{ match ($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'draft' => 'zinc',
'rejected' => 'red',
'archived' => 'blue',
} }}">
<span @class([
'badge',
'ok' => $pr->status->value === 'published',
'warn' => $pr->status->value === 'review',
'err' => $pr->status->value === 'rejected',
'hub' => in_array($pr->status->value, ['archived', 'draft'], true),
])>
{{ $pr->status->label() }}
</flux:badge>
</span>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm">{{ $pr->portal->label() }}</flux:text>
@ -704,16 +734,23 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class exten
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center justify-center py-10">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Pressemitteilungen gefunden.') }}
</flux:text>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-4
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.newspaper class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Pressemitteilungen gefunden') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0">
{{ __('Passen Sie die Filter an oder erstellen Sie eine neue Pressemitteilung.') }}
</p>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</flux:card>
</article>
{{ $pressReleases->links() }}
</div>

View file

@ -90,46 +90,75 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
<div class="space-y-8">
@php
$statusClass = match ($pr->status->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ strtoupper($pr->language) }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ $pr->portal->label() }}</flux:badge>
</div>
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ __('Firma') }}: {{ $pr->company?->name ?? '' }} ·
{{ __('Kategorie') }}: {{ $categoryName }} ·
{{ __('Autor') }}: {{ $pr->user?->name ?? '' }}
</flux:text>
</div>
<div class="flex gap-2">
<flux:button variant="ghost" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
</flux:card>
@endif
{{-- Status-Aktionen --}}
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
<flux:card>
<div class="flex flex-wrap items-center gap-3">
<flux:text weight="medium" class="text-yellow-700 dark:text-yellow-400">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Content · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
<span class="badge hub">{{ $pr->portal->label() }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Firma') }}:</strong>
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Kategorie') }}:</strong>
{{ $categoryName }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
<strong class="font-semibold text-[color:var(--color-ink)]">{{ __('Autor') }}:</strong>
{{ $pr->user?->name ?? '' }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge warn dot">{{ __('Wartet auf Prüfung') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
{{ __('Diese PM wartet auf Prüfung.') }}
</flux:text>
</p>
<flux:modal.trigger name="confirm-show-publish">
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
</flux:modal.trigger>
@ -137,136 +166,162 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
</flux:modal.trigger>
</div>
</flux:card>
</article>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
<flux:card>
<div class="flex items-center gap-3">
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span class="badge ok dot">{{ __('Live') }}</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
@if ($pr->hits > 0)
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[200px]">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</strong>
{{ __('Aufrufe seit Veröffentlichung') }}
</p>
@endif
<flux:modal.trigger name="confirm-show-archive">
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
</flux:modal.trigger>
@if($pr->hits > 0)
<flux:text class="text-sm text-zinc-500">{{ number_format($pr->hits) }} {{ __('Aufrufe') }}</flux:text>
@endif
</div>
</flux:card>
</article>
@endif
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
{{-- Text --}}
<flux:card>
<div class="prose prose-zinc dark:prose-invert max-w-none">
{!! nl2br(e($pr->text)) !!}
{{-- ============== TEXT + SIDEBAR ============== --}}
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
</flux:card>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! nl2br(e($pr->text)) !!}
</div>
</div>
</article>
{{-- Details --}}
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Details') }}</flux:heading>
<dl class="space-y-2 text-sm">
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Details') }}</span>
</div>
<dl class="p-5 space-y-2.5 text-[12.5px]">
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Status') }}</dt>
<dd class="font-medium">{{ $pr->status->label() }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Status') }}</dt>
<dd class="font-semibold text-[color:var(--color-ink)]">{{ $pr->status->label() }}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Erstellt') }}</dt>
<dd>{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Erstellt') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
</div>
@if($pr->published_at)
@if ($pr->published_at)
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Veröffentlicht') }}</dt>
<dd>{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Veröffentlicht') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
</div>
@endif
<div class="flex justify-between gap-2">
<dt class="text-zinc-500">{{ __('Aufrufe') }}</dt>
<dd>{{ number_format($pr->hits) }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Aufrufe') }}</dt>
<dd class="text-[color:var(--color-ink)] font-semibold">{{ number_format($pr->hits) }}</dd>
</div>
@if($pr->keywords)
<div>
<dt class="text-zinc-500">{{ __('Stichwörter') }}</dt>
<dd class="mt-1">{{ $pr->keywords }}</dd>
@if ($pr->keywords)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Stichwörter') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $pr->keywords }}</dd>
</div>
@endif
@if($pr->backlink_url)
<div>
<dt class="text-zinc-500">{{ __('Backlink') }}</dt>
<dd class="mt-1 break-all">
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline dark:text-blue-400">
@if ($pr->backlink_url)
<div class="pt-2 border-t border-[color:var(--color-bg-rule)]">
<dt class="text-[color:var(--color-ink-3)] mb-1">{{ __('Backlink') }}</dt>
<dd class="break-all">
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</dd>
</div>
@endif
@if($pr->no_export)
<div class="rounded bg-zinc-100 px-2 py-1 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
{{ __('Kein Export') }}
@if ($pr->no_export)
<div class="mt-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
<span class="badge hub">{{ __('Kein Export') }}</span>
</div>
@endif
</dl>
</flux:card>
</article>
@if($pr->images->isNotEmpty())
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Bilder') }}</flux:heading>
<div class="space-y-2">
@foreach($pr->images as $image)
<div class="flex items-center gap-2 text-sm">
<flux:icon.photo class="size-4 text-zinc-400" />
<span class="truncate text-zinc-600 dark:text-zinc-400">{{ basename($image->path) }}</span>
@if($image->is_preview)
<flux:badge size="sm" color="blue">{{ __('Preview') }}</flux:badge>
@if ($pr->images->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bilder') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $pr->images->count() }}
</span>
</div>
<div class="p-5 space-y-2">
@foreach ($pr->images as $image)
<div class="flex items-center gap-2 text-[12.5px]">
<flux:icon.photo class="size-4 flex-shrink-0 text-[color:var(--color-ink-3)]" />
<span class="truncate text-[color:var(--color-ink-2)]">{{ basename($image->path) }}</span>
@if ($image->is_preview)
<span class="badge hub">{{ __('Preview') }}</span>
@endif
</div>
@endforeach
</div>
</flux:card>
</article>
@endif
</div>
</aside>
</div>
@if($statusLogs->isNotEmpty())
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Status-Verlauf') }}</flux:heading>
<ol class="space-y-3 border-s border-zinc-200 ps-4 dark:border-zinc-700">
@foreach($statusLogs as $log)
<li class="text-sm">
<div class="flex flex-wrap items-center gap-2">
@php
$color = match($log->to_status?->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
};
@endphp
<flux:badge size="sm" :color="$color">{{ $log->to_status?->label() ?? $log->to_status }}</flux:badge>
@if($log->from_status)
<span class="text-xs text-zinc-500">
{{ __('von') }} {{ $log->from_status->label() }}
{{-- ============== STATUS-VERLAUF ============== --}}
@if ($statusLogs->isNotEmpty())
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Verlauf') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $statusLogs->count() }} {{ __('Einträge') }}
</span>
</div>
<div class="p-5">
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() ?? $log->to_status }}</span>
@if ($log->from_status)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('von') }} {{ $log->from_status->label() }}
</span>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">·</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ $log->changedBy->name }}</span>
@endif
@if ($log->source !== 'admin')
<span class="badge hub">{{ $log->source }}</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
<span class="text-xs text-zinc-500">·</span>
<span class="text-xs text-zinc-500">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if($log->changedBy)
<span class="text-xs text-zinc-500">·</span>
<span class="text-xs text-zinc-500">{{ $log->changedBy->name }}</span>
@endif
@if($log->source !== 'admin')
<flux:badge size="xs" color="zinc">{{ $log->source }}</flux:badge>
@endif
</div>
@if($log->reason)
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
</flux:card>
</li>
@endforeach
</ol>
</div>
</article>
@endif
@if($pr->status === \App\Enums\PressReleaseStatus::Review)

View file

@ -5,7 +5,7 @@ use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Passwort bestätigen', 'eyebrow' => 'Sicherheitsbereich', 'showFromBanner' => false])] class extends Component {
public string $password = '';
/**
@ -32,26 +32,43 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-4 mb-6">
Dieser Bereich des Publisher-Hubs ist besonders geschützt. Bitte bestätigen Sie zur
Sicherheit erneut Ihr Passwort, bevor Sie fortfahren.
</p>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="confirmPassword" class="space-y-[18px]" x-data="{ showPassword: false }" novalidate>
<form wire:submit="confirmPassword" class="flex flex-col gap-6">
<!-- Password -->
<flux:input
wire:model="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
/>
<div>
<label class="field-label" for="auth-password">Passwort</label>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
required
autofocus
autocomplete="current-password"
class="field-input pr-[72px]"
placeholder="••••••••••"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Confirm') }}</flux:button>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="confirmPassword">
<span wire:loading.remove wire:target="confirmPassword">Bestätigen</span>
<span wire:loading wire:target="confirmPassword">Wird geprüft </span>
</button>
</form>
</div>

View file

@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Passwort vergessen', 'eyebrow' => 'Passwort zurücksetzen', 'topRightLabel' => 'Doch erinnert?', 'topRightLinkText' => 'Zur Anmeldung', 'topRightLinkHref' => '/login'])] class extends Component {
public string $email = '';
/**
@ -22,28 +22,45 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.6] !-mt-4 mb-6">
Geben Sie die E-Mail-Adresse Ihres Kontos ein. Sie erhalten innerhalb weniger Minuten
einen Link, mit dem Sie ein neues Passwort vergeben können.
</p>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
@endif
<form wire:submit="sendPasswordResetLink" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input
wire:model="email"
:label="__('Email Address')"
type="email"
required
autofocus
placeholder="email@example.com"
/>
<form wire:submit="sendPasswordResetLink" class="space-y-[18px]" novalidate>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Email password reset link') }}</flux:button>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
required
autofocus
autocomplete="username"
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="sendPasswordResetLink">
<span wire:loading.remove wire:target="sendPasswordResetLink">Reset-Link senden</span>
<span wire:loading wire:target="sendPasswordResetLink">Link wird gesendet </span>
</button>
<div class="flex items-center justify-center pt-2 text-[12.5px] text-ink-3">
<a href="{{ route('login') }}" class="link-hub" wire:navigate> Zurück zur Anmeldung</a>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-400">
{{ __('Or, return to') }}
<flux:link :href="route('login')" wire:navigate>{{ __('log in') }}</flux:link>
</div>
</div>

View file

@ -1,9 +1,9 @@
<?php
use Illuminate\Auth\Events\Lockout;
use App\Mail\MagicLoginLink;
use App\Services\Auth\MagicLinkGenerator;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
@ -15,7 +15,7 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zurück', 'eyebrow' => 'Anmeldung im Publisher-Hub', 'topRightLabel' => 'Noch kein Konto?', 'topRightLinkText' => 'Konto erstellen', 'topRightLinkHref' => '/register'])] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
@ -52,7 +52,18 @@ new #[Layout('components.layouts.auth')] class extends Component {
RateLimiter::clear($this->throttleKey());
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
// Rollen-basierter Default-Redirect:
// Admin/Editor → /dashboard, Customer → /admin/me.
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
// (build/portal mit FluxUI) als das Hub-Auth-Layout (build/web).
// SPA-Navigation kann den Bundle-Wechsel nicht handhaben.
$defaultRoute = $authenticatedUser?->canAccessAdmin()
? route('dashboard', absolute: false)
: ($authenticatedUser?->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
$this->redirectIntended(default: $defaultRoute);
}
public function sendMagicLink(): void
@ -107,63 +118,94 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="login" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input
wire:model="email"
:label="__('Email address')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<div class="relative">
<flux:input
wire:model="password"
:label="__('Password')"
type="password"
required
autocomplete="current-password"
:placeholder="__('Password')"
/>
@if (Route::has('password.request'))
<flux:link class="absolute right-0 top-0 text-sm" :href="route('password.request')" wire:navigate>
{{ __('Forgot your password?') }}
</flux:link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox wire:model="remember" :label="__('Remember me')" />
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Log in') }}</flux:button>
</div>
</form>
<div class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<p class="mb-3 text-sm text-zinc-600 dark:text-zinc-300">
{{ __('Login without password? Request a one-time email link.') }}
</p>
<flux:button variant="subtle" wire:click="sendMagicLink" class="w-full">
{{ __('Send magic login link') }}
</flux:button>
</div>
@if (Route::has('register'))
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
{{ __('Don\'t have an account?') }}
<flux:link :href="route('register')" wire:navigate>{{ __('Sign up') }}</flux:link>
<div>
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
@endif
<form wire:submit="login" class="space-y-[18px]" x-data="{ showPassword: false }" novalidate>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
autocomplete="username"
required
autofocus
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<div class="flex items-baseline justify-between mb-1.5">
<label class="field-label !mb-0" for="auth-password">Passwort</label>
@if (\Illuminate\Support\Facades\Route::has('password.request'))
<a href="{{ route('password.request') }}" class="link-hub text-[12px]" wire:navigate>
Passwort vergessen?
</a>
@endif
</div>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
class="field-input pr-[72px]"
placeholder="••••••••••"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<label class="flex items-center gap-2.5 text-[12.5px] text-ink-2 cursor-pointer select-none">
<input type="checkbox" wire:model="remember" class="auth-check" />
Angemeldet bleiben
</label>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="login">
<span wire:loading.remove wire:target="login">Anmelden</span>
<span wire:loading wire:target="login">Anmelden </span>
</button>
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
<span class="flex-1 h-px bg-bg-rule"></span>
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">oder</span>
<span class="flex-1 h-px bg-bg-rule"></span>
</div>
<button
type="button"
wire:click="sendMagicLink"
wire:loading.attr="disabled"
wire:target="sendMagicLink"
class="auth-btn-outline !mt-0"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />
<path d="M2.5 4l5.5 5 5.5-5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
</svg>
<span wire:loading.remove wire:target="sendMagicLink">Magic-Link senden</span>
<span wire:loading wire:target="sendMagicLink">Magic-Link wird gesendet </span>
</button>
</form>
</div>

View file

@ -8,12 +8,17 @@ use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstellen', 'eyebrow' => 'Registrierung im Publisher-Hub', 'topRightLabel' => 'Bereits Konto?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component {
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public bool $terms_accepted = false;
/**
* Handle an incoming registration request.
*/
@ -21,77 +26,150 @@ new #[Layout('components.layouts.auth')] class extends Component {
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
'terms_accepted' => ['accepted'],
], [
'terms_accepted.accepted' => 'Bitte bestätigen Sie unsere AGB und die Datenschutzerklärung.',
]);
unset($validated['terms_accepted']);
$validated['password'] = Hash::make($validated['password']);
event(new Registered(($user = User::create($validated))));
event(new Registered($user = User::create($validated)));
Auth::login($user);
$this->redirectIntended(route('dashboard', absolute: false), navigate: true);
// Frisch registrierte User sind in der Regel Customer ohne Admin-
// Rollen → /admin/me. Ohne navigate:true, weil das Panel ein
// anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
$defaultRoute = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
$this->redirectIntended($defaultRoute);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="register" class="flex flex-col gap-6">
<!-- Name -->
<flux:input
wire:model="name"
:label="__('Name')"
type="text"
required
autofocus
autocomplete="name"
:placeholder="__('Full name')"
/>
<!-- Email Address -->
<flux:input
wire:model="email"
:label="__('Email address')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
/>
<!-- Password -->
<flux:input
wire:model="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
/>
<!-- Confirm Password -->
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Create account') }}
</flux:button>
<div>
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
</div>
</form>
@endif
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
{{ __('Already have an account?') }}
<flux:link :href="route('login')" wire:navigate>{{ __('Log in') }}</flux:link>
</div>
<form wire:submit="register" class="space-y-[18px]" x-data="{ showPassword: false, showPasswordConfirmation: false }" novalidate>
<div>
<label class="field-label" for="auth-name">Name</label>
<input
id="auth-name"
type="text"
wire:model="name"
required
autofocus
autocomplete="name"
class="field-input"
placeholder="Vor- und Nachname"
@error('name') aria-invalid="true" @enderror
/>
@error('name')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
required
autocomplete="email"
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<label class="field-label" for="auth-password">Passwort</label>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
class="field-input pr-[72px]"
placeholder="Mindestens 8 Zeichen"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<label class="field-label" for="auth-password-confirmation">Passwort bestätigen</label>
<div class="field-pw-wrap">
<input
id="auth-password-confirmation"
wire:model="password_confirmation"
:type="showPasswordConfirmation ? 'text' : 'password'"
required
autocomplete="new-password"
class="field-input pr-[72px]"
placeholder="Passwort wiederholen"
/>
<button
type="button"
class="field-affix"
@click="showPasswordConfirmation = !showPasswordConfirmation"
x-text="showPasswordConfirmation ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
</div>
<div class="!mt-5">
<label for="auth-terms" class="flex items-start gap-3 cursor-pointer select-none">
<input
id="auth-terms"
type="checkbox"
wire:model="terms_accepted"
required
class="auth-check !mt-[3px]"
@error('terms_accepted') aria-invalid="true" @enderror
/>
<span class="text-[12.5px] text-ink-2 leading-[1.55]">
Ich habe die
<a href="{{ route('agb') }}" target="_blank" rel="noopener" class="link-hub">AGB</a>
und die
<a href="{{ route('datenschutz') }}" target="_blank" rel="noopener" class="link-hub">Datenschutzerklärung</a>
gelesen und stimme der Verarbeitung meiner Daten zur Konto-Erstellung ausdrücklich zu.
</span>
</label>
@error('terms_accepted')
<p class="field-error !ml-7">{{ $message }}</p>
@enderror
</div>
<button type="submit" class="auth-btn-primary !mt-[18px]" wire:loading.attr="disabled" wire:target="register">
<span wire:loading.remove wire:target="register">Konto erstellen</span>
<span wire:loading wire:target="register">Konto wird angelegt </span>
</button>
</form>
</div>

View file

@ -10,11 +10,14 @@ use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Neues Passwort vergeben', 'eyebrow' => 'Passwort zurücksetzen'])] class extends Component {
#[Locked]
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
@ -38,9 +41,6 @@ new #[Layout('components.layouts.auth')] class extends Component {
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$this->only('email', 'password', 'password_confirmation', 'token'),
function ($user) {
@ -53,9 +53,6 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status != Password::PasswordReset) {
$this->addError('email', __($status));
@ -68,46 +65,79 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.6] !-mt-4 mb-6">
Vergeben Sie ein neues Passwort für Ihr Konto. Mindestens 8 Zeichen, idealerweise eine
Kombination aus Buchstaben, Zahlen und Sonderzeichen.
</p>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="resetPassword" class="space-y-[18px]" x-data="{ showPassword: false, showPasswordConfirmation: false }" novalidate>
<form wire:submit="resetPassword" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input
wire:model="email"
:label="__('Email')"
type="email"
required
autocomplete="email"
/>
<!-- Password -->
<flux:input
wire:model="password"
:label="__('Password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Password')"
/>
<!-- Confirm Password -->
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
:placeholder="__('Confirm password')"
/>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Reset password') }}
</flux:button>
<div>
<label class="field-label" for="auth-email">E-Mail-Adresse</label>
<input
id="auth-email"
type="email"
wire:model="email"
required
autocomplete="email"
class="field-input"
@error('email') aria-invalid="true" @enderror
/>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<label class="field-label" for="auth-password">Neues Passwort</label>
<div class="field-pw-wrap">
<input
id="auth-password"
wire:model="password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
class="field-input pr-[72px]"
placeholder="Mindestens 8 Zeichen"
@error('password') aria-invalid="true" @enderror
/>
<button
type="button"
class="field-affix"
@click="showPassword = !showPassword"
x-text="showPassword ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div>
<label class="field-label" for="auth-password-confirmation">Passwort bestätigen</label>
<div class="field-pw-wrap">
<input
id="auth-password-confirmation"
wire:model="password_confirmation"
:type="showPasswordConfirmation ? 'text' : 'password'"
required
autocomplete="new-password"
class="field-input pr-[72px]"
placeholder="Neues Passwort wiederholen"
/>
<button
type="button"
class="field-affix"
@click="showPasswordConfirmation = !showPasswordConfirmation"
x-text="showPasswordConfirmation ? 'Verbergen' : 'Anzeigen'"
>Anzeigen</button>
</div>
</div>
<button type="submit" class="auth-btn-primary !mt-[22px]" wire:loading.attr="disabled" wire:target="resetPassword">
<span wire:loading.remove wire:target="resetPassword">Passwort zurücksetzen</span>
<span wire:loading wire:target="resetPassword">Passwort wird gespeichert </span>
</button>
</form>
</div>

View file

@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adresse bestätigen', 'eyebrow' => 'Konto-Verifizierung', 'showFromBanner' => false])] class extends Component {
/**
* Send an email verification notification to the user.
*/
@ -34,24 +34,37 @@ new #[Layout('components.layouts.auth')] class extends Component {
}
}; ?>
<div class="mt-4 flex flex-col gap-6">
<flux:text class="text-center">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</flux:text>
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-4 mb-6">
Wir haben Ihnen einen Bestätigungslink an
<strong class="text-ink font-semibold">{{ Auth::user()?->email }}</strong>
gesendet. Bitte öffnen Sie die Mail und klicken Sie auf den Link, um Ihre E-Mail-Adresse zu bestätigen.
</p>
@if (session('status') == 'verification-link-sent')
<flux:text class="text-center font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</flux:text>
@if (session('status') === 'verification-link-sent')
<div class="field-status mb-6" role="status">
Ein neuer Bestätigungslink wurde an Ihre E-Mail-Adresse versendet.
</div>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<flux:button wire:click="sendVerification" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
<div class="space-y-3">
<button
type="button"
wire:click="sendVerification"
wire:loading.attr="disabled"
wire:target="sendVerification"
class="auth-btn-primary"
>
<span wire:loading.remove wire:target="sendVerification">Bestätigungs-Mail erneut senden</span>
<span wire:loading wire:target="sendVerification">Mail wird gesendet </span>
</button>
<flux:link class="text-sm cursor-pointer" wire:click="logout">
{{ __('Log out') }}
</flux:link>
<button
type="button"
wire:click="logout"
class="w-full text-center text-[12.5px] text-ink-3 hover:text-hub transition-colors py-2"
>
Abmelden
</button>
</div>
</div>

View file

@ -1,12 +1,15 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\BillingAddress;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\Profile;
use App\Models\User;
use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -35,6 +38,9 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
->limit(5)
->get(['id', 'title', 'status', 'company_id', 'created_at']);
$profile = $user->profile;
$billingAddress = $user->billingAddress;
return [
'user' => $user,
'selectedCompany' => $selectedCompany,
@ -43,36 +49,123 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
'published' => $myPRs->get('published', 0),
'review' => $myPRs->get('review', 0),
'draft' => $myPRs->get('draft', 0),
'deltaMonth' => $this->totalDeltaToPreviousMonth(clone $pressReleaseQuery),
],
'qualityHints' => $this->qualityHints($user, $selectedCompany, $pressReleaseQuery),
'profileCompleteness' => $this->profileCompleteness($profile),
'billingCompleteness' => $this->billingCompleteness($billingAddress),
'qualityHints' => $this->qualityHints(
$user,
$profile,
$billingAddress,
$selectedCompany,
$pressReleaseQuery,
),
'recent' => $recent,
'companies' => $context->companiesFor($user),
'bridgeStatus' => [
/* Heute hardcoded — perspektivisch aus echtem Sync-Service. */
'presseecho' => ['state' => 'connected', 'subline' => __('Archiv · Branchen-Tiefe')],
'businessportal24' => ['state' => 'connected', 'subline' => __('Wirtschaft · Live')],
],
];
}
private function qualityHints(User $user, ?Company $selectedCompany, Builder $pressReleaseQuery): array
/**
* Heuristische Profil-Vollständigkeit in %.
* 6 Kernfelder, jedes ~17 %.
*/
private function profileCompleteness(?Profile $profile): int
{
if (! $profile) {
return 0;
}
$fields = [
'salutation_key',
'first_name',
'last_name',
'phone',
'address',
'country_code',
];
$filled = collect($fields)->filter(fn (string $field) => filled($profile->{$field}))->count();
return (int) round(($filled / count($fields)) * 100);
}
/**
* Rechnungsadressen-Vollständigkeit in %.
* Ohne hinterlegte Adresse: 0; sonst je nach gefüllten Pflichtfeldern.
*/
private function billingCompleteness(?BillingAddress $address): int
{
if (! $address) {
return 0;
}
$fields = ['name', 'address1', 'postal_code', 'city', 'country_code'];
$filled = collect($fields)->filter(fn (string $field) => filled($address->{$field}))->count();
return (int) round(($filled / count($fields)) * 100);
}
/**
* Vergleicht PRs im aktuellen Monat mit dem Vormonat (Differenz, Vorzeichen mit Pfeil im View).
*/
private function totalDeltaToPreviousMonth(Builder $pressReleaseQuery): int
{
$now = Carbon::now();
$currentStart = $now->copy()->startOfMonth();
$previousStart = $now->copy()->subMonthNoOverflow()->startOfMonth();
$previousEnd = $now->copy()->subMonthNoOverflow()->endOfMonth();
$currentCount = (clone $pressReleaseQuery)
->where('created_at', '>=', $currentStart)
->count();
$previousCount = (clone $pressReleaseQuery)
->whereBetween('created_at', [$previousStart, $previousEnd])
->count();
return $currentCount - $previousCount;
}
/**
* @return list<array{icon: string, title: string, description: string, href: string, action: string, percent?: int}>
*/
private function qualityHints(
User $user,
?Profile $profile,
?BillingAddress $billingAddress,
?Company $selectedCompany,
Builder $pressReleaseQuery,
): array {
$hints = [];
if (! $user->profile()->exists()) {
$profilePercent = $this->profileCompleteness($profile);
if ($profilePercent < 100) {
$hints[] = [
'color' => 'amber',
'icon' => 'user',
'title' => __('Profil unvollständig'),
'description' => __('Ergänzen Sie Ihre Profildaten für eine sauberere Kundenakte.'),
'description' => __('Ergänzen Sie Salutation, Telefon und Adresse für eine saubere Kundenakte.'),
'href' => route('me.profile').'#profil',
'action' => __('Profil öffnen'),
'percent' => $profilePercent,
];
}
if (! $user->billingAddress()->exists()) {
$billingPercent = $this->billingCompleteness($billingAddress);
if ($billingPercent < 100) {
$hints[] = [
'color' => 'amber',
'icon' => 'archive-box',
'title' => __('Rechnungsadresse fehlt'),
'title' => $billingAddress
? __('Rechnungsadresse unvollständig')
: __('Rechnungsadresse fehlt'),
'description' => __('Hinterlegen Sie eine Rechnungsadresse, damit spätere Buchungen sauber abgerechnet werden können.'),
'href' => route('me.profile').'#rechnungsadresse',
'action' => __('Rechnungsadresse ergänzen'),
'percent' => $billingPercent,
];
}
@ -83,7 +176,6 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
if ($contactsCount === 0) {
$hints[] = [
'color' => 'blue',
'icon' => 'user-group',
'title' => __('Keine Pressekontakte hinterlegt'),
'description' => __('Ergänzen Sie Pressekontakte für diese Firma.'),
@ -98,9 +190,12 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
if ($unassignedPressReleasesCount > 0) {
$hints[] = [
'color' => 'amber',
'icon' => 'newspaper',
'title' => trans_choice(':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma', $unassignedPressReleasesCount, ['count' => $unassignedPressReleasesCount]),
'title' => trans_choice(
':count Pressemitteilung ohne Firma|:count Pressemitteilungen ohne Firma',
$unassignedPressReleasesCount,
['count' => $unassignedPressReleasesCount],
),
'description' => __('Ordnen Sie Legacy-Pressemitteilungen einer Firma zu, damit Portal und Pressekontakte eindeutig sind.'),
'href' => route('me.press-releases.index', ['company' => 'unassigned']),
'action' => __('Pressemitteilungen prüfen'),
@ -112,126 +207,371 @@ new #[Layout('components.layouts.app'), Title('Mein Dashboard')] class extends C
}
}; ?>
<div class="space-y-6">
<flux:card>
<flux:heading size="lg">{{ __('Willkommen, :name', ['name' => $user->name]) }}</flux:heading>
<flux:subheading>
{{ $selectedCompany
? __('Übersicht für :company', ['company' => $selectedCompany->name])
: __('Übersicht Ihres Kundenkontos') }}
</flux:subheading>
</flux:card>
<div class="space-y-8">
{{-- Statistiken --}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
</flux:card>
<flux:card>
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
</flux:card>
</div>
@if($qualityHints)
<flux:card>
<div class="mb-4">
<flux:heading size="sm">{{ __('Datenqualität') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">{{ __('Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.') }}</flux:text>
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · A · 01') }}</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
{{ __('Mein Dashboard') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Willkommen zurück, ') }}<strong class="font-semibold text-[color:var(--color-ink)]">{{ $user->name }}</strong>.
@if ($selectedCompany)
{{ __('Übersicht für :company', ['company' => $selectedCompany->name]) }}
@endif
{{ __('Hier sehen Sie Status und Reichweite Ihres Kundenkontos für presseecho und businessportal24.') }}
</p>
</div>
<div class="grid gap-3 lg:grid-cols-3">
@foreach($qualityHints as $hint)
<a href="{{ $hint['href'] }}" wire:navigate class="rounded-lg border border-zinc-200 p-4 transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-900">
<div class="flex items-start gap-3">
<flux:badge color="{{ $hint['color'] }}" size="sm" icon="{{ $hint['icon'] }}" />
<div class="min-w-0 flex-1">
<flux:text weight="semibold">{{ $hint['title'] }}</flux:text>
<flux:text class="mt-1 text-sm text-zinc-500">{{ $hint['description'] }}</flux:text>
<flux:text class="mt-3 text-xs font-medium text-zinc-700 dark:text-zinc-300">
{{ $hint['action'] ?? __('Öffnen') }} &rarr;
</flux:text>
</div>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
@if ($selectedCompany)
<span class="badge hub dot">
{{ __('Aktive Firma:') }} <strong class="font-semibold">{{ $selectedCompany->name }}</strong>
</span>
@else
<a href="{{ route('me.profile') }}#firmen" wire:navigate
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-[4px] text-[12px] font-semibold whitespace-nowrap bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)] hover:bg-[color:var(--color-warn-soft)]/80 transition">
<flux:icon.exclamation-triangle class="size-[13px] flex-shrink-0" />
{{ __('Keine Firma zugeordnet') }}
<span class="underline underline-offset-[3px] decoration-[color:var(--color-accent-deep)]/40">{{ __('zuordnen') }} </span>
</a>
@endif
</div>
</header>
{{-- ============== STAT-CARDS KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="$stats['total']">
<x-slot:meta>{{ now()->format('Y') }}</x-slot:meta>
<x-slot:trend>
@php
$delta = $stats['deltaMonth'];
$deltaLabel = $delta === 0
? __('keine Veränderung ggü. Vormonat')
: trans_choice(':sign:abs ggü. Vormonat', abs($delta), [
'sign' => $delta > 0 ? '+' : '',
'abs' => abs($delta),
]);
@endphp
<span class="flex items-center gap-1">
@if ($delta > 0)
<flux:icon.arrow-trending-up class="size-[11px] text-[color:var(--color-ok)]" />
@elseif ($delta < 0)
<flux:icon.arrow-trending-down class="size-[11px] text-[color:var(--color-loss)]" />
@else
<flux:icon.minus class="size-[11px] text-[color:var(--color-ink-4)]" />
@endif
{{ $deltaLabel }}
</span>
</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Veröffentlicht')" :value="$stats['published']">
<x-slot:meta>
<span class="badge ok" style="font-size:9.5px;padding:1px 6px;">{{ __('live') }}</span>
</x-slot:meta>
<x-slot:trend>{{ __('auf beiden Portalen') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('In Prüfung')" :value="$stats['review']">
<x-slot:meta>{{ __('Ø 4 h') }}</x-slot:meta>
<x-slot:trend>{{ __('redaktionelle Prüfung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Entwürfe')" :value="$stats['draft']">
<x-slot:meta>{{ __('privat') }}</x-slot:meta>
<x-slot:trend>{{ __('gespeichert, nicht eingereicht') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== ZWEISPALTEN-GRID ============== --}}
<section class="grid gap-6 lg:grid-cols-[2fr_1fr]">
{{-- LINKS: Pressemitteilungen-Liste / Empty-State --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Meine letzten Pressemitteilungen') }}</span>
<div class="flex items-center gap-3">
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $recent->count() }} {{ __('von') }} {{ $stats['total'] }}
</span>
<a href="{{ route('me.press-releases.index') }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Alle anzeigen') }}
</a>
@endforeach
</div>
</div>
</flux:card>
@endif
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
{{-- Letzte Pressemitteilungen --}}
<flux:card class="p-0">
<div class="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Meine letzten Pressemitteilungen') }}</flux:heading>
<flux:button size="sm" variant="ghost" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Alle anzeigen') }}
</flux:button>
</div>
<div class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse($recent as $pr)
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-4 py-3 transition hover:bg-zinc-50 dark:hover:bg-zinc-800">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{{ $pr->title }}</p>
<p class="text-xs text-zinc-500">{{ $pr->company?->name ?? '' }} · {{ $pr->created_at->format('d.m.Y') }}</p>
</div>
<flux:badge color="{{ match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
} }}" size="sm">
{{ $pr->status->label() }}
</flux:badge>
</a>
@empty
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Noch keine Pressemitteilungen') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
{{ __('Starten Sie mit einer ersten Pressemitteilung für die aktive Firma oder für Ihr Kundenkonto.') }}
</flux:text>
<flux:button class="mt-4" variant="primary" href="{{ route('me.press-releases.create') }}" wire:navigate>
@forelse ($recent as $pr)
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
class="flex items-center justify-between gap-3 px-5 py-3 border-b border-[color:var(--color-bg-rule)] last:border-b-0 hover:bg-[color:var(--color-bg)] transition-colors">
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] font-medium text-[color:var(--color-ink)] m-0">{{ $pr->title }}</p>
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5 m-0">
{{ $pr->company?->name ?? __('Ohne Firma') }} · {{ $pr->created_at->format('d.m.Y') }}
</p>
</div>
<span @class([
'badge',
'ok' => $pr->status === PressReleaseStatus::Published,
'warn' => $pr->status === PressReleaseStatus::Review,
'err' => $pr->status === PressReleaseStatus::Rejected,
'hub' => ! in_array($pr->status, [PressReleaseStatus::Published, PressReleaseStatus::Review, PressReleaseStatus::Rejected], true),
])>
{{ $pr->status->label() }}
</span>
</a>
@empty
<div class="px-10 py-14 flex flex-col items-center text-center">
<div class="w-16 h-16 rounded-[6px] flex items-center justify-center mb-5 relative
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.newspaper class="size-7" />
<span class="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center font-mono
bg-[color:var(--color-accent)] text-white">0</span>
</div>
<div class="text-[16px] font-semibold m-0 text-[color:var(--color-ink)]">
{{ __('Noch keine Pressemitteilungen') }}
</div>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[460px] text-[color:var(--color-ink-3)]">
{{ __('Starten Sie mit einer ersten Mitteilung für die aktive Firma oder Ihr Kundenkonto. Veröffentlichung erfolgt nach redaktioneller Prüfung auf beiden Portalen.') }}
</p>
<div class="mt-6 flex items-center gap-2.5">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Erste Pressemitteilung erstellen') }}
</flux:button>
</div>
@endforelse
</div>
</flux:card>
{{-- Zugeordnete Firmen --}}
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Meine Firmen') }}</flux:heading>
@forelse($companies as $company)
<div class="py-2 text-sm">
<p class="font-medium">{{ $company->name }}</p>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
<div class="mt-9 grid gap-3 w-full max-w-[560px] grid-cols-1 sm:grid-cols-3">
@foreach ([
['num' => '01', 'label' => __('Firma zuordnen')],
['num' => '02', 'label' => __('Mitteilung verfassen')],
['num' => '03', 'label' => __('Zur Prüfung senden')],
] as $step)
<div class="text-left px-3 py-2.5 rounded-[3px]
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
<div class="font-mono text-[9.5px] tracking-[0.16em] font-bold mb-1 text-[color:var(--color-accent-deep)]">
{{ $step['num'] }}
</div>
<div class="text-[11.5px] font-semibold leading-tight text-[color:var(--color-ink)]">
{{ $step['label'] }}
</div>
</div>
@endforeach
</div>
</div>
@endforelse
<div class="mt-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil & Firma verwalten') }}
</flux:button>
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] flex items-center gap-2.5 text-[11.5px]
bg-[color:var(--color-bg-elev)] text-[color:var(--color-ink-3)]">
<flux:icon.shield-check class="size-[12px] text-[color:var(--color-hub)] flex-shrink-0" />
{{ __('Tipp: Geprüfte Mitteilungen erscheinen i. d. R. binnen') }}
<strong class="font-semibold text-[color:var(--color-ink-2)]">{{ __('4 Stunden') }}</strong>
{{ __('werktags auf beiden Portalen.') }}
</div>
</flux:card>
</div>
</article>
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
{{-- RECHTS: Datenqualität --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Datenqualität') }}</span>
@if (count($qualityHints) > 0)
<span class="badge warn dot">{{ count($qualityHints) }} {{ __('offen') }}</span>
@else
<span class="badge ok dot">{{ __('vollständig') }}</span>
@endif
</div>
<div class="px-5 py-5">
<p class="text-[12px] leading-[1.55] m-0 mb-4 text-[color:var(--color-ink-3)]">
{{ __('Diese Hinweise helfen, Ihr User Backend vollständig und sauber zu halten.') }}
</p>
@if (count($qualityHints) > 0)
<div class="space-y-3">
@foreach ($qualityHints as $hint)
<x-portal.hint-card
:icon="$hint['icon']"
:title="$hint['title']"
:percent="$hint['percent'] ?? null"
:href="$hint['href']"
:action="$hint['action']"
>
{{ $hint['description'] }}
</x-portal.hint-card>
@endforeach
</div>
@else
<div class="flex flex-col items-center text-center py-6">
<flux:icon.check-badge class="size-8 text-[color:var(--color-ok)] mb-2" />
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ __('Alles im grünen Bereich') }}
</div>
<p class="text-[11.5px] text-[color:var(--color-ink-3)] mt-1 m-0">
{{ __('Profil, Rechnungsadresse und Firmen-Daten sind vollständig.') }}
</p>
</div>
@endif
</div>
</article>
</section>
{{-- ============== UNTERER GRID: FIRMEN + BRAND-BRIDGE ============== --}}
<section class="grid gap-6 lg:grid-cols-[2fr_1fr]">
{{-- Firmen --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Meine Firmen') }}</span>
<div class="flex items-center gap-3">
<span class="badge hub" style="font-size:9.5px;padding:1px 6px;">
{{ $companies->count() }} {{ __('zugeordnet') }}
</span>
<a href="{{ route('me.profile') }}" wire:navigate
class="text-[12px] font-semibold text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/30">
{{ __('Profil & Firma verwalten') }}
</a>
</div>
</div>
<div class="p-6">
@if ($companies->isNotEmpty())
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
@foreach ($companies as $company)
<a href="{{ route('me.press-kits.show', $company->id) }}" wire:navigate
class="block relative rounded-[5px] p-5 transition-colors
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]
hover:border-[color:var(--color-hub)]/50">
<div class="flex items-center gap-3 mb-2">
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)]
text-[color:var(--color-hub)] text-[12px] font-bold">
{{ \Illuminate\Support\Str::of($company->name)->take(2)->upper() }}
</span>
<div class="min-w-0">
<div class="text-[13.5px] font-semibold truncate text-[color:var(--color-ink)]">
{{ $company->name }}
</div>
<div class="text-[11px] mt-0.5 text-[color:var(--color-ink-3)]">
{{ __('Presse-Kit öffnen') }}
</div>
</div>
</div>
</a>
@endforeach
</div>
@else
<div class="grid gap-3 grid-cols-1 md:grid-cols-2">
<div class="relative rounded-[5px] p-5 transition-colors
border border-dashed border-[color:var(--color-bg-rule)] hover:bg-[color:var(--color-bg-elev)]">
<div class="flex items-center gap-3 mb-3">
<span class="w-10 h-10 rounded-[4px] flex items-center justify-center
border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)]
text-[color:var(--color-ink-4)]">
<flux:icon.building-office class="size-[18px]" />
</span>
<div>
<div class="text-[13.5px] font-semibold text-[color:var(--color-ink-2)]">
{{ __('Firma hinzufügen') }}
</div>
<div class="text-[11px] mt-0.5 text-[color:var(--color-ink-3)]">
{{ __('Slot frei') }}
</div>
</div>
</div>
<p class="text-[11.5px] leading-[1.5] m-0 text-[color:var(--color-ink-3)]">
{{ __('Pressestellen, für die Sie Mitteilungen erstellen — mit eigenem Logo, Kontaktperson und Themen-Tags.') }}
</p>
</div>
<div class="p-5 rounded-[5px]
bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)]">
<div class="eyebrow muted mb-2">{{ __('Hinweis') }}</div>
<div class="text-[13px] leading-[1.55] m-0 text-[color:var(--color-ink-2)]">
{{ __('Keine Firmen zugeordnet. Wenn hier eine Firma fehlen sollte, prüfen Sie bitte Ihr Profil oder wenden Sie sich an den Support.') }}
</div>
<div class="mt-3">
<flux:button size="sm" variant="ghost" href="{{ route('me.profile') }}" wire:navigate>
{{ __('Profil prüfen') }}
</flux:button>
</div>
</div>
</div>
@endif
</div>
</article>
{{-- Brand-Bridge (dark) --}}
<article class="panel-dark">
<div class="panel-head">
<span class="eyebrow on-dark">{{ __('Brand-Bridge') }}</span>
<span class="font-mono text-[10px] tracking-[0.14em] uppercase text-[color:var(--color-ink-on-dark-3)]">A · B</span>
</div>
<div class="px-5 py-5">
<div class="text-[12.5px] leading-[1.55] m-0 mb-4 text-[color:var(--color-ink-on-dark-2)]">
{{ __('Ein Konto, beide Portale — Veröffentlichungen werden parallel auf presseecho und businessportal24 ausgespielt.') }}
</div>
<div class="grid gap-3 grid-cols-2">
{{-- Konstantes dunkles Hub-Blau (Phase 5): bleibt in beiden Modi gleich. --}}
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-pe"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">presseecho</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
{{ __($bridgeStatus['presseecho']['state']) }}
</div>
<div class="text-[10.5px] mt-0.5 text-[color:var(--color-ink-on-dark-3)]">
{{ $bridgeStatus['presseecho']['subline'] }}
</div>
</div>
<div class="rounded-[4px] px-3.5 py-3 border bg-[color:var(--color-panel-dark-2)] border-white/5">
<div class="flex items-center gap-2 mb-1.5">
<span class="dot-bp"></span>
<span class="text-[11px] font-bold tracking-[0.14em] uppercase text-white/85">businessportal24</span>
</div>
<div class="font-mono text-[15px] font-semibold text-white tabular-nums">
{{ __($bridgeStatus['businessportal24']['state']) }}
</div>
<div class="text-[10.5px] mt-0.5 text-[color:var(--color-ink-on-dark-3)]">
{{ $bridgeStatus['businessportal24']['subline'] }}
</div>
</div>
</div>
<hr class="mt-5 mb-4 border-0 h-px bg-white/10" />
<div class="space-y-2 text-[11.5px] text-[color:var(--color-ink-on-dark-2)]">
<div class="flex items-center justify-between">
<span>{{ __('API-Status') }}</span>
<span class="flex items-center gap-1.5 text-white">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
{{ __('operational') }}
</span>
</div>
<div class="flex items-center justify-between">
<span>{{ __('Tarif') }}</span>
<span class="font-mono text-white">{{ __('Starter') }}</span>
</div>
</div>
</div>
</article>
</section>
{{-- ============== FOOTER ============== --}}
<footer class="flex items-center justify-between pt-4 pb-2 text-[11px]
border-t border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-3)]">
<span>© {{ now()->format('Y') }} pressekonto.de · Publisher-Hub</span>
<span class="flex items-center gap-5">
<a href="{{ route('me.security') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Sicherheit') }}</a>
<a href="{{ route('me.tokens.index') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('API & Tokens') }}</a>
<a href="{{ route('me.profile') }}" wire:navigate class="hover:text-[color:var(--color-hub)]">{{ __('Profil') }}</a>
</span>
</footer>
</div>

View file

@ -129,32 +129,44 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Neue Pressemitteilung') }}</flux:heading>
<flux:subheading>{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}</flux:subheading>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Neue PM') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Neue Pressemitteilung') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
</header>
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text…') }}" />
<flux:error name="text" />
</flux:field>
@ -170,18 +182,20 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</article>
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Firma') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Firma') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="companyId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($myCompanies as $c)
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
@ -189,11 +203,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</flux:field>
<flux:field>
<flux:label>{{ __('Kategorie') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Kategorie') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
@ -210,10 +224,13 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</flux:select>
</flux:field>
</div>
</flux:card>
</article>
<flux:card>
<div class="space-y-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
<div class="p-5 space-y-2">
<flux:button type="button" variant="primary" class="w-full" wire:click="save('review')">
{{ __('Zur Prüfung einreichen') }}
</flux:button>
@ -221,7 +238,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Als Entwurf speichern') }}
</flux:button>
</div>
</flux:card>
</div>
</article>
</aside>
</div>
</div>

View file

@ -139,32 +139,45 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
}
}; ?>
<div class="space-y-6">
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
<flux:subheading>{{ __('Inhalt und Metadaten der Pressemitteilung aktualisieren.') }}</flux:subheading>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Bearbeiten') }}</span>
<span class="badge hub">ID {{ $id }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Pressemitteilung bearbeiten') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Inhalt und Metadaten der Pressemitteilung aktualisieren.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</flux:card>
</header>
<div class="grid gap-6 lg:grid-cols-[1fr,300px]">
<div class="space-y-6">
<flux:card>
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
<div class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:input wire:model="title" />
<flux:error name="title" />
</flux:field>
<flux:field>
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
<flux:textarea wire:model="text" rows="20" />
<flux:error name="text" />
</flux:field>
@ -180,19 +193,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:error name="backlinkUrl" />
</flux:field>
</div>
</flux:card>
</article>
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
</div>
<div class="space-y-4">
<flux:card>
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
<div class="space-y-4">
<aside class="space-y-4">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
</div>
<div class="p-5 space-y-4">
<flux:field>
<flux:label>{{ __('Firma') }}</flux:label>
<flux:select wire:model="companyId">
@foreach($myCompanies as $c)
@foreach ($myCompanies as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
</flux:select>
@ -203,8 +218,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
<flux:label>{{ __('Kategorie') }}</flux:label>
<flux:select wire:model="categoryId">
<option value="">{{ __('Bitte wählen…') }}</option>
@foreach($categories as $cat)
@php $catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id; @endphp
@foreach ($categories as $cat)
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
<option value="{{ $cat->id }}">{{ $catName }}</option>
@endforeach
</flux:select>
@ -221,11 +236,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</flux:select>
</flux:field>
</div>
</flux:card>
</article>
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Speichern') }}
</flux:button>
</div>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
</div>
<div class="p-5">
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
</aside>
</div>
</div>

View file

@ -95,29 +95,52 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
}
}; ?>
<div class="space-y-6">
<div class="space-y-8">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@endif
<flux:card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<flux:heading size="lg">{{ __('Meine Pressemitteilungen') }}</flux:heading>
@if($selectedCompany)
<flux:subheading>{{ __('Gefiltert auf :company', ['company' => $selectedCompany->name]) }}</flux:subheading>
@endif
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilungen') }}</span>
</div>
<h1 class="text-[34px] font-bold tracking-[-0.7px] leading-[1.1] m-0 text-[color:var(--color-ink)]">
{{ __('Meine Pressemitteilungen') }}
</h1>
<p class="text-[13.5px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
@if ($selectedCompany)
{{ __('Gefiltert auf :company', ['company' => $selectedCompany->name]) }}
@else
{{ __('Übersicht aller PMs Ihres Kundenkontos, mit Filter und Schnellaktionen.') }}
@endif
</p>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
</flux:card>
</header>
<flux:card>
<div class="flex flex-col gap-3 sm:flex-row">
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
</div>
<div class="p-5 flex flex-col gap-3 sm:flex-row">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Titel suchen…') }}" icon="magnifying-glass" class="flex-1" />
<flux:select wire:model.live="statusFilter" class="sm:w-44">
<option value="all">{{ __('Alle Status') }}</option>
@ -133,9 +156,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
</flux:select>
@endif
</div>
</flux:card>
</article>
<flux:card class="p-0">
{{-- ============== TABELLE-PANEL ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Alle Pressemitteilungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $pressReleases->count()]) }}
</span>
</div>
<div class="p-4">
<flux:table>
<flux:table.columns>
@ -155,13 +185,13 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
<flux:text class="text-sm">{{ $pr->company?->name ?? '' }}</flux:text>
</flux:table.cell>
<flux:table.cell>
<flux:badge color="{{ match($pr->status->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
} }}">{{ $pr->status->label() }}</flux:badge>
<span @class([
'badge',
'ok' => $pr->status->value === 'published',
'warn' => $pr->status->value === 'review',
'err' => $pr->status->value === 'rejected',
'hub' => in_array($pr->status->value, ['archived', 'draft'], true),
])>{{ $pr->status->label() }}</span>
</flux:table.cell>
<flux:table.cell>
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y') }}</flux:text>
@ -180,13 +210,18 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<flux:icon.newspaper class="size-10 text-zinc-300" />
<flux:text weight="semibold" class="mt-3">{{ __('Keine Pressemitteilungen gefunden') }}</flux:text>
<flux:text class="mt-1 max-w-md text-sm text-zinc-500">
<div class="flex flex-col items-center justify-center px-4 py-12 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-4
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.newspaper class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Pressemitteilungen gefunden') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] max-w-md m-0 mb-4">
{{ __('Passen Sie die Filter an oder erstellen Sie eine neue Pressemitteilung.') }}
</flux:text>
<flux:button class="mt-4" size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
</p>
<flux:button size="sm" variant="primary" icon="plus" href="{{ route('me.press-releases.create') }}" wire:navigate>
{{ __('Neue Pressemitteilung') }}
</flux:button>
</div>
@ -195,7 +230,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
@endforelse
</flux:table>
</div>
</flux:card>
</article>
{{ $pressReleases->links() }}
</div>

View file

@ -104,76 +104,122 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
}
}; ?>
<div class="space-y-6">
@if(session('success'))
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
<div class="space-y-8">
@php
$statusClass = match ($pr->status->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
@if (session('success'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
{{ session('success') }}
</div>
@endif
<flux:card>
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<flux:badge :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ strtoupper($pr->language) }}</flux:badge>
</div>
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
<flux:text class="mt-1 text-sm text-zinc-500">
{{ $pr->company?->name ?? '' }} · {{ $categoryName }} · {{ $pr->created_at->format('d.m.Y') }}
</flux:text>
</div>
<div class="flex gap-2">
@if($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
@if (session('error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
{{ session('error') }}
</div>
@if($shareUrl)
<div class="mt-4 rounded-md border border-emerald-300 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
<flux:heading size="sm" class="mb-2">{{ __('Öffentlicher Vorschau-Link erstellt') }}</flux:heading>
<flux:text class="mb-2 text-xs text-zinc-500">{{ __('Gültig bis :date.', ['date' => $shareExpiresAt]) }}</flux:text>
<flux:input readonly :value="$shareUrl" />
</div>
@endif
</flux:card>
@if($pr->status === PressReleaseStatus::Rejected && $latestRejection)
<flux:callout color="red" icon="exclamation-triangle">
<flux:callout.heading>{{ __('Diese Pressemitteilung wurde abgelehnt') }}</flux:callout.heading>
<flux:callout.text>
@if($latestRejection->reason)
<strong>{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
@endif
<span class="mt-2 block text-xs text-red-700/70 dark:text-red-300/70">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
</flux:callout.text>
</flux:callout>
@endif
@if($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<flux:card>
<div class="flex flex-wrap items-center justify-between gap-3">
<flux:text class="text-sm text-zinc-500">
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilung') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ $pr->title }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ $pr->company?->name ?? '' }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $categoryName }}
<span class="text-[color:var(--color-bg-rule)] mx-1">·</span>
{{ $pr->created_at->format('d.m.Y') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
{{ __('Vorschau-Link') }}
</flux:button>
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zurück') }}
</flux:button>
</div>
</header>
{{-- ============== SHARE-LINK ERFOLG ============== --}}
@if ($shareUrl)
<article class="panel" style="border-color:var(--color-ok);">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Öffentlicher Vorschau-Link erstellt') }}</span>
<span class="badge ok dot">{{ __('gültig bis :date', ['date' => $shareExpiresAt]) }}</span>
</div>
<div class="p-5">
<flux:input readonly :value="$shareUrl" />
</div>
</article>
@endif
{{-- ============== REJECTION-HINWEIS ============== --}}
@if ($pr->status === PressReleaseStatus::Rejected && $latestRejection)
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Diese Pressemitteilung wurde abgelehnt') }}</span>
<span class="badge err dot">{{ __('Handlung erforderlich') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-err-soft)] border border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
<flux:icon.exclamation-triangle class="size-[18px]" />
</div>
<div class="flex-1 text-[13px] text-[color:var(--color-ink-2)]">
@if ($latestRejection->reason)
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Begründung') }}:</strong>
<span class="block mt-1 whitespace-pre-line">{{ $latestRejection->reason }}</span>
@else
{{ __('Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.') }}
@endif
<span class="mt-2 block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Abgelehnt am') }} {{ $latestRejection->created_at->format('d.m.Y H:i') }}
</span>
</div>
</div>
</article>
@endif
{{-- ============== STATUS-WORKFLOW ============== --}}
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status-Workflow') }}</span>
<span @class(['badge', 'dot', $pr->status === PressReleaseStatus::Rejected ? 'err' : 'hub'])>
{{ $pr->status === PressReleaseStatus::Rejected ? __('Überarbeiten') : __('Entwurf') }}
</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-3">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0 flex-1 min-w-[220px]">
{{ $pr->status === PressReleaseStatus::Rejected
? __('Sie können den Text bearbeiten und erneut zur Prüfung einreichen.')
: __('Reichen Sie den Entwurf ein, sobald er vollständig ist.') }}
</flux:text>
<div class="flex items-center gap-2">
@if($canEdit)
</p>
<div class="flex items-center gap-2 flex-shrink-0">
@if ($canEdit)
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
{{ __('Bearbeiten') }}
</flux:button>
@ -184,140 +230,180 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</flux:button>
</div>
</div>
</flux:card>
</article>
@endif
@if($pr->status === PressReleaseStatus::Review)
<flux:callout color="yellow" icon="clock">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</flux:callout>
@endif
<div class="grid gap-6 xl:grid-cols-2">
<flux:card>
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<flux:heading size="lg">{{ __('Zugeordnete Pressekontakte') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</flux:text>
@if ($pr->status === PressReleaseStatus::Review)
<article class="panel" style="border-color:var(--color-warn); border-left-width:3px;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('In Prüfung') }}</span>
<span class="badge warn dot">{{ __('Geduld bitte') }}</span>
</div>
<div class="p-5 flex items-start gap-3">
<div class="w-9 h-9 rounded-[5px] flex items-center justify-center flex-shrink-0
bg-[color:var(--color-warn-soft)] border border-[color:var(--color-warn)]/30 text-[color:var(--color-accent-deep)]">
<flux:icon.clock class="size-[18px]" />
</div>
<p class="flex-1 text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Ihre Pressemitteilung wird gerade geprüft. Sie werden benachrichtigt, sobald eine Entscheidung vorliegt.') }}
</p>
</div>
</article>
@endif
@if($pr->company)
{{-- ============== KONTAKTE + STATUS/VERLAUF ============== --}}
<div class="grid gap-6 xl:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
@if ($pr->company)
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
{{ __('Firma') }}
</flux:button>
@endif
</div>
<div class="space-y-3">
@forelse($contacts as $contact)
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text weight="semibold">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</flux:text>
<flux:text class="text-sm text-zinc-500">{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}</flux:text>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500">
@if($contact->email)
<a href="mailto:{{ $contact->email }}" class="text-blue-600 hover:underline dark:text-blue-400">{{ $contact->email }}</a>
@endif
@if($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
</div>
@empty
<div class="rounded-lg border border-dashed border-zinc-200 p-4 text-sm text-zinc-500 dark:border-zinc-700">
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
@if($pr->company)
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate class="font-medium text-blue-600 hover:underline dark:text-blue-400">
{{ __('Kontakte in der Firma prüfen.') }}
</a>
@endif
</div>
@endforelse
</div>
</flux:card>
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Status & Verlauf') }}</flux:heading>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Aktueller Status') }}</flux:text>
<flux:badge class="mt-1" :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Erstellt') }}</flux:text>
<flux:text weight="semibold">{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Veröffentlicht') }}</flux:text>
<flux:text weight="semibold">{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}</flux:text>
</div>
<div class="rounded-lg bg-zinc-50 p-3 dark:bg-zinc-900">
<flux:text class="text-xs text-zinc-500">{{ __('Aufrufe') }}</flux:text>
<flux:text weight="semibold">{{ number_format($pr->hits, 0, ',', '.') }}</flux:text>
</div>
</div>
<flux:separator class="my-4" />
@if($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-zinc-200 ps-4 dark:border-zinc-700">
@foreach($statusLogs as $log)
<li class="text-sm">
<div class="flex flex-wrap items-center gap-2">
@php
$color = match($log->to_status?->value) {
'published' => 'green',
'review' => 'yellow',
'rejected' => 'red',
'archived' => 'blue',
default => 'zinc',
};
@endphp
<flux:badge size="sm" :color="$color">{{ $log->to_status?->label() }}</flux:badge>
<span class="text-xs text-zinc-500">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if($log->changedBy)
<span class="text-xs text-zinc-500">
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
</span>
<div class="p-5">
<p class="text-[12px] text-[color:var(--color-ink-3)] mt-0 mb-4">
{{ __('Kontakte, die dieser Pressemitteilung zugeordnet sind.') }}
</p>
<div class="space-y-2">
@forelse ($contacts as $contact)
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ trim(($contact->first_name ?? '').' '.($contact->last_name ?? '')) ?: __('Kontakt ohne Name') }}
</div>
<div class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $contact->responsibility ?: __('Keine Rolle hinterlegt') }}
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11.5px] text-[color:var(--color-ink-3)]">
@if ($contact->email)
<a href="mailto:{{ $contact->email }}"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $contact->email }}
</a>
@endif
@if ($contact->phone)
<span>{{ $contact->phone }}</span>
@endif
</div>
@if($log->reason)
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
</div>
@empty
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] p-4 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Dieser Pressemitteilung ist noch kein Pressekontakt zugeordnet.') }}
@if ($pr->company)
<a href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate
class="font-medium text-[color:var(--color-hub)] hover:underline">
{{ __('Kontakte in der Firma prüfen.') }}
</a>
@endif
</li>
@endforeach
</ol>
@else
<flux:text class="text-sm text-zinc-500">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</flux:text>
@endif
</flux:card>
</div>
</div>
@endforelse
</div>
</div>
</article>
<flux:card>
<div class="prose prose-zinc dark:prose-invert max-w-none">
{!! nl2br(e($pr->text)) !!}
</div>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Status & Verlauf') }}</span>
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
<div class="p-5">
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aktueller Status') }}</div>
<div class="mt-1.5">
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Erstellt') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->created_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Veröffentlicht') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ $pr->published_at?->format('d.m.Y H:i') ?? '' }}
</div>
</div>
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Aufrufe') }}</div>
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
{{ number_format($pr->hits, 0, ',', '.') }}
</div>
</div>
</div>
@if($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-zinc-200 pt-4 text-sm text-zinc-500 dark:border-zinc-700">
@if($pr->keywords)
<p><strong>{{ __('Stichwörter') }}:</strong> {{ $pr->keywords }}</p>
@endif
@if($pr->backlink_url)
<p><strong>{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline">{{ $pr->backlink_url }}</a>
<div class="my-4 border-t border-[color:var(--color-bg-rule)]"></div>
@if ($statusLogs->isNotEmpty())
<ol class="space-y-3 border-s border-[color:var(--color-bg-rule)] ps-4">
@foreach ($statusLogs as $log)
<li class="text-[12.5px]">
<div class="flex flex-wrap items-center gap-2">
@php
$logClass = match ($log->to_status?->value) {
'published' => 'ok',
'review' => 'warn',
'rejected' => 'err',
default => 'hub',
};
@endphp
<span @class(['badge', $logClass])>{{ $log->to_status?->label() }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $log->created_at->format('d.m.Y H:i') }}
</span>
@if ($log->changedBy)
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('durch :name', ['name' => $log->changedBy->name]) }}
</span>
@endif
</div>
@if ($log->reason)
<p class="mt-1.5 text-[color:var(--color-ink-2)] m-0">{{ $log->reason }}</p>
@endif
</li>
@endforeach
</ol>
@else
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('Noch keine Statusänderungen protokolliert.') }}
</p>
@endif
</div>
@endif
</flux:card>
</article>
</div>
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
</div>
<div class="p-5">
<div class="prose prose-zinc dark:prose-invert max-w-none text-[color:var(--color-ink)]">
{!! nl2br(e($pr->text)) !!}
</div>
@if ($pr->keywords || $pr->backlink_url)
<div class="mt-6 space-y-2 border-t border-[color:var(--color-bg-rule)] pt-4 text-[12.5px] text-[color:var(--color-ink-2)]">
@if ($pr->keywords)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Stichwörter') }}:</strong>
{{ $pr->keywords }}
</p>
@endif
@if ($pr->backlink_url)
<p class="m-0">
<strong class="text-[color:var(--color-ink)] font-semibold">{{ __('Backlink') }}:</strong>
<a href="{{ $pr->backlink_url }}" target="_blank"
class="text-[color:var(--color-hub)] underline underline-offset-2 decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
{{ $pr->backlink_url }}
</a>
</p>
@endif
</div>
@endif
</div>
</article>
</div>

View file

@ -8,7 +8,15 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
{{-- Hub × FluxUI Phase 1: Inter Tight + JetBrains Mono + Source Serif 4
(Source Serif 4 nur für brand-mark in Headern, deshalb mitgeladen). --}}
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|jetbrains-mono:400,500,600|source-serif-4:400,500,600,700" rel="stylesheet" />
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
{{-- Phase 1 Refinement: NUR portal.css einbinden KEIN resources/js/app.js.
app.js startet Alpine via `Alpine.start()`, aber @fluxScripts (am Ende
des <body>) bringt bereits eine eigene Alpine-Instanz mit. Geladen
wir beides, läuft Alpine doppelt Browser-Warning
"Detected multiple instances of Alpine running" und kaputte Bindings.
Für x-data im Portal greift Alpine aus @fluxScripts. --}}
@vite(['resources/css/portal.css'], 'build/portal')
@fluxAppearance

View file

@ -22,7 +22,7 @@
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
@if (in_array(($theme ?? null), ['businessportal24', 'presseecho', 'presseportale'], true))
@if (in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
<link href="https://fonts.bunny.net/css?family=inter-tight:400,500,600,700|source-serif-4:400,500,600,700|jetbrains-mono:400,500,600" rel="stylesheet" />
@endif
@ -72,7 +72,7 @@
@stack('styles')
<!-- Domain-spezifische Fonts -->
@if (! in_array(($theme ?? null), ['businessportal24', 'presseecho', 'presseportale'], true))
@if (! in_array(($theme ?? null), ['businessportal24', 'presseecho', 'pressekonto'], true))
<link href="https://fonts.bunny.net/css?family=montserrat:400,500,600,700" rel="stylesheet" />
@endif
</head>

View file

@ -1,12 +1,12 @@
@extends('web.layouts.web-master')
@php
$brand = config('domains.domains.presseportale.brand', []);
$brand = config('domains.domains.pressekonto.brand', []);
$from = request()->query('from');
$todayDate = now()->locale('de')->isoFormat('dddd, D. MMMM YYYY');
@endphp
@section('title', $brand['meta_title'] ?? 'presseportale Publisher-Hub')
@section('title', $brand['meta_title'] ?? 'pressekonto Publisher-Hub')
@section('meta_description',
$brand['meta_description'] ??
'Der gemeinsame Publisher-Hub für presseecho und
@ -35,7 +35,7 @@
</h1>
<p class="text-[16px] lg:text-[17px] leading-[1.55] text-ink-2 mt-7 m-0 max-w-[560px]">
<x-web.brand-mark brand="presseportale" :serif="false" /> ist der gemeinsame Publisher-Bereich für
<x-web.brand-mark brand="pressekonto" :serif="false" /> ist der gemeinsame Publisher-Bereich für
unsere beiden Pressefachportale. Pressemitteilungen schreiben, redaktionell prüfen lassen, auf
beiden Reichweiten veröffentlichen und Reichweite, Empfänger und Abrechnung an einem Ort
verwalten.
@ -106,7 +106,7 @@
class="w-[140px] h-[140px] rounded-full bg-hub-grad flex flex-col items-center justify-center text-white shadow-lg shadow-hub/30 text-center">
<div class="text-[10px] font-bold tracking-[0.22em] uppercase text-hub-line">Hub</div>
<div class="text-[15px] font-bold tracking-[-0.3px] mt-1">
<x-web.brand-mark brand="presseportale" :serif="false" />
<x-web.brand-mark brand="pressekonto" variant="on-dark" :serif="false" />
</div>
</div>
</div>
@ -692,8 +692,8 @@
</a>
<div class="text-[11px] text-ink-on-dark-2 text-center mt-3 leading-[1.5]">
Rückruf werktags innerhalb von 4&nbsp;h<br />
<a href="mailto:vertrieb@presseportale.com"
class="text-white/85 underline underline-offset-[3px] decoration-white/30">vertrieb@presseportale.com</a>
<a href="mailto:vertrieb@pressekonto.de"
class="text-white/85 underline underline-offset-[3px] decoration-white/30">vertrieb@pressekonto.de</a>
</div>
</div>
</article>
@ -723,13 +723,13 @@
{{-- ============== PLATTFORM-FAMILIE ============== --}}
<section id="familie" class="max-w-layout mx-auto px-8 pt-16 lg:pt-20 pb-12 lg:pb-16">
<header class="mb-10">
<div class="section-eyebrow mb-4">Hinter presseportale</div>
<div class="section-eyebrow mb-4">Hinter pressekonto</div>
<h2
class="text-[26px] lg:text-[32px] font-bold m-0 tracking-[-0.6px] text-ink leading-[1.18] max-w-[820px]">
Zwei eigenständige Pressefachportale. Eine kuratierte Verlags-Familie.
</h2>
<p class="text-[14.5px] text-ink-2 leading-[1.55] m-0 mt-4 max-w-[760px]">
<x-web.brand-mark brand="presseportale" :serif="false" /> ist nicht „irgendein Tool" — es ist die
<x-web.brand-mark brand="pressekonto" :serif="false" /> ist nicht „irgendein Tool" — es ist die
zentrale Plattform für unsere beiden redaktionell geführten Pressefachportale. Jedes Portal hat einen
eigenen Charakter, eigene Leserschaft und eigene Themen-Schwerpunkte.
</p>
@ -840,7 +840,7 @@
<path d="M9 12h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<div class="text-[13px] leading-[1.55]">
Pressemitteilungen, die Sie über <x-web.brand-mark brand="presseportale" variant="on-dark"
Pressemitteilungen, die Sie über <x-web.brand-mark brand="pressekonto" variant="on-dark"
:serif="false" /> einreichen, erscheinen auf <strong class="text-white font-semibold">beiden
Portalen</strong> ohne Aufpreis, ohne doppelte Eingabe. Sie haben eine zentrale
Mitteilungs-Verwaltung und eine zentrale Reichweiten-Statistik.
@ -863,7 +863,7 @@
<div>
<p class="text-[15px] lg:text-[16px] leading-[1.7] text-ink-2 m-0">
Über <x-web.brand-mark brand="presseportale" :serif="false" /> veröffentlichen unter anderem
Über <x-web.brand-mark brand="pressekonto" :serif="false" /> veröffentlichen unter anderem
<a class="text-hub font-semibold underline underline-offset-[3px] decoration-hub/25 hover:decoration-hub"
href="#">Siemens AG</a>,
<a class="text-hub font-semibold underline underline-offset-[3px] decoration-hub/25 hover:decoration-hub"

View file

@ -24,7 +24,7 @@
Styling</h3>
<p>Die Styles werden basierend auf der Domain automatisch geladen:</p>
<ul class="list-disc pl-5 mt-2 space-y-1">
<li>presseportale.test Hauptstil (Portal)</li>
<li>pressekonto.test Hauptstil (Portal)</li>
<li>landing1.local - Landingpage 1</li>
<li>landing2.local - Landingpage 2</li>
</ul>

View file

@ -16,7 +16,7 @@
## Aktive Routen
### Basis
- `GET /` → öffentliche Publisher-Landing (`web/presseportale.blade.php`, Hub-Theme); eingeloggte Admins werden nach Login direkt nach `/dashboard` geleitet.
- `GET /` → öffentliche Publisher-Landing (`web/pressekonto.blade.php`, Hub-Theme); eingeloggte Admins werden nach Login direkt nach `/dashboard` geleitet.
- `GET /dashboard``dashboard`
- `GET /settings` (Redirect) → `settings/profile`

View file

@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
// Hinweis: Die Root-Route (`/`) auf der Portal-Domain rendert seit dem
// Hub-Launch die öffentliche Publisher-Landing (web/presseportale.blade.php).
// Hub-Launch die öffentliche Publisher-Landing (web/pressekonto.blade.php).
// Eingeloggte Admins werden über die Auth-Pipeline direkt nach /dashboard
// geleitet (siehe FORTIFY_HOME / LoginResponseContract).
Route::get('dashboard', DashboardController::class)

View file

@ -7,7 +7,7 @@ $domainPortal = config('domains.domain_portal');
$domainPresseecho = config('domains.domain_presseecho');
$domainBusinessportal = config('domains.domain_businessportal');
// Portal-Bereich (lokal: presseportale.test, live: presseportale.com via .env)
// Portal-Bereich (lokal: pressekonto.test, live: pressekonto.de via .env)
Route::domain($domainPortal)->group(function () {
// Auth-Routen laden
require __DIR__.'/auth.php';
@ -32,7 +32,7 @@ Route::domain($domainPortal)->group(function () {
});
// API-Routen für alle Domains
/*Route::domain('api.presseportale.test')->group(function () {
/*Route::domain('api.pressekonto.test')->group(function () {
require __DIR__ . '/api.php';
});*/

View file

@ -188,10 +188,10 @@ Route::get('/', function () use ($applyWebDomainConfig, $webHomeData) {
$applyWebDomainConfig('businessportal24');
return view('web.businessportal24', $webHomeData(Portal::Businessportal24));
} elseif (str_contains($domain, 'presseportale')) {
$applyWebDomainConfig('presseportale');
} elseif (str_contains($domain, 'pressekonto')) {
$applyWebDomainConfig('pressekonto');
return view('web.presseportale');
return view('web.pressekonto');
}
$applyWebDomainConfig('businessportal24');

View file

@ -11,8 +11,8 @@ echo "📋 Schritt 1: DNS-Einträge prüfen"
echo ""
echo "Bitte stelle sicher, dass folgende Einträge in deiner Hosts-Datei existieren:"
echo ""
echo "127.0.0.1 presseportale.test"
echo "127.0.0.1 assets.presseportale.test"
echo "127.0.0.1 pressekonto.test"
echo "127.0.0.1 assets.pressekonto.test"
echo "127.0.0.1 assets.presseecho.test"
echo "127.0.0.1 assets.businessportal24.test"
echo ""
@ -43,8 +43,8 @@ echo "🔍 Schritt 3: Traefik-Routen testen"
echo ""
# Test mit curl (SSL-Verifikation ignorieren für self-signed certs)
echo "Teste assets.presseportale.test..."
if curl -Iks https://assets.presseportale.test | head -1 | grep -q "404\|200"; then
echo "Teste assets.pressekonto.test..."
if curl -Iks https://assets.pressekonto.test | head -1 | grep -q "404\|200"; then
echo " ✅ Erreichbar über HTTPS"
else
echo " ⚠️ Nicht erreichbar - Docker Container neu starten!"

Some files were not shown because too many files have changed in this diff Show more