From 389d5d1820ec7e33a8a3e3763f74322dd58ed635 Mon Sep 17 00:00:00 2001
From: Kevin Adametz
Date: Fri, 23 Jan 2026 17:34:40 +0100
Subject: [PATCH] 23-01-2026
---
.devcontainer/devcontainer.json | 84 ++
INIT.md | 455 ++++++++++
.../CleanupNewsletterBlockedEmails.php | 122 +++
.../SyncNewsletterFerienwohnungen.php | 254 ++++++
.../Commands/SyncNewsletterKulturreisen.php | 226 +++++
app/Console/Commands/readme.md | 18 +
app/Exports/NewsletterExport.php | 76 ++
.../Controllers/API/NavigationController.php | 185 ++++
.../Controllers/CMS/CMSNewsController.php | 44 +-
.../Controllers/NavigationTreeController.php | 165 ++++
app/Http/Controllers/NewsletterController.php | 391 ++++++++
app/Models/NewsletterContact.php | 326 +++++++
app/Models/NewsletterLog.php | 86 ++
app/Services/NavigationTreeService.php | 681 ++++++++++++++
app/Services/Util.php | 213 +++--
composer.json | 14 +-
config/permissions.php | 133 +--
...00001_create_newsletter_contacts_table.php | 86 ++
...07_000002_create_newsletter_logs_table.php | 51 ++
..._end_date_to_newsletter_contacts_table.php | 32 +
dev/frontend-navigation/BACKEND-UI.md | 312 +++++++
.../KernelControllerListener.php | 256 ++++++
dev/frontend-navigation/PageRepository.php | 180 ++++
dev/frontend-navigation/README.md | 197 +++++
dev/frontend-navigation/header.html.twig | 325 +++++++
dev/frontend-navigation/navigation-api.md | 414 +++++++++
dev/frontend-navigation/navigation.md | 47 +
dev/frontend-navigation/test-api.html | 496 +++++++++++
dev/frontend-navigation/test-api.php | 249 ++++++
docker-compose.yml | 64 +-
docs/NEWSLETTER.md | 324 +++++++
init.sh | 151 ++++
mein.sterntours.de.code-workspace | 1 +
newsletter/assets/jordanien-1.jpg | Bin 0 -> 110371 bytes
newsletter/assets/jordanien-1.png | Bin 0 -> 1803276 bytes
newsletter/assets/jordanien-2.jpg | Bin 0 -> 104853 bytes
newsletter/assets/jordanien-2.png | Bin 0 -> 1879735 bytes
newsletter/assets/sterntours-logo.png | Bin 0 -> 23537 bytes
newsletter/assets/usedom-1.jpg | Bin 0 -> 133209 bytes
newsletter/assets/usedom-1.png | Bin 0 -> 2040379 bytes
newsletter/assets/usedom-2.jpg | Bin 0 -> 101956 bytes
newsletter/assets/usedom-2.png | Bin 0 -> 1658516 bytes
newsletter/assets/wlogo.png | Bin 0 -> 23537 bytes
newsletter/sterntours-nl1.html | 376 ++++++++
newsletter/usedom-nl1.html | 378 ++++++++
.../src/Controllers/LfmController.php | 2 +-
.../src/Controllers/UploadController.php | 110 ++-
.../laravel-filemanager/src/LfmPath.php | 79 +-
.../laravel-filemanager/src/lang/de/lfm.php | 12 +-
.../src/views/index.blade.php | 650 ++++++++------
.../views/booking/_detail_drafs.blade.php | 4 +-
resources/views/cms/news/index.blade.php | 117 +--
.../layouts/includes/layout-sidenav.blade.php | 835 +++++++++++-------
resources/views/navigation/index.blade.php | 560 ++++++++++++
resources/views/newsletter/detail.blade.php | 235 +++++
resources/views/newsletter/edit.blade.php | 150 ++++
resources/views/newsletter/index.blade.php | 322 +++++++
routes/api.php | 13 +-
routes/web.php | 24 +
59 files changed, 9642 insertions(+), 883 deletions(-)
create mode 100644 .devcontainer/devcontainer.json
create mode 100644 INIT.md
create mode 100644 app/Console/Commands/CleanupNewsletterBlockedEmails.php
create mode 100644 app/Console/Commands/SyncNewsletterFerienwohnungen.php
create mode 100644 app/Console/Commands/SyncNewsletterKulturreisen.php
create mode 100644 app/Console/Commands/readme.md
create mode 100644 app/Exports/NewsletterExport.php
create mode 100644 app/Http/Controllers/API/NavigationController.php
create mode 100644 app/Http/Controllers/NavigationTreeController.php
create mode 100644 app/Http/Controllers/NewsletterController.php
create mode 100644 app/Models/NewsletterContact.php
create mode 100644 app/Models/NewsletterLog.php
create mode 100644 app/Services/NavigationTreeService.php
create mode 100644 database/migrations/2025_11_07_000001_create_newsletter_contacts_table.php
create mode 100644 database/migrations/2025_11_07_000002_create_newsletter_logs_table.php
create mode 100644 database/migrations/2025_11_12_000001_add_last_travel_end_date_to_newsletter_contacts_table.php
create mode 100644 dev/frontend-navigation/BACKEND-UI.md
create mode 100644 dev/frontend-navigation/KernelControllerListener.php
create mode 100644 dev/frontend-navigation/PageRepository.php
create mode 100644 dev/frontend-navigation/README.md
create mode 100644 dev/frontend-navigation/header.html.twig
create mode 100644 dev/frontend-navigation/navigation-api.md
create mode 100644 dev/frontend-navigation/navigation.md
create mode 100644 dev/frontend-navigation/test-api.html
create mode 100644 dev/frontend-navigation/test-api.php
create mode 100644 docs/NEWSLETTER.md
create mode 100644 init.sh
create mode 100644 newsletter/assets/jordanien-1.jpg
create mode 100644 newsletter/assets/jordanien-1.png
create mode 100644 newsletter/assets/jordanien-2.jpg
create mode 100644 newsletter/assets/jordanien-2.png
create mode 100644 newsletter/assets/sterntours-logo.png
create mode 100644 newsletter/assets/usedom-1.jpg
create mode 100644 newsletter/assets/usedom-1.png
create mode 100644 newsletter/assets/usedom-2.jpg
create mode 100644 newsletter/assets/usedom-2.png
create mode 100644 newsletter/assets/wlogo.png
create mode 100644 newsletter/sterntours-nl1.html
create mode 100644 newsletter/usedom-nl1.html
create mode 100644 resources/views/navigation/index.blade.php
create mode 100644 resources/views/newsletter/detail.blade.php
create mode 100644 resources/views/newsletter/edit.blade.php
create mode 100644 resources/views/newsletter/index.blade.php
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..8d83327
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,84 @@
+{
+ "name": "STERN TOURS (Dev Container)",
+ // 1. DIES IST DER WICHTIGSTE TEIL:
+ // Wir verwenden Docker Compose für alle Services
+ "dockerComposeFile": [
+ "../docker-compose.yml"
+ ],
+ "service": "laravel.test",
+ // 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
+ // Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt.
+ "workspaceFolder": "/var/www/html",
+ // 4. WIR LEGEN DEN BENUTZER FEST:
+ // Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
+ "remoteUser": "sail",
+ // 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
+ // Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
+ "features": {},
+ // 6. BEFEHLE NACH DEM ERSTELLEN:
+ // Installiert nur die Tools die ohne Root-Rechte funktionieren
+ //"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
+ // 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "bmewburn.vscode-intelephense-client",
+ "onecentlin.laravel-blade",
+ "shufo.vscode-blade-formatter",
+ "bradlc.vscode-tailwindcss"
+ ]
+ }
+ },
+ // 8. ZU STARTENDE DIENSTE:
+ // Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
+ "runServices": [
+ "laravel.test",
+ "mysql",
+ "mysql-stern",
+ "redis",
+ "mailpit"
+ ],
+ // 9. ZUSÄTZLICHE KONFIGURATION:
+ // Umgebungsvariablen für den DevContainer
+ "containerEnv": {
+ "WWWUSER": "501",
+ "WWWGROUP": "20",
+ "LARAVEL_SAIL": "1"
+ },
+ // 10. MOUNT-KONFIGURATION:
+ // Stellt sicher, dass der Code korrekt gemountet wird
+ "mounts": [
+ "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
+ ],
+ // 11. FORWARD PORTS:
+ // Ports die automatisch weitergeleitet werden sollen
+ "forwardPorts": [
+ 33064,
+ 33065,
+ 6379,
+ 1030,
+ 8030
+ ],
+ "portsAttributes": {
+ "33064": {
+ "label": "MySQL",
+ "onAutoForward": "silent"
+ },
+ "33065": {
+ "label": "MySQL Stern",
+ "onAutoForward": "silent"
+ },
+ "6379": {
+ "label": "Redis",
+ "onAutoForward": "silent"
+ },
+ "1030": {
+ "label": "Mailpit SMTP",
+ "onAutoForward": "silent"
+ },
+ "8030": {
+ "label": "Mailpit Dashboard",
+ "onAutoForward": "notify"
+ }
+ }
+}
diff --git a/INIT.md b/INIT.md
new file mode 100644
index 0000000..b85cb81
--- /dev/null
+++ b/INIT.md
@@ -0,0 +1,455 @@
+# Sterntours Laravel Projekt - Initialisierung & Setup
+
+Diese Dokumentation beschreibt die Initialisierung und Einrichtung des Sterntours Laravel-Projekts.
+
+## 📋 Inhaltsverzeichnis
+
+1. [Voraussetzungen](#voraussetzungen)
+2. [Schnellstart](#schnellstart)
+3. [Manuelle Installation](#manuelle-installation)
+4. [Projekt-Struktur](#projekt-struktur)
+5. [Umgebungskonfiguration](#umgebungskonfiguration)
+6. [Datenbanken](#datenbanken)
+7. [Domains & Routing](#domains--routing)
+8. [Häufige Befehle](#häufige-befehle)
+9. [Fehlerbehebung](#fehlerbehebung)
+
+---
+
+## Voraussetzungen
+
+Bevor du das Projekt initialisierst, stelle sicher, dass folgende Software installiert ist:
+
+- **Docker Desktop** (Version 20.10+)
+ - [Download für macOS](https://www.docker.com/products/docker-desktop)
+ - [Download für Windows](https://www.docker.com/products/docker-desktop)
+ - [Download für Linux](https://docs.docker.com/engine/install/)
+
+- **Composer** (Version 2.0+)
+ - [Installation Guide](https://getcomposer.org/download/)
+
+- **Node.js & NPM** (Version 16+ empfohlen)
+ - [Download](https://nodejs.org/)
+
+- **Git** (für Versionskontrolle)
+ - [Download](https://git-scm.com/)
+
+---
+
+## Schnellstart
+
+Das Projekt kann mit einem einzigen Befehl initialisiert werden:
+
+```bash
+bash init.sh
+```
+
+Das Script führt automatisch alle notwendigen Schritte aus:
+
+1. ✓ Prüft Docker-Installation
+2. ✓ Erstellt `.env` Datei
+3. ✓ Installiert Composer-Dependencies
+4. ✓ Installiert NPM-Dependencies
+5. ✓ Generiert Application Key
+6. ✓ Erstellt Docker-Netzwerke
+7. ✓ Startet Docker-Container
+8. ✓ Führt Datenbank-Migrationen aus
+9. ✓ Erstellt Storage-Links
+10. ✓ Optimiert die Application
+
+---
+
+## Manuelle Installation
+
+Falls du das Projekt manuell einrichten möchtest:
+
+### 1. Repository klonen
+
+```bash
+git clone sterntours
+cd sterntours
+```
+
+### 2. Umgebungsvariablen konfigurieren
+
+```bash
+cp .env.example .env
+```
+
+Bearbeite die `.env` Datei nach Bedarf (siehe [Umgebungskonfiguration](#umgebungskonfiguration)).
+
+### 3. Dependencies installieren
+
+```bash
+# Composer Dependencies
+composer install
+
+# NPM Dependencies
+npm install
+```
+
+### 4. Application Key generieren
+
+```bash
+php artisan key:generate
+```
+
+### 5. Docker Proxy-Netzwerk erstellen
+
+```bash
+docker network create proxy
+```
+
+### 6. Docker Container starten (Laravel Sail)
+
+```bash
+./vendor/bin/sail up -d
+```
+
+### 7. Datenbank migrieren
+
+```bash
+./vendor/bin/sail artisan migrate
+```
+
+### 8. Storage Links erstellen
+
+```bash
+./vendor/bin/sail artisan storage:link
+```
+
+### 9. Cache optimieren
+
+```bash
+./vendor/bin/sail artisan config:cache
+./vendor/bin/sail artisan route:cache
+./vendor/bin/sail artisan view:cache
+```
+
+---
+
+## Projekt-Struktur
+
+```
+sterntours/
+├── app/ # Application Code
+│ ├── Console/ # Artisan Commands
+│ ├── Http/ # Controllers, Middleware
+│ ├── Models/ # Eloquent Models
+│ ├── Services/ # Business Logic
+│ └── Repositories/ # Data Access Layer
+├── config/ # Konfigurationsdateien
+├── database/ # Migrations, Seeds, Factories
+├── public/ # Öffentliche Assets
+├── resources/ # Views, Assets (Sass, JS)
+├── routes/ # Route Definitionen
+├── storage/ # Logs, Cache, Uploads
+├── tests/ # Unit & Feature Tests
+├── docker-compose.yml # Docker Setup
+├── init.sh # Initialisierungsskript
+└── INIT.md # Diese Datei
+```
+
+---
+
+## Umgebungskonfiguration
+
+### Wichtige .env Variablen
+
+#### Haupt-Datenbank (CRM)
+```env
+DB_CONNECTION=mysql
+DB_HOST=mysql
+DB_PORT=3306
+DB_DATABASE=stern_crm
+DB_USERNAME=sail
+DB_PASSWORD=password
+```
+
+#### Stern-Datenbank (Legacy)
+```env
+DB_CONNECTION_STERN=mysql
+DB_HOST_STERN=mysql-stern
+DB_PORT_STERN=3306
+DB_DATABASE_STERN=stern_db
+DB_USERNAME_STERN=sail
+DB_PASSWORD_STERN=password
+```
+
+#### Mail-Konfiguration (Mailpit)
+```env
+MAIL_MAILER=smtp
+MAIL_HOST=mailpit
+MAIL_PORT=1025
+MAIL_ENCRYPTION=null
+```
+
+#### Redis Cache
+```env
+REDIS_HOST=redis
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+```
+
+---
+
+## Datenbanken
+
+Das Projekt verwendet **zwei separate MySQL-Datenbanken**:
+
+### 1. stern_crm (Haupt-CRM)
+- **Port:** 33064
+- **Verwendung:** Haupt-Application, Kundenverwaltung
+- **Host in Container:** mysql
+
+### 2. stern_db (Legacy Datenbank)
+- **Port:** 33065
+- **Verwendung:** Alte Stern-Tours Daten
+- **Host in Container:** mysql-stern
+
+### Datenbankzugriff von außen
+
+```bash
+# CRM Datenbank
+mysql -h 127.0.0.1 -P 33064 -u sail -p stern_crm
+
+# Stern Datenbank
+mysql -h 127.0.0.1 -P 33065 -u sail -p stern_db
+```
+
+---
+
+## Domains & Routing
+
+Das Projekt verwendet **Traefik** als Reverse Proxy und ist über folgende Domains erreichbar:
+
+| Domain | Zweck | Port |
+|--------|-------|------|
+| `https://mein.sterntours.test` | Haupt-Application | 443 |
+| `https://sterntours.test` | Alternative URL | 443 |
+| `https://assets.sterntours.test` | Vite Dev-Server | 5173 |
+| `https://sterntours-mail.test` | Mailpit Dashboard | 8025 |
+
+### Hosts-Datei konfigurieren
+
+Füge folgende Einträge zu deiner `/etc/hosts` (Linux/Mac) oder `C:\Windows\System32\drivers\etc\hosts` (Windows) hinzu:
+
+```
+127.0.0.1 mein.sterntours.test
+127.0.0.1 sterntours.test
+127.0.0.1 assets.sterntours.test
+127.0.0.1 sterntours-mail.test
+```
+
+---
+
+## Häufige Befehle
+
+### Docker Container
+
+```bash
+# Container starten
+./vendor/bin/sail up -d
+
+# Container stoppen
+./vendor/bin/sail down
+
+# Container neu bauen
+./vendor/bin/sail build --no-cache
+
+# Logs anzeigen
+./vendor/bin/sail logs -f
+
+# Spezifischen Service anzeigen
+./vendor/bin/sail logs mysql -f
+```
+
+### Artisan Befehle
+
+```bash
+# Migrationen ausführen
+./vendor/bin/sail artisan migrate
+
+# Migrationen zurücksetzen
+./vendor/bin/sail artisan migrate:rollback
+
+# Seeds ausführen
+./vendor/bin/sail artisan db:seed
+
+# Cache leeren
+./vendor/bin/sail artisan cache:clear
+./vendor/bin/sail artisan config:clear
+./vendor/bin/sail artisan route:clear
+./vendor/bin/sail artisan view:clear
+
+# Cache optimieren
+./vendor/bin/sail artisan config:cache
+./vendor/bin/sail artisan route:cache
+./vendor/bin/sail artisan view:cache
+```
+
+### Composer
+
+```bash
+# Packages installieren
+./vendor/bin/sail composer install
+
+# Package hinzufügen
+./vendor/bin/sail composer require
+
+# Package entfernen
+./vendor/bin/sail composer remove
+
+# Autoload aktualisieren
+./vendor/bin/sail composer dump-autoload
+```
+
+### NPM / Assets
+
+```bash
+# Dependencies installieren
+npm install
+
+# Development Build
+npm run dev
+
+# Production Build
+npm run prod
+
+# Watch Mode
+npm run watch
+```
+
+### Tests
+
+```bash
+# Alle Tests ausführen
+./vendor/bin/sail test
+
+# Spezifische Test-Datei
+./vendor/bin/sail test tests/Feature/ExampleTest.php
+
+# Mit Coverage
+./vendor/bin/sail test --coverage
+```
+
+### Shell / SSH
+
+```bash
+# Shell im Container öffnen
+./vendor/bin/sail shell
+
+# Root Shell
+./vendor/bin/sail root-shell
+
+# MySQL Shell
+./vendor/bin/sail mysql
+
+# Redis CLI
+./vendor/bin/sail redis
+```
+
+---
+
+## Fehlerbehebung
+
+### Problem: "Docker is not running"
+
+**Lösung:** Starte Docker Desktop und warte, bis es vollständig gestartet ist.
+
+```bash
+# macOS/Linux
+sudo systemctl start docker
+
+# Windows
+Starte Docker Desktop über das Startmenü
+```
+
+### Problem: "Port already in use"
+
+**Lösung:** Prüfe, welche Ports belegt sind und ändere sie in der `.env` Datei:
+
+```bash
+# Ports prüfen
+lsof -i :33064
+lsof -i :33065
+
+# In .env ändern
+FORWARD_DB_PORT=33064
+FORWARD_DB_PORT_STERN=33065
+```
+
+### Problem: "Network proxy not found"
+
+**Lösung:** Erstelle das Traefik Proxy-Netzwerk manuell:
+
+```bash
+docker network create proxy
+```
+
+### Problem: "Permission denied" beim init.sh
+
+**Lösung:** Mache das Script ausführbar:
+
+```bash
+chmod +x init.sh
+./init.sh
+```
+
+### Problem: "Class not found"
+
+**Lösung:** Regeneriere Composer Autoload:
+
+```bash
+./vendor/bin/sail composer dump-autoload
+./vendor/bin/sail artisan clear-compiled
+```
+
+### Problem: "Migration failed"
+
+**Lösung:** Prüfe Datenbankverbindung und setze zurück:
+
+```bash
+# Verbindung testen
+./vendor/bin/sail artisan tinker
+>>> DB::connection()->getPdo();
+
+# Migrationen zurücksetzen
+./vendor/bin/sail artisan migrate:fresh
+```
+
+### Problem: "Vite/Assets nicht geladen"
+
+**Lösung:** Starte den Dev-Server:
+
+```bash
+npm run dev
+# oder
+npm run watch
+```
+
+---
+
+## Support & Kontakt
+
+Bei Fragen oder Problemen:
+
+1. Prüfe die [Fehlerbehebung](#fehlerbehebung)
+2. Prüfe die [Laravel Dokumentation](https://laravel.com/docs)
+3. Prüfe die [Laravel Sail Dokumentation](https://laravel.com/docs/sail)
+
+---
+
+## Changelog
+
+### Version 1.0.0 (2025-11-07)
+- ✓ Initiales Setup-Script erstellt
+- ✓ Dokumentation erstellt
+- ✓ Docker Compose Konfiguration
+- ✓ Dual-Datenbank Setup
+- ✓ Traefik Routing konfiguriert
+
+---
+
+**Viel Erfolg mit dem Projekt! 🚀**
+
diff --git a/app/Console/Commands/CleanupNewsletterBlockedEmails.php b/app/Console/Commands/CleanupNewsletterBlockedEmails.php
new file mode 100644
index 0000000..f87ce0b
--- /dev/null
+++ b/app/Console/Commands/CleanupNewsletterBlockedEmails.php
@@ -0,0 +1,122 @@
+info('Suche nach blockierten E-Mail-Adressen...');
+
+ $dryRun = $this->option('dry-run');
+
+ // Liste der blockierten Domains
+ $blockedDomains = [
+ '@guest.booking.com',
+ '@messages.homeaway.com',
+ '@fewo.check24.de',
+ '@booking.com',
+ '@homeaway.com',
+ '@check24.de',
+ '@partner.booking.com',
+ ];
+
+ $stats = [
+ 'found' => 0,
+ 'deleted' => 0,
+ ];
+
+ // Hole alle Newsletter-Kontakte
+ $contacts = NewsletterContact::all();
+
+ $this->info("Prüfe {$contacts->count()} Kontakte...");
+
+ $bar = $this->output->createProgressBar($contacts->count());
+ $bar->start();
+
+ foreach ($contacts as $contact) {
+ $emailLower = strtolower($contact->email);
+ $isBlockedEmail = false;
+
+ foreach ($blockedDomains as $domain) {
+ if (str_ends_with($emailLower, strtolower($domain))) {
+ $isBlockedEmail = true;
+ break;
+ }
+ }
+
+ if ($isBlockedEmail) {
+ $stats['found']++;
+
+ if ($dryRun) {
+ $this->newLine();
+ $this->warn("Würde löschen: {$contact->email} (ID: {$contact->id})");
+ } else {
+ // Log erstellen vor dem Löschen
+ $contact->logs()->create([
+ 'action' => 'deleted',
+ 'description' => 'Kontakt entfernt - blockierte E-Mail-Domain (Buchungsplattform-Alias)',
+ ]);
+
+ $contact->delete();
+ $stats['deleted']++;
+ }
+ }
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+
+ // Statistiken ausgeben
+ if ($dryRun) {
+ $this->info('Dry-Run abgeschlossen!');
+ $this->table(
+ ['Statistik', 'Anzahl'],
+ [
+ ['Gefundene blockierte E-Mails', $stats['found']],
+ ]
+ );
+
+ if ($stats['found'] > 0) {
+ $this->newLine();
+ $this->info('Führe den Befehl ohne --dry-run aus, um die Kontakte zu löschen:');
+ $this->comment('php artisan newsletter:cleanup-blocked-emails');
+ }
+ } else {
+ $this->info('Bereinigung abgeschlossen!');
+ $this->table(
+ ['Statistik', 'Anzahl'],
+ [
+ ['Gefundene blockierte E-Mails', $stats['found']],
+ ['Gelöschte Kontakte', $stats['deleted']],
+ ]
+ );
+ }
+
+ return 0;
+ }
+}
diff --git a/app/Console/Commands/SyncNewsletterFerienwohnungen.php b/app/Console/Commands/SyncNewsletterFerienwohnungen.php
new file mode 100644
index 0000000..3630a9a
--- /dev/null
+++ b/app/Console/Commands/SyncNewsletterFerienwohnungen.php
@@ -0,0 +1,254 @@
+info('Starte Synchronisation von Ferienwohnungs-Buchungen...');
+
+ $force = $this->option('force');
+
+ // Statistiken
+ $stats = [
+ 'processed' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'errors' => 0,
+ ];
+
+ // Hole alle Buchungen mit TravelUser und invoice_number
+ $query = TravelUserBookingFewo::with(['travel_user'])
+ ->whereNotNull('travel_user_id')
+ ->whereNotNull('invoice_number')
+ ->where('invoice_number', '!=', '')
+ // Nur wenn invoice_number eine reine Nummer ist (keine Storno etc.)
+ ->whereRaw('invoice_number REGEXP "^[0-9]+$"')
+ ->whereHas('travel_user', function ($q) {
+ $q->whereNotNull('email')
+ ->where('email', '!=', '');
+ })
+ // Nur Buchungen, bei denen die Reise bereits beendet ist (to_date in der Vergangenheit)
+ ->whereNotNull('to_date')
+ ->where('to_date', '<', now());
+
+ if (!$force) {
+ // Nur Buchungen der letzten 30 Tage (basierend auf Rückreisedatum) wenn nicht --force
+ $query->where('to_date', '>=', now()->subDays(30));
+ }
+
+ $bookings = $query->get();
+
+ $this->info("Verarbeite {$bookings->count()} Buchungen...");
+
+ $bar = $this->output->createProgressBar($bookings->count());
+ $bar->start();
+
+ foreach ($bookings as $booking) {
+ try {
+ $stats['processed']++;
+
+ $travelUser = $booking->travel_user;
+
+ // Validiere E-Mail
+ if (!$travelUser || !$travelUser->email || !filter_var($travelUser->email, FILTER_VALIDATE_EMAIL)) {
+ $stats['skipped']++;
+ $bar->advance();
+ continue;
+ }
+
+ // Filtere Alias/Proxy E-Mail-Adressen von Buchungsplattformen
+ $blockedDomains = [
+ '@guest.booking.com',
+ '@messages.homeaway.com',
+ '@fewo.check24.de',
+ '@booking.com',
+ '@homeaway.com',
+ '@check24.de',
+ '@partner.booking.com',
+ ];
+
+ $emailLower = strtolower($travelUser->email);
+ $isBlockedEmail = false;
+ foreach ($blockedDomains as $domain) {
+ if (str_ends_with($emailLower, strtolower($domain))) {
+ $isBlockedEmail = true;
+ break;
+ }
+ }
+
+ if ($isBlockedEmail) {
+ $stats['skipped']++;
+ $bar->advance();
+ continue;
+ }
+
+ // Prüfe ob invoice_number wirklich eine reine Zahl ist
+ if (!preg_match('/^[0-9]+$/', $booking->invoice_number)) {
+ $stats['skipped']++;
+ $bar->advance();
+ continue;
+ }
+
+ // Generiere Hash für Duplikat-Erkennung
+ $syncHash = NewsletterContact::generateSyncHash(
+ $travelUser->email,
+ NewsletterContact::SOURCE_BOOKING_FERIENWOHNUNGEN
+ );
+
+ // Suche oder erstelle Kontakt
+ $contact = NewsletterContact::withTrashed()
+ ->where('email', strtolower(trim($travelUser->email)))
+ ->first();
+
+ $isNew = false;
+
+ if (!$contact) {
+ // Neuer Kontakt
+ $contact = new NewsletterContact();
+ $isNew = true;
+ $stats['created']++;
+ } else {
+ // Wenn gelöscht, wiederherstellen
+ if ($contact->trashed()) {
+ $contact->restore();
+ }
+ $stats['updated']++;
+ }
+
+ // Aktualisiere Kontaktdaten
+ $contact->email = strtolower(trim($travelUser->email));
+ $contact->firstname = $travelUser->first_name ?: $contact->firstname;
+ $contact->lastname = $travelUser->last_name ?: $contact->lastname;
+
+ // Setze Gruppe Ferienwohnungen
+ $contact->group_ferienwohnungen = true;
+
+ // Source nur bei neuem Kontakt setzen (wenn noch nicht aus Kulturreisen)
+ if ($isNew) {
+ $contact->source = NewsletterContact::SOURCE_BOOKING_FERIENWOHNUNGEN;
+ $contact->subscribed_at = $booking->booking_date ?
+ \Carbon\Carbon::parse($booking->booking_date) :
+ $booking->created_at;
+ }
+
+ // Referenz zum TravelUser
+ $contact->travel_user_id = $travelUser->id;
+
+ // Aktualisiere Buchungsstatistiken
+ // Nur Buchungen mit invoice_number (reine Nummer) zählen
+ $userBookings = TravelUserBookingFewo::where('travel_user_id', $travelUser->id)
+ ->whereNotNull('invoice_number')
+ ->where('invoice_number', '!=', '')
+ ->whereRaw('invoice_number REGEXP "^[0-9]+$"')
+ ->count();
+
+ $contact->total_bookings_ferienwohnungen = $userBookings;
+
+ // Letztes Buchungsdatum
+ $lastBooking = TravelUserBookingFewo::where('travel_user_id', $travelUser->id)
+ ->whereNotNull('invoice_number')
+ ->where('invoice_number', '!=', '')
+ ->whereRaw('invoice_number REGEXP "^[0-9]+$"')
+ ->orderBy('booking_date', 'DESC')
+ ->first();
+
+ if ($lastBooking && $lastBooking->booking_date) {
+ $lastBookingDate = \Carbon\Carbon::parse($lastBooking->booking_date);
+ if (!$contact->last_booking_at || $lastBookingDate->gt($contact->last_booking_at)) {
+ $contact->last_booking_at = $lastBookingDate;
+ }
+ }
+
+ // Letztes Reiseenddatum (to_date) - nur abgeschlossene Reisen
+ $lastTravelEndBooking = TravelUserBookingFewo::where('travel_user_id', $travelUser->id)
+ ->whereNotNull('invoice_number')
+ ->where('invoice_number', '!=', '')
+ ->whereRaw('invoice_number REGEXP "^[0-9]+$"')
+ ->whereNotNull('to_date')
+ ->where('to_date', '<', now())
+ ->orderBy('to_date', 'DESC')
+ ->first();
+
+ if ($lastTravelEndBooking && $lastTravelEndBooking->to_date) {
+ $lastTravelEndDate = \Carbon\Carbon::parse($lastTravelEndBooking->to_date);
+ if (!$contact->last_travel_end_date || $lastTravelEndDate->gt($contact->last_travel_end_date)) {
+ $contact->last_travel_end_date = $lastTravelEndDate;
+ }
+ }
+
+ // Status
+ if ($isNew || $contact->status === NewsletterContact::STATUS_INACTIVE) {
+ $contact->status = NewsletterContact::STATUS_ACTIVE;
+ }
+
+ $contact->sync_hash = $syncHash;
+ $contact->last_synced_at = now();
+
+ $contact->save();
+
+ // Log erstellen
+ if ($isNew) {
+ $contact->logs()->create([
+ 'action' => 'booking_added',
+ 'description' => 'Kontakt durch Ferienwohnungs-Buchung erstellt',
+ 'metadata' => [
+ 'booking_id' => $booking->id,
+ 'invoice_number' => $booking->invoice_number,
+ ],
+ ]);
+ }
+ } catch (\Exception $e) {
+ $stats['errors']++;
+ $this->error("Fehler bei Buchung {$booking->id}: " . $e->getMessage());
+ }
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+
+ // Statistiken ausgeben
+ $this->info('Synchronisation abgeschlossen!');
+ $this->table(
+ ['Statistik', 'Anzahl'],
+ [
+ ['Verarbeitet', $stats['processed']],
+ ['Neu erstellt', $stats['created']],
+ ['Aktualisiert', $stats['updated']],
+ ['Übersprungen', $stats['skipped']],
+ ['Fehler', $stats['errors']],
+ ]
+ );
+
+ return 0;
+ }
+}
diff --git a/app/Console/Commands/SyncNewsletterKulturreisen.php b/app/Console/Commands/SyncNewsletterKulturreisen.php
new file mode 100644
index 0000000..c895505
--- /dev/null
+++ b/app/Console/Commands/SyncNewsletterKulturreisen.php
@@ -0,0 +1,226 @@
+info('Starte Synchronisation von Kulturreisen-Buchungen...');
+
+ $force = $this->option('force');
+
+ // Statistiken
+ $stats = [
+ 'processed' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'skipped' => 0,
+ 'errors' => 0,
+ ];
+
+ // Hole alle Buchungen mit Kunden
+ $query = Booking::with(['customer', 'lead.status'])
+ ->whereNotNull('customer_id')
+ ->whereHas('customer', function ($q) {
+ $q->whereNotNull('email')
+ ->where('email', '!=', '');
+ })
+ // Nur Buchungen, bei denen die Reise bereits beendet ist (end_date in der Vergangenheit)
+ ->whereNotNull('end_date')
+ ->where('end_date', '<', now());
+
+ if (!$force) {
+ // Nur Buchungen der letzten 30 Tage (basierend auf Rückreisedatum) wenn nicht --force
+ $query->where('end_date', '>=', now()->subDays(30));
+ }
+
+ $bookings = $query->get();
+
+ $this->info("Verarbeite {$bookings->count()} Buchungen...");
+
+ $bar = $this->output->createProgressBar($bookings->count());
+ $bar->start();
+
+ foreach ($bookings as $booking) {
+ try {
+ $stats['processed']++;
+
+ $customer = $booking->customer;
+
+ // Validiere E-Mail
+ if (!$customer || !$customer->email || !filter_var($customer->email, FILTER_VALIDATE_EMAIL)) {
+ $stats['skipped']++;
+ $bar->advance();
+ continue;
+ }
+
+ // Filtere Alias/Proxy E-Mail-Adressen von Buchungsplattformen
+ $blockedDomains = [
+ '@guest.booking.com',
+ '@messages.homeaway.com',
+ '@fewo.check24.de',
+ '@booking.com',
+ '@homeaway.com',
+ '@check24.de',
+ '@partner.booking.com',
+ ];
+
+ $emailLower = strtolower($customer->email);
+ $isBlockedEmail = false;
+ foreach ($blockedDomains as $domain) {
+ if (str_ends_with($emailLower, strtolower($domain))) {
+ $isBlockedEmail = true;
+ break;
+ }
+ }
+
+ if ($isBlockedEmail) {
+ $stats['skipped']++;
+ $bar->advance();
+ continue;
+ }
+
+ // Generiere Hash für Duplikat-Erkennung
+ $syncHash = NewsletterContact::generateSyncHash(
+ $customer->email,
+ NewsletterContact::SOURCE_BOOKING_KULTURREISEN
+ );
+
+ // Suche oder erstelle Kontakt
+ $contact = NewsletterContact::withTrashed()
+ ->where('email', strtolower(trim($customer->email)))
+ ->first();
+
+ $isNew = false;
+
+ if (!$contact) {
+ // Neuer Kontakt
+ $contact = new NewsletterContact();
+ $isNew = true;
+ $stats['created']++;
+ } else {
+ // Wenn gelöscht, wiederherstellen
+ if ($contact->trashed()) {
+ $contact->restore();
+ }
+ $stats['updated']++;
+ }
+
+ // Aktualisiere Kontaktdaten
+ $contact->email = strtolower(trim($customer->email));
+ $contact->firstname = $customer->firstname ?: $contact->firstname;
+ $contact->lastname = $customer->name ?: $contact->lastname;
+
+ // Setze Gruppe Kulturreisen
+ $contact->group_kulturreisen = true;
+
+ // Source nur bei neuem Kontakt setzen
+ if ($isNew) {
+ $contact->source = NewsletterContact::SOURCE_BOOKING_KULTURREISEN;
+ $contact->subscribed_at = $booking->booking_date ?: $booking->created_at;
+ }
+
+ // Referenz zum Customer
+ $contact->customer_id = $customer->id;
+
+ // Aktualisiere Buchungsstatistiken
+ $customerBookings = Booking::where('customer_id', $customer->id)->count();
+ $contact->total_bookings_kulturreisen = $customerBookings;
+
+ // Letztes Buchungsdatum
+ $lastBooking = Booking::where('customer_id', $customer->id)
+ ->orderBy('booking_date', 'DESC')
+ ->first();
+
+ if ($lastBooking && $lastBooking->booking_date) {
+ $contact->last_booking_at = $lastBooking->booking_date;
+ }
+
+ // Letztes Reiseenddatum (end_date) - nur abgeschlossene Reisen
+ $lastTravelEndBooking = Booking::where('customer_id', $customer->id)
+ ->whereNotNull('end_date')
+ ->where('end_date', '<', now())
+ ->orderBy('end_date', 'DESC')
+ ->first();
+
+ if ($lastTravelEndBooking && $lastTravelEndBooking->end_date) {
+ if (!$contact->last_travel_end_date || $lastTravelEndBooking->end_date->gt($contact->last_travel_end_date)) {
+ $contact->last_travel_end_date = $lastTravelEndBooking->end_date;
+ }
+ }
+
+ // Status
+ if ($isNew || $contact->status === NewsletterContact::STATUS_INACTIVE) {
+ $contact->status = NewsletterContact::STATUS_ACTIVE;
+ }
+
+ $contact->sync_hash = $syncHash;
+ $contact->last_synced_at = now();
+
+ $contact->save();
+
+ // Log erstellen
+ if ($isNew) {
+ $contact->logs()->create([
+ 'action' => 'booking_added',
+ 'description' => 'Kontakt durch Kulturreisen-Buchung erstellt',
+ 'metadata' => [
+ 'booking_id' => $booking->id,
+ 'lead_id' => $booking->lead_id,
+ ],
+ ]);
+ }
+ } catch (\Exception $e) {
+ $stats['errors']++;
+ $this->error("Fehler bei Buchung {$booking->id}: " . $e->getMessage());
+ }
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+
+ // Statistiken ausgeben
+ $this->info('Synchronisation abgeschlossen!');
+ $this->table(
+ ['Statistik', 'Anzahl'],
+ [
+ ['Verarbeitet', $stats['processed']],
+ ['Neu erstellt', $stats['created']],
+ ['Aktualisiert', $stats['updated']],
+ ['Übersprungen', $stats['skipped']],
+ ['Fehler', $stats['errors']],
+ ]
+ );
+
+ return 0;
+ }
+}
diff --git a/app/Console/Commands/readme.md b/app/Console/Commands/readme.md
new file mode 100644
index 0000000..5b30dca
--- /dev/null
+++ b/app/Console/Commands/readme.md
@@ -0,0 +1,18 @@
+Newsletter-Synchronisation (wie gewohnt):
+
+# Normale Synchronisation (letzte 30 Tage)
+
+php artisan newsletter:sync-ferienwohnungenphp artisan newsletter:sync-kulturreisen
+
+# Vollständige Synchronisation
+
+php artisan newsletter:sync-ferienwohnungen --forcephp artisan newsletter:sync-kulturreisen --force
+#Bereinigung bestehender blockierter E-Mails:
+
+# Erst testen (zeigt nur an, was gelöscht würde)
+
+php artisan newsletter:cleanup-blocked-emails --dry-run
+
+# Tatsächlich löschen
+
+php artisan newsletter:cleanup-blocked-emails
diff --git a/app/Exports/NewsletterExport.php b/app/Exports/NewsletterExport.php
new file mode 100644
index 0000000..a42d56a
--- /dev/null
+++ b/app/Exports/NewsletterExport.php
@@ -0,0 +1,76 @@
+contacts = $contacts;
+ }
+
+ /**
+ * @return \Illuminate\Support\Collection
+ */
+ public function collection()
+ {
+ return $this->contacts;
+ }
+
+ /**
+ * @return array
+ */
+ public function headings(): array
+ {
+ return [
+ 'ID',
+ 'E-Mail',
+ 'Vorname',
+ 'Nachname',
+ 'Gruppe Kulturreisen',
+ 'Gruppe Ferienwohnungen',
+ 'Status',
+ 'Herkunft',
+ 'Buchungen Kulturreisen',
+ 'Buchungen Ferienwohnungen',
+ 'Letzte Buchung',
+ 'Letzte Reise',
+ 'Angemeldet am',
+ 'Abgemeldet am',
+ 'Erstellt am',
+ ];
+ }
+
+ /**
+ * @param NewsletterContact $contact
+ * @return array
+ */
+ public function map($contact): array
+ {
+ return [
+ $contact->id,
+ $contact->email,
+ $contact->firstname,
+ $contact->lastname,
+ $contact->group_kulturreisen ? 'Ja' : 'Nein',
+ $contact->group_ferienwohnungen ? 'Ja' : 'Nein',
+ $contact->status_label,
+ $contact->source_label,
+ $contact->total_bookings_kulturreisen,
+ $contact->total_bookings_ferienwohnungen,
+ $contact->last_booking_at ? $contact->last_booking_at->format('d.m.Y') : '',
+ $contact->last_travel_end_date ? $contact->last_travel_end_date->format('d.m.Y') : '',
+ $contact->subscribed_at ? $contact->subscribed_at->format('d.m.Y H:i') : '',
+ $contact->unsubscribed_at ? $contact->unsubscribed_at->format('d.m.Y H:i') : '',
+ $contact->created_at->format('d.m.Y H:i'),
+ ];
+ }
+}
diff --git a/app/Http/Controllers/API/NavigationController.php b/app/Http/Controllers/API/NavigationController.php
new file mode 100644
index 0000000..e20b3e4
--- /dev/null
+++ b/app/Http/Controllers/API/NavigationController.php
@@ -0,0 +1,185 @@
+navigationService = $navigationService;
+ }
+
+ /**
+ * Gibt den kompletten Navigationsbaum zurück
+ *
+ * @return JsonResponse
+ */
+ public function getNavigationTree(): JsonResponse
+ {
+ try {
+ $tree = $this->navigationService->getNavigationTree();
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $tree,
+ 'meta' => [
+ 'total_nodes' => $this->navigationService->countNodes($tree),
+ 'generated_at' => now()->toIso8601String()
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Gibt einen spezifischen Teil des Navigationsbaums zurück
+ *
+ * @param int $rootId Die ID des Root-Knotens
+ * @return JsonResponse
+ */
+ public function getNavigationSubTree(int $rootId): JsonResponse
+ {
+ try {
+ $tree = $this->navigationService->getNavigationSubTree($rootId);
+
+ if (!$tree) {
+ return response()->json([
+ 'success' => false,
+ 'error' => 'Navigation node not found'
+ ], 404);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $tree,
+ 'meta' => [
+ 'total_nodes' => $this->navigationService->countNodes([$tree]),
+ 'generated_at' => now()->toIso8601String()
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Gibt eine flache Liste aller Navigationspunkte zurück (ohne Hierarchie)
+ *
+ * @return JsonResponse
+ */
+ public function getFlatNavigationList(): JsonResponse
+ {
+ try {
+ $list = $this->navigationService->getFlatNavigationList();
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $list,
+ 'meta' => [
+ 'total_nodes' => count($list),
+ 'generated_at' => now()->toIso8601String()
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Gibt nur die aktiven Navigationspunkte zurück
+ *
+ * @return JsonResponse
+ */
+ public function getActiveNavigationTree(): JsonResponse
+ {
+ try {
+ $tree = $this->navigationService->getNavigationTree(true);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $tree,
+ 'meta' => [
+ 'total_nodes' => $this->navigationService->countNodes($tree),
+ 'generated_at' => now()->toIso8601String()
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück
+ *
+ * @param int $pageId
+ * @return JsonResponse
+ */
+ public function getBreadcrumb(int $pageId): JsonResponse
+ {
+ try {
+ $breadcrumb = $this->navigationService->getBreadcrumb($pageId);
+
+ if (empty($breadcrumb)) {
+ return response()->json([
+ 'success' => false,
+ 'error' => 'Page not found'
+ ], 404);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $breadcrumb,
+ 'meta' => [
+ 'depth' => count($breadcrumb),
+ 'generated_at' => now()->toIso8601String()
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Löscht den Navigation-Cache
+ *
+ * @return JsonResponse
+ */
+ public function clearCache(): JsonResponse
+ {
+ try {
+ $this->navigationService->clearCache();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Navigation cache cleared successfully'
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+}
diff --git a/app/Http/Controllers/CMS/CMSNewsController.php b/app/Http/Controllers/CMS/CMSNewsController.php
index 5545400..ad575e3 100644
--- a/app/Http/Controllers/CMS/CMSNewsController.php
+++ b/app/Http/Controllers/CMS/CMSNewsController.php
@@ -8,12 +8,13 @@ use App\Models\News;
use App\Models\Page;
use Carbon\Carbon;
use IqContent\LaravelFilemanager\Lfm;
+use Illuminate\Support\Str;
use Request;
class CMSNewsController extends Controller
{
-/*
+ /*
* Create a new controller instance.
*
* @return void
@@ -21,29 +22,25 @@ class CMSNewsController extends Controller
public function __construct()
{
$this->middleware(['admin', '2fa']);
-
}
public function index()
{
$data = [
- 'news' => News::all(),//News::where('lvl', 1)->get(),
+ 'news' => News::all(), //News::where('lvl', 1)->get(),
];
return view('cms.news.index', $data);
-
}
public function detail($id)
{
- if($id === "new") {
+ if ($id === "new") {
$news = new News();
$id = 'new';
$news->status = 1;
$news->content_new = "";
-
-
- }else{
+ } else {
$news = News::findOrFail($id);
$id = $news->id;
}
@@ -53,26 +50,25 @@ class CMSNewsController extends Controller
'lfm_helper' => app(Lfm::class),
];
return view('cms.news.detail', $data);
-
}
public function store($id)
{
$data = Request::all();
- if($id === "new") {
+ if ($id === "new") {
$news = new News();
$news->model = 'news';
$news->owner_second = 0;
$news->show_in_navi = 1;
$news->catalog_id = 1;
- }else{
+ } else {
$news = News::findOrFail($id);
}
-
+
$news->title = $data['title'];
$news->status = isset($data['status']) ? true : false;
- $news->slug = $data['slug'];
+ $news->slug = Str::slug($data['slug']);
$news->date = $data['date'];
$news->content_new = $data['content_new'];
$news->box_body = $data['image'];
@@ -80,37 +76,37 @@ class CMSNewsController extends Controller
$news->pagetitle = $data['pagetitle'];
$news->keywords = $data['keywords'];
- $news->order = (new Carbon($news->date))->format('Ymd')*-1;
+ $news->order = (new Carbon($news->date))->format('Ymd') * -1;
$root_news = News::where('cms_settings', 'news_root')->first();
- if($id != $root_news->id){
+ if ($id != $root_news->id) {
//root ID = 3126
$news->lvl = 1;
$news->owner = $root_news->id;
$news->parent_id = $root_news->id;
$news->tree_root = $root_news->id;
- if($first_news = $root_news->children->first()){
+ if ($first_news = $root_news->children->first()) {
$news->lft = $first_news->lft;
$news->rgt = $first_news->rgt;
- }else{
- $news->lft = $root_news->lft +1;
- $news->rgt = $root_news->lft +2;
+ } else {
+ $news->lft = $root_news->lft + 1;
+ $news->rgt = $root_news->lft + 2;
}
}
-
+
$news->save();
\Session()->flash('alert-save', '1');
return redirect(route('cms_news_detail', [$news->id]));
-
}
- public function delete($id){
+ public function delete($id)
+ {
$news = News::findOrFail($id);
//TODO
//check for delete, only delete lvl 2 .,...?
- if ($news->lvl != 1){
+ if ($news->lvl != 1) {
abort(404);
die();
}
@@ -119,6 +115,4 @@ class CMSNewsController extends Controller
\Session()->flash('alert-success', __('News gelöscht'));
return redirect(route('cms_news'));
}
-
-
}
diff --git a/app/Http/Controllers/NavigationTreeController.php b/app/Http/Controllers/NavigationTreeController.php
new file mode 100644
index 0000000..9c9c542
--- /dev/null
+++ b/app/Http/Controllers/NavigationTreeController.php
@@ -0,0 +1,165 @@
+navigationService = $navigationService;
+ }
+
+ /**
+ * Zeigt die Navigationsbaum-Übersicht
+ *
+ * @return \Illuminate\View\View
+ */
+ public function index()
+ {
+ return view('navigation.index');
+ }
+
+ /**
+ * Gibt die Navigationsbaum-Daten als JSON zurück (Frontend-Struktur)
+ *
+ * @param Request $request
+ * @return JsonResponse
+ */
+ public function getData(Request $request): JsonResponse
+ {
+ try {
+ $includeHidden = $request->get('include_hidden', true);
+ $tree = $this->navigationService->getFrontendNavigationTree($includeHidden);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $tree,
+ 'meta' => [
+ 'total_nodes' => $this->navigationService->countNodes($tree),
+ 'include_hidden' => $includeHidden,
+ 'structure' => 'frontend'
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Sucht im Navigationsbaum
+ *
+ * @param Request $request
+ * @return JsonResponse
+ */
+ public function search(Request $request): JsonResponse
+ {
+ try {
+ $query = $request->get('query', '');
+ $flatList = $this->navigationService->getFlatNavigationList();
+
+ // Filtere nach Suchbegriff
+ $results = array_filter($flatList, function ($node) use ($query) {
+ return stripos($node['title'], $query) !== false
+ || stripos($node['slug'], $query) !== false
+ || stripos($node['url'], $query) !== false;
+ });
+
+ return response()->json([
+ 'success' => true,
+ 'data' => array_values($results),
+ 'meta' => [
+ 'query' => $query,
+ 'total_results' => count($results)
+ ]
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+
+ /**
+ * Exportiert den Navigationsbaum als JSON-Datei
+ *
+ * @param Request $request
+ * @return \Illuminate\Http\Response
+ */
+ public function export(Request $request)
+ {
+ try {
+ $includeHidden = $request->get('include_hidden', true);
+ $tree = $this->navigationService->getFrontendNavigationTree($includeHidden);
+
+ $filename = 'navigation-tree-frontend-' . date('Y-m-d-His') . '.json';
+ $json = json_encode($tree, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+
+ return response($json)
+ ->header('Content-Type', 'application/json')
+ ->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
+ } catch (\Exception $e) {
+ return back()->with('error', 'Export fehlgeschlagen: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Löscht den Cache
+ *
+ * @return \Illuminate\Http\RedirectResponse
+ */
+ public function clearCache()
+ {
+ try {
+ $this->navigationService->clearCache();
+ return back()->with('success', 'Navigation-Cache erfolgreich gelöscht');
+ } catch (\Exception $e) {
+ return back()->with('error', 'Cache-Löschung fehlgeschlagen: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Zeigt die Statistiken
+ *
+ * @return JsonResponse
+ */
+ public function stats(): JsonResponse
+ {
+ try {
+ $allTree = $this->navigationService->getNavigationTree(false);
+ $activeTree = $this->navigationService->getNavigationTree(true);
+
+ $flatList = $this->navigationService->getFlatNavigationList();
+
+ // Zähle verschiedene Typen
+ $stats = [
+ 'total_pages' => count($flatList),
+ 'total_nodes' => $this->navigationService->countNodes($allTree),
+ 'active_nodes' => $this->navigationService->countNodes($activeTree),
+ 'inactive_nodes' => $this->navigationService->countNodes($allTree) - $this->navigationService->countNodes($activeTree),
+ 'travel_programs' => count(array_filter($flatList, fn($n) => $n['is_travel_program'])),
+ 'fewo_lodgings' => count(array_filter($flatList, fn($n) => $n['is_fewo_lodging'])),
+ 'country_pages' => count(array_filter($flatList, fn($n) => $n['is_country_page'])),
+ ];
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $stats
+ ]);
+ } catch (\Exception $e) {
+ return response()->json([
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ], 500);
+ }
+ }
+}
diff --git a/app/Http/Controllers/NewsletterController.php b/app/Http/Controllers/NewsletterController.php
new file mode 100644
index 0000000..f6a78fb
--- /dev/null
+++ b/app/Http/Controllers/NewsletterController.php
@@ -0,0 +1,391 @@
+middleware(['admin', '2fa']);
+ }
+
+ /**
+ * Liste aller Newsletter-Kontakte
+ */
+ public function index()
+ {
+ $data = [
+ 'statistics' => $this->getStatistics(),
+ ];
+ return view('newsletter.index', $data);
+ }
+
+ /**
+ * DataTables Daten für die Liste
+ */
+ public function getDatatable(Request $request)
+ {
+ $query = NewsletterContact::query()
+ ->select([
+ 'id',
+ 'email',
+ 'firstname',
+ 'lastname',
+ 'group_kulturreisen',
+ 'group_ferienwohnungen',
+ 'status',
+ 'source',
+ 'total_bookings_kulturreisen',
+ 'total_bookings_ferienwohnungen',
+ 'last_booking_at',
+ 'last_travel_end_date',
+ 'created_at',
+ ]);
+
+ // Filter nach Gruppe
+ if ($request->has('group') && $request->group != '') {
+ if ($request->group == 'kulturreisen') {
+ $query->where('group_kulturreisen', true);
+ } elseif ($request->group == 'ferienwohnungen') {
+ $query->where('group_ferienwohnungen', true);
+ }
+ }
+
+ // Filter nach Status
+ if ($request->has('status') && $request->status != '') {
+ $query->where('status', $request->status);
+ }
+
+ // Filter nach Source
+ if ($request->has('source') && $request->source != '') {
+ $query->where('source', $request->source);
+ }
+
+ // Filter nach Datum der letzten Buchung (von)
+ if ($request->has('travel_from') && $request->travel_from != '') {
+ $query->whereDate('last_travel_end_date', '>=', Carbon::parse($request->travel_from)->format('Y-m-d H:i:s'));
+ }
+
+ // Filter nach Datum der letzten Buchung (bis)
+ if ($request->has('travel_to') && $request->travel_to != '') {
+ $query->whereDate('last_travel_end_date', '<=', Carbon::parse($request->travel_to)->format('Y-m-d H:i:s'));
+ }
+
+ return DataTables::of($query)
+ ->addColumn('full_name', function ($contact) {
+ return $contact->full_name ?: '-';
+ })
+ ->addColumn('groups', function ($contact) {
+ $html = '';
+ if ($contact->group_kulturreisen) {
+ $html .= 'Kulturreisen ';
+ }
+ if ($contact->group_ferienwohnungen) {
+ $html .= 'Ferienwohnungen ';
+ }
+ return $html ?: '-';
+ })
+ ->addColumn('status_badge', function ($contact) {
+ return '' . $contact->status_label . ' ';
+ })
+ ->addColumn('total_bookings', function ($contact) {
+ $html = '';
+ if ($contact->total_bookings_kulturreisen > 0) {
+ $html .= 'K: ' . $contact->total_bookings_kulturreisen . ' ';
+ }
+ if ($contact->total_bookings_ferienwohnungen > 0) {
+ $html .= 'F: ' . $contact->total_bookings_ferienwohnungen . ' ';
+ }
+ return $html ?: '0';
+ })
+ ->addColumn('last_booking', function ($contact) {
+ return $contact->last_booking_at ? $contact->last_booking_at->format('d.m.Y') : '-';
+ })
+ ->addColumn('last_travel', function ($contact) {
+ return $contact->last_travel_end_date ? $contact->last_travel_end_date->format('d.m.Y') : '-';
+ })
+ ->addColumn('source_label', function ($contact) {
+ return $contact->source_label;
+ })
+ ->addColumn('created', function ($contact) {
+ return $contact->created_at->format('d.m.Y');
+ })
+ ->addColumn('actions', function ($contact) {
+ $html = '';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
';
+ return $html;
+ })
+ ->rawColumns(['groups', 'status_badge', 'total_bookings', 'actions'])
+ ->make(true);
+ }
+
+ /**
+ * Detailansicht eines Kontakts
+ */
+ public function detail($id)
+ {
+ $contact = NewsletterContact::with(['customer', 'travel_user', 'logs.user'])
+ ->findOrFail($id);
+
+ $data = [
+ 'contact' => $contact,
+ ];
+
+ return view('newsletter.detail', $data);
+ }
+
+ /**
+ * Formular zum Bearbeiten eines Kontakts
+ */
+ public function edit($id)
+ {
+ if ($id === 'new') {
+ $contact = new NewsletterContact();
+ $contact->status = NewsletterContact::STATUS_ACTIVE;
+ $contact->source = NewsletterContact::SOURCE_MANUAL;
+ } else {
+ $contact = NewsletterContact::findOrFail($id);
+ }
+
+ $data = [
+ 'contact' => $contact,
+ 'id' => $id,
+ ];
+
+ return view('newsletter.edit', $data);
+ }
+
+ /**
+ * Speichern eines Kontakts
+ */
+ public function store($id, Request $request)
+ {
+ $rules = [
+ 'email' => 'required|email',
+ 'status' => 'required|in:' . implode(',', [
+ NewsletterContact::STATUS_ACTIVE,
+ NewsletterContact::STATUS_INACTIVE,
+ NewsletterContact::STATUS_UNSUBSCRIBED,
+ NewsletterContact::STATUS_BOUNCED,
+ ]),
+ ];
+
+ $validator = Validator::make($request->all(), $rules);
+
+ if ($validator->fails()) {
+ return back()
+ ->withErrors($validator)
+ ->withInput();
+ }
+
+ if ($id === 'new') {
+ $contact = new NewsletterContact();
+ $isNew = true;
+ } else {
+ $contact = NewsletterContact::findOrFail($id);
+ $isNew = false;
+ }
+
+ // Speichere alte Werte für Log
+ $oldStatus = $contact->status;
+ $oldGroups = [
+ 'kulturreisen' => $contact->group_kulturreisen,
+ 'ferienwohnungen' => $contact->group_ferienwohnungen,
+ ];
+
+ $contact->email = strtolower(trim($request->email));
+ $contact->firstname = $request->firstname;
+ $contact->lastname = $request->lastname;
+ $contact->status = $request->status;
+ $contact->source = $request->source ?? NewsletterContact::SOURCE_MANUAL;
+ $contact->group_kulturreisen = $request->has('group_kulturreisen');
+ $contact->group_ferienwohnungen = $request->has('group_ferienwohnungen');
+ $contact->notes = $request->notes;
+
+ if ($isNew) {
+ $contact->subscribed_at = now();
+ }
+
+ $contact->save();
+
+ // Log erstellen
+ if ($isNew) {
+ $contact->logs()->create([
+ 'action' => 'subscribed',
+ 'description' => 'Kontakt manuell erstellt',
+ 'user_id' => auth()->id(),
+ ]);
+ } else {
+ // Status geändert?
+ if ($oldStatus !== $contact->status) {
+ $contact->logs()->create([
+ 'action' => 'status_changed',
+ 'description' => 'Status geändert von ' . NewsletterContact::$statusLabels[$oldStatus] . ' zu ' . $contact->status_label,
+ 'user_id' => auth()->id(),
+ ]);
+ }
+
+ // Gruppen geändert?
+ if (
+ $oldGroups['kulturreisen'] !== $contact->group_kulturreisen ||
+ $oldGroups['ferienwohnungen'] !== $contact->group_ferienwohnungen
+ ) {
+ $contact->logs()->create([
+ 'action' => 'group_changed',
+ 'description' => 'Gruppenzugehörigkeit geändert',
+ 'user_id' => auth()->id(),
+ ]);
+ }
+ }
+
+ \Session()->flash('alert-success', $isNew ? 'Kontakt erstellt' : 'Kontakt aktualisiert');
+
+ return redirect()->route('newsletter.detail', $contact->id);
+ }
+
+ /**
+ * Kontakt löschen (soft delete)
+ */
+ public function delete($id)
+ {
+ $contact = NewsletterContact::findOrFail($id);
+
+ $contact->logs()->create([
+ 'action' => 'unsubscribed',
+ 'description' => 'Kontakt gelöscht',
+ 'user_id' => auth()->id(),
+ ]);
+
+ $contact->delete();
+
+ \Session()->flash('alert-success', 'Kontakt gelöscht');
+
+ return redirect()->route('newsletter.index');
+ }
+
+ /**
+ * Kontakt abmelden
+ */
+ public function unsubscribe($id, Request $request)
+ {
+ $contact = NewsletterContact::findOrFail($id);
+ $contact->unsubscribe($request->reason);
+
+ \Session()->flash('alert-success', 'Kontakt abgemeldet');
+
+ return back();
+ }
+
+ /**
+ * Kontakt wieder aktivieren
+ */
+ public function resubscribe($id)
+ {
+ $contact = NewsletterContact::findOrFail($id);
+ $contact->resubscribe();
+
+ \Session()->flash('alert-success', 'Kontakt wieder aktiviert');
+
+ return back();
+ }
+
+ /**
+ * Synchronisation starten
+ */
+ public function sync(Request $request)
+ {
+ $type = $request->get('type', 'all');
+ $force = $request->has('force');
+
+ $output = [];
+
+ if ($type === 'all' || $type === 'kulturreisen') {
+ Artisan::call('newsletter:sync-kulturreisen', $force ? ['--force' => true] : []);
+ $output['kulturreisen'] = Artisan::output();
+ }
+
+ if ($type === 'all' || $type === 'ferienwohnungen') {
+ Artisan::call('newsletter:sync-ferienwohnungen', $force ? ['--force' => true] : []);
+ $output['ferienwohnungen'] = Artisan::output();
+ }
+
+ \Session()->flash('alert-success', 'Synchronisation abgeschlossen');
+
+ return back()->with('sync_output', $output);
+ }
+
+ /**
+ * Export von Kontakten
+ */
+ public function export(Request $request)
+ {
+ $query = NewsletterContact::query();
+
+ // Filter nach Gruppe
+ if ($request->has('group') && $request->group != '') {
+ if ($request->group == 'kulturreisen') {
+ $query->where('group_kulturreisen', true);
+ } elseif ($request->group == 'ferienwohnungen') {
+ $query->where('group_ferienwohnungen', true);
+ }
+ }
+
+ // Filter nach Status
+ if ($request->has('status') && $request->status != '') {
+ $query->where('status', $request->status);
+ }
+
+ // Filter nach Source
+ if ($request->has('source') && $request->source != '') {
+ $query->where('source', $request->source);
+ }
+
+ // Filter nach Datum der letzten Reise (von)
+ if ($request->has('travel_from') && $request->travel_from != '') {
+ $query->whereDate('last_travel_end_date', '>=', Carbon::parse($request->travel_from)->format('Y-m-d H:i:s'));
+ }
+
+ // Filter nach Datum der letzten Reise (bis)
+ if ($request->has('travel_to') && $request->travel_to != '') {
+ $query->whereDate('last_travel_end_date', '<=', Carbon::parse($request->travel_to)->format('Y-m-d H:i:s'));
+ }
+
+ $contacts = $query->get();
+
+ // Dateiname mit Datum und Filter-Infos
+ $group = $request->get('group', 'all');
+ $status = $request->get('status', 'all');
+ $filename = 'newsletter_' . $group . '_' . $status . '_' . date('Y-m-d') . '.csv';
+
+ return Excel::download(new NewsletterExport($contacts), $filename);
+ }
+
+ /**
+ * Statistiken für Dashboard
+ */
+ private function getStatistics()
+ {
+ return [
+ 'total' => NewsletterContact::count(),
+ 'active' => NewsletterContact::where('status', NewsletterContact::STATUS_ACTIVE)->count(),
+ 'kulturreisen' => NewsletterContact::where('group_kulturreisen', true)->count(),
+ 'ferienwohnungen' => NewsletterContact::where('group_ferienwohnungen', true)->count(),
+ 'with_bookings' => NewsletterContact::withBookings()->count(),
+ 'multiple_bookers' => NewsletterContact::multipleBookers()->count(),
+ 'unsubscribed' => NewsletterContact::where('status', NewsletterContact::STATUS_UNSUBSCRIBED)->count(),
+ 'last_sync' => NewsletterContact::max('last_synced_at'),
+ ];
+ }
+}
diff --git a/app/Models/NewsletterContact.php b/app/Models/NewsletterContact.php
new file mode 100644
index 0000000..bafd063
--- /dev/null
+++ b/app/Models/NewsletterContact.php
@@ -0,0 +1,326 @@
+ 'boolean',
+ 'group_ferienwohnungen' => 'boolean',
+ 'subscribed_at' => 'datetime',
+ 'unsubscribed_at' => 'datetime',
+ 'last_booking_at' => 'datetime',
+ 'last_travel_end_date' => 'datetime',
+ 'last_synced_at' => 'datetime',
+ 'total_bookings_kulturreisen' => 'int',
+ 'total_bookings_ferienwohnungen' => 'int',
+ 'customer_id' => 'int',
+ 'travel_user_id' => 'int',
+ ];
+
+ protected $fillable = [
+ 'email',
+ 'firstname',
+ 'lastname',
+ 'group_kulturreisen',
+ 'group_ferienwohnungen',
+ 'source',
+ 'status',
+ 'subscribed_at',
+ 'unsubscribed_at',
+ 'last_booking_at',
+ 'last_travel_end_date',
+ 'total_bookings_kulturreisen',
+ 'total_bookings_ferienwohnungen',
+ 'customer_id',
+ 'travel_user_id',
+ 'last_synced_at',
+ 'sync_hash',
+ 'notes',
+ ];
+
+ // Konstanten für Source
+ const SOURCE_BOOKING_KULTURREISEN = 'booking_kulturreisen';
+ const SOURCE_BOOKING_FERIENWOHNUNGEN = 'booking_ferienwohnungen';
+ const SOURCE_NEWSLETTER_SIGNUP = 'newsletter_signup';
+ const SOURCE_MANUAL = 'manual';
+ const SOURCE_IMPORT = 'import';
+
+ // Konstanten für Status
+ const STATUS_ACTIVE = 'active';
+ const STATUS_INACTIVE = 'inactive';
+ const STATUS_UNSUBSCRIBED = 'unsubscribed';
+ const STATUS_BOUNCED = 'bounced';
+
+ public static $sourceLabels = [
+ self::SOURCE_BOOKING_KULTURREISEN => 'Buchung Kulturreisen',
+ self::SOURCE_BOOKING_FERIENWOHNUNGEN => 'Buchung Ferienwohnungen',
+ self::SOURCE_NEWSLETTER_SIGNUP => 'Newsletter-Anmeldung',
+ self::SOURCE_MANUAL => 'Manuell',
+ self::SOURCE_IMPORT => 'Import',
+ ];
+
+ public static $statusLabels = [
+ self::STATUS_ACTIVE => 'Aktiv',
+ self::STATUS_INACTIVE => 'Inaktiv',
+ self::STATUS_UNSUBSCRIBED => 'Abgemeldet',
+ self::STATUS_BOUNCED => 'Bounced',
+ ];
+
+ public static $statusColors = [
+ self::STATUS_ACTIVE => 'success',
+ self::STATUS_INACTIVE => 'secondary',
+ self::STATUS_UNSUBSCRIBED => 'warning',
+ self::STATUS_BOUNCED => 'danger',
+ ];
+
+ /**
+ * Beziehung zum Customer (Kulturreisen)
+ */
+ public function customer()
+ {
+ return $this->belongsTo(Customer::class, 'customer_id');
+ }
+
+ /**
+ * Beziehung zum TravelUser (Ferienwohnungen)
+ */
+ public function travel_user()
+ {
+ return $this->belongsTo(TravelUser::class, 'travel_user_id');
+ }
+
+ /**
+ * Logs zu diesem Kontakt
+ */
+ public function logs()
+ {
+ return $this->hasMany(NewsletterLog::class, 'newsletter_contact_id')->orderBy('created_at', 'DESC');
+ }
+
+ /**
+ * Vollständiger Name
+ */
+ public function getFullNameAttribute()
+ {
+ return trim($this->firstname . ' ' . $this->lastname);
+ }
+
+ /**
+ * Gruppenzugehörigkeit als Array
+ */
+ public function getGroupsAttribute()
+ {
+ $groups = [];
+ if ($this->group_kulturreisen) {
+ $groups[] = 'Kulturreisen';
+ }
+ if ($this->group_ferienwohnungen) {
+ $groups[] = 'Ferienwohnungen';
+ }
+ return $groups;
+ }
+
+ /**
+ * Gruppenzugehörigkeit als String
+ */
+ public function getGroupsStringAttribute()
+ {
+ return implode(', ', $this->groups);
+ }
+
+ /**
+ * Status-Label
+ */
+ public function getStatusLabelAttribute()
+ {
+ return self::$statusLabels[$this->status] ?? $this->status;
+ }
+
+ /**
+ * Status-Color
+ */
+ public function getStatusColorAttribute()
+ {
+ return self::$statusColors[$this->status] ?? 'secondary';
+ }
+
+ /**
+ * Source-Label
+ */
+ public function getSourceLabelAttribute()
+ {
+ return self::$sourceLabels[$this->source] ?? $this->source;
+ }
+
+ /**
+ * Status-Badge HTML
+ */
+ public function getStatusBadgeAttribute()
+ {
+ return '' . $this->status_label . ' ';
+ }
+
+ /**
+ * Gesamtzahl Buchungen
+ */
+ public function getTotalBookingsAttribute()
+ {
+ return $this->total_bookings_kulturreisen + $this->total_bookings_ferienwohnungen;
+ }
+
+ /**
+ * Ist Kontakt aktiv?
+ */
+ public function isActive()
+ {
+ return $this->status === self::STATUS_ACTIVE;
+ }
+
+ /**
+ * Ist Kontakt abgemeldet?
+ */
+ public function isUnsubscribed()
+ {
+ return $this->status === self::STATUS_UNSUBSCRIBED;
+ }
+
+ /**
+ * Hat Kontakt mindestens eine Buchung?
+ */
+ public function hasBookings()
+ {
+ return $this->total_bookings > 0;
+ }
+
+ /**
+ * Kontakt abmelden
+ */
+ public function unsubscribe($reason = null)
+ {
+ $this->status = self::STATUS_UNSUBSCRIBED;
+ $this->unsubscribed_at = now();
+ $this->save();
+
+ // Log erstellen
+ $this->logs()->create([
+ 'action' => 'unsubscribed',
+ 'description' => $reason ?? 'Kontakt abgemeldet',
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Kontakt wieder aktivieren
+ */
+ public function resubscribe()
+ {
+ $this->status = self::STATUS_ACTIVE;
+ $this->unsubscribed_at = null;
+ $this->save();
+
+ // Log erstellen
+ $this->logs()->create([
+ 'action' => 'subscribed',
+ 'description' => 'Kontakt wieder aktiviert',
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Hash für Duplikat-Erkennung generieren
+ */
+ public static function generateSyncHash($email, $source)
+ {
+ return md5(strtolower(trim($email)) . '_' . $source);
+ }
+
+ /**
+ * Scope: Nur aktive Kontakte
+ */
+ public function scopeActive($query)
+ {
+ return $query->where('status', self::STATUS_ACTIVE);
+ }
+
+ /**
+ * Scope: Nur Kulturreisen
+ */
+ public function scopeKulturreisen($query)
+ {
+ return $query->where('group_kulturreisen', true);
+ }
+
+ /**
+ * Scope: Nur Ferienwohnungen
+ */
+ public function scopeFerienwohnungen($query)
+ {
+ return $query->where('group_ferienwohnungen', true);
+ }
+
+ /**
+ * Scope: Mit Buchungen
+ */
+ public function scopeWithBookings($query)
+ {
+ return $query->where(function ($q) {
+ $q->where('total_bookings_kulturreisen', '>', 0)
+ ->orWhere('total_bookings_ferienwohnungen', '>', 0);
+ });
+ }
+
+ /**
+ * Scope: Mehrfachbucher
+ */
+ public function scopeMultipleBookers($query)
+ {
+ return $query->where(function ($q) {
+ $q->where('total_bookings_kulturreisen', '>', 1)
+ ->orWhere('total_bookings_ferienwohnungen', '>', 1);
+ });
+ }
+}
diff --git a/app/Models/NewsletterLog.php b/app/Models/NewsletterLog.php
new file mode 100644
index 0000000..ec5b2b8
--- /dev/null
+++ b/app/Models/NewsletterLog.php
@@ -0,0 +1,86 @@
+ 'int',
+ 'user_id' => 'int',
+ 'metadata' => 'array',
+ ];
+
+ protected $fillable = [
+ 'newsletter_contact_id',
+ 'action',
+ 'description',
+ 'metadata',
+ 'user_id',
+ ];
+
+ // Aktions-Konstanten
+ const ACTION_SUBSCRIBED = 'subscribed';
+ const ACTION_UNSUBSCRIBED = 'unsubscribed';
+ const ACTION_BOOKING_ADDED = 'booking_added';
+ const ACTION_STATUS_CHANGED = 'status_changed';
+ const ACTION_GROUP_CHANGED = 'group_changed';
+ const ACTION_EMAIL_SENT = 'email_sent';
+ const ACTION_BOUNCED = 'bounced';
+
+ public static $actionLabels = [
+ self::ACTION_SUBSCRIBED => 'Angemeldet',
+ self::ACTION_UNSUBSCRIBED => 'Abgemeldet',
+ self::ACTION_BOOKING_ADDED => 'Buchung hinzugefügt',
+ self::ACTION_STATUS_CHANGED => 'Status geändert',
+ self::ACTION_GROUP_CHANGED => 'Gruppe geändert',
+ self::ACTION_EMAIL_SENT => 'E-Mail versendet',
+ self::ACTION_BOUNCED => 'Bounced',
+ ];
+
+ /**
+ * Beziehung zum Newsletter-Kontakt
+ */
+ public function newsletter_contact()
+ {
+ return $this->belongsTo(NewsletterContact::class, 'newsletter_contact_id');
+ }
+
+ /**
+ * Beziehung zum Admin-User
+ */
+ public function user()
+ {
+ return $this->belongsTo(SfGuardUser::class, 'user_id');
+ }
+
+ /**
+ * Action-Label
+ */
+ public function getActionLabelAttribute()
+ {
+ return self::$actionLabels[$this->action] ?? $this->action;
+ }
+}
+
diff --git a/app/Services/NavigationTreeService.php b/app/Services/NavigationTreeService.php
new file mode 100644
index 0000000..abe5c32
--- /dev/null
+++ b/app/Services/NavigationTreeService.php
@@ -0,0 +1,681 @@
+cacheEnabled = $enabled;
+ return $this;
+ }
+
+ /**
+ * Setzt die Cache-Zeit
+ *
+ * @param int $minutes
+ * @return $this
+ */
+ public function setCacheTime(int $minutes): self
+ {
+ $this->cacheTime = $minutes;
+ return $this;
+ }
+
+ /**
+ * Löscht den Navigation-Cache
+ *
+ * @return void
+ */
+ public function clearCache(): void
+ {
+ Cache::forget('navigation_tree_full');
+ Cache::forget('navigation_tree_active');
+ Cache::forget('navigation_flat_full');
+ Cache::forget('navigation_flat_active');
+
+ // Lösche auch alle Subtree-Caches
+ $keys = Cache::get('navigation_subtree_keys', []);
+ foreach ($keys as $key) {
+ Cache::forget($key);
+ }
+ Cache::forget('navigation_subtree_keys');
+ }
+ /**
+ * Gibt den kompletten Navigationsbaum zurück
+ *
+ * @param bool $onlyActive Nur aktive und sichtbare Seiten zurückgeben
+ * @param bool $onlyShowInNavi Nur Seiten die in der Navigation angezeigt werden sollen
+ * @return array
+ */
+ public function getNavigationTree(bool $onlyActive = false, bool $onlyShowInNavi = false): array
+ {
+ $cacheKey = $onlyActive ? 'navigation_tree_active' : 'navigation_tree_full';
+
+ if ($this->cacheEnabled) {
+ return Cache::remember($cacheKey, $this->cacheTime, function () use ($onlyActive, $onlyShowInNavi) {
+ return $this->buildNavigationTree($onlyActive, $onlyShowInNavi);
+ });
+ }
+
+ return $this->buildNavigationTree($onlyActive, $onlyShowInNavi);
+ }
+
+ /**
+ * Gibt den Navigationsbaum wie im Frontend zurück (nur Länderseiten mit Children)
+ *
+ * @param bool $includeHidden Auch ausgeblendete Pages anzeigen
+ * @return array
+ */
+ public function getFrontendNavigationTree(bool $includeHidden = false): array
+ {
+ $cacheKey = 'navigation_tree_frontend_' . ($includeHidden ? 'with_hidden' : 'visible');
+
+ if ($this->cacheEnabled) {
+ return Cache::remember($cacheKey, $this->cacheTime, function () use ($includeHidden) {
+ return $this->buildFrontendNavigationTree($includeHidden);
+ });
+ }
+
+ return $this->buildFrontendNavigationTree($includeHidden);
+ }
+
+ /**
+ * Baut den Frontend-Navigationsbaum auf
+ *
+ * @param bool $includeHidden
+ * @return array
+ */
+ protected function buildFrontendNavigationTree(bool $includeHidden = false): array
+ {
+ $tree = [];
+
+ // 1. Länderseiten (Hauptnavigation)
+ $countryPages = $this->getCountryPages($includeHidden);
+ foreach ($countryPages as $page) {
+ $node = $this->buildFrontendNode($page, $includeHidden);
+ if ($node) {
+ $node['section'] = 'Länder-Navigation';
+ $tree[] = $node;
+ }
+ }
+
+ // 2. Ferienwohnungen (USEDOM)
+ $fewoPages = $this->getFewoPages($includeHidden);
+ if (!empty($fewoPages)) {
+ $tree[] = [
+ 'is_section_separator' => true,
+ 'title' => 'USEDOM Ferienwohnungen',
+ 'icon' => 'isv-fewo',
+ 'section' => 'Ferienwohnungen'
+ ];
+ foreach ($fewoPages as $page) {
+ $node = $this->buildFrontendNode($page, $includeHidden);
+ if ($node) {
+ $node['section'] = 'Ferienwohnungen';
+ $tree[] = $node;
+ }
+ }
+ }
+
+ // 3. Weitere wichtige Seiten (Mehr-Menü)
+ $morePages = $this->getMoreMenuPages($includeHidden);
+ if (!empty($morePages)) {
+ $tree[] = [
+ 'is_section_separator' => true,
+ 'title' => 'Weitere Seiten (Mehr-Menü)',
+ 'icon' => 'fa fa-ellipsis-v',
+ 'section' => 'Mehr'
+ ];
+ foreach ($morePages as $page) {
+ $node = $this->buildFrontendNode($page, $includeHidden);
+ if ($node) {
+ $node['section'] = 'Mehr';
+ $tree[] = $node;
+ }
+ }
+ }
+
+ return $tree;
+ }
+
+ /**
+ * Hole alle Länderseiten
+ *
+ * @param bool $includeHidden
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
+ protected function getCountryPages(bool $includeHidden = false)
+ {
+ $query = Page::whereNull('parent_id')
+ ->whereNotNull('country_id')
+ ->orderBy('order')
+ ->orderBy('title');
+
+ if (!$includeHidden) {
+ $query->where('show_in_navi', 1);
+ $query->where('status', 1);
+ }
+
+ return $query->get();
+ }
+
+ /**
+ * Hole Ferienwohnungs-Übersichtsseite mit Children
+ *
+ * @param bool $includeHidden
+ * @return array
+ */
+ protected function getFewoPages(bool $includeHidden = false)
+ {
+ // Suche die Hauptseite "Ferienwohnungen"
+ $query = Page::where('slug', 'ferienwohnungen')
+ ->orWhere('real_url_path', '/ferienwohnungen');
+
+ if (!$includeHidden) {
+ $query->where('status', 1);
+ }
+
+ $fewoMainPage = $query->first();
+
+ if (!$fewoMainPage) {
+ return [];
+ }
+
+ // Nur die Hauptseite zurückgeben, Children werden über buildFrontendNode geladen
+ return [$fewoMainPage];
+ }
+
+ /**
+ * Hole Seiten für "Mehr"-Menü
+ *
+ * @param bool $includeHidden
+ * @return array
+ */
+ protected function getMoreMenuPages(bool $includeHidden = false)
+ {
+ // Typische Slugs/Pfade aus dem Mehr-Menü mit ihren möglichen Children
+ $morePages = [
+ 'ueber-uns' => true, // Könnte Children haben
+ 'reiseversicherung' => false, // Keine Children
+ 'reisefuehrer' => true, // Könnte Children haben
+ 'reisemagazin' => true, // Könnte Children haben
+ 'reisenews' => true // Könnte Children haben
+ ];
+
+ $pages = [];
+
+ foreach ($morePages as $slug => $hasChildren) {
+ $query = Page::where('slug', $slug)
+ ->orWhere('real_url_path', '/' . $slug);
+
+ if (!$includeHidden) {
+ $query->where('status', 1);
+ }
+
+ $page = $query->first();
+ if ($page) {
+ $pages[] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Baut einen Frontend-Node auf (mit Children gruppiert nach beforeTitle)
+ *
+ * @param Page $page
+ * @param bool $includeHidden
+ * @param bool $loadChildren Soll Children geladen werden?
+ * @return array|null
+ */
+ protected function buildFrontendNode(Page $page, bool $includeHidden = false, bool $loadChildren = true): ?array
+ {
+ $node = $this->buildNodeData($page, true);
+
+ if (!$loadChildren) {
+ $node['children'] = [];
+ $node['has_children'] = false;
+ return $node;
+ }
+
+ // Hole Children
+ $query = Page::where('parent_id', $page->id)
+ ->orderBy('order')
+ ->orderBy('title');
+
+ if (!$includeHidden) {
+ $query->where('show_in_navi', 1);
+ $query->where('status', 1);
+ }
+
+ $children = $query->get();
+
+ // Gruppiere Children nach beforeTitle
+ $groupedChildren = [
+ 'main' => [],
+ 'infos' => []
+ ];
+
+ foreach ($children as $child) {
+ $childNode = $this->buildNodeData($child, true);
+
+ if ($child->before_title === 'Infos') {
+ $groupedChildren['infos'][] = $childNode;
+ } else {
+ $groupedChildren['main'][] = $childNode;
+ }
+ }
+
+ // Baue finale Children-Liste mit Gruppierung
+ $finalChildren = [];
+
+ // Erst Haupt-Children
+ foreach ($groupedChildren['main'] as $childNode) {
+ $finalChildren[] = $childNode;
+ }
+
+ // Dann Info-Children mit Separator
+ if (!empty($groupedChildren['infos'])) {
+ $finalChildren[] = [
+ 'is_separator' => true,
+ 'title' => 'Infos',
+ 'icon' => 'fa fa-info-circle'
+ ];
+ foreach ($groupedChildren['infos'] as $childNode) {
+ $finalChildren[] = $childNode;
+ }
+ }
+
+ $node['children'] = $finalChildren;
+ $node['has_children'] = count($finalChildren) > 0;
+
+ return $node;
+ }
+
+ /**
+ * Baut den Navigationsbaum auf (ohne Caching)
+ *
+ * @param bool $onlyActive
+ * @param bool $onlyShowInNavi
+ * @return array
+ */
+ protected function buildNavigationTree(bool $onlyActive = false, bool $onlyShowInNavi = false): array
+ {
+ // Hole alle Root-Seiten (ohne Parent)
+ $query = Page::whereNull('parent_id')
+ ->orderBy('order')
+ ->orderBy('title');
+
+ if ($onlyActive) {
+ $query->where('status', 1);
+ }
+
+ if ($onlyShowInNavi) {
+ $query->where('show_in_navi', 1);
+ }
+
+ $rootPages = $query->get();
+
+ $tree = [];
+ foreach ($rootPages as $page) {
+ $tree[] = $this->buildNode($page, $onlyActive, $onlyShowInNavi);
+ }
+
+ return $tree;
+ }
+
+ /**
+ * Gibt einen Teilbaum zurück, beginnend mit einer bestimmten Page
+ *
+ * @param int $rootId Die ID der Root-Page
+ * @param bool $onlyActive Nur aktive Seiten zurückgeben
+ * @param bool $onlyShowInNavi Nur Seiten die in der Navigation angezeigt werden sollen
+ * @return array|null
+ */
+ public function getNavigationSubTree(int $rootId, bool $onlyActive = false, bool $onlyShowInNavi = false): ?array
+ {
+ $cacheKey = "navigation_subtree_{$rootId}_" . ($onlyActive ? 'active' : 'full');
+
+ if ($this->cacheEnabled) {
+ // Speichere Cache-Key für späteres Löschen
+ $keys = Cache::get('navigation_subtree_keys', []);
+ if (!in_array($cacheKey, $keys)) {
+ $keys[] = $cacheKey;
+ Cache::put('navigation_subtree_keys', $keys, $this->cacheTime);
+ }
+
+ return Cache::remember($cacheKey, $this->cacheTime, function () use ($rootId, $onlyActive, $onlyShowInNavi) {
+ return $this->buildSubTree($rootId, $onlyActive, $onlyShowInNavi);
+ });
+ }
+
+ return $this->buildSubTree($rootId, $onlyActive, $onlyShowInNavi);
+ }
+
+ /**
+ * Baut einen Teilbaum auf (ohne Caching)
+ *
+ * @param int $rootId
+ * @param bool $onlyActive
+ * @param bool $onlyShowInNavi
+ * @return array|null
+ */
+ protected function buildSubTree(int $rootId, bool $onlyActive = false, bool $onlyShowInNavi = false): ?array
+ {
+ $page = Page::find($rootId);
+
+ if (!$page) {
+ return null;
+ }
+
+ return $this->buildNode($page, $onlyActive, $onlyShowInNavi);
+ }
+
+ /**
+ * Gibt eine flache Liste aller Navigationspunkte zurück
+ *
+ * @param bool $onlyActive Nur aktive Seiten zurückgeben
+ * @param bool $onlyShowInNavi Nur Seiten die in der Navigation angezeigt werden sollen
+ * @return array
+ */
+ public function getFlatNavigationList(bool $onlyActive = false, bool $onlyShowInNavi = false): array
+ {
+ $cacheKey = $onlyActive ? 'navigation_flat_active' : 'navigation_flat_full';
+
+ if ($this->cacheEnabled) {
+ return Cache::remember($cacheKey, $this->cacheTime, function () use ($onlyActive, $onlyShowInNavi) {
+ return $this->buildFlatList($onlyActive, $onlyShowInNavi);
+ });
+ }
+
+ return $this->buildFlatList($onlyActive, $onlyShowInNavi);
+ }
+
+ /**
+ * Baut eine flache Liste auf (ohne Caching)
+ *
+ * @param bool $onlyActive
+ * @param bool $onlyShowInNavi
+ * @return array
+ */
+ protected function buildFlatList(bool $onlyActive = false, bool $onlyShowInNavi = false): array
+ {
+ $query = Page::orderBy('order')
+ ->orderBy('title');
+
+ if ($onlyActive) {
+ $query->where('status', 1);
+ }
+
+ if ($onlyShowInNavi) {
+ $query->where('show_in_navi', 1);
+ }
+
+ $pages = $query->get();
+
+ $list = [];
+ foreach ($pages as $page) {
+ $list[] = $this->buildNodeData($page, false);
+ }
+
+ return $list;
+ }
+
+ /**
+ * Baut einen einzelnen Knoten mit allen Kindern rekursiv auf
+ *
+ * @param Page $page
+ * @param bool $onlyActive
+ * @param bool $onlyShowInNavi
+ * @return array
+ */
+ protected function buildNode(Page $page, bool $onlyActive = false, bool $onlyShowInNavi = false): array
+ {
+ $node = $this->buildNodeData($page, true);
+
+ // Hole alle Child-Seiten
+ $query = Page::where('parent_id', $page->id)
+ ->orderBy('order')
+ ->orderBy('title');
+
+ if ($onlyActive) {
+ $query->where('status', 1);
+ }
+
+ if ($onlyShowInNavi) {
+ $query->where('show_in_navi', 1);
+ }
+
+ $children = $query->get();
+
+ $childNodes = [];
+ foreach ($children as $child) {
+ $childNodes[] = $this->buildNode($child, $onlyActive, $onlyShowInNavi);
+ }
+
+ $node['children'] = $childNodes;
+ $node['has_children'] = count($childNodes) > 0;
+
+ return $node;
+ }
+
+ /**
+ * Erstellt die Datenstruktur für einen einzelnen Navigationspunkt
+ *
+ * @param Page $page
+ * @param bool $includeRelations Beziehungen laden (TravelProgram, etc.)
+ * @return array
+ */
+ protected function buildNodeData(Page $page, bool $includeRelations = true): array
+ {
+ $data = [
+ 'id' => $page->id,
+ 'title' => $page->title,
+ 'title_short' => $page->title_short,
+ 'before_title' => $page->before_title,
+ 'slug' => $page->slug,
+ 'real_url_path' => $page->real_url_path,
+ 'url' => $page->real_url_path ?: $this->buildUrlPath($page),
+ 'status' => $page->status,
+ 'show_in_navi' => $page->show_in_navi,
+ 'order' => $page->order,
+ 'template' => $page->template,
+ 'lvl' => $page->lvl,
+ 'parent_id' => $page->parent_id,
+
+ // SEO-Informationen
+ 'pagetitle' => $page->pagetitle,
+ 'description' => $page->description,
+ 'keywords' => $page->keywords,
+ 'canonical_url' => $page->canonical_url,
+
+ // Tree-Informationen
+ 'lft' => $page->lft,
+ 'rgt' => $page->rgt,
+ 'tree_root' => $page->tree_root,
+
+ // Box-Informationen (für Karten/Boxen in der Navigation)
+ 'box_body' => $page->box_body,
+ 'box_image_url' => $page->box_image_url,
+ 'box_star' => $page->box_star,
+ 'box_discount' => $page->box_discount,
+
+ // Zusätzliche Flags
+ 'is_travel_program' => !is_null($page->travel_program),
+ 'is_fewo_lodging' => !is_null($page->fewo_lodging),
+ 'is_country_page' => !is_null($page->country_id),
+ ];
+
+ if ($includeRelations) {
+ // Lade Beziehungen wenn benötigt
+ if ($page->travel_program) {
+ $travelProgram = $page->travel_program_content;
+ if ($travelProgram) {
+ $data['travel_program'] = [
+ 'id' => $travelProgram->id,
+ 'title' => $travelProgram->title,
+ 'subtitle' => $travelProgram->subtitle,
+ 'status' => $travelProgram->status,
+ 'position' => $travelProgram->position,
+ 'duration' => $travelProgram->duration,
+ 'price_from' => $travelProgram->price_from,
+ ];
+ }
+ }
+
+ if ($page->fewo_lodging) {
+ $fewoLodging = $page->fewo_lodging();
+ if ($fewoLodging) {
+ $fewo = $fewoLodging->first();
+ if ($fewo) {
+ $data['fewo_lodging'] = [
+ 'id' => $fewo->id,
+ 'name' => $fewo->name,
+ 'status' => $fewo->status,
+ ];
+ }
+ }
+ }
+
+ if ($page->country_id) {
+ $country = $page->travel_country;
+ if ($country) {
+ $data['country'] = [
+ 'id' => $country->id,
+ 'name' => $country->name,
+ 'title' => $country->title,
+ 'slug' => $country->slug,
+ ];
+ }
+ }
+ }
+
+ // CMS Settings (JSON)
+ if ($page->cms_settings) {
+ try {
+ $data['cms_settings'] = json_decode($page->cms_settings, true);
+ } catch (\Exception $e) {
+ $data['cms_settings'] = null;
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Baut den URL-Pfad für eine Page rekursiv auf (Fallback wenn real_url_path nicht gesetzt)
+ *
+ * @param Page $page
+ * @return string
+ */
+ protected function buildUrlPath(Page $page): string
+ {
+ $slugs = [];
+ $current = $page;
+
+ // Sammle alle Slugs von unten nach oben
+ while ($current) {
+ if ($current->slug) {
+ array_unshift($slugs, $current->slug);
+ }
+ $current = $current->parent_page;
+ }
+
+ return '/' . implode('/', $slugs);
+ }
+
+ /**
+ * Zählt die Anzahl der Knoten im Baum
+ *
+ * @param array $tree
+ * @return int
+ */
+ public function countNodes(array $tree): int
+ {
+ $count = 0;
+ foreach ($tree as $node) {
+ $count++; // Zähle den aktuellen Knoten
+ if (isset($node['children']) && is_array($node['children'])) {
+ $count += $this->countNodes($node['children']);
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * Findet einen Knoten anhand seiner ID im Baum
+ *
+ * @param array $tree
+ * @param int $nodeId
+ * @return array|null
+ */
+ public function findNodeById(array $tree, int $nodeId): ?array
+ {
+ foreach ($tree as $node) {
+ if ($node['id'] === $nodeId) {
+ return $node;
+ }
+ if (isset($node['children']) && is_array($node['children'])) {
+ $found = $this->findNodeById($node['children'], $nodeId);
+ if ($found) {
+ return $found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Gibt den Breadcrumb-Pfad für eine bestimmte Page zurück
+ *
+ * @param int $pageId
+ * @return array
+ */
+ public function getBreadcrumb(int $pageId): array
+ {
+ $page = Page::find($pageId);
+ if (!$page) {
+ return [];
+ }
+
+ $breadcrumb = [];
+ $current = $page;
+
+ while ($current) {
+ array_unshift($breadcrumb, [
+ 'id' => $current->id,
+ 'title' => $current->title,
+ 'title_short' => $current->title_short,
+ 'url' => $current->real_url_path ?: $this->buildUrlPath($current),
+ 'slug' => $current->slug,
+ ]);
+ $current = $current->parent_page;
+ }
+
+ return $breadcrumb;
+ }
+}
diff --git a/app/Services/Util.php b/app/Services/Util.php
index 7709762..76884ff 100644
--- a/app/Services/Util.php
+++ b/app/Services/Util.php
@@ -1,12 +1,14 @@
format(\Util::formatDateTimeDB());
}
//date
return \Carbon::parse($date)->format(\Util::formatDateDB());
}
- public static function _reformat_date($date, $to = 'date'){
- if($to === 'datetime'){
+ public static function _reformat_date($date, $to = 'date')
+ {
+ if ($to === 'datetime') {
return \Carbon::parse($date)->format('Y-m-d - H:i');
}
return \Carbon::parse($date)->format('Y-m-d');
}
- public static function _format_number($value){
+ public static function _format_number($value)
+ {
return preg_replace("/[^0-9,]/", "", $value);
-
}
- public static function _number_format($value, $dec=2){
+ public static function _number_format($value, $dec = 2)
+ {
return number_format(($value), $dec, ',', '.');
-
}
- public static function _first_replace($value, $search='re:', $replace=''){
+ public static function _first_replace($value, $search = 're:', $replace = '')
+ {
do {
$before = strlen($value);
- $value = trim(preg_replace('/^'.$search.'/i', $replace, $value));
-
- }while($before !== strlen($value));
+ $value = trim(preg_replace('/^' . $search . '/i', $replace, $value));
+ } while ($before !== strlen($value));
return $value;
-
}
- public static function _explodeLines($value = false){
- if($value){
- return explode('#', str_replace(array("\r\n", "\r", "\n"),"#", $value));
+ public static function _explodeLines($value = false)
+ {
+ if ($value) {
+ return explode('#', str_replace(array("\r\n", "\r", "\n"), "#", $value));
}
return null;
}
- public static function _implodeLines($value, $glue=PHP_EOL){
- if(is_array($value)){
+ public static function _implodeLines($value, $glue = PHP_EOL)
+ {
+ if (is_array($value)) {
return implode($glue, $value);
}
return $value;
}
- public static function _clean_float($value){
+ public static function _clean_float($value)
+ {
$groups = explode(".", preg_replace("/[^0-9.-]/", "", str_replace(',', '.', $value)));
$lastGroup = 0;
- if(count($groups) > 1){
+ if (count($groups) > 1) {
$lastGroup = array_pop($groups);
}
$number = implode('', $groups);
- return (strlen($lastGroup) < 3) ? floatval($number.'.'.$lastGroup) : floatval($number.$lastGroup);
+ return (strlen($lastGroup) < 3) ? floatval($number . '.' . $lastGroup) : floatval($number . $lastGroup);
}
public static function sanitize($string, $force_lowercase = true, $anal = false, $substr = false)
{
- $strip = array("~", "`", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "=", "+", "[", "{", "]",
- "}", "\\", "|", ";", ":", "\"", "'", "‘", "’", "“", "”", "–", "—",
- "—", "–", ",", "<", ".", ">", "/", "?");
+ $strip = array(
+ "~",
+ "`",
+ "!",
+ "@",
+ "#",
+ "$",
+ "%",
+ "^",
+ "&",
+ "*",
+ "(",
+ ")",
+ "_",
+ "=",
+ "+",
+ "[",
+ "{",
+ "]",
+ "}",
+ "\\",
+ "|",
+ ";",
+ ":",
+ "\"",
+ "'",
+ "‘",
+ "’",
+ "“",
+ "”",
+ "–",
+ "—",
+ "—",
+ "–",
+ ",",
+ "<",
+ ".",
+ ">",
+ "/",
+ "?"
+ );
$clean = trim(str_replace($strip, "", strip_tags($string)));
$clean = preg_replace('/\s+/', "_", $clean);
- $clean = ($anal) ? preg_replace("/[^a-zA-Z0-9]/", "", $clean) : $clean ;
-
- if($substr){
- $clean = (strlen($clean) > 33) ? substr($clean,0,33) : $clean;
+ $clean = ($anal) ? preg_replace("/[^a-zA-Z0-9]/", "", $clean) : $clean;
+ if ($substr) {
+ $clean = (strlen($clean) > 33) ? substr($clean, 0, 33) : $clean;
}
return ($force_lowercase) ?
(function_exists('mb_strtolower')) ?
- mb_strtolower($clean, 'UTF-8') :
- strtolower($clean) :
+ mb_strtolower($clean, 'UTF-8') :
+ strtolower($clean) :
$clean;
}
- public static function replacePlaceholders($search, $replace){
+ public static function replacePlaceholders($search, $replace)
+ {
preg_match_all("/\{{(.+?)\}}/", $search, $matches);
- if (isset($matches[1]) && count($matches[1]) > 0){
+ if (isset($matches[1]) && count($matches[1]) > 0) {
foreach ($matches[1] as $key => $value) {
$kvalue = trim($value);
- if (array_key_exists($kvalue, $replace)){
+ if (array_key_exists($kvalue, $replace)) {
$search = preg_replace("/\{\{$value\}\}/", $replace[$kvalue], $search);
}
}
@@ -158,7 +206,7 @@ class Util
- // $html = preg_replace("/(?)div/", "$1p", $html);
+ // $html = preg_replace("/(?)div/", "$1p", $html);
$html = str_replace('
', '', $html);
$html = str_replace('
', '', $html);
@@ -176,7 +224,7 @@ class Util
@$dom->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$removeStyleTags = ['ul', 'li', 'h1', 'h2', 'br'];
- foreach ($removeStyleTags as $removeStyleTag){
+ foreach ($removeStyleTags as $removeStyleTag) {
$elements = $dom->getElementsByTagName($removeStyleTag);
foreach ($elements as $element) {
$element->removeAttribute('style');
@@ -184,17 +232,16 @@ class Util
}
$removeFullTags = ['span', 'a'];
- foreach ($removeFullTags as $removeFullTag){
+ foreach ($removeFullTags as $removeFullTag) {
$domElemsToRemove = [];
$elements = $dom->getElementsByTagName($removeFullTag);
foreach ($elements as $element) {
$domElemsToRemove[] = $element;
-
}
foreach ($domElemsToRemove as $domElem) {
- if($removeFullTag == 'span' && strpos($domElem->getAttribute('style'), 'font-weight: 700') !== false){
- $new_node = $dom->createTextNode("".$domElem->nodeValue." ");
- }else{
+ if ($removeFullTag == 'span' && strpos($domElem->getAttribute('style'), 'font-weight: 700') !== false) {
+ $new_node = $dom->createTextNode("" . $domElem->nodeValue . " ");
+ } else {
$new_node = $dom->createTextNode($domElem->nodeValue);
}
$domElem->parentNode->replaceChild($new_node, $domElem);
@@ -235,28 +282,28 @@ class Util
$element->parentNode->replaceChild($new_node, $element);
} */
- $new_node = $dom->createTextNode($element->nodeValue . "
");
- $p = $dom->createElement('p', $element->nodeValue);
- $div = $dom->createElement('div');
- // $new_node = $dom->createElement('div', $new_node);
- $div->setAttribute('class', 'mediaInfo');
- $div->appendChild($p);
-
- // dump($element);
- // die();
- //
- $element->parentNode->replaceChild($div, $element);
+ $new_node = $dom->createTextNode($element->nodeValue . "");
+ $p = $dom->createElement('p', $element->nodeValue);
+ $div = $dom->createElement('div');
+ // $new_node = $dom->createElement('div', $new_node);
+ $div->setAttribute('class', 'mediaInfo');
+ $div->appendChild($p);
+ // dump($element);
+ // die();
+ //
+ $element->parentNode->replaceChild($div, $element);
}
$html = $dom->saveHTML();
return $html;
}
- public static function prepareCollapseValues(){
- if(\Session::has('collapse_shows')){
+ public static function prepareCollapseValues()
+ {
+ if (\Session::has('collapse_shows')) {
$collapse_shows = \Session::get('collapse_shows');
- if(strpos($collapse_shows, ',')){
+ if (strpos($collapse_shows, ',')) {
$collapse_shows = explode(',', $collapse_shows);
return json_encode($collapse_shows);
}
@@ -266,32 +313,35 @@ class Util
return json_encode([0 => 'non']);
}
- public static function convertArrayWindowsCharset($values){
- foreach ($values as $key=>$string){
+ public static function convertArrayWindowsCharset($values)
+ {
+ foreach ($values as $key => $string) {
$values[$key] = self::convertStringWindowsCharset($string);
}
return $values;
}
- public static function convertStringWindowsCharset($value) {
+ public static function convertStringWindowsCharset($value)
+ {
$charset = mb_detect_encoding($value, "UTF-8, ISO-8859-1, ISO-8859-15", true);
return mb_convert_encoding($value, "Windows-1252", $charset);
}
- public static function getMimeFromHeader($http_response_header){
+ public static function getMimeFromHeader($http_response_header)
+ {
$pattern = "/^content-type\s*:\s*(.*)$/i";
if (($header = array_values(preg_grep($pattern, $http_response_header))) &&
- (preg_match($pattern, $header[0], $match) !== false))
- {
+ (preg_match($pattern, $header[0], $match) !== false)
+ ) {
return $match[1];
-
}
return "";
}
- public static function getExtensionFromMime($mine){
+ public static function getExtensionFromMime($mine)
+ {
$mime_types = [
'application/pdf' => 'pdf',
'image/png' => 'png',
@@ -304,15 +354,16 @@ class Util
return isset($mime_types[$mine]) ? $mime_types[$mine] : "";
}
- public static function getURLasContent($url, $base=false){
- $arrContextOptions=array(
- "ssl"=>array(
- "verify_peer"=>false,
- "verify_peer_name"=>false,
+ public static function getURLasContent($url, $base = false)
+ {
+ $arrContextOptions = array(
+ "ssl" => array(
+ "verify_peer" => false,
+ "verify_peer_name" => false,
),
);
$content = file_get_contents($url, false, stream_context_create($arrContextOptions));
- if($base){
+ if ($base) {
$type = pathinfo($url, PATHINFO_EXTENSION);
$base64Data = base64_encode($content);
return 'data:image/' . $type . ';base64,' . $base64Data;
@@ -332,6 +383,4 @@ class Util
return $size;
}
}
-
-
-}
\ No newline at end of file
+}
diff --git a/composer.json b/composer.json
index cf534af..65281b0 100755
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,10 @@
{
"name": "laravel/laravel",
"description": "The Laravel Framework.",
- "keywords": ["framework", "laravel"],
+ "keywords": [
+ "framework",
+ "laravel"
+ ],
"license": "MIT",
"type": "project",
"repositories": [
@@ -14,7 +17,7 @@
}
],
"require": {
- "php": "^8.0|^8.2",
+ "php": "^8.0|^8.2|^8.3",
"bacon/bacon-qr-code": "^3.0",
"barryvdh/laravel-dompdf": "*",
"cviebrock/eloquent-sluggable": "*",
@@ -48,7 +51,7 @@
"nunomaduro/collision": "^5.0",
"phpunit/phpunit": "^9.3.3",
"barryvdh/laravel-debugbar": "*",
- "barryvdh/laravel-ide-helper": "*"
+ "barryvdh/laravel-ide-helper": "*"
},
"autoload": {
"files": [
@@ -69,8 +72,7 @@
},
"extra": {
"laravel": {
- "dont-discover": [
- ]
+ "dont-discover": []
}
},
"scripts": {
@@ -99,4 +101,4 @@
},
"minimum-stability": "dev",
"prefer-stable": true
-}
+}
\ No newline at end of file
diff --git a/config/permissions.php b/config/permissions.php
index 18980ae..a6bdd69 100755
--- a/config/permissions.php
+++ b/config/permissions.php
@@ -1,74 +1,77 @@
[
+ 'groups' => [
0 => [
- 'my-dat' => ['name' => 'Ihre Daten' , 'color' => 'client'],
- ],
+ 'my-dat' => ['name' => 'Ihre Daten', 'color' => 'client'],
+ ],
1 => [
- 'crm' => ['name' => 'ADMIN CRM ' , 'color' => 'admin'],
- 'crm-tp' => ['name' => 'ADMIN CRM > Reiseprogramme' , 'color' => 'admin'],
- 'crm-tp-pr' => ['name' => 'ADMIN CRM > Reiseprogramme > Programme' , 'color' => 'admin'],
- 'crm-tp-dr' => ['name' => 'ADMIN CRM > Reiseprogramme > Vorlagen' , 'color' => 'admin'],
- 'crm-tp-tc' => ['name' => 'ADMIN CRM > Reiseprogramme > Inhalte' , 'color' => 'admin'],
- 'crm-bo' => ['name' => 'ADMIN CRM > Buchungen' , 'color' => 'admin'],
- 'crm-bo-re' => ['name' => 'ADMIN CRM > Buchungen > Übersicht' , 'color' => 'admin'],
- 'crm-bo-bo' => ['name' => 'ADMIN CRM > Buchungen > Buchungen' , 'color' => 'admin'],
- 'crm-bo-le' => ['name' => 'ADMIN CRM > Buchungen > Anfragen' , 'color' => 'admin'],
- 'crm-bo-cu' => ['name' => 'ADMIN CRM > Buchungen > Kunden' , 'color' => 'admin'],
- 'crm-cm' => ['name' => 'ADMIN CRM > Kundenverwaltung' , 'color' => 'admin'],
- 'crm-cm-cf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)' , 'color' => 'admin'],
- 'crm-cm-bf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)' , 'color' => 'admin'],
- 'crm-mail' => ['name' => 'ADMIN CRM > E-Mails' , 'color' => 'admin'],
- 'crm-mail-le' => ['name' => 'ADMIN CRM > E-Mails > Anfragen' , 'color' => 'admin'],
- 'crm-mail-bo' => ['name' => 'ADMIN CRM > E-Mails > Buchungen' , 'color' => 'admin'],
- 'crm-mail-bf' => ['name' => 'ADMIN CRM > E-Mails > Buchungen (Fewo)' , 'color' => 'admin'],
- 'crm-iq-tl' => ['name' => 'ADMIN CRM > Reisebausteine' , 'color' => 'admin'],
- 'crm-iq-tl-pro' => ['name' => 'ADMIN CRM > Reisebausteine > Programm' , 'color' => 'admin'],
- 'crm-iq-tl-gp' => ['name' => 'ADMIN CRM > Reisebausteine > Gruppe' , 'color' => 'admin'],
- 'crm-iq-tl-it' => ['name' => 'ADMIN CRM > Reisebausteine > Baustein' , 'color' => 'admin'],
- 'crm-old-cm' => ['name' => 'ADMIN CRM altes System > Kundenverwaltung' , 'color' => 'info'],
- 'cms' => ['name' => 'ADMIN CMS' , 'color' => 'secondary'],
- 'cms-iq-assets' => ['name' => 'ADMIN CMS > Medien' , 'color' => 'secondary'],
- 'cms-tg' => ['name' => 'ADMIN CMS > Reiseführer' , 'color' => 'secondary'],
- 'cms-fewo' => ['name' => 'ADMIN CMS > FeWo' , 'color' => 'secondary'],
- 'cms-book' => ['name' => 'ADMIN CMS > Buchungen' , 'color' => 'secondary'],
- 'cms-fb' => ['name' => 'ADMIN CMS > Feedback' , 'color' => 'secondary'],
- 'cms-nw' => ['name' => 'ADMIN CMS > News' , 'color' => 'secondary'],
- 'cms-aq' => ['name' => 'ADMIN CMS > Fragen & Antworten' , 'color' => 'secondary'],
- 'cms-sb' => ['name' => 'ADMIN CMS > Sidebar' , 'color' => 'secondary'],
- 'cms-cn' => ['name' => 'ADMIN CMS > Inhalte' , 'color' => 'secondary'],
- 'cms-cn-in' => ['name' => 'ADMIN CMS > Inhalte > Infos' , 'color' => 'secondary'],
- 'cms-cn-al' => ['name' => 'ADMIN CMS > Inhalte > Inhalte' , 'color' => 'secondary'],
- 'cms-cn-au' => ['name' => 'ADMIN CMS > Inhalte > Autor' , 'color' => 'secondary'],
+ 'crm' => ['name' => 'ADMIN CRM ', 'color' => 'admin'],
+ 'crm-tp' => ['name' => 'ADMIN CRM > Reiseprogramme', 'color' => 'admin'],
+ 'crm-tp-pr' => ['name' => 'ADMIN CRM > Reiseprogramme > Programme', 'color' => 'admin'],
+ 'crm-tp-dr' => ['name' => 'ADMIN CRM > Reiseprogramme > Vorlagen', 'color' => 'admin'],
+ 'crm-tp-tc' => ['name' => 'ADMIN CRM > Reiseprogramme > Inhalte', 'color' => 'admin'],
+ 'crm-bo' => ['name' => 'ADMIN CRM > Buchungen', 'color' => 'admin'],
+ 'crm-bo-re' => ['name' => 'ADMIN CRM > Buchungen > Übersicht', 'color' => 'admin'],
+ 'crm-bo-bo' => ['name' => 'ADMIN CRM > Buchungen > Buchungen', 'color' => 'admin'],
+ 'crm-bo-le' => ['name' => 'ADMIN CRM > Buchungen > Anfragen', 'color' => 'admin'],
+ 'crm-bo-cu' => ['name' => 'ADMIN CRM > Buchungen > Kunden', 'color' => 'admin'],
+ 'crm-cm' => ['name' => 'ADMIN CRM > Kundenverwaltung', 'color' => 'admin'],
+ 'crm-cm-cf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Kunden (FeWo)', 'color' => 'admin'],
+ 'crm-cm-bf' => ['name' => 'ADMIN CRM > Kundenverwaltung > Buchungen (FeWo)', 'color' => 'admin'],
+ 'crm-mail' => ['name' => 'ADMIN CRM > E-Mails', 'color' => 'admin'],
+ 'crm-mail-le' => ['name' => 'ADMIN CRM > E-Mails > Anfragen', 'color' => 'admin'],
+ 'crm-mail-bo' => ['name' => 'ADMIN CRM > E-Mails > Buchungen', 'color' => 'admin'],
+ 'crm-mail-bf' => ['name' => 'ADMIN CRM > E-Mails > Buchungen (Fewo)', 'color' => 'admin'],
+ 'crm-iq-tl' => ['name' => 'ADMIN CRM > Reisebausteine', 'color' => 'admin'],
+ 'crm-iq-tl-pro' => ['name' => 'ADMIN CRM > Reisebausteine > Programm', 'color' => 'admin'],
+ 'crm-iq-tl-gp' => ['name' => 'ADMIN CRM > Reisebausteine > Gruppe', 'color' => 'admin'],
+ 'crm-iq-tl-it' => ['name' => 'ADMIN CRM > Reisebausteine > Baustein', 'color' => 'admin'],
+ 'crm-old-cm' => ['name' => 'ADMIN CRM altes System > Kundenverwaltung', 'color' => 'info'],
+ 'cms' => ['name' => 'ADMIN CMS', 'color' => 'secondary'],
+ 'cms-iq-assets' => ['name' => 'ADMIN CMS > Medien', 'color' => 'secondary'],
+ 'cms-tg' => ['name' => 'ADMIN CMS > Reiseführer', 'color' => 'secondary'],
+ 'cms-fewo' => ['name' => 'ADMIN CMS > FeWo', 'color' => 'secondary'],
+ 'cms-book' => ['name' => 'ADMIN CMS > Buchungen', 'color' => 'secondary'],
+ 'cms-fb' => ['name' => 'ADMIN CMS > Feedback', 'color' => 'secondary'],
+ 'cms-nw' => ['name' => 'ADMIN CMS > News', 'color' => 'secondary'],
+ 'cms-aq' => ['name' => 'ADMIN CMS > Fragen & Antworten', 'color' => 'secondary'],
+ 'cms-sb' => ['name' => 'ADMIN CMS > Sidebar', 'color' => 'secondary'],
+ 'cms-cn' => ['name' => 'ADMIN CMS > Inhalte', 'color' => 'secondary'],
+ 'cms-cn-in' => ['name' => 'ADMIN CMS > Inhalte > Infos', 'color' => 'secondary'],
+ 'cms-cn-al' => ['name' => 'ADMIN CMS > Inhalte > Inhalte', 'color' => 'secondary'],
+ 'cms-cn-au' => ['name' => 'ADMIN CMS > Inhalte > Autor', 'color' => 'secondary'],
+ 'cms-cn-co' => ['name' => 'ADMIN CMS > Inhalte > Länder', 'color' => 'secondary'],
+ 'cms-newsletter' => ['name' => 'ADMIN CMS > Newsletter', 'color' => 'secondary'],
+ 'crm-nav-api' => ['name' => 'ADMIN CRM > Navigation API', 'color' => 'secondary'],
],
2 => [
- 'sua-bo-n-edit' => ['name' => 'SUPERADMIN > Buchungen > Notizen > bearbeiten' , 'color' => 'secondary'],
- 'sua-fewo-n-edit' => ['name' => 'SUPERADMIN > FeWo > Notizen > bearbeiten' , 'color' => 'secondary'],
- 'sua-st' => ['name' => 'SUPERADMIN > Einstellungen' , 'color' => 'superadmin'],
- 'sua-st-al' => ['name' => 'SUPERADMIN > Einstellungen > Airline' , 'color' => 'superadmin'],
- 'sua-st-ap' => ['name' => 'SUPERADMIN > Einstellungen > Airport' , 'color' => 'superadmin'],
- 'sua-st-em' => ['name' => 'SUPERADMIN > Einstellungen > E-Mails' , 'color' => 'superadmin'],
- 'sua-st-ke' => ['name' => 'SUPERADMIN > Einstellungen > Keywords' , 'color' => 'superadmin'],
- 'sua-st-sp' => ['name' => 'SUPERADMIN > Einstellungen > Leistungsträger' , 'color' => 'superadmin'],
- 'sua-st-tn' => ['name' => 'SUPERADMIN > Einstellungen > Nationalitäten' , 'color' => 'superadmin'],
- 'sua-st-co' => ['name' => 'SUPERADMIN > Einstellungen > Reiseländer' , 'color' => 'superadmin'],
- 'sua-st-tp' => ['name' => 'SUPERADMIN > Einstellungen > Reiseprogramme' , 'color' => 'superadmin'],
- 'sua-st-tpl' => ['name' => 'SUPERADMIN > Einstellungen > Reiseorte' , 'color' => 'superadmin'],
- 'sua-st-bs' => ['name' => 'SUPERADMIN > Einstellungen > Reisestatus' , 'color' => 'superadmin'],
- 'sua-st-tc' => ['name' => 'SUPERADMIN > Einstellungen > Veranstalter' , 'color' => 'superadmin'],
- 'sua-st-tca' => ['name' => 'SUPERADMIN > Einstellungen > Reiseart' , 'color' => 'superadmin'],
- 'sua-st-tap' => ['name' => 'SUPERADMIN > Einstellungen > Zielflughafen' , 'color' => 'superadmin'],
- 'sua-st-in' => ['name' => 'SUPERADMIN > Einstellungen > Versicherungen' , 'color' => 'superadmin'],
- 'sua-st-ca' => ['name' => 'SUPERADMIN > Einstellungen > Kategorien' , 'color' => 'superadmin'],
- 'sua-st-tgn' => ['name' => 'SUPERADMIN > Einstellungen > Reisehinweise' , 'color' => 'superadmin'],
- 'sua-re' => ['name' => 'SUPERADMIN > Export' , 'color' => 'superadmin'],
- 'sua-re-bo' => ['name' => 'SUPERADMIN > Export > Buchungen' , 'color' => 'superadmin'],
- 'sua-re-pp' => ['name' => 'SUPERADMIN > Export > Leistungsträger' , 'color' => 'superadmin'],
- 'sua-re-fw' => ['name' => 'SUPERADMIN > Export > Fewo' , 'color' => 'superadmin'],
- 'sua-re-le' => ['name' => 'SUPERADMIN > Export > Anfragen' , 'color' => 'superadmin'],
- 'sua-ur-rt' => ['name' => 'SUPERADMIN > User Rechte' , 'color' => 'danger'],
- 'cms-cn-co' => ['name' => 'ADMIN CMS > Inhalte > Länder' , 'color' => 'secondary'],
+ 'sua-bo-n-edit' => ['name' => 'SUPERADMIN > Buchungen > Notizen > bearbeiten', 'color' => 'secondary'],
+ 'sua-fewo-n-edit' => ['name' => 'SUPERADMIN > FeWo > Notizen > bearbeiten', 'color' => 'secondary'],
+ 'sua-st' => ['name' => 'SUPERADMIN > Einstellungen', 'color' => 'superadmin'],
+ 'sua-st-al' => ['name' => 'SUPERADMIN > Einstellungen > Airline', 'color' => 'superadmin'],
+ 'sua-st-ap' => ['name' => 'SUPERADMIN > Einstellungen > Airport', 'color' => 'superadmin'],
+ 'sua-st-em' => ['name' => 'SUPERADMIN > Einstellungen > E-Mails', 'color' => 'superadmin'],
+ 'sua-st-ke' => ['name' => 'SUPERADMIN > Einstellungen > Keywords', 'color' => 'superadmin'],
+ 'sua-st-sp' => ['name' => 'SUPERADMIN > Einstellungen > Leistungsträger', 'color' => 'superadmin'],
+ 'sua-st-tn' => ['name' => 'SUPERADMIN > Einstellungen > Nationalitäten', 'color' => 'superadmin'],
+ 'sua-st-co' => ['name' => 'SUPERADMIN > Einstellungen > Reiseländer', 'color' => 'superadmin'],
+ 'sua-st-tp' => ['name' => 'SUPERADMIN > Einstellungen > Reiseprogramme', 'color' => 'superadmin'],
+ 'sua-st-tpl' => ['name' => 'SUPERADMIN > Einstellungen > Reiseorte', 'color' => 'superadmin'],
+ 'sua-st-bs' => ['name' => 'SUPERADMIN > Einstellungen > Reisestatus', 'color' => 'superadmin'],
+ 'sua-st-tc' => ['name' => 'SUPERADMIN > Einstellungen > Veranstalter', 'color' => 'superadmin'],
+ 'sua-st-tca' => ['name' => 'SUPERADMIN > Einstellungen > Reiseart', 'color' => 'superadmin'],
+ 'sua-st-tap' => ['name' => 'SUPERADMIN > Einstellungen > Zielflughafen', 'color' => 'superadmin'],
+ 'sua-st-in' => ['name' => 'SUPERADMIN > Einstellungen > Versicherungen', 'color' => 'superadmin'],
+ 'sua-st-ca' => ['name' => 'SUPERADMIN > Einstellungen > Kategorien', 'color' => 'superadmin'],
+ 'sua-st-tgn' => ['name' => 'SUPERADMIN > Einstellungen > Reisehinweise', 'color' => 'superadmin'],
+ 'sua-re' => ['name' => 'SUPERADMIN > Export', 'color' => 'superadmin'],
+ 'sua-re-bo' => ['name' => 'SUPERADMIN > Export > Buchungen', 'color' => 'superadmin'],
+ 'sua-re-pp' => ['name' => 'SUPERADMIN > Export > Leistungsträger', 'color' => 'superadmin'],
+ 'sua-re-fw' => ['name' => 'SUPERADMIN > Export > Fewo', 'color' => 'superadmin'],
+ 'sua-re-le' => ['name' => 'SUPERADMIN > Export > Anfragen', 'color' => 'superadmin'],
+ 'sua-ur-rt' => ['name' => 'SUPERADMIN > User Rechte', 'color' => 'danger'],
+
],
],
'roles' => [
@@ -76,4 +79,4 @@ return [
1 => 'Admin',
2 => 'SuperAdmin'
]
-];
\ No newline at end of file
+];
diff --git a/database/migrations/2025_11_07_000001_create_newsletter_contacts_table.php b/database/migrations/2025_11_07_000001_create_newsletter_contacts_table.php
new file mode 100644
index 0000000..3f98437
--- /dev/null
+++ b/database/migrations/2025_11_07_000001_create_newsletter_contacts_table.php
@@ -0,0 +1,86 @@
+id();
+
+ // Kontaktdaten
+ $table->string('email')->index();
+ $table->string('firstname')->nullable();
+ $table->string('lastname')->nullable();
+
+ // Newsletter-Gruppen (kann mehrere sein)
+ $table->boolean('group_kulturreisen')->default(false)->index();
+ $table->boolean('group_ferienwohnungen')->default(false)->index();
+
+ // Status & Herkunft
+ $table->enum('source', [
+ 'booking_kulturreisen',
+ 'booking_ferienwohnungen',
+ 'newsletter_signup',
+ 'manual',
+ 'import'
+ ])->index();
+
+ $table->enum('status', [
+ 'active',
+ 'inactive',
+ 'unsubscribed',
+ 'bounced'
+ ])->default('active')->index();
+
+ // Tracking-Informationen
+ $table->timestamp('subscribed_at')->nullable();
+ $table->timestamp('unsubscribed_at')->nullable();
+ $table->timestamp('last_booking_at')->nullable();
+
+ // Statistiken
+ $table->integer('total_bookings_kulturreisen')->default(0);
+ $table->integer('total_bookings_ferienwohnungen')->default(0);
+
+ // Referenzen zu Originaldaten
+ $table->integer('customer_id')->nullable()->index()->comment('Referenz zu customer Tabelle für Kulturreisen');
+ $table->integer('travel_user_id')->nullable()->index()->comment('Referenz zu travel_users Tabelle für Ferienwohnungen');
+
+ // Sync-Informationen
+ $table->timestamp('last_synced_at')->nullable();
+ $table->string('sync_hash')->nullable()->comment('Hash für Duplikat-Erkennung');
+
+ // Notizen & Zusatzinformationen
+ $table->text('notes')->nullable();
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ // Unique Index auf E-Mail (nur für nicht gelöschte Einträge)
+ $table->unique(['email', 'deleted_at'], 'newsletter_email_unique');
+
+ // Composite Index für häufige Abfragen mit kürzeren Namen
+ $table->index(['status', 'group_kulturreisen', 'group_ferienwohnungen'], 'newsletter_status_groups_idx');
+ $table->index(['source', 'status'], 'newsletter_source_status_idx');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('newsletter_contacts');
+ }
+}
+
diff --git a/database/migrations/2025_11_07_000002_create_newsletter_logs_table.php b/database/migrations/2025_11_07_000002_create_newsletter_logs_table.php
new file mode 100644
index 0000000..67b1625
--- /dev/null
+++ b/database/migrations/2025_11_07_000002_create_newsletter_logs_table.php
@@ -0,0 +1,51 @@
+id();
+ $table->foreignId('newsletter_contact_id')->constrained()->onDelete('cascade');
+
+ $table->enum('action', [
+ 'subscribed',
+ 'unsubscribed',
+ 'booking_added',
+ 'status_changed',
+ 'group_changed',
+ 'email_sent',
+ 'bounced'
+ ]);
+
+ $table->string('description')->nullable();
+ $table->json('metadata')->nullable();
+
+ $table->integer('user_id')->nullable()->comment('Admin-User der die Aktion ausgeführt hat');
+
+ $table->timestamps();
+
+ $table->index(['newsletter_contact_id', 'created_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('newsletter_logs');
+ }
+}
+
diff --git a/database/migrations/2025_11_12_000001_add_last_travel_end_date_to_newsletter_contacts_table.php b/database/migrations/2025_11_12_000001_add_last_travel_end_date_to_newsletter_contacts_table.php
new file mode 100644
index 0000000..c9e204f
--- /dev/null
+++ b/database/migrations/2025_11_12_000001_add_last_travel_end_date_to_newsletter_contacts_table.php
@@ -0,0 +1,32 @@
+dateTime('last_travel_end_date')->nullable()->after('last_booking_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('newsletter_contacts', function (Blueprint $table) {
+ $table->dropColumn('last_travel_end_date');
+ });
+ }
+}
diff --git a/dev/frontend-navigation/BACKEND-UI.md b/dev/frontend-navigation/BACKEND-UI.md
new file mode 100644
index 0000000..81c0836
--- /dev/null
+++ b/dev/frontend-navigation/BACKEND-UI.md
@@ -0,0 +1,312 @@
+# Backend-UI für Navigation API
+
+Die Backend-UI bietet eine benutzerfreundliche grafische Oberfläche zur Verwaltung und Visualisierung des Navigationsbaums **genau wie im Frontend (header.html.twig)**.
+
+## Zugriff
+
+**URL:** `/navigation-api`
+**Permission:** `crm-nav-api`
+**Menü:** Navigation API (Seitenmenü)
+
+## Frontend-Struktur
+
+Die Backend-UI zeigt den Navigationsbaum **exakt wie im Frontend** mit allen Bereichen:
+
+### 🌍 Länder-Navigation
+
+- ✅ **Länderseiten** (Pages mit `country_id`) als Root-Level
+- ✅ **Sortierung** nach `order` und `title`
+- ✅ **Gruppierung** der Children nach `beforeTitle` (Haupt / Infos)
+- ✅ **titleShort** wird verwendet statt vollständigem Titel
+- ✅ Toggle zum Auf-/Zuklappen der Children
+
+### 🏠 USEDOM Ferienwohnungen
+
+- ✅ **Ferienwohnungs-Übersicht** als Hauptseite
+- ✅ **Einzelne FeWos** als Children
+- ✅ Sortiert nach `order` und `title`
+
+### 📑 Mehr-Menü Seiten
+
+- ✅ **Über uns**
+- ✅ **Reiseversicherung**
+- ✅ **Reiseführer** (mit möglichen Children)
+- ✅ **Reisemagazin** (mit möglichen Children)
+- ✅ **Reisenews** (mit möglichen Children)
+
+### Allgemein
+
+- ✅ **Ausgeblendete Pages** werden angezeigt (Badge "Ausgeblendet")
+- ✅ **Section-Separators** trennen die Bereiche visuell
+
+## Features
+
+### 📊 Live-Statistiken
+
+Oben auf der Seite werden wichtige Kennzahlen angezeigt:
+
+- **Gesamt Seiten:** Alle Pages in der Datenbank
+- **Aktive Seiten:** Nur sichtbare und aktive Pages
+- **Reiseprogramme:** Anzahl der TravelProgram-Pages
+- **Länderseiten:** Anzahl der Country-Pages
+
+Die Statistiken werden beim Laden der Seite automatisch aktualisiert.
+
+### 🌳 Interaktiver Navigationsbaum
+
+Der Hauptbereich zeigt den hierarchischen Navigationsbaum mit folgenden Features:
+
+#### Visuelle Darstellung
+
+- **Icons:**
+
+ - ⭐ Stern (gelb) = Länderseite (Root-Level)
+ - ✈️ Flugzeug = Reiseprogramm
+ - 🏠 Haus = Ferienwohnung
+ - ℹ️ Info-Circle = Infos-Gruppe
+ - 📄 Dokument = Normale Seite
+
+- **Badges:**
+
+ - 🔴 **Inaktiv** = Status ist 0
+ - ⚠️ **Ausgeblendet** = show_in_navi ist 0 (wird trotzdem angezeigt!)
+ - 🔵 **Reiseprogramm** = Hat TravelProgram
+ - 💙 **FeWo** = Hat FewoLodging
+ - 💚 **Land** = Hat Country
+ - ⚪ **Gruppe: Infos** = beforeTitle ist gesetzt
+
+- **Hierarchie:**
+
+ - **Root-Level:** Länderseiten (fett, grauer Hintergrund, blaue Linie links)
+ - **Children:** Eingerückt, normale Darstellung
+ - **Separator "Infos":** Grauer Block trennt Haupt- von Info-Seiten
+ - Toggle-Buttons nur bei Länderseiten
+
+- **Besonderheiten:**
+ - **titleShort** wird angezeigt (wie im Frontend)
+ - **Gruppierung** nach beforeTitle (Haupt, dann Infos)
+ - **Ausgeblendete** Pages haben gelbes Badge aber sind sichtbar
+
+#### Interaktion
+
+- **Auf-/Zuklappen:**
+
+ - Klick auf Pfeil: Einzelnen Knoten auf-/zuklappen
+ - "Alle aufklappen": Zeigt kompletten Baum
+ - "Alle zuklappen": Zeigt nur Root-Ebene
+
+- **Hover-Effekt:**
+ - Hintergrund wird grau beim Überfahren mit der Maus
+
+### 🔍 Suche
+
+Die Suchfunktion durchsucht alle Navigationspunkte nach:
+
+- **Titel:** Page-Titel
+- **Slug:** URL-freundlicher Name
+- **URL:** Vollständiger Pfad
+
+**Verwendung:**
+
+1. Suchbegriff eingeben
+2. Enter drücken oder auf Suchbutton klicken
+3. Gefundene Knoten werden gelb markiert
+4. Parent-Knoten werden automatisch aufgeklappt
+
+### 🎯 Filter
+
+**"Mit ausgeblendeten" / "Nur sichtbare"**
+
+- **Mit ausgeblendeten (Standard):** Zeigt alle Pages inkl. ausgeblendete (show_in_navi=0)
+- **Nur sichtbare:** Zeigt nur Pages mit show_in_navi=1 (wie im Frontend)
+
+Der aktive Filter wird durch die Button-Farbe angezeigt:
+
+- Blau = Mit ausgeblendeten
+- Primär = Nur sichtbare
+
+Ausgeblendete Pages werden mit einem gelben Badge "Ausgeblendet" markiert.
+
+### 📥 Export
+
+**JSON-Export-Funktion:**
+
+- Klick auf "Export JSON" lädt eine JSON-Datei herunter
+- Dateiname: `navigation-tree-YYYY-MM-DD-HHMMSS.json`
+- Inhalt: Kompletter Navigationsbaum entsprechend aktuellem Filter
+- Format: Pretty-printed JSON mit UTF-8 Encoding
+
+**Verwendungszwecke:**
+
+- Backup der Navigationsstruktur
+- Import in andere Systeme
+- Analyse und Dokumentation
+- Debugging
+
+### 🔄 Cache-Verwaltung
+
+**Cache leeren:**
+
+- Klick auf "Cache leeren"
+- Bestätigung erforderlich
+- Löscht alle gecachten Navigationsdaten
+- Wird automatisch nach 60 Minuten erneuert
+
+**Wann Cache leeren?**
+
+- Nach Änderungen an Pages
+- Nach Import/Export von Daten
+- Bei veralteten Anzeigen
+- Nach Struktur-Änderungen
+
+## Technische Details
+
+### API-Calls
+
+Die UI nutzt folgende Endpunkte:
+
+```javascript
+// Statistiken laden
+GET /navigation-api/stats
+
+// Navigationsbaum laden (Frontend-Struktur)
+GET /navigation-api/data?include_hidden=0|1
+
+// Suche durchführen
+GET /navigation-api/search?query=...
+
+// Export starten (Frontend-Struktur)
+GET /navigation-api/export?include_hidden=0|1
+
+// Cache leeren
+POST /navigation-api/clear-cache
+```
+
+**Unterschied zu API-Endpunkten:**
+
+- `/api/navigation/*` = Kompletter Baum (API)
+- `/navigation-api/*` = Frontend-Struktur (nur Länderseiten)
+
+### Performance
+
+- **Caching:** 60 Minuten Server-seitig
+- **Lazy Loading:** Kinder werden nur bei Bedarf gerendert
+- **Optimierte Queries:** Eager Loading von Relationships
+- **Frontend-Rendering:** jQuery-basiert, schnell auch bei 1000+ Knoten
+
+### Browser-Kompatibilität
+
+- ✅ Chrome/Edge (aktuell)
+- ✅ Firefox (aktuell)
+- ✅ Safari (aktuell)
+- ⚠️ IE11 (eingeschränkt)
+
+## Styling
+
+### Farben
+
+- **Primary (Blau):** Reiseprogramme, aktive Aktionen
+- **Success (Grün):** Aktive Pages, Länder
+- **Info (Cyan):** Ferienwohnungen
+- **Warning (Gelb):** "Nicht in Navi", Suchmarkierungen
+- **Danger (Rot):** Inaktive Pages, Löschen-Aktionen
+
+### Responsive Design
+
+Die UI ist responsive und funktioniert auf verschiedenen Bildschirmgrößen:
+
+- **Desktop (>1200px):** Volle Features, 4 Statistik-Karten
+- **Tablet (768-1199px):** 2 Statistik-Karten pro Zeile
+- **Mobile (<768px):** 1 Statistik-Karte pro Zeile, vereinfachte Toolbar
+
+## Shortcuts
+
+Keine Keyboard-Shortcuts implementiert (noch).
+
+## Bekannte Einschränkungen
+
+1. **Sehr große Bäume (>10.000 Knoten):**
+
+ - Kann zu langsamem Rendering führen
+ - Empfehlung: Filter verwenden
+
+2. **Suche:**
+
+ - Nur client-seitig, keine Server-Suche
+ - Bei vielen Ergebnissen kann es unübersichtlich werden
+
+3. **Keine Bearbeitung:**
+ - Nur Anzeige, keine Inline-Bearbeitung
+ - Änderungen müssen über CMS erfolgen
+
+## Zukünftige Erweiterungen
+
+Mögliche Features für die Zukunft:
+
+- [ ] Drag & Drop zum Verschieben von Knoten
+- [ ] Inline-Bearbeitung von Titeln
+- [ ] Bulk-Operationen (Status ändern, löschen, etc.)
+- [ ] Mehr Filter-Optionen (Template, Level, etc.)
+- [ ] Keyboard-Shortcuts
+- [ ] Pagination für sehr große Bäume
+- [ ] Graphische Visualisierung (D3.js Tree)
+- [ ] Breadcrumb-Anzeige für selektierten Knoten
+- [ ] Export in andere Formate (CSV, XML)
+
+## Troubleshooting
+
+### Problem: Baum lädt nicht
+
+**Lösung:**
+
+1. Browser-Konsole öffnen (F12)
+2. Fehler prüfen
+3. Netzwerk-Tab prüfen: Status-Code der API-Calls
+4. Backend-Logs prüfen
+
+### Problem: Statistiken zeigen "-"
+
+**Lösung:**
+
+1. API-Endpunkt `/navigation-api/stats` prüfen
+2. Browser-Konsole auf Fehler prüfen
+3. Permission `crm-tp-na` prüfen
+
+### Problem: Cache wird nicht geleert
+
+**Lösung:**
+
+1. CSRF-Token prüfen
+2. POST-Request erfolgreich? (Netzwerk-Tab)
+3. Server-Logs prüfen
+4. Cache-System aktiv? (config/cache.php)
+
+### Problem: Suche funktioniert nicht
+
+**Lösung:**
+
+1. JavaScript-Fehler in Konsole?
+2. jQuery geladen?
+3. Suchbegriff korrekt eingegeben?
+
+## Support
+
+Bei Problemen oder Fragen:
+
+1. Dokumentation lesen: `dev/frontend-navigation/README.md`
+2. API-Dokumentation: `dev/frontend-navigation/navigation-api.md`
+3. Test-Tools verwenden: `dev/frontend-navigation/test-api.html`
+
+## Changelog
+
+### Version 1.0 (Initial Release)
+
+- ✅ Navigationsbaum-Visualisierung
+- ✅ Live-Statistiken
+- ✅ Suche
+- ✅ Filter (Alle/Aktive)
+- ✅ Export (JSON)
+- ✅ Cache-Verwaltung
+- ✅ Responsive Design
+- ✅ Icon-basierte Typerkennung
+- ✅ Badge-System für Status
diff --git a/dev/frontend-navigation/KernelControllerListener.php b/dev/frontend-navigation/KernelControllerListener.php
new file mode 100644
index 0000000..fd7e0d1
--- /dev/null
+++ b/dev/frontend-navigation/KernelControllerListener.php
@@ -0,0 +1,256 @@
+
+ * @date 12/02/2016
+ */
+
+namespace AppBundle\Listener;
+
+
+use AppBundle\AppBundle;
+use AppBundle\Controller\DefaultController;
+use AppBundle\Entity\Page;
+use AppBundle\Util;
+use Doctrine\ORM\EntityManager;
+use Doctrine\ORM\PersistentCollection;
+use Symfony\Bundle\FrameworkBundle\Routing\Router;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Session\Session;
+use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
+use Symfony\Component\HttpKernel\Event\FilterControllerEvent;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+class KernelControllerListener
+{
+ private $em;
+ private $controllerResolver;
+
+ public function __construct(EntityManager $entityManager, ControllerResolverInterface $controllerResolver)
+ {
+ $this->em = $entityManager;
+ $this->controllerResolver = $controllerResolver;
+ }
+
+ private function setSessionAttributeByTime($request, $key)
+ {
+
+ $session = $request->getSession();
+ $session->set('_open_side_about', '');
+ $session->set('_open_side_search', '');
+
+ if ($key === 'default') { //is default visit
+ if (!$session->get('default_visit')) { //first visit
+ $session->set('default_visit', true);
+ $session->set('_open_side_about', 'open');
+ $session->set('_open_side_search', 'open');
+ }
+ }
+
+ if ($key === 'api') { //is api = Reiseführer
+ if (!$session->get('api_visit')) { //first visit
+ $session->set('api_visit', true);
+ $session->set('_open_side_about', 'open');
+ }
+ }
+ }
+ public function onKernelController(FilterControllerEvent $event)
+ {
+ $request = $event->getRequest();
+
+ $session = $request->getSession();
+ Util::setMySession('search_request_b', $session->get('search_request_b'));
+ Util::setMySession('search_request_e', $session->get('search_request_e'));
+ Util::setMySession('search_request_c', $session->get('search_request_c'));
+
+ if ($request->get('_controller') === 'AppBundle\Controller\DefaultController::homeAction') {
+ $this->setSessionAttributeByTime($request, "default");
+ }
+ if ($request->get('_controller') == 'AppBundle\Controller\DefaultController::defaultAction') {
+
+ $repo = $this->em->getRepository('AppBundle:Page');
+ $path = preg_replace('/^\/?(.*?)\/?$/', '$1', $request->getPathInfo());
+ /** @var Page $node */
+ $node = null;
+
+ // Try to find by url path. It's possible that the path consists of two parts:
+ // - the beginning part represents a node
+ // - the ending part represents a handler
+ // e.g. /path/to/travel/program/buchen ("buchen" is the handler part)
+ $pathArray = explode('/', $path);
+ $restOfPath = '';
+ $curPath = $path;
+ //search for entry in new tree objects
+ $api = Util::loadFromApi('cms/search', ['url' => $curPath]);
+ while (!empty($pathArray)) {
+ if (!$api) {
+ $node = $repo->findOneBy(['realUrlPath' => '/' . $curPath]);
+ if ($node) {
+ break;
+ }
+ }
+ $restOfPath = '/' . array_pop($pathArray) . $restOfPath;
+ $curPath = implode('/', $pathArray);
+ }
+ //find and try 301
+ //find => to
+ $redirects = [
+ '/reisefuehrer/tuerkei' => 'tuerkei-reisen/reisefuehrer',
+ '/reisefuehrer/jordanien' => 'jordanien-reisen/reisefuehrer',
+ '/reisefuehrer/oman' => 'oman-reisen/reisefuehrer',
+ '/reisefuehrer/israel' => 'israel-reisen/israel-reisefuehrer',
+ '/reisefuehrer/aegypten' => 'aegypten-reisen/aegypten-reisefuehrer',
+ '/reisemagazin/tuerkei-reisemagazin' => 'tuerkei-reisen/tuerkei-reisemagazin',
+ '/reisemagazin/aegypten' => 'aegypten-reisen/aegypten-reisemagazin',
+ '/reisemagazin/israel' => 'israel-reisen/israel-reisemagazin',
+ '/reisemagazin/jordanien' => 'jordanien-reisen/jordanien-reisemagazin',
+ '/reisemagazin/marokko' => 'marokko-urlaub/marokko-reisemagazin'
+
+ ];
+ //301
+ foreach ($redirects as $find => $to) {
+ if (strpos($restOfPath, $find) !== false) {
+ $restOfPath = str_replace($find, $to, $restOfPath);
+ $protocol = 'https';
+ if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] === "off") {
+ $protocol = 'http';
+ }
+ header("Location: " . $protocol . "://" . $_SERVER["HTTP_HOST"] . "/" . $restOfPath, true, 301);
+ exit();
+ }
+ }
+ //load content from API, is found by cms/search
+
+ if ($api) {
+ $this->setSessionAttributeByTime($request, "api");
+ $request->attributes->set('_controller', 'AppBundle:Cms:iqTravelGuide');
+ $request->attributes->set('api', $api);
+ $request->attributes->set('template', 'TravelGuide');
+ try {
+ $controller = $this->controllerResolver->getController($request);
+ } catch (\LogicException $e) {
+ // If there is no controller action, call the default action and pass the template name
+ $request->attributes->set('_controller', 'AppBundle:Cms:Default');
+ $request->attributes->set('template', "Default");
+ }
+ $event->setController($controller ?? $this->controllerResolver->getController($request));
+ return;
+ }
+ $this->setSessionAttributeByTime($request, "default");
+
+ if (!$node) {
+ // Now try to find a page by tracing a page node path using the page nodes' slugs
+
+ $pathArray = explode('/', $path);
+
+ while (!empty($pathArray)) {
+ $restOfPath = '/' . implode('/', $pathArray);
+ $slug = array_shift($pathArray);
+
+ $qb = $repo->createQueryBuilder('p');
+
+ $qb->where($qb->expr()->eq('p.slug', ':slug'));
+ $qb->setParameter('slug', $slug);
+
+ if ($node != null) {
+ $qb->andWhere($qb->expr()->eq('p.parent', ':parentId'));
+ $qb->setParameter('parentId', $node->getId());
+ } else {
+ $qb->andWhere($qb->expr()->isNull('p.parent'));
+ }
+
+ $qb->setMaxResults(1);
+ $childNode = $qb->getQuery()->getOneOrNullResult();
+ if (!$childNode) {
+ $whitelist = [
+ 'buchen',
+ 'berechne-gesamtpreis',
+ 'show_nationality_country_text',
+ 'pdf',
+ ];
+ if (!in_array($slug, $whitelist)) {
+ throw new HttpException(404, 'Seite nicht gefunden: ' . $slug);
+ }
+ break;
+ }
+ if ($node) {
+
+ // Avoid database calls to parent later
+ $childNode->setParent($node);
+ }
+ $node = $childNode;
+ }
+ if ($node && $node->getRealUrlPath() && $node->getRealUrlPath() != '/' . $path) {
+ // If there realUrlPath is set and the slug path differs from realUrlPath, then the slug path is
+ // not a valid URL. Otherwise, there would be two different URLs representing the same page.
+ $event->setController(function () {
+ throw new NotFoundHttpException('Invalid URL');
+ });
+ return;
+ }
+ }
+ if (!$node) {
+ // Search for a redirect entry
+ $redirect = $this->em->getRepository('AppBundle:Redirect')->findOneBy(['sourceUrlPath' => '/' . $path]);
+ if ($redirect) {
+ $redirectUrl = $redirect->getPage()->getUrlPath();
+ $event->setController(function () use ($redirectUrl) {
+ return new RedirectResponse($redirectUrl, 301);
+ });
+ return;
+ }
+ }
+ if ($node) {
+
+ if ($node->getStatus() == 0) {
+ throw new NotFoundHttpException('Inactive page');
+ }
+
+ $request->attributes->set('page', $node);
+
+ if ($node->getTravelProgram() != null && (
+ $restOfPath == '/buchen' || $restOfPath == '/berechne-gesamtpreis' || $restOfPath == '/show_nationality_country_text')) {
+ // Special case: Booking actions
+ $request->attributes->set('travelProgramPage', $node);
+ $request->attributes->set('action', $restOfPath);
+ $request->attributes->set('_controller', 'AppBundle:Booking:index');
+ } elseif ($restOfPath && $node->getTravelProgram() !== null && (
+ $restOfPath === '/pdf')) {
+ $request->attributes->set('_controller', 'AppBundle:Cms:pdf');
+ } elseif ($node->getTravelProgram() !== null) {
+ if ($node->getTravelProgram()->getStatus() == 0) {
+ throw new \NotFoundHttpException('Inactive travel program');
+ }
+ $request->attributes->set('_controller', 'AppBundle:Cms:travelProgram');
+ } elseif (
+ $node->getFewoLodging() != null &&
+ ($restOfPath === '/buchen' || $restOfPath === '/berechne-gesamtpreis')
+ ) {
+ $request->attributes->set('fewoTravelProgramPage', $node);
+ $request->attributes->set('action', $restOfPath);
+ $request->attributes->set('_controller', 'AppBundle:FewoBooking:index');
+ } elseif ($node->getFewoLodging() !== null) {
+ $request->attributes->set('fewoLodgingPage', $node);
+ $request->attributes->set('action', $restOfPath);
+ $request->attributes->set('_controller', 'AppBundle:Cms:fewoLodging');
+ } else {
+ $handler = $node->getTemplate() ? ucfirst($node->getTemplate()) : 'Default';
+ $request->attributes->set('_controller', 'AppBundle:Cms:' . $handler);
+ if ($node->getTemplate()) {
+ try {
+ $controller = $this->controllerResolver->getController($request);
+ } catch (\LogicException $e) {
+ // If there is no controller action, call the default action and pass the template name
+ $request->attributes->set('_controller', 'AppBundle:Cms:Default');
+ $request->attributes->set('template', $node->getTemplate());
+ }
+ }
+ }
+ } else {
+ return;
+ }
+ $event->setController($controller ?? $this->controllerResolver->getController($request));
+ }
+ }
+}
diff --git a/dev/frontend-navigation/PageRepository.php b/dev/frontend-navigation/PageRepository.php
new file mode 100644
index 0000000..47e0156
--- /dev/null
+++ b/dev/frontend-navigation/PageRepository.php
@@ -0,0 +1,180 @@
+getChildrenQueryBuilder($page)
+ ->leftJoin('node.travelProgram', 'tp')
+ ->addSelect('tp')
+ ->andWhere('tp.status > 0')
+ ->andWhere('node.status > 0')
+ ->orderBy('node.order')
+ ->addOrderBy('tp.position')
+ ->addOrderBy('node.title')
+ ->getQuery()
+ ->execute();
+ /** @var Page $childPage */
+ foreach ($pages as &$childPage) {
+ if ($childPage->getTravelProgram()) {
+ $this->getEntityManager()->getRepository('AppBundle:TravelPeriod')->getTrueTravelPeriods(
+ $childPage->getTravelProgram()
+ );
+ }
+ }
+ return $pages;
+ }
+
+ public function findWithTravelProgramsOfCountry(TravelCountry $country)
+ {
+ return $this->createQueryBuilder('node')
+ ->innerJoin('node.travelProgram', 'tp')
+ ->innerJoin('tp.countries', 'c')
+ ->where('c.id = ' . $country->getId())
+ ->andWhere('node.status = 1')
+ ->andWhere('tp.status = 1')
+ ->getQuery()
+ ->execute()
+ ;
+ }
+
+ /**
+ * @return Page[]
+ */
+ public function findOffers()
+ {
+ $ret = [];
+ $countries = $this->getEntityManager()->getRepository('AppBundle:TravelCountry')->findAll();
+ foreach ($countries as $country) {
+ $ret = array_merge(
+ $ret,
+ $this->createQueryBuilder('node')
+ ->innerJoin('node.travelProgram', 'tp')
+ ->addSelect('tp')
+ ->innerJoin('tp.countries', 'c')
+ ->where('c.id = ' . $country->getId())
+ ->andWhere('node.status = 1')
+ ->andWhere('tp.status = 1')
+ ->orderBy('node.order')
+ ->addOrderBy('tp.position')
+ ->addOrderBy('node.title')
+ ->setMaxResults(3)
+ ->getQuery()
+ ->execute()
+ );
+ }
+ shuffle($ret);
+ return $ret;
+ }
+
+ public function findCountryPages()
+ {
+ return $this->createQueryBuilder('node')
+ ->innerJoin('node.country', 'country')
+ ->where('node.status > 0')
+ ->andWhere('node.template = \'overview\'')
+ ->andWhere('node.lvl = 0')
+ ->andWhere('node.order > 0')
+ ->orderBy('node.order,node.title')
+ ->getQuery()
+ ->execute()
+ ;
+ }
+
+ public function findTopCountryNavPages()
+ {
+ return $this->createQueryBuilder('node')
+ ->innerJoin('node.country', 'country')
+ ->leftJoin('node.children', 'childPage', Expr\Join::WITH, 'childPage.status > 0')
+ ->addSelect('childPage')
+ ->where('node.status > 0')
+ ->andWhere('node.template = \'overview\'')
+ ->andWhere('node.lvl = 0')
+ ->andWhere('node.order > 0')
+ ->orderBy('node.order,node.title, childPage.order, childPage.title')
+ ->getQuery()
+ ->execute()
+ ;
+ }
+
+ public function findFeedbacks($rootPageId)
+ {
+ $qb = $this->createQueryBuilder('node');
+ return $qb
+ ->where($qb->expr()->eq('node.parent', $rootPageId))
+ ->andWhere('node.showInNavi = 1')
+ ->andWhere('node.status = 1')
+ ->orderBy('node.order')
+ ->getQuery()
+ ->execute()
+ ;
+ }
+
+ public function findParentsWithShowNav($rootPageId)
+ {
+
+ $qb = $this->createQueryBuilder('node');
+ $pages = $qb->innerJoin('node.travelProgram', 'tp')
+ ->addSelect('tp')
+ ->where($qb->expr()->eq('node.parent', $rootPageId))
+ ->andWhere('node.showInNavi = 1')
+ ->andWhere('node.status = 1')
+ ->andWhere('tp.status > 0')
+ ->orderBy('node.order')
+ ->getQuery()
+ ->execute();
+
+ foreach ($pages as &$childPage) {
+ if ($childPage->getTravelProgram()) {
+ // var_dump($childPage->getTravelProgram()->getId());
+ // $this->getEntityManager()->getRepository('AppBundle:TravelPeriod')->getTrueTravelPeriods($childPage->getTravelProgram());
+ }
+ }
+ return $pages;
+ }
+
+ /**
+ * @param Page $page
+ *
+ * @return Page[]|\Doctrine\Common\Collections\Collection
+ */
+ public function getSiblings(Page $page)
+ {
+ $parent = $page->getParent();
+ if (!$parent) {
+ // On purpose, we don't treat root pages as if they were siblings
+ return [];
+ }
+ $siblings = $parent->getChildren();
+ foreach ($siblings as &$sibling) {
+ $sibling->setParent($parent);
+ }
+
+ // Da diese Methode nur für die Navigation verwendet wird, kann man hier vorfiltern
+ $filteredSiblings = [];
+ foreach ($siblings as &$sibling) {
+ if ($sibling->getStatus() == 1 && $sibling->getShowInNavi() == 1) {
+ $filteredSiblings[] = $sibling;
+ }
+ }
+
+ return $filteredSiblings;
+ }
+}
diff --git a/dev/frontend-navigation/README.md b/dev/frontend-navigation/README.md
new file mode 100644
index 0000000..eae68a6
--- /dev/null
+++ b/dev/frontend-navigation/README.md
@@ -0,0 +1,197 @@
+# Frontend Navigation - Backend API
+
+Dieses Verzeichnis enthält die Dokumentation und Referenzimplementierungen für die Frontend-Navigation.
+
+## Übersicht
+
+Die Navigation-API stellt die komplette Seitenstruktur des CMS als hierarchischen Baum oder flache Liste zur Verfügung.
+
+## Schnellstart
+
+### Backend-UI (Admin-Interface)
+
+Zugriff über: **Navigation API** im Seitenmenü (Permission: `crm-nav-api`)
+
+Die Backend-UI zeigt die **komplette Frontend-Navigation**:
+
+- 📊 Live-Statistiken (Gesamt, Aktiv, Programme, Länder)
+- 🌍 **Länder-Navigation** mit Children (gruppiert nach Haupt/Infos)
+- 🏠 **USEDOM Ferienwohnungen** mit allen FeWos
+- 📑 **Mehr-Menü Seiten** (Über uns, Reiseversicherung, Reiseführer, Reisemagazin, Reisenews)
+- 🔍 Volltext-Suche über Titel, Slug und URL
+- 🌳 Interaktiver Nested Tree mit Auf-/Zuklappen
+- 🎯 Filter (Mit ausgeblendeten/Nur sichtbare)
+- 📥 JSON-Export
+- 🔄 Cache-Verwaltung
+
+### API-Endpunkte
+
+```
+GET /api/navigation/tree - Kompletter Navigationsbaum
+GET /api/navigation/tree/active - Nur aktive Navigationspunkte
+GET /api/navigation/tree/{rootId} - Teilbaum ab einer bestimmten Page
+GET /api/navigation/flat - Flache Liste aller Pages
+GET /api/navigation/breadcrumb/{pageId} - Breadcrumb-Pfad
+```
+
+### Beispiel-Verwendung
+
+```javascript
+// Kompletten Navigationsbaum abrufen
+fetch("/api/navigation/tree")
+ .then((response) => response.json())
+ .then((data) => {
+ console.log(data.data); // Navigationsbaum
+ });
+```
+
+## Dateien
+
+- **README.md** - Diese Datei (Übersicht)
+- **BACKEND-UI.md** - Dokumentation der Backend-UI (Admin-Interface)
+- **navigation.md** - Dokumentation der Frontend-Routing-Logik (Symfony-basiert)
+- **navigation-api.md** - Vollständige API-Dokumentation mit Beispielen
+- **test-api.php** - CLI-Test-Script für alle API-Endpunkte
+- **test-api.html** - Browser-basierte interaktive Test-Oberfläche
+- **KernelControllerListener.php** - Referenz: Symfony KernelControllerListener
+- **PageRepository.php** - Referenz: Doctrine Page Repository
+
+## Implementierung
+
+Die API wurde im Laravel-Backend implementiert:
+
+### Backend-Komponenten
+
+**API-Layer:**
+
+1. **API Controller:** `app/Http/Controllers/API/NavigationController.php`
+
+ - REST API-Endpunkte
+ - JSON-Responses
+
+2. **Web Controller:** `app/Http/Controllers/NavigationTreeController.php`
+
+ - Backend-UI (Admin-Interface)
+ - Daten-Export und Cache-Management
+
+3. **Service:** `app/Services/NavigationTreeService.php`
+
+ - Business-Logik
+ - Rekursiver Baum-Aufbau
+ - Caching (60 Min.)
+ - Helper-Methoden (Breadcrumb, Node-Suche, etc.)
+
+4. **Model:** `app/Models/Page.php`
+
+ - Eloquent Model für die Page-Entität
+ - Relationships (parent, children, travel_program, etc.)
+
+5. **Views:** `resources/views/navigation/index.blade.php`
+
+ - Interaktive Baum-Visualisierung
+ - Suche und Filter
+ - Statistiken
+
+6. **Routes:**
+ - API: `routes/api.php`
+ - Web: `routes/web.php` (Permission: `crm-tp-na`)
+
+## Features
+
+### Navigationsbaum
+
+- ✅ Hierarchische Struktur mit unbegrenzter Tiefe
+- ✅ Alle Page-Eigenschaften enthalten (SEO, Template, Status, etc.)
+- ✅ Beziehungen zu TravelProgram, FewoLodging, Country
+- ✅ Filterung nach Status und Sichtbarkeit
+- ✅ Sortierung nach Order und Title
+
+### Zusätzliche Funktionen
+
+- ✅ Breadcrumb-Generierung
+- ✅ Teilbaum-Abfrage
+- ✅ Flache Listen-Ansicht
+- ✅ Node-Zählung
+- ✅ URL-Pfad-Generierung
+
+### Performance
+
+- Effiziente Queries mit Eager Loading
+- Rekursive Baum-Konstruktion
+- Unterstützung für Nested Set Tree (lft/rgt)
+- Caching-ready
+
+## Datenstruktur
+
+Jeder Navigationspunkt enthält:
+
+```json
+{
+ "id": 1,
+ "title": "Seitentitel",
+ "slug": "seitentitel",
+ "url": "/seitentitel",
+ "status": 1,
+ "show_in_navi": 1,
+ "order": 1,
+ "template": "default",
+ "lvl": 0,
+ "parent_id": null,
+ "is_travel_program": false,
+ "is_fewo_lodging": false,
+ "is_country_page": false,
+ "children": [],
+ "has_children": false
+}
+```
+
+Siehe `navigation-api.md` für eine vollständige Feldbeschreibung.
+
+## Routing-Logik (Frontend)
+
+Das Frontend verwendet einen `KernelControllerListener` (Symfony), der:
+
+1. **API-Lookup** prüft (externe Reiseführer)
+2. **Datenbank-Suche** durchführt (Page-Entität)
+3. **Redirects** verarbeitet (301-Weiterleitungen)
+4. **Controller zuweist** basierend auf Content-Type
+
+Siehe `navigation.md` für Details zur Routing-Logik.
+
+## Unterschiede Frontend/Backend
+
+| Aspekt | Frontend (Symfony) | Backend (Laravel) |
+| ---------- | ----------------------------- | -------------------------- |
+| Framework | Symfony 2.x | Laravel 5.x |
+| ORM | Doctrine | Eloquent |
+| Routing | KernelControllerListener | API Routes |
+| Tree-Logik | Nested Set + Parent Traversal | Parent-Child Relationships |
+| API | External (IQ API) | Internal (Laravel) |
+
+## Wartung
+
+### Neue Felder hinzufügen
+
+1. Füge das Feld in `NavigationTreeService::buildNodeData()` hinzu
+2. Aktualisiere die Dokumentation in `navigation-api.md`
+
+### Performance optimieren
+
+1. Füge Caching in `NavigationTreeService` hinzu
+2. Verwende Eager Loading für Relations
+3. Implementiere Pagination für große Bäume
+
+### Tests
+
+Empfohlene Tests:
+
+- Unit Tests für `NavigationTreeService`
+- API Tests für alle Endpunkte
+- Performance Tests für große Navigationsbäume
+
+## Support
+
+Bei Fragen zur API siehe:
+
+- `navigation-api.md` - Vollständige API-Dokumentation
+- `navigation.md` - Frontend Routing-Dokumentation
diff --git a/dev/frontend-navigation/header.html.twig b/dev/frontend-navigation/header.html.twig
new file mode 100644
index 0000000..c4c3675
--- /dev/null
+++ b/dev/frontend-navigation/header.html.twig
@@ -0,0 +1,325 @@
+
+
+ {% if content.info.office_important_note_active == 1 %}
+
+ {% endif %}
+
+
+
+
diff --git a/dev/frontend-navigation/navigation-api.md b/dev/frontend-navigation/navigation-api.md
new file mode 100644
index 0000000..8d08449
--- /dev/null
+++ b/dev/frontend-navigation/navigation-api.md
@@ -0,0 +1,414 @@
+# Navigation API Dokumentation
+
+Die Navigation-API stellt Endpunkte bereit, um die Frontend-Navigationsstruktur im Backend abzurufen.
+
+## Basis-URL
+
+Alle Endpunkte sind unter `/api/navigation/` verfügbar.
+
+## Endpunkte
+
+### 1. Kompletter Navigationsbaum
+
+**GET** `/api/navigation/tree`
+
+Gibt den kompletten hierarchischen Navigationsbaum mit allen Seiten zurück.
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "id": 1,
+ "title": "Startseite",
+ "title_short": null,
+ "before_title": null,
+ "slug": "startseite",
+ "real_url_path": "/",
+ "url": "/",
+ "status": 1,
+ "show_in_navi": 1,
+ "order": 1,
+ "template": "home",
+ "lvl": 0,
+ "parent_id": null,
+ "pagetitle": "Willkommen",
+ "description": "Startseite",
+ "keywords": "start, home",
+ "canonical_url": null,
+ "lft": 1,
+ "rgt": 10,
+ "tree_root": 1,
+ "box_body": null,
+ "box_image_url": null,
+ "box_star": null,
+ "box_discount": null,
+ "is_travel_program": false,
+ "is_fewo_lodging": false,
+ "is_country_page": false,
+ "cms_settings": null,
+ "children": [
+ {
+ "id": 2,
+ "title": "Über uns",
+ "slug": "ueber-uns",
+ "url": "/ueber-uns",
+ "children": [],
+ "has_children": false
+ }
+ ],
+ "has_children": true
+ }
+ ],
+ "meta": {
+ "total_nodes": 42,
+ "generated_at": "2024-01-15T10:30:00+00:00"
+ }
+}
+```
+
+---
+
+### 2. Nur aktive Navigationspunkte
+
+**GET** `/api/navigation/tree/active`
+
+Gibt nur aktive Seiten zurück (`status = 1` und `show_in_navi = 1`).
+
+**Response:** Gleiche Struktur wie oben, aber gefiltert.
+
+---
+
+### 3. Teilbaum ab einem bestimmten Knoten
+
+**GET** `/api/navigation/tree/{rootId}`
+
+Gibt einen Teilbaum zurück, beginnend mit der angegebenen Page-ID.
+
+**Parameter:**
+
+- `rootId` (integer): Die ID der Seite, ab der der Baum aufgebaut werden soll
+
+**Beispiel:** `/api/navigation/tree/5`
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "data": {
+ "id": 5,
+ "title": "Reisen",
+ "slug": "reisen",
+ "url": "/reisen",
+ "children": [
+ {
+ "id": 6,
+ "title": "Türkei",
+ "slug": "tuerkei",
+ "url": "/reisen/tuerkei",
+ "children": [],
+ "has_children": false
+ }
+ ],
+ "has_children": true
+ },
+ "meta": {
+ "total_nodes": 15,
+ "generated_at": "2024-01-15T10:30:00+00:00"
+ }
+}
+```
+
+---
+
+### 4. Flache Liste (ohne Hierarchie)
+
+**GET** `/api/navigation/flat`
+
+Gibt alle Navigationspunkte als flache Liste zurück (ohne parent-child Beziehung).
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "id": 1,
+ "title": "Startseite",
+ "slug": "startseite",
+ "url": "/",
+ "status": 1,
+ "show_in_navi": 1,
+ "order": 1,
+ "template": "home",
+ "lvl": 0,
+ "parent_id": null,
+ "is_travel_program": false,
+ "is_fewo_lodging": false,
+ "is_country_page": false
+ },
+ {
+ "id": 2,
+ "title": "Über uns",
+ "slug": "ueber-uns",
+ "url": "/ueber-uns",
+ "status": 1,
+ "show_in_navi": 1,
+ "order": 2,
+ "template": "default",
+ "lvl": 0,
+ "parent_id": null,
+ "is_travel_program": false,
+ "is_fewo_lodging": false,
+ "is_country_page": false
+ }
+ ],
+ "meta": {
+ "total_nodes": 42,
+ "generated_at": "2024-01-15T10:30:00+00:00"
+ }
+}
+```
+
+---
+
+### 5. Breadcrumb-Pfad
+
+**GET** `/api/navigation/breadcrumb/{pageId}`
+
+Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück (von der Wurzel bis zur Zielseite).
+
+**Parameter:**
+
+- `pageId` (integer): Die ID der Seite, für die der Breadcrumb erstellt werden soll
+
+**Beispiel:** `/api/navigation/breadcrumb/15`
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "id": 1,
+ "title": "Startseite",
+ "title_short": null,
+ "url": "/",
+ "slug": "startseite"
+ },
+ {
+ "id": 5,
+ "title": "Reisen",
+ "title_short": null,
+ "url": "/reisen",
+ "slug": "reisen"
+ },
+ {
+ "id": 10,
+ "title": "Türkei",
+ "title_short": null,
+ "url": "/reisen/tuerkei",
+ "slug": "tuerkei"
+ },
+ {
+ "id": 15,
+ "title": "Istanbul",
+ "title_short": null,
+ "url": "/reisen/tuerkei/istanbul",
+ "slug": "istanbul"
+ }
+ ],
+ "meta": {
+ "depth": 4,
+ "generated_at": "2024-01-15T10:30:00+00:00"
+ }
+}
+```
+
+---
+
+## Felder-Beschreibung
+
+Jeder Navigationspunkt enthält folgende Informationen:
+
+### Basis-Informationen
+
+- `id`: Eindeutige ID der Seite
+- `title`: Vollständiger Titel
+- `title_short`: Kurzer Titel (optional)
+- `before_title`: Text vor dem Titel (optional)
+- `slug`: URL-freundlicher Name
+- `real_url_path`: Der definierte URL-Pfad aus der Datenbank
+- `url`: Der vollständige URL-Pfad (entweder `real_url_path` oder automatisch generiert)
+
+### Navigationsinformationen
+
+- `status`: Status der Seite (0 = inaktiv, 1 = aktiv)
+- `show_in_navi`: Soll in Navigation angezeigt werden (0 = nein, 1 = ja)
+- `order`: Sortierreihenfolge
+- `lvl`: Hierarchie-Ebene (0 = Root)
+- `parent_id`: ID der Eltern-Seite (null = Root)
+
+### SEO-Informationen
+
+- `pagetitle`: SEO-Titel
+- `description`: Meta-Beschreibung
+- `keywords`: Meta-Keywords
+- `canonical_url`: Kanonische URL
+
+### Template & Layout
+
+- `template`: Template-Name (z.B. "home", "overview", "default")
+- `box_body`: Inhalt für Box-Darstellung
+- `box_image_url`: Bild-URL für Box
+- `box_star`: Sternebewertung
+- `box_discount`: Rabatt-Information
+
+### Tree-Struktur
+
+- `lft`, `rgt`: Nested Set Tree Werte
+- `tree_root`: ID der Baumwurzel
+
+### Content-Type Flags
+
+- `is_travel_program`: Ist ein Reiseprogramm
+- `is_fewo_lodging`: Ist eine Ferienwohnung
+- `is_country_page`: Ist eine Länderseite
+
+### Beziehungen (wenn vorhanden)
+
+**Travel Program:**
+
+```json
+"travel_program": {
+ "id": 123,
+ "title": "7 Tage Istanbul",
+ "subtitle": "Die schönsten Orte",
+ "status": 1,
+ "position": 1,
+ "duration": 7,
+ "price_from": 899.00
+}
+```
+
+**Fewo Lodging:**
+
+```json
+"fewo_lodging": {
+ "id": 45,
+ "name": "Villa am Meer",
+ "status": 1
+}
+```
+
+**Country:**
+
+```json
+"country": {
+ "id": 10,
+ "name": "Türkei",
+ "title": "Türkei Reisen",
+ "slug": "tuerkei"
+}
+```
+
+### Hierarchie-Informationen
+
+- `children`: Array von Child-Knoten (gleiche Struktur)
+- `has_children`: Boolean, ob Kinder vorhanden sind
+
+---
+
+## Fehlerbehandlung
+
+Bei Fehlern wird ein JSON-Response mit folgendem Format zurückgegeben:
+
+```json
+{
+ "success": false,
+ "error": "Fehlerbeschreibung"
+}
+```
+
+**HTTP Status Codes:**
+
+- `200`: Erfolgreiche Anfrage
+- `404`: Seite/Ressource nicht gefunden
+- `500`: Serverfehler
+
+---
+
+## Verwendungsbeispiele
+
+### JavaScript/Fetch
+
+```javascript
+// Kompletten Navigationsbaum abrufen
+fetch("/api/navigation/tree")
+ .then((response) => response.json())
+ .then((data) => {
+ console.log("Navigation Tree:", data.data);
+ console.log("Total Nodes:", data.meta.total_nodes);
+ });
+
+// Breadcrumb für eine Seite abrufen
+fetch("/api/navigation/breadcrumb/15")
+ .then((response) => response.json())
+ .then((data) => {
+ console.log("Breadcrumb:", data.data);
+ });
+```
+
+### PHP/Laravel
+
+```php
+use Illuminate\Support\Facades\Http;
+
+// Kompletten Navigationsbaum abrufen
+$response = Http::get('http://yourdomain.com/api/navigation/tree');
+$navigationTree = $response->json()['data'];
+
+// Teilbaum abrufen
+$response = Http::get('http://yourdomain.com/api/navigation/tree/5');
+$subTree = $response->json()['data'];
+```
+
+### cURL
+
+```bash
+# Kompletten Baum
+curl -X GET http://yourdomain.com/api/navigation/tree
+
+# Nur aktive Navigationspunkte
+curl -X GET http://yourdomain.com/api/navigation/tree/active
+
+# Breadcrumb
+curl -X GET http://yourdomain.com/api/navigation/breadcrumb/15
+```
+
+---
+
+## Performance-Hinweise
+
+- Der komplette Navigationsbaum kann bei großen Websites viele Knoten enthalten
+- Verwenden Sie `/api/navigation/tree/active` für Frontend-Navigationen
+- Verwenden Sie `/api/navigation/tree/{rootId}` um nur relevante Teilbäume zu laden
+- Erwägen Sie Caching für häufig abgerufene Navigationsstrukturen
+
+---
+
+## Implementierung
+
+Die Navigation-API basiert auf folgenden Komponenten:
+
+1. **Controller:** `App\Http\Controllers\API\NavigationController`
+2. **Service:** `App\Services\NavigationTreeService`
+3. **Model:** `App\Models\Page`
+4. **Routen:** Definiert in `routes/api.php`
+
+Der Service verwendet rekursive Methoden, um den hierarchischen Baum aufzubauen und kann einfach erweitert werden.
diff --git a/dev/frontend-navigation/navigation.md b/dev/frontend-navigation/navigation.md
new file mode 100644
index 0000000..f68dac7
--- /dev/null
+++ b/dev/frontend-navigation/navigation.md
@@ -0,0 +1,47 @@
+# Routing & Navigations-Logik (KernelControllerListener)
+
+Die Klasse `src/AppBundle/Listener/KernelControllerListener.php` implementiert die zentrale Routing-Logik für das CMS. Sie greift ein, wenn der Symfony-Router den Request an `AppBundle\Controller\DefaultController::defaultAction` leitet.
+
+## Funktionsweise
+
+Der Listener analysiert den Request-Pfad (`pathInfo`) und entscheidet, welcher Controller tatsächlich ausgeführt werden soll.
+
+### 1. API-Lookup (Priorität 1)
+
+Zuerst wird geprüft, ob der Pfad über einen externen API-Call (`Util::loadFromApi('cms/search', ...)`) aufgelöst werden kann.
+
+- **Ziel-Controller:** `AppBundle:Cms:iqTravelGuide`
+- **Template:** `TravelGuide`
+
+### 2. Datenbank-Suche (Page Entity)
+
+Wenn die API nichts zurückgibt, wird in der lokalen Datenbank (`AppBundle:Page`) gesucht.
+
+- **Methode A (Direkt):** Suche nach Übereinstimmung im Feld `realUrlPath`.
+- **Methode B (Tree-Traversierung):** Der Pfad wird anhand der Slashes (`/`) zerlegt. Es wird versucht, den Pfad hierarchisch über `slug` und die Eltern-Kind-Beziehung (`parent`) aufzulösen. Die anfällige Nested-Set-Logik (`lft`/`rgt`, `lvl`) wird hier bewusst umgangen, um 404-Fehler bei inkonsistenten Trees zu vermeiden.
+
+### 3. Redirects
+
+- **Hardcoded:** Es gibt eine Liste fester 301-Weiterleitungen (z.B. alte Reiseführer-URLs).
+- **Datenbank:** Tabelle `AppBundle:Redirect`. Wenn keine Page gefunden wird, wird hier geprüft.
+
+### 4. Controller-Zuweisung
+
+Sobald eine `Page`-Entität (`$node`) gefunden wurde, wird der Controller basierend auf dem Inhaltstyp bestimmt:
+
+| Inhaltstyp | URL-Suffix | Controller | Action |
+| ----------------- | ------------------------- | ----------------------- | ------------------------- |
+| **TravelProgram** | `/buchen`, `/berechne...` | `AppBundle:Booking` | `index` |
+| **TravelProgram** | `/pdf` | `AppBundle:Cms` | `pdf` |
+| **TravelProgram** | _(keins)_ | `AppBundle:Cms` | `travelProgram` |
+| **FewoLodging** | `/buchen` | `AppBundle:FewoBooking` | `index` |
+| **FewoLodging** | _(keins)_ | `AppBundle:Cms` | `fewoLodging` |
+| **Standard Page** | _(keins)_ | `AppBundle:Cms` | _Dyn. nach Template-Name_ |
+
+**Fallback für Standard Pages:**
+Wenn die Page ein Template (z.B. "About") definiert hat, versucht das System `AppBundle:Cms:About` aufzurufen. Existiert diese Action nicht, wird `AppBundle:Cms:Default` verwendet und der Template-Name als Parameter übergeben.
+
+### 5. Fehlerbehandlung
+
+- **404:** Wenn Pfad nicht gefunden und nicht in der Whitelist (`buchen`, `pdf` etc.) -> `HttpException(404)`.
+- **Inaktiv:** Wenn Page-Status `0` ist -> `NotFoundHttpException`.
diff --git a/dev/frontend-navigation/test-api.html b/dev/frontend-navigation/test-api.html
new file mode 100644
index 0000000..c5b4088
--- /dev/null
+++ b/dev/frontend-navigation/test-api.html
@@ -0,0 +1,496 @@
+
+
+
+
+
+ Navigation API Tester
+
+
+
+
+
🗺️ Navigation API Tester
+
+
+
Dieses Tool testet alle verfügbaren Endpunkte der Navigation API und zeigt die Ergebnisse in einer strukturierten Form.
+
+
+
+ Basis-URL:
+
+
+
+
Alle Endpunkte testen
+
+
+
+
+
/tree
+
+ Gibt den kompletten hierarchischen Navigationsbaum mit allen Seiten zurück.
+
+
Testen
+
+
+
+
+
+
+
/tree/active
+
+ Gibt nur aktive Seiten zurück (status = 1 und show_in_navi = 1).
+
+
Testen
+
+
+
+
+
+
+
/tree/{rootId}
+
+ Gibt einen Teilbaum zurück, beginnend mit der angegebenen Page-ID.
+
+
+
Testen
+
+
+
+
+
+
+
/flat
+
+ Gibt alle Navigationspunkte als flache Liste zurück (ohne parent-child Beziehung).
+
+
Testen
+
+
+
+
+
+
+
/breadcrumb/{pageId}
+
+ Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück.
+
+
+
Testen
+
+
+
+
+
+
+
/cache/clear
+
+ Löscht den kompletten Navigation-Cache.
+
+
Testen
+
+
+
+
+
+
Test-Zusammenfassung
+
+
+
+
+
+
+
+
diff --git a/dev/frontend-navigation/test-api.php b/dev/frontend-navigation/test-api.php
new file mode 100644
index 0000000..d5560e5
--- /dev/null
+++ b/dev/frontend-navigation/test-api.php
@@ -0,0 +1,249 @@
+ false,
+ 'error' => $error,
+ 'duration' => $duration
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'http_code' => $httpCode,
+ 'data' => json_decode($response, true),
+ 'duration' => $duration
+ ];
+}
+
+/**
+ * Ausgabe-Helfer
+ */
+function printHeader($text)
+{
+ echo "\n" . COLOR_BLUE . str_repeat('=', 80) . COLOR_RESET . "\n";
+ echo COLOR_BLUE . $text . COLOR_RESET . "\n";
+ echo COLOR_BLUE . str_repeat('=', 80) . COLOR_RESET . "\n\n";
+}
+
+function printSuccess($text)
+{
+ echo COLOR_GREEN . "✓ " . $text . COLOR_RESET . "\n";
+}
+
+function printError($text)
+{
+ echo COLOR_RED . "✗ " . $text . COLOR_RESET . "\n";
+}
+
+function printInfo($text)
+{
+ echo COLOR_YELLOW . "ℹ " . $text . COLOR_RESET . "\n";
+}
+
+function printJson($data, $maxDepth = 3, $currentDepth = 0)
+{
+ if ($currentDepth >= $maxDepth) {
+ echo "[... gekürzt nach Tiefe $maxDepth]\n";
+ return;
+ }
+
+ echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
+}
+
+/**
+ * Testet einen einzelnen Endpunkt
+ */
+function testEndpoint($name, $endpoint, $method = 'GET', $showFullData = false)
+{
+ printInfo("Teste: $name");
+ echo " Endpunkt: $endpoint\n";
+ echo " Methode: $method\n";
+
+ $result = apiRequest($endpoint, $method);
+
+ if (!$result['success']) {
+ printError("Request fehlgeschlagen: " . $result['error']);
+ return false;
+ }
+
+ $httpCode = $result['http_code'];
+ $data = $result['data'];
+ $duration = $result['duration'];
+
+ echo " HTTP Status: $httpCode\n";
+ echo " Dauer: {$duration}ms\n";
+
+ if ($httpCode === 200) {
+ printSuccess("Erfolgreich");
+
+ if (isset($data['success']) && $data['success']) {
+ if (isset($data['meta'])) {
+ echo "\n Metadaten:\n";
+ foreach ($data['meta'] as $key => $value) {
+ echo " $key: $value\n";
+ }
+ }
+
+ if ($showFullData && isset($data['data'])) {
+ echo "\n Daten (gekürzt):\n";
+ $preview = $data['data'];
+ if (is_array($preview) && count($preview) > 2) {
+ $preview = array_slice($preview, 0, 2);
+ echo " " . json_encode($preview, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
+ echo " ... und " . (count($data['data']) - 2) . " weitere Einträge\n";
+ } else {
+ printJson($preview, 2);
+ }
+ }
+ }
+ } else {
+ printError("HTTP-Fehler $httpCode");
+ if (isset($data['error'])) {
+ echo " Fehlermeldung: " . $data['error'] . "\n";
+ }
+ }
+
+ echo "\n";
+ return $httpCode === 200;
+}
+
+// Hauptprogramm
+printHeader("Navigation API Test Suite");
+
+echo "Base URL: " . BASE_URL . "\n";
+echo "API Prefix: " . API_PREFIX . "\n\n";
+
+$results = [];
+
+// Test 1: Kompletter Navigationsbaum
+printHeader("Test 1: Kompletter Navigationsbaum");
+$results['tree'] = testEndpoint(
+ "Kompletter Navigationsbaum",
+ "/tree",
+ "GET",
+ true
+);
+
+// Test 2: Nur aktive Navigationspunkte
+printHeader("Test 2: Nur aktive Navigationspunkte");
+$results['tree_active'] = testEndpoint(
+ "Aktive Navigationspunkte",
+ "/tree/active",
+ "GET",
+ true
+);
+
+// Test 3: Teilbaum (ersetze 1 mit einer existierenden Page-ID)
+printHeader("Test 3: Teilbaum ab Root-ID 1");
+$results['subtree'] = testEndpoint(
+ "Teilbaum ab Root-ID 1",
+ "/tree/1",
+ "GET",
+ true
+);
+
+// Test 4: Flache Liste
+printHeader("Test 4: Flache Liste aller Navigationspunkte");
+$results['flat'] = testEndpoint(
+ "Flache Liste",
+ "/flat",
+ "GET",
+ true
+);
+
+// Test 5: Breadcrumb (ersetze 1 mit einer existierenden Page-ID)
+printHeader("Test 5: Breadcrumb für Page-ID 1");
+$results['breadcrumb'] = testEndpoint(
+ "Breadcrumb für Page-ID 1",
+ "/breadcrumb/1",
+ "GET",
+ true
+);
+
+// Test 6: Cache leeren
+printHeader("Test 6: Cache leeren");
+$results['cache_clear'] = testEndpoint(
+ "Cache leeren",
+ "/cache/clear",
+ "POST",
+ false
+);
+
+// Zusammenfassung
+printHeader("Test-Zusammenfassung");
+
+$total = count($results);
+$passed = count(array_filter($results));
+$failed = $total - $passed;
+
+echo "Gesamt: $total Tests\n";
+printSuccess("Erfolgreich: $passed");
+if ($failed > 0) {
+ printError("Fehlgeschlagen: $failed");
+}
+
+$percentage = round(($passed / $total) * 100, 1);
+echo "\nErfolgsrate: $percentage%\n";
+
+if ($percentage === 100.0) {
+ printSuccess("\nAlle Tests bestanden! 🎉");
+} else {
+ printError("\nEinige Tests sind fehlgeschlagen. Bitte überprüfen Sie die Ausgabe oben.");
+}
+
+echo "\n";
+
+// Weitere Hinweise
+printHeader("Weitere Informationen");
+echo "Vollständige API-Dokumentation: dev/frontend-navigation/navigation-api.md\n";
+echo "README: dev/frontend-navigation/README.md\n\n";
+echo "Hinweis: Wenn Tests fehlschlagen, überprüfen Sie:\n";
+echo " 1. Ist die BASE_URL korrekt?\n";
+echo " 2. Läuft der Server?\n";
+echo " 3. Sind Page-Einträge in der Datenbank vorhanden?\n";
+echo " 4. Sind die Routen korrekt registriert?\n\n";
diff --git a/docker-compose.yml b/docker-compose.yml
index 77170e4..a51906b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,22 +1,37 @@
services:
laravel.test:
build:
- context: ./vendor/laravel/sail/runtimes/8.2
+ context: ./vendor/laravel/sail/runtimes/8.3
dockerfile: Dockerfile
args:
- WWWGROUP: '${WWWGROUP}'
- image: sail-8.2/app
+ WWWGROUP: '${WWWGROUP:-20}'
+ WWWUSER: '${WWWUSER:-501}'
+ image: sail-8.3/app
extra_hosts:
- 'host.docker.internal:host-gateway'
- ports:
- #- '${APP_PORT:-80}:80'
- - '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
+
environment:
- WWWUSER: '${WWWUSER}'
+ WWWUSER: '${WWWUSER:-501}'
+ WWWGROUP: '${WWWGROUP:-20}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
+ DB_CONNECTION: mysql
+ DB_HOST: mysql
+ DB_PORT: 3306
+ DB_DATABASE: stern_crm
+ DB_USERNAME: sail
+ DB_PASSWORD: password
+ DB_CONNECTION_STERN: mysql
+ DB_HOST_STERN: mysql-stern
+ DB_PORT_STERN: 3306
+ DB_DATABASE_STERN: stern_db
+ DB_USERNAME_STERN: sail
+ DB_PASSWORD_STERN: password
+ MAIL_HOST: mailpit
+ MAIL_PORT: 1025
+ REDIS_HOST: redis
volumes:
- '.:/var/www/html'
networks:
@@ -26,15 +41,31 @@ services:
- mysql
- mysql-stern
- redis
+ - mailpit
labels:
- "traefik.enable=true"
# Hauptdomain
- "traefik.http.routers.meinsterntours.rule=Host(`mein.sterntours.test`)"
- "traefik.http.routers.meinsterntours.entrypoints=websecure"
- "traefik.http.routers.meinsterntours.tls=true"
- - "traefik.http.routers.meinsterntours.service=meinsterntours-service"
+ - "traefik.http.routers.meinsterntours.service=sterntours-service"
+
+ # Hauptdomain
+ - "traefik.http.routers.sterntours.rule=Host(`sterntours.test`)"
+ - "traefik.http.routers.sterntours.entrypoints=websecure"
+ - "traefik.http.routers.sterntours.tls=true"
+ - "traefik.http.routers.sterntours.service=sterntours-service"
+
+ # Asset Domain für Vite-Server (Port 5173 - Haupt/Portal)
+ - "traefik.http.routers.assets-sterntours.rule=Host(`assets.sterntours.test`)"
+ - "traefik.http.routers.assets-sterntours.entrypoints=websecure"
+ - "traefik.http.routers.assets-sterntours.tls=true"
+ - "traefik.http.routers.assets-sterntours.service=assets-sterntours-service"
+
# Service Definition - NUR EINMAL!
- - "traefik.http.services.meinsterntours-service.loadbalancer.server.port=80"
+ - "traefik.http.services.sterntours-service.loadbalancer.server.port=80"
+ - "traefik.http.services.assets-sterntours-service.loadbalancer.server.port=5173"
+ - "traefik.http.services.assets-sterntours-service.loadbalancer.server.scheme=http"
- "traefik.docker.network=proxy"
mysql:
image: 'mysql/mysql-server:8.0'
@@ -99,6 +130,21 @@ services:
- ping
retries: 3
timeout: 5s
+ mailpit:
+ image: 'axllent/mailpit:latest'
+ ports:
+ - '${FORWARD_MAILPIT_PORT:-1030}:1025'
+ - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8030}:8025'
+ networks:
+ - sail
+ - proxy
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.sterntours-mail.rule=Host(`sterntours-mail.test`)"
+ - "traefik.http.routers.sterntours-mail.entrypoints=websecure"
+ - "traefik.http.routers.sterntours-mail.tls=true"
+ - "traefik.http.services.sterntours-mail.loadbalancer.server.port=8025"
+ - "traefik.docker.network=proxy"
networks:
sail:
driver: bridge
diff --git a/docs/NEWSLETTER.md b/docs/NEWSLETTER.md
new file mode 100644
index 0000000..6ea548d
--- /dev/null
+++ b/docs/NEWSLETTER.md
@@ -0,0 +1,324 @@
+# Newsletter-Modul Dokumentation
+
+## Übersicht
+
+Das Newsletter-Modul ermöglicht die zentrale Verwaltung von Newsletter-Kontakten aus beiden Buchungssystemen (Kulturreisen und Ferienwohnungen). Es synchronisiert automatisch Kundendaten aus Buchungen und bietet Export-Funktionen für Newsletter-Kampagnen.
+
+## Features
+
+- ✅ Zentrale Kontaktverwaltung für beide Newsletter-Gruppen
+- ✅ Automatische Synchronisation aus Buchungssystemen
+- ✅ Duplikat-Erkennung über E-Mail-Adressen
+- ✅ Tracking von Buchungsstatistiken
+- ✅ Status-Verwaltung (Aktiv, Inaktiv, Abgemeldet, Bounced)
+- ✅ Herkunfts-Tracking (Buchung, Newsletter-Anmeldung, etc.)
+- ✅ Export-Funktion (CSV)
+- ✅ Aktivitäts-Log für jeden Kontakt
+- ✅ Filter- und Suchfunktionen
+
+## Installation
+
+### 1. Migrationen ausführen
+
+```bash
+./vendor/bin/sail artisan migrate
+```
+
+Dies erstellt folgende Tabellen:
+- `newsletter_contacts` - Haupt-Kontakte-Tabelle
+- `newsletter_logs` - Aktivitäts-Log
+
+### 2. Berechtigung hinzufügen
+
+In der Datenbank muss die Berechtigung `cms-newsletter` für Benutzer aktiviert werden, die Zugriff auf das Newsletter-Modul haben sollen.
+
+```sql
+-- Beispiel: Berechtigung für einen User aktivieren
+-- Dies muss über das bestehende Berechtigungssystem erfolgen
+```
+
+### 3. Erste Synchronisation
+
+Nach der Installation sollte eine vollständige Synchronisation durchgeführt werden:
+
+```bash
+# Alle Buchungen synchronisieren
+./vendor/bin/sail artisan newsletter:sync-kulturreisen --force
+./vendor/bin/sail artisan newsletter:sync-ferienwohnungen --force
+```
+
+## Verwendung
+
+### Admin-Panel
+
+Das Newsletter-Modul ist im Admin-Panel unter **CMS > Inhalte > Newsletter** erreichbar.
+
+#### Hauptfunktionen:
+
+1. **Kontaktliste**
+ - Übersicht aller Newsletter-Kontakte
+ - Filter nach Gruppe, Status, Herkunft
+ - Suchfunktion nach E-Mail und Name
+ - DataTables mit Sortierung und Pagination
+
+2. **Kontakt-Detail**
+ - Vollständige Kontaktinformationen
+ - Buchungsstatistiken
+ - Aktivitäts-Log
+ - Verlinkung zu Original-Kundendaten
+
+3. **Kontakt bearbeiten**
+ - Manuelles Erstellen von Kontakten
+ - Bearbeiten von Kontaktdaten
+ - Status-Änderung
+ - Gruppen-Zuweisung
+
+4. **Synchronisation**
+ - Manuelle Synchronisation über UI
+ - Incremental Sync (letzte 30 Tage)
+ - Full Sync (alle Buchungen)
+
+5. **Export**
+ - Export nach Gruppe
+ - Export nach Status
+ - CSV-Format
+ - Vorkonfigurierte Export-Optionen
+
+### Artisan-Befehle
+
+#### Synchronisation Kulturreisen
+
+```bash
+# Incremental Sync (letzte 30 Tage)
+./vendor/bin/sail artisan newsletter:sync-kulturreisen
+
+# Full Sync (alle Buchungen)
+./vendor/bin/sail artisan newsletter:sync-kulturreisen --force
+```
+
+Dieser Befehl:
+- Liest alle Buchungen aus der `booking` Tabelle
+- Verknüpft mit `customer` Daten
+- Erstellt oder aktualisiert Newsletter-Kontakte
+- Zählt Buchungen pro Kunde
+- Trackt letzte Buchung
+
+#### Synchronisation Ferienwohnungen
+
+```bash
+# Incremental Sync (letzte 30 Tage)
+./vendor/bin/sail artisan newsletter:sync-ferienwohnungen
+
+# Full Sync (alle Buchungen)
+./vendor/bin/sail artisan newsletter:sync-ferienwohnungen --force
+```
+
+Dieser Befehl:
+- Liest Buchungen aus `travel_user_booking_fewos` mit `invoice_number`
+- Nur Buchungen mit reiner Nummer (keine Storno etc.)
+- Verknüpft mit `travel_users` Daten
+- Erstellt oder aktualisiert Newsletter-Kontakte
+- Zählt Buchungen pro Kunde
+
+### Automatische Synchronisation
+
+Für regelmäßige Synchronisation empfiehlt sich ein Cron-Job:
+
+```bash
+# In der crontab
+# Täglich um 2 Uhr morgens
+0 2 * * * cd /var/www/html && ./vendor/bin/sail artisan newsletter:sync-kulturreisen
+0 2 * * * cd /var/www/html && ./vendor/bin/sail artisan newsletter:sync-ferienwohnungen
+```
+
+Oder im Laravel Scheduler (`app/Console/Kernel.php`):
+
+```php
+protected function schedule(Schedule $schedule)
+{
+ // Täglich um 2 Uhr
+ $schedule->command('newsletter:sync-kulturreisen')->dailyAt('02:00');
+ $schedule->command('newsletter:sync-ferienwohnungen')->dailyAt('02:15');
+}
+```
+
+## Datenmodell
+
+### Newsletter Contact
+
+| Feld | Typ | Beschreibung |
+|------|-----|--------------|
+| `id` | bigint | Primary Key |
+| `email` | string | E-Mail-Adresse (unique) |
+| `firstname` | string | Vorname |
+| `lastname` | string | Nachname |
+| `group_kulturreisen` | boolean | Zugehörigkeit zur Gruppe Kulturreisen |
+| `group_ferienwohnungen` | boolean | Zugehörigkeit zur Gruppe Ferienwohnungen |
+| `source` | enum | Herkunft des Kontakts |
+| `status` | enum | Status (active, inactive, unsubscribed, bounced) |
+| `subscribed_at` | timestamp | Zeitpunkt der Anmeldung |
+| `unsubscribed_at` | timestamp | Zeitpunkt der Abmeldung |
+| `last_booking_at` | timestamp | Zeitpunkt der letzten Buchung |
+| `total_bookings_kulturreisen` | int | Anzahl Buchungen Kulturreisen |
+| `total_bookings_ferienwohnungen` | int | Anzahl Buchungen Ferienwohnungen |
+| `customer_id` | int | Referenz zu `customer` (Kulturreisen) |
+| `travel_user_id` | int | Referenz zu `travel_users` (Ferienwohnungen) |
+| `last_synced_at` | timestamp | Letzte Synchronisation |
+| `sync_hash` | string | Hash für Duplikat-Erkennung |
+| `notes` | text | Notizen |
+
+### Newsletter Log
+
+| Feld | Typ | Beschreibung |
+|------|-----|--------------|
+| `id` | bigint | Primary Key |
+| `newsletter_contact_id` | bigint | Foreign Key zu newsletter_contacts |
+| `action` | enum | Aktion (subscribed, unsubscribed, etc.) |
+| `description` | string | Beschreibung der Aktion |
+| `metadata` | json | Zusätzliche Daten |
+| `user_id` | int | Admin-User der die Aktion ausgeführt hat |
+
+## Status-Typen
+
+- **active**: Aktiver Newsletter-Empfänger
+- **inactive**: Inaktiv (z.B. temporär deaktiviert)
+- **unsubscribed**: Abgemeldet vom Newsletter
+- **bounced**: E-Mail nicht zustellbar (Bounced)
+
+## Herkunfts-Typen
+
+- **booking_kulturreisen**: Aus Kulturreisen-Buchung
+- **booking_ferienwohnungen**: Aus Ferienwohnungs-Buchung
+- **newsletter_signup**: Newsletter-Anmeldung über Formular
+- **manual**: Manuell erstellt
+- **import**: Über Import hinzugefügt
+
+## Export-Formate
+
+### CSV-Export
+
+Der CSV-Export enthält folgende Spalten:
+
+1. ID
+2. E-Mail
+3. Vorname
+4. Nachname
+5. Gruppe Kulturreisen (Ja/Nein)
+6. Gruppe Ferienwohnungen (Ja/Nein)
+7. Status
+8. Herkunft
+9. Buchungen Kulturreisen
+10. Buchungen Ferienwohnungen
+11. Letzte Buchung
+12. Angemeldet am
+13. Abgemeldet am
+14. Erstellt am
+
+## Best Practices
+
+### Duplikat-Vermeidung
+
+Das System verwendet einen Hash basierend auf E-Mail und Quelle zur Duplikat-Erkennung. Kontakte werden automatisch zusammengeführt, wenn:
+- Dieselbe E-Mail-Adresse in beiden Systemen vorkommt
+- Der neuere Datensatz wird bevorzugt
+- Gruppenzugehörigkeiten werden kombiniert
+
+### Datenschutz
+
+- Soft-Delete: Gelöschte Kontakte werden nicht permanent entfernt
+- Log-Tracking: Alle Änderungen werden protokolliert
+- Abmelde-Funktion: Kontakte können sich jederzeit abmelden
+- Export-Kontrolle: Nur autorisierte Benutzer können exportieren
+
+### Performance
+
+- Incremental Sync: Standardmäßig werden nur die letzten 30 Tage synchronisiert
+- Indexierung: E-Mail, Status und Gruppen sind indexiert
+- DataTables: Server-Side Processing für große Datenmengen
+- Batch-Processing: Synchronisation verarbeitet Datensätze effizient
+
+## Troubleshooting
+
+### Problem: Synchronisation schlägt fehl
+
+**Lösung:**
+1. Prüfe Datenbankverbindungen (beide Datenbanken müssen erreichbar sein)
+2. Prüfe Logs: `storage/logs/laravel.log`
+3. Führe Sync mit `--force` aus für vollständige Synchronisation
+
+### Problem: Duplikate in der Liste
+
+**Lösung:**
+1. Prüfe `sync_hash` Spalte
+2. Führe manuelle Bereinigung durch
+3. Kontakte können manuell zusammengeführt werden
+
+### Problem: Export enthält keine Daten
+
+**Lösung:**
+1. Prüfe Filter-Einstellungen
+2. Prüfe Status der Kontakte
+3. Prüfe Berechtigungen
+
+## API-Endpunkte
+
+Alle Routen sind unter dem Middleware-Schutz `['admin', '2fa', 'auth.permission:cms-newsletter']`.
+
+| Methode | Route | Beschreibung |
+|---------|-------|--------------|
+| GET | `/newsletter` | Liste aller Kontakte |
+| GET | `/newsletter/datatable` | DataTables AJAX |
+| GET | `/newsletter/{id}` | Detail eines Kontakts |
+| GET | `/newsletter/{id}/edit` | Bearbeitungsformular |
+| POST | `/newsletter/{id}/store` | Speichern |
+| DELETE | `/newsletter/{id}` | Löschen (soft delete) |
+| POST | `/newsletter/{id}/unsubscribe` | Abmelden |
+| POST | `/newsletter/{id}/resubscribe` | Wieder aktivieren |
+| POST | `/newsletter/sync` | Synchronisation starten |
+| GET | `/newsletter/export` | Export |
+
+## Erweiterungen
+
+### Integration mit Newsletter-Diensten
+
+Das Modul kann einfach mit externen Newsletter-Diensten (Mailchimp, SendGrid, etc.) integriert werden:
+
+```php
+// Beispiel: Export für Mailchimp
+$contacts = NewsletterContact::active()->kulturreisen()->get();
+foreach ($contacts as $contact) {
+ // Mailchimp API Call
+}
+```
+
+### Webhook für Abmeldungen
+
+Eine öffentliche Abmelde-Route kann hinzugefügt werden:
+
+```php
+// routes/web.php
+Route::get('/newsletter/unsubscribe/{token}', 'NewsletterPublicController@unsubscribe');
+```
+
+### Double-Opt-In
+
+Für Newsletter-Anmeldungen kann ein Double-Opt-In Prozess implementiert werden.
+
+## Support
+
+Bei Fragen oder Problemen:
+1. Prüfe diese Dokumentation
+2. Prüfe Laravel Logs
+3. Prüfe Artisan Command Output
+4. Kontaktiere das Entwickler-Team
+
+## Changelog
+
+### Version 1.0.0 (2025-11-07)
+- ✅ Initiales Release
+- ✅ Kontaktverwaltung
+- ✅ Synchronisation Kulturreisen
+- ✅ Synchronisation Ferienwohnungen
+- ✅ Export-Funktion
+- ✅ Admin-Panel Integration
+
diff --git a/init.sh b/init.sh
new file mode 100644
index 0000000..e03efe0
--- /dev/null
+++ b/init.sh
@@ -0,0 +1,151 @@
+#!/bin/bash
+
+# Sterntours Laravel Projekt - Initialisierungsskript
+# Dieses Script führt alle notwendigen Schritte zur Initialisierung des Projekts aus
+
+set -e # Beendet das Script bei Fehlern
+
+echo "================================================"
+echo "Sterntours Laravel Projekt - Initialisierung"
+echo "================================================"
+echo ""
+
+# Farben für Ausgabe
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+# Funktion für Erfolgsmeldungen
+success() {
+ echo -e "${GREEN}✓ $1${NC}"
+}
+
+# Funktion für Info-Meldungen
+info() {
+ echo -e "${YELLOW}➜ $1${NC}"
+}
+
+# Funktion für Fehler
+error() {
+ echo -e "${RED}✗ $1${NC}"
+}
+
+# 1. Prüfe ob Docker läuft
+info "Prüfe Docker Installation..."
+if ! command -v docker &> /dev/null; then
+ error "Docker ist nicht installiert!"
+ exit 1
+fi
+
+if ! docker info &> /dev/null; then
+ error "Docker läuft nicht! Bitte starte Docker."
+ exit 1
+fi
+success "Docker ist bereit"
+
+# 2. Prüfe ob Composer installiert ist
+info "Prüfe Composer Installation..."
+if ! command -v composer &> /dev/null; then
+ error "Composer ist nicht installiert!"
+ exit 1
+fi
+success "Composer ist installiert"
+
+# 3. .env Datei erstellen falls nicht vorhanden
+info "Prüfe .env Datei..."
+if [ ! -f .env ]; then
+ if [ -f .env.example ]; then
+ cp .env.example .env
+ success ".env Datei erstellt"
+ else
+ error ".env.example nicht gefunden!"
+ exit 1
+ fi
+else
+ success ".env Datei existiert bereits"
+fi
+
+# 4. Composer Dependencies installieren
+info "Installiere Composer Dependencies..."
+composer install --no-interaction --prefer-dist --optimize-autoloader
+success "Composer Dependencies installiert"
+
+# 5. NPM Dependencies installieren
+info "Prüfe Node.js Installation..."
+if command -v npm &> /dev/null; then
+ info "Installiere NPM Dependencies..."
+ npm install
+ success "NPM Dependencies installiert"
+else
+ error "Node.js/NPM nicht gefunden - überspringe NPM Installation"
+fi
+
+# 6. Application Key generieren
+info "Generiere Application Key..."
+php artisan key:generate --force
+success "Application Key generiert"
+
+# 7. Erstelle proxy Netzwerk falls nicht vorhanden
+info "Erstelle Docker Proxy Netzwerk..."
+if ! docker network inspect proxy &> /dev/null; then
+ docker network create proxy
+ success "Proxy Netzwerk erstellt"
+else
+ success "Proxy Netzwerk existiert bereits"
+fi
+
+# 8. Docker Container starten
+info "Starte Docker Container (Laravel Sail)..."
+./vendor/bin/sail up -d
+success "Docker Container gestartet"
+
+# 9. Warte bis Datenbank bereit ist
+info "Warte auf Datenbank..."
+sleep 10
+success "Datenbank sollte bereit sein"
+
+# 10. Datenbank Migrationen ausführen
+info "Führe Datenbank Migrationen aus..."
+./vendor/bin/sail artisan migrate --force
+success "Datenbank Migrationen ausgeführt"
+
+# 11. Storage Links erstellen
+info "Erstelle Storage Links..."
+./vendor/bin/sail artisan storage:link
+success "Storage Links erstellt"
+
+# 12. Cache leeren
+info "Leere Application Cache..."
+./vendor/bin/sail artisan config:clear
+./vendor/bin/sail artisan cache:clear
+./vendor/bin/sail artisan view:clear
+./vendor/bin/sail artisan route:clear
+success "Cache geleert"
+
+# 13. Optimierung (optional)
+info "Optimiere Application..."
+./vendor/bin/sail artisan config:cache
+./vendor/bin/sail artisan route:cache
+./vendor/bin/sail artisan view:cache
+success "Application optimiert"
+
+echo ""
+echo "================================================"
+echo -e "${GREEN}✓ Initialisierung erfolgreich abgeschlossen!${NC}"
+echo "================================================"
+echo ""
+echo "Dein Projekt ist nun bereit:"
+echo " - Haupt-URL: https://mein.sterntours.test"
+echo " - Alternative URL: https://sterntours.test"
+echo " - Mailpit Dashboard: https://sterntours-mail.test"
+echo " - Vite Assets: https://assets.sterntours.test"
+echo ""
+echo "Nützliche Befehle:"
+echo " - Container stoppen: ./vendor/bin/sail down"
+echo " - Container starten: ./vendor/bin/sail up -d"
+echo " - Logs anzeigen: ./vendor/bin/sail logs -f"
+echo " - Artisan Befehle: ./vendor/bin/sail artisan "
+echo " - Tests ausführen: ./vendor/bin/sail test"
+echo ""
+
diff --git a/mein.sterntours.de.code-workspace b/mein.sterntours.de.code-workspace
index 362d7c2..7f1e073 100644
--- a/mein.sterntours.de.code-workspace
+++ b/mein.sterntours.de.code-workspace
@@ -1,4 +1,5 @@
{
+ "name": "STERN TOURS [DEV CONTAINER]",
"folders": [
{
"path": "."
diff --git a/newsletter/assets/jordanien-1.jpg b/newsletter/assets/jordanien-1.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..93a86d8eae6c2b9ecc2ecaaa2526398bf2f1eb9e
GIT binary patch
literal 110371
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