Display CMS Optimierungen 29-05-2026

- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges"
- B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern
- B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter
- Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option
- Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header
- Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-05-29 15:57:33 +00:00
parent 9262132325
commit 6c6d683b9a
42 changed files with 2267 additions and 13905 deletions

View file

@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Display-Player URL (Basis-URL der Player-Seite ohne ?id=).
# Live: https://cabinet.b2in.eu/display
# Lokal: https://portal.b2in.test/_cabinet/display (Player-Seite + API laufen auf der Portal-Domain)
# Ohne Wert greift der Produktions-Fallback aus config/display.php.
DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
# Cookie Consent & Google Analytics (acme/cookie-consent) # Cookie Consent & Google Analytics (acme/cookie-consent)
# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX # GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\DisplayMedia;
use App\Services\DisplayMediaService;
use Illuminate\Console\Command;
class GenerateVideoThumbnails extends Command
{
protected $signature = 'display-media:generate-video-thumbnails
{--force : Regenerate posters even if a thumbnail already exists}';
protected $description = 'Generate poster frames for uploaded display media videos using ffmpeg';
public function handle(DisplayMediaService $service): int
{
$query = DisplayMedia::query()
->where('type', 'video')
->where('source_type', 'upload');
if (! $this->option('force')) {
$query->whereNull('thumbnail_path');
}
$videos = $query->get();
if ($videos->isEmpty()) {
$this->info('Keine Videos zum Verarbeiten gefunden.');
return self::SUCCESS;
}
$this->info(sprintf('%d Video(s) werden verarbeitet...', $videos->count()));
$generated = 0;
$failed = 0;
foreach ($videos as $video) {
$thumbnailPath = $service->generateVideoThumbnail($video);
if ($thumbnailPath !== null) {
$video->update(['thumbnail_path' => $thumbnailPath]);
$this->line(" <info>✓</info> {$video->getDisplayName()}");
$generated++;
} else {
$this->warn("{$video->getDisplayName()} (Poster konnte nicht erzeugt werden)");
$failed++;
}
}
$this->newLine();
$this->info(sprintf('Fertig: %d erzeugt, %d fehlgeschlagen.', $generated, $failed));
return self::SUCCESS;
}
}

View file

@ -16,7 +16,9 @@ class DisplayPreviewController extends Controller
->where('preview_token', $token) ->where('preview_token', $token)
->firstOrFail(); ->firstOrFail();
return response()->file(public_path('_cabinet/display/index.html')); return response()->file(public_path('_cabinet/display/index.html'), [
'Cache-Control' => 'no-cache, must-revalidate',
]);
} }
public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse

View file

@ -13,14 +13,21 @@ class ModulePreviewController extends Controller
{ {
public function show(DisplayVersion $module): BinaryFileResponse public function show(DisplayVersion $module): BinaryFileResponse
{ {
return response()->file(public_path('_cabinet/display/index.html')); return $this->playerResponse();
} }
public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse
{ {
abort_unless($item->display_version_id === $module->id, 404); abort_unless($item->display_version_id === $module->id, 404);
return response()->file(public_path('_cabinet/display/index.html')); return $this->playerResponse();
}
private function playerResponse(): BinaryFileResponse
{
return response()->file(public_path('_cabinet/display/index.html'), [
'Cache-Control' => 'no-cache, must-revalidate',
]);
} }
public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse

View file

@ -30,6 +30,9 @@ class DisplayList extends Component
public $addVersionSelect = null; public $addVersionSelect = null;
/** @var array<int> */
public $versionsToAdd = [];
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
public ?string $draftPreviewToken = null; public ?string $draftPreviewToken = null;
@ -75,6 +78,20 @@ class DisplayList extends Component
$this->persistDraftPreviewIfNeeded(); $this->persistDraftPreviewIfNeeded();
} }
public function addSelectedVersions(): void
{
foreach ($this->versionsToAdd as $versionId) {
$id = (int) $versionId;
if ($id && ! in_array($id, $this->selectedVersionIds, true)) {
$this->selectedVersionIds[] = $id;
}
}
$this->versionsToAdd = [];
$this->persistDraftPreviewIfNeeded();
}
private function firstAvailableVersionId(): ?int private function firstAvailableVersionId(): ?int
{ {
return DisplayVersion::active() return DisplayVersion::active()
@ -175,10 +192,31 @@ class DisplayList extends Component
} }
$display->draftPlaylist->delete(); $display->draftPlaylist->delete();
$display->clearPreviewToken();
session()->flash('success', 'Entwurf wurde verworfen.'); session()->flash('success', 'Entwurf wurde verworfen.');
} }
public function rotatePreviewToken(int $displayId): void
{
$display = Display::with('draftPlaylist')->findOrFail($displayId);
if (! $display->draftPlaylist) {
session()->flash('success', 'Für dieses Display gibt es keinen Entwurf.');
return;
}
$display->rotatePreviewToken();
if ($this->displayId === $display->id && $this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) {
$this->draftPreviewToken = $display->preview_token;
$this->previewFrameRefreshCounter++;
}
session()->flash('success', 'Vorschau-Link wurde neu erzeugt. Der alte Link ist jetzt ungültig.');
}
public function publishDraft(int $displayId): void public function publishDraft(int $displayId): void
{ {
$display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId); $display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId);
@ -201,6 +239,8 @@ class DisplayList extends Component
return $display->draftPlaylist->fresh('modules'); return $display->draftPlaylist->fresh('modules');
}); });
$display->clearPreviewToken();
session()->flash('success', 'Entwurf wurde veröffentlicht.'); session()->flash('success', 'Entwurf wurde veröffentlicht.');
} }
@ -314,6 +354,7 @@ class DisplayList extends Component
$this->displayIsActive = true; $this->displayIsActive = true;
$this->displayIsTest = false; $this->displayIsTest = false;
$this->addVersionSelect = null; $this->addVersionSelect = null;
$this->versionsToAdd = [];
$this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; $this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
$this->draftPreviewToken = null; $this->draftPreviewToken = null;
$this->previewFrameRefreshCounter = 0; $this->previewFrameRefreshCounter = 0;

View file

@ -117,8 +117,7 @@ class DisplayMediaPicker extends Component
->active() ->active()
->when($this->type === 'image', fn ($q) => $q->images()) ->when($this->type === 'image', fn ($q) => $q->images())
->when($this->type === 'video', fn ($q) => $q->videos()) ->when($this->type === 'video', fn ($q) => $q->videos())
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") ->when($this->search, fn ($q) => $q->search($this->search))
->orWhere('title', 'like', "%{$this->search}%"))
->orderByDesc('created_at') ->orderByDesc('created_at')
->paginate(18); ->paginate(18);
} }

View file

@ -3,10 +3,10 @@
namespace App\Livewire\Admin\Cms; namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType; use App\Enums\DisplayVersionType;
use App\Models\DisplayMedia;
use App\Models\DisplayVersion; use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem; use App\Models\DisplayVersionItem;
use App\Support\DisplayModuleSettings; use App\Support\DisplayModuleSettings;
use Illuminate\Support\Facades\File;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
@ -56,8 +56,12 @@ class DisplayVersionEditor extends Component
public bool $mediaIsActive = true; public bool $mediaIsActive = true;
// Offers: Slide fields // Offers: Slide fields (single dynamic detail layout)
public string $slideType = 'product-hero'; public bool $slideShowLogo = true;
public string $slideLogoUrl = '';
public string $slideBrandText = '';
public int $slideDuration = 8000; public int $slideDuration = 8000;
@ -65,30 +69,46 @@ class DisplayVersionEditor extends Component
public string $slideBadge = ''; public string $slideBadge = '';
public bool $slideShowBadge = true;
public string $slideEyebrow = ''; public string $slideEyebrow = '';
public bool $slideShowEyebrow = true;
public string $slideTitle = ''; public string $slideTitle = '';
public string $slideSubline = ''; public string $slideSubline = '';
public bool $slideShowSubline = false;
public string $slidePrice = ''; public string $slidePrice = '';
public string $slideOriginalPrice = ''; public string $slideOriginalPrice = '';
public bool $slideStrikeOriginalPrice = false;
public string $slideTagText = ''; public string $slideTagText = '';
public bool $slideShowPrice = false;
/** @var array<string> */ /** @var array<string> */
public array $slideBullets = []; public array $slideBullets = [];
public bool $slideShowBullets = true;
public string $slideDisclaimer = ''; public string $slideDisclaimer = '';
public bool $slideShowDisclaimer = false;
public string $slideQrUrl = ''; public string $slideQrUrl = '';
public string $slideQrTitle = ''; public string $slideQrTitle = '';
public bool $slideShowQr = true;
public string $slideContact = ''; public string $slideContact = '';
public bool $slideShowBrandText = false; public bool $slideShowContact = true;
public string $slideBrandTagline = ''; public string $slideBrandTagline = '';
@ -99,32 +119,25 @@ class DisplayVersionEditor extends Component
public array $settings = []; public array $settings = [];
/** @var array<string> */
public array $availableVideos = [];
public int $previewFrameRefreshCounter = 0; public int $previewFrameRefreshCounter = 0;
/** @var array<string> */
public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
public function mount(DisplayVersion $displayVersion): void public function mount(DisplayVersion $displayVersion): void
{ {
$this->version = $displayVersion; $this->version = $displayVersion;
$this->versionName = $displayVersion->name; $this->versionName = $displayVersion->name;
$this->settings = $this->settingsWithDefaults(); $this->settings = $this->settingsWithDefaults();
$this->normalizeBrandPositions();
if ($this->version->type === DisplayVersionType::VideoDisplay) {
$this->loadAvailableVideos();
}
} }
public function loadAvailableVideos(): void public function updated(string $name): void
{ {
$assetsPath = public_path('_cabinet/assets'); if (str_starts_with($name, 'settings.show_footer')
|| str_starts_with($name, 'settings.logo_position')
if (File::exists($assetsPath)) { || str_starts_with($name, 'settings.claim_position')) {
$this->availableVideos = collect(File::files($assetsPath)) $this->normalizeBrandPositions();
->map(fn ($file) => $file->getFilename())
->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
->values()
->toArray();
} }
} }
@ -160,12 +173,56 @@ class DisplayVersionEditor extends Component
public function saveSettings(): void public function saveSettings(): void
{ {
$this->normalizeBrandPositions();
$this->version->update(['settings' => $this->settings]); $this->version->update(['settings' => $this->settings]);
$this->showSettingsModal = false; $this->showSettingsModal = false;
$this->refreshModulePreview(); $this->refreshModulePreview();
session()->flash('success', 'Einstellungen gespeichert!'); session()->flash('success', 'Einstellungen gespeichert!');
} }
/**
* Keep the B2in logo/claim corners consistent:
* - bottom corners are only valid while the footer is hidden,
* - the claim can never sit in the same corner as the logo.
*/
private function normalizeBrandPositions(): void
{
if ($this->version->type !== DisplayVersionType::B2in) {
return;
}
$footerShown = ($this->settings['show_footer'] ?? true) !== false;
$allowed = $footerShown ? ['top-left', 'top-right'] : self::BRAND_POSITIONS;
$logo = $this->settings['logo_position'] ?? 'top-left';
$claim = $this->settings['claim_position'] ?? 'top-right';
$logo = $this->moveIntoAllowed($logo, $allowed);
$claim = $this->moveIntoAllowed($claim, $allowed);
if ($claim === $logo) {
$claim = collect($allowed)->first(fn (string $position) => $position !== $logo) ?? $claim;
}
$this->settings['logo_position'] = $logo;
$this->settings['claim_position'] = $claim;
}
/**
* @param array<string> $allowed
*/
private function moveIntoAllowed(string $position, array $allowed): string
{
if (in_array($position, $allowed, true)) {
return $position;
}
// Pull bottom corners up to the matching top corner when forbidden.
$fallback = str_replace('bottom-', 'top-', $position);
return in_array($fallback, $allowed, true) ? $fallback : ($allowed[0] ?? 'top-left');
}
// ======================================== // ========================================
// ITEM CRUD // ITEM CRUD
// ======================================== // ========================================
@ -276,10 +333,20 @@ class DisplayVersionEditor extends Component
'videoFilename' => $this->videoFilename = $url, 'videoFilename' => $this->videoFilename = $url,
'mediaUrl' => $this->mediaUrl = $url, 'mediaUrl' => $this->mediaUrl = $url,
'slideImageUrl' => $this->slideImageUrl = $url, 'slideImageUrl' => $this->slideImageUrl = $url,
'slideLogoUrl' => $this->slideLogoUrl = $url,
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url, 'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
'settings.logo_url' => $this->settings['logo_url'] = $url,
default => null, default => null,
}; };
// The media type for a B2in playlist item is derived from the chosen
// media the type selector in the form is only an informational hint.
if ($field === 'mediaUrl' && $mediaId) {
$media = DisplayMedia::find($mediaId);
if ($media) {
$this->mediaType = $media->isVideo() ? 'video' : 'image';
}
}
} }
public function addBullet(): void public function addBullet(): void
@ -306,33 +373,43 @@ class DisplayVersionEditor extends Component
private function loadItemContent(DisplayVersionItem $item): void private function loadItemContent(DisplayVersionItem $item): void
{ {
$content = $item->content; $content = $item->content;
$isActive = (bool) $item->is_active;
match ($item->item_type) { match ($item->item_type) {
'video' => $this->loadVideoContent($content), 'video' => $this->loadVideoContent($content, $isActive),
'footer' => $this->loadFooterContent($content), 'footer' => $this->loadFooterContent($content, $isActive),
'media' => $this->loadMediaContent($content), 'media' => $this->loadMediaContent($content, $isActive),
'slide' => $this->loadSlideContent($content), 'slide' => $this->loadSlideContent($content, $isActive),
default => null, default => null,
}; };
} }
private function loadVideoContent(array $content): void /**
* @param array<string, mixed> $content
*/
private function loadVideoContent(array $content, bool $isActive): void
{ {
$this->videoFilename = $content['filename'] ?? ''; $this->videoFilename = $content['filename'] ?? '';
$this->videoTitle = $content['title'] ?? ''; $this->videoTitle = $content['title'] ?? '';
$this->videoPosition = $content['position'] ?? 25; $this->videoPosition = $content['position'] ?? 25;
$this->videoIsActive = true; $this->videoIsActive = $isActive;
} }
private function loadFooterContent(array $content): void /**
* @param array<string, mixed> $content
*/
private function loadFooterContent(array $content, bool $isActive): void
{ {
$this->footerHeadline = $content['headline'] ?? ''; $this->footerHeadline = $content['headline'] ?? '';
$this->footerSubline = $content['subline'] ?? ''; $this->footerSubline = $content['subline'] ?? '';
$this->footerUrl = $content['url'] ?? ''; $this->footerUrl = $content['url'] ?? '';
$this->footerIsActive = true; $this->footerIsActive = $isActive;
} }
private function loadMediaContent(array $content): void /**
* @param array<string, mixed> $content
*/
private function loadMediaContent(array $content, bool $isActive): void
{ {
$this->mediaType = $content['media_type'] ?? 'image'; $this->mediaType = $content['media_type'] ?? 'image';
$this->mediaCategory = $content['category'] ?? 'immobilien'; $this->mediaCategory = $content['category'] ?? 'immobilien';
@ -340,12 +417,17 @@ class DisplayVersionEditor extends Component
$this->mediaHeadline = $content['headline'] ?? ''; $this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? ''; $this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = $content['duration_seconds'] ?? 10; $this->mediaDuration = $content['duration_seconds'] ?? 10;
$this->mediaIsActive = true; $this->mediaIsActive = $isActive;
} }
private function loadSlideContent(array $content): void /**
* @param array<string, mixed> $content
*/
private function loadSlideContent(array $content, bool $isActive): void
{ {
$this->slideType = $content['type'] ?? 'product-hero'; $this->slideShowLogo = $content['show_logo'] ?? true;
$this->slideLogoUrl = $content['logo_url'] ?? '';
$this->slideBrandText = $content['brand_text'] ?? '';
$this->slideDuration = $content['duration'] ?? 8000; $this->slideDuration = $content['duration'] ?? 8000;
$this->slideImageUrl = $content['image_url'] ?? ''; $this->slideImageUrl = $content['image_url'] ?? '';
$this->slideBadge = $content['badge_text'] ?? ''; $this->slideBadge = $content['badge_text'] ?? '';
@ -354,15 +436,26 @@ class DisplayVersionEditor extends Component
$this->slideSubline = $content['subline'] ?? ''; $this->slideSubline = $content['subline'] ?? '';
$this->slidePrice = $content['price'] ?? ''; $this->slidePrice = $content['price'] ?? '';
$this->slideOriginalPrice = $content['original_price'] ?? ''; $this->slideOriginalPrice = $content['original_price'] ?? '';
$this->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false;
$this->slideTagText = $content['tag_text'] ?? ''; $this->slideTagText = $content['tag_text'] ?? '';
$this->slideBullets = $content['bullets'] ?? []; $this->slideBullets = $content['bullets'] ?? [];
$this->slideDisclaimer = $content['disclaimer'] ?? ''; $this->slideDisclaimer = $content['disclaimer'] ?? '';
$this->slideQrUrl = $content['qr_url'] ?? ''; $this->slideQrUrl = $content['qr_url'] ?? '';
$this->slideQrTitle = $content['qr_title'] ?? ''; $this->slideQrTitle = $content['qr_title'] ?? '';
$this->slideContact = $content['contact'] ?? ''; $this->slideContact = $content['contact'] ?? '';
$this->slideShowBrandText = $content['show_brand_text'] ?? false;
$this->slideBrandTagline = $content['brand_tagline'] ?? ''; $this->slideBrandTagline = $content['brand_tagline'] ?? '';
$this->slideIsActive = true; $this->slideIsActive = $isActive;
// Show flags fall back to "is there content?" so slides created before
// the dynamic detail layout keep rendering exactly as they did.
$this->slideShowBadge = $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '';
$this->slideShowEyebrow = $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '';
$this->slideShowSubline = $content['show_subline'] ?? ($content['subline'] ?? '') !== '';
$this->slideShowBullets = $content['show_bullets'] ?? ! empty($content['bullets']);
$this->slideShowPrice = $content['show_price'] ?? ($content['price'] ?? '') !== '';
$this->slideShowDisclaimer = $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '';
$this->slideShowQr = $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '';
$this->slideShowContact = $content['show_contact'] ?? ($content['contact'] ?? '') !== '';
} }
/** /**
@ -390,22 +483,33 @@ class DisplayVersionEditor extends Component
'duration_seconds' => $this->mediaDuration, 'duration_seconds' => $this->mediaDuration,
], ],
'slide' => [ 'slide' => [
'type' => $this->slideType, 'type' => 'detail',
'show_logo' => $this->slideShowLogo,
'logo_url' => $this->slideLogoUrl,
'brand_text' => $this->slideBrandText,
'duration' => $this->slideDuration, 'duration' => $this->slideDuration,
'image_url' => $this->slideImageUrl, 'image_url' => $this->slideImageUrl,
'badge_text' => $this->slideBadge, 'badge_text' => $this->slideBadge,
'show_badge' => $this->slideShowBadge,
'eyebrow' => $this->slideEyebrow, 'eyebrow' => $this->slideEyebrow,
'show_eyebrow' => $this->slideShowEyebrow,
'title' => $this->slideTitle, 'title' => $this->slideTitle,
'subline' => $this->slideSubline, 'subline' => $this->slideSubline,
'show_subline' => $this->slideShowSubline,
'price' => $this->slidePrice, 'price' => $this->slidePrice,
'original_price' => $this->slideOriginalPrice, 'original_price' => $this->slideOriginalPrice,
'strike_original_price' => $this->slideStrikeOriginalPrice,
'tag_text' => $this->slideTagText, 'tag_text' => $this->slideTagText,
'bullets' => $this->slideBullets, 'show_price' => $this->slideShowPrice,
'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')),
'show_bullets' => $this->slideShowBullets,
'disclaimer' => $this->slideDisclaimer, 'disclaimer' => $this->slideDisclaimer,
'show_disclaimer' => $this->slideShowDisclaimer,
'qr_url' => $this->slideQrUrl, 'qr_url' => $this->slideQrUrl,
'qr_title' => $this->slideQrTitle, 'qr_title' => $this->slideQrTitle,
'show_qr' => $this->slideShowQr,
'contact' => $this->slideContact, 'contact' => $this->slideContact,
'show_brand_text' => $this->slideShowBrandText, 'show_contact' => $this->slideShowContact,
'brand_tagline' => $this->slideBrandTagline, 'brand_tagline' => $this->slideBrandTagline,
], ],
default => [], default => [],
@ -451,22 +555,32 @@ class DisplayVersionEditor extends Component
$this->mediaSubline = ''; $this->mediaSubline = '';
$this->mediaDuration = 10; $this->mediaDuration = 10;
$this->mediaIsActive = true; $this->mediaIsActive = true;
$this->slideType = 'product-hero'; $this->slideShowLogo = true;
$this->slideLogoUrl = '';
$this->slideBrandText = '';
$this->slideDuration = 8000; $this->slideDuration = 8000;
$this->slideImageUrl = ''; $this->slideImageUrl = '';
$this->slideBadge = ''; $this->slideBadge = '';
$this->slideShowBadge = true;
$this->slideEyebrow = ''; $this->slideEyebrow = '';
$this->slideShowEyebrow = true;
$this->slideTitle = ''; $this->slideTitle = '';
$this->slideSubline = ''; $this->slideSubline = '';
$this->slideShowSubline = false;
$this->slidePrice = ''; $this->slidePrice = '';
$this->slideOriginalPrice = ''; $this->slideOriginalPrice = '';
$this->slideStrikeOriginalPrice = false;
$this->slideTagText = ''; $this->slideTagText = '';
$this->slideShowPrice = false;
$this->slideBullets = []; $this->slideBullets = [];
$this->slideShowBullets = true;
$this->slideDisclaimer = ''; $this->slideDisclaimer = '';
$this->slideShowDisclaimer = false;
$this->slideQrUrl = ''; $this->slideQrUrl = '';
$this->slideQrTitle = ''; $this->slideQrTitle = '';
$this->slideShowQr = true;
$this->slideContact = ''; $this->slideContact = '';
$this->slideShowBrandText = false; $this->slideShowContact = true;
$this->slideBrandTagline = ''; $this->slideBrandTagline = '';
$this->slideIsActive = true; $this->slideIsActive = true;
} }

View file

@ -54,7 +54,20 @@ class DisplayVersionList extends Component
public function deleteVersion(int $id): void public function deleteVersion(int $id): void
{ {
$version = DisplayVersion::findOrFail($id); $version = DisplayVersion::query()
->withCount([
'playlistItems as displays_count' => fn ($query) => $query
->join('display_playlists', 'display_playlist_items.display_playlist_id', '=', 'display_playlists.id')
->select(DB::raw('count(distinct display_playlists.display_id)')),
])
->findOrFail($id);
if ($version->displays_count > 0) {
session()->flash('error', 'Modul "'.$version->name.'" wird noch von '.$version->displays_count.' Display(s) verwendet und kann nicht gelöscht werden. Entfernen Sie es zuerst aus den betroffenen Bespielungen.');
return;
}
$name = $version->name; $name = $version->name;
$version->delete(); $version->delete();

View file

@ -45,10 +45,10 @@ class QuickStatus extends Component
], ],
]; ];
public function mount(): void public function mount(?string $k = null): void
{ {
$validKey = config('domains.cabinet_status_key'); $validKey = config('domains.cabinet_status_key');
$key = request()->get('key'); $key = $k ?? request()->query('k');
if (! $validKey || $key !== $validKey) { if (! $validKey || $key !== $validKey) {
$this->authorized = false; $this->authorized = false;
@ -92,6 +92,13 @@ class QuickStatus extends Component
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
]); ]);
$showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true);
if (! $showsNotice) {
$this->noticeHeadline = '';
$this->noticeSubtext = '';
}
CabinetTabletSetting::current()->update([ CabinetTabletSetting::current()->update([
'store_status' => $this->storeStatus, 'store_status' => $this->storeStatus,
'notice_headline' => $this->noticeHeadline ?: null, 'notice_headline' => $this->noticeHeadline ?: null,

View file

@ -64,4 +64,20 @@ class Display extends Model
return $this->preview_token; return $this->preview_token;
} }
public function rotatePreviewToken(): string
{
$this->preview_token = Str::random(40);
$this->save();
return $this->preview_token;
}
public function clearPreviewToken(): void
{
if ($this->preview_token !== null) {
$this->preview_token = null;
$this->save();
}
}
} }

View file

@ -150,4 +150,12 @@ class DisplayMedia extends Model
{ {
return $query->where('collection', $collection); return $query->where('collection', $collection);
} }
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where(function (Builder $query) use ($term): void {
$query->where('filename', 'like', "%{$term}%")
->orWhere('title', 'like', "%{$term}%");
});
}
} }

View file

@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
class DisplayMediaService class DisplayMediaService
{ {
@ -34,7 +35,7 @@ class DisplayMediaService
} }
} }
return DisplayMedia::create([ $media = DisplayMedia::create([
'filename' => $filename, 'filename' => $filename,
'disk' => 'public', 'disk' => 'public',
'path' => $relativePath, 'path' => $relativePath,
@ -45,6 +46,80 @@ class DisplayMediaService
'collection' => $collection, 'collection' => $collection,
'metadata' => ! empty($metadata) ? $metadata : null, 'metadata' => ! empty($metadata) ? $metadata : null,
]); ]);
if ($type === 'video') {
$thumbnailPath = $this->generateVideoThumbnail($media);
if ($thumbnailPath !== null) {
$media->update(['thumbnail_path' => $thumbnailPath]);
}
}
return $media;
}
/**
* Generate a poster frame for an uploaded video using ffmpeg.
*
* Returns the relative thumbnail path on the media's disk, or null when
* generation is not possible (e.g. ffmpeg missing or an unreadable file).
*/
public function generateVideoThumbnail(DisplayMedia $media): ?string
{
if (! $media->isVideo() || ! $media->isUpload() || ! $media->path) {
return null;
}
$disk = Storage::disk($media->disk);
$videoPath = $disk->path($media->path);
if (! is_file($videoPath)) {
return null;
}
$thumbnailRelativePath = preg_replace('/\.[^.\/]+$/', '', $media->path).'-poster.jpg';
$thumbnailPath = $disk->path($thumbnailRelativePath);
if (! is_dir(dirname($thumbnailPath))) {
@mkdir(dirname($thumbnailPath), 0755, true);
}
// Try a frame ~1s in first (avoids black intro frames); fall back to
// the very first frame for clips shorter than one second.
foreach (['1', '0'] as $seekSeconds) {
if ($this->extractFrame($videoPath, $thumbnailPath, $seekSeconds) && filesize($thumbnailPath) > 0) {
return $thumbnailRelativePath;
}
}
if (is_file($thumbnailPath)) {
@unlink($thumbnailPath);
}
return null;
}
private function extractFrame(string $videoPath, string $thumbnailPath, string $seekSeconds): bool
{
$process = new Process([
'ffmpeg',
'-y',
'-ss', $seekSeconds,
'-i', $videoPath,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-q:v', '3',
$thumbnailPath,
]);
$process->setTimeout(60);
try {
$process->run();
} catch (\Throwable) {
return false;
}
return $process->isSuccessful() && is_file($thumbnailPath);
} }
/** /**

View file

@ -143,25 +143,40 @@ class DisplayPlaylistConfigBuilder
*/ */
private function offersData(DisplayVersion $module, Collection $items): array private function offersData(DisplayVersion $module, Collection $items): array
{ {
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [ $slides = $items->where('item_type', 'slide')->values()->map(function ($item) {
'type' => $item->content['type'] ?? 'product-hero', $content = $item->content;
'duration' => $item->content['duration'] ?? 8000,
'image_url' => $item->content['image_url'] ?? '', return [
'badge_text' => $item->content['badge_text'] ?? '', 'type' => $content['type'] ?? 'detail',
'eyebrow' => $item->content['eyebrow'] ?? '', 'show_logo' => $content['show_logo'] ?? true,
'title' => $item->content['title'] ?? '', 'logo_url' => $content['logo_url'] ?? '',
'subline' => $item->content['subline'] ?? '', 'brand_text' => $content['brand_text'] ?? '',
'price' => $item->content['price'] ?? '', 'duration' => $content['duration'] ?? 8000,
'original_price' => $item->content['original_price'] ?? '', 'image_url' => $content['image_url'] ?? '',
'tag_text' => $item->content['tag_text'] ?? '', 'badge_text' => $content['badge_text'] ?? '',
'bullets' => $item->content['bullets'] ?? [], 'show_badge' => $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '',
'disclaimer' => $item->content['disclaimer'] ?? '', 'eyebrow' => $content['eyebrow'] ?? '',
'qr_url' => $item->content['qr_url'] ?? '', 'show_eyebrow' => $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '',
'qr_title' => $item->content['qr_title'] ?? '', 'title' => $content['title'] ?? '',
'contact' => $item->content['contact'] ?? '', 'subline' => $content['subline'] ?? '',
'show_brand_text' => $item->content['show_brand_text'] ?? false, 'show_subline' => $content['show_subline'] ?? ($content['subline'] ?? '') !== '',
'brand_tagline' => $item->content['brand_tagline'] ?? '', 'price' => $content['price'] ?? '',
]); 'original_price' => $content['original_price'] ?? '',
'strike_original_price' => $content['strike_original_price'] ?? false,
'tag_text' => $content['tag_text'] ?? '',
'show_price' => $content['show_price'] ?? ($content['price'] ?? '') !== '',
'bullets' => $content['bullets'] ?? [],
'show_bullets' => $content['show_bullets'] ?? ! empty($content['bullets']),
'disclaimer' => $content['disclaimer'] ?? '',
'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '',
'qr_url' => $content['qr_url'] ?? '',
'qr_title' => $content['qr_title'] ?? '',
'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '',
'contact' => $content['contact'] ?? '',
'show_contact' => $content['show_contact'] ?? ($content['contact'] ?? '') !== '',
'brand_tagline' => $content['brand_tagline'] ?? '',
];
});
return [ return [
'type' => 'offers', 'type' => 'offers',

View file

@ -21,6 +21,11 @@ class DisplayModuleSettings
'theme' => 'dark', 'theme' => 'dark',
'header_logo_url' => '../assets/b2in-logo-positive.svg', 'header_logo_url' => '../assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property', 'header_claim' => 'Connecting Design & Property',
'logo_position' => 'top-left',
'claim_position' => 'top-right',
'show_logo' => true,
'show_claim' => true,
'show_footer' => true,
'footer_url' => 'B2in.eu', 'footer_url' => 'B2in.eu',
'footer_name' => '', 'footer_name' => '',
'footer_prefix' => 'by', 'footer_prefix' => 'by',
@ -35,12 +40,6 @@ class DisplayModuleSettings
], ],
DisplayVersionType::Offers->value => [ DisplayVersionType::Offers->value => [
'loop' => true, 'loop' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => 'Bielefeld',
'footer_claim' => '',
'footer_url' => '',
'qr_default_title' => 'Kontakt',
'qr_subtitle' => 'QR scannen',
'transition' => [ 'transition' => [
'type' => 'fade', 'type' => 'fade',
'duration' => 600, 'duration' => 600,

View file

@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder
'email' => 'kevin.adametz@me.com', 'email' => 'kevin.adametz@me.com',
'password' => Hash::make('xunfew-0Jygjy-minnyt'), 'password' => Hash::make('xunfew-0Jygjy-minnyt'),
]); ]);
$this->call(TestDisplaySeeder::class);
} }
} }

View file

@ -169,7 +169,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de', 'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Kontakt', 'qr_title' => 'Kontakt',
'contact' => "0521 98620100\nTel. oder WhatsApp", 'contact' => "0521 98620100\nTel. oder WhatsApp",
'show_brand_text' => true, 'show_logo' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => 'Bielefeld',
'brand_tagline' => "Planung • Beratung\nLieferung & Montage", 'brand_tagline' => "Planung • Beratung\nLieferung & Montage",
], ],
[ [
@ -188,7 +190,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de', 'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Reservieren', 'qr_title' => 'Reservieren',
'contact' => "0521 98620100\nTel. oder WhatsApp", 'contact' => "0521 98620100\nTel. oder WhatsApp",
'show_brand_text' => false, 'show_logo' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => '',
'brand_tagline' => '', 'brand_tagline' => '',
], ],
[ [
@ -212,7 +216,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de', 'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Reservieren', 'qr_title' => 'Reservieren',
'contact' => "0521 98620100\nTel. oder WhatsApp", 'contact' => "0521 98620100\nTel. oder WhatsApp",
'show_brand_text' => false, 'show_logo' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => '',
'brand_tagline' => '', 'brand_tagline' => '',
], ],
[ [
@ -231,7 +237,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de', 'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Sichern', 'qr_title' => 'Sichern',
'contact' => "0521 98620100\nTel. oder WhatsApp", 'contact' => "0521 98620100\nTel. oder WhatsApp",
'show_brand_text' => false, 'show_logo' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => '',
'brand_tagline' => '', 'brand_tagline' => '',
], ],
]; ];

View file

@ -0,0 +1,31 @@
<?php
namespace Database\Seeders;
use App\Models\Display;
use Illuminate\Database\Seeder;
class TestDisplaySeeder extends Seeder
{
/**
* Stellt sicher, dass genau ein Test-Display existiert (Konzept §10.1).
* Idempotent: legt nur an, wenn noch kein Test-Display vorhanden ist.
*/
public function run(): void
{
if (Display::query()->where('is_test', true)->exists()) {
$this->command?->info('Test-Display existiert bereits übersprungen.');
return;
}
Display::query()->create([
'name' => 'Test-Display',
'location' => 'Vorschau / Test',
'is_active' => true,
'is_test' => true,
]);
$this->command?->info('Test-Display angelegt.');
}
}

View file

@ -13,7 +13,8 @@
| **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 | | **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 |
| **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 | | **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 |
| **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 | | **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 |
| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen | | **7** | Aufräumen + alte Pivot-Tabelle entfernen | ✅ 13.05.2026 |
| **8** | Review: Fehler / Optimierungen / Erweiterungen | 🟡 29.05.2026 (Befundaufnahme) |
Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert
@ -308,3 +309,67 @@ Umsetzung:
- Admin-Iframes laden per `loading="lazy"` verzögert, um die parallelen Player-Vorschauen leichter zu halten - Admin-Iframes laden per `loading="lazy"` verzögert, um die parallelen Player-Vorschauen leichter zu halten
- Video-Display-Items zeigen im Editor sichtbar an, ob die Quelle aus der Mediathek oder aus einem Legacy-Dateinamen kommt - Video-Display-Items zeigen im Editor sichtbar an, ob die Quelle aus der Mediathek oder aus einem Legacy-Dateinamen kommt
---
## Phase 8 Review 29.05.2026 (Befundaufnahme)
Geprüft wurden die drei Navigationspunkte:
- `admin/cms/display-media` → Volt `admin.cms.display-media-library`
- `admin/cms/display-modules``App\Livewire\Admin\Cms\DisplayVersionList` (+ Editor `DisplayVersionEditor`)
- `admin/cms/displays``App\Livewire\Admin\Cms\DisplayList`
**Vorgehen:** Code-Review der Komponenten/Views/Services/Player + Laravel-Logs + DB-Stand. Eine Browser-Sichtprüfung war in dieser Session nicht möglich (Browser-MCP nicht verfügbar). **Alle Befunde (#1#11) wurden inzwischen umgesetzt** (Details je Punkt unten).
**Aktueller Datenstand (DB):** 5 Displays (davon **0** Test-Displays), 6 Module, 7 Playlists (5 Live + 2 Entwürfe), 13 Medien.
**Tests:** `DisplayListTest`, `DisplayMediaTest`, `DisplayVersionTest`, `DisplayVersionApiTest`, `DisplayPlaylistMigrationTest`**95 passed** (276 Assertions). Keine CMS-Fehler in `storage/logs` (vorhandene Log-Fehler betreffen unrelated `immobilien-azizi`-Routen).
### 🔴 Fehler (sollten behoben werden)
1. **Mediathek-Suche hebelt alle Filter aus.** ✅ **behoben 29.05.2026**
In `display-media-library.blade.php` (`$media`-Computed) und in `DisplayMediaPicker::resolveMediaItems()` wurde die Suche als `->where('filename','like',…)->orWhere('title','like',…)` ohne Gruppierung an die vorherigen `when()`-Filter gehängt. Durch SQL-Präzedenz (`AND` bindet stärker als `OR`) wurde die Query zu `… AND filename LIKE … OR title LIKE …`. Sobald gesucht wurde, wurden **Typ-, Quelle-, Sammlungs- und (beim Picker) der `active()`-Filter ignoriert**, sobald ein Treffer über `title` zustande kam.
*Behebung:* Neuer gekapselter Scope `DisplayMedia::scopeSearch()` (Closure um `filename`/`title`), den Library und Picker gemeinsam nutzen. Tests: `keeps preceding filters when combined with the search scope`, `search scope respects the active filter on the media picker` in `DisplayMediaTest.php`.
2. **Inhalt-Bearbeiten reaktiviert deaktivierte Items.** ✅ **behoben 29.05.2026**
In `DisplayVersionEditor` übergab `loadItemContent()` nur das `content`-Array an `loadVideoContent()/loadFooterContent()/loadMediaContent()/loadSlideContent()`. Alle vier setzten `…IsActive = true` fest das tatsächliche `is_active` des Items wurde nie geladen. Wer ein zuvor per Auge-Icon deaktiviertes Item öffnete und „Aktualisieren" klickte, hat es über `getActiveFlag()` **ungewollt wieder aktiviert**.
*Behebung:* `loadItemContent()` reicht `(bool) $item->is_active` an die Loader durch, die es in die jeweilige `…IsActive`-Property schreiben. Test: `editing an inactive item keeps it inactive` in `DisplayVersionTest.php`.
### 🟡 Optimierungen / Aufräumen
3. **Toter Code im Video-Editor.****behoben 29.05.2026** `DisplayVersionEditor::loadAvailableVideos()`, Property `$availableVideos`, der `mount()`-Aufruf und der ungenutzte `Illuminate\Support\Facades\File`-Import wurden entfernt (im aktiven Editor-Blade ungenutzt; das einzige Blade mit `availableVideos` gehört zur Legacy-`CabinetDisplay`). Spart den Dateisystem-Scan von `public/_cabinet/assets` bei jedem Editor-Aufruf.
4. **Modul-Löschen ohne Schutz.****behoben 29.05.2026** `DisplayVersionList::deleteVersion()` ermittelt jetzt den `displays_count` (distinct Displays über Playlists) und **blockiert** das Löschen, solange das Modul in irgendeiner Bespielung (Live oder Entwurf) genutzt wird; es erscheint ein roter Flash-Hinweis mit Anzahl. Module ohne Nutzung lassen sich weiterhin löschen. Test: `cannot delete a display version that is used by a playlist` in `DisplayVersionTest.php`.
5. **Doppelte Such-Logik** zwischen Library und Picker (siehe #1) → ein gemeinsamer Query-Scope `DisplayMedia::scopeSearch(string $term)` verhindert künftige Divergenz. ✅ **erledigt 29.05.2026** (im Zuge von #1).
6. **Preview-Token bleibt nach Veröffentlichen bestehen.** ✅ **behoben 29.05.2026**
`DisplayList::publishDraft()`/`discardDraft()` ließen `displays.preview_token` gesetzt, obwohl `/preview/{token}` danach 404 lieferte (kein Draft mehr); ein Rotations-Button fehlte ganz.
*Behebung:* Neue Model-Methoden `Display::rotatePreviewToken()` und `Display::clearPreviewToken()`. `publishDraft()` und `discardDraft()` setzen den Token jetzt zurück (alter Link wird ungültig). Im Entwurf gibt es einen „Link erneuern"-Button (`rotatePreviewToken()` mit `wire:confirm`), der bei offenem Entwurf zugleich die Iframe-Vorschau aktualisiert. Tests: `publishing a draft clears the preview token`, `can discard a draft playlist` (erweitert) und `can rotate the preview token of a draft` in `DisplayListTest.php`.
### 🟠 Konfiguration / Dev-Umgebung
7. **Live-Vorschau zeigt im lokalen Dev auf die Produktion.** ✅ **behoben 29.05.2026**
`config/display.php` `player_url` defaultete auf `https://cabinet.b2in.eu/display`, und `DISPLAY_PLAYER_URL` war in `.env` nicht gesetzt. In `display-list.blade.php` nutzten „Vorschau", „Live-URL zum Kopieren" und „Display-Übersicht öffnen" diese Produktions-URL, während die Entwurfs-„Test-URL" über `url('/preview/…')` lokal lief. Lokal sah man Live also den Produktionsstand, Entwurf aber lokale Daten inkonsistent und irreführend.
*Behebung:* Player-Seite (`public/_cabinet/display/`) **und** die Display-API liegen auf der Portal-Domain, daher ist der Player lokal unter `https://portal.b2in.test/_cabinet/display` erreichbar (der Player ermittelt `BASE_URL` aus `window.location.origin` und ruft die API auf derselben Domain auf).
- `.env`: `DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display` gesetzt (Config-Cache geleert).
- `.env.example`: dokumentiert (Live-Wert als Kommentar).
- `phpunit.xml`: `DISPLAY_PLAYER_URL=https://cabinet.b2in.eu/display` gepinnt, damit die bestehenden URL-Tests (`DisplayListTest`, `DisplayVersionApiTest`) deterministisch gegen den Produktionswert prüfen, unabhängig von der lokalen `.env`.
- Der Produktions-Fallback in `config/display.php` (und damit das Live-Verhalten) bleibt unverändert.
### 🟢 Erweiterungen / Verbesserungen (UX)
8. **Test-Display existiert nicht.****behoben 29.05.2026** Neuer idempotenter `Database\Seeders\TestDisplaySeeder` legt genau **ein** Test-Display an (überspringt, falls schon vorhanden) und ist im `DatabaseSeeder` registriert. In der Dev-DB wurde das Test-Display angelegt. Tests: `seeds exactly one test display`, `is idempotent and does not create a second test display` in `TestDisplaySeederTest.php`.
9. **Externe Bilder ohne Thumbnail.****behoben 29.05.2026** Grid, Listenansicht und Detail-Sidebar (`display-media-library.blade.php`) rendern externe Bilder jetzt direkt via `<img src="{{ external_url }}">` (gemeinsame `$thumbSrc`-Logik: Upload-Thumbnail → sonst `external_url`). Nicht-Bild-Externe behalten das Link-Icon. Test: `renders external image media with an inline thumbnail` in `DisplayMediaTest.php`.
10. **Mediathek-Pagination ohne `resetPage`.****behoben 29.05.2026** Die Volt-Komponente nutzt jetzt `WithPagination` und Update-Hooks (`updatedSearch/updatedFilterType/updatedFilterSource/updatedFilterCollection`), die bei jeder Filter-/Suchänderung `resetPage()` aufrufen. Test: `resets pagination to page one when a filter changes` in `DisplayMediaTest.php`.
11. **Modul-Hinzufügen nur einzeln.****behoben 29.05.2026** Der Bespielungs-Dialog (`display-list.blade.php`) nutzt jetzt einen durchsuchbaren Flux-Listbox-Multi-Select (`versionsToAdd`) plus „Hinzufügen"-Button (`addSelectedVersions()`), der alle ausgewählten Module auf einmal übernimmt (Duplikate werden übersprungen). Die programmatische `addVersion()`-Methode bleibt für Einzel-Adds/Tests erhalten. Test: `can add multiple modules to a playlist at once` in `DisplayListTest.php`.
### Empfohlene Reihenfolge
1. #1 + #2 (echte Fehler, klein, mit Tests) → 2. #7 (Dev-Konfiguration, blockiert lokales Testen) → 3. #3/#4/#5/#6 (Aufräumen/Robustheit) → 4. #8#11 (UX-Erweiterungen). Jeder Punkt wird gemäß Projekt-Regeln mit Test abgesichert und mit Pint formatiert.
**Stand 29.05.2026:** Alle Befunde (#1#11) sind umgesetzt, getestet und mit Pint formatiert.

View file

@ -20,6 +20,7 @@
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="DISPLAY_PLAYER_URL" value="https://cabinet.b2in.eu/display"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/> <env name="DB_CONNECTION" value="sqlite" force="true"/>

View file

@ -143,6 +143,24 @@
text-transform: uppercase; color: rgba(255,255,255,0.7); text-transform: uppercase; color: rgba(255,255,255,0.7);
} }
/* B2in Brand (positionable logo + claim) */
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
.b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
.b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
.b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
/* Legibility scrims behind positioned brand elements */
.b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
.b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
.b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
/* B2in Media */ /* B2in Media */
.b2in-media { .b2in-media {
flex: 1; position: relative; overflow: hidden; flex: 1; position: relative; overflow: hidden;
@ -171,6 +189,8 @@
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
line-height: 1.4; 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 */
.b2in-footer { .b2in-footer {
@ -204,6 +224,9 @@
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
} }
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); } .b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
.b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-text { .b2in-layer[data-theme="light"] .b2in-text {
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent); background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
} }
@ -325,6 +348,10 @@
font-size: 24px; color: #737373; text-align: right; font-size: 24px; color: #737373; text-align: right;
line-height: 1.35; font-weight: 400; line-height: 1.35; font-weight: 400;
} }
.offer-price-note.strike {
color: #dc2626; text-decoration: line-through;
text-decoration-color: #dc2626; text-decoration-thickness: 3px;
}
/* Bullets */ /* Bullets */
.offer-bullets { .offer-bullets {
@ -412,6 +439,16 @@
.status-error { color: #ef4444; font-weight: 500; } .status-error { color: #ef4444; font-weight: 500; }
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; } .status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
.display-empty {
position: absolute; inset: 0; z-index: 10;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 6vw;
background: #000; color: #fff;
}
.display-empty__title { font-size: 2.4vh; font-weight: 600; }
.display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
.display-overview { .display-overview {
position: fixed; inset: 0; z-index: 10000; position: fixed; inset: 0; z-index: 10000;
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%); overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
@ -549,6 +586,7 @@ class DisplayPlayer {
this.lastSuccessTime = Date.now(); this.lastSuccessTime = Date.now();
this.isRunning = false; this.isRunning = false;
this.activeVersionRenderer = null; this.activeVersionRenderer = null;
this.emptyVersionStreak = 0;
// DOM // DOM
this.viewport = document.getElementById('viewport'); this.viewport = document.getElementById('viewport');
@ -810,6 +848,19 @@ class DisplayPlayer {
return; return;
} }
// Guard against a delay-free infinite restart loop when no version in the
// playlist has any playable content (e.g. a freshly created, empty module).
if (!this.versionHasContent(version)) {
this.emptyVersionStreak++;
if (this.emptyVersionStreak >= this.playlist.length) {
this.showEmptyPlaylist();
return;
}
this.advanceVersion();
return;
}
this.emptyVersionStreak = 0;
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`); console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
// Clean up previous renderer // Clean up previous renderer
@ -850,6 +901,38 @@ class DisplayPlayer {
this.playCurrentVersion(); this.playCurrentVersion();
} }
versionHasContent(version) {
if (!version) return false;
switch (version.type) {
case 'video-display':
return (version.videoPlaylist || []).length > 0;
case 'b2in':
return (version.items || []).some(item => item.is_active);
case 'offers':
return (version.slides || []).length > 0;
default:
return false;
}
}
showEmptyPlaylist() {
this.isRunning = false;
this.emptyVersionStreak = 0;
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
this.viewport.innerHTML = `
<div class="display-empty">
<p class="display-empty__title">Noch keine Inhalte vorhanden</p>
<p class="display-empty__hint">Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.</p>
</div>
`;
console.log('[Display] Playlist has no playable content playback stopped.');
}
// ======================================== // ========================================
// UI HELPERS // UI HELPERS
// ======================================== // ========================================
@ -1132,7 +1215,7 @@ class B2inRenderer {
build() { build() {
const layer = document.createElement('div'); const layer = document.createElement('div');
layer.className = 'version-layer b2in-layer active'; layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
layer.setAttribute('data-theme', this.theme); 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 || '../assets/b2in-logo-positive.svg');
@ -1145,11 +1228,57 @@ class B2inRenderer {
: ''; : '';
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu'); const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
layer.innerHTML = ` // Footer visibility, brand element visibility + corner positioning
<header class="b2in-header"> const showFooter = this.settings.show_footer !== false;
const showLogo = this.settings.show_logo !== false;
const showClaim = this.settings.show_claim !== false;
const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left';
let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right';
// Bottom corners only allowed when the footer is hidden
if (showFooter) {
if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-');
if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-');
}
// Claim must not share the logo corner
if (claimPos === logoPos) {
claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos;
}
const logoVisible = showLogo;
const claimVisible = showClaim && !!headerClaim;
const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top'));
const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom'));
const logoHtml = logoVisible
? `<div class="b2in-brand b2in-brand-logo pos-${logoPos}">
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in"> <img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
<span class="b2in-claim">${escapeHtml(headerClaim)}</span> </div>`
</header> : '';
const claimHtml = claimVisible
? `<div class="b2in-brand b2in-brand-claim pos-${claimPos}">${escapeHtml(headerClaim)}</div>`
: '';
const footerHtml = showFooter
? `<footer class="b2in-footer">
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${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">
</div>
</footer>`
: '';
layer.innerHTML = `
${hasTop ? '<div class="b2in-scrim b2in-scrim-top"></div>' : ''}
${(hasBottom && !showFooter) ? '<div class="b2in-scrim b2in-scrim-bottom"></div>' : ''}
${logoHtml}
${claimHtml}
<section class="b2in-media"> <section class="b2in-media">
<div class="b2in-media-layer active" id="b2in-layer-a"></div> <div class="b2in-media-layer active" id="b2in-layer-a"></div>
<div class="b2in-media-layer" id="b2in-layer-b"></div> <div class="b2in-media-layer" id="b2in-layer-b"></div>
@ -1158,15 +1287,7 @@ class B2inRenderer {
<div class="b2in-headline" id="b2in-headline"></div> <div class="b2in-headline" id="b2in-headline"></div>
<div class="b2in-subline" id="b2in-subline"></div> <div class="b2in-subline" id="b2in-subline"></div>
</section> </section>
<footer class="b2in-footer"> ${footerHtml}
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${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">
</div>
</footer>
<div class="b2in-progress-track"> <div class="b2in-progress-track">
<div class="b2in-progress-fill" id="b2in-progress"></div> <div class="b2in-progress-fill" id="b2in-progress"></div>
</div> </div>
@ -1375,34 +1496,52 @@ class OffersRenderer {
} }
buildSlide(slide) { buildSlide(slide) {
// Single dynamic detail layout: every block is toggleable. Older slides
// without explicit show_* flags fall back to "is there content?".
const has = v => v !== undefined && v !== null && v !== '';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
const contactText = slide.contact || this.settings.footer_claim || '';
const show = {
logo: slide.show_logo ?? true,
badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text),
eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow),
subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline),
bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0),
price: (slide.show_price ?? has(slide.price)) && has(slide.price),
disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer),
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
};
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'offers-slide-container'; wrapper.className = 'offers-slide-container';
const article = document.createElement('article'); const article = document.createElement('article');
article.className = 'offer-slide'; article.className = 'offer-slide';
// --- HEADER --- // --- HEADER (Logo & Marke) per slide, toggleable ---
if (show.logo) {
const header = document.createElement('header'); const header = document.createElement('header');
header.className = 'offer-header'; header.className = 'offer-header';
const brand = document.createElement('div'); const brand = document.createElement('div');
brand.className = 'offer-brand'; brand.className = 'offer-brand';
const brandLogo = document.createElement('img'); const brandLogo = document.createElement('img');
brandLogo.src = this.resolveUrl(this.settings.logo_url || '../logo-cabinet-300.png'); brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png');
brandLogo.alt = 'CABINET'; brandLogo.alt = 'Logo';
brandLogo.className = 'offer-brand-logo'; brandLogo.className = 'offer-brand-logo';
brand.appendChild(brandLogo); brand.appendChild(brandLogo);
if (slide.show_brand_text) { if (has(slide.brand_text)) {
const brandText = document.createElement('span'); const brandText = document.createElement('span');
brandText.className = 'offer-brand-text'; brandText.className = 'offer-brand-text';
brandText.textContent = this.settings.brand_text || 'Bielefeld'; brandText.textContent = slide.brand_text;
brand.appendChild(brandText); brand.appendChild(brandText);
} }
header.appendChild(brand); header.appendChild(brand);
if (slide.show_brand_text && slide.brand_tagline) { if (has(slide.brand_tagline)) {
const tagline = document.createElement('div'); const tagline = document.createElement('div');
tagline.className = 'offer-tagline'; tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>'); tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
@ -1410,6 +1549,10 @@ class OffersRenderer {
} }
article.appendChild(header); article.appendChild(header);
} else {
// Without the header row, let the hero stay the flexible middle row.
article.style.gridTemplateRows = '1fr auto';
}
// --- HERO --- // --- HERO ---
const hero = document.createElement('section'); const hero = document.createElement('section');
@ -1419,7 +1562,7 @@ class OffersRenderer {
hero.style.background = `url('${imgUrl}') center/cover no-repeat`; hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
} }
if (slide.badge_text) { if (show.badge) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'offer-hero-badge large'; badge.className = 'offer-hero-badge large';
badge.textContent = slide.badge_text; badge.textContent = slide.badge_text;
@ -1439,7 +1582,7 @@ class OffersRenderer {
const infoContent = document.createElement('div'); const infoContent = document.createElement('div');
infoContent.className = 'offer-info-content'; infoContent.className = 'offer-info-content';
if (slide.eyebrow) { if (show.eyebrow) {
const eyebrow = document.createElement('p'); const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow'; eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow; eyebrow.textContent = slide.eyebrow;
@ -1448,22 +1591,19 @@ class OffersRenderer {
if (slide.title) { if (slide.title) {
const title = document.createElement('h1'); const title = document.createElement('h1');
const titleSize = (slide.type === 'product-details') ? 'medium' : 'large'; title.className = 'offer-title medium';
title.className = `offer-title ${titleSize}`;
title.innerHTML = slide.title.replace(/\n/g, '<br>'); title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title); infoContent.appendChild(title);
} }
// Subline (product-impulse) if (show.subline) {
if (slide.subline) {
const subline = document.createElement('p'); const subline = document.createElement('p');
subline.className = 'offer-subline'; subline.className = 'offer-subline';
subline.textContent = slide.subline; subline.textContent = slide.subline;
infoContent.appendChild(subline); infoContent.appendChild(subline);
} }
// Bullets (product-details) if (show.bullets) {
if (slide.bullets && slide.bullets.length > 0) {
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.className = 'offer-bullets'; ul.className = 'offer-bullets';
slide.bullets.forEach(text => { slide.bullets.forEach(text => {
@ -1477,8 +1617,8 @@ class OffersRenderer {
info.appendChild(infoContent); info.appendChild(infoContent);
// Price block (product-hero, product-impulse) // Price block
if (slide.price) { if (show.price) {
const priceBlock = document.createElement('div'); const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block'; priceBlock.className = 'offer-price-block';
@ -1492,7 +1632,7 @@ class OffersRenderer {
if (slide.original_price) { if (slide.original_price) {
const note = document.createElement('div'); const note = document.createElement('div');
note.className = 'offer-price-note'; note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price; note.textContent = slide.original_price;
priceRow.appendChild(note); priceRow.appendChild(note);
} }
@ -1511,8 +1651,8 @@ class OffersRenderer {
info.appendChild(priceBlock); info.appendChild(priceBlock);
} }
// Disclaimer (intro) // Disclaimer
if (slide.disclaimer) { if (show.disclaimer) {
const footer = document.createElement('div'); const footer = document.createElement('div');
footer.className = 'offer-info-footer'; footer.className = 'offer-info-footer';
const disc = document.createElement('span'); const disc = document.createElement('span');
@ -1524,10 +1664,12 @@ class OffersRenderer {
bottom.appendChild(info); bottom.appendChild(info);
// QR Box // QR / Contact box only when at least one of the two is enabled.
if (show.qr || show.contact) {
const qrBox = document.createElement('aside'); const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box'; qrBox.className = 'offer-qr-box';
if (show.qr) {
const qrHeader = document.createElement('div'); const qrHeader = document.createElement('div');
qrHeader.className = 'offer-qr-header'; qrHeader.className = 'offer-qr-header';
qrHeader.innerHTML = ` qrHeader.innerHTML = `
@ -1538,17 +1680,14 @@ class OffersRenderer {
const qrWrapper = document.createElement('div'); const qrWrapper = document.createElement('div');
qrWrapper.className = 'offer-qr-wrapper'; qrWrapper.className = 'offer-qr-wrapper';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
if (qrUrl) {
const qrImg = document.createElement('img'); 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.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'; qrImg.alt = 'QR Code';
qrWrapper.appendChild(qrImg); qrWrapper.appendChild(qrImg);
}
qrBox.appendChild(qrWrapper); qrBox.appendChild(qrWrapper);
}
const contactText = slide.contact || this.settings.footer_claim || ''; if (show.contact) {
if (contactText) {
const contact = document.createElement('p'); const contact = document.createElement('p');
contact.className = 'offer-qr-contact'; contact.className = 'offer-qr-contact';
contact.innerHTML = contactText.replace(/\n/g, '<br>'); contact.innerHTML = contactText.replace(/\n/g, '<br>');
@ -1556,6 +1695,10 @@ class OffersRenderer {
} }
bottom.appendChild(qrBox); bottom.appendChild(qrBox);
} else {
bottom.style.gridTemplateColumns = '1fr';
}
article.appendChild(bottom); article.appendChild(bottom);
wrapper.appendChild(article); wrapper.appendChild(article);

File diff suppressed because it is too large Load diff

View file

@ -119,6 +119,7 @@
{{ $slot }} {{ $slot }}
@livewireScripts
@fluxScripts @fluxScripts
</body> </body>
</html> </html>

View file

@ -380,9 +380,9 @@
<flux:toast /> <flux:toast />
{{-- Flux vor Livewire: flux.js registriert Alpine.data('fluxModal') im alpine:init-Handler --}} {{-- Reihenfolge laut Flux-Doku: Livewire stellt das gebündelte Alpine bereit, danach registriert flux.js seine Alpine-Komponenten (fluxModal, ). --}}
@fluxScripts
@livewireScripts @livewireScripts
@fluxScripts
</body> </body>
</html> </html>

View file

@ -26,17 +26,5 @@
@livewireScripts @livewireScripts
@fluxScripts @fluxScripts
@include('partials.theme-toggle-script') @include('partials.theme-toggle-script')
<script src="{{ asset('vendor/livewire/livewire.js') }}"></script>
<!-- Debug: Script-Status -->
<script>
console.log('Body Scripts geladen');
console.log('Livewire JS:', {{ file_exists(public_path('vendor/livewire/livewire.js')) ? 'true' : 'false' }});
if (typeof Livewire !== 'undefined') {
console.log('Livewire verfügbar:', true);
} else {
console.log('Livewire verfügbar:', false);
}
</script>
</body> </body>
</html> </html>

View file

@ -43,6 +43,7 @@
</div> </div>
</div> </div>
</div> </div>
@livewireScripts
@fluxScripts @fluxScripts
@include('partials.theme-toggle-script') @include('partials.theme-toggle-script')
</body> </body>

View file

@ -0,0 +1,27 @@
@props(['url' => '', 'size' => 'h-10 w-10'])
@php
$url = (string) $url;
$path = parse_url($url, PHP_URL_PATH) ?? '';
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$isVideo = in_array($ext, ['mp4', 'webm', 'mov', 'm4v', 'ogv'], true);
@endphp
<div {{ $attributes->merge(['class' => "relative {$size} shrink-0 flex items-center justify-center overflow-hidden rounded-lg border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800"]) }}>
@if($url === '')
<x-heroicon-o-photo class="h-5 w-5 text-zinc-300 dark:text-zinc-600" />
@elseif($isVideo)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $url }}#t=1">
</video>
<span class="pointer-events-none absolute inset-0 flex items-center justify-center">
<x-heroicon-s-play class="h-4 w-4 text-white/90 drop-shadow" />
</span>
@else
<img src="{{ $url }}" alt="" class="h-full w-full object-cover" loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.classList.remove('hidden');" />
<span class="hidden">
<x-heroicon-o-link class="h-5 w-5 text-zinc-400" />
</span>
@endif
</div>

View file

@ -220,6 +220,13 @@
<flux:icon.play class="w-3 h-3" /> <flux:icon.play class="w-3 h-3" />
{{ __('Test-URL') }} {{ __('Test-URL') }}
</a> </a>
<flux:button wire:click="rotatePreviewToken({{ $display->id }})"
wire:confirm="Vorschau-Link neu erzeugen? Der bisherige Link wird damit ungültig."
size="xs"
variant="ghost"
icon="arrow-path">
{{ __('Link erneuern') }}
</flux:button>
<flux:button wire:click="publishDraft({{ $display->id }})" <flux:button wire:click="publishDraft({{ $display->id }})"
wire:confirm="Diesen Entwurf veröffentlichen und den Live-Stand ersetzen?" wire:confirm="Diesen Entwurf veröffentlichen und den Live-Stand ersetzen?"
size="xs" size="xs"
@ -342,17 +349,23 @@
@if($availableVersions->isNotEmpty()) @if($availableVersions->isNotEmpty())
<div class="flex gap-2"> <div class="flex gap-2">
<div class="flex-1"> <div class="flex-1">
<flux:select wire:model="addVersionSelect" placeholder="Modul hinzufügen..."> <flux:select wire:model="versionsToAdd"
variant="listbox"
multiple
searchable
placeholder="Module hinzufügen..."
selected-suffix="Module ausgewählt">
@foreach($availableVersions as $version) @foreach($availableVersions as $version)
<option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</option> <flux:select.option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</flux:select.option>
@endforeach @endforeach
</flux:select> </flux:select>
</div> </div>
<flux:button wire:click="addVersion" <flux:button wire:click="addSelectedVersions"
type="button" type="button"
icon="plus" icon="plus"
size="sm" size="sm"
variant="ghost"> variant="ghost">
{{ __('Hinzufügen') }}
</flux:button> </flux:button>
</div> </div>
@else @else

View file

@ -6,11 +6,12 @@ use Flux\Flux;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
use Livewire\WithPagination;
use function Livewire\Volt\{layout, title, state, computed, on, uses}; use function Livewire\Volt\{layout, title, state, computed, on, uses};
layout('components.layouts.app'); layout('components.layouts.app');
title('Display-Mediathek'); title('Display-Mediathek');
uses([WithFileUploads::class]); uses([WithFileUploads::class, WithPagination::class]);
state([ state([
'search' => '', 'search' => '',
@ -47,12 +48,16 @@ $media = computed(
default => $q, default => $q,
}) })
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection)) ->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") ->when($this->search, fn ($q) => $q->search($this->search))
->orWhere('title', 'like', "%{$this->search}%"))
->orderByDesc('created_at') ->orderByDesc('created_at')
->paginate(48), ->paginate(48),
); );
$updatedSearch = fn () => $this->resetPage();
$updatedFilterType = fn () => $this->resetPage();
$updatedFilterSource = fn () => $this->resetPage();
$updatedFilterCollection = fn () => $this->resetPage();
$collections = computed(fn () => DisplayMedia::query() $collections = computed(fn () => DisplayMedia::query()
->whereNotNull('collection') ->whereNotNull('collection')
->where('collection', '!=', '') ->where('collection', '!=', '')
@ -283,27 +288,37 @@ $closeDetail = function () {
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}" {{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
wire:click="startEdit({{ $item->id }})"> wire:click="startEdit({{ $item->id }})">
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800"> @php
@if ($item->isImage() && $item->isUpload()) $thumbSrc = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
<img src="{{ $item->getThumbnailUrl() }}" $videoFrameSrc = (! $thumbSrc && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
@endphp
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($thumbSrc)
<img src="{{ $thumbSrc }}"
alt="{{ $item->alt_text ?? $item->filename }}" alt="{{ $item->alt_text ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" /> class="h-full w-full object-cover" loading="lazy" />
@elseif ($videoFrameSrc)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $videoFrameSrc }}#t=1" type="{{ $item->mime_type }}">
</video>
@elseif ($item->isVideo()) @elseif ($item->isVideo())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400"> <div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
<x-heroicon-o-film class="h-10 w-10" /> <x-heroicon-o-film class="h-10 w-10" />
<span class="text-xs">Video</span> <span class="text-xs">Video</span>
</div> </div>
@elseif ($item->isExternal() && $item->isImage())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-blue-400">
<x-heroicon-o-photo class="h-10 w-10" />
<span class="text-xs">Extern</span>
</div>
@else @else
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400"> <div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
<x-heroicon-o-link class="h-10 w-10" /> <x-heroicon-o-link class="h-10 w-10" />
<span class="text-xs">Link</span> <span class="text-xs">Link</span>
</div> </div>
@endif @endif
@if ($item->isVideo() && ($thumbSrc || $videoFrameSrc))
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/30 backdrop-blur-sm">
<x-heroicon-s-play class="h-5 w-5" />
</span>
</div>
@endif
</div> </div>
<div class="flex items-center gap-1.5 p-2"> <div class="flex items-center gap-1.5 p-2">
@if ($item->isVideo()) @if ($item->isVideo())
@ -352,9 +367,17 @@ $closeDetail = function () {
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}" class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
wire:click="startEdit({{ $item->id }})"> wire:click="startEdit({{ $item->id }})">
<td class="px-3 py-1.5"> <td class="px-3 py-1.5">
<div class="flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800"> @php
@if ($item->isImage() && $item->isUpload()) $rowThumb = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
<img src="{{ $item->getThumbnailUrl() }}" class="h-full w-full object-cover" loading="lazy" /> $rowVideoFrame = (! $rowThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
@endphp
<div class="relative flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
@if ($rowThumb)
<img src="{{ $rowThumb }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($rowVideoFrame)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $rowVideoFrame }}#t=1" type="{{ $item->mime_type }}">
</video>
@elseif ($item->isVideo()) @elseif ($item->isVideo())
<x-heroicon-s-film class="h-4 w-4 text-purple-500" /> <x-heroicon-s-film class="h-4 w-4 text-purple-500" />
@else @else
@ -418,17 +441,18 @@ $closeDetail = function () {
{{-- Preview --}} {{-- Preview --}}
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800"> <div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
@if ($editMedia->isImage() && $editMedia->isUpload()) @if ($editMedia->isImage())
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}" <img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
class="w-full object-contain" style="max-height: 300px;" /> class="w-full object-contain" style="max-height: 300px;" />
@elseif ($editMedia->isVideo() && $editMedia->isUpload()) @elseif ($editMedia->isVideo() && $editMedia->isUpload())
<video controls class="w-full" style="max-height: 300px;"> <video controls preload="metadata" class="w-full" style="max-height: 300px;"
@if ($editMedia->getThumbnailUrl()) poster="{{ $editMedia->getThumbnailUrl() }}" @endif>
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}"> <source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
</video> </video>
@elseif ($editMedia->isExternal()) @elseif ($editMedia->isExternal())
<div class="flex flex-col items-center justify-center gap-3 py-8"> <div class="flex flex-col items-center justify-center gap-3 py-8">
<x-heroicon-o-link class="h-12 w-12 text-blue-400" /> <x-heroicon-o-link class="h-12 w-12 text-blue-400" />
<span class="text-sm text-zinc-500">Externe Ressource</span> <span class="text-sm text-zinc-500">Externe Ressource (Vorschau nicht einbettbar)</span>
</div> </div>
@endif @endif
</div> </div>

View file

@ -2,11 +2,20 @@
<div class="flex items-end gap-3"> <div class="flex items-end gap-3">
<div class="flex-1"> <div class="flex-1">
@if ($selectedMedia) @if ($selectedMedia)
@php
$selectedThumb = $selectedMedia->getThumbnailUrl()
?? ($selectedMedia->isImage() && $selectedMedia->isExternal() ? $selectedMedia->external_url : null);
$selectedVideoFrame = (! $selectedThumb && $selectedMedia->isVideo() && $selectedMedia->isUpload()) ? $selectedMedia->getUrl() : null;
@endphp
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700"> <div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
@if ($selectedMedia->isImage() && $selectedMedia->isUpload()) @if ($selectedThumb)
<img src="{{ $selectedMedia->getThumbnailUrl() }}" <img src="{{ $selectedThumb }}"
alt="{{ $selectedMedia->filename }}" alt="{{ $selectedMedia->filename }}"
class="h-16 w-16 rounded-md object-cover" /> class="h-16 w-16 rounded-md object-cover" />
@elseif ($selectedVideoFrame)
<video class="h-16 w-16 rounded-md object-cover" preload="metadata" muted playsinline>
<source src="{{ $selectedVideoFrame }}#t=1" type="{{ $selectedMedia->mime_type }}">
</video>
@elseif ($selectedMedia->isVideo()) @elseif ($selectedMedia->isVideo())
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20"> <div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20">
<x-heroicon-o-film class="h-8 w-8 text-purple-500" /> <x-heroicon-o-film class="h-8 w-8 text-purple-500" />
@ -88,9 +97,18 @@
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}" {{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
wire:click="selectMedia({{ $item->id }})"> wire:click="selectMedia({{ $item->id }})">
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800"> <div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($item->isImage() && $item->isUpload()) @php
<img src="{{ $item->getThumbnailUrl() }}" $pickThumb = $item->getThumbnailUrl()
?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
$pickVideoFrame = (! $pickThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
@endphp
@if ($pickThumb)
<img src="{{ $pickThumb }}"
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" /> alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($pickVideoFrame)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $pickVideoFrame }}#t=1" type="{{ $item->mime_type }}">
</video>
@elseif ($item->isVideo()) @elseif ($item->isVideo())
<div class="flex h-full w-full items-center justify-center text-purple-500"> <div class="flex h-full w-full items-center justify-center text-purple-500">
<x-heroicon-o-film class="h-10 w-10" /> <x-heroicon-o-film class="h-10 w-10" />

View file

@ -121,9 +121,13 @@
type="video" type="video"
label="Video aus Mediathek" label="Video aus Mediathek"
:key="'picker-video-' . ($itemId ?? 'new')" /> :key="'picker-video-' . ($itemId ?? 'new')" />
<flux:input wire:model="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..." <div class="flex items-end gap-3">
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." /> <x-media-thumb :url="$videoFilename" />
<flux:input wire:model="videoTitle" label="Titel (optional)" placeholder="z.B. Herbst Kollektion 2025" /> <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>
<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 (%)" <flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
description="Vertikale Position im Video (0 = oben, 100 = unten)" /> description="Vertikale Position im Video (0 = oben, 100 = unten)" />
<flux:checkbox wire:model="videoIsActive" label="Aktiv" /> <flux:checkbox wire:model="videoIsActive" label="Aktiv" />
@ -140,22 +144,29 @@
{{-- Media fields (B2in) --}} {{-- Media fields (B2in) --}}
@if($itemType === 'media') @if($itemType === 'media')
<flux:select wire:model="mediaType" label="Medientyp">
<option value="image">Bild</option>
<option value="video">Video</option>
</flux:select>
<flux:select wire:model="mediaCategory" label="Kategorie">
<option value="immobilien">Immobilien</option>
<option value="moebel">Möbel</option>
</flux:select>
<livewire:admin.cms.display-media-picker <livewire:admin.cms.display-media-picker
:value="null" :value="null"
field="mediaUrl" field="mediaUrl"
:type="$mediaType === 'video' ? 'video' : 'image'" type="all"
label="Aus Mediathek" label="Aus Mediathek"
:key="'picker-media-' . ($itemId ?? 'new')" /> :key="'picker-media-' . ($itemId ?? 'new')" />
<flux:input wire:model="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..." <div class="flex items-end gap-3">
description="Über die Mediathek auswählen oder direkt URL eingeben." /> <x-media-thumb :url="$mediaUrl" />
<flux:input wire:model.live.debounce.500ms="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt URL eingeben." class="flex-1" />
</div>
<div class="flex items-center gap-2">
<flux:text class="text-sm">{{ __('Medientyp:') }}</flux:text>
<flux:badge size="sm" :color="$mediaType === 'video' ? 'purple' : 'sky'">
{{ $mediaType === 'video' ? __('Video') : __('Bild') }}
</flux:badge>
<flux:text class="text-xs text-zinc-400">{{ __('wird automatisch aus dem gewählten Medium erkannt') }}</flux:text>
</div>
<flux:select wire:model="mediaCategory" label="Kategorie">
<option value="immobilien">Immobilien</option>
<option value="moebel">Möbel</option>
<option value="sonstiges">Sonstiges</option>
</flux:select>
<flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." /> <flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." />
<flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." /> <flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." />
@if($mediaType === 'image') @if($mediaType === 'image')
@ -165,47 +176,79 @@
<flux:checkbox wire:model="mediaIsActive" label="Aktiv" /> <flux:checkbox wire:model="mediaIsActive" label="Aktiv" />
@endif @endif
{{-- Slide fields (Offers) --}} {{-- Slide fields (Offers) einheitliches Detail-Layout mit Ein-/Ausblende-Schaltern --}}
@if($itemType === 'slide') @if($itemType === 'slide')
{{-- Basis --}} <flux:text class="text-sm text-zinc-500 dark:text-zinc-400">
<flux:select wire:model.live="slideType" label="Slide-Typ"> {{ __('Jedes Angebot nutzt dasselbe Detail-Layout. Über die Schalter blendest du einzelne Bausteine ein oder aus und befüllst sie mit Inhalten.') }}
<option value="intro">Intro</option> </flux:text>
<option value="product-hero">Produkt-Hero</option>
<option value="product-details">Produkt-Details</option> {{-- Logo & Marke (Kopfbereich) --}}
<option value="product-impulse">Produkt-Impuls</option> <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
</flux:select> <flux:heading size="sm">{{ __('Logo & Marke') }}</flux:heading>
<flux:input wire:model="slideDuration" type="number" min="1000" label="Dauer (ms)" /> <flux:switch wire:model.live="slideShowLogo" label="Logo & Marken-Text anzeigen"
description="Kopfbereich oben mit Logo, Marken-Text und Tagline." />
@if($slideShowLogo)
<livewire:admin.cms.display-media-picker
:value="null"
field="slideLogoUrl"
type="image"
label="Logo aus Mediathek"
:key="'picker-slide-logo-' . ($itemId ?? 'new')" />
<div class="flex items-end gap-3">
<x-media-thumb :url="$slideLogoUrl" />
<flux:input wire:model.live.debounce.500ms="slideLogoUrl" label="Logo-URL" placeholder="Standard: CABINET-Logo"
description="Leer = Standard-Logo wird genutzt." class="flex-1" />
</div>
<flux:input wire:model="slideBrandText" label="Marken-Text" placeholder="z.B. Bielefeld"
description="Text direkt neben dem Logo (optional)." />
<flux:input wire:model="slideBrandTagline" label="Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage"
description="Rechts im Kopfbereich (optional)." />
@endif
</div>
{{-- Bild & Badge (wichtigstes Element farblich hervorgehoben) --}}
<div class="space-y-4 rounded-xl border-2 border-blue-300 bg-blue-50/60 p-4 ring-1 ring-blue-200 dark:border-blue-700 dark:bg-blue-950/30 dark:ring-blue-900">
<div class="flex items-center gap-2">
<flux:heading size="sm">{{ __('Bild & Badge') }}</flux:heading>
<flux:badge color="blue" size="sm">{{ __('Wichtigstes Element') }}</flux:badge>
</div>
<livewire:admin.cms.display-media-picker <livewire:admin.cms.display-media-picker
:value="null" :value="null"
field="slideImageUrl" field="slideImageUrl"
type="image" type="image"
label="Bild aus Mediathek" label="Bild aus Mediathek"
:key="'picker-slide-' . ($itemId ?? 'new')" /> :key="'picker-slide-' . ($itemId ?? 'new')" />
<flux:input wire:model="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..." <div class="flex items-end gap-3">
description="Über die Mediathek auswählen oder direkt URL eingeben." /> <x-media-thumb :url="$slideImageUrl" />
<flux:input wire:model.live.debounce.500ms="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt URL eingeben." class="flex-1" />
</div>
<flux:switch wire:model.live="slideShowBadge" label="Badge anzeigen" description="Kleine Markierung über dem Bild." />
@if($slideShowBadge)
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" /> <flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
@endif
</div>
{{-- Texte --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Texte') }}</flux:heading>
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard"
description="Hauptüberschrift des Angebots (Zeilenumbruch mit Enter möglich)." />
<flux:switch wire:model.live="slideShowEyebrow" label="Eyebrow anzeigen" description="Kleine Überzeile über dem Titel." />
@if($slideShowEyebrow)
<flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" /> <flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" />
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard" />
{{-- Intro-spezifisch --}}
@if($slideType === 'intro')
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
<flux:checkbox wire:model="slideShowBrandText" label="Brand-Text anzeigen" />
@if($slideShowBrandText)
<flux:input wire:model="slideBrandTagline" label="Brand-Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
@endif @endif
<flux:switch wire:model.live="slideShowSubline" label="Unterzeile anzeigen" />
@if($slideShowSubline)
<flux:input wire:model="slideSubline" label="Unterzeile" placeholder="z.B. Heute mitnehmen" />
@endif @endif
</div>
{{-- Product-Hero --}} {{-- Aufzählung --}}
@if($slideType === 'product-hero') <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" /> <flux:heading size="sm">{{ __('Aufzählungspunkte') }}</flux:heading>
<flux:input wire:model="slideOriginalPrice" label="Originalpreis" placeholder="z.B. statt 4.744 €" /> <flux:switch wire:model.live="slideShowBullets" label="Aufzählung anzeigen" description="Liste mit Stichpunkten, z.B. Produktdetails." />
@endif @if($slideShowBullets)
{{-- Product-Details --}}
@if($slideType === 'product-details')
<div>
<flux:heading size="sm" class="mb-2">{{ __('Aufzählungspunkte') }}</flux:heading>
<div class="space-y-2"> <div class="space-y-2">
@foreach($slideBullets as $i => $bullet) @foreach($slideBullets as $i => $bullet)
<div class="flex items-center gap-2" wire:key="bullet-{{ $i }}"> <div class="flex items-center gap-2" wire:key="bullet-{{ $i }}">
@ -214,30 +257,58 @@
</div> </div>
@endforeach @endforeach
</div> </div>
<flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus" class="mt-2"> <flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus">
{{ __('Punkt hinzufügen') }} {{ __('Punkt hinzufügen') }}
</flux:button> </flux:button>
@endif
</div> </div>
@endif
{{-- Product-Impulse --}} {{-- Preis --}}
@if($slideType === 'product-impulse') <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:input wire:model="slideSubline" label="Subline" placeholder="z.B. Heute mitnehmen" /> <flux:heading size="sm">{{ __('Preis') }}</flux:heading>
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 199 €" /> <flux:switch wire:model.live="slideShowPrice" label="Preis anzeigen" />
<flux:input wire:model="slideTagText" label="Tag-Text" placeholder="z.B. Im Store verfügbar" /> @if($slideShowPrice)
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" />
<flux:input wire:model="slideOriginalPrice" label="Originalpreis (optional)" placeholder="z.B. statt 4.744 €" />
@if(trim($slideOriginalPrice) !== '')
<flux:switch wire:model.live="slideStrikeOriginalPrice" label="Streichpreis"
description="Originalpreis wird rot durchgestrichen dargestellt." />
@endif @endif
<flux:input wire:model="slideTagText" label="Hinweis-Tag (optional)" placeholder="z.B. Im Store verfügbar" />
@endif
</div>
{{-- QR --}} {{-- Hinweis --}}
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4 mt-2"> <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm" class="mb-3">{{ __('QR-Code & Kontakt') }}</flux:heading> <flux:heading size="sm">{{ __('Hinweis') }}</flux:heading>
<div class="space-y-4"> <flux:switch wire:model.live="slideShowDisclaimer" label="Disclaimer anzeigen" />
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de" /> @if($slideShowDisclaimer)
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
@endif
</div>
{{-- QR & Kontakt --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('QR-Code & Kontakt') }}</flux:heading>
<flux:switch wire:model.live="slideShowQr" label="QR-Code anzeigen" />
@if($slideShowQr)
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de"
description="Leer = es wird die Web/QR-URL aus den Einstellungen genutzt." />
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" /> <flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
@endif
<flux:switch wire:model.live="slideShowContact" label="Kontakt anzeigen" />
@if($slideShowContact)
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" /> <flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
</div> @endif
</div> </div>
<flux:checkbox wire:model="slideIsActive" label="Aktiv" /> {{-- Anzeige --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Anzeige') }}</flux:heading>
<flux:input wire:model="slideDuration" type="number" min="1000" step="500" label="Dauer (ms)"
description="Wie lange dieses Angebot eingeblendet wird." />
<flux:switch wire:model="slideIsActive" label="Aktiv" description="Inaktive Angebote werden im Display übersprungen." />
</div>
@endif @endif
<div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700"> <div class="space-y-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
@ -246,22 +317,37 @@
<flux:subheading>{{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}</flux:subheading> <flux:subheading>{{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}</flux:subheading>
</div> </div>
<div class="mx-auto aspect-[9/16] w-full max-w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-black shadow-sm dark:border-zinc-700"> {{-- Stable iframe element: only its `src` changes between the
"new" (about:blank) and "saved" state. Swapping the element
structure inside the teleported Flux modal crashes Livewire's
morph/cleanup, so we keep the DOM shape constant. --}}
<div class="relative mx-auto aspect-[9/16] w-full max-w-[320px] overflow-hidden rounded-xl border border-zinc-200 bg-black shadow-sm dark:border-zinc-700">
<iframe <iframe
wire:key="item-modal-module-preview-{{ $previewFrameRefreshCounter }}" wire:key="item-modal-preview"
src="{{ $this->itemPreviewUrl() }}" src="{{ $itemId ? $this->itemPreviewUrl() : 'about:blank' }}"
class="h-full w-full border-0" class="h-full w-full border-0"
loading="lazy" loading="lazy"
title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}" title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}"
></iframe> ></iframe>
@unless($itemId)
<div class="absolute inset-0 flex items-center justify-center bg-zinc-50 text-center dark:bg-zinc-900">
<div class="px-6 text-sm text-zinc-500 dark:text-zinc-400">
<flux:icon.eye class="mx-auto mb-2 h-8 w-8 opacity-40" />
{{ __('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.') }}
</div>
</div>
@endunless
</div> </div>
@if($itemId)
<a href="{{ $this->itemPreviewUrl() }}" <a href="{{ $this->itemPreviewUrl() }}"
target="_blank" target="_blank"
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400"> class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
<flux:icon.arrow-top-right-on-square class="w-3 h-3" /> <flux:icon.arrow-top-right-on-square class="w-3 h-3" />
{{ __('Vollbild öffnen') }} {{ __('Vollbild öffnen') }}
</a> </a>
@endif
</div> </div>
<div class="flex flex-wrap justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700"> <div class="flex flex-wrap justify-end gap-3 border-t border-zinc-200 pt-4 dark:border-zinc-700">

View file

@ -10,6 +10,15 @@
</x-success-alert> </x-success-alert>
@endif @endif
@if (session()->has('error'))
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<div class="flex items-start gap-3">
<flux:icon.exclamation-circle class="mt-0.5 h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
<p class="text-sm font-medium text-red-800 dark:text-red-200">{{ session('error') }}</p>
</div>
</div>
@endif
<flux:card> <flux:card>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>

View file

@ -6,18 +6,18 @@
<flux:card> <flux:card>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<flux:heading size="lg">{{ __('Slides') }}</flux:heading> <flux:heading size="lg">{{ __('Angebote') }}</flux:heading>
<flux:subheading>{{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }}</flux:subheading> <flux:subheading>{{ __('Angebote werden im einheitlichen Detail-Layout in der angegebenen Reihenfolge angezeigt') }}</flux:subheading>
</div> </div>
<flux:button wire:click="openItemModal(null, 'slide')" icon="plus"> <flux:button wire:click="openItemModal(null, 'slide')" icon="plus">
{{ __('Slide hinzufügen') }} {{ __('Angebot hinzufügen') }}
</flux:button> </flux:button>
</div> </div>
@if($slides->isEmpty()) @if($slides->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400"> <div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" /> <flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Slides vorhanden.') }}</p> <p>{{ __('Noch keine Angebote vorhanden.') }}</p>
</div> </div>
@else @else
<div class="space-y-3"> <div class="space-y-3">
@ -42,29 +42,30 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
@php
$c = $item->content;
$enabledBlocks = collect([
'Badge' => ($c['show_badge'] ?? ! empty($c['badge_text'])) && ! empty($c['badge_text']),
'Aufzählung' => ($c['show_bullets'] ?? ! empty($c['bullets'])) && ! empty($c['bullets']),
'Preis' => ($c['show_price'] ?? ! empty($c['price'])) && ! empty($c['price']),
'QR' => ($c['show_qr'] ?? ! empty($c['qr_url'])) && ! empty($c['qr_url']),
'Kontakt' => ($c['show_contact'] ?? ! empty($c['contact'])) && ! empty($c['contact']),
])->filter()->keys();
@endphp
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm"> <flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge> </flux:badge>
<flux:badge color="amber" size="sm"> <span class="font-semibold text-sm truncate">{{ $c['title'] ?? '' }}</span>
{{ match($item->content['type'] ?? '') {
'intro' => 'Intro',
'product-hero' => 'Produkt-Hero',
'product-details' => 'Produkt-Details',
'product-impulse' => 'Produkt-Impuls',
default => $item->content['type'] ?? '',
} }}
</flux:badge>
<span class="font-semibold text-sm">{{ $item->content['title'] ?? '' }}</span>
</div> </div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4"> <div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
@if(!empty($item->content['price'])) <span>{{ number_format(($c['duration'] ?? 8000) / 1000, 1) }}s</span>
<span class="font-medium">{{ $item->content['price'] }}</span> @if(!empty($c['price']) && ($c['show_price'] ?? ! empty($c['price'])))
@endif <span class="font-medium">{{ $c['price'] }}</span>
<span>{{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s</span>
@if(!empty($item->content['badge_text']))
<span>{{ $item->content['badge_text'] }}</span>
@endif @endif
@foreach($enabledBlocks as $block)
<flux:badge color="zinc" size="sm">{{ $block }}</flux:badge>
@endforeach
</div> </div>
</div> </div>

View file

@ -1,25 +1,74 @@
@if($version->type->value === 'b2in') @if($version->type->value === 'b2in')
@php
$footerShown = ($settings['show_footer'] ?? true) !== false;
$showLogo = ($settings['show_logo'] ?? true) !== false;
$showClaim = ($settings['show_claim'] ?? true) !== false;
$logoPos = $settings['logo_position'] ?? 'top-left';
$claimPos = $settings['claim_position'] ?? 'top-right';
$brandPositions = [
'top-left' => __('Oben links'),
'top-right' => __('Oben rechts'),
'bottom-left' => __('Unten links'),
'bottom-right' => __('Unten rechts'),
];
@endphp
<flux:select wire:model="settings.theme" label="Theme"> <flux:select wire:model="settings.theme" label="Theme">
<option value="dark">Dark</option> <option value="dark">Dark</option>
<option value="light">Light</option> <option value="light">Light</option>
</flux:select> </flux:select>
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700"> <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Header') }}</flux:heading> <flux:heading size="sm">{{ __('Marke') }}</flux:heading>
<flux:subheading>{{ __('Logo und Claim. Standardmäßig oben im Header. Die Ecken lassen sich frei wählen.') }}</flux:subheading>
<livewire:admin.cms.display-media-picker <livewire:admin.cms.display-media-picker
:value="null" :value="null"
field="settings.header_logo_url" field="settings.header_logo_url"
type="image" type="image"
label="Header-Logo aus Mediathek" label="Logo aus Mediathek"
:key="'picker-b2in-header-logo-' . $context . '-' . $version->id" /> :key="'picker-b2in-header-logo-' . $context . '-' . $version->id" />
<flux:input wire:model="settings.header_logo_url" label="Header-Logo URL" placeholder="../assets/b2in-logo-positive.svg" /> <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" />
</div>
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" /> <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" />
</div>
@if($showLogo)
<flux:select wire:model.live="settings.logo_position" label="Logo-Position">
@foreach($brandPositions as $value => $label)
<option value="{{ $value }}" @disabled($footerShown && str_starts_with($value, 'bottom'))>{{ $label }}</option>
@endforeach
</flux:select>
@endif
@if($showClaim)
<flux:select wire:model.live="settings.claim_position" label="Claim-Position">
@foreach($brandPositions as $value => $label)
<option value="{{ $value }}"
@disabled(($showLogo && $value === $logoPos) || ($footerShown && str_starts_with($value, 'bottom')))>
{{ $label }}{{ ($showLogo && $value === $logoPos) ? ' '.__('(Logo)') : '' }}
</option>
@endforeach
</flux:select>
@endif
@if($footerShown)
<flux:callout variant="secondary" icon="information-circle">
<flux:callout.text>{{ __('Untere Ecken sind nur verfügbar, wenn der Footer ausgeblendet ist.') }}</flux:callout.text>
</flux:callout>
@endif
</div> </div>
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700"> <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:heading size="sm">{{ __('Footer & QR') }}</flux:heading>
<flux:switch wire:model.live="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_prefix" label="Footer-Präfix" placeholder="by" />
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" /> <flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
<flux:input wire:model="settings.footer_url" label="Footer Domain" placeholder="z.B. b2in.de" /> <flux:input wire:model="settings.footer_url" label="Footer Domain" placeholder="z.B. b2in.eu" />
<flux:input wire:model="settings.qr_url" label="QR-URL (optional)" placeholder="https://b2in.de" <flux:input wire:model="settings.qr_url" label="QR-URL (optional)" placeholder="https://b2in.e"
description="Leer = QR-Code nutzt die Footer-Domain." /> description="Leer = QR-Code nutzt die Footer-Domain." />
</div> </div>
<flux:select wire:model="settings.transition.type" label="Transition"> <flux:select wire:model="settings.transition.type" label="Transition">
@ -32,30 +81,14 @@
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" /> <flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
@elseif($version->type->value === 'offers') @elseif($version->type->value === 'offers')
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" /> <flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Branding') }}</flux:heading>
<livewire:admin.cms.display-media-picker
:value="null"
field="settings.logo_url"
type="image"
label="Logo aus Mediathek"
:key="'picker-offers-logo-' . $context . '-' . $version->id" />
<flux:input wire:model="settings.logo_url" label="Logo URL" placeholder="../logo-cabinet-300.png" />
<flux:input wire:model="settings.brand_text" label="Brand-Text" placeholder="Bielefeld" />
</div>
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Footer & QR für alle Slides') }}</flux:heading>
<flux:input wire:model="settings.footer_claim" label="Footer-Claim" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
<flux:input wire:model="settings.footer_url" label="Web/QR-URL" placeholder="https://cabinet-bielefeld.de"
description="Wird als QR-Ziel genutzt, wenn der einzelne Slide keine eigene QR-URL hat." />
<flux:input wire:model="settings.qr_default_title" label="Standard QR-Titel" placeholder="Kontakt" />
<flux:input wire:model="settings.qr_subtitle" label="QR-Unterzeile" placeholder="QR scannen" />
</div>
<flux:select wire:model="settings.transition.type" label="Transition"> <flux:select wire:model="settings.transition.type" label="Transition">
<option value="fade">Fade</option> <option value="fade">Fade</option>
<option value="slide">Slide</option> <option value="slide">Slide</option>
</flux:select> </flux:select>
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" /> <flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
<flux:callout variant="secondary" icon="information-circle">
<flux:callout.text>{{ __('Logo, Marken-Text, QR-Code und Kontakt werden je Angebot direkt am Element gepflegt.') }}</flux:callout.text>
</flux:callout>
@elseif($version->type->value === 'video-display') @elseif($version->type->value === 'video-display')
<flux:input wire:model="settings.qr_label" label="QR-Label im Footer" placeholder="Website" /> <flux:input wire:model="settings.qr_label" label="QR-Label im Footer" placeholder="Website" />
@endif @endif

View file

@ -16,40 +16,43 @@
</flux:button> </flux:button>
</div> </div>
@if($videos->isEmpty()) @if ($videos->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400"> <div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" /> <flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Videos vorhanden.') }}</p> <p>{{ __('Noch keine Videos vorhanden.') }}</p>
</div> </div>
@else @else
<div class="space-y-3"> <div class="space-y-3">
@foreach($videos as $index => $item) @foreach ($videos as $index => $item)
<div wire:key="item-{{ $item->id }}" <div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition"> class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@if($index > 0) @if ($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button> <flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost"
icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif @endif
@if($index < count($videos) - 1) @if ($index < count($videos) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button> <flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs"
variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif @endif
</div> </div>
<div class="flex h-16 w-12 shrink-0 items-center justify-center rounded-lg bg-black text-[10px] font-semibold uppercase text-white"> <x-media-thumb :url="$item->content['filename'] ?? ''" size="h-16 w-12" />
Video
</div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1"> <div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm"> <flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} {{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge> </flux:badge>
<span class="font-semibold text-sm">{{ $item->content['title'] ?? $item->content['filename'] ?? '' }}</span> <span
class="font-semibold text-sm">{{ $item->content['title'] ?? ($item->content['filename'] ?? '') }}</span>
</div> </div>
@php @php
$videoSource = $item->content['filename'] ?? ''; $videoSource = $item->content['filename'] ?? '';
$isMediaLibrarySource = str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http'); $isMediaLibrarySource =
str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http');
@endphp @endphp
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400"> <div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'"> <flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
@ -61,9 +64,13 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button> <flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost"
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button> :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button> <flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost"
icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})"
wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost"
icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div> </div>
</div> </div>
@endforeach @endforeach
@ -76,34 +83,40 @@
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading> <flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
<flux:subheading>{{ __('Inhalte werden im Footer rotiert') }}</flux:subheading> <flux:subheading>{{ __('Inhalte werden im Footer rotiert / ohne Inhalte bleibt der untere Teil frei.') }}
</flux:subheading>
</div> </div>
<flux:button wire:click="openItemModal(null, 'footer')" icon="plus"> <flux:button wire:click="openItemModal(null, 'footer')" icon="plus">
{{ __('Inhalt hinzufügen') }} {{ __('Inhalt hinzufügen') }}
</flux:button> </flux:button>
</div> </div>
@if($footers->isEmpty()) @if ($footers->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400"> <div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" /> <flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p> <p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p>
</div> </div>
@else @else
<div class="space-y-3"> <div class="space-y-3">
@foreach($footers as $index => $item) @foreach ($footers as $index => $item)
<div wire:key="item-{{ $item->id }}" <div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition"> class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@if($index > 0) @if ($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button> <flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs"
variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif @endif
@if($index < count($footers) - 1) @if ($index < count($footers) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button> <flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs"
variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif @endif
</div> </div>
<div class="flex h-16 w-12 shrink-0 flex-col justify-end rounded-lg bg-zinc-900 p-1 text-[8px] text-white"> <div
class="flex h-16 w-12 shrink-0 flex-col justify-end rounded-lg bg-zinc-900 p-1 text-[8px] text-white">
<div class="truncate text-zinc-400">{{ $item->content['headline'] ?? 'Footer' }}</div> <div class="truncate text-zinc-400">{{ $item->content['headline'] ?? 'Footer' }}</div>
<div class="truncate font-semibold">{{ $item->content['subline'] ?? '' }}</div> <div class="truncate font-semibold">{{ $item->content['subline'] ?? '' }}</div>
</div> </div>
@ -117,16 +130,20 @@
</div> </div>
<div class="text-xs text-zinc-600 dark:text-zinc-400"> <div class="text-xs text-zinc-600 dark:text-zinc-400">
{{ $item->content['subline'] ?? '' }} {{ $item->content['subline'] ?? '' }}
@if(!empty($item->content['url'])) @if (!empty($item->content['url']))
<span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span> <span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span>
@endif @endif
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button> <flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost"
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button> :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button> <flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost"
icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})"
wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost"
icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div> </div>
</div> </div>
@endforeach @endforeach

View file

@ -35,26 +35,26 @@ mount(function ($hubId = null) {
// Auto-generate slug from name // Auto-generate slug from name
$updatedName = function ($value) { $updatedName = function ($value) {
if (!$this->hubId) { // Only auto-generate for new hubs if (!$this->hubId) {
// Only auto-generate for new hubs
$this->slug = \Illuminate\Support\Str::slug($value); $this->slug = \Illuminate\Support\Str::slug($value);
} }
}; };
$locations = computed(function () { $locations = computed(function () {
if (!$this->hubId) return collect(); if (!$this->hubId) {
return collect();
}
return \App\Models\HubLocation::where('hub_id', $this->hubId) return \App\Models\HubLocation::where('hub_id', $this->hubId)->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%")->orWhere('city_name', 'like', "%{$this->zipSearch}%"))->orderBy('zip_code')->paginate(50);
->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%")
->orWhere('city_name', 'like', "%{$this->zipSearch}%"))
->orderBy('zip_code')
->paginate(50);
}); });
$partners = computed(function () { $partners = computed(function () {
if (!$this->hubId) return collect(); if (!$this->hubId) {
return collect();
}
return \App\Models\Partner::where('hub_id', $this->hubId) return \App\Models\Partner::where('hub_id', $this->hubId)->get();
->get();
}); });
// Dummy save function // Dummy save function
@ -119,7 +119,7 @@ $deleteLocation = function ($id) {
</flux:tabs> </flux:tabs>
{{-- TAB 1: Identität & Design --}} {{-- TAB 1: Identität & Design --}}
@if($activeTab === 'identity') @if ($activeTab === 'identity')
<flux:card class="p-6 space-y-6"> <flux:card class="p-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- Basis-Informationen --}} {{-- Basis-Informationen --}}
@ -127,13 +127,14 @@ $deleteLocation = function ($id) {
<flux:field> <flux:field>
<flux:label>{{ __('Hub-Name') }} <span class="text-red-500">*</span></flux:label> <flux:label>{{ __('Hub-Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model.live="name" placeholder="Ostwestfalen-Lippe" /> <flux:input wire:model.live="name" placeholder="Ostwestfalen-Lippe" />
<flux:description>{{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }}</flux:description> <flux:description>{{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }}
</flux:description>
</flux:field> </flux:field>
<flux:field> <flux:field>
<flux:label>{{ __('URL-Slug') }} <span class="text-red-500">*</span></flux:label> <flux:label>{{ __('URL-Slug') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="slug" placeholder="owl" /> <flux:input wire:model="slug" placeholder="owl" />
<flux:description>{{ __('Für saubere URLs, z.B. b2in.de/region/owl') }}</flux:description> <flux:description>{{ __('Für saubere URLs, z.B. b2in.eu/region/owl') }}</flux:description>
</flux:field> </flux:field>
<flux:field> <flux:field>
@ -149,9 +150,10 @@ $deleteLocation = function ($id) {
{{-- Vorschau --}} {{-- Vorschau --}}
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg"> <div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<h4 class="font-semibold mb-3 text-zinc-900 dark:text-zinc-100">{{ __('Vorschau: Kunden-Landingpage') }}</h4> <h4 class="font-semibold mb-3 text-zinc-900 dark:text-zinc-100">
{{ __('Vorschau: Kunden-Landingpage') }}</h4>
<div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg"> <div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg">
@if($keyvisual) @if ($keyvisual)
<img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" /> <img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" />
@else @else
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
@ -169,7 +171,7 @@ $deleteLocation = function ($id) {
{{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }} {{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }}
</p> </p>
</div> </div>
@if($emblem) @if ($emblem)
<div class="absolute top-3 right-3 w-12 h-12 bg-white rounded-full p-2 shadow-lg"> <div class="absolute top-3 right-3 w-12 h-12 bg-white rounded-full p-2 shadow-lg">
<img src="{{ $emblem }}" alt="Emblem" class="w-full h-full object-contain" /> <img src="{{ $emblem }}" alt="Emblem" class="w-full h-full object-contain" />
</div> </div>
@ -211,7 +213,7 @@ $deleteLocation = function ($id) {
@endif @endif
{{-- TAB 2: Geografie & PLZ --}} {{-- TAB 2: Geografie & PLZ --}}
@if($activeTab === 'geography') @if ($activeTab === 'geography')
<div class="space-y-6"> <div class="space-y-6">
{{-- Info-Box --}} {{-- Info-Box --}}
@ -241,11 +243,12 @@ $deleteLocation = function ($id) {
<div class="mt-6"> <div class="mt-6">
{{-- Einzelne PLZ --}} {{-- Einzelne PLZ --}}
@if($importMethod === 'single') @if ($importMethod === 'single')
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<flux:field> <flux:field>
<flux:label>{{ __('Postleitzahl') }} <span class="text-red-500">*</span></flux:label> <flux:label>{{ __('Postleitzahl') }} <span class="text-red-500">*</span>
</flux:label>
<flux:input wire:model="newZipCode" placeholder="33602" /> <flux:input wire:model="newZipCode" placeholder="33602" />
</flux:field> </flux:field>
<flux:field> <flux:field>
@ -260,7 +263,7 @@ $deleteLocation = function ($id) {
@endif @endif
{{-- PLZ-Bereich --}} {{-- PLZ-Bereich --}}
@if($importMethod === 'range') @if ($importMethod === 'range')
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<flux:field> <flux:field>
@ -276,13 +279,14 @@ $deleteLocation = function ($id) {
{{ __('Bereich importieren') }} {{ __('Bereich importieren') }}
</flux:button> </flux:button>
<flux:description> <flux:description>
⚠️ {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }} ⚠️
{{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }}
</flux:description> </flux:description>
</div> </div>
@endif @endif
{{-- CSV-Import --}} {{-- CSV-Import --}}
@if($importMethod === 'csv') @if ($importMethod === 'csv')
<div class="space-y-4"> <div class="space-y-4">
<flux:field> <flux:field>
<flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label> <flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label>
@ -310,21 +314,18 @@ $deleteLocation = function ($id) {
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<flux:heading size="lg"> <flux:heading size="lg">
{{ __('Zugeordnete Postleitzahlen') }} {{ __('Zugeordnete Postleitzahlen') }}
@if($hubId) @if ($hubId)
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400"> <span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
({{ $this->locations->total() }} {{ __('gesamt') }}) ({{ $this->locations->total() }} {{ __('gesamt') }})
</span> </span>
@endif @endif
</flux:heading> </flux:heading>
<flux:input <flux:input wire:model.live.debounce="zipSearch"
wire:model.live.debounce="zipSearch" placeholder="{{ __('PLZ oder Stadt suchen...') }}" icon="magnifying-glass"
placeholder="{{ __('PLZ oder Stadt suchen...') }}" class="w-64" />
icon="magnifying-glass"
class="w-64"
/>
</div> </div>
@if($hubId) @if ($hubId)
<flux:table> <flux:table>
<flux:table.columns> <flux:table.columns>
<flux:table.column>{{ __('PLZ') }}</flux:table.column> <flux:table.column>{{ __('PLZ') }}</flux:table.column>
@ -340,12 +341,8 @@ $deleteLocation = function ($id) {
</flux:table.cell> </flux:table.cell>
<flux:table.cell>{{ $location->city_name }}</flux:table.cell> <flux:table.cell>{{ $location->city_name }}</flux:table.cell>
<flux:table.cell class="text-right"> <flux:table.cell class="text-right">
<flux:button <flux:button variant="ghost" size="sm" icon="trash"
variant="ghost" wire:click="deleteLocation({{ $location->id }})" />
size="sm"
icon="trash"
wire:click="deleteLocation({{ $location->id }})"
/>
</flux:table.cell> </flux:table.cell>
</flux:table.row> </flux:table.row>
@empty @empty
@ -362,7 +359,7 @@ $deleteLocation = function ($id) {
</flux:table> </flux:table>
{{-- Pagination --}} {{-- Pagination --}}
@if($this->locations->hasPages()) @if ($this->locations->hasPages())
<div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4"> <div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4">
{{ $this->locations->links() }} {{ $this->locations->links() }}
</div> </div>
@ -378,11 +375,11 @@ $deleteLocation = function ($id) {
@endif @endif
{{-- TAB 3: Partner-Monitor --}} {{-- TAB 3: Partner-Monitor --}}
@if($activeTab === 'partners') @if ($activeTab === 'partners')
<flux:card class="p-6"> <flux:card class="p-6">
<flux:heading size="lg" class="mb-4"> <flux:heading size="lg" class="mb-4">
{{ __('Partner in diesem Hub') }} {{ __('Partner in diesem Hub') }}
@if($hubId) @if ($hubId)
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400"> <span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
({{ $this->partners->count() }} {{ __('Partner') }}) ({{ $this->partners->count() }} {{ __('Partner') }})
</span> </span>
@ -401,7 +398,7 @@ $deleteLocation = function ($id) {
</div> </div>
</flux:card> </flux:card>
@if($hubId) @if ($hubId)
<flux:table> <flux:table>
<flux:table.columns> <flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column> <flux:table.column>{{ __('Name') }}</flux:table.column>
@ -419,7 +416,7 @@ $deleteLocation = function ($id) {
<div class="font-semibold text-zinc-900 dark:text-zinc-100"> <div class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ $partner->company_name }} {{ $partner->company_name }}
</div> </div>
@if($partner->display_name && $partner->display_name !== $partner->company_name) @if ($partner->display_name && $partner->display_name !== $partner->company_name)
<div class="text-xs text-zinc-500">{{ $partner->display_name }}</div> <div class="text-xs text-zinc-500">{{ $partner->display_name }}</div>
@endif @endif
</flux:table.cell> </flux:table.cell>
@ -439,7 +436,7 @@ $deleteLocation = function ($id) {
{{ $partner->city ?? '-' }} {{ $partner->city ?? '-' }}
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
@if($partner->delivery_radius_km) @if ($partner->delivery_radius_km)
<span class="text-sm">{{ $partner->delivery_radius_km }} km</span> <span class="text-sm">{{ $partner->delivery_radius_km }} km</span>
@else @else
<span class="text-sm text-zinc-400">-</span> <span class="text-sm text-zinc-400">-</span>
@ -479,4 +476,3 @@ $deleteLocation = function ($id) {
</flux:card> </flux:card>
@endif @endif
</div> </div>

View file

@ -197,7 +197,7 @@ test('can create a draft playlist from live modules', function () {
test('can discard a draft playlist', function () { test('can discard a draft playlist', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(); $version = DisplayVersion::factory()->create();
$display = Display::factory()->create(); $display = Display::factory()->create(['preview_token' => 'token-discard-123456789012345678901234']);
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]); createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
Livewire::actingAs($user) Livewire::actingAs($user)
@ -205,6 +205,57 @@ test('can discard a draft playlist', function () {
->call('discardDraft', $display->id); ->call('discardDraft', $display->id);
expect($display->fresh()->draftPlaylist)->toBeNull(); expect($display->fresh()->draftPlaylist)->toBeNull();
expect($display->fresh()->preview_token)->toBeNull();
});
test('publishing a draft clears the preview token', function () {
$user = User::factory()->create();
$liveVersion = DisplayVersion::factory()->create();
$draftVersion = DisplayVersion::factory()->create();
$display = Display::factory()->create(['preview_token' => 'token-publish-12345678901234567890123456']);
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]);
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]);
Livewire::actingAs($user)
->test(DisplayList::class)
->call('publishDraft', $display->id);
expect($display->fresh()->preview_token)->toBeNull();
});
test('can rotate the preview token of a draft', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create();
$display = Display::factory()->create(['preview_token' => 'token-old-1234567890123456789012345678901']);
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]);
Livewire::actingAs($user)
->test(DisplayList::class)
->call('rotatePreviewToken', $display->id);
$newToken = $display->fresh()->preview_token;
expect($newToken)->not->toBeNull()
->and($newToken)->not->toBe('token-old-1234567890123456789012345678901');
});
test('can add multiple modules to a playlist at once', function () {
$user = User::factory()->create();
$version1 = DisplayVersion::factory()->create();
$version2 = DisplayVersion::factory()->create();
$version3 = DisplayVersion::factory()->create();
$display = Display::factory()->create();
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, []);
Livewire::actingAs($user)
->test(DisplayList::class)
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
->set('versionsToAdd', [$version1->id, $version3->id])
->call('addSelectedVersions')
->assertSet('selectedVersionIds', [$version1->id, $version3->id])
->assertSet('versionsToAdd', []);
expect(true)->toBeTrue();
}); });
test('can publish a draft playlist over the live playlist', function () { test('can publish a draft playlist over the live playlist', function () {
@ -313,8 +364,8 @@ test('module select only shows modules that can still be added', function () {
Livewire::actingAs($user) Livewire::actingAs($user)
->test(DisplayList::class) ->test(DisplayList::class)
->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED) ->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED)
->assertDontSeeHtml('<option value="'.$selectedVersion->id.'">Schon gewählt') ->assertDontSee('Schon gewählt (')
->assertSeeHtml('<option value="'.$availableVersion->id.'">Noch verfügbar'); ->assertSee('Noch verfügbar (');
}); });
test('module select is replaced by a hint when all modules are already selected', function () { test('module select is replaced by a hint when all modules are already selected', function () {

View file

@ -14,6 +14,31 @@ beforeEach(function () {
Storage::fake('public'); Storage::fake('public');
}); });
/**
* Create a real, tiny mp4 on disk using ffmpeg and return the path.
* Returns null when ffmpeg is unavailable so callers can skip.
*/
function makeRealTestVideo(): ?string
{
$ffmpeg = trim((string) shell_exec('command -v ffmpeg'));
if ($ffmpeg === '') {
return null;
}
$path = tempnam(sys_get_temp_dir(), 'vid_').'.mp4';
$command = sprintf(
'%s -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -pix_fmt yuv420p %s 2>/dev/null',
escapeshellarg($ffmpeg),
escapeshellarg($path)
);
shell_exec($command);
return is_file($path) && filesize($path) > 0 ? $path : null;
}
// ======================================== // ========================================
// MODEL TESTS // MODEL TESTS
// ======================================== // ========================================
@ -66,6 +91,20 @@ it('returns display name from title or filename', function () {
expect($withoutTitle->getDisplayName())->toBe('file.mp4'); expect($withoutTitle->getDisplayName())->toBe('file.mp4');
}); });
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',
]);
expect($media->getThumbnailUrl())->toContain('display-media/2026/05/tour-poster.jpg');
});
it('has no thumbnail url for a video without a poster', function () {
$media = DisplayMedia::factory()->video()->create(['thumbnail_path' => null]);
expect($media->getThumbnailUrl())->toBeNull();
});
// ======================================== // ========================================
// SCOPE TESTS // SCOPE TESTS
// ======================================== // ========================================
@ -105,6 +144,41 @@ it('filters by collection scope', function () {
expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1); expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1);
}); });
it('keeps preceding filters when combined with the search scope', function () {
DisplayMedia::factory()->create([
'type' => 'image',
'filename' => 'haus.webp',
'title' => 'Schaufenster',
]);
DisplayMedia::factory()->video()->create([
'filename' => 'tour.mp4',
'title' => 'Haus Tour',
]);
$results = DisplayMedia::images()->search('haus')->get();
expect($results)->toHaveCount(1)
->and($results->first()->filename)->toBe('haus.webp');
});
it('search scope respects the active filter on the media picker', function () {
DisplayMedia::factory()->create([
'filename' => 'aktiv.webp',
'title' => 'Goya',
'is_active' => true,
]);
DisplayMedia::factory()->create([
'filename' => 'inaktiv.webp',
'title' => 'Goya',
'is_active' => false,
]);
$results = DisplayMedia::active()->search('goya')->get();
expect($results)->toHaveCount(1)
->and($results->first()->filename)->toBe('aktiv.webp');
});
it('filters by active scope', function () { it('filters by active scope', function () {
DisplayMedia::factory()->create(['is_active' => true]); DisplayMedia::factory()->create(['is_active' => true]);
DisplayMedia::factory()->create(['is_active' => false]); DisplayMedia::factory()->create(['is_active' => false]);
@ -142,6 +216,33 @@ it('stores a video upload', function () {
->and($media->mime_type)->toBe('video/mp4'); ->and($media->mime_type)->toBe('video/mp4');
}); });
it('generates a poster thumbnail when a real video is uploaded', function () {
$videoPath = makeRealTestVideo();
if ($videoPath === null) {
$this->markTestSkipped('ffmpeg is not available.');
}
$service = app(DisplayMediaService::class);
$file = new UploadedFile($videoPath, 'showroom.mp4', 'video/mp4', null, true);
$media = $service->storeUpload($file);
expect($media->thumbnail_path)->not->toBeNull();
expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue();
@unlink($videoPath);
});
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');
$media = $service->storeUpload($file);
expect($media->thumbnail_path)->toBeNull();
});
it('stores an svg upload as image media', function () { it('stores an svg upload as image media', function () {
$service = app(DisplayMediaService::class); $service = app(DisplayMediaService::class);
$file = UploadedFile::fake()->createWithContent( $file = UploadedFile::fake()->createWithContent(
@ -158,6 +259,44 @@ it('stores an svg upload as image media', function () {
expect(Storage::disk('public')->exists($media->path))->toBeTrue(); expect(Storage::disk('public')->exists($media->path))->toBeTrue();
}); });
it('resets pagination to page one when a filter changes', function () {
Volt::test('admin.cms.display-media-library')
->call('gotoPage', 3)
->set('filterType', 'video')
->assertSet('paginators.page', 1);
});
it('renders external image media with an inline thumbnail', function () {
$media = DisplayMedia::factory()->external()->create([
'type' => 'image',
'external_url' => 'https://example.com/foto.jpg',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('src="https://example.com/foto.jpg"');
});
it('renders a stored poster image for a video with a thumbnail', function () {
DisplayMedia::factory()->video()->create([
'path' => 'display-media/2026/05/tour.mp4',
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('tour-poster.jpg');
});
it('renders an inline video frame for an uploaded video without a poster', function () {
DisplayMedia::factory()->video()->create([
'path' => 'display-media/2026/05/clip.mp4',
'thumbnail_path' => null,
'mime_type' => 'video/mp4',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('clip.mp4#t=1');
});
it('accepts svg uploads in the display media library', function () { it('accepts svg uploads in the display media library', function () {
$file = UploadedFile::fake()->createWithContent( $file = UploadedFile::fake()->createWithContent(
'brand.svg', 'brand.svg',
@ -237,6 +376,42 @@ it('deletes external media record', function () {
expect(DisplayMedia::find($media->id))->toBeNull(); expect(DisplayMedia::find($media->id))->toBeNull();
}); });
// ========================================
// COMMAND TESTS
// ========================================
it('reports when there are no videos to process', function () {
$this->artisan('display-media:generate-video-thumbnails')
->expectsOutputToContain('Keine Videos zum Verarbeiten gefunden.')
->assertSuccessful();
});
it('backfills posters for existing uploaded videos', function () {
$videoPath = makeRealTestVideo();
if ($videoPath === null) {
$this->markTestSkipped('ffmpeg is not available.');
}
Storage::disk('public')->putFileAs(
'display-media/2026/05',
new UploadedFile($videoPath, 'clip.mp4', 'video/mp4', null, true),
'clip.mp4'
);
$media = DisplayMedia::factory()->video()->create([
'source_type' => 'upload',
'path' => 'display-media/2026/05/clip.mp4',
'thumbnail_path' => null,
]);
$this->artisan('display-media:generate-video-thumbnails')->assertSuccessful();
expect($media->fresh()->thumbnail_path)->not->toBeNull();
@unlink($videoPath);
});
// ======================================== // ========================================
// ROUTE TESTS // ROUTE TESTS
// ======================================== // ========================================

View file

@ -115,9 +115,45 @@ test('returns playlist with b2in config', function () {
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertJsonPath('playlist.0.type', 'b2in'); $response->assertJsonPath('playlist.0.type', 'b2in');
$response->assertJsonPath('playlist.0.settings.theme', 'dark'); $response->assertJsonPath('playlist.0.settings.theme', 'dark');
$response->assertJsonPath('playlist.0.settings.show_footer', true);
$response->assertJsonPath('playlist.0.settings.logo_position', 'top-left');
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-right');
$response->assertJsonPath('playlist.0.items.0.category', 'immobilien'); $response->assertJsonPath('playlist.0.items.0.category', 'immobilien');
}); });
test('b2in config passes through custom brand positions and footer toggle', function () {
$version = DisplayVersion::factory()->create([
'type' => 'b2in',
'settings' => [
'show_footer' => false,
'logo_position' => 'bottom-right',
'claim_position' => 'top-left',
],
]);
DisplayVersionItem::factory()->create([
'display_version_id' => $version->id,
'item_type' => 'media',
'content' => [
'category' => 'sonstiges',
'media_type' => 'image',
'media_url' => '../assets/test.jpg',
'headline' => 'H',
'subline' => 'S',
'duration_seconds' => 10,
],
]);
$display = Display::factory()->create();
publishDisplayModules($display, [$version->id]);
$response = $this->getJson("/api/display/{$display->id}/config");
$response->assertSuccessful();
$response->assertJsonPath('playlist.0.settings.show_footer', false);
$response->assertJsonPath('playlist.0.settings.logo_position', 'bottom-right');
$response->assertJsonPath('playlist.0.settings.claim_position', 'top-left');
$response->assertJsonPath('playlist.0.items.0.category', 'sonstiges');
});
test('returns playlist with offers config', function () { test('returns playlist with offers config', function () {
$version = DisplayVersion::factory()->create([ $version = DisplayVersion::factory()->create([
'type' => 'offers', 'type' => 'offers',
@ -320,38 +356,32 @@ test('module preview returns a single module config', function () {
$response->assertJsonPath('playlist.0.items.0.category', 'moebel'); $response->assertJsonPath('playlist.0.items.0.category', 'moebel');
}); });
test('module preview exposes configurable player chrome settings', function () { test('offers slides carry their own logo and brand chrome', function () {
$version = DisplayVersion::factory()->create([ $version = DisplayVersion::factory()->create([
'type' => 'offers', 'type' => 'offers',
'name' => 'Custom Chrome', 'name' => 'Custom Chrome',
'settings' => [
'logo_url' => '/storage/display-media/logo.svg',
'brand_text' => 'Musterstadt',
'footer_claim' => 'Beratung im Schauraum',
'footer_url' => 'cabinet-bielefeld.de',
'qr_default_title' => 'Mehr Infos',
'qr_subtitle' => 'Jetzt scannen',
],
]); ]);
DisplayVersionItem::factory()->create([ DisplayVersionItem::factory()->create([
'display_version_id' => $version->id, 'display_version_id' => $version->id,
'item_type' => 'slide', 'item_type' => 'slide',
'content' => [ 'content' => [
'type' => 'intro', 'type' => 'detail',
'title' => 'Intro', 'title' => 'Intro',
'image_url' => '../assets/intro.jpg', 'image_url' => '../assets/intro.jpg',
'show_logo' => true,
'logo_url' => '/storage/display-media/logo.svg',
'brand_text' => 'Musterstadt',
'brand_tagline' => 'Beratung im Schauraum',
], ],
]); ]);
$response = $this->getJson("/api/display/module/{$version->id}/preview"); $response = $this->getJson("/api/display/module/{$version->id}/preview");
$response->assertSuccessful(); $response->assertSuccessful();
$response->assertJsonPath('playlist.0.settings.logo_url', '/storage/display-media/logo.svg'); $response->assertJsonPath('playlist.0.slides.0.show_logo', true);
$response->assertJsonPath('playlist.0.settings.brand_text', 'Musterstadt'); $response->assertJsonPath('playlist.0.slides.0.logo_url', '/storage/display-media/logo.svg');
$response->assertJsonPath('playlist.0.settings.footer_claim', 'Beratung im Schauraum'); $response->assertJsonPath('playlist.0.slides.0.brand_text', 'Musterstadt');
$response->assertJsonPath('playlist.0.settings.footer_url', 'cabinet-bielefeld.de'); $response->assertJsonPath('playlist.0.slides.0.brand_tagline', 'Beratung im Schauraum');
$response->assertJsonPath('playlist.0.settings.qr_default_title', 'Mehr Infos');
$response->assertJsonPath('playlist.0.settings.qr_subtitle', 'Jetzt scannen');
}); });
test('module item preview returns only the selected slide', function () { test('module item preview returns only the selected slide', function () {
@ -416,11 +446,28 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
->toContain('Aktive Live-Displays') ->toContain('Aktive Live-Displays')
->toContain('renderOverview(data.displays || [])') ->toContain('renderOverview(data.displays || [])')
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})') ->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
->toContain('this.settings.logo_url')
->toContain('this.settings.footer_claim') ->toContain('this.settings.footer_claim')
->toContain('this.settings.footer_url') ->toContain('this.settings.footer_url')
->toContain('this.settings.header_logo_url') ->toContain('this.settings.header_logo_url')
->toContain('this.settings.qr_label'); ->toContain('this.settings.qr_label')
->toContain('this.settings.show_footer !== false')
->toContain('this.settings.show_logo !== false')
->toContain('this.settings.show_claim !== false')
->toContain('this.settings.logo_position')
->toContain('this.settings.claim_position')
->toContain('b2in-brand-logo pos-')
->toContain('b2in-scrim')
->toContain('.b2in-layer.no-footer .b2in-text');
});
test('display player stops looping when the playlist has no playable content', function () {
$player = file_get_contents(public_path('_cabinet/display/index.html'));
expect($player)
->toContain('versionHasContent(version)')
->toContain('showEmptyPlaylist()')
->toContain('this.emptyVersionStreak >= this.playlist.length')
->toContain('Noch keine Inhalte vorhanden');
}); });
test('preview player pages are reachable for valid display and module previews', function () { test('preview player pages are reachable for valid display and module previews', function () {
@ -438,6 +485,20 @@ test('preview player pages are reachable for valid display and module previews',
->assertSuccessful(); ->assertSuccessful();
}); });
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) {
$response = $this->get($url);
$response->assertSuccessful();
expect($response->headers->get('Cache-Control'))->toContain('no-cache');
}
});
test('existing display config api still works', function () { test('existing display config api still works', function () {
$response = $this->getJson('/api/display/config'); $response = $this->getJson('/api/display/config');

View file

@ -3,6 +3,10 @@
use App\Enums\DisplayVersionType; use App\Enums\DisplayVersionType;
use App\Livewire\Admin\Cms\DisplayVersionEditor; use App\Livewire\Admin\Cms\DisplayVersionEditor;
use App\Livewire\Admin\Cms\DisplayVersionList; use App\Livewire\Admin\Cms\DisplayVersionList;
use App\Models\Display;
use App\Models\DisplayMedia;
use App\Models\DisplayPlaylist;
use App\Models\DisplayPlaylistItem;
use App\Models\DisplayVersion; use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem; use App\Models\DisplayVersionItem;
use App\Models\User; use App\Models\User;
@ -91,6 +95,29 @@ test('can delete a display version', function () {
expect(DisplayVersion::find($version->id))->toBeNull(); expect(DisplayVersion::find($version->id))->toBeNull();
}); });
test('cannot delete a display version that is used by a playlist', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['name' => 'Genutztes Modul']);
$display = Display::factory()->create();
$playlist = DisplayPlaylist::factory()->create([
'display_id' => $display->id,
'status' => DisplayPlaylist::STATUS_PUBLISHED,
'published_at' => now(),
]);
DisplayPlaylistItem::factory()->create([
'display_playlist_id' => $playlist->id,
'display_version_id' => $version->id,
'sort_order' => 0,
]);
Livewire::actingAs($user)
->test(DisplayVersionList::class)
->call('deleteVersion', $version->id)
->assertSee('kann nicht gelöscht werden');
expect(DisplayVersion::find($version->id))->not->toBeNull();
});
test('can toggle display version active status', function () { test('can toggle display version active status', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(['is_active' => true]); $version = DisplayVersion::factory()->create(['is_active' => true]);
@ -152,6 +179,81 @@ test('item edit modal renders module iframe preview', function () {
->assertSee('Schließen'); ->assertSee('Schließen');
}); });
test('item modal url inputs avoid wire:model.blur to prevent the teleported-modal cleanup crash', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'media')
->assertDontSee('wire:model.blur', false)
->assertSee('wire:model.live.debounce.500ms="mediaUrl"', false);
});
test('video item modal labels the title field as an internal name not shown on screen', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'video')
->assertSee('Name')
->assertSee('wird nicht im Video eingeblendet')
->assertDontSee('Titel (optional)');
});
test('video playlist renders a media thumbnail for stored videos', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
DisplayVersionItem::factory()->create([
'display_version_id' => $version->id,
'item_type' => 'video',
'content' => ['filename' => '/storage/display-media/clip.mp4', 'title' => 'Clip', 'position' => 25],
]);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->assertSee('/storage/display-media/clip.mp4#t=1', false);
});
test('new item modal shows a placeholder instead of the module preview', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'video')
->assertSee('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.')
->assertSee('src="about:blank"', false)
->assertDontSee('/preview/module/'.$version->id.'/item/', false);
});
test('item modal preview iframe keeps a stable wire:key to avoid morph crashes', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'video')
->assertSee('wire:key="item-modal-preview"', false);
});
test('saved item modal shows the single-item preview iframe', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
$item = DisplayVersionItem::factory()->create([
'display_version_id' => $version->id,
'item_type' => 'video',
'content' => ['filename' => 'clip.mp4', 'title' => 'Clip', 'position' => 25],
]);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', $item->id)
->assertSee('wire:key="item-modal-preview"', false)
->assertSee('/preview/module/'.$version->id.'/item/'.$item->id, false);
});
test('can add a video item to video-display version', function () { test('can add a video item to video-display version', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']); $version = DisplayVersion::factory()->create(['type' => 'video-display']);
@ -213,6 +315,26 @@ test('can edit an existing item', function () {
expect($item->content['title'])->toBe('New Title'); expect($item->content['title'])->toBe('New Title');
}); });
test('editing an inactive item keeps it inactive', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'video-display']);
$item = DisplayVersionItem::factory()->create([
'display_version_id' => $version->id,
'item_type' => 'video',
'content' => ['filename' => 'old.mp4', 'title' => 'Old', 'position' => 25],
'is_active' => false,
]);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', $item->id)
->assertSet('videoIsActive', false)
->set('videoTitle', 'New Title')
->call('saveItem');
expect($item->fresh()->is_active)->toBeFalse();
});
test('updating an item keeps modal open and refreshes iframe preview', function () { test('updating an item keeps modal open and refreshes iframe preview', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']); $version = DisplayVersion::factory()->create(['type' => 'offers']);
@ -299,12 +421,13 @@ test('can add a slide item to offers version', function () {
Livewire::actingAs($user) Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version]) ->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide') ->call('openItemModal', null, 'slide')
->set('slideType', 'product-hero')
->set('slideDuration', 10000) ->set('slideDuration', 10000)
->set('slideImageUrl', '../assets/goya1.jpg') ->set('slideImageUrl', '../assets/goya1.jpg')
->set('slideShowBadge', true)
->set('slideBadge', 'Einzelstück') ->set('slideBadge', 'Einzelstück')
->set('slideEyebrow', 'Hersteller: Sudbrock') ->set('slideEyebrow', 'Hersteller: Sudbrock')
->set('slideTitle', 'GOYA Sideboard') ->set('slideTitle', 'GOYA Sideboard')
->set('slideShowPrice', true)
->set('slidePrice', '489 €') ->set('slidePrice', '489 €')
->set('slideOriginalPrice', 'statt 4.744 €') ->set('slideOriginalPrice', 'statt 4.744 €')
->set('slideQrUrl', 'https://cabinet-bielefeld.de') ->set('slideQrUrl', 'https://cabinet-bielefeld.de')
@ -315,9 +438,10 @@ test('can add a slide item to offers version', function () {
$item = DisplayVersionItem::where('display_version_id', $version->id)->first(); $item = DisplayVersionItem::where('display_version_id', $version->id)->first();
expect($item)->not->toBeNull(); expect($item)->not->toBeNull();
expect($item->item_type)->toBe('slide'); expect($item->item_type)->toBe('slide');
expect($item->content['type'])->toBe('product-hero'); expect($item->content['type'])->toBe('detail');
expect($item->content['title'])->toBe('GOYA Sideboard'); expect($item->content['title'])->toBe('GOYA Sideboard');
expect($item->content['price'])->toBe('489 €'); expect($item->content['price'])->toBe('489 €');
expect($item->content['show_price'])->toBeTrue();
expect($item->content['image_url'])->toBe('../assets/goya1.jpg'); expect($item->content['image_url'])->toBe('../assets/goya1.jpg');
expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de'); expect($item->content['qr_url'])->toBe('https://cabinet-bielefeld.de');
}); });
@ -329,14 +453,99 @@ test('can add a slide with bullets to offers version', function () {
Livewire::actingAs($user) Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version]) ->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide') ->call('openItemModal', null, 'slide')
->set('slideType', 'product-details')
->set('slideTitle', 'GOYA Sideboard') ->set('slideTitle', 'GOYA Sideboard')
->set('slideShowBullets', true)
->set('slideBullets', ['Einzelstück', 'Abholung möglich', 'Lieferung optional']) ->set('slideBullets', ['Einzelstück', 'Abholung möglich', 'Lieferung optional'])
->call('saveItem'); ->call('saveItem');
$item = DisplayVersionItem::where('display_version_id', $version->id)->first(); $item = DisplayVersionItem::where('display_version_id', $version->id)->first();
expect($item->content['bullets'])->toHaveCount(3); expect($item->content['bullets'])->toHaveCount(3);
expect($item->content['bullets'][0])->toBe('Einzelstück'); expect($item->content['bullets'][0])->toBe('Einzelstück');
expect($item->content['show_bullets'])->toBeTrue();
});
test('slide editor persists the strike-through original price option', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide')
->set('slideTitle', 'Streich Slide')
->set('slideShowPrice', true)
->set('slidePrice', '199 €')
->set('slideOriginalPrice', 'statt 399 €')
->set('slideStrikeOriginalPrice', true)
->call('saveItem');
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
expect($item->content['strike_original_price'])->toBeTrue();
expect($item->content['original_price'])->toBe('statt 399 €');
});
test('slide editor no longer exposes a slide type selector', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide')
->assertDontSee('Slide-Typ')
->assertSee('Bild & Badge')
->assertSee('Badge anzeigen')
->assertSee('Aufzählung anzeigen')
->assertSee('QR-Code anzeigen');
});
test('hidden slide blocks are persisted via their show flags', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide')
->set('slideTitle', 'Nur Titel')
->set('slideShowBadge', false)
->set('slideShowEyebrow', false)
->set('slideShowBullets', false)
->set('slideShowPrice', false)
->set('slideShowQr', false)
->set('slideShowContact', false)
->call('saveItem');
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
expect($item->content['show_badge'])->toBeFalse();
expect($item->content['show_eyebrow'])->toBeFalse();
expect($item->content['show_bullets'])->toBeFalse();
expect($item->content['show_price'])->toBeFalse();
expect($item->content['show_qr'])->toBeFalse();
expect($item->content['show_contact'])->toBeFalse();
});
test('legacy slides without show flags keep rendering their populated blocks', function () {
$version = DisplayVersion::factory()->create(['type' => 'offers']);
DisplayVersionItem::factory()->create([
'display_version_id' => $version->id,
'item_type' => 'slide',
'content' => [
'type' => 'product-hero',
'title' => 'Legacy Slide',
'badge_text' => 'Einzelstück',
'price' => '489 €',
'bullets' => ['Punkt A'],
],
]);
$config = app(\App\Services\DisplayPlaylistConfigBuilder::class)
->fromModules(DisplayVersion::whereKey($version->id)->with('items')->get());
$slide = $config['playlist'][0]['slides'][0];
expect($slide['show_badge'])->toBeTrue();
expect($slide['show_price'])->toBeTrue();
expect($slide['show_bullets'])->toBeTrue();
expect($slide['show_subline'])->toBeFalse();
expect($slide['show_disclaimer'])->toBeFalse();
}); });
test('can edit a slide item with new fields', function () { test('can edit a slide item with new fields', function () {
@ -399,22 +608,174 @@ test('can save version settings', function () {
expect($version->fresh()->settings['footer_prefix'])->toBe('powered by'); expect($version->fresh()->settings['footer_prefix'])->toBe('powered by');
}); });
test('module settings expose player chrome fields in the editor', function () { test('b2in defaults include brand position and footer toggle', function () {
$defaults = \App\Support\DisplayModuleSettings::defaults(DisplayVersionType::B2in);
expect($defaults)->toHaveKey('logo_position', 'top-left')
->and($defaults)->toHaveKey('claim_position', 'top-right')
->and($defaults)->toHaveKey('show_footer', true)
->and($defaults)->toHaveKey('show_logo', true)
->and($defaults)->toHaveKey('show_claim', true);
});
test('selecting a video medium auto-sets the media type to video', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
$media = DisplayMedia::factory()->video()->create();
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'media')
->set('mediaType', 'image')
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
->assertSet('mediaType', 'video');
});
test('selecting an image medium auto-sets the media type to image', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
$media = DisplayMedia::factory()->create(['type' => 'image']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'media')
->set('mediaType', 'video')
->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl())
->assertSet('mediaType', 'image');
});
test('media item modal offers the sonstiges category and an automatic type hint', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'media')
->assertSee('Sonstiges')
->assertSee('wird automatisch aus dem gewählten Medium erkannt');
});
test('b2in settings editor shows the brand section and footer toggle', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->assertSee('Marke')
->assertSee('Logo-Position')
->assertSee('Claim-Position')
->assertSee('Logo anzeigen')
->assertSee('Claim anzeigen')
->assertSee('Footer anzeigen');
});
test('saving b2in settings persists logo and claim visibility', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openSettingsModal')
->set('settings.show_logo', false)
->set('settings.show_claim', false)
->call('saveSettings');
$settings = $version->fresh()->settings;
expect($settings['show_logo'])->toBeFalse()
->and($settings['show_claim'])->toBeFalse();
});
test('enabling the footer pulls bottom brand positions back to the top', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->set('settings.show_footer', false)
->set('settings.logo_position', 'bottom-left')
->set('settings.claim_position', 'bottom-right')
->set('settings.show_footer', true)
->assertSet('settings.logo_position', 'top-left')
->assertSet('settings.claim_position', 'top-right');
});
test('bottom brand positions are kept when the footer is hidden', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->set('settings.show_footer', false)
->set('settings.logo_position', 'bottom-left')
->assertSet('settings.logo_position', 'bottom-left');
});
test('claim never shares the same corner as the logo', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->set('settings.show_footer', false)
->set('settings.logo_position', 'bottom-left')
->set('settings.claim_position', 'bottom-left')
->assertSet('settings.claim_position', fn ($value) => $value !== 'bottom-left');
});
test('saving b2in settings persists brand positions and footer toggle', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'b2in']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openSettingsModal')
->set('settings.show_footer', false)
->set('settings.logo_position', 'bottom-right')
->set('settings.claim_position', 'top-left')
->call('saveSettings');
$settings = $version->fresh()->settings;
expect($settings['show_footer'])->toBeFalse()
->and($settings['logo_position'])->toBe('bottom-right')
->and($settings['claim_position'])->toBe('top-left');
});
test('offers meta settings only keep loop and transition', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']); $version = DisplayVersion::factory()->create(['type' => 'offers']);
Livewire::actingAs($user) Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version]) ->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->assertSee('Meta-Einstellungen für dieses Modul') ->assertSee('Meta-Einstellungen für dieses Modul')
->assertSee('Diese Werte gelten für die gesamte Media-Playlist bzw. alle Slides dieses Moduls.')
->call('openSettingsModal') ->call('openSettingsModal')
->assertSee('Branding') ->assertSee('Endlosschleife')
->assertSee('Logo URL') ->assertSee('Transition')
->assertDontSee('Logo Alt-Text') ->assertSee('Transition-Dauer (ms)')
->assertSee('Brand-Text') ->assertDontSee('Branding')
->assertSee('Footer & QR für alle Slides') ->assertDontSee('Brand-Text')
->assertSee('Footer-Claim') ->assertDontSee('Footer & QR für alle Slides')
->assertSee('Web/QR-URL') ->assertDontSee('Footer-Claim');
->assertSee('Standard QR-Titel') });
->assertSee('QR-Unterzeile');
test('offer slide editor manages logo and brand text per slide', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['type' => 'offers']);
Livewire::actingAs($user)
->test(DisplayVersionEditor::class, ['displayVersion' => $version])
->call('openItemModal', null, 'slide')
->assertSee('Logo & Marke')
->assertSee('Logo & Marken-Text anzeigen')
->set('slideTitle', 'Logo Slide')
->set('slideShowLogo', true)
->set('slideLogoUrl', '/storage/display-media/logo.svg')
->set('slideBrandText', 'Musterstadt')
->set('slideBrandTagline', 'Beratung • Lieferung')
->call('saveItem');
$item = DisplayVersionItem::where('display_version_id', $version->id)->first();
expect($item->content['show_logo'])->toBeTrue();
expect($item->content['logo_url'])->toBe('/storage/display-media/logo.svg');
expect($item->content['brand_text'])->toBe('Musterstadt');
expect($item->content['brand_tagline'])->toBe('Beratung • Lieferung');
}); });

View file

@ -0,0 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\URL;
beforeEach(function () {
URL::forceRootUrl('https://'.config('domains.domain_portal'));
});
test('admin layout loads livewire before flux so flux can register its alpine components', function () {
$user = User::factory()->create();
$content = $this->actingAs($user)
->get(route('admin.cms.display-modules'))
->assertSuccessful()
->getContent();
$livewirePosition = strpos($content, 'livewire.js?id=');
$fluxPosition = strpos($content, '/flux/flux');
expect($livewirePosition)->not->toBeFalse('Livewire script tag is missing from the layout.');
expect($fluxPosition)->not->toBeFalse('Flux script tag is missing from the layout.');
expect($livewirePosition)->toBeLessThan(
$fluxPosition,
'Livewire must be loaded before Flux; otherwise window.Alpine is undefined when flux.js registers fluxModal/fluxSelectSearchClearable.'
);
});
test('admin layout loads the livewire script only once', function () {
$user = User::factory()->create();
$content = $this->actingAs($user)
->get(route('admin.cms.display-modules'))
->assertSuccessful()
->getContent();
expect(substr_count($content, 'livewire.js?id='))->toBe(
1,
'Livewire must only be injected once; a duplicate script tag creates multiple Alpine instances.'
);
});

View file

@ -57,11 +57,11 @@ it('announcement bar visible on about', fn () => $this->get('/about')->assertSee
it('homepage has navigation', fn () => $this->get('/')->assertSee('Immobilien')->assertSee('Netzwerk')->assertSee('Magazin')); it('homepage has navigation', fn () => $this->get('/')->assertSee('Immobilien')->assertSee('Netzwerk')->assertSee('Magazin'));
it('immobilien has all five sections', function () { it('immobilien has all five sections', function () {
$this->get('/immobilien') $this->get('/immobilien')
->assertSee('Dynamik') ->assertSee('Warum Dubai für Investoren relevant bleibt')
->assertSee('Investment in Dubai') ->assertSee('Der Kaufprozess mit B2in')
->assertSee('Kaufprozess') ->assertSee('Passt Dubai zu Ihrer Investmentstrategie?')
->assertSee('Meine Aufgabe') ->assertSee('Einrichtung mitdenken')
->assertSee('Investor'); ->assertSee('Investoren');
}); });
it('netzwerk has teaser cards', fn () => $this->get('/netzwerk')->assertSee('Einrichtungsnetzwerk')->assertSee('In Entwicklung')); it('netzwerk has teaser cards', fn () => $this->get('/netzwerk')->assertSee('Einrichtungsnetzwerk')->assertSee('In Entwicklung'));
it('magazin has all five articles', function () { it('magazin has all five articles', function () {

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Models\Display;
use Database\Seeders\TestDisplaySeeder;
it('seeds exactly one test display', function () {
$this->seed(TestDisplaySeeder::class);
expect(Display::query()->where('is_test', true)->count())->toBe(1);
});
it('is idempotent and does not create a second test display', function () {
$this->seed(TestDisplaySeeder::class);
$this->seed(TestDisplaySeeder::class);
expect(Display::query()->where('is_test', true)->count())->toBe(1);
});