From bebfa63e870db8c88f54db0936917ad724198bf7 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 3 Jun 2026 15:43:18 +0200 Subject: [PATCH] Display CMS Optimierungen 03-06-2026 --- .gitignore | 1 + app/Console/Commands/PublishFluxAssets.php | 84 ++++++ .../Admin/Cms/DisplayVersionEditor.php | 93 +++++- app/Livewire/Cabinet/QuickStatus.php | 2 +- app/Services/DisplayPlaylistConfigBuilder.php | 5 + app/Services/DisplayQrCodeService.php | 71 +++++ app/Support/DisplayModuleSettings.php | 2 +- composer.json | 6 + .../02-deployment-live-29-05-2026.md | 274 +++++++++++++++++ public/_cabinet/display/index.html | 281 ++++++++++++------ .../admin/cms/cabinet-info-tablet.blade.php | 97 +++--- .../version-editor-settings.blade.php | 2 +- tests/Feature/CabinetQuickStatusTest.php | 6 + tests/Feature/DisplayVersionApiTest.php | 20 ++ tests/Feature/DisplayVersionTest.php | 53 ++++ tests/Feature/FluxAssetRouteTest.php | 31 ++ tests/Feature/PublishFluxAssetsTest.php | 30 ++ 17 files changed, 925 insertions(+), 133 deletions(-) create mode 100644 app/Console/Commands/PublishFluxAssets.php create mode 100644 app/Services/DisplayQrCodeService.php create mode 100644 dev/displays-11-05-2026/02-deployment-live-29-05-2026.md create mode 100644 tests/Feature/FluxAssetRouteTest.php create mode 100644 tests/Feature/PublishFluxAssetsTest.php diff --git a/.gitignore b/.gitignore index 1e667ef0..8b98b037 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /public/hot /public/storage /public/vendor +/public/flux /storage/*.key /storage/app /storage/framework diff --git a/app/Console/Commands/PublishFluxAssets.php b/app/Console/Commands/PublishFluxAssets.php new file mode 100644 index 00000000..cc2e6e5d --- /dev/null +++ b/app/Console/Commands/PublishFluxAssets.php @@ -0,0 +1,84 @@ +resolveSourceDirectory(); + $targetDir = public_path('flux'); + + if (! File::isDirectory($targetDir)) { + File::makeDirectory($targetDir, 0755, true); + } + + $files = [ + 'flux.js', + 'flux.min.js', + 'editor.js', + 'editor.min.js', + 'editor.css', + ]; + + $published = 0; + + foreach ($files as $file) { + $source = $sourceDir.'/'.$file; + + if (! is_file($source)) { + continue; + } + + $target = $targetDir.'/'.$file; + + if (is_file($target) && ! $this->option('force') && hash_file('sha256', $source) === hash_file('sha256', $target)) { + $this->line(" • {$file} (unverändert)"); + + continue; + } + + File::copy($source, $target); + $this->line(" ✓ {$file}"); + $published++; + } + + if ($published === 0) { + $this->info('Flux-Assets sind bereits aktuell.'); + + return self::SUCCESS; + } + + $this->newLine(); + $this->info("{$published} Flux-Asset(s) nach public/flux/ veröffentlicht."); + + return self::SUCCESS; + } + + private function resolveSourceDirectory(): string + { + $proDist = base_path('vendor/livewire/flux-pro/dist'); + + if (is_dir($proDist)) { + return $proDist; + } + + $freeDist = base_path('vendor/livewire/flux/dist'); + + if (is_dir($freeDist)) { + return $freeDist; + } + + throw new RuntimeException('Flux vendor assets not found. Run composer install first.'); + } +} diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index ac5a318d..1bee84d3 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -6,6 +6,7 @@ use App\Enums\DisplayVersionType; use App\Models\DisplayMedia; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; +use App\Services\DisplayQrCodeService; use App\Support\DisplayModuleSettings; use Livewire\Attributes\On; use Livewire\Component; @@ -174,6 +175,7 @@ class DisplayVersionEditor extends Component public function saveSettings(): void { $this->normalizeBrandPositions(); + $this->settings = $this->settingsWithQrImage($this->settings); $this->version->update(['settings' => $this->settings]); $this->showSettingsModal = false; $this->refreshModulePreview(); @@ -463,7 +465,7 @@ class DisplayVersionEditor extends Component */ private function buildItemContent(): array { - return match ($this->itemType) { + $content = match ($this->itemType) { 'video' => [ 'filename' => $this->videoFilename, 'title' => $this->videoTitle, @@ -514,6 +516,95 @@ class DisplayVersionEditor extends Component ], default => [], }; + + return $this->contentWithQrImage($content); + } + + /** + * @param array $content + * @return array + */ + private function contentWithQrImage(array $content): array + { + $qrUrl = match ($this->itemType) { + 'footer' => $content['url'] ?? null, + 'slide' => $content['qr_url'] ?? null, + default => null, + }; + + $qrCodeService = app(DisplayQrCodeService::class); + $qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null); + + if ($qrCode) { + $content['qr_image_path'] = $qrCode['path']; + $content['qr_image_url'] = $qrCode['url']; + } elseif ($this->itemId && is_string($qrUrl) && $qrUrl !== '') { + $existingContent = DisplayVersionItem::find($this->itemId)?->content ?? []; + $existingQrUrl = match ($this->itemType) { + 'footer' => $existingContent['url'] ?? null, + 'slide' => $existingContent['qr_url'] ?? null, + default => null, + }; + $preservedExistingQrImage = false; + + if (is_string($existingQrUrl) + && $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl) + && ! empty($existingContent['qr_image_path']) + && ! empty($existingContent['qr_image_url'])) { + $content['qr_image_path'] = $existingContent['qr_image_path']; + $content['qr_image_url'] = $existingContent['qr_image_url']; + $preservedExistingQrImage = true; + } + + if (! $preservedExistingQrImage) { + unset($content['qr_image_path'], $content['qr_image_url']); + } + } else { + unset($content['qr_image_path'], $content['qr_image_url']); + } + + return $content; + } + + /** + * @param array $settings + * @return array + */ + private function settingsWithQrImage(array $settings): array + { + if ($this->version->type !== DisplayVersionType::B2in) { + return $settings; + } + + $qrUrl = $settings['qr_url'] ?? $settings['footer_url'] ?? null; + $qrCodeService = app(DisplayQrCodeService::class); + $qrCode = $qrCodeService->generateForUrl(is_string($qrUrl) ? $qrUrl : null); + + if ($qrCode) { + $settings['qr_image_path'] = $qrCode['path']; + $settings['qr_image_url'] = $qrCode['url']; + } elseif (is_string($qrUrl) && $qrUrl !== '') { + $existingSettings = $this->settingsWithDefaults(); + $existingQrUrl = $existingSettings['qr_url'] ?? $existingSettings['footer_url'] ?? null; + $preservedExistingQrImage = false; + + if (is_string($existingQrUrl) + && $qrCodeService->normalizeUrl($existingQrUrl) === $qrCodeService->normalizeUrl($qrUrl) + && ! empty($existingSettings['qr_image_path']) + && ! empty($existingSettings['qr_image_url'])) { + $settings['qr_image_path'] = $existingSettings['qr_image_path']; + $settings['qr_image_url'] = $existingSettings['qr_image_url']; + $preservedExistingQrImage = true; + } + + if (! $preservedExistingQrImage) { + unset($settings['qr_image_path'], $settings['qr_image_url']); + } + } else { + unset($settings['qr_image_path'], $settings['qr_image_url']); + } + + return $settings; } private function getActiveFlag(): bool diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php index a97410df..d9968785 100644 --- a/app/Livewire/Cabinet/QuickStatus.php +++ b/app/Livewire/Cabinet/QuickStatus.php @@ -48,7 +48,7 @@ class QuickStatus extends Component public function mount(?string $k = null): void { $validKey = config('domains.cabinet_status_key'); - $key = $k ?? request()->query('k'); + $key = $k ?? request()->query('k') ?? request()->query('key'); if (! $validKey || $key !== $validKey) { $this->authorized = false; diff --git a/app/Services/DisplayPlaylistConfigBuilder.php b/app/Services/DisplayPlaylistConfigBuilder.php index 46c74140..155c9d01 100644 --- a/app/Services/DisplayPlaylistConfigBuilder.php +++ b/app/Services/DisplayPlaylistConfigBuilder.php @@ -90,6 +90,10 @@ class DisplayPlaylistConfigBuilder $data['url'] = $item->content['url']; } + if (! empty($item->content['qr_image_url'])) { + $data['qr_image_url'] = $item->content['qr_image_url']; + } + return $data; }); @@ -170,6 +174,7 @@ class DisplayPlaylistConfigBuilder 'disclaimer' => $content['disclaimer'] ?? '', 'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '', 'qr_url' => $content['qr_url'] ?? '', + 'qr_image_url' => $content['qr_image_url'] ?? '', 'qr_title' => $content['qr_title'] ?? '', 'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '', 'contact' => $content['contact'] ?? '', diff --git a/app/Services/DisplayQrCodeService.php b/app/Services/DisplayQrCodeService.php new file mode 100644 index 00000000..f7858259 --- /dev/null +++ b/app/Services/DisplayQrCodeService.php @@ -0,0 +1,71 @@ +normalizeUrl($url); + + if ($normalizedUrl === '') { + return null; + } + + $path = 'display-qr-codes/'.sha1($normalizedUrl).'.png'; + $disk = Storage::disk('public'); + + if (! $disk->exists($path)) { + try { + $response = Http::timeout(10) + ->retry(2, 300) + ->get($this->remoteQrUrl($normalizedUrl)); + + if (! $response->successful() || $response->body() === '') { + return null; + } + + $disk->put($path, $response->body()); + } catch (\Throwable) { + return null; + } + } + + return [ + 'path' => $path, + 'url' => asset('storage/'.$path), + ]; + } + + public function normalizeUrl(?string $url): string + { + $url = trim((string) $url); + + if ($url === '') { + return ''; + } + + if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { + return $url; + } + + return 'https://'.$url; + } + + private function remoteQrUrl(string $url): string + { + return 'https://api.qrserver.com/v1/create-qr-code/?'.http_build_query([ + 'size' => '300x300', + 'margin' => '8', + 'color' => '000000', + 'bgcolor' => 'ffffff', + 'data' => $url, + ]); + } +} diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php index 2fcad412..f33b8375 100644 --- a/app/Support/DisplayModuleSettings.php +++ b/app/Support/DisplayModuleSettings.php @@ -19,7 +19,7 @@ class DisplayModuleSettings ], DisplayVersionType::B2in->value => [ 'theme' => 'dark', - 'header_logo_url' => '../assets/b2in-logo-positive.svg', + 'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg', 'header_claim' => 'Connecting Design & Property', 'logo_position' => 'top-left', 'claim_position' => 'top-right', diff --git a/composer.json b/composer.json index 30bae6cf..ecad2a81 100644 --- a/composer.json +++ b/composer.json @@ -63,8 +63,14 @@ "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", "@php artisan livewire:publish --assets --ansi", + "@php artisan flux:publish-assets --ansi --force", "@php artisan boost:update --ansi || true" ], + "post-install-cmd": [ + "@php artisan vendor:publish --tag=laravel-assets --ansi --force", + "@php artisan livewire:publish --assets --ansi", + "@php artisan flux:publish-assets --ansi --force" + ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], diff --git a/dev/displays-11-05-2026/02-deployment-live-29-05-2026.md b/dev/displays-11-05-2026/02-deployment-live-29-05-2026.md new file mode 100644 index 00000000..9f451acd --- /dev/null +++ b/dev/displays-11-05-2026/02-deployment-live-29-05-2026.md @@ -0,0 +1,274 @@ +# Display CMS – Deployment auf Live (29.05.2026) + +Kurze Übersicht der Änderungen aus Commit `6c6d683` und was beim Live-Deploy zu beachten ist. + +--- + +## Was wurde geändert? + +### Mediathek +- Videos zeigen ein **Vorschaubild** (Poster-Frame) statt nur eines Icons. +- Poster werden beim Upload automatisch erzeugt (`ffmpeg`). +- Neuer Artisan-Command zum Nachgenerieren bestehender Videos: `display-media:generate-video-thumbnails` +- Neue Kategorie **„Sonstiges“** für B2in-Medien. + +### B2in-Display-Modul +- Media-Picker zeigt **alle Medien** (Bild + Video); der Typ wird aus der Datei abgeleitet. +- **Marke** (ehem. Header): Logo und Claim frei in Ecken positionierbar; separate Schalter für Anzeige. +- **Footer** global ein-/ausblendbar; unten positionierte Markenelemente nur ohne Footer. +- Kleine **Thumbnail-Vorschau** vor allen Medien-URL-Feldern im Editor. + +### Video-Playlist-Modul +- Video-Vorschaubilder in der Übersicht. +- Feld „Titel“ heißt jetzt **„Name“** (intern, nicht auf dem Display). +- Vorschau im Modal zeigt bei neuen Einträgen kein falsches Video mehr. + +### Angebote-Modul +- **Kein Slide-Typ** mehr – einheitliches Detail-Layout. +- Jeder Inhaltsblock (Logo, Badge, Texte, Preis, QR, Kontakt …) ist **pro Angebot** ein-/ausblendbar. +- Logo und Brand-Text liegen **pro Slide**, nicht mehr in den Modul-Meta-Einstellungen. +- Meta-Einstellungen: nur noch Endlosschleife, Transition, Dauer. +- **Streichpreis**: Originalpreis rot durchgestrichen (optional). + +### Display-Player (`public/_cabinet/display/index.html`) +- B2in: Marke/Footer-Einstellungen werden ausgespielt. +- Angebote: dynamisches Layout, Streichpreis, Show-Flags. +- **Bugfix**: Endlosschleife bei leeren Modulen gestoppt (kein CPU-Loop mehr). + +### Admin-Portal (allgemein) +- **Script-Reihenfolge** korrigiert: `@livewireScripts` vor `@fluxScripts` (behebt `fluxModal`-Fehler). +- Stale Datei `public/flux/flux.js` **entfernt** – Flux wird **nicht** statisch aus `public/` geladen (siehe Abschnitt „Flux UI auf Live“). +- Livewire-Modal-Crash beim Aktualisieren von Inhalten behoben. + +--- + +## Flux UI auf Live (wichtig – Switcher, Modals, Dropdowns) + +### Diagnose (portal.b2in.eu) + +```bash +curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test" +# Aktuell: HTTP 404 ← Browser führt das Script NICHT aus → Switcher/Modals kaputt + +curl -I "https://portal.b2in.eu/vendor/livewire/livewire.min.js?id=test" +# HTTP 200 ← Livewire funktioniert, weil Assets statisch unter public/vendor/livewire/ liegen +``` + +**Ursache:** Flux wird auf Live per Laravel-Route ausgeliefert, liefert dort aber **HTTP 404** (trotz JS-Inhalt). Livewire nutzt veröffentlichte Dateien in `public/vendor/livewire/` → **HTTP 200**. + +**Symptome:** Modals öffnen nicht, Switcher reagieren nicht, Konsole zeigt `ReferenceError: Can't find variable: fluxModal`. + +### Fix auf dem Live-Server + +Flux-Assets wie Livewire statisch veröffentlichen: + +```bash +git pull +composer install --no-dev --optimize-autoloader +# ↑ führt flux:publish-assets automatisch aus (composer.json post-install-cmd) + +# Falls nötig manuell: +php artisan flux:publish-assets --force + +php artisan route:clear +php artisan config:cache +php artisan view:clear +``` + +**auth.json** muss auf Live existieren (Flux Pro) – die Datei liegt nicht in Git. + +Der Command kopiert aktuelle Dateien aus `vendor/livewire/flux-pro/dist/` nach `public/flux/`. Nginx liefert sie dann direkt mit **HTTP 200** aus (wie bei Livewire). + +### Prüfen ob Flux korrekt läuft + +```bash +curl -I "https://portal.b2in.eu/flux/flux.min.js?id=test" +# Erwartung: HTTP 200 +# Kein x-fastcgi-cache Header (dann kommt es aus PHP statt statisch) +``` + +Im Admin-Portal → DevTools → Netzwerk: + +1. `vendor/livewire/livewire.min.js` → **200** +2. `flux/flux.min.js` → **200** (nicht 404!) +3. `livewire.js` lädt **vor** `flux.min.js` +4. Hard-Reload (`Cmd+Shift+R` / `Ctrl+Shift+R`) + +### Wichtig + +| ❌ Falsch | ✅ Richtig | +|---|---| +| Alte `public/flux/flux.js` aus altem Backup zurückkopieren | `php artisan flux:publish-assets --force` | +| `public/flux/` löschen ohne neu zu publishen | Danach immer `flux:publish-assets` ausführen | +| Nur `route:clear` erwarten, dass es reicht | Statische Assets publishen (wie bei Livewire) | + +--- + +## Live-Deploy Checkliste + +### 1. Code deployen + +```bash +git pull +composer install --no-dev --optimize-autoloader +``` + +`composer install` veröffentlicht dabei automatisch Livewire- und Flux-Assets nach `public/` (`post-install-cmd`). Falls der Deploy-Prozess Composer-Skripte mit `--no-scripts` deaktiviert, danach zwingend manuell ausführen: + +```bash +php artisan livewire:publish --assets +php artisan flux:publish-assets --force +``` + +**Keine neuen Migrationen** in diesem Release. Die Spalte `display_media.thumbnail_path` existiert bereits seit der Mediathek-Migration (`2026_03_26_*`). + +### 2. FFmpeg installieren (Pflicht für Video-Poster) + +Video-Poster werden serverseitig per `ffmpeg` erzeugt (`DisplayMediaService`, Artisan-Command). Ohne FFmpeg funktionieren Uploads weiterhin, aber **keine Video-Vorschaubilder** (weder in der Mediathek noch im Editor). + +#### Dev Container (lokal) + +Im Dev Container ist `ffmpeg` bereits vorinstalliert. Prüfen: + +```bash +ffmpeg -version +# Erwartung: /usr/bin/ffmpeg +``` + +#### Ubuntu / Debian (Live-Server) + +```bash +sudo apt update +sudo apt install -y ffmpeg +ffmpeg -version +``` + +#### CentOS / RHEL / AlmaLinux + +```bash +sudo dnf install -y ffmpeg +# Alternativ auf älteren Systemen: +sudo yum install -y ffmpeg +``` + +Falls `ffmpeg` im Standard-Repo fehlt, ggf. **RPM Fusion** aktivieren. + +#### macOS (lokal ohne Dev Container) + +```bash +brew install ffmpeg +``` + +#### Wichtig: FFmpeg muss für PHP erreichbar sein + +Nicht nur in der SSH-Session, sondern auch für den **Webserver-User** (PHP-FPM), da Poster sowohl beim Artisan-Command als auch beim Upload in der Mediathek erzeugt werden: + +```bash +# Prüfen (User ggf. anpassen: www-data, nginx, apache …) +sudo -u www-data which ffmpeg +sudo -u www-data ffmpeg -version +``` + +Wenn `which ffmpeg` für den Webserver-User leer ist, `ffmpeg` systemweit installieren oder sicherstellen, dass `/usr/bin` im PATH von PHP-FPM liegt. Der Code ruft `ffmpeg` direkt auf – ein separater `.env`-Key ist nicht nötig. + +### 3. Bestehende Videos: Poster nachgenerieren + +Einmalig nach dem Deploy (oder bei Bedarf erneut mit `--force`): + +```bash +php artisan display-media:generate-video-thumbnails +``` + +Thumbnails landen auf dem `public`-Disk neben der Videodatei (`…-poster.jpg`). + +### 4. Laravel-Caches leeren + +```bash +php artisan route:clear # wichtig für Flux-Routen auf Portal-Domain +php artisan config:cache +php artisan view:clear +``` + +> **Hinweis:** `route:cache` erst verwenden, wenn `/flux/flux.min.js` auf Live mit HTTP 200 antwortet. Siehe Abschnitt „Flux UI auf Live“. + +### 5. Storage prüfen + +```bash +php artisan storage:link # nur falls noch nicht vorhanden +``` + +Schreibrechte auf `storage/app/public/display-media/` müssen für den Webserver/PHP-User gesetzt sein (Poster-Erzeugung). + +### 6. Umgebungsvariablen + +In `.env` auf Live prüfen: + +| Variable | Live-Beispiel | Hinweis | +|---|---|---| +| `DISPLAY_PLAYER_URL` | `https://cabinet.b2in.eu/display` | Player-URL für Admin-Vorschau und Links | +| `DISPLAY_DOMAIN` | `b2in.eu` | Fallback in `config/display.php` | +| `FILESYSTEM_DISK` | `public` oder S3 | Poster werden auf dem Media-Disk gespeichert | + +Keine neuen Pflicht-ENV-Keys in diesem Release. + +### 7. Frontend-Assets + +Für dieses Release reichen **keine** separaten npm-Builds – Änderungen liegen in Blade-Templates, PHP und der statischen Player-HTML-Datei. + +Falls euer Deploy-Prozess generell Assets baut: + +```bash +npm ci +npm run build:admin +``` + +### 8. Display-Player in der Praxis (Cabinet-Geräte) + +- Die Player-Datei `public/_cabinet/display/index.html` ist **statisch** und wird mit dem Deploy ausgeliefert. +- Geräte laden die Config alle **60 Sekunden** neu; nach Deploy spätestens innerhalb einer Minute die neue Player-Logik. +- Zur Sicherheit: einmal **Hard-Reload** am Display-Browser oder kurz warten (Player hat zusätzlich 6h-Reload und Datei-Change-Erkennung). +- **Cache-Control** für Preview-Routen ist auf `no-cache` gesetzt – betrifft Admin-Vorschau, nicht zwingend CDN vor `/display`. + +### 9. Nach dem Deploy testen + +Kurz im Admin-Portal (`portal.b2in.eu`): + +1. **Mediathek** – Video zeigt Poster? Neues Video hochladen → Poster erscheint? +2. **B2in-Modul** – Footer aus, Logo-Ecke unten, Claim-Position, Vorschau im iframe. +3. **Angebote** – Slide bearbeiten, Blöcke ein/aus, Streichpreis, Logo pro Slide. +4. **Leeres Modul** – Editor öffnen ohne Inhalte → kein Browser-Crash, kein Endlos-Reload in der Vorschau. +5. **Browser-Konsole** – keine `fluxModal`- oder Livewire-Morph-Fehler mehr. +6. **Flux-Assets** – `/flux/flux.min.js` → 200; Switcher und Modals reagieren normal. + +--- + +## Rückwärtskompatibilität + +- **Bestehende Angebots-Slides** ohne `show_*`-Flags rendern weiter wie bisher (Fallback: Block sichtbar, wenn Inhalt vorhanden). +- **B2in-Module** mit alten Einstellungen behalten ihre Defaults (Footer an, Logo oben links). +- **Offers Meta-Einstellungen** (Logo, Brand-Text, Footer) in alten Modul-`settings` werden ignoriert – Branding liegt jetzt pro Slide. Bestehende Slides ohne Logo-Felder nutzen weiter die Player-Defaults. + +--- + +## Bekannte Abhängigkeiten + +| Abhängigkeit | Zweck | Ohne | +|---|---|---| +| `ffmpeg` | Video-Poster | Upload ok, kein Thumbnail | +| `public`-Storage | Medien + Poster | Upload schlägt fehl | +| Portal-Domain | Admin + API | Editor/Vorschau | +| Display-Domain | Player-Ausspielung | Cabinet-Geräte | + +--- + +## Rollback + +Bei Problemen: auf den vorherigen Git-Stand zurück (`git revert` oder Deploy des letzten Releases). + +- Keine DB-Migration rückgängig machen nötig. +- Bereits erzeugte Poster-Dateien können liegen bleiben (harmlos). +- `public/flux/` nicht manuell aus altem Backup wiederherstellen – stattdessen `php artisan flux:publish-assets --force`. + +--- + +*Stand: 29.05.2026 · Commit `6c6d683`* diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html index c1669239..f0c5987b 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -270,6 +270,9 @@ transform-origin: top left; flex-shrink: 0; } + .offer-slide--no-header { grid-template-rows: 1fr auto; } + .offer-slide--no-bottom { grid-template-rows: auto 1fr; } + .offer-slide--no-header.offer-slide--no-bottom { grid-template-rows: 1fr; } /* Offer Header */ .offer-header { @@ -309,6 +312,13 @@ display: grid; grid-template-columns: 1fr 300px; gap: 24px; align-items: stretch; } + .offer-bottom--info-only, + .offer-bottom--qr-only { + grid-template-columns: 1fr; + } + .offer-bottom--compact { + align-items: start; + } /* Info Box */ .offer-info { @@ -317,6 +327,13 @@ border: 1px solid #e8e8e8; border-radius: 24px; padding: 28px; min-height: 340px; } + .offer-info--compact { + min-height: 0; + padding: 24px 28px; + } + .offer-info--title-only { + padding: 22px 28px; + } .offer-info-content { flex: 1; } .offer-eyebrow { font-size: 18px; color: #737373; letter-spacing: 0.14em; @@ -546,6 +563,57 @@ function normalizeQrUrl(url) { return `https://${url}`; } +function qrCodeImageUrl(url, size = 300, margin = 8, extraParams = {}) { + const normalizedUrl = normalizeQrUrl(url); + if (!normalizedUrl) return ''; + + const params = new URLSearchParams({ + size: `${size}x${size}`, + margin: String(margin), + data: normalizedUrl, + ...extraParams, + }); + + return `https://api.qrserver.com/v1/create-qr-code/?${params.toString()}`; +} + +function setQrImage(img, url, options = {}) { + const baseSrc = options.imageUrl || qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {}); + if (!img || !baseSrc) return false; + + const maxAttempts = options.maxAttempts || 3; + let attempt = 0; + + img.alt = options.alt || 'QR Code'; + img.loading = 'eager'; + img.decoding = 'async'; + img.referrerPolicy = 'no-referrer'; + img.style.visibility = 'hidden'; + + const load = () => { + attempt++; + const separator = baseSrc.includes('?') ? '&' : '?'; + img.src = `${baseSrc}${separator}cb=${Date.now()}-${attempt}`; + }; + + img.onload = () => { + img.style.visibility = 'visible'; + }; + + img.onerror = () => { + if (attempt < maxAttempts) { + setTimeout(load, attempt * 1000); + return; + } + + img.style.visibility = 'hidden'; + }; + + load(); + + return true; +} + /** * Cabinet Display Player * @@ -1157,8 +1225,7 @@ class VideoDisplayRenderer { headline.textContent = content.headline || ''; subline.textContent = content.subline || ''; - if (content.url) { - qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=10&data=${encodeURIComponent(content.url)}`; + if ((content.url || content.qr_image_url) && setQrImage(qrImg, content.url, { imageUrl: content.qr_image_url, margin: 10, alt: 'QR' })) { qrContainer.style.display = 'flex'; } else { qrContainer.style.display = 'none'; @@ -1218,7 +1285,7 @@ class B2inRenderer { layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : ''); layer.setAttribute('data-theme', this.theme); - const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg'); + const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/assets/b2in-logo-positive.svg'); const headerClaim = this.settings.header_claim || 'Connecting Design & Property'; const footerUrl = this.settings.footer_url || 'B2in.eu'; const footerName = this.settings.footer_name || ''; @@ -1227,6 +1294,7 @@ class B2inRenderer { ? `${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}` : ''; const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu'); + const qrImageUrl = this.settings.qr_image_url || ''; // Footer visibility, brand element visibility + corner positioning const showFooter = this.settings.show_footer !== false; @@ -1269,7 +1337,7 @@ class B2inRenderer { ${footerNameHtml} ` : ''; @@ -1300,6 +1368,11 @@ class B2inRenderer { this.headline = layer.querySelector('#b2in-headline'); this.subline = layer.querySelector('#b2in-subline'); this.progress = layer.querySelector('#b2in-progress'); + + const footerQrImg = layer.querySelector('.b2in-footer-qr img'); + if (footerQrImg) { + setQrImage(footerQrImg, qrUrl, { imageUrl: qrImageUrl, size: 200, margin: 5, alt: 'QR' }); + } } start() { @@ -1409,6 +1482,8 @@ class B2inRenderer { if (!url) return ''; if (url.startsWith('http')) return url; if (url.startsWith('/')) return url; + if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`; + if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`; if (url.startsWith('../')) return url; return `../${url}`; } @@ -1512,12 +1587,19 @@ class OffersRenderer { qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl), contact: (slide.show_contact ?? has(slide.contact)) && has(contactText), }; + const showInfo = has(slide.title) || show.eyebrow || show.subline || show.bullets || show.price || show.disclaimer; + const showQrBox = show.qr || show.contact; + const showBottom = showInfo || showQrBox; + const infoBlockCount = [has(slide.title), show.eyebrow, show.subline, show.bullets, show.price, show.disclaimer] + .filter(Boolean).length; const wrapper = document.createElement('div'); wrapper.className = 'offers-slide-container'; const article = document.createElement('article'); article.className = 'offer-slide'; + if (!show.logo) article.classList.add('offer-slide--no-header'); + if (!showBottom) article.classList.add('offer-slide--no-bottom'); // --- HEADER (Logo & Marke) – per slide, toggleable --- if (show.logo) { @@ -1549,9 +1631,6 @@ class OffersRenderer { } article.appendChild(header); - } else { - // Without the header row, let the hero stay the flexible middle row. - article.style.gridTemplateRows = '1fr auto'; } // --- HERO --- @@ -1571,101 +1650,114 @@ class OffersRenderer { article.appendChild(hero); + if (!showBottom) { + wrapper.appendChild(article); + + return wrapper; + } + // --- BOTTOM: Info + QR --- const bottom = document.createElement('section'); bottom.className = 'offer-bottom'; + if (showInfo && !showQrBox) bottom.classList.add('offer-bottom--info-only'); + if (!showInfo && showQrBox) bottom.classList.add('offer-bottom--qr-only'); + if (showInfo && infoBlockCount <= 3 && !show.price && !show.disclaimer) bottom.classList.add('offer-bottom--compact'); // Info - const info = document.createElement('div'); - info.className = 'offer-info'; + if (showInfo) { + const info = document.createElement('div'); + info.className = 'offer-info'; + if (infoBlockCount <= 3 && !show.price && !show.disclaimer) info.classList.add('offer-info--compact'); + if (infoBlockCount === 1 && has(slide.title)) info.classList.add('offer-info--title-only'); - const infoContent = document.createElement('div'); - infoContent.className = 'offer-info-content'; + const infoContent = document.createElement('div'); + infoContent.className = 'offer-info-content'; - if (show.eyebrow) { - const eyebrow = document.createElement('p'); - eyebrow.className = 'offer-eyebrow'; - eyebrow.textContent = slide.eyebrow; - infoContent.appendChild(eyebrow); - } - - if (slide.title) { - const title = document.createElement('h1'); - title.className = 'offer-title medium'; - title.innerHTML = slide.title.replace(/\n/g, '
'); - infoContent.appendChild(title); - } - - if (show.subline) { - const subline = document.createElement('p'); - subline.className = 'offer-subline'; - subline.textContent = slide.subline; - infoContent.appendChild(subline); - } - - if (show.bullets) { - const ul = document.createElement('ul'); - ul.className = 'offer-bullets'; - slide.bullets.forEach(text => { - if (!text) return; - const li = document.createElement('li'); - li.innerHTML = `${this.escapeHtml(text)}`; - ul.appendChild(li); - }); - infoContent.appendChild(ul); - } - - info.appendChild(infoContent); - - // Price block - if (show.price) { - const priceBlock = document.createElement('div'); - priceBlock.className = 'offer-price-block'; - - const priceRow = document.createElement('div'); - priceRow.className = 'offer-price-row'; - - const price = document.createElement('span'); - price.className = 'offer-price'; - price.textContent = slide.price; - priceRow.appendChild(price); - - if (slide.original_price) { - const note = document.createElement('div'); - note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note'; - note.textContent = slide.original_price; - priceRow.appendChild(note); + if (show.eyebrow) { + const eyebrow = document.createElement('p'); + eyebrow.className = 'offer-eyebrow'; + eyebrow.textContent = slide.eyebrow; + infoContent.appendChild(eyebrow); } - if (slide.tag_text) { - const note = document.createElement('div'); - note.className = 'offer-price-note'; - const tag = document.createElement('span'); - tag.className = 'offer-impulse-tag'; - tag.textContent = slide.tag_text; - note.appendChild(tag); - priceRow.appendChild(note); + if (slide.title) { + const title = document.createElement('h1'); + title.className = 'offer-title medium'; + title.innerHTML = slide.title.replace(/\n/g, '
'); + infoContent.appendChild(title); } - priceBlock.appendChild(priceRow); - info.appendChild(priceBlock); - } + if (show.subline) { + const subline = document.createElement('p'); + subline.className = 'offer-subline'; + subline.textContent = slide.subline; + infoContent.appendChild(subline); + } - // Disclaimer - if (show.disclaimer) { - const footer = document.createElement('div'); - footer.className = 'offer-info-footer'; - const disc = document.createElement('span'); - disc.className = 'offer-disclaimer'; - disc.textContent = slide.disclaimer; - footer.appendChild(disc); - info.appendChild(footer); - } + if (show.bullets) { + const ul = document.createElement('ul'); + ul.className = 'offer-bullets'; + slide.bullets.forEach(text => { + if (!text) return; + const li = document.createElement('li'); + li.innerHTML = `${this.escapeHtml(text)}`; + ul.appendChild(li); + }); + infoContent.appendChild(ul); + } - bottom.appendChild(info); + info.appendChild(infoContent); + + // Price block + if (show.price) { + const priceBlock = document.createElement('div'); + priceBlock.className = 'offer-price-block'; + + const priceRow = document.createElement('div'); + priceRow.className = 'offer-price-row'; + + const price = document.createElement('span'); + price.className = 'offer-price'; + price.textContent = slide.price; + priceRow.appendChild(price); + + if (slide.original_price) { + const note = document.createElement('div'); + note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note'; + note.textContent = slide.original_price; + priceRow.appendChild(note); + } + + if (slide.tag_text) { + const note = document.createElement('div'); + note.className = 'offer-price-note'; + const tag = document.createElement('span'); + tag.className = 'offer-impulse-tag'; + tag.textContent = slide.tag_text; + note.appendChild(tag); + priceRow.appendChild(note); + } + + priceBlock.appendChild(priceRow); + info.appendChild(priceBlock); + } + + // Disclaimer + if (show.disclaimer) { + const footer = document.createElement('div'); + footer.className = 'offer-info-footer'; + const disc = document.createElement('span'); + disc.className = 'offer-disclaimer'; + disc.textContent = slide.disclaimer; + footer.appendChild(disc); + info.appendChild(footer); + } + + bottom.appendChild(info); + } // QR / Contact box – only when at least one of the two is enabled. - if (show.qr || show.contact) { + if (showQrBox) { const qrBox = document.createElement('aside'); qrBox.className = 'offer-qr-box'; @@ -1681,8 +1773,15 @@ class OffersRenderer { const qrWrapper = document.createElement('div'); qrWrapper.className = 'offer-qr-wrapper'; const qrImg = document.createElement('img'); - qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`; - qrImg.alt = 'QR Code'; + setQrImage(qrImg, qrUrl, { + imageUrl: slide.qr_image_url || '', + size: 300, + margin: 8, + params: { + color: '000000', + bgcolor: 'ffffff', + }, + }); qrWrapper.appendChild(qrImg); qrBox.appendChild(qrWrapper); } @@ -1695,8 +1794,6 @@ class OffersRenderer { } bottom.appendChild(qrBox); - } else { - bottom.style.gridTemplateColumns = '1fr'; } article.appendChild(bottom); @@ -1757,6 +1854,8 @@ class OffersRenderer { if (!url) return ''; if (url.startsWith('http')) return url; if (url.startsWith('/')) return url; + if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`; + if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`; if (url.startsWith('../')) return url; return `../${url}`; } diff --git a/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php b/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php index 800f3ec7..d7d3927a 100644 --- a/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php +++ b/resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php @@ -11,12 +11,23 @@

{{ __('Info-Tablet Steuerung') }}

-

Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.

-

• Automatisch: Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.

-

• API-Endpunkt: {{ url('/api/cabinet-tablet/status') }}

-

• Website-Endpunkt: https://cabinet.b2in.eu/info

-

• Quick-Status-Endpunkt: https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}

-

• Quick-Status-Hinweis: Der Quick-Status-Endpunkt ist ein kleines Tool, um den Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen hinzufügen.

+

Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien + Termin.

+

• Automatisch: Offen/Geschlossen wird automatisch aus den hinterlegten + Öffnungszeiten berechnet.

+

• API-Endpunkt: {{ url('/api/cabinet-tablet/status') }} +

+

• Website-Endpunkt: https://cabinet.b2in.eu/info

+

• Quick-Status-Endpunkt: https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }} +

+

• Quick-Status-Hinweis: Der Quick-Status-Endpunkt ist ein kleines Tool, um den + Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen + hinzufügen.

@@ -29,9 +40,8 @@ @endif -
Manuell geschlossen (Gelb) - @if($storeStatus !== 'auto') + @if ($storeStatus !== 'auto') @php - $noticeColors = match($storeStatus) { - 'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800', + $noticeColors = match ($storeStatus) { + 'notice' + => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800', 'warning' => 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800', - default => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800', + default + => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800', }; @endphp
- - @error('noticeHeadline') {{ $message }} @enderror + + @error('noticeHeadline') + {{ $message }} + @enderror - - @error('noticeSubtext') {{ $message }} @enderror + + @error('noticeSubtext') + {{ $message }} + @enderror
@endif @@ -76,30 +94,28 @@ {{-- Öffnungszeiten --}} {{ __('Öffnungszeiten') }} -

Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.

+

Felder leer lassen = Geschlossen. Der Status + "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.

- @foreach([ - ['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'], - ['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'], - ['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'], - ['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'], - ['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'], - ['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'], - ['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose'], - ] as $row) + @foreach ([['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'], ['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'], ['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'], ['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'], ['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'], ['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'], ['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose']] as $row) @php $isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']}); @endphp -
- {{ $row['label'] }} +
+ {{ $row['label'] }}
- +
- +
- + {{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
@@ -111,7 +127,7 @@
{{ __('Sonderöffnung heute') }} - @if($overrideOpenToday || $overrideCloseToday) + @if ($overrideOpenToday || $overrideCloseToday) {{ __('Zurücksetzen') }} @@ -126,7 +142,7 @@ @error('overrideCloseToday') {{ $message }} @enderror
-
--}} + --}} {{-- Nächster Termin @@ -139,7 +155,7 @@ @error('nextAppointmentTime') {{ $message }} @enderror
- --}} + --}} {{-- Kontakt --}} @@ -147,10 +163,15 @@
- @error('contactPhone') {{ $message }} @enderror + @error('contactPhone') + {{ $message }} + @enderror - - @error('contactEmail') {{ $message }} @enderror + + @error('contactEmail') + {{ $message }} + @enderror
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php index ceda105a..1c61b448 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php @@ -27,7 +27,7 @@ :key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
- +
diff --git a/tests/Feature/CabinetQuickStatusTest.php b/tests/Feature/CabinetQuickStatusTest.php index acd77995..63c73819 100644 --- a/tests/Feature/CabinetQuickStatusTest.php +++ b/tests/Feature/CabinetQuickStatusTest.php @@ -16,6 +16,12 @@ test('page is accessible without login', function () { ->assertSeeLivewire(QuickStatus::class); }); +test('page accepts key query parameter used in admin link', function () { + $this->get('/info/status?key='.VALID_KEY) + ->assertSuccessful() + ->assertSeeLivewire(QuickStatus::class); +}); + test('page shows forbidden when key is missing', function () { Livewire::test(QuickStatus::class, ['k' => '']) ->assertSet('authorized', false) diff --git a/tests/Feature/DisplayVersionApiTest.php b/tests/Feature/DisplayVersionApiTest.php index f6af4e05..51688c75 100644 --- a/tests/Feature/DisplayVersionApiTest.php +++ b/tests/Feature/DisplayVersionApiTest.php @@ -176,6 +176,7 @@ test('returns playlist with offers config', function () { 'bullets' => [], 'disclaimer' => '', 'qr_url' => 'https://cabinet-bielefeld.de', + 'qr_image_url' => 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", 'show_brand_text' => false, @@ -193,6 +194,7 @@ test('returns playlist with offers config', function () { $response->assertJsonPath('playlist.0.slides.0.title', 'GOYA Sideboard'); $response->assertJsonPath('playlist.0.slides.0.price', '489 €'); $response->assertJsonPath('playlist.0.slides.0.qr_url', 'https://cabinet-bielefeld.de'); + $response->assertJsonPath('playlist.0.slides.0.qr_image_url', 'https://portal.b2in.test/storage/display-qr-codes/cabinet.png'); }); test('returns playlist with multiple versions in order', function () { @@ -449,7 +451,25 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('this.settings.footer_claim') ->toContain('this.settings.footer_url') ->toContain('this.settings.header_logo_url') + ->toContain('/_cabinet/assets/b2in-logo-positive.svg') + ->toContain("url.startsWith('../assets/')") + ->toContain("!url.includes('/') && url.endsWith('.svg')") ->toContain('this.settings.qr_label') + ->toContain('function setQrImage') + ->toContain('function qrCodeImageUrl') + ->toContain('options.imageUrl') + ->toContain('content.qr_image_url') + ->toContain('this.settings.qr_image_url') + ->toContain('slide.qr_image_url') + ->toContain("img.loading = 'eager'") + ->toContain('maxAttempts') + ->toContain('cb=${Date.now()}-${attempt}') + ->toContain('offer-slide--no-header') + ->toContain('offer-slide--no-bottom') + ->toContain('offer-bottom--info-only') + ->toContain('offer-bottom--qr-only') + ->toContain('offer-info--compact') + ->toContain('const showBottom = showInfo || showQrBox') ->toContain('this.settings.show_footer !== false') ->toContain('this.settings.show_logo !== false') ->toContain('this.settings.show_claim !== false') diff --git a/tests/Feature/DisplayVersionTest.php b/tests/Feature/DisplayVersionTest.php index de0bbc2a..5a8ab568 100644 --- a/tests/Feature/DisplayVersionTest.php +++ b/tests/Feature/DisplayVersionTest.php @@ -10,6 +10,8 @@ use App\Models\DisplayPlaylistItem; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; use App\Models\User; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; use Livewire\Livewire; @@ -70,6 +72,7 @@ test('can create a display version', function () { expect($version->settings)->toBeArray(); expect($version->settings)->toHaveKey('theme'); expect($version->settings)->toHaveKey('header_logo_url'); + expect($version->settings['header_logo_url'])->toBe('/_cabinet/assets/b2in-logo-positive.svg'); expect($version->settings)->toHaveKey('footer_url'); }); @@ -446,6 +449,56 @@ test('can add a slide item to offers version', function () { expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de'); }); +test('slide editor generates and stores a qr image when a qr url is saved', function () { + Storage::fake('public'); + Http::fake([ + 'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']), + ]); + + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'offers']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'slide') + ->set('slideTitle', 'QR Slide') + ->set('slideQrUrl', 'cabinet-bielefeld.de') + ->call('saveItem'); + + $item = DisplayVersionItem::where('display_version_id', $version->id)->first(); + + expect($item->content['qr_url'])->toBe('cabinet-bielefeld.de') + ->and($item->content['qr_image_path'])->toStartWith('display-qr-codes/') + ->and($item->content['qr_image_url'])->toContain('/storage/display-qr-codes/'); + + expect(Storage::disk('public')->exists($item->content['qr_image_path']))->toBeTrue(); + Http::assertSentCount(1); +}); + +test('b2in settings generate and store a qr image when a qr url is saved', function () { + Storage::fake('public'); + Http::fake([ + 'api.qrserver.com/*' => Http::response('fake-png', 200, ['Content-Type' => 'image/png']), + ]); + + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openSettingsModal') + ->set('settings.qr_url', 'b2in.eu') + ->call('saveSettings'); + + $settings = $version->fresh()->settings; + + expect($settings['qr_image_path'])->toStartWith('display-qr-codes/') + ->and($settings['qr_image_url'])->toContain('/storage/display-qr-codes/'); + + expect(Storage::disk('public')->exists($settings['qr_image_path']))->toBeTrue(); + Http::assertSentCount(1); +}); + test('can add a slide with bullets to offers version', function () { $user = User::factory()->create(); $version = DisplayVersion::factory()->create(['type' => 'offers']); diff --git a/tests/Feature/FluxAssetRouteTest.php b/tests/Feature/FluxAssetRouteTest.php new file mode 100644 index 00000000..2d2a4781 --- /dev/null +++ b/tests/Feature/FluxAssetRouteTest.php @@ -0,0 +1,31 @@ +get('https://'.config('domains.domain_portal').'/flux/flux.js?id=test') + ->assertSuccessful() + ->assertHeader('content-type', 'text/javascript; charset=utf-8'); +}); + +test('portal domain serves minified flux javascript route in production mode', function () { + config(['app.debug' => false]); + + $this->get('https://'.config('domains.domain_portal').'/flux/flux.min.js?id=test') + ->assertSuccessful() + ->assertHeader('content-type', 'text/javascript; charset=utf-8'); +}); + +test('flux pro vendor bundle contains alpine components used by the admin ui', function () { + $path = base_path('vendor/livewire/flux-pro/dist/flux.js'); + + expect(is_file($path))->toBeTrue('Flux Pro must be installed via composer (auth.json on live).'); + + expect(file_get_contents($path))->toContain('fluxModal'); +}); diff --git a/tests/Feature/PublishFluxAssetsTest.php b/tests/Feature/PublishFluxAssetsTest.php new file mode 100644 index 00000000..bdd54057 --- /dev/null +++ b/tests/Feature/PublishFluxAssetsTest.php @@ -0,0 +1,30 @@ +artisan('flux:publish-assets') + ->assertSuccessful(); + + expect(is_file(public_path('flux/flux.js')))->toBeTrue(); + expect(is_file(public_path('flux/flux.min.js')))->toBeTrue(); + expect(filesize(public_path('flux/flux.min.js')))->toBeGreaterThan(1000); + + File::deleteDirectory($targetDir); +}); + +test('composer install and update publish flux assets', function () { + $composer = json_decode((string) file_get_contents(base_path('composer.json')), true, flags: JSON_THROW_ON_ERROR); + $expectedCommand = '@php artisan flux:publish-assets --ansi --force'; + + expect($composer['scripts']['post-install-cmd'] ?? [])->toContain($expectedCommand); + expect($composer['scripts']['post-update-cmd'] ?? [])->toContain($expectedCommand); +});