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/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php index 27b2b900..8ab72eda 100644 --- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -69,8 +69,15 @@ class DisplayMediaPicker extends Component $service = app(DisplayMediaService::class); $lastMedia = null; - foreach ($this->quickUploads as $file) { - $lastMedia = $service->storeUpload($file); + try { + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + } + } catch (\RuntimeException $exception) { + $this->quickUploads = []; + $this->addError('quickUploads', $exception->getMessage()); + + return; } $this->quickUploads = []; diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index ac5a318d..ec85b24a 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(); @@ -322,6 +324,25 @@ class DisplayVersionEditor extends Component return url('/preview/module/'.$this->version->id.'/item/'.$this->itemId).'?refresh='.$this->previewFrameRefreshCounter; } + /** + * @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string}|null + */ + public function displayMediaFormatStatus(?string $url): ?array + { + $url = trim((string) $url); + + if ($url === '') { + return null; + } + + return $this->displayMediaForUrl($url)?->getDisplayFormatStatus() ?? [ + 'status' => 'unknown', + 'label' => 'Format unbekannt', + 'color' => 'zinc', + 'description' => 'Dieses Medium wurde nicht in der Mediathek gefunden. Ziel ist 9:16 im Hochformat.', + ]; + } + #[On('display-media-selected')] public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void { @@ -463,7 +484,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 +535,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 @@ -590,6 +700,33 @@ class DisplayVersionEditor extends Component $this->previewFrameRefreshCounter++; } + private function displayMediaForUrl(string $url): ?DisplayMedia + { + $mediaPath = $this->mediaPathFromUrl($url); + + return DisplayMedia::query() + ->where('external_url', $url) + ->when($mediaPath !== null, fn ($query) => $query->orWhere('path', $mediaPath)) + ->first(); + } + + private function mediaPathFromUrl(string $url): ?string + { + $path = parse_url($url, PHP_URL_PATH); + $path = is_string($path) ? $path : $url; + $path = ltrim($path, '/'); + + if (str_starts_with($path, 'storage/')) { + return substr($path, strlen('storage/')); + } + + if (str_starts_with($path, 'display-media/')) { + return $path; + } + + return null; + } + /** * @return array */ diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php index 80dd21ec..69189a46 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionList.php +++ b/app/Livewire/Admin/Cms/DisplayVersionList.php @@ -80,6 +80,55 @@ class DisplayVersionList extends Component $version->update(['is_active' => ! $version->is_active]); } + public function copyVersion(int $id): void + { + $source = DisplayVersion::query()->with('items')->findOrFail($id); + + $copy = DB::transaction(function () use ($source) { + $copy = DisplayVersion::create([ + 'name' => $this->copyNameFor($source->name), + 'type' => $source->type, + 'settings' => $source->settings ?? [], + 'is_active' => $source->is_active, + ]); + + foreach ($source->items as $item) { + $copy->items()->create([ + 'item_type' => $item->item_type, + 'content' => $item->content, + 'sort_order' => $item->sort_order, + 'is_active' => $item->is_active, + ]); + } + + return $copy; + }); + + session()->flash('success', 'Modul "'.$source->name.'" wurde als "'.$copy->name.'" kopiert!'); + } + + private function copyNameFor(string $sourceName): string + { + $baseName = preg_match('/^Kopie \d+ - (.+)$/', $sourceName, $matches) === 1 + ? $matches[1] + : $sourceName; + + $maxCopyNumber = 0; + + DisplayVersion::query() + ->where('name', 'like', 'Kopie % - '.$baseName) + ->pluck('name') + ->each(function (string $name) use ($baseName, &$maxCopyNumber): void { + if (preg_match('/^Kopie (\d+) - '.preg_quote($baseName, '/').'$/', $name, $matches) !== 1) { + return; + } + + $maxCopyNumber = max($maxCopyNumber, (int) $matches[1]); + }); + + return 'Kopie '.($maxCopyNumber + 1).' - '.$baseName; + } + /** * @return array */ diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php index a97410df..13340bce 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; @@ -92,13 +92,6 @@ class QuickStatus extends Component 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', ]); - $showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true); - - if (! $showsNotice) { - $this->noticeHeadline = ''; - $this->noticeSubtext = ''; - } - CabinetTabletSetting::current()->update([ 'store_status' => $this->storeStatus, 'notice_headline' => $this->noticeHeadline ?: null, diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php index eb910368..f3d474de 100644 --- a/app/Models/DisplayMedia.php +++ b/app/Models/DisplayMedia.php @@ -12,6 +12,10 @@ class DisplayMedia extends Model /** @use HasFactory<\Database\Factories\DisplayMediaFactory> */ use HasFactory; + private const DISPLAY_TARGET_RATIO = 9 / 16; + + private const DISPLAY_RATIO_TOLERANCE = 0.03; + protected $table = 'display_media'; protected $fillable = [ @@ -50,13 +54,13 @@ class DisplayMedia extends Model return $this->external_url; } - return Storage::disk($this->disk)->url($this->path); + return Storage::url($this->path); } public function getThumbnailUrl(): ?string { if ($this->thumbnail_path) { - return Storage::disk($this->disk)->url($this->thumbnail_path); + return Storage::url($this->thumbnail_path); } if ($this->isUpload() && $this->isImage()) { @@ -66,6 +70,57 @@ class DisplayMedia extends Model return null; } + /** + * @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string} + */ + public function getDisplayFormatStatus(): array + { + $dimensions = $this->getDimensions(); + + if ($dimensions === null) { + return [ + 'status' => 'unknown', + 'label' => 'Format unbekannt', + 'color' => 'zinc', + 'description' => 'Keine Abmessungen gespeichert. Ziel ist 9:16 im Hochformat.', + ]; + } + + $ratio = $dimensions['width'] / $dimensions['height']; + $matchesTarget = abs($ratio - self::DISPLAY_TARGET_RATIO) <= self::DISPLAY_TARGET_RATIO * self::DISPLAY_RATIO_TOLERANCE; + $description = sprintf( + '%dx%d px, erkanntes Verhältnis %s. Ziel ist 9:16 im Hochformat.', + $dimensions['width'], + $dimensions['height'], + $this->formatRatio($dimensions['width'], $dimensions['height']), + ); + + return [ + 'status' => $matchesTarget ? 'match' : 'mismatch', + 'label' => $matchesTarget ? 'Format passt' : 'Format passt nicht', + 'color' => $matchesTarget ? 'green' : 'amber', + 'description' => $description, + ]; + } + + /** + * @return array{width: int, height: int}|null + */ + public function getDimensions(): ?array + { + $width = $this->metadata['width'] ?? null; + $height = $this->metadata['height'] ?? null; + + if (! is_numeric($width) || ! is_numeric($height) || (int) $width <= 0 || (int) $height <= 0) { + return null; + } + + return [ + 'width' => (int) $width, + 'height' => (int) $height, + ]; + } + // ======================================== // TYPE CHECKS // ======================================== @@ -117,6 +172,24 @@ class DisplayMedia extends Model return $this->title ?: $this->filename; } + private function formatRatio(int $width, int $height): string + { + $divisor = $this->greatestCommonDivisor($width, $height); + + return ($width / $divisor).':'.($height / $divisor); + } + + private function greatestCommonDivisor(int $first, int $second): int + { + while ($second !== 0) { + $remainder = $first % $second; + $first = $second; + $second = $remainder; + } + + return max($first, 1); + } + // ======================================== // SCOPES // ======================================== diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index f62c459d..6f333c54 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -7,12 +7,15 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Process\Process; class DisplayMediaService { /** * Store an uploaded file and create a DisplayMedia record. + * + * @throws RuntimeException When the file cannot be written to storage. */ public function storeUpload(UploadedFile $file, ?string $collection = null): DisplayMedia { @@ -22,7 +25,11 @@ class DisplayMediaService $datePath = now()->format('Y/m'); $relativePath = "display-media/{$datePath}/{$storageName}"; - Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName); + $storedPath = Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName); + + if ($storedPath === false || ! Storage::disk('public')->exists($relativePath)) { + throw new RuntimeException("Die Datei \"{$filename}\" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen."); + } $type = in_array($extension, ['mp4', 'webm', 'mov']) ? 'video' : 'image'; @@ -33,6 +40,13 @@ class DisplayMediaService $metadata['width'] = $dimensions[0]; $metadata['height'] = $dimensions[1]; } + } else { + $dimensions = $this->readVideoDimensions($file->getRealPath()); + + if ($dimensions !== null) { + $metadata['width'] = $dimensions['width']; + $metadata['height'] = $dimensions['height']; + } } $media = DisplayMedia::create([ @@ -122,6 +136,43 @@ class DisplayMediaService return $process->isSuccessful() && is_file($thumbnailPath); } + /** + * @return array{width: int, height: int}|null + */ + private function readVideoDimensions(string $videoPath): ?array + { + $process = new Process([ + 'ffprobe', + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'csv=s=x:p=0', + $videoPath, + ]); + $process->setTimeout(30); + + try { + $process->run(); + } catch (\Throwable) { + return null; + } + + if (! $process->isSuccessful()) { + return null; + } + + $dimensions = trim($process->getOutput()); + + if (! preg_match('/^(?\d+)x(?\d+)$/', $dimensions, $matches)) { + return null; + } + + return [ + 'width' => (int) $matches['width'], + 'height' => (int) $matches['height'], + ]; + } + /** * Create a DisplayMedia record from an external URL. */ 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..de9badb5 100644 --- a/app/Support/DisplayModuleSettings.php +++ b/app/Support/DisplayModuleSettings.php @@ -6,6 +6,14 @@ use App\Enums\DisplayVersionType; class DisplayModuleSettings { + /** @var list */ + private const B2IN_BOOLEAN_KEYS = [ + 'show_logo', + 'show_claim', + 'show_footer', + 'display_active', + ]; + /** * @return array */ @@ -19,7 +27,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', @@ -55,6 +63,44 @@ class DisplayModuleSettings */ public static function merge(DisplayVersionType|string $type, ?array $settings): array { - return array_replace_recursive(self::defaults($type), $settings ?? []); + $merged = array_replace_recursive(self::defaults($type), $settings ?? []); + + $typeValue = $type instanceof DisplayVersionType ? $type->value : $type; + + if ($typeValue === DisplayVersionType::B2in->value) { + return self::normalizeB2inBooleans($merged); + } + + if ($typeValue === DisplayVersionType::Offers->value && array_key_exists('loop', $merged)) { + $merged['loop'] = self::toBool($merged['loop']); + } + + return $merged; + } + + /** + * @param array $settings + * @return array + */ + private static function normalizeB2inBooleans(array $settings): array + { + foreach (self::B2IN_BOOLEAN_KEYS as $key) { + if (array_key_exists($key, $settings)) { + $settings[$key] = self::toBool($settings[$key]); + } + } + + return $settings; + } + + private static function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $filtered ?? (bool) $value; } } 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..eee68e97 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -189,8 +189,6 @@ font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); line-height: 1.4; } - /* Without footer the text reclaims the footer's space at the bottom */ - .b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; } /* B2in Footer */ .b2in-footer { @@ -237,6 +235,13 @@ } .b2in-layer[data-theme="light"] .b2in-footer-name { color: rgba(43,63,81,0.5); } + /* Without footer the text reclaims the footer's space and loses the bottom scrim */ + .b2in-layer.no-footer .b2in-text, + .b2in-layer.no-footer[data-theme="light"] .b2in-text { + padding-bottom: 4vh; + background: none; + } + /* ======================================== OFFERS TYPE (DOM-based slides) ======================================== */ @@ -270,6 +275,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 +317,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 +332,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; @@ -540,12 +562,96 @@ function escapeHtml(value) { return div.innerHTML; } +function detectDisplayPortalBaseUrl() { + const { protocol, hostname, port } = window.location; + const portSuffix = port ? `:${port}` : ''; + + if (hostname === 'cabinet.b2in.eu') { + return 'https://portal.b2in.eu'; + } + + if (hostname.startsWith('cabinet.')) { + return `${protocol}//${hostname.replace(/^cabinet\./, 'portal.')}${portSuffix}`; + } + + return window.location.origin; +} + +const DISPLAY_PORTAL_BASE_URL = detectDisplayPortalBaseUrl(); + +function resolveDisplayUrl(url) { + if (!url) return ''; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + if (url.startsWith('/_cabinet/')) return url; + if (url.startsWith('/') && DISPLAY_PORTAL_BASE_URL !== window.location.origin) { + return `${DISPLAY_PORTAL_BASE_URL}${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}`; +} + function normalizeQrUrl(url) { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) return 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 + ? resolveDisplayUrl(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 * @@ -563,7 +669,7 @@ class DisplayPlayer { this.displayId = this.detectDisplayId(); // API - this.BASE_URL = this.detectBaseUrl(); + this.BASE_URL = DISPLAY_PORTAL_BASE_URL; this.API_CONFIG = this.detectConfigUrl(); this.API_CHECK = this.detectCheckUrl(); this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`; @@ -689,11 +795,7 @@ class DisplayPlayer { } detectBaseUrl() { - const hostname = window.location.hostname; - if (hostname === 'cabinet.b2in.eu') { - return 'https://portal.b2in.eu'; - } - return window.location.origin; + return DISPLAY_PORTAL_BASE_URL; } // ======================================== @@ -706,27 +808,25 @@ class DisplayPlayer { try { if (!this.displayId && !this.previewToken && !this.moduleId) { await this.fetchOverview(); - return; + } else { + await this.fetchConfig(); + + if (this.playlist.length === 0) { + this.showError('Keine Versionen zugewiesen'); + this.scheduleRetry(); + } else { + this.hideLoading(); + this.startPlaylist(); + } } - - await this.fetchConfig(); - - if (this.playlist.length === 0) { - this.showError('Keine Versionen zugewiesen'); - this.scheduleRetry(); - return; - } - - this.hideLoading(); - this.startPlaylist(); } catch (error) { console.error('[Display] Init failed:', error); this.showError('Konfiguration konnte nicht geladen werden'); this.scheduleRetry(); } - this.startPolling(); - this.recordFileVersion(); + this.startConfigPolling(); + this.startFileVersionPolling(); this.scheduleAutoReload(); } @@ -759,7 +859,7 @@ class DisplayPlayer { this.renderOverview(data.displays || []); } - startPolling() { + startConfigPolling() { if (!this.API_CHECK) { return; } @@ -771,7 +871,7 @@ class DisplayPlayer { const data = await response.json(); if (data.updated_at !== this.cachedTimestamp) { - console.log('[Display] Change detected, reloading...'); + console.log('[Display] Playlist geändert – Seite wird neu geladen'); location.reload(); } } catch (error) { @@ -784,17 +884,33 @@ class DisplayPlayer { } } }, this.POLL_INTERVAL); + } + startFileVersionPolling() { + this.recordFileVersion(); setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL); } + playerFileCheckUrl() { + const url = new URL(window.location.href); + url.searchParams.set('_player_filecheck', '1'); + + return url.toString(); + } + + readPlayerFileVersion(response) { + return response.headers.get('ETag') + || response.headers.get('Last-Modified') + || response.headers.get('Content-Length'); + } + async recordFileVersion() { try { - const response = await fetch(window.location.href, { + const response = await fetch(this.playerFileCheckUrl(), { method: 'HEAD', cache: 'no-store', }); - this.fileVersion = response.headers.get('ETag') || response.headers.get('Last-Modified'); + this.fileVersion = this.readPlayerFileVersion(response); } catch (e) { // Ignorieren – wird beim nächsten Check erneut versucht } @@ -802,14 +918,23 @@ class DisplayPlayer { async checkFileVersion() { try { - const response = await fetch(window.location.href, { + const response = await fetch(this.playerFileCheckUrl(), { method: 'HEAD', cache: 'no-store', }); - const version = response.headers.get('ETag') || response.headers.get('Last-Modified'); + const version = this.readPlayerFileVersion(response); - if (this.fileVersion && version && version !== this.fileVersion) { - console.log('[Display] Datei geändert – Seite wird neu geladen'); + if (!version) { + return; + } + + if (this.fileVersion === null) { + this.fileVersion = version; + return; + } + + if (version !== this.fileVersion) { + console.log('[Display] Player-Datei geändert – Seite wird neu geladen'); location.reload(); } } catch (e) { @@ -1157,8 +1282,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'; @@ -1166,12 +1290,7 @@ class VideoDisplayRenderer { } resolveAssetUrl(src) { - if (!src) return ''; - if (src.startsWith('http')) return src; - if (src.startsWith('/')) return src; - if (src.startsWith('../')) return src; - // Assets relative to _cabinet - return `../${src}`; + return resolveDisplayUrl(src); } destroy() { @@ -1218,7 +1337,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 +1346,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 +1389,7 @@ class B2inRenderer { ${footerNameHtml} ` : ''; @@ -1300,6 +1420,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() { @@ -1373,15 +1498,31 @@ class B2inRenderer { // Image const img = document.createElement('img'); img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; + + let handled = false; + const finish = () => { + if (handled || this.destroyed) return; + handled = true; + clearTimeout(this.itemTimer); + this.showItem(index + 1); + }; + + // Broken image (e.g. deleted media, 404): skip after a short grace + // period instead of showing a black frame for the full duration. + // The delay also prevents a rapid loop when every image is broken. + img.onerror = () => { + console.warn(`[B2in] Image error: ${item.media_url}`); + this.hideProgress(); + setTimeout(finish, 2000); + }; + img.src = this.resolveUrl(item.media_url); incoming.appendChild(img); const duration = (item.duration_seconds || this.settings.default_image_duration || 10) * 1000; this.showProgress(duration); - this.itemTimer = setTimeout(() => { - if (!this.destroyed) this.showItem(index + 1); - }, duration); + this.itemTimer = setTimeout(finish, duration); } // Crossfade @@ -1406,11 +1547,7 @@ class B2inRenderer { } resolveUrl(url) { - if (!url) return ''; - if (url.startsWith('http')) return url; - if (url.startsWith('/')) return url; - if (url.startsWith('../')) return url; - return `../${url}`; + return resolveDisplayUrl(url); } destroy() { @@ -1512,12 +1649,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 +1693,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 +1712,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 +1835,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 +1856,6 @@ class OffersRenderer { } bottom.appendChild(qrBox); - } else { - bottom.style.gridTemplateColumns = '1fr'; } article.appendChild(bottom); @@ -1754,11 +1913,7 @@ class OffersRenderer { } resolveUrl(url) { - if (!url) return ''; - if (url.startsWith('http')) return url; - if (url.startsWith('/')) return url; - if (url.startsWith('../')) return url; - return `../${url}`; + return resolveDisplayUrl(url); } escapeHtml(str) { diff --git a/resources/css/portal.css b/resources/css/portal.css index dc9c97f3..f5d23558 100644 --- a/resources/css/portal.css +++ b/resources/css/portal.css @@ -118,5 +118,19 @@ textarea[data-flux-control]::placeholder { scroll-behavior: smooth; } +/* + * Flux switch thumb in dark mode: track uses data-checked on ui-switch.group, thumb uses + * group-data-checked:translate-x-[15px]. If dark:translate-x-[2px] wins (stale views/CSS), + * the knob stays left while the track looks "on". Attribute-based rules avoid that. + */ +ui-switch.group[data-checked] > span { + --tw-translate-x: 15px; + translate: var(--tw-translate-x) var(--tw-translate-y); +} + +ui-switch.group[data-checked] > span:where(:dir(rtl), [dir='rtl'], [dir='rtl'] *) { + --tw-translate-x: -15px; +} + /* Theme-specific button styles for Backend Portal */ 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/display-media-library.blade.php b/resources/views/livewire/admin/cms/display-media-library.blade.php index d4905839..a0ecdb01 100644 --- a/resources/views/livewire/admin/cms/display-media-library.blade.php +++ b/resources/views/livewire/admin/cms/display-media-library.blade.php @@ -88,9 +88,16 @@ $handleUploads = function () { $service = app(DisplayMediaService::class); $count = 0; - foreach ($this->uploads as $file) { - $service->storeUpload($file); - $count++; + try { + foreach ($this->uploads as $file) { + $service->storeUpload($file); + $count++; + } + } catch (\RuntimeException $exception) { + $this->uploads = []; + $this->addError('uploads', $exception->getMessage()); + + return; } $this->uploads = []; diff --git a/resources/views/livewire/admin/cms/display-version-editor.blade.php b/resources/views/livewire/admin/cms/display-version-editor.blade.php index 70e39924..6b7b4ba6 100644 --- a/resources/views/livewire/admin/cms/display-version-editor.blade.php +++ b/resources/views/livewire/admin/cms/display-version-editor.blade.php @@ -126,6 +126,16 @@
+ @if($formatStatus = $this->displayMediaFormatStatus($videoFilename)) +
+ + {{ __($formatStatus['label']) }} + + + {{ $formatStatus['description'] }} + +
+ @endif {{ $mediaType === 'video' ? __('Video') : __('Bild') }} + @if($formatStatus = $this->displayMediaFormatStatus($mediaUrl)) + + {{ __($formatStatus['label']) }} + + @endif {{ __('wird automatisch aus dem gewählten Medium erkannt') }} + @if($formatStatus = $this->displayMediaFormatStatus($mediaUrl)) + + {{ $formatStatus['description'] }} + + @endif diff --git a/resources/views/livewire/admin/cms/display-version-list.blade.php b/resources/views/livewire/admin/cms/display-version-list.blade.php index 4caecae3..258d6432 100644 --- a/resources/views/livewire/admin/cms/display-version-list.blade.php +++ b/resources/views/livewire/admin/cms/display-version-list.blade.php @@ -79,6 +79,14 @@ icon="pencil"> + + + {{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }} + @if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? '')) + + {{ __($formatStatus['label']) }} + + @endif {{ ucfirst($item->content['category'] ?? '–') }} @@ -62,6 +67,9 @@ @if(($item->content['media_type'] ?? 'image') === 'image') {{ $item->content['duration_seconds'] ?? 10 }}s @endif + @if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? '')) + {{ $formatStatus['description'] }} + @endif 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..bb4047e2 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,13 +27,13 @@ :key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
- +
- - + +
@if($showLogo) @@ -63,7 +63,7 @@
{{ __('Footer & QR') }} - @@ -78,7 +78,7 @@ - + @elseif($version->type->value === 'offers') diff --git a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php index 53f59aec..edb153ff 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php @@ -58,6 +58,12 @@ {{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }} + @if ($formatStatus = $this->displayMediaFormatStatus($videoSource)) + + {{ __($formatStatus['label']) }} + + {{ $formatStatus['description'] }} + @endif {{ $videoSource ?: '–' }} Position: {{ $item->content['position'] ?? 25 }}%
diff --git a/tests/Feature/CabinetQuickStatusTest.php b/tests/Feature/CabinetQuickStatusTest.php index acd77995..b2c43ccc 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) @@ -67,10 +73,11 @@ test('save persists status and headline to database', function () { expect($settings->notice_subtext)->toBe('Heute nur bis 14 Uhr'); }); -test('save clears headline when switching to auto', function () { +test('save preserves headline when switching to auto', function () { CabinetTabletSetting::factory()->create([ 'store_status' => 'notice', 'notice_headline' => 'Alter Text', + 'notice_subtext' => 'Alter Subtext', ]); Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) @@ -78,8 +85,24 @@ test('save clears headline when switching to auto', function () { ->call('save') ->assertHasNoErrors(); - expect(CabinetTabletSetting::current()->store_status)->toBe('auto'); - expect(CabinetTabletSetting::current()->notice_headline)->toBeNull(); + $settings = CabinetTabletSetting::current(); + expect($settings->store_status)->toBe('auto'); + expect($settings->notice_headline)->toBe('Alter Text'); + expect($settings->notice_subtext)->toBe('Alter Subtext'); +}); + +test('switching back to notice restores saved headline from database', function () { + CabinetTabletSetting::factory()->create([ + 'store_status' => 'notice', + 'notice_headline' => 'Gespeicherter Hinweis', + 'notice_subtext' => 'Gespeicherter Subtext', + ]); + + Livewire::test(QuickStatus::class, ['k' => VALID_KEY]) + ->call('selectStatus', 'auto') + ->call('selectStatus', 'notice') + ->assertSet('noticeHeadline', 'Gespeicherter Hinweis') + ->assertSet('noticeSubtext', 'Gespeicherter Subtext'); }); test('save validates headline max length', function () { diff --git a/tests/Feature/DisplayMediaTest.php b/tests/Feature/DisplayMediaTest.php index 2ebc5683..0cb85e96 100644 --- a/tests/Feature/DisplayMediaTest.php +++ b/tests/Feature/DisplayMediaTest.php @@ -91,6 +91,23 @@ it('returns display name from title or filename', function () { expect($withoutTitle->getDisplayName())->toBe('file.mp4'); }); +it('reports whether media dimensions match the portrait display format', function () { + $portrait = DisplayMedia::factory()->create([ + 'metadata' => ['width' => 1080, 'height' => 1920], + ]); + $landscape = DisplayMedia::factory()->create([ + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + $unknown = DisplayMedia::factory()->create(['metadata' => null]); + + expect($portrait->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'match', 'label' => 'Format passt']) + ->and($landscape->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'mismatch', 'label' => 'Format passt nicht']) + ->and($unknown->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'unknown', 'label' => 'Format unbekannt']); +}); + it('returns the stored poster as thumbnail url for a video', function () { $media = DisplayMedia::factory()->video()->create([ 'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg', @@ -229,11 +246,69 @@ it('generates a poster thumbnail when a real video is uploaded', function () { $media = $service->storeUpload($file); expect($media->thumbnail_path)->not->toBeNull(); + expect($media->metadata)->toMatchArray(['width' => 320, 'height' => 240]); expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue(); @unlink($videoPath); }); +it('throws and creates no record when the file cannot be written to storage', function () { + $disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $disk->shouldReceive('putFileAs')->once()->andReturn(false); + Storage::shouldReceive('disk')->with('public')->andReturn($disk); + + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('broken.jpg'); + + expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class); + expect(DisplayMedia::count())->toBe(0); +}); + +it('throws and creates no record when the stored file does not exist afterwards', function () { + $disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $disk->shouldReceive('putFileAs')->once()->andReturn('display-media/2026/07/ghost.jpg'); + $disk->shouldReceive('exists')->once()->andReturn(false); + Storage::shouldReceive('disk')->with('public')->andReturn($disk); + + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('ghost.jpg'); + + expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class); + expect(DisplayMedia::count())->toBe(0); +}); + +it('shows a validation error in the media library when the storage write fails', function () { + $this->mock(DisplayMediaService::class) + ->shouldReceive('storeUpload') + ->once() + ->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.')); + + $file = UploadedFile::fake()->image('broken.jpg'); + + Volt::test('admin.cms.display-media-library') + ->set('uploads', [$file]) + ->call('handleUploads') + ->assertHasErrors('uploads'); + + expect(DisplayMedia::count())->toBe(0); +}); + +it('shows a validation error in the media picker when the storage write fails', function () { + $this->mock(DisplayMediaService::class) + ->shouldReceive('storeUpload') + ->once() + ->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.')); + + $file = UploadedFile::fake()->image('broken.jpg'); + + Livewire\Livewire::test(\App\Livewire\Admin\Cms\DisplayMediaPicker::class) + ->set('quickUploads', [$file]) + ->assertHasErrors('quickUploads') + ->assertNotDispatched('display-media-selected'); + + expect(DisplayMedia::count())->toBe(0); +}); + it('does not set a thumbnail when the video cannot be decoded', function () { $service = app(DisplayMediaService::class); $file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4'); diff --git a/tests/Feature/DisplayVersionApiTest.php b/tests/Feature/DisplayVersionApiTest.php index f6af4e05..4e0bae5a 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 () { @@ -442,6 +444,15 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('container-type: size;') ->toContain("if (hostname === 'cabinet.b2in.eu') {") ->toContain("return 'https://portal.b2in.eu';") + ->toContain('startConfigPolling') + ->toContain('startFileVersionPolling') + ->toContain('playerFileCheckUrl') + ->toContain('readPlayerFileVersion') + ->toContain('Player-Datei geändert') + ->toContain('Playlist geändert') + ->toContain('DISPLAY_PORTAL_BASE_URL') + ->toContain("url.startsWith('/_cabinet/')") + ->toContain('`${DISPLAY_PORTAL_BASE_URL}${url}`') ->toContain('/api/display/overview') ->toContain('Aktive Live-Displays') ->toContain('renderOverview(data.displays || [])') @@ -449,7 +460,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') @@ -457,7 +486,17 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('this.settings.claim_position') ->toContain('b2in-brand-logo pos-') ->toContain('b2in-scrim') - ->toContain('.b2in-layer.no-footer .b2in-text'); + ->toContain('.b2in-layer.no-footer .b2in-text') + ->toContain('background: none'); +}); + +test('display player skips broken images instead of showing a black frame', function () { + $player = file_get_contents(public_path('_cabinet/display/index.html')); + + expect($player) + ->toContain('img.onerror = () => {') + ->toContain('[B2in] Image error:') + ->toContain('setTimeout(finish, 2000);'); }); test('display player stops looping when the playlist has no playable content', function () { @@ -489,10 +528,12 @@ test('preview player is served with a revalidation cache header', function () { $display = Display::factory()->create(['preview_token' => 'fresh-token']); $version = DisplayVersion::factory()->create(); - foreach ([ - '/preview/fresh-token', - "/preview/module/{$version->id}", - ] as $url) { + foreach ( + [ + '/preview/fresh-token', + "/preview/module/{$version->id}", + ] as $url + ) { $response = $this->get($url); $response->assertSuccessful(); expect($response->headers->get('Cache-Control'))->toContain('no-cache'); diff --git a/tests/Feature/DisplayVersionTest.php b/tests/Feature/DisplayVersionTest.php index de0bbc2a..01ffa33e 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'); }); @@ -129,6 +132,65 @@ test('can toggle display version active status', function () { expect($version->fresh()->is_active)->toBeFalse(); }); +test('can copy a display version with all items and settings', function () { + $user = User::factory()->create(); + $source = DisplayVersion::factory()->create([ + 'name' => 'B2In Immobilien Dark', + 'type' => DisplayVersionType::B2in, + 'settings' => ['theme' => 'dark', 'show_footer' => false], + 'is_active' => true, + ]); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $source->id, + 'item_type' => 'media', + 'content' => ['headline' => 'Test Headline'], + 'sort_order' => 0, + ]); + DisplayVersionItem::factory()->create([ + 'display_version_id' => $source->id, + 'item_type' => 'media', + 'content' => ['headline' => 'Zweites Medium'], + 'sort_order' => 1, + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->call('copyVersion', $source->id); + + $copy = DisplayVersion::query()->where('name', 'Kopie 1 - B2In Immobilien Dark')->first(); + + expect($copy)->not->toBeNull() + ->and($copy->type)->toBe(DisplayVersionType::B2in) + ->and($copy->settings)->toBe(['theme' => 'dark', 'show_footer' => false]) + ->and($copy->is_active)->toBeTrue() + ->and($copy->items)->toHaveCount(2) + ->and($copy->items->pluck('content.headline')->all())->toBe(['Test Headline', 'Zweites Medium']); +}); + +test('copying a display version increments the copy number', function () { + $user = User::factory()->create(); + $source = DisplayVersion::factory()->create(['name' => 'Schaufenster Video']); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->call('copyVersion', $source->id) + ->call('copyVersion', $source->id); + + expect(DisplayVersion::where('name', 'Kopie 1 - Schaufenster Video')->exists())->toBeTrue() + ->and(DisplayVersion::where('name', 'Kopie 2 - Schaufenster Video')->exists())->toBeTrue(); +}); + +test('copying an existing copy keeps the original base name in the title', function () { + $user = User::factory()->create(); + $copy = DisplayVersion::factory()->create(['name' => 'Kopie 1 - Angebote Schauraum']); + + Livewire::actingAs($user) + ->test(DisplayVersionList::class) + ->call('copyVersion', $copy->id); + + expect(DisplayVersion::where('name', 'Kopie 2 - Angebote Schauraum')->exists())->toBeTrue(); +}); + // ======================================== // DisplayVersionEditor // ======================================== @@ -446,6 +508,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']); @@ -644,6 +756,53 @@ test('selecting an image medium auto-sets the media type to image', function () ->assertSet('mediaType', 'image'); }); +test('b2in media editor shows a matching portrait format badge', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + $media = DisplayMedia::factory()->create([ + 'type' => 'image', + 'metadata' => ['width' => 1080, 'height' => 1920], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'media') + ->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl()) + ->assertSee('Format passt') + ->assertSee('1080x1920 px'); +}); + +test('b2in media editor warns for landscape media', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + $media = DisplayMedia::factory()->create([ + 'type' => 'image', + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'media') + ->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl()) + ->assertSee('Format passt nicht') + ->assertSee('1920x1080 px'); +}); + +test('video display editor warns for non portrait video media', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'video-display']); + $media = DisplayMedia::factory()->video()->create([ + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'video') + ->call('onDisplayMediaSelected', 'videoFilename', $media->id, $media->getUrl()) + ->assertSee('Format passt nicht') + ->assertSee('1920x1080 px'); +}); + test('media item modal offers the sonstiges category and an automatic type hint', function () { $user = User::factory()->create(); $version = DisplayVersion::factory()->create(['type' => 'b2in']); 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); +}); diff --git a/tests/Unit/DisplayModuleSettingsTest.php b/tests/Unit/DisplayModuleSettingsTest.php new file mode 100644 index 00000000..0094a56c --- /dev/null +++ b/tests/Unit/DisplayModuleSettingsTest.php @@ -0,0 +1,28 @@ + 1, + 'show_claim' => 0, + 'show_footer' => 'true', + 'display_active' => 'false', + ]); + + expect($merged['show_logo'])->toBeTrue() + ->and($merged['show_claim'])->toBeFalse() + ->and($merged['show_footer'])->toBeTrue() + ->and($merged['display_active'])->toBeFalse(); +}); + +test('merge normalizes offers loop boolean', function () { + $merged = DisplayModuleSettings::merge(DisplayVersionType::Offers, [ + 'loop' => 0, + ]); + + expect($merged['loop'])->toBeFalse(); +});