Compare commits

...

2 commits

Author SHA1 Message Date
6c6d683b9a 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>
2026-05-29 15:57:33 +00:00
9262132325 Display Module 13-05-2026 2026-05-13 14:34:08 +02:00
68 changed files with 2755 additions and 14231 deletions

View file

@ -62,7 +62,7 @@ services:
MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password
volumes: volumes:
- '../:/var/www/html' - '../:/var/www/html'
- './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro' - './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
networks: networks:
- sail - sail
depends_on: depends_on:

View file

@ -1,2 +1,4 @@
[PHP]
; Muss über Sail-Standard (99-sail.ini: 100M) liegen; siehe Display-Mediathek / Livewire-Uploads (~200 MB).
upload_max_filesize = 210M upload_max_filesize = 210M
post_max_size = 210M post_max_size = 210M

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

@ -4,6 +4,8 @@ namespace App\Console\Commands;
use App\Models\Display; use App\Models\Display;
use App\Models\DisplayFooterContent; use App\Models\DisplayFooterContent;
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\DisplayVideo; use App\Models\DisplayVideo;
@ -75,7 +77,16 @@ class MigrateLegacyDisplays extends Command
'is_active' => true, 'is_active' => true,
]); ]);
$display->versions()->attach($version->id, ['sort_order' => 0]); $playlist = $display->playlists()->create([
'status' => DisplayPlaylist::STATUS_PUBLISHED,
'published_at' => now(),
]);
DisplayPlaylistItem::create([
'display_playlist_id' => $playlist->id,
'display_version_id' => $version->id,
'sort_order' => 0,
]);
$this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items."); $this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items.");
$this->info("Created version: {$version->name} (ID: {$version->id})"); $this->info("Created version: {$version->name} (ID: {$version->id})");

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

@ -9,6 +9,35 @@ use Illuminate\Http\JsonResponse;
class DisplayVersionApiController extends Controller class DisplayVersionApiController extends Controller
{ {
public function overview(): JsonResponse
{
$displays = Display::query()
->with(['livePlaylist.modules'])
->where('is_active', true)
->whereHas('livePlaylist.modules')
->orderBy('name')
->get()
->map(function (Display $display): array {
$playlist = $display->livePlaylist;
return [
'id' => $display->id,
'name' => $display->name,
'location' => $display->location,
'is_active' => $display->is_active,
'is_live' => true,
'module_count' => $playlist?->modules->count() ?? 0,
'updated_at' => $playlist?->updated_at?->toIso8601String(),
'url' => rtrim(config('display.player_url'), '/').'/?id='.$display->id,
];
})
->values();
return response()->json([
'displays' => $displays,
]);
}
public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
{ {
if (! $display->is_active) { if (! $display->is_active) {

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;
@ -44,7 +47,7 @@ class DisplayList extends Component
], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED; ], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED;
if ($id) { if ($id) {
$display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules', 'versions'])->findOrFail($id); $display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules'])->findOrFail($id);
$this->displayId = $display->id; $this->displayId = $display->id;
$this->displayName = $display->name; $this->displayName = $display->name;
$this->displayLocation = $display->location ?? ''; $this->displayLocation = $display->location ?? '';
@ -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()
@ -137,7 +154,6 @@ class DisplayList extends Component
$this->previewFrameRefreshCounter++; $this->previewFrameRefreshCounter++;
} else { } else {
$this->syncPublishedPlaylist($display); $this->syncPublishedPlaylist($display);
$this->syncLegacyPivot($display, $this->selectedVersionIds);
} }
$this->closeModal(); $this->closeModal();
@ -176,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);
@ -202,24 +239,11 @@ class DisplayList extends Component
return $display->draftPlaylist->fresh('modules'); return $display->draftPlaylist->fresh('modules');
}); });
$this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist)); $display->clearPreviewToken();
session()->flash('success', 'Entwurf wurde veröffentlicht.'); session()->flash('success', 'Entwurf wurde veröffentlicht.');
} }
/**
* @param array<int> $versionIds
*/
private function syncLegacyPivot(Display $display, array $versionIds): void
{
$syncData = [];
foreach ($versionIds as $sortOrder => $versionId) {
$syncData[$versionId] = ['sort_order' => $sortOrder];
}
$display->versions()->sync($syncData);
}
private function syncPublishedPlaylist(Display $display): void private function syncPublishedPlaylist(Display $display): void
{ {
$playlist = $display->playlists()->firstOrCreate( $playlist = $display->playlists()->firstOrCreate(
@ -297,8 +321,7 @@ class DisplayList extends Component
return $this->moduleIdsForPlaylist($display->draftPlaylist); return $this->moduleIdsForPlaylist($display->draftPlaylist);
} }
return $this->moduleIdsForPlaylist($display->livePlaylist) return $this->moduleIdsForPlaylist($display->livePlaylist);
?: $display->versions->pluck('id')->all();
} }
public function deleteDisplay(int $id): void public function deleteDisplay(int $id): void
@ -331,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,9 +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 Illuminate\Support\Facades\File; use App\Support\DisplayModuleSettings;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
@ -55,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;
@ -64,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 = '';
@ -98,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();
} }
} }
@ -159,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
// ======================================== // ========================================
@ -275,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
@ -305,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';
@ -339,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'] ?? '';
@ -353,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'] ?? '') !== '';
} }
/** /**
@ -389,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 => [],
@ -450,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;
} }
@ -480,46 +595,7 @@ class DisplayVersionEditor extends Component
*/ */
private function settingsWithDefaults(): array private function settingsWithDefaults(): array
{ {
return array_replace_recursive($this->defaultSettings(), $this->version->settings ?? []); return DisplayModuleSettings::merge($this->version->type, $this->version->settings);
}
/**
* @return array<string, mixed>
*/
private function defaultSettings(): array
{
return match ($this->version->type) {
DisplayVersionType::VideoDisplay => [
'qr_label' => 'Website',
],
DisplayVersionType::B2in => [
'theme' => 'dark',
'header_logo_url' => '../assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property',
'footer_url' => 'B2in.eu',
'footer_name' => '',
'footer_prefix' => 'by',
'qr_url' => '',
'transition' => [
'type' => 'crossfade',
'duration_ms' => 800,
],
'default_image_duration' => 10,
],
DisplayVersionType::Offers => [
'loop' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => 'Bielefeld',
'footer_claim' => '',
'footer_url' => '',
'qr_default_title' => 'Kontakt',
'qr_subtitle' => 'QR scannen',
'transition' => [
'type' => 'fade',
'duration' => 600,
],
],
};
} }
public function render() public function render()

View file

@ -4,6 +4,8 @@ namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType; use App\Enums\DisplayVersionType;
use App\Models\DisplayVersion; use App\Models\DisplayVersion;
use App\Support\DisplayModuleSettings;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class DisplayVersionList extends Component class DisplayVersionList extends Component
@ -52,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();
@ -70,40 +85,17 @@ class DisplayVersionList extends Component
*/ */
private function defaultSettingsForType(string $type): array private function defaultSettingsForType(string $type): array
{ {
return match ($type) { return DisplayModuleSettings::defaults($type);
'b2in' => [
'theme' => 'dark',
'header_logo_url' => '../assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property',
'footer_name' => '',
'footer_url' => 'B2in.eu',
'footer_prefix' => 'by',
'qr_url' => '',
'transition' => ['type' => 'crossfade', 'duration_ms' => 800],
'default_image_duration' => 10,
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
'display_active' => true,
],
'offers' => [
'loop' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => 'Bielefeld',
'footer_claim' => '',
'footer_url' => '',
'qr_default_title' => 'Kontakt',
'qr_subtitle' => 'QR scannen',
'transition' => ['type' => 'fade', 'duration' => 600],
],
'video-display' => [
'qr_label' => 'Website',
],
default => [],
};
} }
public function render() public function render()
{ {
$versions = DisplayVersion::withCount(['items', 'displays']) $versions = DisplayVersion::withCount([
'items',
'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)')),
])
->orderBy('name') ->orderBy('name')
->get(); ->get();

View file

@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
{ {
$this->validate([ $this->validate([
'uploads' => 'nullable|array|max:20', 'uploads' => 'nullable|array|max:20',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]); ]);
$service = app(MediaConversionService::class); $service = app(MediaConversionService::class);

View file

@ -73,7 +73,7 @@ class MediaPicker extends Component
{ {
$this->validate([ $this->validate([
'quickUploads' => 'nullable|array|max:5', 'quickUploads' => 'nullable|array|max:5',
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]); ]);
$service = app(MediaConversionService::class); $service = app(MediaConversionService::class);

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

@ -4,7 +4,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -30,17 +29,6 @@ class Display extends Model
]; ];
} }
/**
* @deprecated Wird in Phase 7 entfernt. Nutze stattdessen liveModules()
* oder die Playlist-Relationen (livePlaylist, draftPlaylist).
*/
public function versions(): BelongsToMany
{
return $this->belongsToMany(DisplayVersion::class, 'display_display_version')
->withPivot('sort_order')
->orderByPivot('sort_order');
}
/** /**
* @return HasMany<DisplayPlaylist, $this> * @return HasMany<DisplayPlaylist, $this>
*/ */
@ -67,29 +55,6 @@ class Display extends Model
->where('status', DisplayPlaylist::STATUS_DRAFT); ->where('status', DisplayPlaylist::STATUS_DRAFT);
} }
/**
* Liefert die Module der aktuell veröffentlichten Bespielung in Reihenfolge.
*/
public function liveModules(): BelongsToMany
{
return $this->belongsToMany(
DisplayVersion::class,
'display_playlist_items',
'display_playlist_id',
'display_version_id'
)
->wherePivotIn(
'display_playlist_id',
DisplayPlaylist::query()
->where('display_id', $this->id ?? 0)
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->select('id')
)
->withPivot(['sort_order', 'id'])
->withTimestamps()
->orderByPivot('sort_order');
}
public function ensurePreviewToken(): string public function ensurePreviewToken(): string
{ {
if (! $this->preview_token) { if (! $this->preview_token) {
@ -99,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

@ -6,7 +6,6 @@ use App\Enums\DisplayVersionType;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class DisplayVersion extends Model class DisplayVersion extends Model
@ -35,10 +34,12 @@ class DisplayVersion extends Model
return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order'); return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order');
} }
public function displays(): BelongsToMany /**
* @return HasMany<DisplayPlaylistItem, $this>
*/
public function playlistItems(): HasMany
{ {
return $this->belongsToMany(Display::class, 'display_display_version') return $this->hasMany(DisplayPlaylistItem::class);
->withPivot('sort_order');
} }
/** /**

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

@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\DisplayPlaylist; use App\Models\DisplayPlaylist;
use App\Models\DisplayVersion; use App\Models\DisplayVersion;
use App\Support\DisplayModuleSettings;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class DisplayPlaylistConfigBuilder class DisplayPlaylistConfigBuilder
@ -95,9 +96,7 @@ class DisplayPlaylistConfigBuilder
return [ return [
'type' => 'video-display', 'type' => 'video-display',
'version_name' => $module->name, 'version_name' => $module->name,
'settings' => array_replace([ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'qr_label' => 'Website',
], $module->settings ?? []),
'videoPlaylist' => $videos, 'videoPlaylist' => $videos,
'footerContent' => $footerContent, 'footerContent' => $footerContent,
]; ];
@ -133,20 +132,7 @@ class DisplayPlaylistConfigBuilder
return [ return [
'type' => 'b2in', 'type' => 'b2in',
'version_name' => $module->name, 'version_name' => $module->name,
'settings' => array_replace_recursive([ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'theme' => 'dark',
'header_logo_url' => '../assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property',
'footer_url' => 'B2in.eu',
'footer_name' => '',
'footer_prefix' => 'by',
'qr_url' => '',
'transition' => [
'type' => 'crossfade',
'duration_ms' => 800,
],
'default_image_duration' => 10,
], $module->settings ?? []),
'items' => $mediaItems, 'items' => $mediaItems,
]; ];
} }
@ -157,42 +143,45 @@ 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',
'version_name' => $module->name, 'version_name' => $module->name,
'settings' => array_replace_recursive([ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'loop' => true,
'logo_url' => '../logo-cabinet-300.png',
'brand_text' => 'Bielefeld',
'footer_claim' => '',
'footer_url' => '',
'qr_default_title' => 'Kontakt',
'qr_subtitle' => 'QR scannen',
'transition' => [
'type' => 'fade',
'duration' => 600,
],
], $module->settings ?? []),
'slides' => $slides, 'slides' => $slides,
]; ];
} }

View file

@ -0,0 +1,60 @@
<?php
namespace App\Support;
use App\Enums\DisplayVersionType;
class DisplayModuleSettings
{
/**
* @return array<string, mixed>
*/
public static function defaults(DisplayVersionType|string $type): array
{
$typeValue = $type instanceof DisplayVersionType ? $type->value : $type;
return match ($typeValue) {
DisplayVersionType::VideoDisplay->value => [
'qr_label' => 'Website',
],
DisplayVersionType::B2in->value => [
'theme' => 'dark',
'header_logo_url' => '../assets/b2in-logo-positive.svg',
'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_name' => '',
'footer_prefix' => 'by',
'qr_url' => '',
'transition' => [
'type' => 'crossfade',
'duration_ms' => 800,
],
'default_image_duration' => 10,
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
'display_active' => true,
],
DisplayVersionType::Offers->value => [
'loop' => true,
'transition' => [
'type' => 'fade',
'duration' => 600,
],
],
default => [],
};
}
/**
* @param array<string, mixed>|null $settings
* @return array<string, mixed>
*/
public static function merge(DisplayVersionType|string $type, ?array $settings): array
{
return array_replace_recursive(self::defaults($type), $settings ?? []);
}
}

View file

@ -22,4 +22,7 @@ return [
// Haupt-Domain // Haupt-Domain
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'), 'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
// Öffentliche Player-URL der Display-Domain
'player_url' => env('DISPLAY_PLAYER_URL', 'https://cabinet.b2in.eu/display'),
]; ];

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('display_display_version');
}
public function down(): void
{
Schema::create('display_display_version', function (Blueprint $table) {
$table->id();
$table->foreignId('display_id')->constrained()->cascadeOnDelete();
$table->foreignId('display_version_id')->constrained()->cascadeOnDelete();
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->unique(['display_id', 'display_version_id']);
});
}
};

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

@ -16,8 +16,8 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
|---|---| |---|---|
| `cms/display-dashboard` | Übersicht / Einstieg | | `cms/display-dashboard` | Übersicht / Einstieg |
| `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) | | `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) |
| `cms/display-versions` | Inhalts-„Versionen" | | `cms/display-modules` | Inhalts-Module |
| `cms/display-versions/{id}/edit` | Editor für eine Version | | `cms/display-modules/{id}/edit` | Editor für ein Modul |
| `cms/displays` | Physische Displays + Playlist-Zuweisung | | `cms/displays` | Physische Displays + Playlist-Zuweisung |
| `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) | | `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) |
@ -25,13 +25,14 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
``` ```
displays (5 Datensätze live) displays (5 Datensätze live)
└── m:n via display_display_version (sort_order = Playlist-Reihenfolge) └── 1:n display_playlists (Live/Entwurf)
└── display_versions (5 Datensätze live) └── 1:n display_playlist_items (sort_order = Playlist-Reihenfolge)
├── type: video-display | b2in | offers └── display_versions (technisch), fachlich Module
├── settings: JSON ├── type: video-display | b2in | offers
└── 1:n display_version_items (17 Datensätze live) ├── settings: JSON
├── item_type: video | footer | media | slide └── 1:n display_version_items
└── content: JSON ├── item_type: video | footer | media | slide
└── content: JSON
``` ```
### 1.3 Echte Live-Daten (Stand heute) ### 1.3 Echte Live-Daten (Stand heute)
@ -83,7 +84,7 @@ displays (5 Datensätze live)
| Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. | | Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. |
| Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. | | Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. |
Routen werden entsprechend umbenannt: `display-versions``display-modules`. Routen wurden entsprechend umbenannt: `display-versions``display-modules`. Die Übergangs-Redirects wurden in Phase 7 entfernt.
### 2.2 Neues mentales Modell ### 2.2 Neues mentales Modell
@ -181,7 +182,7 @@ für jedes Display D:
erstelle display_playlists (display_id=D.id, status='published', published_at=now()) erstelle display_playlists (display_id=D.id, status='published', published_at=now())
für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order: für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order:
erstelle display_playlist_items (...) erstelle display_playlist_items (...)
display_display_version-Tabelle bleibt vorerst → wird in Phase 7 dropped. display_display_version-Tabelle wurde in Phase 7 dropped.
``` ```
**Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute. **Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute.
@ -355,15 +356,15 @@ Jede Phase liefert ein in sich getestetes, deploybares Inkrement.
- [ ] Player-Templates: Single-Module-Modus - [ ] Player-Templates: Single-Module-Modus
### Phase 6 Umbenennung & Onboarding (Tag 3) ### Phase 6 Umbenennung & Onboarding (Tag 3)
- [ ] Routen: `display-versions``display-modules` (mit 301-Redirect) - [x] Routen: `display-versions``display-modules`
- [ ] Komponenten / Views umbenennen - [x] Komponenten / Views umbenennen
- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren - [x] Dashboard-Texte / Hilfe-Bausteine aktualisieren
- [ ] Tooltips an Schlüsselstellen - [x] Tooltips an Schlüsselstellen
### Phase 7 Aufräumen (Tag 4) ### Phase 7 Aufräumen (Tag 4)
- [ ] `display_display_version`-Tabelle dropped - [x] `display_display_version`-Tabelle dropped
- [ ] Alte Routen entfernt - [x] Alte Routen entfernt
- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen) - [x] Entwicklerdoku in `dev/displays-11-05-2026` aktualisiert
- [ ] Vollständiger Test-Run - [ ] Vollständiger Test-Run
--- ---

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
@ -245,7 +246,7 @@ Umsetzung:
## Phase 6 Umbenennung Versionen → Module + Onboarding ## Phase 6 Umbenennung Versionen → Module + Onboarding
**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs bleiben kompatibel und leiten weiter. **Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs wurden während der Übergangsphase per 301 weitergeleitet und in Phase 7 entfernt.
### Stand 12.05.2026 ✅ abgeschlossen ### Stand 12.05.2026 ✅ abgeschlossen
@ -261,9 +262,9 @@ Dateien:
Umsetzung: Umsetzung:
- Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit` - Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit`
- Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit` - Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit`
- Alte `display-versions`-Routen bleiben erhalten und leiten per 301 auf die Modul-Routen weiter - Alte `display-versions`-Routen waren während der Übergangsphase als 301-Redirects aktiv und wurden in Phase 7 entfernt
- Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“ - Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“
- Technische Modell-/Klassennamen bleiben bis Phase 7 kompatibel bei `DisplayVersion` - Technische Modell-/Klassennamen bleiben bei `DisplayVersion`, da sie fachlich weiterhin die wiederverwendbaren Module abbilden
#### Tests #### Tests
@ -276,3 +277,99 @@ tests/Feature/DisplayPlaylistMigrationTest.php ok
Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean. Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean.
---
## Phase 7 Technisches Aufräumen & Optimierung
**Ziel:** Nach Stabilisierung des neuen Playlist-Flows wird die alte Pivot-Kompatibilität entfernt und der Modul-Editor weiter vereinheitlicht.
### Stand 13.05.2026 ✅ umgesetzt
Dateien:
- `app/Models/Display.php`
- `app/Models/DisplayVersion.php`
- `app/Livewire/Admin/Cms/DisplayList.php`
- `app/Console/Commands/MigrateLegacyDisplays.php`
- `app/Support/DisplayModuleSettings.php`
- `app/Services/DisplayPlaylistConfigBuilder.php`
- `app/Livewire/Admin/Cms/DisplayVersionEditor.php`
- `app/Livewire/Admin/Cms/DisplayVersionList.php`
- `routes/admin.php`
- `database/migrations/2026_05_13_103600_drop_display_display_version_table.php`
- `resources/views/livewire/admin/cms/display-list.blade.php`
- `resources/views/livewire/admin/cms/display-version-editor.blade.php`
- `resources/views/livewire/admin/cms/partials/version-editor-video.blade.php`
Umsetzung:
- Alte Pivot-Tabelle `display_display_version` wird per Migration entfernt
- Legacy-Relationen `Display::versions()` und `DisplayVersion::displays()` wurden entfernt
- Display-Bearbeitung, Draft-Veröffentlichung und Legacy-Migrations-Command schreiben ausschließlich in `display_playlists` und `display_playlist_items`
- Alte `display-versions`-Redirect-Routen wurden entfernt; die Admin-UI nutzt nur noch `display-modules`
- Modul-Settings-Defaults liegen zentral in `App\Support\DisplayModuleSettings` und werden von Editor, Listen-Erstellung und API-Config-Builder gemeinsam genutzt
- 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
---
## 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

@ -32,7 +32,7 @@ services:
REDIS_HOST: global-redis REDIS_HOST: global-redis
volumes: volumes:
- '.:/var/www/html' - '.:/var/www/html'
- './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro' - './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
networks: networks:
- sail - sail
- proxy - proxy

View file

@ -3,7 +3,7 @@
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png"> accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
<flux:file-upload.dropzone <flux:file-upload.dropzone
heading="Dateien hochladen" heading="Dateien hochladen"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei" text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
with-progress /> with-progress />
</flux:file-upload> </flux:file-upload>

View file

@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
{ {
$this->validate([ $this->validate([
'uploads' => 'nullable|array|max:20', 'uploads' => 'nullable|array|max:20',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]); ]);
$service = app(MediaConversionService::class); $service = app(MediaConversionService::class);

View file

@ -73,7 +73,7 @@ class MediaPicker extends Component
{ {
$this->validate([ $this->validate([
'quickUploads' => 'nullable|array|max:5', 'quickUploads' => 'nullable|array|max:5',
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]); ]);
$service = app(MediaConversionService::class); $service = app(MediaConversionService::class);

View file

@ -18,7 +18,7 @@ class MediaUploader extends Component
public string $directory = 'cms/uploads'; public string $directory = 'cms/uploads';
#[Validate('file|max:10240')] #[Validate('file|max:204800')]
public $file; public $file;
public function updatedFile(): void public function updatedFile(): void

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

@ -85,11 +85,11 @@ Beim Hochladen neuer Videos beachten:
- [ ] Format: **MP4** (H.264 + AAC) - [ ] Format: **MP4** (H.264 + AAC)
- [ ] Auflösung: **Max 1920x1080** - [ ] Auflösung: **Max 1920x1080**
- [ ] Bitrate: **5-10 Mbps** - [ ] Bitrate: **5-10 Mbps**
- [ ] Dateigröße: **Max 100 MB** - [ ] Dateigröße: **Max 200 MB**
- [ ] Länge: **15-60 Sekunden** (optimal) - [ ] Länge: **15-60 Sekunden** (optimal)
### ⚠️ Vermeiden: ### ⚠️ Vermeiden:
- ❌ Zu große Dateien (>100MB) - ❌ Zu große Dateien (>200MB)
- ❌ Zu hohe Bitrate (>10 Mbps) - ❌ Zu hohe Bitrate (>10 Mbps)
- ❌ Zu lange Videos (>3 Min) - ❌ Zu lange Videos (>3 Min)
- ❌ Exotische Formate (MOV, AVI, WMV) - ❌ Exotische Formate (MOV, AVI, WMV)

View file

@ -214,7 +214,7 @@ setTimeout(() => {
### 3. **Dateigrößen** ### 3. **Dateigrößen**
- **Optimal:** 10-50 MB pro Video - **Optimal:** 10-50 MB pro Video
- **Maximum:** 100 MB pro Video - **Maximum:** 200 MB pro Video
- **Warum:** Schnelleres Laden, weniger Buffering - **Warum:** Schnelleres Laden, weniger Buffering
### 4. **Playlist-Größe** ### 4. **Playlist-Größe**

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 {
@ -411,6 +438,66 @@
.status-message { font-weight: 300; opacity: 0.7; } .status-message { font-weight: 300; opacity: 0.7; }
.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 {
position: fixed; inset: 0; z-index: 10000;
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
color: #fff; cursor: auto; padding: clamp(24px, 5vw, 72px);
}
.display-overview.hidden { display: none; }
.display-overview__inner { width: min(1120px, 100%); margin: 0 auto; }
.display-overview__eyebrow {
color: #38bdf8; font-size: 13px; font-weight: 700;
letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 12px;
}
.display-overview h1 {
font-size: clamp(34px, 6vw, 76px); line-height: 0.95;
letter-spacing: -0.05em; margin-bottom: 18px;
}
.display-overview__intro {
max-width: 720px; color: rgba(255,255,255,0.68);
font-size: clamp(16px, 2vw, 22px); line-height: 1.5; margin-bottom: 36px;
}
.display-overview__grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.display-card {
display: flex; flex-direction: column; gap: 16px;
min-height: 220px; padding: 24px; border-radius: 28px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08); color: #fff; text-decoration: none;
box-shadow: 0 24px 70px rgba(0,0,0,0.24);
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.display-card:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.55);
background: rgba(255,255,255,0.12);
}
.display-card__badges { display: flex; flex-wrap: wrap; gap: 8px; }
.display-badge {
border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(134,239,172,0.28);
}
.display-badge--live { background: rgba(56,189,248,0.18); color: #7dd3fc; border-color: rgba(125,211,252,0.28); }
.display-card__title { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; }
.display-card__meta { display: grid; gap: 6px; color: rgba(255,255,255,0.62); font-size: 15px; }
.display-card__action { margin-top: auto; color: #7dd3fc; font-weight: 700; }
.display-overview__empty {
border: 1px dashed rgba(255,255,255,0.24); border-radius: 28px;
padding: 32px; color: rgba(255,255,255,0.62);
}
</style> </style>
</head> </head>
<body> <body>
@ -434,6 +521,18 @@
<div class="status-sub">Neustart in Kürze...</div> <div class="status-sub">Neustart in Kürze...</div>
</div> </div>
<div class="display-overview hidden" id="display-overview">
<div class="display-overview__inner">
<div class="display-overview__eyebrow">Cabinet Display Player</div>
<h1>Aktive Live-Displays</h1>
<p class="display-overview__intro">
Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
</p>
<div class="display-overview__grid" id="display-overview-list"></div>
</div>
</div>
<script> <script>
function escapeHtml(value) { function escapeHtml(value) {
const div = document.createElement('div'); const div = document.createElement('div');
@ -462,15 +561,12 @@ class DisplayPlayer {
this.moduleId = this.detectModuleId(); this.moduleId = this.detectModuleId();
this.itemId = this.detectItemId(); this.itemId = this.detectItemId();
this.displayId = this.detectDisplayId(); this.displayId = this.detectDisplayId();
if (!this.displayId && !this.previewToken && !this.moduleId) {
this.showError('Keine Display-ID oder Vorschau angegeben. URL: /display/index.html?id=1');
return;
}
// API // API
this.BASE_URL = this.detectBaseUrl(); this.BASE_URL = this.detectBaseUrl();
this.API_CONFIG = this.detectConfigUrl(); this.API_CONFIG = this.detectConfigUrl();
this.API_CHECK = this.detectCheckUrl(); this.API_CHECK = this.detectCheckUrl();
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
// Timing // Timing
this.POLL_INTERVAL = 60000; this.POLL_INTERVAL = 60000;
@ -490,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');
@ -497,6 +594,8 @@ class DisplayPlayer {
this.loadingInfo = document.getElementById('loading-info'); this.loadingInfo = document.getElementById('loading-info');
this.errorOverlay = document.getElementById('error-overlay'); this.errorOverlay = document.getElementById('error-overlay');
this.errorMessage = document.getElementById('error-message'); this.errorMessage = document.getElementById('error-message');
this.overviewOverlay = document.getElementById('display-overview');
this.overviewList = document.getElementById('display-overview-list');
this.loadingInfo.textContent = this.detectLoadingLabel(); this.loadingInfo.textContent = this.detectLoadingLabel();
@ -583,13 +682,16 @@ class DisplayPlayer {
} }
return `Modul #${this.moduleId}`; return `Modul #${this.moduleId}`;
} }
if (!this.displayId) {
return 'Display-Übersicht';
}
return `Display #${this.displayId}`; return `Display #${this.displayId}`;
} }
detectBaseUrl() { detectBaseUrl() {
const hostname = window.location.hostname; const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) { if (hostname === 'cabinet.b2in.eu') {
return 'https://b2in.eu'; return 'https://portal.b2in.eu';
} }
return window.location.origin; return window.location.origin;
} }
@ -602,6 +704,11 @@ class DisplayPlayer {
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`); console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
try { try {
if (!this.displayId && !this.previewToken && !this.moduleId) {
await this.fetchOverview();
return;
}
await this.fetchConfig(); await this.fetchConfig();
if (this.playlist.length === 0) { if (this.playlist.length === 0) {
@ -642,6 +749,16 @@ class DisplayPlayer {
console.log(`[Display] Loaded ${this.playlist.length} version(s)`); console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
} }
async fetchOverview() {
const response = await fetch(this.API_OVERVIEW);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.renderOverview(data.displays || []);
}
startPolling() { startPolling() {
if (!this.API_CHECK) { if (!this.API_CHECK) {
return; return;
@ -731,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
@ -771,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
// ======================================== // ========================================
@ -779,11 +941,50 @@ class DisplayPlayer {
this.loadingOverlay.classList.add('hidden'); this.loadingOverlay.classList.add('hidden');
} }
renderOverview(displays) {
this.hideLoading();
this.errorOverlay.classList.add('hidden');
this.overviewOverlay.classList.remove('hidden');
if (displays.length === 0) {
this.overviewList.innerHTML = `
<div class="display-overview__empty">
Es sind aktuell keine aktiven Live-Displays veröffentlicht.
</div>
`;
return;
}
this.overviewList.innerHTML = displays.map(display => `
<a class="display-card" href="${this.escapeHtml(display.url)}">
<div class="display-card__badges">
<span class="display-badge">Aktiv</span>
<span class="display-badge display-badge--live">Live</span>
</div>
<div>
<div class="display-card__title">${this.escapeHtml(display.name)}</div>
<div class="display-card__meta">
<span>Display-ID: ${this.escapeHtml(display.id)}</span>
${display.location ? `<span>Standort: ${this.escapeHtml(display.location)}</span>` : ''}
<span>${this.escapeHtml(display.module_count)} Modul(e) veröffentlicht</span>
</div>
</div>
<div class="display-card__action">Display öffnen</div>
</a>
`).join('');
}
showError(msg) { showError(msg) {
this.loadingOverlay.classList.add('hidden'); this.loadingOverlay.classList.add('hidden');
this.errorOverlay.classList.remove('hidden'); this.errorOverlay.classList.remove('hidden');
this.errorMessage.textContent = msg; this.errorMessage.textContent = msg;
} }
escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
} }
@ -1014,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');
@ -1027,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>
@ -1040,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>
@ -1257,42 +1496,64 @@ 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 ---
const header = document.createElement('header'); if (show.logo) {
header.className = 'offer-header'; const header = document.createElement('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);
if (has(slide.brand_tagline)) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
} else {
// Without the header row, let the hero stay the flexible middle row.
article.style.gridTemplateRows = '1fr auto';
} }
header.appendChild(brand);
if (slide.show_brand_text && slide.brand_tagline) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
// --- HERO --- // --- HERO ---
const hero = document.createElement('section'); const hero = document.createElement('section');
hero.className = 'offer-hero'; hero.className = 'offer-hero';
@ -1301,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;
@ -1321,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;
@ -1330,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 => {
@ -1359,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';
@ -1374,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);
} }
@ -1393,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');
@ -1406,38 +1664,41 @@ class OffersRenderer {
bottom.appendChild(info); bottom.appendChild(info);
// QR Box // QR / Contact box only when at least one of the two is enabled.
const qrBox = document.createElement('aside'); if (show.qr || show.contact) {
qrBox.className = 'offer-qr-box'; const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box';
const qrHeader = document.createElement('div'); if (show.qr) {
qrHeader.className = 'offer-qr-header'; const qrHeader = document.createElement('div');
qrHeader.innerHTML = ` qrHeader.className = 'offer-qr-header';
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p> qrHeader.innerHTML = `
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p> <p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
`; <p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
qrBox.appendChild(qrHeader); `;
qrBox.appendChild(qrHeader);
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 || ''; const qrImg = document.createElement('img');
if (qrUrl) { qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
const qrImg = document.createElement('img'); qrImg.alt = 'QR Code';
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`; qrWrapper.appendChild(qrImg);
qrImg.alt = 'QR Code'; qrBox.appendChild(qrWrapper);
qrWrapper.appendChild(qrImg); }
}
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>'); qrBox.appendChild(contact);
qrBox.appendChild(contact); }
bottom.appendChild(qrBox);
} else {
bottom.style.gridTemplateColumns = '1fr';
} }
bottom.appendChild(qrBox);
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

@ -183,7 +183,7 @@
<flux:navlist.group :heading="__('Cabinet')" class="grid mb-4"> <flux:navlist.group :heading="__('Cabinet')" class="grid mb-4">
<flux:navlist.group expandable <flux:navlist.group expandable
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])" :expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
heading="Store Displays" class="grid"> heading="Store Displays" class="grid">
<flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')" <flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')"
:current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }} :current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }}
@ -192,7 +192,7 @@
:current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }} :current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }}
</flux:navlist.item> </flux:navlist.item>
<flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-modules')" <flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-modules')"
:current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit'])" wire:navigate>{{ __('Module') }} :current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit'])" wire:navigate>{{ __('Module') }}
</flux:navlist.item> </flux:navlist.item>
<flux:navlist.item icon="tv" :href="route('admin.cms.displays')" <flux:navlist.item icon="tv" :href="route('admin.cms.displays')"
:current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }} :current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }}
@ -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

@ -171,9 +171,9 @@ $tabletStatus = computed(function () {
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen: Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
</p> </p>
<ul class="mt-2 ml-5 list-disc space-y-1"> <ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder, SVG-Logos und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jede Modul hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jedes Modul hat einen bestimmten Typ, passende Inhalte und eigene Meta-Einstellungen fuer Logo, Claim, Footer, QR-Code oder Theme.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Module als Playlist zugewiesen.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> Die physischen Bildschirme im Showroom. Pro Display gibt es einen Live-Stand und optional einen Entwurf, der separat vorbereitet, getestet und bewusst veröffentlicht wird.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
</ul> </ul>
</div> </div>
@ -190,10 +190,10 @@ $tabletStatus = computed(function () {
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten. Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
</p> </p>
<ul class="mt-2 ml-5 list-disc space-y-1"> <ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder, SVG-Dateien und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 200 MB (z.&nbsp;B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Module eingebunden werden.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 200 MB (z.&nbsp;B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Module eingebunden werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue Dateien inklusive SVG-Logos hochladen.</li>
</ul> </ul>
</div> </div>
@ -211,19 +211,19 @@ $tabletStatus = computed(function () {
<ul class="mt-2 ml-5 list-disc space-y-1"> <ul class="mt-2 ml-5 list-disc space-y-1">
<li> <li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong> <strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong>
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> (Dateiname, Titel, Position/Ausschnitt) und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link). Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> aus der Mediathek oder Legacy-Dateinamen, Position/Ausschnitt und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link). Mediathek-URLs wie <code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/storage/...</code> werden direkt abgespielt.
</li> </li>
<li> <li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong> <strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong>
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme. Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme sowie zentrale Meta-Einstellungen für Header-Logo, Claim, Footer-Domain und QR-Code.
</li> </li>
<li> <li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong> <strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong>
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes. Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes. Logo, Brand-Text, Footer-Claim und Web-/QR-URL werden einmal am Modul gepflegt und automatisch von allen Slides übernommen.
</li> </li>
</ul> </ul>
<p class="mt-2"> <p class="mt-2">
Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren. Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren. Der Modul-Editor zeigt Inline-Vorschaubilder, eine 9:16-Player-Vorschau und eine Vollbild-Vorschau. Im Slide-Bearbeiten-Dialog wird nur der aktuell bearbeitete Slide als Einzel-Vorschau gerendert.
</p> </p>
</div> </div>
@ -238,9 +238,11 @@ $tabletStatus = computed(function () {
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom. Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
</p> </p>
<ul class="mt-2 ml-5 list-disc space-y-1"> <ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Live und Entwurf:</strong> Jedes Display zeigt den veröffentlichten Live-Stand und optional einen Entwurf. Entwürfe können aus Live angelegt, separat bearbeitet, verworfen oder veröffentlicht werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Live- oder Entwurfsstand können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Vorschau:</strong> Live- und Entwurfs-URLs sind direkt kopierbar. Entwürfe und Module können zusätzlich im 9:16-Iframe oder im Vollbild geprüft werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Änderungen werden beim nächsten Abruf automatisch übernommen.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Live-Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Entwürfe laufen über Preview-Tokens, Module über eigene Preview-Endpunkte.</li>
</ul> </ul>
</div> </div>
@ -282,10 +284,12 @@ $tabletStatus = computed(function () {
Typischer Workflow Typischer Workflow
</flux:heading> </flux:heading>
<ol class="mt-2 ml-5 list-decimal space-y-1"> <ol class="mt-2 ml-5 list-decimal space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien hochladen</strong> Bilder, SVG-Logos oder Videos in der Display-Mediathek ablegen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul erstellen</strong> Unter „Module" ein neues Modul mit passendem Typ anlegen (z.&nbsp;B. „Frühling 2026 Video").</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul erstellen</strong> Unter „Module" ein neues Modul mit passendem Typ anlegen (z.&nbsp;B. „Frühling 2026 Video").</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Meta-Einstellungen pflegen</strong> Logo, Claim, Footer, QR-Code, Theme oder Anzeigezeiten einmal auf Modulebene setzen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> Unter „Displays" das Modul einem physischen Bildschirm zuordnen.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen, aktivieren und per Vorschau prüfen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Fertig</strong> Das Display lädt die neuen Inhalte automatisch über die API.</li> <li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display-Entwurf erstellen</strong> Unter „Displays" aus dem Live-Stand einen Entwurf erzeugen und dort Module hinzufügen, sortieren oder entfernen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Prüfen und veröffentlichen</strong> Entwurf in der 9:16-Vorschau oder im Vollbild testen und anschließend bewusst veröffentlichen.</li>
</ol> </ol>
</div> </div>

View file

@ -10,6 +10,29 @@
</x-success-alert> </x-success-alert>
@endif @endif
@php
$displayPlayerUrl = rtrim(config('display.player_url') ?: 'https://cabinet.b2in.eu/display', '/');
$displayOverviewUrl = $displayPlayerUrl.'/';
@endphp
<flux:card class="mb-6 border-blue-200 bg-blue-50/70 dark:border-blue-500/30 dark:bg-blue-950/20">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<flux:heading size="lg">{{ __('Öffentliche Display-Übersicht') }}</flux:heading>
<flux:text class="mt-1">
{{ __('Hier sehen Sie alle aktiven Live-Displays und können die Wiedergabe direkt öffnen.') }}
</flux:text>
<div class="mt-2 text-xs font-mono text-blue-700 dark:text-blue-300">
{{ $displayOverviewUrl }}
</div>
</div>
<flux:button href="{{ $displayOverviewUrl }}" target="_blank" variant="primary" icon="arrow-top-right-on-square">
{{ __('Display-Übersicht öffnen') }}
</flux:button>
</div>
</flux:card>
<flux:card> <flux:card>
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
@ -30,7 +53,7 @@
<div class="space-y-4"> <div class="space-y-4">
@foreach($displays as $display) @foreach($displays as $display)
@php @php
$liveDisplayUrl = url('/_cabinet/display/index.html').'?id='.$display->id; $liveDisplayUrl = $displayPlayerUrl.'/?id='.$display->id;
$liveApiUrl = url('/api/display/'.$display->id.'/config'); $liveApiUrl = url('/api/display/'.$display->id.'/config');
@endphp @endphp
<div wire:key="display-{{ $display->id }}" <div wire:key="display-{{ $display->id }}"
@ -197,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"
@ -319,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
@ -364,6 +400,7 @@
wire:key="draft-preview-{{ $previewFrameRefreshCounter }}" wire:key="draft-preview-{{ $previewFrameRefreshCounter }}"
src="{{ $draftPreviewUrl }}" src="{{ $draftPreviewUrl }}"
class="h-full w-full border-0" class="h-full w-full border-0"
loading="lazy"
title="{{ __('Entwurfs-Vorschau') }}" title="{{ __('Entwurfs-Vorschau') }}"
></iframe> ></iframe>
@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

@ -70,6 +70,7 @@
wire:key="module-preview-{{ $previewFrameRefreshCounter }}" wire:key="module-preview-{{ $previewFrameRefreshCounter }}"
src="{{ $this->modulePreviewUrl() }}" src="{{ $this->modulePreviewUrl() }}"
class="h-full w-full border-0" class="h-full w-full border-0"
loading="lazy"
title="{{ __('Modul-Vorschau') }}" title="{{ __('Modul-Vorschau') }}"
></iframe> ></iframe>
</div> </div>
@ -120,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" />
@ -139,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')
@ -164,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>
<option value="product-impulse">Produkt-Impuls</option>
</flux:select>
<flux:input wire:model="slideDuration" type="number" min="1000" label="Dauer (ms)" />
<livewire:admin.cms.display-media-picker
:value="null"
field="slideImageUrl"
type="image"
label="Bild aus Mediathek"
:key="'picker-slide-' . ($itemId ?? 'new')" />
<flux:input wire:model="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt URL eingeben." />
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
<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 --}} {{-- Logo & Marke (Kopfbereich) --}}
@if($slideType === 'intro') <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" /> <flux:heading size="sm">{{ __('Logo & Marke') }}</flux:heading>
<flux:checkbox wire:model="slideShowBrandText" label="Brand-Text anzeigen" /> <flux:switch wire:model.live="slideShowLogo" label="Logo & Marken-Text anzeigen"
@if($slideShowBrandText) description="Kopfbereich oben mit Logo, Marken-Text und Tagline." />
<flux:input wire:model="slideBrandTagline" label="Brand-Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage" /> @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 @endif
@endif </div>
{{-- Product-Hero --}} {{-- Bild & Badge (wichtigstes Element farblich hervorgehoben) --}}
@if($slideType === 'product-hero') <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">
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" /> <div class="flex items-center gap-2">
<flux:input wire:model="slideOriginalPrice" label="Originalpreis" placeholder="z.B. statt 4.744 €" /> <flux:heading size="sm">{{ __('Bild & Badge') }}</flux:heading>
@endif <flux:badge color="blue" size="sm">{{ __('Wichtigstes Element') }}</flux:badge>
</div>
<livewire:admin.cms.display-media-picker
:value="null"
field="slideImageUrl"
type="image"
label="Bild aus Mediathek"
:key="'picker-slide-' . ($itemId ?? 'new')" />
<div class="flex items-end gap-3">
<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" />
@endif
</div>
{{-- Product-Details --}} {{-- Texte --}}
@if($slideType === 'product-details') <div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<div> <flux:heading size="sm">{{ __('Texte') }}</flux:heading>
<flux:heading size="sm" class="mb-2">{{ __('Aufzählungspunkte') }}</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" />
@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
</div>
{{-- Aufzählung --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Aufzählungspunkte') }}</flux:heading>
<flux:switch wire:model.live="slideShowBullets" label="Aufzählung anzeigen" description="Liste mit Stichpunkten, z.B. Produktdetails." />
@if($slideShowBullets)
<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 }}">
@ -213,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>
</div> @endif
@endif
{{-- Product-Impulse --}}
@if($slideType === 'product-impulse')
<flux:input wire:model="slideSubline" label="Subline" placeholder="z.B. Heute mitnehmen" />
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 199 €" />
<flux:input wire:model="slideTagText" label="Tag-Text" placeholder="z.B. Im Store verfügbar" />
@endif
{{-- QR --}}
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4 mt-2">
<flux:heading size="sm" class="mb-3">{{ __('QR-Code & Kontakt') }}</flux:heading>
<div class="space-y-4">
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de" />
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
</div>
</div> </div>
<flux:checkbox wire:model="slideIsActive" label="Aktiv" /> {{-- Preis --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Preis') }}</flux:heading>
<flux:switch wire:model.live="slideShowPrice" label="Preis anzeigen" />
@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
<flux:input wire:model="slideTagText" label="Hinweis-Tag (optional)" placeholder="z.B. Im Store verfügbar" />
@endif
</div>
{{-- Hinweis --}}
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Hinweis') }}</flux:heading>
<flux:switch wire:model.live="slideShowDisclaimer" label="Disclaimer anzeigen" />
@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" />
@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" />
@endif
</div>
{{-- 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">
@ -245,21 +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"
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>
<a href="{{ $this->itemPreviewUrl() }}" @if($itemId)
target="_blank" <a href="{{ $this->itemPreviewUrl() }}"
class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400"> target="_blank"
<flux:icon.arrow-top-right-on-square class="w-3 h-3" /> class="mx-auto flex max-w-[320px] items-center justify-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400">
{{ __('Vollbild öffnen') }} <flux:icon.arrow-top-right-on-square class="w-3 h-3" />
</a> {{ __('Vollbild öffnen') }}
</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

@ -3,7 +3,7 @@
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png"> accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
<flux:file-upload.dropzone <flux:file-upload.dropzone
heading="Dateien hochladen" heading="Dateien hochladen"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei" text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
with-progress /> with-progress />
</flux:file-upload> </flux:file-upload>

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,47 +16,61 @@
</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>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4"> @php
<span>{{ $item->content['filename'] ?? '' }}</span> $videoSource = $item->content['filename'] ?? '';
$isMediaLibrarySource =
str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http');
@endphp
<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'">
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
</flux:badge>
<span class="truncate">{{ $videoSource ?: '' }}</span>
<span>Position: {{ $item->content['position'] ?? 25 }}%</span> <span>Position: {{ $item->content['position'] ?? 25 }}%</span>
</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
@ -69,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>
@ -110,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
@ -103,12 +103,12 @@ $deleteLocation = function ($id) {
{{-- Flash Message --}} {{-- Flash Message --}}
@if (session()->has('message')) @if (session()->has('message'))
<flux:card class="p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"> <flux:card class="p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<flux:icon.check-circle class="h-5 w-5 text-green-600 dark:text-green-400" /> <flux:icon.check-circle class="h-5 w-5 text-green-600 dark:text-green-400" />
<span class="text-sm text-green-900 dark:text-green-100">{{ session('message') }}</span> <span class="text-sm text-green-900 dark:text-green-100">{{ session('message') }}</span>
</div> </div>
</flux:card> </flux:card>
@endif @endif
{{-- Tabs --}} {{-- Tabs --}}
@ -119,364 +119,360 @@ $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 --}}
<div class="space-y-6"> <div class="space-y-6">
<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:field> </flux:description>
</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>
<flux:label>{{ __('Status') }}</flux:label> <flux:label>{{ __('Status') }}</flux:label>
<flux:checkbox wire:model="is_active"> <flux:checkbox wire:model="is_active">
{{ __('Hub ist aktiv und für Kunden sichtbar') }} {{ __('Hub ist aktiv und für Kunden sichtbar') }}
</flux:checkbox> </flux:checkbox>
<flux:description> <flux:description>
{{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }} {{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }}
</flux:description> </flux:description>
</flux:field> </flux:field>
</div> </div>
{{-- 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">
<div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg"> {{ __('Vorschau: Kunden-Landingpage') }}</h4>
@if($keyvisual) <div class="relative h-48 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 shadow-lg">
<img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" /> @if ($keyvisual)
@else <img src="{{ $keyvisual }}" class="w-full h-full object-cover" alt="Keyvisual" />
<div class="flex items-center justify-center h-full"> @else
<div class="text-center"> <div class="flex items-center justify-center h-full">
<flux:icon.photo class="w-12 h-12 text-zinc-400 mx-auto mb-2" /> <div class="text-center">
<p class="text-sm text-zinc-500">{{ __('Keyvisual hochladen') }}</p> <flux:icon.photo class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
<p class="text-sm text-zinc-500">{{ __('Keyvisual hochladen') }}</p>
</div>
</div>
@endif
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
<h3 class="text-xl font-bold text-white">
{{ $name ?: __('Ihr Hub-Name') }}
</h3>
<p class="text-sm text-zinc-200">
{{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }}
</p>
</div> </div>
@if ($emblem)
<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" />
</div>
@endif
</div> </div>
@endif
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
<h3 class="text-xl font-bold text-white">
{{ $name ?: __('Ihr Hub-Name') }}
</h3>
<p class="text-sm text-zinc-200">
{{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }}
</p>
</div>
@if($emblem)
<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" />
</div>
@endif
</div> </div>
</div> </div>
</div>
<flux:separator /> <flux:separator />
{{-- Keyvisual Upload --}} {{-- Keyvisual Upload --}}
<flux:field> <flux:field>
<flux:label>{{ __('Keyvisual (Hintergrundbild)') }}</flux:label> <flux:label>{{ __('Keyvisual (Hintergrundbild)') }}</flux:label>
{{-- <flux:input type="file" wire:model="keyvisual" accept="image/*" /> --}} {{-- <flux:input type="file" wire:model="keyvisual" accept="image/*" /> --}}
<flux:description> <flux:description>
{{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }} {{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }}
<br> <br>
<span class="text-xs"> <span class="text-xs">
{{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }} {{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }}
{{ __('Empfohlen: 1920x800px, max. 2MB') }} {{ __('Empfohlen: 1920x800px, max. 2MB') }}
</span> </span>
</flux:description> </flux:description>
</flux:field> </flux:field>
{{-- Wappen Upload --}} {{-- Wappen Upload --}}
<flux:field> <flux:field>
<flux:label>{{ __('Wappen / Emblem') }}</flux:label> <flux:label>{{ __('Wappen / Emblem') }}</flux:label>
{{-- <flux:input type="file" wire:model="emblem" accept="image/*" /> --}} {{-- <flux:input type="file" wire:model="emblem" accept="image/*" /> --}}
<flux:description> <flux:description>
{{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }} {{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }}
<br> <br>
<span class="text-xs"> <span class="text-xs">
{{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }} {{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }}
{{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }} {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }}
</span> </span>
</flux:description> </flux:description>
</flux:field> </flux:field>
</flux:card> </flux:card>
@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 --}}
<flux:card class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"> <flux:card class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<flux:icon.light-bulb class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" /> <flux:icon.light-bulb class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div class="flex-1"> <div class="flex-1">
<div class="text-sm font-semibold text-yellow-900 dark:text-yellow-100"> <div class="text-sm font-semibold text-yellow-900 dark:text-yellow-100">
{{ __('Die Mapping-Engine') }} {{ __('Die Mapping-Engine') }}
</div> </div>
<div class="text-xs text-yellow-700 dark:text-yellow-300 mt-1"> <div class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
{{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }} {{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }}
</div>
</div> </div>
</div> </div>
</div> </flux:card>
</flux:card>
{{-- PLZ-Import Tools --}} {{-- PLZ-Import Tools --}}
<flux:card class="p-6"> <flux:card class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Postleitzahlen hinzufügen') }}</flux:heading> <flux:heading size="lg" class="mb-4">{{ __('Postleitzahlen hinzufügen') }}</flux:heading>
<flux:tabs wire:model.live="importMethod" variant="segmented"> <flux:tabs wire:model.live="importMethod" variant="segmented">
<flux:tab name="single" icon="plus">{{ __('Einzeln') }}</flux:tab> <flux:tab name="single" icon="plus">{{ __('Einzeln') }}</flux:tab>
<flux:tab name="range" icon="arrows-right-left">{{ __('Bereich') }}</flux:tab> <flux:tab name="range" icon="arrows-right-left">{{ __('Bereich') }}</flux:tab>
<flux:tab name="csv" icon="document">{{ __('CSV-Import') }}</flux:tab> <flux:tab name="csv" icon="document">{{ __('CSV-Import') }}</flux:tab>
</flux:tabs> </flux:tabs>
<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:input wire:model="newZipCode" placeholder="33602" /> </flux:label>
</flux:field> <flux:input wire:model="newZipCode" placeholder="33602" />
<flux:field> </flux:field>
<flux:label>{{ __('Stadt') }} <span class="text-red-500">*</span></flux:label> <flux:field>
<flux:input wire:model="newCityName" placeholder="Bielefeld" /> <flux:label>{{ __('Stadt') }} <span class="text-red-500">*</span></flux:label>
</flux:field> <flux:input wire:model="newCityName" placeholder="Bielefeld" />
</flux:field>
</div>
<flux:button wire:click="addSingleZip" icon="plus">
{{ __('PLZ hinzufügen') }}
</flux:button>
</div>
@endif
{{-- PLZ-Bereich --}}
@if ($importMethod === 'range')
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<flux:field>
<flux:label>{{ __('Von PLZ') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="rangeStart" placeholder="33000" />
</flux:field>
<flux:field>
<flux:label>{{ __('Bis PLZ') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="rangeEnd" placeholder="33999" />
</flux:field>
</div>
<flux:button wire:click="addZipRange" icon="arrows-right-left">
{{ __('Bereich importieren') }}
</flux:button>
<flux:description>
⚠️
{{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }}
</flux:description>
</div>
@endif
{{-- CSV-Import --}}
@if ($importMethod === 'csv')
<div class="space-y-4">
<flux:field>
<flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label>
<flux:input type="file" wire:model="csvFile" accept=".csv" />
<flux:description>
{{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }}
<br>
<span class="text-xs">{{ __('Beispiel:') }}</span>
<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded">
33602,Bielefeld<br>33603,Bielefeld<br>33604,Bielefeld
</code>
</flux:description>
</flux:field>
<flux:button wire:click="importCsv" icon="arrow-up-tray">
{{ __('CSV importieren') }}
</flux:button>
</div>
@endif
</div>
</flux:card>
{{-- PLZ-Liste --}}
<flux:card>
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<flux:heading size="lg">
{{ __('Zugeordnete Postleitzahlen') }}
@if ($hubId)
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
({{ $this->locations->total() }} {{ __('gesamt') }})
</span>
@endif
</flux:heading>
<flux:input wire:model.live.debounce="zipSearch"
placeholder="{{ __('PLZ oder Stadt suchen...') }}" icon="magnifying-glass"
class="w-64" />
</div> </div>
<flux:button wire:click="addSingleZip" icon="plus">
{{ __('PLZ hinzufügen') }}
</flux:button>
</div>
@endif
{{-- PLZ-Bereich --}} @if ($hubId)
@if($importMethod === 'range') <flux:table>
<div class="space-y-4"> <flux:table.columns>
<div class="grid grid-cols-2 gap-4"> <flux:table.column>{{ __('PLZ') }}</flux:table.column>
<flux:field> <flux:table.column>{{ __('Stadt') }}</flux:table.column>
<flux:label>{{ __('Von PLZ') }} <span class="text-red-500">*</span></flux:label> <flux:table.column class="text-right w-32">{{ __('Aktion') }}</flux:table.column>
<flux:input wire:model="rangeStart" placeholder="33000" /> </flux:table.columns>
</flux:field>
<flux:field>
<flux:label>{{ __('Bis PLZ') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="rangeEnd" placeholder="33999" />
</flux:field>
</div>
<flux:button wire:click="addZipRange" icon="arrows-right-left">
{{ __('Bereich importieren') }}
</flux:button>
<flux:description>
⚠️ {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }}
</flux:description>
</div>
@endif
{{-- CSV-Import --}} <flux:table.rows>
@if($importMethod === 'csv') @forelse($this->locations as $location)
<div class="space-y-4"> <flux:table.row :key="$location->id">
<flux:field> <flux:table.cell>
<flux:label>{{ __('CSV-Datei') }} <span class="text-red-500">*</span></flux:label> <span class="font-mono">{{ $location->zip_code }}</span>
<flux:input type="file" wire:model="csvFile" accept=".csv" /> </flux:table.cell>
<flux:description> <flux:table.cell>{{ $location->city_name }}</flux:table.cell>
{{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }} <flux:table.cell class="text-right">
<br> <flux:button variant="ghost" size="sm" icon="trash"
<span class="text-xs">{{ __('Beispiel:') }}</span> wire:click="deleteLocation({{ $location->id }})" />
<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded"> </flux:table.cell>
33602,Bielefeld<br>33603,Bielefeld<br>33604,Bielefeld </flux:table.row>
</code> @empty
</flux:description> <flux:table.row>
</flux:field> <flux:table.cell colspan="3" class="text-center py-8">
<flux:button wire:click="importCsv" icon="arrow-up-tray"> <flux:icon.map-pin class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
{{ __('CSV importieren') }} <p class="text-zinc-600 dark:text-zinc-400">
</flux:button> {{ __('Noch keine PLZs zugeordnet') }}
</div> </p>
@endif </flux:table.cell>
</div> </flux:table.row>
</flux:card> @endforelse
</flux:table.rows>
</flux:table>
{{-- PLZ-Liste --}} {{-- Pagination --}}
<flux:card> @if ($this->locations->hasPages())
<div class="p-6"> <div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4">
<div class="flex items-center justify-between mb-4"> {{ $this->locations->links() }}
<flux:heading size="lg"> </div>
{{ __('Zugeordnete Postleitzahlen') }}
@if($hubId)
<span class="text-sm font-normal text-zinc-600 dark:text-zinc-400">
({{ $this->locations->total() }} {{ __('gesamt') }})
</span>
@endif @endif
</flux:heading> @else
<flux:input <div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
wire:model.live.debounce="zipSearch" {{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }}
placeholder="{{ __('PLZ oder Stadt suchen...') }}" </div>
icon="magnifying-glass" @endif
class="w-64"
/>
</div> </div>
</flux:card>
@if($hubId) </div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('PLZ') }}</flux:table.column>
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
<flux:table.column class="text-right w-32">{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($this->locations as $location)
<flux:table.row :key="$location->id">
<flux:table.cell>
<span class="font-mono">{{ $location->zip_code }}</span>
</flux:table.cell>
<flux:table.cell>{{ $location->city_name }}</flux:table.cell>
<flux:table.cell class="text-right">
<flux:button
variant="ghost"
size="sm"
icon="trash"
wire:click="deleteLocation({{ $location->id }})"
/>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="3" class="text-center py-8">
<flux:icon.map-pin class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
<p class="text-zinc-600 dark:text-zinc-400">
{{ __('Noch keine PLZs zugeordnet') }}
</p>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
{{-- Pagination --}}
@if($this->locations->hasPages())
<div class="mt-4 border-t border-zinc-200 dark:border-zinc-700 pt-4">
{{ $this->locations->links() }}
</div>
@endif
@else
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
{{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }}
</div>
@endif
</div>
</flux:card>
</div>
@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>
@endif
</flux:heading>
{{-- Info --}}
<flux:card class="p-4 mb-4 bg-zinc-50 dark:bg-zinc-800 border-0">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
<strong>{{ __('Logik:') }}</strong>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>{{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}</li>
<li>{{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}</li>
<li>{{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}</li>
</ul>
</div>
</flux:card>
@if ($hubId)
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
<flux:table.column>{{ __('Lieferradius') }}</flux:table.column>
<flux:table.column class="text-center">{{ __('Status') }}</flux:table.column>
<flux:table.column class="text-right">{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($this->partners as $partner)
<flux:table.row :key="$partner->id">
<flux:table.cell>
<div class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ $partner->company_name }}
</div>
@if ($partner->display_name && $partner->display_name !== $partner->company_name)
<div class="text-xs text-zinc-500">{{ $partner->display_name }}</div>
@endif
</flux:table.cell>
<flux:table.cell>
@php
$typeColors = [
'Retailer' => 'blue',
'Manufacturer' => 'purple',
'Estate-Agent' => 'green',
];
@endphp
<flux:badge :color="$typeColors[$partner->type] ?? 'zinc'" size="sm">
{{ $partner->type }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
{{ $partner->city ?? '-' }}
</flux:table.cell>
<flux:table.cell>
@if ($partner->delivery_radius_km)
<span class="text-sm">{{ $partner->delivery_radius_km }} km</span>
@else
<span class="text-sm text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-center">
<flux:badge :color="$partner->is_active ? 'green' : 'zinc'" size="sm">
{{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell class="text-right">
<flux:button variant="ghost" size="sm" icon="eye">
{{ __('Details') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6" class="text-center py-8">
<flux:icon.user-group class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
<p class="text-zinc-600 dark:text-zinc-400">
{{ __('Noch keine Partner in diesem Hub') }}
</p>
<p class="text-xs text-zinc-500 mt-2">
{{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }}
</p>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@else
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
{{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }}
</div>
@endif @endif
</flux:heading>
{{-- Info --}}
<flux:card class="p-4 mb-4 bg-zinc-50 dark:bg-zinc-800 border-0">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
<strong>{{ __('Logik:') }}</strong>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>{{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}</li>
<li>{{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}</li>
<li>{{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}</li>
</ul>
</div>
</flux:card> </flux:card>
@if($hubId)
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Stadt') }}</flux:table.column>
<flux:table.column>{{ __('Lieferradius') }}</flux:table.column>
<flux:table.column class="text-center">{{ __('Status') }}</flux:table.column>
<flux:table.column class="text-right">{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($this->partners as $partner)
<flux:table.row :key="$partner->id">
<flux:table.cell>
<div class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ $partner->company_name }}
</div>
@if($partner->display_name && $partner->display_name !== $partner->company_name)
<div class="text-xs text-zinc-500">{{ $partner->display_name }}</div>
@endif
</flux:table.cell>
<flux:table.cell>
@php
$typeColors = [
'Retailer' => 'blue',
'Manufacturer' => 'purple',
'Estate-Agent' => 'green',
];
@endphp
<flux:badge :color="$typeColors[$partner->type] ?? 'zinc'" size="sm">
{{ $partner->type }}
</flux:badge>
</flux:table.cell>
<flux:table.cell>
{{ $partner->city ?? '-' }}
</flux:table.cell>
<flux:table.cell>
@if($partner->delivery_radius_km)
<span class="text-sm">{{ $partner->delivery_radius_km }} km</span>
@else
<span class="text-sm text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-center">
<flux:badge :color="$partner->is_active ? 'green' : 'zinc'" size="sm">
{{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
</flux:table.cell>
<flux:table.cell class="text-right">
<flux:button variant="ghost" size="sm" icon="eye">
{{ __('Details') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6" class="text-center py-8">
<flux:icon.user-group class="w-12 h-12 text-zinc-400 mx-auto mb-2" />
<p class="text-zinc-600 dark:text-zinc-400">
{{ __('Noch keine Partner in diesem Hub') }}
</p>
<p class="text-xs text-zinc-500 mt-2">
{{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }}
</p>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@else
<div class="text-center py-8 text-zinc-600 dark:text-zinc-400">
{{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }}
</div>
@endif
</flux:card>
@endif @endif
</div> </div>

View file

@ -272,16 +272,16 @@ new class extends Component
$rules['deliveryRadius'] = 'required|integer|min:1|max:500'; $rules['deliveryRadius'] = 'required|integer|min:1|max:500';
$rules['assemblyRadius'] = 'required|integer|min:1|max:500'; $rules['assemblyRadius'] = 'required|integer|min:1|max:500';
$rules['newTeamPhotos'] = 'nullable|array|max:10'; $rules['newTeamPhotos'] = 'nullable|array|max:10';
$rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; $rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
$rules['newShowroomPhotos'] = 'nullable|array|max:20'; $rules['newShowroomPhotos'] = 'nullable|array|max:20';
$rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; $rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
} }
if ($this->isManufacturer()) { if ($this->isManufacturer()) {
$rules['brandName'] = 'required|string|max:255'; $rules['brandName'] = 'required|string|max:255';
$rules['brandDescription'] = 'nullable|string|max:1000'; $rules['brandDescription'] = 'nullable|string|max:1000';
$rules['newBrandImages'] = 'nullable|array|max:10'; $rules['newBrandImages'] = 'nullable|array|max:10';
$rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; $rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
} }
$this->validate($rules, [ $this->validate($rules, [
@ -305,11 +305,11 @@ new class extends Component
'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'), 'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'),
'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'), 'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'),
'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), 'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newTeamPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'newTeamPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), 'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), 'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newBrandImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'newBrandImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
]); ]);
$specialties = array_values(array_filter( $specialties = array_values(array_filter(
@ -793,7 +793,7 @@ new class extends Component
<flux:card class="shadow-elegant"> <flux:card class="shadow-elegant">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">{{ __('Team-Fotos') }}</flux:heading> <flux:heading size="lg">{{ __('Team-Fotos') }}</flux:heading>
<flux:subheading>{{ __('Nur JPG/PNG max. 10 MB pro Bild') }}</flux:subheading> <flux:subheading>{{ __('Nur JPG/PNG max. 200 MB pro Bild') }}</flux:subheading>
</div> </div>
<flux:separator class="mb-6" /> <flux:separator class="mb-6" />
@ -867,7 +867,7 @@ new class extends Component
@endif @endif
<flux:file-upload wire:model="newTeamPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png"> <flux:file-upload wire:model="newTeamPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress /> <flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload> </flux:file-upload>
@if (count($newTeamPhotos) > 0) @if (count($newTeamPhotos) > 0)
@ -892,7 +892,7 @@ new class extends Component
<flux:card class="shadow-elegant"> <flux:card class="shadow-elegant">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">{{ __('Showroom-Galerie') }}</flux:heading> <flux:heading size="lg">{{ __('Showroom-Galerie') }}</flux:heading>
<flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil nur JPG/PNG, max. 10 MB') }}</flux:subheading> <flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil nur JPG/PNG, max. 200 MB') }}</flux:subheading>
</div> </div>
<flux:separator class="mb-6" /> <flux:separator class="mb-6" />
@ -966,7 +966,7 @@ new class extends Component
@endif @endif
<flux:file-upload wire:model="newShowroomPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png"> <flux:file-upload wire:model="newShowroomPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress /> <flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload> </flux:file-upload>
@if (count($newShowroomPhotos) > 0) @if (count($newShowroomPhotos) > 0)
@ -993,7 +993,7 @@ new class extends Component
<flux:card class="shadow-elegant"> <flux:card class="shadow-elegant">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">{{ __('Marken-Bilder') }}</flux:heading> <flux:heading size="lg">{{ __('Marken-Bilder') }}</flux:heading>
<flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) nur JPG/PNG, max. 10 MB') }}</flux:subheading> <flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) nur JPG/PNG, max. 200 MB') }}</flux:subheading>
</div> </div>
<flux:separator class="mb-6" /> <flux:separator class="mb-6" />
@ -1067,7 +1067,7 @@ new class extends Component
@endif @endif
<flux:file-upload wire:model="newBrandImages" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png"> <flux:file-upload wire:model="newBrandImages" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress /> <flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload> </flux:file-upload>
@if (count($newBrandImages) > 0) @if (count($newBrandImages) > 0)

View file

@ -461,7 +461,7 @@ new class extends Component
'status' => 'required|in:active,draft', 'status' => 'required|in:active,draft',
// Bilder // Bilder
'mainImages' => 'nullable|array|min:0|max:10', 'mainImages' => 'nullable|array|min:0|max:10',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', 'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
// Maße & Material // Maße & Material
'widthCm' => 'nullable|integer|min:1', 'widthCm' => 'nullable|integer|min:1',
'heightCm' => 'nullable|integer|min:1', 'heightCm' => 'nullable|integer|min:1',
@ -546,7 +546,7 @@ new class extends Component
'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'), 'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'),
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'), 'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'), 'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'), 'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'),
'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'), 'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'),
'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'), 'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'),
@ -1229,7 +1229,7 @@ new class extends Component
<flux:card class="shadow-elegant"> <flux:card class="shadow-elegant">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">{{ __('Produktbilder') }}</flux:heading> <flux:heading size="lg">{{ __('Produktbilder') }}</flux:heading>
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 10 MB pro Bild, max. 10 Bilder') }} <flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 200 MB pro Bild, max. 10 Bilder') }}
</flux:subheading> </flux:subheading>
</div> </div>
<flux:separator class="mb-6" /> <flux:separator class="mb-6" />
@ -1309,7 +1309,7 @@ new class extends Component
<flux:file-upload wire:model="mainImages" label="Upload files" multiple <flux:file-upload wire:model="mainImages" label="Upload files" multiple
accept="image/jpeg,image/png,.jpg,.jpeg,.png"> accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}" <flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
text="{{ __('Nur JPEG oder PNG max. 10 MB') }}" with-progress /> text="{{ __('Nur JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload> </flux:file-upload>
@if (isset($mainImages) && count($mainImages) > 0) @if (isset($mainImages) && count($mainImages) > 0)

View file

@ -238,7 +238,7 @@ new class extends Component
'status' => 'required|in:active,draft', 'status' => 'required|in:active,draft',
'partnerProductNumber' => 'nullable|string|max:100', 'partnerProductNumber' => 'nullable|string|max:100',
'mainImages' => 'nullable|array|min:0|max:10', 'mainImages' => 'nullable|array|min:0|max:10',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', 'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
]; ];
$messages = [ $messages = [
@ -250,7 +250,7 @@ new class extends Component
'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'), 'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'),
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'), 'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'), 'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
]; ];
if ($isAdminWithoutPartner) { if ($isAdminWithoutPartner) {
@ -459,7 +459,7 @@ new class extends Component
<flux:card class="shadow-elegant"> <flux:card class="shadow-elegant">
<div class="mb-4"> <div class="mb-4">
<flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading> <flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading>
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 10 MB pro Bild, max. 10 Bilder') }} <flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 200 MB pro Bild, max. 10 Bilder') }}
</flux:subheading> </flux:subheading>
</div> </div>
<flux:separator class="mb-6" /> <flux:separator class="mb-6" />
@ -539,7 +539,7 @@ new class extends Component
<flux:file-upload wire:model="mainImages" label="Upload files" multiple <flux:file-upload wire:model="mainImages" label="Upload files" multiple
accept="image/jpeg,image/png,.jpg,.jpeg,.png"> accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}" <flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
text="{{ __('Nur JPEG oder PNG max. 10 MB') }}" with-progress /> text="{{ __('Nur JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload> </flux:file-upload>
@if (isset($mainImages) && count($mainImages) > 0) @if (isset($mainImages) && count($mainImages) > 0)

View file

@ -66,10 +66,6 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
// Display CMS // Display CMS
Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard'); Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard');
Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media'); Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media');
Route::redirect('admin/cms/display-versions', 'admin/cms/display-modules', 301)->name('admin.cms.display-versions');
Route::get('admin/cms/display-versions/{displayVersion}/edit', function (\App\Models\DisplayVersion $displayVersion) {
return redirect()->route('admin.cms.display-module-edit', $displayVersion, 301);
})->name('admin.cms.display-version-edit');
Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules'); Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules');
Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit'); Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit');
Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays'); Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays');

View file

@ -45,6 +45,7 @@ Route::domain($domainPortal)->group(function () {
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
// Display Version API (per physical display) // Display Version API (per physical display)
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']); Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']); Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
@ -127,6 +128,7 @@ Route::get('/api/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTable
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
// Fallback: Display Version API // Fallback: Display Version API
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']); Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']); Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);

View file

@ -29,3 +29,10 @@ test('media picker zeigt ausgewähltes medium ohne livewire property fehler', fu
->assertSee('test-image.jpg') ->assertSee('test-image.jpg')
->assertDontSee('Kein Medium ausgewählt'); ->assertDontSee('Kein Medium ausgewählt');
}); });
test('cms media picker und library uploader nutzen 200 mb dateigrenze', function () {
expect(file_get_contents(app_path('Livewire/Admin/Cms/MediaPicker.php')))
->toContain('max:204800')
->and(file_get_contents(app_path('Livewire/Admin/Cms/MediaLibraryUploader.php')))
->toContain('max:204800');
});

View file

@ -79,9 +79,6 @@ test('can assign versions to a display', function () {
->call('save'); ->call('save');
$display->refresh(); $display->refresh();
expect($display->versions)->toHaveCount(2);
expect($display->versions->first()->id)->toBe($version1->id);
expect($display->versions->last()->id)->toBe($version2->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]);
}); });
@ -100,8 +97,6 @@ test('can reorder versions in playlist', function () {
->call('save'); ->call('save');
$display->refresh(); $display->refresh();
expect($display->versions->first()->id)->toBe($version2->id);
expect($display->versions->last()->id)->toBe($version1->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id, $version1->id]); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id, $version1->id]);
}); });
@ -110,10 +105,7 @@ test('can remove version from playlist', function () {
$version1 = DisplayVersion::factory()->create(); $version1 = DisplayVersion::factory()->create();
$version2 = DisplayVersion::factory()->create(); $version2 = DisplayVersion::factory()->create();
$display = Display::factory()->create(); $display = Display::factory()->create();
$display->versions()->attach([ createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]);
$version1->id => ['sort_order' => 0],
$version2->id => ['sort_order' => 1],
]);
Livewire::actingAs($user) Livewire::actingAs($user)
->test(DisplayList::class) ->test(DisplayList::class)
@ -122,8 +114,6 @@ test('can remove version from playlist', function () {
->call('save'); ->call('save');
$display->refresh(); $display->refresh();
expect($display->versions)->toHaveCount(1);
expect($display->versions->first()->id)->toBe($version2->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id]); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id]);
}); });
@ -207,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)
@ -215,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 () {
@ -233,7 +274,6 @@ test('can publish a draft playlist over the live playlist', function () {
expect($display->draftPlaylist)->toBeNull(); expect($display->draftPlaylist)->toBeNull();
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
expect($display->versions->pluck('id')->all())->toBe([$draftVersion->id]);
}); });
test('can edit live playlist without changing draft playlist', function () { test('can edit live playlist without changing draft playlist', function () {
@ -277,7 +317,6 @@ test('can edit draft playlist without changing live playlist', function () {
expect($display->preview_token)->not->toBeNull(); expect($display->preview_token)->not->toBeNull();
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]);
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]); expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
expect($display->versions->pluck('id')->all())->toBe([]);
}); });
test('draft editor renders iframe preview url', function () { test('draft editor renders iframe preview url', function () {
@ -325,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 () {
@ -354,9 +393,26 @@ test('renders live and draft playlist columns', function () {
->test(DisplayList::class) ->test(DisplayList::class)
->assertSee('Live Modul') ->assertSee('Live Modul')
->assertSee('Draft Modul') ->assertSee('Draft Modul')
->assertSee('Öffentliche Display-Übersicht')
->assertSee('Display-Übersicht öffnen')
->assertSee('https://cabinet.b2in.eu/display/', false)
->assertSee('Live bearbeiten') ->assertSee('Live bearbeiten')
->assertSee('Live-URL zum Kopieren') ->assertSee('Live-URL zum Kopieren')
->assertSee(url('/_cabinet/display/index.html').'?id='.$display->id, false) ->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false)
->assertSee('Entwurf bearbeiten') ->assertSee('Entwurf bearbeiten')
->assertSee('Test-Display'); ->assertSee('Test-Display');
}); });
test('display live urls fall back to cabinet domain when config is empty', function () {
config(['display.player_url' => '']);
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['name' => 'Live Modul']);
$display = Display::factory()->create();
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version->id]);
Livewire::actingAs($user)
->test(DisplayList::class)
->assertSee('https://cabinet.b2in.eu/display/', false)
->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false);
});

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

@ -4,7 +4,6 @@ use App\Models\Display;
use App\Models\DisplayPlaylist; use App\Models\DisplayPlaylist;
use App\Models\DisplayPlaylistItem; use App\Models\DisplayPlaylistItem;
use App\Models\DisplayVersion; use App\Models\DisplayVersion;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
it('creates the display_playlists table with unique status per display', function () { it('creates the display_playlists table with unique status per display', function () {
@ -27,64 +26,8 @@ it('adds is_test and preview_token to displays', function () {
expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue(); expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue();
}); });
it('migrates existing pivot entries into a published playlist with same ordering', function () { it('drops the legacy display version pivot table', function () {
$display = Display::factory()->create(); expect(Schema::hasTable('display_display_version'))->toBeFalse();
$moduleA = DisplayVersion::factory()->create(['name' => 'Modul A']);
$moduleB = DisplayVersion::factory()->create(['name' => 'Modul B']);
DB::table('display_playlists')->where('display_id', $display->id)->delete();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $moduleA->id, 'sort_order' => 0],
['display_id' => $display->id, 'display_version_id' => $moduleB->id, 'sort_order' => 1],
]);
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
$migration->up();
$playlist = DisplayPlaylist::query()
->where('display_id', $display->id)
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->first();
expect($playlist)->not->toBeNull();
expect($playlist->published_at)->not->toBeNull();
$orderedIds = $playlist->items()->pluck('display_version_id')->all();
expect($orderedIds)->toBe([$moduleA->id, $moduleB->id]);
});
it('is idempotent and does not duplicate published playlists on re-run', function () {
$display = Display::factory()->create();
$module = DisplayVersion::factory()->create();
DB::table('display_playlists')->where('display_id', $display->id)->delete();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
]);
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
$migration->up();
$migration->up();
$count = DisplayPlaylist::query()
->where('display_id', $display->id)
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->count();
expect($count)->toBe(1);
});
it('does not break the legacy versions() relation', function () {
$display = Display::factory()->create();
$module = DisplayVersion::factory()->create();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
]);
expect($display->fresh()->versions)->toHaveCount(1);
}); });
it('exposes a live playlist relation and a draft playlist relation on display', function () { it('exposes a live playlist relation and a draft playlist relation on display', function () {

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',
@ -214,14 +250,34 @@ test('check endpoint returns only updated_at', function () {
$response->assertJsonStructure(['updated_at']); $response->assertJsonStructure(['updated_at']);
}); });
test('display config ignores legacy pivot and reads published playlist', function () { test('display overview lists active displays with published modules', function () {
$legacyVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Legacy']); $liveVersion = DisplayVersion::factory()->create(['name' => 'Live Module']);
DisplayVersionItem::factory()->create([ $activeDisplay = Display::factory()->create([
'display_version_id' => $legacyVersion->id, 'name' => 'Showroom Eingang',
'item_type' => 'video', 'location' => 'Bielefeld',
'content' => ['filename' => 'legacy.mp4', 'title' => 'Legacy', 'position' => 25], 'is_active' => true,
]); ]);
publishDisplayModules($activeDisplay, [$liveVersion->id]);
$inactiveDisplay = Display::factory()->create(['is_active' => false]);
publishDisplayModules($inactiveDisplay, [$liveVersion->id]);
Display::factory()->create(['name' => 'Ohne Live']);
$response = $this->getJson('/api/display/overview');
$response->assertSuccessful()
->assertJsonCount(1, 'displays')
->assertJsonPath('displays.0.id', $activeDisplay->id)
->assertJsonPath('displays.0.name', 'Showroom Eingang')
->assertJsonPath('displays.0.location', 'Bielefeld')
->assertJsonPath('displays.0.is_active', true)
->assertJsonPath('displays.0.is_live', true)
->assertJsonPath('displays.0.module_count', 1)
->assertJsonPath('displays.0.url', 'https://cabinet.b2in.eu/display/?id='.$activeDisplay->id);
});
test('display config reads published playlist', function () {
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']); $publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
DisplayVersionItem::factory()->create([ DisplayVersionItem::factory()->create([
'display_version_id' => $publishedVersion->id, 'display_version_id' => $publishedVersion->id,
@ -230,7 +286,6 @@ test('display config ignores legacy pivot and reads published playlist', functio
]); ]);
$display = Display::factory()->create(); $display = Display::factory()->create();
$display->versions()->attach($legacyVersion->id, ['sort_order' => 0]);
publishDisplayModules($display, [$publishedVersion->id]); publishDisplayModules($display, [$publishedVersion->id]);
$response = $this->getJson("/api/display/{$display->id}/config"); $response = $this->getJson("/api/display/{$display->id}/config");
@ -301,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 () {
@ -391,12 +440,34 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
->toContain('width: min(100vw, calc(100vh * 9 / 16));') ->toContain('width: min(100vw, calc(100vh * 9 / 16));')
->toContain('height: min(100vh, calc(100vw * 16 / 9));') ->toContain('height: min(100vh, calc(100vw * 16 / 9));')
->toContain('container-type: size;') ->toContain('container-type: size;')
->toContain("if (hostname === 'cabinet.b2in.eu') {")
->toContain("return 'https://portal.b2in.eu';")
->toContain('/api/display/overview')
->toContain('Aktive Live-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 () {
@ -414,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;
@ -36,6 +40,20 @@ test('display version list renders for authenticated users', function () {
$response->assertSeeLivewire(DisplayVersionList::class); $response->assertSeeLivewire(DisplayVersionList::class);
}); });
test('display dashboard documentation describes current workflow', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.cms.display-dashboard'))
->assertSuccessful()
->assertSee('Live und Entwurf')
->assertSee('Meta-Einstellungen pflegen')
->assertSee('SVG-Logos')
->assertSee('/storage/...')
->assertSee('Entwurf in der 9:16-Vorschau');
});
test('can create a display version', function () { test('can create a display version', function () {
$user = User::factory()->create(); $user = User::factory()->create();
@ -77,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]);
@ -104,19 +145,6 @@ test('display version editor renders with correct version data', function () {
$response->assertSeeLivewire(DisplayVersionEditor::class); $response->assertSeeLivewire(DisplayVersionEditor::class);
}); });
test('old display version routes redirect to module routes', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create();
$this->actingAs($user);
$this->get(route('admin.cms.display-versions'))
->assertRedirect(route('admin.cms.display-modules'));
$this->get(route('admin.cms.display-version-edit', $version))
->assertRedirect(route('admin.cms.display-module-edit', $version));
});
test('display module editor renders module preview', function () { test('display module editor renders module preview', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$version = DisplayVersion::factory()->create(['name' => 'Preview Modul']); $version = DisplayVersion::factory()->create(['name' => 'Preview Modul']);
@ -151,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']);
@ -212,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']);
@ -298,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')
@ -314,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');
}); });
@ -328,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 () {
@ -398,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);
});