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("/(

', '', $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.

+
+ +
+ + +
+ + + + +
+
+
Kompletter Navigationsbaum
+ GET +
+
/tree
+
+ Gibt den kompletten hierarchischen Navigationsbaum mit allen Seiten zurück. +
+ +
+
+ + +
+
+
Nur aktive Navigationspunkte
+ GET +
+
/tree/active
+
+ Gibt nur aktive Seiten zurück (status = 1 und show_in_navi = 1). +
+ +
+
+ + +
+
+
Teilbaum ab Root-ID
+ GET +
+
/tree/{rootId}
+
+ Gibt einen Teilbaum zurück, beginnend mit der angegebenen Page-ID.
+ +
+ +
+
+ + +
+
+
Flache Liste
+ GET +
+
/flat
+
+ Gibt alle Navigationspunkte als flache Liste zurück (ohne parent-child Beziehung). +
+ +
+
+ + +
+
+
Breadcrumb-Pfad
+ GET +
+
/breadcrumb/{pageId}
+
+ Gibt den Breadcrumb-Pfad für eine bestimmte Seite zurück.
+ +
+ +
+
+ + +
+
+
Cache leeren
+ POST +
+
/cache/clear
+
+ Löscht den kompletten Navigation-Cache. +
+ +
+
+ + +
+

Test-Zusammenfassung

+
+
+
0
+
Gesamt
+
+
+
0
+
Erfolgreich
+
+
+
0
+
Fehlgeschlagen
+
+
+
+
+ + + + + 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 + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + 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.message-empty') }} -
- -
- - -
- -
- -
-
- -