Compare commits

...

5 commits

Author SHA1 Message Date
193d37b1ce Display CMS: Upload-Fehler abfangen und kaputte Bilder im Player überspringen.
Mediathek-Uploads legen keinen Datensatz mehr an, wenn der Datei-Write
fehlschlägt (verhindert 404-Einträge auf den Displays). Der Player
überspringt Bilder mit Ladefehler nach 2s statt Schwarzbild zu zeigen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 08:57:31 +00:00
2a34adc515 QuickStatus: Hinweistext beim Wechsel auf Auto beibehalten.
Gespeicherte Headline und Subtext bleiben erhalten und stehen beim erneuten Umschalten auf Hinweis wieder zur Verfügung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 08:27:44 +00:00
7491d20d39 Display CMS: Footer-Verlauf, Format-Hinweise und Switch-Fixes.
Blendet den B2IN-Text-Verlauf ohne Footer aus, zeigt 9:16-Format-Badges in der Mediathek, normalisiert Modul-Booleans und korrigiert Flux-Switches im Dark Mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 08:27:44 +00:00
6c2da9bdac Display-Module in der Modulliste duplizieren.
Ermöglicht das Kopieren inklusive aller Inhalte und Einstellungen mit fortlaufender Benennung als „Kopie N - …“.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 08:24:40 +00:00
bebfa63e87 Display CMS Optimierungen 03-06-2026 2026-06-03 15:43:18 +02:00
29 changed files with 1633 additions and 210 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
/public/hot
/public/storage
/public/vendor
/public/flux
/storage/*.key
/storage/app
/storage/framework

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use RuntimeException;
class PublishFluxAssets extends Command
{
protected $signature = 'flux:publish-assets {--force : Overwrite existing published Flux assets}';
protected $description = 'Publish Flux JavaScript assets to public/flux for static delivery (like livewire:publish --assets)';
public function handle(): int
{
$sourceDir = $this->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(" <comment>•</comment> {$file} (unverändert)");
continue;
}
File::copy($source, $target);
$this->line(" <info>✓</info> {$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.');
}
}

View file

@ -69,9 +69,16 @@ class DisplayMediaPicker extends Component
$service = app(DisplayMediaService::class);
$lastMedia = null;
try {
foreach ($this->quickUploads as $file) {
$lastMedia = $service->storeUpload($file);
}
} catch (\RuntimeException $exception) {
$this->quickUploads = [];
$this->addError('quickUploads', $exception->getMessage());
return;
}
$this->quickUploads = [];

View file

@ -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<string, mixed> $content
* @return array<string, mixed>
*/
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<string, mixed> $settings
* @return array<string, mixed>
*/
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<string, mixed>
*/

View file

@ -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<string, mixed>
*/

View file

@ -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,

View file

@ -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
// ========================================

View file

@ -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('/^(?<width>\d+)x(?<height>\d+)$/', $dimensions, $matches)) {
return null;
}
return [
'width' => (int) $matches['width'],
'height' => (int) $matches['height'],
];
}
/**
* Create a DisplayMedia record from an external URL.
*/

View file

@ -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'] ?? '',

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
class DisplayQrCodeService
{
/**
* @return array{path: string, url: string}|null
*/
public function generateForUrl(?string $url): ?array
{
$normalizedUrl = $this->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,
]);
}
}

View file

@ -6,6 +6,14 @@ use App\Enums\DisplayVersionType;
class DisplayModuleSettings
{
/** @var list<string> */
private const B2IN_BOOLEAN_KEYS = [
'show_logo',
'show_claim',
'show_footer',
'display_active',
];
/**
* @return array<string, mixed>
*/
@ -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<string, mixed> $settings
* @return array<string, mixed>
*/
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;
}
}

View file

@ -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');\""
],

View file

@ -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`*

View file

@ -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();
return;
}
} else {
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 {
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
: '';
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}
</div>
<div class="b2in-footer-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
<img alt="QR">
</div>
</footer>`
: '';
@ -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,13 +1712,25 @@ 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
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';
@ -1663,9 +1816,10 @@ class OffersRenderer {
}
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) {

View file

@ -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 */

View file

@ -11,12 +11,23 @@
<div class="flex-1">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Info-Tablet Steuerung') }}</h3>
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.</p>
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.</p>
<p>• <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code></p>
<p>• <strong>Website-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
<p>• <strong>Quick-Status-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code></p>
<p>• <strong>Quick-Status-Hinweis:</strong> 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.</p>
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien
Termin.</p>
<p>• <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten
Öffnungszeiten berechnet.</p>
<p>• <strong>API-Endpunkt:</strong> <code
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code>
</p>
<p>• <strong>Website-Endpunkt:</strong> <code
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
<p>• <strong>Quick-Status-Endpunkt:</strong> <code
class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank"
href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code>
</p>
<p>• <strong>Quick-Status-Hinweis:</strong> 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.</p>
</div>
</div>
</div>
@ -29,8 +40,7 @@
</x-success-alert>
@endif
<form wire:submit.prevent="save"
x-data
<form wire:submit.prevent="save" x-data
x-on:submit.prevent="
const hours = {};
document.querySelectorAll('[data-hours-prop]').forEach(el => {
@ -54,20 +64,28 @@
<option value="closed">Manuell geschlossen (Gelb)</option>
</flux:select>
@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
<div class="space-y-4 p-4 rounded-lg {{ $noticeColors }}">
<flux:input wire:model="noticeHeadline" label="Headline" placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
@error('noticeHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="noticeHeadline" label="Headline"
placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
@error('noticeHeadline')
<span class="text-red-600 text-sm">{{ $message }}</span>
@enderror
<flux:input wire:model="noticeSubtext" label="Subtext (optional)" placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
@error('noticeSubtext') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="noticeSubtext" label="Subtext (optional)"
placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
@error('noticeSubtext')
<span class="text-red-600 text-sm">{{ $message }}</span>
@enderror
</div>
@endif
</div>
@ -76,30 +94,28 @@
{{-- Öffnungszeiten --}}
<flux:card>
<flux:heading size="lg" class="mb-1">{{ __('Öffnungszeiten') }}</flux:heading>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status
"Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
<div class="space-y-3">
@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
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
<div
class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
<span
class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
<div data-hours-prop="{{ $row['open'] }}">
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="–" />
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung"
time-format="24-hour" interval="15" clearable placeholder="–" />
</div>
<div data-hours-prop="{{ $row['close'] }}">
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss"
time-format="24-hour" interval="15" clearable placeholder="–" />
</div>
<span class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
<span
class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
</span>
</div>
@ -111,7 +127,7 @@
<flux:card>
<div class="flex items-center justify-between mb-4">
<flux:heading size="lg">{{ __('Sonderöffnung heute') }}</flux:heading>
@if($overrideOpenToday || $overrideCloseToday)
@if ($overrideOpenToday || $overrideCloseToday)
<flux:button wire:click="clearOverrides" size="sm" variant="ghost" icon="x-mark">
{{ __('Zurücksetzen') }}
</flux:button>
@ -126,7 +142,7 @@
<flux:time-picker wire:model="overrideCloseToday" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="–" />
@error('overrideCloseToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
</flux:card> --}}
{{-- Nächster Termin
<flux:card>
@ -139,7 +155,7 @@
<flux:time-picker wire:model="nextAppointmentTime" label="Uhrzeit" time-format="24-hour" interval="15" clearable placeholder="–" />
@error('nextAppointmentTime') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
</flux:card> --}}
{{-- Kontakt --}}
<flux:card>
@ -147,10 +163,15 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:input wire:model="contactPhone" label="Telefon" placeholder="z.B. 0521 98620100" />
@error('contactPhone') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
@error('contactPhone')
<span class="text-red-600 text-sm">{{ $message }}</span>
@enderror
<flux:input wire:model="contactEmail" label="E-Mail" type="email" placeholder="z.B. info@cabinet-bielefeld.de" />
@error('contactEmail') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="contactEmail" label="E-Mail" type="email"
placeholder="z.B. info@cabinet-bielefeld.de" />
@error('contactEmail')
<span class="text-red-600 text-sm">{{ $message }}</span>
@enderror
</div>
</flux:card>

View file

@ -88,10 +88,17 @@ $handleUploads = function () {
$service = app(DisplayMediaService::class);
$count = 0;
try {
foreach ($this->uploads as $file) {
$service->storeUpload($file);
$count++;
}
} catch (\RuntimeException $exception) {
$this->uploads = [];
$this->addError('uploads', $exception->getMessage());
return;
}
$this->uploads = [];

View file

@ -126,6 +126,16 @@
<flux:input wire:model.live.debounce.500ms="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." class="flex-1" />
</div>
@if($formatStatus = $this->displayMediaFormatStatus($videoFilename))
<div class="flex flex-wrap items-center gap-2">
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $formatStatus['description'] }}
</flux:text>
</div>
@endif
<flux:input wire:model="videoTitle" label="Name" placeholder="z.B. Herbst Kollektion 2025"
description="Interner Name des Videos – wird nicht im Video eingeblendet." />
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
@ -160,8 +170,18 @@
<flux:badge size="sm" :color="$mediaType === 'video' ? 'purple' : 'sky'">
{{ $mediaType === 'video' ? __('Video') : __('Bild') }}
</flux:badge>
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
@endif
<flux:text class="text-xs text-zinc-400">{{ __('wird automatisch aus dem gewählten Medium erkannt') }}</flux:text>
</div>
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $formatStatus['description'] }}
</flux:text>
@endif
<flux:select wire:model="mediaCategory" label="Kategorie">
<option value="immobilien">Immobilien</option>
<option value="moebel">Möbel</option>

View file

@ -79,6 +79,14 @@
icon="pencil">
</flux:button>
<flux:button wire:click="copyVersion({{ $version->id }})"
wire:confirm="Möchten Sie dieses Modul wirklich kopieren? Es wird eine Kopie mit allen Inhalten angelegt."
size="sm"
variant="ghost"
icon="document-duplicate"
title="{{ __('Modul kopieren') }}">
</flux:button>
<flux:button wire:click="deleteVersion({{ $version->id }})"
wire:confirm="Möchten Sie dieses Modul wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
size="sm"

View file

@ -52,6 +52,11 @@
<flux:badge color="{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'purple' : 'sky' }}" size="sm">
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
</flux:badge>
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
@endif
<flux:badge color="zinc" size="sm">
{{ ucfirst($item->content['category'] ?? '–') }}
</flux:badge>
@ -62,6 +67,9 @@
@if(($item->content['media_type'] ?? 'image') === 'image')
<span>{{ $item->content['duration_seconds'] ?? 10 }}s</span>
@endif
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
<span>{{ $formatStatus['description'] }}</span>
@endif
</div>
</div>

View file

@ -27,13 +27,13 @@
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
<div class="flex items-end gap-3">
<x-media-thumb :url="$settings['header_logo_url'] ?? ''" />
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="../assets/b2in-logo-positive.svg" class="flex-1" />
<flux:input wire:model.live.debounce.500ms="settings.header_logo_url" label="Logo URL" placeholder="/_cabinet/assets/b2in-logo-positive.svg" class="flex-1" />
</div>
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
<div class="flex flex-wrap gap-6">
<flux:switch wire:model.live="settings.show_logo" label="Logo anzeigen" />
<flux:switch wire:model.live="settings.show_claim" label="Claim anzeigen" />
<flux:switch wire:model.live.boolean="settings.show_logo" label="Logo anzeigen" />
<flux:switch wire:model.live.boolean="settings.show_claim" label="Claim anzeigen" />
</div>
@if($showLogo)
@ -63,7 +63,7 @@
</div>
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Footer & QR') }}</flux:heading>
<flux:switch wire:model.live="settings.show_footer" label="Footer anzeigen"
<flux:switch wire:model.live.boolean="settings.show_footer" label="Footer anzeigen"
description="Blendet die Fußzeile mit Domain, Name und QR-Code ein oder aus." />
<flux:input wire:model="settings.footer_prefix" label="Footer-Präfix" placeholder="by" />
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
@ -78,7 +78,7 @@
</flux:select>
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
<flux:checkbox wire:model.boolean="settings.display_active" label="Display aktiv" />
@elseif($version->type->value === 'offers')
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
<flux:select wire:model="settings.transition.type" label="Transition">

View file

@ -58,6 +58,12 @@
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
</flux:badge>
@if ($formatStatus = $this->displayMediaFormatStatus($videoSource))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
<span>{{ $formatStatus['description'] }}</span>
@endif
<span class="truncate">{{ $videoSource ?: '–' }}</span>
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
</div>

View file

@ -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 () {

View file

@ -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');

View file

@ -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 ([
foreach (
[
'/preview/fresh-token',
"/preview/module/{$version->id}",
] as $url) {
] as $url
) {
$response = $this->get($url);
$response->assertSuccessful();
expect($response->headers->get('Cache-Control'))->toContain('no-cache');

View file

@ -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']);

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\URL;
beforeEach(function () {
URL::forceRootUrl('https://'.config('domains.domain_portal'));
});
test('portal domain serves flux javascript from laravel route', function () {
$this->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');
});

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\File;
test('flux publish assets command copies javascript into public flux directory', function () {
$targetDir = public_path('flux');
if (File::isDirectory($targetDir)) {
File::deleteDirectory($targetDir);
}
$this->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);
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Enums\DisplayVersionType;
use App\Support\DisplayModuleSettings;
test('merge normalizes b2in boolean settings from legacy scalar values', function () {
$merged = DisplayModuleSettings::merge(DisplayVersionType::B2in, [
'show_logo' => 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();
});