diff --git a/.devcontainer/docker-compose.dev.yml b/.devcontainer/docker-compose.dev.yml index df7d4f4..04f71ac 100644 --- a/.devcontainer/docker-compose.dev.yml +++ b/.devcontainer/docker-compose.dev.yml @@ -62,7 +62,7 @@ services: MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password volumes: - '../:/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: - sail depends_on: diff --git a/.devcontainer/php-upload-limits.ini b/.devcontainer/php-upload-limits.ini index ed99be6..4a13806 100644 --- a/.devcontainer/php-upload-limits.ini +++ b/.devcontainer/php-upload-limits.ini @@ -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 post_max_size = 210M diff --git a/.env.example b/.env.example index ed229cd..4ffd7b0 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false 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) # GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX diff --git a/app/Console/Commands/GenerateVideoThumbnails.php b/app/Console/Commands/GenerateVideoThumbnails.php new file mode 100644 index 0000000..e325da0 --- /dev/null +++ b/app/Console/Commands/GenerateVideoThumbnails.php @@ -0,0 +1,59 @@ +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(" {$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; + } +} diff --git a/app/Console/Commands/MigrateLegacyDisplays.php b/app/Console/Commands/MigrateLegacyDisplays.php index 2ea2dc0..4ddcb64 100644 --- a/app/Console/Commands/MigrateLegacyDisplays.php +++ b/app/Console/Commands/MigrateLegacyDisplays.php @@ -4,6 +4,8 @@ namespace App\Console\Commands; use App\Models\Display; use App\Models\DisplayFooterContent; +use App\Models\DisplayPlaylist; +use App\Models\DisplayPlaylistItem; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; use App\Models\DisplayVideo; @@ -75,7 +77,16 @@ class MigrateLegacyDisplays extends Command '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("Created version: {$version->name} (ID: {$version->id})"); diff --git a/app/Http/Controllers/Api/DisplayPreviewController.php b/app/Http/Controllers/Api/DisplayPreviewController.php index 05cf830..a907c8d 100644 --- a/app/Http/Controllers/Api/DisplayPreviewController.php +++ b/app/Http/Controllers/Api/DisplayPreviewController.php @@ -16,7 +16,9 @@ class DisplayPreviewController extends Controller ->where('preview_token', $token) ->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 diff --git a/app/Http/Controllers/Api/DisplayVersionApiController.php b/app/Http/Controllers/Api/DisplayVersionApiController.php index 37ca1ac..b101124 100644 --- a/app/Http/Controllers/Api/DisplayVersionApiController.php +++ b/app/Http/Controllers/Api/DisplayVersionApiController.php @@ -9,6 +9,35 @@ use Illuminate\Http\JsonResponse; 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 { if (! $display->is_active) { diff --git a/app/Http/Controllers/Api/ModulePreviewController.php b/app/Http/Controllers/Api/ModulePreviewController.php index 560184a..bec62e4 100644 --- a/app/Http/Controllers/Api/ModulePreviewController.php +++ b/app/Http/Controllers/Api/ModulePreviewController.php @@ -13,14 +13,21 @@ class ModulePreviewController extends Controller { 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 { 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 diff --git a/app/Livewire/Admin/Cms/DisplayList.php b/app/Livewire/Admin/Cms/DisplayList.php index dda18eb..698e540 100644 --- a/app/Livewire/Admin/Cms/DisplayList.php +++ b/app/Livewire/Admin/Cms/DisplayList.php @@ -30,6 +30,9 @@ class DisplayList extends Component public $addVersionSelect = null; + /** @var array */ + public $versionsToAdd = []; + public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; public ?string $draftPreviewToken = null; @@ -44,7 +47,7 @@ class DisplayList extends Component ], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED; 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->displayName = $display->name; $this->displayLocation = $display->location ?? ''; @@ -75,6 +78,20 @@ class DisplayList extends Component $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 { return DisplayVersion::active() @@ -137,7 +154,6 @@ class DisplayList extends Component $this->previewFrameRefreshCounter++; } else { $this->syncPublishedPlaylist($display); - $this->syncLegacyPivot($display, $this->selectedVersionIds); } $this->closeModal(); @@ -176,10 +192,31 @@ class DisplayList extends Component } $display->draftPlaylist->delete(); + $display->clearPreviewToken(); 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 { $display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId); @@ -202,24 +239,11 @@ class DisplayList extends Component return $display->draftPlaylist->fresh('modules'); }); - $this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist)); + $display->clearPreviewToken(); session()->flash('success', 'Entwurf wurde veröffentlicht.'); } - /** - * @param array $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 { $playlist = $display->playlists()->firstOrCreate( @@ -297,8 +321,7 @@ class DisplayList extends Component return $this->moduleIdsForPlaylist($display->draftPlaylist); } - return $this->moduleIdsForPlaylist($display->livePlaylist) - ?: $display->versions->pluck('id')->all(); + return $this->moduleIdsForPlaylist($display->livePlaylist); } public function deleteDisplay(int $id): void @@ -331,6 +354,7 @@ class DisplayList extends Component $this->displayIsActive = true; $this->displayIsTest = false; $this->addVersionSelect = null; + $this->versionsToAdd = []; $this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; $this->draftPreviewToken = null; $this->previewFrameRefreshCounter = 0; diff --git a/app/Livewire/Admin/Cms/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php index 1c56870..27b2b90 100644 --- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -117,8 +117,7 @@ class DisplayMediaPicker extends Component ->active() ->when($this->type === 'image', fn ($q) => $q->images()) ->when($this->type === 'video', fn ($q) => $q->videos()) - ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") - ->orWhere('title', 'like', "%{$this->search}%")) + ->when($this->search, fn ($q) => $q->search($this->search)) ->orderByDesc('created_at') ->paginate(18); } diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index c79d185..ac5a318 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -3,9 +3,10 @@ namespace App\Livewire\Admin\Cms; use App\Enums\DisplayVersionType; +use App\Models\DisplayMedia; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; -use Illuminate\Support\Facades\File; +use App\Support\DisplayModuleSettings; use Livewire\Attributes\On; use Livewire\Component; @@ -55,8 +56,12 @@ class DisplayVersionEditor extends Component public bool $mediaIsActive = true; - // Offers: Slide fields - public string $slideType = 'product-hero'; + // Offers: Slide fields (single dynamic detail layout) + public bool $slideShowLogo = true; + + public string $slideLogoUrl = ''; + + public string $slideBrandText = ''; public int $slideDuration = 8000; @@ -64,30 +69,46 @@ class DisplayVersionEditor extends Component public string $slideBadge = ''; + public bool $slideShowBadge = true; + public string $slideEyebrow = ''; + public bool $slideShowEyebrow = true; + public string $slideTitle = ''; public string $slideSubline = ''; + public bool $slideShowSubline = false; + public string $slidePrice = ''; public string $slideOriginalPrice = ''; + public bool $slideStrikeOriginalPrice = false; + public string $slideTagText = ''; + public bool $slideShowPrice = false; + /** @var array */ public array $slideBullets = []; + public bool $slideShowBullets = true; + public string $slideDisclaimer = ''; + public bool $slideShowDisclaimer = false; + public string $slideQrUrl = ''; public string $slideQrTitle = ''; + public bool $slideShowQr = true; + public string $slideContact = ''; - public bool $slideShowBrandText = false; + public bool $slideShowContact = true; public string $slideBrandTagline = ''; @@ -98,32 +119,25 @@ class DisplayVersionEditor extends Component public array $settings = []; - /** @var array */ - public array $availableVideos = []; - public int $previewFrameRefreshCounter = 0; + /** @var array */ + public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + public function mount(DisplayVersion $displayVersion): void { $this->version = $displayVersion; $this->versionName = $displayVersion->name; $this->settings = $this->settingsWithDefaults(); - - if ($this->version->type === DisplayVersionType::VideoDisplay) { - $this->loadAvailableVideos(); - } + $this->normalizeBrandPositions(); } - public function loadAvailableVideos(): void + public function updated(string $name): void { - $assetsPath = public_path('_cabinet/assets'); - - if (File::exists($assetsPath)) { - $this->availableVideos = collect(File::files($assetsPath)) - ->map(fn ($file) => $file->getFilename()) - ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) - ->values() - ->toArray(); + if (str_starts_with($name, 'settings.show_footer') + || str_starts_with($name, 'settings.logo_position') + || str_starts_with($name, 'settings.claim_position')) { + $this->normalizeBrandPositions(); } } @@ -159,12 +173,56 @@ class DisplayVersionEditor extends Component public function saveSettings(): void { + $this->normalizeBrandPositions(); $this->version->update(['settings' => $this->settings]); $this->showSettingsModal = false; $this->refreshModulePreview(); 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 $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 // ======================================== @@ -275,10 +333,20 @@ class DisplayVersionEditor extends Component 'videoFilename' => $this->videoFilename = $url, 'mediaUrl' => $this->mediaUrl = $url, 'slideImageUrl' => $this->slideImageUrl = $url, + 'slideLogoUrl' => $this->slideLogoUrl = $url, 'settings.header_logo_url' => $this->settings['header_logo_url'] = $url, - 'settings.logo_url' => $this->settings['logo_url'] = $url, 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 @@ -305,33 +373,43 @@ class DisplayVersionEditor extends Component private function loadItemContent(DisplayVersionItem $item): void { $content = $item->content; + $isActive = (bool) $item->is_active; match ($item->item_type) { - 'video' => $this->loadVideoContent($content), - 'footer' => $this->loadFooterContent($content), - 'media' => $this->loadMediaContent($content), - 'slide' => $this->loadSlideContent($content), + 'video' => $this->loadVideoContent($content, $isActive), + 'footer' => $this->loadFooterContent($content, $isActive), + 'media' => $this->loadMediaContent($content, $isActive), + 'slide' => $this->loadSlideContent($content, $isActive), default => null, }; } - private function loadVideoContent(array $content): void + /** + * @param array $content + */ + private function loadVideoContent(array $content, bool $isActive): void { $this->videoFilename = $content['filename'] ?? ''; $this->videoTitle = $content['title'] ?? ''; $this->videoPosition = $content['position'] ?? 25; - $this->videoIsActive = true; + $this->videoIsActive = $isActive; } - private function loadFooterContent(array $content): void + /** + * @param array $content + */ + private function loadFooterContent(array $content, bool $isActive): void { $this->footerHeadline = $content['headline'] ?? ''; $this->footerSubline = $content['subline'] ?? ''; $this->footerUrl = $content['url'] ?? ''; - $this->footerIsActive = true; + $this->footerIsActive = $isActive; } - private function loadMediaContent(array $content): void + /** + * @param array $content + */ + private function loadMediaContent(array $content, bool $isActive): void { $this->mediaType = $content['media_type'] ?? 'image'; $this->mediaCategory = $content['category'] ?? 'immobilien'; @@ -339,12 +417,17 @@ class DisplayVersionEditor extends Component $this->mediaHeadline = $content['headline'] ?? ''; $this->mediaSubline = $content['subline'] ?? ''; $this->mediaDuration = $content['duration_seconds'] ?? 10; - $this->mediaIsActive = true; + $this->mediaIsActive = $isActive; } - private function loadSlideContent(array $content): void + /** + * @param array $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->slideImageUrl = $content['image_url'] ?? ''; $this->slideBadge = $content['badge_text'] ?? ''; @@ -353,15 +436,26 @@ class DisplayVersionEditor extends Component $this->slideSubline = $content['subline'] ?? ''; $this->slidePrice = $content['price'] ?? ''; $this->slideOriginalPrice = $content['original_price'] ?? ''; + $this->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false; $this->slideTagText = $content['tag_text'] ?? ''; $this->slideBullets = $content['bullets'] ?? []; $this->slideDisclaimer = $content['disclaimer'] ?? ''; $this->slideQrUrl = $content['qr_url'] ?? ''; $this->slideQrTitle = $content['qr_title'] ?? ''; $this->slideContact = $content['contact'] ?? ''; - $this->slideShowBrandText = $content['show_brand_text'] ?? false; $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, ], 'slide' => [ - 'type' => $this->slideType, + 'type' => 'detail', + 'show_logo' => $this->slideShowLogo, + 'logo_url' => $this->slideLogoUrl, + 'brand_text' => $this->slideBrandText, 'duration' => $this->slideDuration, 'image_url' => $this->slideImageUrl, 'badge_text' => $this->slideBadge, + 'show_badge' => $this->slideShowBadge, 'eyebrow' => $this->slideEyebrow, + 'show_eyebrow' => $this->slideShowEyebrow, 'title' => $this->slideTitle, 'subline' => $this->slideSubline, + 'show_subline' => $this->slideShowSubline, 'price' => $this->slidePrice, 'original_price' => $this->slideOriginalPrice, + 'strike_original_price' => $this->slideStrikeOriginalPrice, '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, + 'show_disclaimer' => $this->slideShowDisclaimer, 'qr_url' => $this->slideQrUrl, 'qr_title' => $this->slideQrTitle, + 'show_qr' => $this->slideShowQr, 'contact' => $this->slideContact, - 'show_brand_text' => $this->slideShowBrandText, + 'show_contact' => $this->slideShowContact, 'brand_tagline' => $this->slideBrandTagline, ], default => [], @@ -450,22 +555,32 @@ class DisplayVersionEditor extends Component $this->mediaSubline = ''; $this->mediaDuration = 10; $this->mediaIsActive = true; - $this->slideType = 'product-hero'; + $this->slideShowLogo = true; + $this->slideLogoUrl = ''; + $this->slideBrandText = ''; $this->slideDuration = 8000; $this->slideImageUrl = ''; $this->slideBadge = ''; + $this->slideShowBadge = true; $this->slideEyebrow = ''; + $this->slideShowEyebrow = true; $this->slideTitle = ''; $this->slideSubline = ''; + $this->slideShowSubline = false; $this->slidePrice = ''; $this->slideOriginalPrice = ''; + $this->slideStrikeOriginalPrice = false; $this->slideTagText = ''; + $this->slideShowPrice = false; $this->slideBullets = []; + $this->slideShowBullets = true; $this->slideDisclaimer = ''; + $this->slideShowDisclaimer = false; $this->slideQrUrl = ''; $this->slideQrTitle = ''; + $this->slideShowQr = true; $this->slideContact = ''; - $this->slideShowBrandText = false; + $this->slideShowContact = true; $this->slideBrandTagline = ''; $this->slideIsActive = true; } @@ -480,46 +595,7 @@ class DisplayVersionEditor extends Component */ private function settingsWithDefaults(): array { - return array_replace_recursive($this->defaultSettings(), $this->version->settings ?? []); - } - - /** - * @return array - */ - 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, - ], - ], - }; + return DisplayModuleSettings::merge($this->version->type, $this->version->settings); } public function render() diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php index 4a0ceb4..80dd21e 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionList.php +++ b/app/Livewire/Admin/Cms/DisplayVersionList.php @@ -4,6 +4,8 @@ namespace App\Livewire\Admin\Cms; use App\Enums\DisplayVersionType; use App\Models\DisplayVersion; +use App\Support\DisplayModuleSettings; +use Illuminate\Support\Facades\DB; use Livewire\Component; class DisplayVersionList extends Component @@ -52,7 +54,20 @@ class DisplayVersionList extends Component 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; $version->delete(); @@ -70,40 +85,17 @@ class DisplayVersionList extends Component */ private function defaultSettingsForType(string $type): array { - return match ($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 => [], - }; + return DisplayModuleSettings::defaults($type); } 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') ->get(); diff --git a/app/Livewire/Admin/Cms/MediaLibraryUploader.php b/app/Livewire/Admin/Cms/MediaLibraryUploader.php index 8b495b6..90c1986 100644 --- a/app/Livewire/Admin/Cms/MediaLibraryUploader.php +++ b/app/Livewire/Admin/Cms/MediaLibraryUploader.php @@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component { $this->validate([ '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); diff --git a/app/Livewire/Admin/Cms/MediaPicker.php b/app/Livewire/Admin/Cms/MediaPicker.php index 14eb146..ee4e9af 100644 --- a/app/Livewire/Admin/Cms/MediaPicker.php +++ b/app/Livewire/Admin/Cms/MediaPicker.php @@ -73,7 +73,7 @@ class MediaPicker extends Component { $this->validate([ '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); diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php index 47dcd6a..a97410d 100644 --- a/app/Livewire/Cabinet/QuickStatus.php +++ b/app/Livewire/Cabinet/QuickStatus.php @@ -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'); - $key = request()->get('key'); + $key = $k ?? request()->query('k'); if (! $validKey || $key !== $validKey) { $this->authorized = false; @@ -92,6 +92,13 @@ class QuickStatus extends Component 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', ]); + $showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true); + + if (! $showsNotice) { + $this->noticeHeadline = ''; + $this->noticeSubtext = ''; + } + CabinetTabletSetting::current()->update([ 'store_status' => $this->storeStatus, 'notice_headline' => $this->noticeHeadline ?: null, diff --git a/app/Models/Display.php b/app/Models/Display.php index 279ec51..73d705d 100644 --- a/app/Models/Display.php +++ b/app/Models/Display.php @@ -4,7 +4,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; 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 */ @@ -67,29 +55,6 @@ class Display extends Model ->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 { if (! $this->preview_token) { @@ -99,4 +64,20 @@ class Display extends Model 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(); + } + } } diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php index 3cf2397..eb91036 100644 --- a/app/Models/DisplayMedia.php +++ b/app/Models/DisplayMedia.php @@ -150,4 +150,12 @@ class DisplayMedia extends Model { 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}%"); + }); + } } diff --git a/app/Models/DisplayVersion.php b/app/Models/DisplayVersion.php index 20cf372..539a805 100644 --- a/app/Models/DisplayVersion.php +++ b/app/Models/DisplayVersion.php @@ -6,7 +6,6 @@ use App\Enums\DisplayVersionType; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; class DisplayVersion extends Model @@ -35,10 +34,12 @@ class DisplayVersion extends Model return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order'); } - public function displays(): BelongsToMany + /** + * @return HasMany + */ + public function playlistItems(): HasMany { - return $this->belongsToMany(Display::class, 'display_display_version') - ->withPivot('sort_order'); + return $this->hasMany(DisplayPlaylistItem::class); } /** diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index bc08115..f62c459 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Symfony\Component\Process\Process; class DisplayMediaService { @@ -34,7 +35,7 @@ class DisplayMediaService } } - return DisplayMedia::create([ + $media = DisplayMedia::create([ 'filename' => $filename, 'disk' => 'public', 'path' => $relativePath, @@ -45,6 +46,80 @@ class DisplayMediaService 'collection' => $collection, '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); } /** diff --git a/app/Services/DisplayPlaylistConfigBuilder.php b/app/Services/DisplayPlaylistConfigBuilder.php index ea05231..46c7414 100644 --- a/app/Services/DisplayPlaylistConfigBuilder.php +++ b/app/Services/DisplayPlaylistConfigBuilder.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\DisplayPlaylist; use App\Models\DisplayVersion; +use App\Support\DisplayModuleSettings; use Illuminate\Database\Eloquent\Collection; class DisplayPlaylistConfigBuilder @@ -95,9 +96,7 @@ class DisplayPlaylistConfigBuilder return [ 'type' => 'video-display', 'version_name' => $module->name, - 'settings' => array_replace([ - 'qr_label' => 'Website', - ], $module->settings ?? []), + 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), 'videoPlaylist' => $videos, 'footerContent' => $footerContent, ]; @@ -133,20 +132,7 @@ class DisplayPlaylistConfigBuilder return [ 'type' => 'b2in', 'version_name' => $module->name, - 'settings' => array_replace_recursive([ - '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 ?? []), + 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), 'items' => $mediaItems, ]; } @@ -157,42 +143,45 @@ class DisplayPlaylistConfigBuilder */ private function offersData(DisplayVersion $module, Collection $items): array { - $slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [ - 'type' => $item->content['type'] ?? 'product-hero', - 'duration' => $item->content['duration'] ?? 8000, - 'image_url' => $item->content['image_url'] ?? '', - 'badge_text' => $item->content['badge_text'] ?? '', - 'eyebrow' => $item->content['eyebrow'] ?? '', - 'title' => $item->content['title'] ?? '', - 'subline' => $item->content['subline'] ?? '', - 'price' => $item->content['price'] ?? '', - 'original_price' => $item->content['original_price'] ?? '', - 'tag_text' => $item->content['tag_text'] ?? '', - 'bullets' => $item->content['bullets'] ?? [], - 'disclaimer' => $item->content['disclaimer'] ?? '', - 'qr_url' => $item->content['qr_url'] ?? '', - 'qr_title' => $item->content['qr_title'] ?? '', - 'contact' => $item->content['contact'] ?? '', - 'show_brand_text' => $item->content['show_brand_text'] ?? false, - 'brand_tagline' => $item->content['brand_tagline'] ?? '', - ]); + $slides = $items->where('item_type', 'slide')->values()->map(function ($item) { + $content = $item->content; + + return [ + 'type' => $content['type'] ?? 'detail', + 'show_logo' => $content['show_logo'] ?? true, + 'logo_url' => $content['logo_url'] ?? '', + 'brand_text' => $content['brand_text'] ?? '', + 'duration' => $content['duration'] ?? 8000, + 'image_url' => $content['image_url'] ?? '', + 'badge_text' => $content['badge_text'] ?? '', + 'show_badge' => $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '', + 'eyebrow' => $content['eyebrow'] ?? '', + 'show_eyebrow' => $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '', + 'title' => $content['title'] ?? '', + 'subline' => $content['subline'] ?? '', + 'show_subline' => $content['show_subline'] ?? ($content['subline'] ?? '') !== '', + '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 [ 'type' => 'offers', 'version_name' => $module->name, - 'settings' => array_replace_recursive([ - '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 ?? []), + 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), 'slides' => $slides, ]; } diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php new file mode 100644 index 0000000..2fcad41 --- /dev/null +++ b/app/Support/DisplayModuleSettings.php @@ -0,0 +1,60 @@ + + */ + 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|null $settings + * @return array + */ + public static function merge(DisplayVersionType|string $type, ?array $settings): array + { + return array_replace_recursive(self::defaults($type), $settings ?? []); + } +} diff --git a/config/display.php b/config/display.php index 9443e45..f1fff65 100644 --- a/config/display.php +++ b/config/display.php @@ -22,4 +22,7 @@ return [ // Haupt-Domain 'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'), + // Öffentliche Player-URL der Display-Domain + 'player_url' => env('DISPLAY_PLAYER_URL', 'https://cabinet.b2in.eu/display'), + ]; diff --git a/database/migrations/2026_05_13_103600_drop_display_display_version_table.php b/database/migrations/2026_05_13_103600_drop_display_display_version_table.php new file mode 100644 index 0000000..cfb9b9a --- /dev/null +++ b/database/migrations/2026_05_13_103600_drop_display_display_version_table.php @@ -0,0 +1,26 @@ +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']); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0f83fd4..b40ae54 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder 'email' => 'kevin.adametz@me.com', 'password' => Hash::make('xunfew-0Jygjy-minnyt'), ]); + + $this->call(TestDisplaySeeder::class); } } diff --git a/database/seeders/DisplayVersionSeeder.php b/database/seeders/DisplayVersionSeeder.php index d6a3357..4d27410 100644 --- a/database/seeders/DisplayVersionSeeder.php +++ b/database/seeders/DisplayVersionSeeder.php @@ -169,7 +169,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Kontakt', '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", ], [ @@ -188,7 +190,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], [ @@ -212,7 +216,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], [ @@ -231,7 +237,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Sichern', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], ]; diff --git a/database/seeders/TestDisplaySeeder.php b/database/seeders/TestDisplaySeeder.php new file mode 100644 index 0000000..8bc6729 --- /dev/null +++ b/database/seeders/TestDisplaySeeder.php @@ -0,0 +1,31 @@ +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.'); + } +} diff --git a/dev/displays-11-05-2026/00-entwicklungskonzept.md b/dev/displays-11-05-2026/00-entwicklungskonzept.md index a6fdce2..07d7534 100644 --- a/dev/displays-11-05-2026/00-entwicklungskonzept.md +++ b/dev/displays-11-05-2026/00-entwicklungskonzept.md @@ -16,8 +16,8 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor |---|---| | `cms/display-dashboard` | Übersicht / Einstieg | | `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) | -| `cms/display-versions` | Inhalts-„Versionen" | -| `cms/display-versions/{id}/edit` | Editor für eine Version | +| `cms/display-modules` | Inhalts-Module | +| `cms/display-modules/{id}/edit` | Editor für ein Modul | | `cms/displays` | Physische Displays + Playlist-Zuweisung | | `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) -└── m:n via display_display_version (sort_order = Playlist-Reihenfolge) - └── display_versions (5 Datensätze live) - ├── type: video-display | b2in | offers - ├── settings: JSON - └── 1:n display_version_items (17 Datensätze live) - ├── item_type: video | footer | media | slide - └── content: JSON +└── 1:n display_playlists (Live/Entwurf) + └── 1:n display_playlist_items (sort_order = Playlist-Reihenfolge) + └── display_versions (technisch), fachlich Module + ├── type: video-display | b2in | offers + ├── settings: JSON + └── 1:n display_version_items + ├── item_type: video | footer | media | slide + └── content: JSON ``` ### 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. | | 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 @@ -181,7 +182,7 @@ für jedes Display D: 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: 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. @@ -355,15 +356,15 @@ Jede Phase liefert ein in sich getestetes, deploybares Inkrement. - [ ] Player-Templates: Single-Module-Modus ### Phase 6 – Umbenennung & Onboarding (Tag 3) -- [ ] Routen: `display-versions` → `display-modules` (mit 301-Redirect) -- [ ] Komponenten / Views umbenennen -- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren -- [ ] Tooltips an Schlüsselstellen +- [x] Routen: `display-versions` → `display-modules` +- [x] Komponenten / Views umbenennen +- [x] Dashboard-Texte / Hilfe-Bausteine aktualisieren +- [x] Tooltips an Schlüsselstellen ### Phase 7 – Aufräumen (Tag 4) -- [ ] `display_display_version`-Tabelle dropped -- [ ] Alte Routen entfernt -- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen) +- [x] `display_display_version`-Tabelle dropped +- [x] Alte Routen entfernt +- [x] Entwicklerdoku in `dev/displays-11-05-2026` aktualisiert - [ ] Vollständiger Test-Run --- diff --git a/dev/displays-11-05-2026/01-status.md b/dev/displays-11-05-2026/01-status.md index e29d514..16628d7 100644 --- a/dev/displays-11-05-2026/01-status.md +++ b/dev/displays-11-05-2026/01-status.md @@ -13,7 +13,8 @@ | **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 | | **5** | Modul-Editor: 3-stufige Vorschau | ✅ 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 @@ -245,7 +246,7 @@ Umsetzung: ## 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 @@ -261,9 +262,9 @@ Dateien: Umsetzung: - 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` -- 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“ -- 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 @@ -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. +--- + +## 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 `` (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. + + diff --git a/docker-compose.yml b/docker-compose.yml index e3a8b9e..f77ab25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: REDIS_HOST: global-redis volumes: - '.:/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: - sail - proxy diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php index f571e05..4924e6f 100644 --- a/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php +++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php @@ -3,7 +3,7 @@ accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png"> diff --git a/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php index 8b495b6..90c1986 100644 --- a/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php +++ b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php @@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component { $this->validate([ '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); diff --git a/packages/flux-cms/core/src/Helpers/MediaPicker.php b/packages/flux-cms/core/src/Helpers/MediaPicker.php index 14eb146..ee4e9af 100644 --- a/packages/flux-cms/core/src/Helpers/MediaPicker.php +++ b/packages/flux-cms/core/src/Helpers/MediaPicker.php @@ -73,7 +73,7 @@ class MediaPicker extends Component { $this->validate([ '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); diff --git a/packages/flux-cms/core/src/Helpers/MediaUploader.php b/packages/flux-cms/core/src/Helpers/MediaUploader.php index c06737d..059cdd0 100644 --- a/packages/flux-cms/core/src/Helpers/MediaUploader.php +++ b/packages/flux-cms/core/src/Helpers/MediaUploader.php @@ -18,7 +18,7 @@ class MediaUploader extends Component public string $directory = 'cms/uploads'; - #[Validate('file|max:10240')] + #[Validate('file|max:204800')] public $file; public function updatedFile(): void diff --git a/phpunit.xml b/phpunit.xml index 5cf230b..4dbc029 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,7 @@ + diff --git a/public/_cabinet/_docs/QUICK_START.md b/public/_cabinet/_docs/QUICK_START.md index 69a1f80..3d2f744 100644 --- a/public/_cabinet/_docs/QUICK_START.md +++ b/public/_cabinet/_docs/QUICK_START.md @@ -85,11 +85,11 @@ Beim Hochladen neuer Videos beachten: - [ ] Format: **MP4** (H.264 + AAC) - [ ] Auflösung: **Max 1920x1080** - [ ] Bitrate: **5-10 Mbps** -- [ ] Dateigröße: **Max 100 MB** +- [ ] Dateigröße: **Max 200 MB** - [ ] Länge: **15-60 Sekunden** (optimal) ### ⚠️ Vermeiden: -- ❌ Zu große Dateien (>100MB) +- ❌ Zu große Dateien (>200MB) - ❌ Zu hohe Bitrate (>10 Mbps) - ❌ Zu lange Videos (>3 Min) - ❌ Exotische Formate (MOV, AVI, WMV) diff --git a/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md b/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md index a8b86d3..7e69043 100644 --- a/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md +++ b/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md @@ -214,7 +214,7 @@ setTimeout(() => { ### 3. **Dateigrößen** - **Optimal:** 10-50 MB pro Video -- **Maximum:** 100 MB pro Video +- **Maximum:** 200 MB pro Video - **Warum:** Schnelleres Laden, weniger Buffering ### 4. **Playlist-Größe** diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html index 4873654..c166923 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -143,6 +143,24 @@ 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 { flex: 1; position: relative; overflow: hidden; @@ -171,6 +189,8 @@ font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); line-height: 1.4; } + /* Without footer the text reclaims the footer's space at the bottom */ + .b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; } /* B2in Footer */ .b2in-footer { @@ -204,6 +224,9 @@ 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-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 { 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; 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 */ .offer-bullets { @@ -411,6 +438,66 @@ .status-message { font-weight: 300; opacity: 0.7; } .status-error { color: #ef4444; font-weight: 500; } .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); + } @@ -434,6 +521,18 @@
Neustart in Kürze...
+ + - - - diff --git a/resources/views/components/layouts/auth/split.blade.php b/resources/views/components/layouts/auth/split.blade.php index 0627b6c..8af6917 100644 --- a/resources/views/components/layouts/auth/split.blade.php +++ b/resources/views/components/layouts/auth/split.blade.php @@ -43,6 +43,7 @@ + @livewireScripts @fluxScripts @include('partials.theme-toggle-script') diff --git a/resources/views/components/media-thumb.blade.php b/resources/views/components/media-thumb.blade.php new file mode 100644 index 0000000..7a8eb53 --- /dev/null +++ b/resources/views/components/media-thumb.blade.php @@ -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 + +
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 === '') + + @elseif($isVideo) + + + + + @else + + + @endif +
diff --git a/resources/views/livewire/admin/cms/display-dashboard.blade.php b/resources/views/livewire/admin/cms/display-dashboard.blade.php index e488d9b..64d4fa7 100644 --- a/resources/views/livewire/admin/cms/display-dashboard.blade.php +++ b/resources/views/livewire/admin/cms/display-dashboard.blade.php @@ -171,9 +171,9 @@ $tabletStatus = computed(function () { Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:

    -
  • Mediathek - 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.
  • -
  • Module – 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).
  • -
  • Displays – Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Module als Playlist zugewiesen.
  • +
  • Mediathek - 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.
  • +
  • Module – 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.
  • +
  • Displays – 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.
  • Info-Tablet – Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.
@@ -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.

    -
  • Direkt-Upload: 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.
  • +
  • Direkt-Upload: 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.
  • Externe URLs: Für Videos über 200 MB (z. 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.
  • Sammlungen: Ordnen Sie Medien in Sammlungen wie immobilien, moebel oder brand, um bei vielen Dateien den Überblick zu behalten.
  • -
  • Medienauswahl im Editor: 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.
  • +
  • Medienauswahl im Editor: 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.
@@ -211,19 +211,19 @@ $tabletStatus = computed(function () {
  • Video-Display – - Für Video-Playlists mit optionalem Footer. Inhalte: Videos (Dateiname, Titel, Position/Ausschnitt) und Footer-Zeilen (Überschrift, Unterzeile, optionaler QR-Code-Link). + Für Video-Playlists mit optionalem Footer. Inhalte: Videos aus der Mediathek oder Legacy-Dateinamen, Position/Ausschnitt und Footer-Zeilen (Überschrift, Unterzeile, optionaler QR-Code-Link). Mediathek-URLs wie /storage/... werden direkt abgespielt.
  • B2in Display – - Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: Media-Items 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: Media-Items 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.
  • Angebote – - 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.

- 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.

@@ -238,9 +238,11 @@ $tabletStatus = computed(function () { Ein Display repräsentiert einen physischen Bildschirm im Showroom.

    -
  • Modul-Zuweisung: Jedem Display können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.
  • +
  • Live und Entwurf: 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.
  • +
  • Modul-Zuweisung: Jedem Live- oder Entwurfsstand können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.
  • +
  • Vorschau: 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.
  • Aktiv/Inaktiv: Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.
  • -
  • API-Anbindung: Jedes Display ruft seine Inhalte über eine JSON-API ab (/api/display/{id}/config). Änderungen werden beim nächsten Abruf automatisch übernommen.
  • +
  • API-Anbindung: Jedes Display ruft seine Live-Inhalte über eine JSON-API ab (/api/display/{id}/config). Entwürfe laufen über Preview-Tokens, Module über eigene Preview-Endpunkte.
@@ -282,10 +284,12 @@ $tabletStatus = computed(function () { Typischer Workflow
    +
  1. Medien hochladen – Bilder, SVG-Logos oder Videos in der Display-Mediathek ablegen.
  2. Modul erstellen – Unter „Module" ein neues Modul mit passendem Typ anlegen (z. B. „Frühling 2026 Video").
  3. -
  4. Inhalte hinzufügen – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.
  5. -
  6. Display zuweisen – Unter „Displays" das Modul einem physischen Bildschirm zuordnen.
  7. -
  8. Fertig – Das Display lädt die neuen Inhalte automatisch über die API.
  9. +
  10. Meta-Einstellungen pflegen – Logo, Claim, Footer, QR-Code, Theme oder Anzeigezeiten einmal auf Modulebene setzen.
  11. +
  12. Inhalte hinzufügen – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen, aktivieren und per Vorschau prüfen.
  13. +
  14. Display-Entwurf erstellen – Unter „Displays" aus dem Live-Stand einen Entwurf erzeugen und dort Module hinzufügen, sortieren oder entfernen.
  15. +
  16. Prüfen und veröffentlichen – Entwurf in der 9:16-Vorschau oder im Vollbild testen und anschließend bewusst veröffentlichen.
diff --git a/resources/views/livewire/admin/cms/display-list.blade.php b/resources/views/livewire/admin/cms/display-list.blade.php index 4a29f64..0d129a2 100644 --- a/resources/views/livewire/admin/cms/display-list.blade.php +++ b/resources/views/livewire/admin/cms/display-list.blade.php @@ -10,6 +10,29 @@ @endif + @php + $displayPlayerUrl = rtrim(config('display.player_url') ?: 'https://cabinet.b2in.eu/display', '/'); + $displayOverviewUrl = $displayPlayerUrl.'/'; + @endphp + + +
+
+ {{ __('Öffentliche Display-Übersicht') }} + + {{ __('Hier sehen Sie alle aktiven Live-Displays und können die Wiedergabe direkt öffnen.') }} + +
+ {{ $displayOverviewUrl }} +
+
+ + + {{ __('Display-Übersicht öffnen') }} + +
+
+
@@ -30,7 +53,7 @@
@foreach($displays as $display) @php - $liveDisplayUrl = url('/_cabinet/display/index.html').'?id='.$display->id; + $liveDisplayUrl = $displayPlayerUrl.'/?id='.$display->id; $liveApiUrl = url('/api/display/'.$display->id.'/config'); @endphp
{{ __('Test-URL') }} + + {{ __('Link erneuern') }} + isNotEmpty())
- + @foreach($availableVersions as $version) - + {{ $version->name }} ({{ $version->type->label() }}) @endforeach
- + {{ __('Hinzufügen') }}
@else @@ -364,6 +400,7 @@ wire:key="draft-preview-{{ $previewFrameRefreshCounter }}" src="{{ $draftPreviewUrl }}" class="h-full w-full border-0" + loading="lazy" title="{{ __('Entwurfs-Vorschau') }}" > @else diff --git a/resources/views/livewire/admin/cms/display-media-library.blade.php b/resources/views/livewire/admin/cms/display-media-library.blade.php index fe843f6..d490583 100644 --- a/resources/views/livewire/admin/cms/display-media-library.blade.php +++ b/resources/views/livewire/admin/cms/display-media-library.blade.php @@ -6,11 +6,12 @@ use Flux\Flux; use Illuminate\Support\Facades\Storage; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; +use Livewire\WithPagination; use function Livewire\Volt\{layout, title, state, computed, on, uses}; layout('components.layouts.app'); title('Display-Mediathek'); -uses([WithFileUploads::class]); +uses([WithFileUploads::class, WithPagination::class]); state([ 'search' => '', @@ -47,12 +48,16 @@ $media = computed( default => $q, }) ->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection)) - ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") - ->orWhere('title', 'like', "%{$this->search}%")) + ->when($this->search, fn ($q) => $q->search($this->search)) ->orderByDesc('created_at') ->paginate(48), ); +$updatedSearch = fn () => $this->resetPage(); +$updatedFilterType = fn () => $this->resetPage(); +$updatedFilterSource = fn () => $this->resetPage(); +$updatedFilterCollection = fn () => $this->resetPage(); + $collections = computed(fn () => DisplayMedia::query() ->whereNotNull('collection') ->where('collection', '!=', '') @@ -283,27 +288,37 @@ $closeDetail = function () { 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' }}" wire:click="startEdit({{ $item->id }})"> -
- @if ($item->isImage() && $item->isUpload()) - getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null); + $videoFrameSrc = (! $thumbSrc && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null; + @endphp +
+ @if ($thumbSrc) + {{ $item->alt_text ?? $item->filename }} + @elseif ($videoFrameSrc) + @elseif ($item->isVideo())
Video
- @elseif ($item->isExternal() && $item->isImage()) -
- - Extern -
@else
Link
@endif + @if ($item->isVideo() && ($thumbSrc || $videoFrameSrc)) +
+ + + +
+ @endif
@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' }}" wire:click="startEdit({{ $item->id }})"> -
- @if ($item->isImage() && $item->isUpload()) - + @php + $rowThumb = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null); + $rowVideoFrame = (! $rowThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null; + @endphp +
+ @if ($rowThumb) + + @elseif ($rowVideoFrame) + @elseif ($item->isVideo()) @else @@ -418,17 +441,18 @@ $closeDetail = function () { {{-- Preview --}}
- @if ($editMedia->isImage() && $editMedia->isUpload()) + @if ($editMedia->isImage()) {{ $editMedia->filename }} @elseif ($editMedia->isVideo() && $editMedia->isUpload()) -
diff --git a/resources/views/livewire/admin/cms/display-media-picker.blade.php b/resources/views/livewire/admin/cms/display-media-picker.blade.php index 53e97ef..f28e9dc 100644 --- a/resources/views/livewire/admin/cms/display-media-picker.blade.php +++ b/resources/views/livewire/admin/cms/display-media-picker.blade.php @@ -2,11 +2,20 @@
@if ($selectedMedia) + @php + $selectedThumb = $selectedMedia->getThumbnailUrl() + ?? ($selectedMedia->isImage() && $selectedMedia->isExternal() ? $selectedMedia->external_url : null); + $selectedVideoFrame = (! $selectedThumb && $selectedMedia->isVideo() && $selectedMedia->isUpload()) ? $selectedMedia->getUrl() : null; + @endphp
- @if ($selectedMedia->isImage() && $selectedMedia->isUpload()) - {{ $selectedMedia->filename }} + @elseif ($selectedVideoFrame) + @elseif ($selectedMedia->isVideo())
@@ -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' }}" wire:click="selectMedia({{ $item->id }})">
- @if ($item->isImage() && $item->isUpload()) - getThumbnailUrl() + ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null); + $pickVideoFrame = (! $pickThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null; + @endphp + @if ($pickThumb) + {{ $item->filename }} + @elseif ($pickVideoFrame) + @elseif ($item->isVideo())
diff --git a/resources/views/livewire/admin/cms/display-version-editor.blade.php b/resources/views/livewire/admin/cms/display-version-editor.blade.php index 70e4f2b..70e3992 100644 --- a/resources/views/livewire/admin/cms/display-version-editor.blade.php +++ b/resources/views/livewire/admin/cms/display-version-editor.blade.php @@ -70,6 +70,7 @@ wire:key="module-preview-{{ $previewFrameRefreshCounter }}" src="{{ $this->modulePreviewUrl() }}" class="h-full w-full border-0" + loading="lazy" title="{{ __('Modul-Vorschau') }}" >
@@ -120,9 +121,13 @@ type="video" label="Video aus Mediathek" :key="'picker-video-' . ($itemId ?? 'new')" /> - - +
+ + +
+ @@ -139,22 +144,29 @@ {{-- Media fields (B2in) --}} @if($itemType === 'media') - - - - - - - - - +
+ + +
+
+ {{ __('Medientyp:') }} + + {{ $mediaType === 'video' ? __('Video') : __('Bild') }} + + {{ __('wird automatisch aus dem gewählten Medium erkannt') }} +
+ + + + + @if($mediaType === 'image') @@ -164,47 +176,79 @@ @endif - {{-- Slide fields (Offers) --}} + {{-- Slide fields (Offers) – einheitliches Detail-Layout mit Ein-/Ausblende-Schaltern --}} @if($itemType === 'slide') - {{-- Basis --}} - - - - - - - - - - - - + + {{ __('Jedes Angebot nutzt dasselbe Detail-Layout. Über die Schalter blendest du einzelne Bausteine ein oder aus und befüllst sie mit Inhalten.') }} + - {{-- Intro-spezifisch --}} - @if($slideType === 'intro') - - - @if($slideShowBrandText) - + {{-- Logo & Marke (Kopfbereich) --}} +
+ {{ __('Logo & Marke') }} + + @if($slideShowLogo) + +
+ + +
+ + @endif - @endif +
- {{-- Product-Hero --}} - @if($slideType === 'product-hero') - - - @endif + {{-- Bild & Badge (wichtigstes Element – farblich hervorgehoben) --}} +
+
+ {{ __('Bild & Badge') }} + {{ __('Wichtigstes Element') }} +
+ +
+ + +
+ + @if($slideShowBadge) + + @endif +
- {{-- Product-Details --}} - @if($slideType === 'product-details') -
- {{ __('Aufzählungspunkte') }} + {{-- Texte --}} +
+ {{ __('Texte') }} + + + @if($slideShowEyebrow) + + @endif + + @if($slideShowSubline) + + @endif +
+ + {{-- Aufzählung --}} +
+ {{ __('Aufzählungspunkte') }} + + @if($slideShowBullets)
@foreach($slideBullets as $i => $bullet)
@@ -213,30 +257,58 @@
@endforeach
- + {{ __('Punkt hinzufügen') }} -
- @endif - - {{-- Product-Impulse --}} - @if($slideType === 'product-impulse') - - - - @endif - - {{-- QR --}} -
- {{ __('QR-Code & Kontakt') }} -
- - - -
+ @endif
- + {{-- Preis --}} +
+ {{ __('Preis') }} + + @if($slideShowPrice) + + + @if(trim($slideOriginalPrice) !== '') + + @endif + + @endif +
+ + {{-- Hinweis --}} +
+ {{ __('Hinweis') }} + + @if($slideShowDisclaimer) + + @endif +
+ + {{-- QR & Kontakt --}} +
+ {{ __('QR-Code & Kontakt') }} + + @if($slideShowQr) + + + @endif + + @if($slideShowContact) + + @endif +
+ + {{-- Anzeige --}} +
+ {{ __('Anzeige') }} + + +
@endif
@@ -245,21 +317,37 @@ {{ __('Zeigt nur den aktuell bearbeiteten Inhalt im Display-Player') }}
-
+ {{-- 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. --}} +
+ + @unless($itemId) +
+
+ + {{ __('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.') }} +
+
+ @endunless
- - - {{ __('Vollbild öffnen') }} - + @if($itemId) + + + {{ __('Vollbild öffnen') }} + + @endif
diff --git a/resources/views/livewire/admin/cms/display-version-list.blade.php b/resources/views/livewire/admin/cms/display-version-list.blade.php index 69866d0..4caecae 100644 --- a/resources/views/livewire/admin/cms/display-version-list.blade.php +++ b/resources/views/livewire/admin/cms/display-version-list.blade.php @@ -10,6 +10,15 @@ @endif + @if (session()->has('error')) +
+
+ +

{{ session('error') }}

+
+
+ @endif +
diff --git a/resources/views/livewire/admin/cms/media-library-uploader.blade.php b/resources/views/livewire/admin/cms/media-library-uploader.blade.php index f571e05..4924e6f 100644 --- a/resources/views/livewire/admin/cms/media-library-uploader.blade.php +++ b/resources/views/livewire/admin/cms/media-library-uploader.blade.php @@ -3,7 +3,7 @@ accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png"> diff --git a/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php index 29eccd3..dfbc8e6 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php @@ -6,18 +6,18 @@
- {{ __('Slides') }} - {{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }} + {{ __('Angebote') }} + {{ __('Angebote werden im einheitlichen Detail-Layout in der angegebenen Reihenfolge angezeigt') }}
- {{ __('Slide hinzufügen') }} + {{ __('Angebot hinzufügen') }}
@if($slides->isEmpty())
-

{{ __('Noch keine Slides vorhanden.') }}

+

{{ __('Noch keine Angebote vorhanden.') }}

@else
@@ -42,29 +42,30 @@
+ @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
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} - - {{ match($item->content['type'] ?? '') { - 'intro' => 'Intro', - 'product-hero' => 'Produkt-Hero', - 'product-details' => 'Produkt-Details', - 'product-impulse' => 'Produkt-Impuls', - default => $item->content['type'] ?? '–', - } }} - - {{ $item->content['title'] ?? '–' }} + {{ $c['title'] ?? '–' }}
-
- @if(!empty($item->content['price'])) - {{ $item->content['price'] }} - @endif - {{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s - @if(!empty($item->content['badge_text'])) - {{ $item->content['badge_text'] }} +
+ {{ number_format(($c['duration'] ?? 8000) / 1000, 1) }}s + @if(!empty($c['price']) && ($c['show_price'] ?? ! empty($c['price']))) + {{ $c['price'] }} @endif + @foreach($enabledBlocks as $block) + {{ $block }} + @endforeach
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php index 91180e8..ceda105 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php @@ -1,25 +1,74 @@ @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
- {{ __('Header') }} + {{ __('Marke') }} + {{ __('Logo und Claim. Standardmäßig oben im Header. Die Ecken lassen sich frei wählen.') }} - +
+ + +
+ +
+ + +
+ + @if($showLogo) + + @foreach($brandPositions as $value => $label) + + @endforeach + + @endif + + @if($showClaim) + + @foreach($brandPositions as $value => $label) + + @endforeach + + @endif + + @if($footerShown) + + {{ __('Untere Ecken sind nur verfügbar, wenn der Footer ausgeblendet ist.') }} + + @endif
{{ __('Footer & QR') }} + - - +
@@ -32,30 +81,14 @@ @elseif($version->type->value === 'offers') -
- {{ __('Branding') }} - - - -
-
- {{ __('Footer & QR für alle Slides') }} - - - - -
+ + {{ __('Logo, Marken-Text, QR-Code und Kontakt werden je Angebot direkt am Element gepflegt.') }} + @elseif($version->type->value === 'video-display') @endif diff --git a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php index a63a855..53f59ae 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php @@ -16,47 +16,61 @@
- @if($videos->isEmpty()) + @if ($videos->isEmpty())

{{ __('Noch keine Videos vorhanden.') }}

@else
- @foreach($videos as $index => $item) + @foreach ($videos as $index => $item)
+ 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">
- @if($index > 0) - + @if ($index > 0) + @endif - @if($index < count($videos) - 1) - + @if ($index < count($videos) - 1) + + @endif
-
- Video -
+
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} - {{ $item->content['title'] ?? $item->content['filename'] ?? '–' }} + {{ $item->content['title'] ?? ($item->content['filename'] ?? '–') }}
-
- {{ $item->content['filename'] ?? '–' }} + @php + $videoSource = $item->content['filename'] ?? ''; + $isMediaLibrarySource = + str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http'); + @endphp +
+ + {{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }} + + {{ $videoSource ?: '–' }} Position: {{ $item->content['position'] ?? 25 }}%
- - - + + +
@endforeach @@ -69,34 +83,40 @@
{{ __('Footer-Inhalte') }} - {{ __('Inhalte werden im Footer rotiert') }} + {{ __('Inhalte werden im Footer rotiert / ohne Inhalte bleibt der untere Teil frei.') }} +
{{ __('Inhalt hinzufügen') }}
- @if($footers->isEmpty()) + @if ($footers->isEmpty())

{{ __('Noch keine Footer-Inhalte vorhanden.') }}

@else
- @foreach($footers as $index => $item) + @foreach ($footers as $index => $item)
+ 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">
- @if($index > 0) - + @if ($index > 0) + + @endif - @if($index < count($footers) - 1) - + @if ($index < count($footers) - 1) + + @endif
-
+
{{ $item->content['headline'] ?? 'Footer' }}
{{ $item->content['subline'] ?? '' }}
@@ -110,16 +130,20 @@
{{ $item->content['subline'] ?? '' }} - @if(!empty($item->content['url'])) + @if (!empty($item->content['url'])) {{ Str::limit($item->content['url'], 40) }} @endif
- - - + + +
@endforeach diff --git a/resources/views/livewire/admin/hubs/manage.blade.php b/resources/views/livewire/admin/hubs/manage.blade.php index 59cb86f..25aa71b 100644 --- a/resources/views/livewire/admin/hubs/manage.blade.php +++ b/resources/views/livewire/admin/hubs/manage.blade.php @@ -35,26 +35,26 @@ mount(function ($hubId = null) { // Auto-generate slug from name $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); } }; $locations = computed(function () { - if (!$this->hubId) return collect(); + if (!$this->hubId) { + return collect(); + } - 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); + 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); }); $partners = computed(function () { - if (!$this->hubId) return collect(); + if (!$this->hubId) { + return collect(); + } - return \App\Models\Partner::where('hub_id', $this->hubId) - ->get(); + return \App\Models\Partner::where('hub_id', $this->hubId)->get(); }); // Dummy save function @@ -103,12 +103,12 @@ $deleteLocation = function ($id) { {{-- Flash Message --}} @if (session()->has('message')) - -
- - {{ session('message') }} -
-
+ +
+ + {{ session('message') }} +
+
@endif {{-- Tabs --}} @@ -119,364 +119,360 @@ $deleteLocation = function ($id) { {{-- TAB 1: Identität & Design --}} - @if($activeTab === 'identity') - -
- {{-- Basis-Informationen --}} -
- - {{ __('Hub-Name') }} * - - {{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }} - + @if ($activeTab === 'identity') + +
+ {{-- Basis-Informationen --}} +
+ + {{ __('Hub-Name') }} * + + {{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }} + + - - {{ __('URL-Slug') }} * - - {{ __('Für saubere URLs, z.B. b2in.de/region/owl') }} - + + {{ __('URL-Slug') }} * + + {{ __('Für saubere URLs, z.B. b2in.eu/region/owl') }} + - - {{ __('Status') }} - - {{ __('Hub ist aktiv und für Kunden sichtbar') }} - - - {{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }} - - -
+ + {{ __('Status') }} + + {{ __('Hub ist aktiv und für Kunden sichtbar') }} + + + {{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }} + + +
- {{-- Vorschau --}} -
-

{{ __('Vorschau: Kunden-Landingpage') }}

-
- @if($keyvisual) - Keyvisual - @else -
-
- -

{{ __('Keyvisual hochladen') }}

+ {{-- Vorschau --}} +
+

+ {{ __('Vorschau: Kunden-Landingpage') }}

+
+ @if ($keyvisual) + Keyvisual + @else +
+
+ +

{{ __('Keyvisual hochladen') }}

+
+
+ @endif +
+

+ {{ $name ?: __('Ihr Hub-Name') }} +

+

+ {{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }} +

+ @if ($emblem) +
+ Emblem +
+ @endif
- @endif -
-

- {{ $name ?: __('Ihr Hub-Name') }} -

-

- {{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }} -

-
- @if($emblem) -
- Emblem -
- @endif
-
- + - {{-- Keyvisual Upload --}} - - {{ __('Keyvisual (Hintergrundbild)') }} - {{-- --}} - - {{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }} -
- - {{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }} - • {{ __('Empfohlen: 1920x800px, max. 2MB') }} - -
-
+ {{-- Keyvisual Upload --}} + + {{ __('Keyvisual (Hintergrundbild)') }} + {{-- --}} + + {{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }} +
+ + {{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }} + • {{ __('Empfohlen: 1920x800px, max. 2MB') }} + +
+
- {{-- Wappen Upload --}} - - {{ __('Wappen / Emblem') }} - {{-- --}} - - {{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }} -
- - {{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }} - • {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }} - -
-
- + {{-- Wappen Upload --}} + + {{ __('Wappen / Emblem') }} + {{-- --}} + + {{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }} +
+ + {{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }} + • {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }} + +
+
+ @endif {{-- TAB 2: Geografie & PLZ --}} - @if($activeTab === 'geography') -
+ @if ($activeTab === 'geography') +
- {{-- Info-Box --}} - -
- -
-
- {{ __('Die Mapping-Engine') }} -
-
- {{ __('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.') }} + {{-- Info-Box --}} + +
+ +
+
+ {{ __('Die Mapping-Engine') }} +
+
+ {{ __('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.') }} +
-
- + - {{-- PLZ-Import Tools --}} - - {{ __('Postleitzahlen hinzufügen') }} + {{-- PLZ-Import Tools --}} + + {{ __('Postleitzahlen hinzufügen') }} - - {{ __('Einzeln') }} - {{ __('Bereich') }} - {{ __('CSV-Import') }} - + + {{ __('Einzeln') }} + {{ __('Bereich') }} + {{ __('CSV-Import') }} + -
- {{-- Einzelne PLZ --}} - @if($importMethod === 'single') -
-
- - {{ __('Postleitzahl') }} * - - - - {{ __('Stadt') }} * - - +
+ {{-- Einzelne PLZ --}} + @if ($importMethod === 'single') +
+
+ + {{ __('Postleitzahl') }} * + + + + + {{ __('Stadt') }} * + + +
+ + {{ __('PLZ hinzufügen') }} + +
+ @endif + + {{-- PLZ-Bereich --}} + @if ($importMethod === 'range') +
+
+ + {{ __('Von PLZ') }} * + + + + {{ __('Bis PLZ') }} * + + +
+ + {{ __('Bereich importieren') }} + + + ⚠️ + {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }} + +
+ @endif + + {{-- CSV-Import --}} + @if ($importMethod === 'csv') +
+ + {{ __('CSV-Datei') }} * + + + {{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }} +
+ {{ __('Beispiel:') }} + + 33602,Bielefeld
33603,Bielefeld
33604,Bielefeld +
+
+
+ + {{ __('CSV importieren') }} + +
+ @endif +
+ + + {{-- PLZ-Liste --}} + +
+
+ + {{ __('Zugeordnete Postleitzahlen') }} + @if ($hubId) + + ({{ $this->locations->total() }} {{ __('gesamt') }}) + + @endif + +
- - {{ __('PLZ hinzufügen') }} - -
- @endif - {{-- PLZ-Bereich --}} - @if($importMethod === 'range') -
-
- - {{ __('Von PLZ') }} * - - - - {{ __('Bis PLZ') }} * - - -
- - {{ __('Bereich importieren') }} - - - ⚠️ {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }} - -
- @endif + @if ($hubId) + + + {{ __('PLZ') }} + {{ __('Stadt') }} + {{ __('Aktion') }} + - {{-- CSV-Import --}} - @if($importMethod === 'csv') -
- - {{ __('CSV-Datei') }} * - - - {{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }} -
- {{ __('Beispiel:') }} - - 33602,Bielefeld
33603,Bielefeld
33604,Bielefeld -
-
-
- - {{ __('CSV importieren') }} - -
- @endif -
- + + @forelse($this->locations as $location) + + + {{ $location->zip_code }} + + {{ $location->city_name }} + + + + + @empty + + + +

+ {{ __('Noch keine PLZs zugeordnet') }} +

+
+
+ @endforelse +
+ - {{-- PLZ-Liste --}} - -
-
- - {{ __('Zugeordnete Postleitzahlen') }} - @if($hubId) - - ({{ $this->locations->total() }} {{ __('gesamt') }}) - + {{-- Pagination --}} + @if ($this->locations->hasPages()) +
+ {{ $this->locations->links() }} +
@endif -
- + @else +
+ {{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }} +
+ @endif
- - @if($hubId) - - - {{ __('PLZ') }} - {{ __('Stadt') }} - {{ __('Aktion') }} - - - - @forelse($this->locations as $location) - - - {{ $location->zip_code }} - - {{ $location->city_name }} - - - - - @empty - - - -

- {{ __('Noch keine PLZs zugeordnet') }} -

-
-
- @endforelse -
-
- - {{-- Pagination --}} - @if($this->locations->hasPages()) -
- {{ $this->locations->links() }} -
- @endif - @else -
- {{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }} -
- @endif -
-
-
+ +
@endif {{-- TAB 3: Partner-Monitor --}} - @if($activeTab === 'partners') - - - {{ __('Partner in diesem Hub') }} - @if($hubId) - - ({{ $this->partners->count() }} {{ __('Partner') }}) - + @if ($activeTab === 'partners') + + + {{ __('Partner in diesem Hub') }} + @if ($hubId) + + ({{ $this->partners->count() }} {{ __('Partner') }}) + + @endif + + + {{-- Info --}} + +
+ {{ __('Logik:') }} +
    +
  • {{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}
  • +
  • {{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}
  • +
  • {{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}
  • +
+
+
+ + @if ($hubId) + + + {{ __('Name') }} + {{ __('Typ') }} + {{ __('Stadt') }} + {{ __('Lieferradius') }} + {{ __('Status') }} + {{ __('Aktion') }} + + + + @forelse($this->partners as $partner) + + +
+ {{ $partner->company_name }} +
+ @if ($partner->display_name && $partner->display_name !== $partner->company_name) +
{{ $partner->display_name }}
+ @endif +
+ + @php + $typeColors = [ + 'Retailer' => 'blue', + 'Manufacturer' => 'purple', + 'Estate-Agent' => 'green', + ]; + @endphp + + {{ $partner->type }} + + + + {{ $partner->city ?? '-' }} + + + @if ($partner->delivery_radius_km) + {{ $partner->delivery_radius_km }} km + @else + - + @endif + + + + {{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }} + + + + + {{ __('Details') }} + + +
+ @empty + + + +

+ {{ __('Noch keine Partner in diesem Hub') }} +

+

+ {{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }} +

+
+
+ @endforelse +
+
+ @else +
+ {{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }} +
@endif -
- - {{-- Info --}} - -
- {{ __('Logik:') }} -
    -
  • {{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}
  • -
  • {{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}
  • -
  • {{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}
  • -
-
- - @if($hubId) - - - {{ __('Name') }} - {{ __('Typ') }} - {{ __('Stadt') }} - {{ __('Lieferradius') }} - {{ __('Status') }} - {{ __('Aktion') }} - - - - @forelse($this->partners as $partner) - - -
- {{ $partner->company_name }} -
- @if($partner->display_name && $partner->display_name !== $partner->company_name) -
{{ $partner->display_name }}
- @endif -
- - @php - $typeColors = [ - 'Retailer' => 'blue', - 'Manufacturer' => 'purple', - 'Estate-Agent' => 'green', - ]; - @endphp - - {{ $partner->type }} - - - - {{ $partner->city ?? '-' }} - - - @if($partner->delivery_radius_km) - {{ $partner->delivery_radius_km }} km - @else - - - @endif - - - - {{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }} - - - - - {{ __('Details') }} - - -
- @empty - - - -

- {{ __('Noch keine Partner in diesem Hub') }} -

-

- {{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }} -

-
-
- @endforelse -
-
- @else -
- {{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }} -
- @endif -
@endif
- diff --git a/resources/views/livewire/partner/my-data.blade.php b/resources/views/livewire/partner/my-data.blade.php index c6fe1f8..22190d6 100644 --- a/resources/views/livewire/partner/my-data.blade.php +++ b/resources/views/livewire/partner/my-data.blade.php @@ -272,16 +272,16 @@ new class extends Component $rules['deliveryRadius'] = 'required|integer|min:1|max:500'; $rules['assemblyRadius'] = 'required|integer|min:1|max:500'; $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.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; + $rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800'; } if ($this->isManufacturer()) { $rules['brandName'] = 'required|string|max:255'; $rules['brandDescription'] = 'nullable|string|max:1000'; $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, [ @@ -305,11 +305,11 @@ new class extends Component 'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'), 'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'), '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.*.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.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), + 'newBrandImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'), ]); $specialties = array_values(array_filter( @@ -793,7 +793,7 @@ new class extends Component
{{ __('Team-Fotos') }} - {{ __('Nur JPG/PNG – max. 10 MB pro Bild') }} + {{ __('Nur JPG/PNG – max. 200 MB pro Bild') }}
@@ -867,7 +867,7 @@ new class extends Component @endif - + @if (count($newTeamPhotos) > 0) @@ -892,7 +892,7 @@ new class extends Component
{{ __('Showroom-Galerie') }} - {{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 10 MB') }} + {{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 200 MB') }}
@@ -966,7 +966,7 @@ new class extends Component @endif - + @if (count($newShowroomPhotos) > 0) @@ -993,7 +993,7 @@ new class extends Component
{{ __('Marken-Bilder') }} - {{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 10 MB') }} + {{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 200 MB') }}
@@ -1067,7 +1067,7 @@ new class extends Component @endif - + @if (count($newBrandImages) > 0) diff --git a/resources/views/livewire/products/form-standard.blade.php b/resources/views/livewire/products/form-standard.blade.php index 7d4da35..4e3c583 100644 --- a/resources/views/livewire/products/form-standard.blade.php +++ b/resources/views/livewire/products/form-standard.blade.php @@ -461,7 +461,7 @@ new class extends Component 'status' => 'required|in:active,draft', // Bilder 'mainImages' => 'nullable|array|min:0|max:10', - 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800', // Maße & Material 'widthCm' => '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 €").'), 'mainImages.max' => __('Maximal 10 Produktbilder 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.'), '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).'), @@ -1229,7 +1229,7 @@ new class extends Component
{{ __('Produktbilder') }} - {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 200 MB pro Bild, max. 10 Bilder') }}
@@ -1309,7 +1309,7 @@ new class extends Component + text="{{ __('Nur JPEG oder PNG – max. 200 MB') }}" with-progress /> @if (isset($mainImages) && count($mainImages) > 0) diff --git a/resources/views/livewire/products/form-teaser.blade.php b/resources/views/livewire/products/form-teaser.blade.php index c13967b..a0dff17 100644 --- a/resources/views/livewire/products/form-teaser.blade.php +++ b/resources/views/livewire/products/form-teaser.blade.php @@ -238,7 +238,7 @@ new class extends Component 'status' => 'required|in:active,draft', 'partnerProductNumber' => 'nullable|string|max:100', 'mainImages' => 'nullable|array|min:0|max:10', - 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800', ]; $messages = [ @@ -250,7 +250,7 @@ new class extends Component 'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'), 'mainImages.max' => __('Maximal 10 Produktbilder 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) { @@ -459,7 +459,7 @@ new class extends Component
{{ $isEditing ? __('Produktbilder') : __('Produktbild') }} - {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 200 MB pro Bild, max. 10 Bilder') }}
@@ -539,7 +539,7 @@ new class extends Component + text="{{ __('Nur JPEG oder PNG – max. 200 MB') }}" with-progress /> @if (isset($mainImages) && count($mainImages) > 0) diff --git a/routes/admin.php b/routes/admin.php index 5b4ba9a..7984a4d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -66,10 +66,6 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { // Display CMS 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'); - 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/{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'); diff --git a/routes/domains.php b/routes/domains.php index 7067d35..5759dde 100644 --- a/routes/domains.php +++ b/routes/domains.php @@ -45,6 +45,7 @@ Route::domain($domainPortal)->group(function () { Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']); // 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}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); 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']); // 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}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']); Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']); diff --git a/tests/Feature/Admin/Cms/MediaPickerTest.php b/tests/Feature/Admin/Cms/MediaPickerTest.php index 9f21b37..b8b3726 100644 --- a/tests/Feature/Admin/Cms/MediaPickerTest.php +++ b/tests/Feature/Admin/Cms/MediaPickerTest.php @@ -29,3 +29,10 @@ test('media picker zeigt ausgewähltes medium ohne livewire property fehler', fu ->assertSee('test-image.jpg') ->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'); +}); diff --git a/tests/Feature/DisplayListTest.php b/tests/Feature/DisplayListTest.php index 0616562..c289592 100644 --- a/tests/Feature/DisplayListTest.php +++ b/tests/Feature/DisplayListTest.php @@ -79,9 +79,6 @@ test('can assign versions to a display', function () { ->call('save'); $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]); }); @@ -100,8 +97,6 @@ test('can reorder versions in playlist', function () { ->call('save'); $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]); }); @@ -110,10 +105,7 @@ test('can remove version from playlist', function () { $version1 = DisplayVersion::factory()->create(); $version2 = DisplayVersion::factory()->create(); $display = Display::factory()->create(); - $display->versions()->attach([ - $version1->id => ['sort_order' => 0], - $version2->id => ['sort_order' => 1], - ]); + createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]); Livewire::actingAs($user) ->test(DisplayList::class) @@ -122,8 +114,6 @@ test('can remove version from playlist', function () { ->call('save'); $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]); }); @@ -207,7 +197,7 @@ test('can create a draft playlist from live modules', function () { test('can discard a draft playlist', function () { $user = User::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]); Livewire::actingAs($user) @@ -215,6 +205,57 @@ test('can discard a draft playlist', function () { ->call('discardDraft', $display->id); 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 () { @@ -233,7 +274,6 @@ test('can publish a draft playlist over the live playlist', function () { expect($display->draftPlaylist)->toBeNull(); 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 () { @@ -277,7 +317,6 @@ test('can edit draft playlist without changing live playlist', function () { expect($display->preview_token)->not->toBeNull(); expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->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 () { @@ -325,8 +364,8 @@ test('module select only shows modules that can still be added', function () { Livewire::actingAs($user) ->test(DisplayList::class) ->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED) - ->assertDontSeeHtml('