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 0000000..93a86d8
Binary files /dev/null and b/newsletter/assets/jordanien-1.jpg differ
diff --git a/newsletter/assets/jordanien-1.png b/newsletter/assets/jordanien-1.png
new file mode 100644
index 0000000..1362e81
Binary files /dev/null and b/newsletter/assets/jordanien-1.png differ
diff --git a/newsletter/assets/jordanien-2.jpg b/newsletter/assets/jordanien-2.jpg
new file mode 100644
index 0000000..5baf01b
Binary files /dev/null and b/newsletter/assets/jordanien-2.jpg differ
diff --git a/newsletter/assets/jordanien-2.png b/newsletter/assets/jordanien-2.png
new file mode 100644
index 0000000..c3722b6
Binary files /dev/null and b/newsletter/assets/jordanien-2.png differ
diff --git a/newsletter/assets/sterntours-logo.png b/newsletter/assets/sterntours-logo.png
new file mode 100644
index 0000000..bb18b78
Binary files /dev/null and b/newsletter/assets/sterntours-logo.png differ
diff --git a/newsletter/assets/usedom-1.jpg b/newsletter/assets/usedom-1.jpg
new file mode 100644
index 0000000..e9cd617
Binary files /dev/null and b/newsletter/assets/usedom-1.jpg differ
diff --git a/newsletter/assets/usedom-1.png b/newsletter/assets/usedom-1.png
new file mode 100644
index 0000000..9b7f0aa
Binary files /dev/null and b/newsletter/assets/usedom-1.png differ
diff --git a/newsletter/assets/usedom-2.jpg b/newsletter/assets/usedom-2.jpg
new file mode 100644
index 0000000..952b138
Binary files /dev/null and b/newsletter/assets/usedom-2.jpg differ
diff --git a/newsletter/assets/usedom-2.png b/newsletter/assets/usedom-2.png
new file mode 100644
index 0000000..ad73731
Binary files /dev/null and b/newsletter/assets/usedom-2.png differ
diff --git a/newsletter/assets/wlogo.png b/newsletter/assets/wlogo.png
new file mode 100644
index 0000000..bb18b78
Binary files /dev/null and b/newsletter/assets/wlogo.png differ
diff --git a/newsletter/sterntours-nl1.html b/newsletter/sterntours-nl1.html
new file mode 100644
index 0000000..f575929
--- /dev/null
+++ b/newsletter/sterntours-nl1.html
@@ -0,0 +1,376 @@
+
+
+
+
+
+
+ STERN TOURS Newsletter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ihr Kulturreise-Spezialist
+
+
+
+
+
+
+ Liebe Reisefreundinnen und Reisefreunde,
+
+ vielleicht ist es schon eine Weile her, dass Sie mit uns die Welt entdeckt haben, und wir hoffen, Sie erinnern sich gerne an Ihre Reise mit STERN TOURS zurück.
+
+
+ Nach einer längeren Pause möchten wir in Zukunft wieder regelmäßiger mit Ihnen in Kontakt treten – nicht mit aufdringlicher Werbung, sondern mit echten Inspirationen, exklusiven Einblicken und besonderen Angeboten, die wir speziell für unsere treuen Kunden zusammenstellen.
+
+
+ Als Auftakt möchten wir Sie an einen Ort entführen, der gerade in der kalten Jahreszeit seine ganze Magie entfaltet.
+
+
+
+
+
+
+
+ Entfliehen Sie dem Grau: Jordaniens Wintersonne ruft!
+
+
+
+
+ Während es bei uns ungemütlich wird, erwarten Sie in Jordanien angenehme Tagestemperaturen , weniger Besucher an den weltberühmten Stätten und ein unvergleichliches Licht in der Wüste. Entdecken Sie das Felsenwunder Petra in aller Ruhe, erleben Sie die Weite des Wadi Rum bei einer Jeep-Tour und lassen Sie sich im Toten Meer treiben.
+
+ Unsere erfahrenen Reiseleiter zeigen Ihnen die Schätze des Haschemitischen Königreichs.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ein Dankeschön für Ihre Treue: 100 € Rabatt auf Ihre nächste Buchung
+
+ Als kleines Willkommen zurück und als Dankeschön für Ihr Vertrauen möchten wir Ihnen den Start ins nächste Abenteuer erleichtern.
+
+ Wir schenken Ihnen 100 € Rabatt auf Ihre nächste Buchung einer unserer Kulturreisen (Angebot gültig für alle neuen Buchungen bis zum 31.12.2025).
+
+
+ Geben Sie bei Ihrer Buchung einfach den Code ENTDECKER100 im Anmerkungen-Feld mit an. Der Rabatt wird dann automatisch bei Ihrer Buchungsbestätigung berücksichtigt und vom Gesamtpreis abgezogen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Wussten Sie schon? Unser Tipp aus Amman
+
+ Kein Besuch in Jordaniens Hauptstadt ist komplett, ohne Knafeh probiert zu haben! Diese süße, warme Käsespeise mit Sirup ist eine lokale Delikatesse und der perfekte Abschluss eines erlebnisreichen Tages. Fragen Sie unsere Reiseleiter nach dem besten Laden dafür!
+
+
+
+
+
+
+
+
+
+
+
+ Wir freuen uns darauf, bald wieder von Ihnen zu hören und vielleicht schon Ihre nächste Traumreise zu planen.
+
+
+ Herzliche Grüße aus Berlin,
+ Leoni Stern
+ und das gesamte Team von STERN TOURS
+
+
+
+
+
+
+
+
+
+
+ Kennen Sie schon unsere Sonnenseite an der Ostsee?
+
+ Manchmal muss es gar nicht die weite Ferne sein. Für eine erholsame Auszeit zwischendurch bieten wir exklusive Ferienwohnungen auf der Sonneninsel Usedom an – perfekt für ein langes Wochenende oder die Familienferien.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warum erhalten Sie diesen Newsletter? Sie erhalten diese E-Mail, da Sie in der Vergangenheit eine Reise bei STERN TOURS gebucht haben.
+ Wir möchten Sie als geschätzten Kunden auch zukünftig über relevante Angebote und Inspirationen informieren. Sie können dem widersprechen oder sich
+ jederzeit abmelden .
+
+
+
+
+
+
+
+
+ STERN TOURS GmbH
+ Emser Straße 3 | 10719 Berlin
+ Telefon: 030 700 94 100 |
+ E-Mail: stern@sterntours.de
+
+
+ Impressum |
+ Datenschutz |
+ Newsletter abbestellen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/newsletter/usedom-nl1.html b/newsletter/usedom-nl1.html
new file mode 100644
index 0000000..7008d96
--- /dev/null
+++ b/newsletter/usedom-nl1.html
@@ -0,0 +1,378 @@
+
+
+
+
+
+
+ STERN TOURS Newsletter - Usedom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ihre Auszeit auf Usedom
+
+
+
+
+
+
+ Liebe Usedom-Freunde,
+
+ vielleicht ist es schon eine Weile her, dass Sie bei uns auf der Sonneninsel zu Gast waren, und wir hoffen, Sie hatten eine wunderbar erholsame Zeit in unseren Ferienwohnungen.
+
+
+ Nach einer längeren Pause möchten wir in Zukunft wieder regelmäßiger mit Ihnen in Kontakt treten – mit saisonalen Insel-Tipps, Informationen zu freien Terminen und exklusiven Angeboten, die wir speziell für unsere treuen Gäste zusammenstellen.
+
+
+ Als Auftakt möchten wir Ihnen die Jahreszeit schmackhaft machen, die für viele Kenner die schönste auf der Insel ist.
+
+
+
+
+
+
+
+ Die Magie der stillen Jahreszeit: Ihr Rückzugsort auf Usedom
+
+
+
+
+ Wenn die großen Touristenströme verschwunden sind, zeigt Usedom sein wahres Gesicht. Genießen Sie endlose Strandspaziergänge in klarer, kalter Luft, wärmen Sie sich danach in der Sauna oder vor dem Kamin auf und erleben Sie die einzigartige Ruhe, die nur der Herbst und Winter an der See bieten können.
+
+ Unsere gemütlichen Ferienwohnungen sind der perfekte Ausgangspunkt für Ihre persönliche Auszeit.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ein Dankeschön für Ihre Treue: 10% Rabatt auf Ihre nächste Buchung
+
+ Als kleines Willkommen zurück und als Dankeschön für Ihr Vertrauen möchten wir Ihnen etwas zurückgeben. Für Ihre nächste Buchung in einer unserer Ferienwohnungen auf Usedom schenken wir Ihnen 10% Rabatt auf den reinen Buchungspreis.
+
+ Vielleicht die perfekte Gelegenheit für eine spontane Herbst-Auszeit oder um schon jetzt die ersten sonnigen Frühlingstage im nächsten Jahr zu planen?
+
+
+ Geben Sie bei Ihrer Anfrage oder Buchung einfach das Stichwort INSEL-TREUE10 im Anmerkungen-Feld an, der Rabatt wird Ihnen in der Bestätigung automatisch abgezogen. (Angebot gültig für alle neuen Buchungen bis zum 31.12.2025)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unser Insel-Tipp für gemütliche Tage
+
+ Wenn das Wetter mal richtig ungemütlich ist, gibt es nichts Besseres als einen Besuch in der Bernsteintherme in Zinnowitz . Lassen Sie sich im warmen Meerwasser-Becken treiben oder genießen Sie einen entspannten Saunagang. Ein perfekter Plan B für einen grauen Tag!
+
+
+
+
+
+
+
+
+
+
+
+ Wir freuen uns darauf, Sie bald wieder als unsere Gäste auf der wunderschönen Insel Usedom begrüßen zu dürfen.
+
+
+ Herzliche Grüße aus Berlin,
+ Leoni Stern
+ und das gesamte Team von STERN TOURS
+
+
+
+
+
+
+
+
+
+
+ Packt Sie manchmal das Fernweh?
+
+ Neben unseren Wohlfühl-Oasen auf Usedom sind wir seit über 25 Jahren Spezialisten für unvergessliche Kulturreisen. Falls Sie mal wieder Lust auf 1001 Nacht, antike Wunder oder orientalische Märkte haben, schauen Sie sich unsere geführten Reisen an.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warum erhalten Sie diesen Newsletter? Sie erhalten diese E-Mail, da Sie in der Vergangenheit eine Reise bei STERN TOURS gebucht haben.
+ Wir möchten Sie als geschätzten Kunden auch zukünftig über relevante Angebote und Inspirationen informieren. Sie können dem widersprechen oder sich
+ jederzeit abmelden .
+
+
+
+
+
+
+
+
+ STERN TOURS GmbH
+ Emser Straße 3 | 10719 Berlin
+ Telefon: 030 700 94 100 |
+ E-Mail: stern@sterntours.de
+
+
+ Impressum |
+ Datenschutz |
+ Newsletter abbestellen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/iqcontent/laravel-filemanager/src/Controllers/LfmController.php b/packages/iqcontent/laravel-filemanager/src/Controllers/LfmController.php
index 149bc8f..9d90bb4 100644
--- a/packages/iqcontent/laravel-filemanager/src/Controllers/LfmController.php
+++ b/packages/iqcontent/laravel-filemanager/src/Controllers/LfmController.php
@@ -112,7 +112,7 @@ class LfmController extends Controller
{
$shared_folder_name = config('lfm.shared_folder_name');
$shared_folder = IQContentFolder::where('name', $shared_folder_name)->where('folder_id', null)->first();
- if(!$shared_folder){
+ if (!$shared_folder) {
IQContentFolder::create([
'folder_id' => null,
'name' => $shared_folder_name,
diff --git a/packages/iqcontent/laravel-filemanager/src/Controllers/UploadController.php b/packages/iqcontent/laravel-filemanager/src/Controllers/UploadController.php
index 71172ee..ec57d6f 100644
--- a/packages/iqcontent/laravel-filemanager/src/Controllers/UploadController.php
+++ b/packages/iqcontent/laravel-filemanager/src/Controllers/UploadController.php
@@ -25,46 +25,98 @@ class UploadController extends LfmController
*/
public function upload()
{
- $uploaded_files = request()->file('upload');
- $error_bag = [];
- $new_filename = null;
+ try {
+ // Prüfe, ob überhaupt Dateien hochgeladen wurden
+ $uploaded_files = request()->file('upload');
+ $error_bag = [];
+ $new_filename = null;
- foreach (is_array($uploaded_files) ? $uploaded_files : [$uploaded_files] as $file) {
- try {
- $new_filename = $this->lfm->upload($file);
- } catch (\Exception $e) {
- Log::error($e->getMessage(), [
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString()
+ // Prüfe auf post_max_size Überschreitung
+ if (empty($uploaded_files) && empty($_FILES) && $_SERVER['REQUEST_METHOD'] == 'POST') {
+ $postMaxSize = ini_get('post_max_size');
+ $uploadMaxSize = ini_get('upload_max_filesize');
+
+ $error_bag[] = "Die Datei ist zu groß für den Upload. " .
+ "Maximale Uploadgröße: {$uploadMaxSize}. " .
+ "Maximale POST-Größe: {$postMaxSize}. " .
+ "Bitte kontaktieren Sie den Administrator, um die Limits zu erhöhen.";
+
+ Log::error('Upload fehlgeschlagen: post_max_size oder upload_max_filesize überschritten', [
+ 'post_max_size' => $postMaxSize,
+ 'upload_max_filesize' => $uploadMaxSize,
+ 'content_length' => $_SERVER['CONTENT_LENGTH'] ?? 'unknown'
]);
- array_push($error_bag, $e->getMessage());
- }
- }
+ } elseif (empty($uploaded_files)) {
+ $error_bag[] = "Es wurde keine Datei zum Upload ausgewählt.";
+ } else {
+ foreach (is_array($uploaded_files) ? $uploaded_files : [$uploaded_files] as $file) {
+ try {
+ $new_filename = $this->lfm->upload($file);
+ } catch (\Exception $e) {
+ $errorMessage = $e->getMessage();
- if (is_array($uploaded_files)) {
- $response = count($error_bag) > 0 ? $error_bag : parent::$success_response;
- } else { // upload via ckeditor5 expects json responses
- if (is_null($new_filename)) {
- $response = ['error' =>
- [
- 'message' => $error_bag[0]
+ // Sicherstellen, dass die Fehlermeldung ein String ist
+ if (is_object($errorMessage) || is_array($errorMessage)) {
+ $errorMessage = json_encode($errorMessage);
+ }
+
+ Log::error('Upload-Fehler: ' . $errorMessage, [
+ 'file_name' => $file ? $file->getClientOriginalName() : 'unknown',
+ 'file_size' => $file ? $file->getSize() : 0,
+ 'error_file' => $e->getFile(),
+ 'error_line' => $e->getLine(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+
+ array_push($error_bag, $errorMessage);
+ }
+ }
+ }
+
+ // Fehlerbehandlung
+ if (count($error_bag) > 0) {
+ $errorMsg = isset($error_bag[0]) ? $error_bag[0] : 'Unbekannter Upload-Fehler';
+
+ // Einheitliches Fehlerformat für Frontend
+ $response = [
+ 'error' => [
+ 'message' => $errorMsg
]
];
- } else {
- /*$response = view(Lfm::PACKAGE_NAME . '::use')
- ->withFile($this->lfm->setName($new_filename)->url());
-*/
+ // HTTP 400 Status für Fehler
+ return response()->json($response, 400);
+ }
+
+ // Erfolgreiche Antwort
+ if (is_array($uploaded_files)) {
+ $response = parent::$success_response;
+ } else { // upload via ckeditor5 expects json responses
$url = $this->lfm->setName($new_filename)->url();
-
$response = [
'url' => $url
];
}
+
+ return response()->json($response);
+ } catch (\Exception $e) {
+ // Fange alle unerwarteten Fehler ab
+ Log::error('Unerwarteter Upload-Fehler: ' . $e->getMessage(), [
+ 'error_file' => $e->getFile(),
+ 'error_line' => $e->getLine(),
+ 'trace' => $e->getTraceAsString(),
+ 'request_data' => [
+ 'content_length' => $_SERVER['CONTENT_LENGTH'] ?? 'unknown',
+ 'post_max_size' => ini_get('post_max_size'),
+ 'upload_max_filesize' => ini_get('upload_max_filesize'),
+ ]
+ ]);
+
+ return response()->json([
+ 'error' => [
+ 'message' => 'Ein unerwarteter Fehler ist aufgetreten: ' . $e->getMessage()
+ ]
+ ], 500);
}
- return response()->json($response);
}
-
-
}
diff --git a/packages/iqcontent/laravel-filemanager/src/LfmPath.php b/packages/iqcontent/laravel-filemanager/src/LfmPath.php
index 668cecf..d2bff7f 100644
--- a/packages/iqcontent/laravel-filemanager/src/LfmPath.php
+++ b/packages/iqcontent/laravel-filemanager/src/LfmPath.php
@@ -10,6 +10,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Return_;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use IqContent\LaravelFilemanager\Events\ImageIsUploading;
use IqContent\LaravelFilemanager\Events\ImageWasUploaded;
+use Illuminate\Support\Facades\Log;
class LfmPath
{
@@ -65,18 +66,18 @@ class LfmPath
{
$parent_folder_id = $this->getModelParentFolderId();
if ($this->isDirectory()) {
- if($this->folder_model == null){
- if($parent_folder_id){
+ if ($this->folder_model == null) {
+ if ($parent_folder_id) {
$this->folder_model = IQContentFolder::where('name', $this->item_name)->where('folder_id', $parent_folder_id)->first();
- } else{
+ } else {
$this->folder_model = new IQContentFolder();
}
}
- }else{
- if($this->file_model == null){
- if($parent_folder_id){
+ } else {
+ if ($this->file_model == null) {
+ if ($parent_folder_id) {
$this->file_model = IQContentFile::where('name', $this->item_name)->where('folder_id', $parent_folder_id)->first();
- }else{
+ } else {
$this->file_model = new IQContentFile();
}
}
@@ -94,27 +95,29 @@ class LfmPath
{
if ($this->isDirectory()) {
return $this->folder_model;
- }else{
+ } else {
return $this->file_model;
}
}
- public function getModelParentFolderId(){
+ public function getModelParentFolderId()
+ {
$parent_folder = $this->getModelFolderByPath();
- if($parent_folder) {
+ if ($parent_folder) {
return $parent_folder->id;
}
return null;
}
- public function getModelFolderByPath($parent = false){
+ public function getModelFolderByPath($parent = false)
+ {
$working_dir = $this->path('working_dir');
$working_dir = substr($working_dir, 0, strrpos($working_dir, '/'));
- $dirs = explode( "/", $working_dir);
+ $dirs = explode("/", $working_dir);
$folder_id = null;
$folder = null;
- foreach ($dirs as $dir){
+ foreach ($dirs as $dir) {
$folder = IQContentFolder::where('name', $dir)->where('folder_id', $folder_id)->first();
- if($folder){
+ if ($folder) {
$folder_id = $folder->id;
$this->parent_dir = $folder;
}
@@ -156,7 +159,7 @@ class LfmPath
public function url()
{
- return $this->storage->url($this->path('url'));
+ return $this->storage->url($this->path('url'));
}
public function folders()
@@ -171,16 +174,16 @@ class LfmPath
return $this->sortByColumn($folders);
}
- public function recrusiveFolders($parent){
+ public function recrusiveFolders($parent)
+ {
$folders = $this->folders();
$b = [];
- foreach ($folders as $folder){
+ foreach ($folders as $folder) {
$b[$folder->name()] = $folder;
$lfm = $folder->getLfm();
$lfm->dir($parent);
$a = $lfm->recrusiveFolders($folder->url());
- $b[$folder->name()."-childs"] = $a;
-
+ $b[$folder->name() . "-childs"] = $a;
}
return $b;
}
@@ -229,19 +232,18 @@ class LfmPath
$parent_folder_id = $this->getModelParentFolderId();
$this->storage->makeDirectory(0777, true, true);
- if(!$this->is_thumb){
+ if (!$this->is_thumb) {
IQContentFolder::create([
'folder_id' => $parent_folder_id,
'name' => $this->item_name,
'identifier' => $this->item_name,
]);
}
-
}
public function isDirectory()
{
- if($this->isDirectory !== null){
+ if ($this->isDirectory !== null) {
return $this->isDirectory;
}
$working_dir = $this->path('working_dir');
@@ -313,7 +315,7 @@ class LfmPath
public function upload($file)
{
$error = $this->uploadValidator($file);
- if($error !== 'pass'){
+ if ($error !== 'pass') {
return false;
}
$new_file_name = $this->getNewName($file);
@@ -325,8 +327,12 @@ class LfmPath
try {
$new_file_name = $this->saveFile($file, $new_file_name);
} catch (\Exception $e) {
- \Log::info($e);
- return $this->error('invalid');
+ Log::error('File upload failed: ' . $e->getMessage(), [
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+ throw new \Exception($this->helper->getError('invalid') . ' ' . $e->getMessage());
}
IQContentFile::create([
'folder_id' => $working_folder_id,
@@ -339,7 +345,7 @@ class LfmPath
'content' => '',
]);
// TODO should be "FileWasUploaded"
- // event(new ImageWasUploaded($new_file_path));
+ // event(new ImageWasUploaded($new_file_path));
return $new_file_name;
}
@@ -352,7 +358,21 @@ class LfmPath
} elseif ($file->getError() == UPLOAD_ERR_INI_SIZE) {
return $this->error('file-size', ['max' => ini_get('upload_max_filesize')]);
} elseif ($file->getError() != UPLOAD_ERR_OK) {
- throw new \Exception('File failed to upload. Error code: ' . $file->getError());
+ $errorCode = $file->getError();
+ $errorMessages = [
+ UPLOAD_ERR_INI_SIZE => 'upload-1',
+ UPLOAD_ERR_FORM_SIZE => 'upload-2',
+ UPLOAD_ERR_PARTIAL => 'upload-3',
+ UPLOAD_ERR_NO_FILE => 'upload-4',
+ UPLOAD_ERR_NO_TMP_DIR => 'upload-6',
+ UPLOAD_ERR_CANT_WRITE => 'upload-7',
+ UPLOAD_ERR_EXTENSION => 'upload-8',
+ ];
+
+ $errorKey = isset($errorMessages[$errorCode]) ? $errorMessages[$errorCode] : 'upload-unknown';
+ $errorParams = $errorKey === 'upload-unknown' ? ['code' => $errorCode] : [];
+
+ throw new \Exception($this->helper->getError($errorKey, $errorParams));
}
$new_file_name = $this->getNewName($file);
@@ -422,7 +442,7 @@ class LfmPath
// generate cropped image content
$this->setName($file_name)->thumb(true);
$image = Image::make($original_image->get());
- $this->image_dimensions = $image->width()."x".$image->height();
+ $this->image_dimensions = $image->width() . "x" . $image->height();
$image->fit(config('lfm.thumb_img_width', 200), config('lfm.thumb_img_height', 200));
$this->storage->put($image->stream()->detach());
@@ -437,9 +457,8 @@ class LfmPath
$this->setName($file_name)->thumb(true);
$image = Image::make(file_get_contents($url));
- // $this->image_dimensions = $image->width()."x".$image->height();
+ // $this->image_dimensions = $image->width()."x".$image->height();
$image->fit(config('lfm.thumb_img_width', 200), config('lfm.thumb_img_height', 200));
$this->storage->put($image->stream()->detach());
}
-
}
diff --git a/packages/iqcontent/laravel-filemanager/src/lang/de/lfm.php b/packages/iqcontent/laravel-filemanager/src/lang/de/lfm.php
index eb036fe..cb2195f 100644
--- a/packages/iqcontent/laravel-filemanager/src/lang/de/lfm.php
+++ b/packages/iqcontent/laravel-filemanager/src/lang/de/lfm.php
@@ -8,7 +8,7 @@ return [
'nav-thumbnails' => 'Thumbnails',
'nav-list' => 'List',
'nav-sort' => 'Sort',
- 'nav-sort-alphabetic'=> 'Sort By Alphabets',
+ 'nav-sort-alphabetic' => 'Sort By Alphabets',
'nav-sort-time' => 'Sort By Time',
'menu-rename' => 'Umbenennen',
@@ -64,11 +64,19 @@ return [
'error-cannotnewdirectory' => 'Sie sind nicht berechtigt, neue Ordner zu erstellen',
'error-cannotrename' => 'Sie sind nicht berechtigt, Ordner / Dateien umzubenennen',
'error-cannotresize' => 'Sie sind nicht berechtigt, die Dateigröße zu ändern',
- 'error-folder-not-found'=> 'Folder not found! (:folder)',
+ 'error-folder-not-found' => 'Folder not found! (:folder)',
'error-size' => 'Over limit size:',
'error-move-exist' => 'Datei existiert bereits.',
'error-move-same' => 'Datei und Ziel sind gleich.',
'error-move-parent' => 'Unterordner kann nicht verschoben werden.',
+ 'error-upload-1' => 'Die Datei überschreitet die in der PHP-Konfiguration (upload_max_filesize) festgelegte maximale Größe.',
+ 'error-upload-2' => 'Die Datei überschreitet die im HTML-Formular (MAX_FILE_SIZE) festgelegte maximale Größe.',
+ 'error-upload-3' => 'Die Datei wurde nur teilweise hochgeladen.',
+ 'error-upload-4' => 'Es wurde keine Datei hochgeladen.',
+ 'error-upload-6' => 'Es fehlt ein temporärer Ordner auf dem Server.',
+ 'error-upload-7' => 'Fehler beim Schreiben der Datei auf die Festplatte.',
+ 'error-upload-8' => 'Eine PHP-Erweiterung hat den Upload gestoppt.',
+ 'error-upload-unknown' => 'Ein unbekannter Upload-Fehler ist aufgetreten (Error Code: :code).',
diff --git a/packages/iqcontent/laravel-filemanager/src/views/index.blade.php b/packages/iqcontent/laravel-filemanager/src/views/index.blade.php
index e47ea0e..566bb64 100644
--- a/packages/iqcontent/laravel-filemanager/src/views/index.blade.php
+++ b/packages/iqcontent/laravel-filemanager/src/views/index.blade.php
@@ -1,290 +1,408 @@
+
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
- {{ trans('laravel-filemanager::lfm.title-page') }}
-
-
-
-
-
-
-
-
- {{-- Use the line below instead of the above if you need to cache the css. --}}
- {{-- --}}
+ {{ trans('laravel-filemanager::lfm.title-page') }}
+
+
+
+
+
+
+
+
+ {{-- Use the line below instead of the above if you need to cache the css. --}}
+ {{-- --}}
+
-
-
-
- {{ trans('laravel-filemanager::lfm.nav-back') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ trans('laravel-filemanager::lfm.btn-open') }}
- {{ trans('laravel-filemanager::lfm.menu-view') }}
- {{ trans('laravel-filemanager::lfm.btn-confirm') }}
-
-
-