diff --git a/.devcontainer/docker-compose.dev.yml b/.devcontainer/docker-compose.dev.yml index 04f71ac..df7d4f4 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/100-b2in-upload-limits.ini:ro' + - './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro' networks: - sail depends_on: diff --git a/.devcontainer/php-upload-limits.ini b/.devcontainer/php-upload-limits.ini index 4a13806..ed99be6 100644 --- a/.devcontainer/php-upload-limits.ini +++ b/.devcontainer/php-upload-limits.ini @@ -1,4 +1,2 @@ -[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 4ffd7b0..ed229cd 100644 --- a/.env.example +++ b/.env.example @@ -64,12 +64,6 @@ 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 deleted file mode 100644 index e325da0..0000000 --- a/app/Console/Commands/GenerateVideoThumbnails.php +++ /dev/null @@ -1,59 +0,0 @@ -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 4ddcb64..2ea2dc0 100644 --- a/app/Console/Commands/MigrateLegacyDisplays.php +++ b/app/Console/Commands/MigrateLegacyDisplays.php @@ -4,8 +4,6 @@ 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; @@ -77,16 +75,7 @@ class MigrateLegacyDisplays extends Command 'is_active' => true, ]); - $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, - ]); + $display->versions()->attach($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 a907c8d..05cf830 100644 --- a/app/Http/Controllers/Api/DisplayPreviewController.php +++ b/app/Http/Controllers/Api/DisplayPreviewController.php @@ -16,9 +16,7 @@ class DisplayPreviewController extends Controller ->where('preview_token', $token) ->firstOrFail(); - return response()->file(public_path('_cabinet/display/index.html'), [ - 'Cache-Control' => 'no-cache, must-revalidate', - ]); + return response()->file(public_path('_cabinet/display/index.html')); } 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 b101124..37ca1ac 100644 --- a/app/Http/Controllers/Api/DisplayVersionApiController.php +++ b/app/Http/Controllers/Api/DisplayVersionApiController.php @@ -9,35 +9,6 @@ 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 bec62e4..560184a 100644 --- a/app/Http/Controllers/Api/ModulePreviewController.php +++ b/app/Http/Controllers/Api/ModulePreviewController.php @@ -13,21 +13,14 @@ class ModulePreviewController extends Controller { public function show(DisplayVersion $module): BinaryFileResponse { - return $this->playerResponse(); + return response()->file(public_path('_cabinet/display/index.html')); } public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse { abort_unless($item->display_version_id === $module->id, 404); - return $this->playerResponse(); - } - - private function playerResponse(): BinaryFileResponse - { - return response()->file(public_path('_cabinet/display/index.html'), [ - 'Cache-Control' => 'no-cache, must-revalidate', - ]); + return response()->file(public_path('_cabinet/display/index.html')); } 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 698e540..dda18eb 100644 --- a/app/Livewire/Admin/Cms/DisplayList.php +++ b/app/Livewire/Admin/Cms/DisplayList.php @@ -30,9 +30,6 @@ class DisplayList extends Component public $addVersionSelect = null; - /** @var array */ - public $versionsToAdd = []; - public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; public ?string $draftPreviewToken = null; @@ -47,7 +44,7 @@ class DisplayList extends Component ], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED; if ($id) { - $display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules'])->findOrFail($id); + $display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules', 'versions'])->findOrFail($id); $this->displayId = $display->id; $this->displayName = $display->name; $this->displayLocation = $display->location ?? ''; @@ -78,20 +75,6 @@ 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() @@ -154,6 +137,7 @@ class DisplayList extends Component $this->previewFrameRefreshCounter++; } else { $this->syncPublishedPlaylist($display); + $this->syncLegacyPivot($display, $this->selectedVersionIds); } $this->closeModal(); @@ -192,31 +176,10 @@ 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); @@ -239,11 +202,24 @@ class DisplayList extends Component return $display->draftPlaylist->fresh('modules'); }); - $display->clearPreviewToken(); + $this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist)); 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( @@ -321,7 +297,8 @@ class DisplayList extends Component return $this->moduleIdsForPlaylist($display->draftPlaylist); } - return $this->moduleIdsForPlaylist($display->livePlaylist); + return $this->moduleIdsForPlaylist($display->livePlaylist) + ?: $display->versions->pluck('id')->all(); } public function deleteDisplay(int $id): void @@ -354,7 +331,6 @@ 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 27b2b90..1c56870 100644 --- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -117,7 +117,8 @@ 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->search($this->search)) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") + ->orWhere('title', 'like', "%{$this->search}%")) ->orderByDesc('created_at') ->paginate(18); } diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index ac5a318..c79d185 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -3,10 +3,9 @@ namespace App\Livewire\Admin\Cms; use App\Enums\DisplayVersionType; -use App\Models\DisplayMedia; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; -use App\Support\DisplayModuleSettings; +use Illuminate\Support\Facades\File; use Livewire\Attributes\On; use Livewire\Component; @@ -56,12 +55,8 @@ class DisplayVersionEditor extends Component public bool $mediaIsActive = true; - // Offers: Slide fields (single dynamic detail layout) - public bool $slideShowLogo = true; - - public string $slideLogoUrl = ''; - - public string $slideBrandText = ''; + // Offers: Slide fields + public string $slideType = 'product-hero'; public int $slideDuration = 8000; @@ -69,46 +64,30 @@ 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 $slideShowContact = true; + public bool $slideShowBrandText = false; public string $slideBrandTagline = ''; @@ -119,25 +98,32 @@ class DisplayVersionEditor extends Component public array $settings = []; - public int $previewFrameRefreshCounter = 0; - /** @var array */ - public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + public array $availableVideos = []; + + public int $previewFrameRefreshCounter = 0; public function mount(DisplayVersion $displayVersion): void { $this->version = $displayVersion; $this->versionName = $displayVersion->name; $this->settings = $this->settingsWithDefaults(); - $this->normalizeBrandPositions(); + + if ($this->version->type === DisplayVersionType::VideoDisplay) { + $this->loadAvailableVideos(); + } } - public function updated(string $name): void + public function loadAvailableVideos(): void { - if (str_starts_with($name, 'settings.show_footer') - || str_starts_with($name, 'settings.logo_position') - || str_starts_with($name, 'settings.claim_position')) { - $this->normalizeBrandPositions(); + $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(); } } @@ -173,56 +159,12 @@ 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 // ======================================== @@ -333,20 +275,10 @@ 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 @@ -373,43 +305,33 @@ 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, $isActive), - 'footer' => $this->loadFooterContent($content, $isActive), - 'media' => $this->loadMediaContent($content, $isActive), - 'slide' => $this->loadSlideContent($content, $isActive), + 'video' => $this->loadVideoContent($content), + 'footer' => $this->loadFooterContent($content), + 'media' => $this->loadMediaContent($content), + 'slide' => $this->loadSlideContent($content), default => null, }; } - /** - * @param array $content - */ - private function loadVideoContent(array $content, bool $isActive): void + private function loadVideoContent(array $content): void { $this->videoFilename = $content['filename'] ?? ''; $this->videoTitle = $content['title'] ?? ''; $this->videoPosition = $content['position'] ?? 25; - $this->videoIsActive = $isActive; + $this->videoIsActive = true; } - /** - * @param array $content - */ - private function loadFooterContent(array $content, bool $isActive): void + private function loadFooterContent(array $content): void { $this->footerHeadline = $content['headline'] ?? ''; $this->footerSubline = $content['subline'] ?? ''; $this->footerUrl = $content['url'] ?? ''; - $this->footerIsActive = $isActive; + $this->footerIsActive = true; } - /** - * @param array $content - */ - private function loadMediaContent(array $content, bool $isActive): void + private function loadMediaContent(array $content): void { $this->mediaType = $content['media_type'] ?? 'image'; $this->mediaCategory = $content['category'] ?? 'immobilien'; @@ -417,17 +339,12 @@ class DisplayVersionEditor extends Component $this->mediaHeadline = $content['headline'] ?? ''; $this->mediaSubline = $content['subline'] ?? ''; $this->mediaDuration = $content['duration_seconds'] ?? 10; - $this->mediaIsActive = $isActive; + $this->mediaIsActive = true; } - /** - * @param array $content - */ - private function loadSlideContent(array $content, bool $isActive): void + private function loadSlideContent(array $content): void { - $this->slideShowLogo = $content['show_logo'] ?? true; - $this->slideLogoUrl = $content['logo_url'] ?? ''; - $this->slideBrandText = $content['brand_text'] ?? ''; + $this->slideType = $content['type'] ?? 'product-hero'; $this->slideDuration = $content['duration'] ?? 8000; $this->slideImageUrl = $content['image_url'] ?? ''; $this->slideBadge = $content['badge_text'] ?? ''; @@ -436,26 +353,15 @@ 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 = $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'] ?? '') !== ''; + $this->slideIsActive = true; } /** @@ -483,33 +389,22 @@ class DisplayVersionEditor extends Component 'duration_seconds' => $this->mediaDuration, ], 'slide' => [ - 'type' => 'detail', - 'show_logo' => $this->slideShowLogo, - 'logo_url' => $this->slideLogoUrl, - 'brand_text' => $this->slideBrandText, + 'type' => $this->slideType, '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, - 'show_price' => $this->slideShowPrice, - 'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')), - 'show_bullets' => $this->slideShowBullets, + 'bullets' => $this->slideBullets, 'disclaimer' => $this->slideDisclaimer, - 'show_disclaimer' => $this->slideShowDisclaimer, 'qr_url' => $this->slideQrUrl, 'qr_title' => $this->slideQrTitle, - 'show_qr' => $this->slideShowQr, 'contact' => $this->slideContact, - 'show_contact' => $this->slideShowContact, + 'show_brand_text' => $this->slideShowBrandText, 'brand_tagline' => $this->slideBrandTagline, ], default => [], @@ -555,32 +450,22 @@ class DisplayVersionEditor extends Component $this->mediaSubline = ''; $this->mediaDuration = 10; $this->mediaIsActive = true; - $this->slideShowLogo = true; - $this->slideLogoUrl = ''; - $this->slideBrandText = ''; + $this->slideType = 'product-hero'; $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->slideShowContact = true; + $this->slideShowBrandText = false; $this->slideBrandTagline = ''; $this->slideIsActive = true; } @@ -595,7 +480,46 @@ class DisplayVersionEditor extends Component */ private function settingsWithDefaults(): array { - return DisplayModuleSettings::merge($this->version->type, $this->version->settings); + 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, + ], + ], + }; } public function render() diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php index 80dd21e..4a0ceb4 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionList.php +++ b/app/Livewire/Admin/Cms/DisplayVersionList.php @@ -4,8 +4,6 @@ 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 @@ -54,20 +52,7 @@ class DisplayVersionList extends Component public function deleteVersion(int $id): void { - $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; - } - + $version = DisplayVersion::findOrFail($id); $name = $version->name; $version->delete(); @@ -85,17 +70,40 @@ class DisplayVersionList extends Component */ private function defaultSettingsForType(string $type): array { - return DisplayModuleSettings::defaults($type); + 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 => [], + }; } public function render() { - $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)')), - ]) + $versions = DisplayVersion::withCount(['items', 'displays']) ->orderBy('name') ->get(); diff --git a/app/Livewire/Admin/Cms/MediaLibraryUploader.php b/app/Livewire/Admin/Cms/MediaLibraryUploader.php index 90c1986..8b495b6 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:204800', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', ]); $service = app(MediaConversionService::class); diff --git a/app/Livewire/Admin/Cms/MediaPicker.php b/app/Livewire/Admin/Cms/MediaPicker.php index ee4e9af..14eb146 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:204800', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', ]); $service = app(MediaConversionService::class); diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php index a97410d..47dcd6a 100644 --- a/app/Livewire/Cabinet/QuickStatus.php +++ b/app/Livewire/Cabinet/QuickStatus.php @@ -45,10 +45,10 @@ class QuickStatus extends Component ], ]; - public function mount(?string $k = null): void + public function mount(): void { $validKey = config('domains.cabinet_status_key'); - $key = $k ?? request()->query('k'); + $key = request()->get('key'); if (! $validKey || $key !== $validKey) { $this->authorized = false; @@ -92,13 +92,6 @@ class QuickStatus extends Component 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', ]); - $showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true); - - if (! $showsNotice) { - $this->noticeHeadline = ''; - $this->noticeSubtext = ''; - } - CabinetTabletSetting::current()->update([ 'store_status' => $this->storeStatus, 'notice_headline' => $this->noticeHeadline ?: null, diff --git a/app/Models/Display.php b/app/Models/Display.php index 73d705d..279ec51 100644 --- a/app/Models/Display.php +++ b/app/Models/Display.php @@ -4,6 +4,7 @@ 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; @@ -29,6 +30,17 @@ 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 */ @@ -55,6 +67,29 @@ 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) { @@ -64,20 +99,4 @@ 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 eb91036..3cf2397 100644 --- a/app/Models/DisplayMedia.php +++ b/app/Models/DisplayMedia.php @@ -150,12 +150,4 @@ 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 539a805..20cf372 100644 --- a/app/Models/DisplayVersion.php +++ b/app/Models/DisplayVersion.php @@ -6,6 +6,7 @@ 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 @@ -34,12 +35,10 @@ class DisplayVersion extends Model return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order'); } - /** - * @return HasMany - */ - public function playlistItems(): HasMany + public function displays(): BelongsToMany { - return $this->hasMany(DisplayPlaylistItem::class); + return $this->belongsToMany(Display::class, 'display_display_version') + ->withPivot('sort_order'); } /** diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index f62c459..bc08115 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -7,7 +7,6 @@ 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 { @@ -35,7 +34,7 @@ class DisplayMediaService } } - $media = DisplayMedia::create([ + return DisplayMedia::create([ 'filename' => $filename, 'disk' => 'public', 'path' => $relativePath, @@ -46,80 +45,6 @@ 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 46c7414..ea05231 100644 --- a/app/Services/DisplayPlaylistConfigBuilder.php +++ b/app/Services/DisplayPlaylistConfigBuilder.php @@ -4,7 +4,6 @@ namespace App\Services; use App\Models\DisplayPlaylist; use App\Models\DisplayVersion; -use App\Support\DisplayModuleSettings; use Illuminate\Database\Eloquent\Collection; class DisplayPlaylistConfigBuilder @@ -96,7 +95,9 @@ class DisplayPlaylistConfigBuilder return [ 'type' => 'video-display', 'version_name' => $module->name, - 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), + 'settings' => array_replace([ + 'qr_label' => 'Website', + ], $module->settings ?? []), 'videoPlaylist' => $videos, 'footerContent' => $footerContent, ]; @@ -132,7 +133,20 @@ class DisplayPlaylistConfigBuilder return [ 'type' => 'b2in', 'version_name' => $module->name, - 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), + '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 ?? []), 'items' => $mediaItems, ]; } @@ -143,45 +157,42 @@ class DisplayPlaylistConfigBuilder */ private function offersData(DisplayVersion $module, Collection $items): array { - $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'] ?? '', - ]; - }); + $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'] ?? '', + ]); return [ 'type' => 'offers', 'version_name' => $module->name, - 'settings' => DisplayModuleSettings::merge($module->type, $module->settings), + '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 ?? []), 'slides' => $slides, ]; } diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php deleted file mode 100644 index 2fcad41..0000000 --- a/app/Support/DisplayModuleSettings.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ - 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 f1fff65..9443e45 100644 --- a/config/display.php +++ b/config/display.php @@ -22,7 +22,4 @@ 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 deleted file mode 100644 index cfb9b9a..0000000 --- a/database/migrations/2026_05_13_103600_drop_display_display_version_table.php +++ /dev/null @@ -1,26 +0,0 @@ -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 b40ae54..0f83fd4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,7 +21,5 @@ 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 4d27410..d6a3357 100644 --- a/database/seeders/DisplayVersionSeeder.php +++ b/database/seeders/DisplayVersionSeeder.php @@ -169,9 +169,7 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Kontakt', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_logo' => true, - 'logo_url' => '../logo-cabinet-300.png', - 'brand_text' => 'Bielefeld', + 'show_brand_text' => true, 'brand_tagline' => "Planung • Beratung\nLieferung & Montage", ], [ @@ -190,9 +188,7 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_logo' => true, - 'logo_url' => '../logo-cabinet-300.png', - 'brand_text' => '', + 'show_brand_text' => false, 'brand_tagline' => '', ], [ @@ -216,9 +212,7 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_logo' => true, - 'logo_url' => '../logo-cabinet-300.png', - 'brand_text' => '', + 'show_brand_text' => false, 'brand_tagline' => '', ], [ @@ -237,9 +231,7 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Sichern', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_logo' => true, - 'logo_url' => '../logo-cabinet-300.png', - 'brand_text' => '', + 'show_brand_text' => false, 'brand_tagline' => '', ], ]; diff --git a/database/seeders/TestDisplaySeeder.php b/database/seeders/TestDisplaySeeder.php deleted file mode 100644 index 8bc6729..0000000 --- a/database/seeders/TestDisplaySeeder.php +++ /dev/null @@ -1,31 +0,0 @@ -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 07d7534..a6fdce2 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-modules` | Inhalts-Module | -| `cms/display-modules/{id}/edit` | Editor für ein Modul | +| `cms/display-versions` | Inhalts-„Versionen" | +| `cms/display-versions/{id}/edit` | Editor für eine Version | | `cms/displays` | Physische Displays + Playlist-Zuweisung | | `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) | @@ -25,14 +25,13 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor ``` displays (5 Datensätze live) -└── 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 +└── 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.3 Echte Live-Daten (Stand heute) @@ -84,7 +83,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 wurden entsprechend umbenannt: `display-versions` → `display-modules`. Die Übergangs-Redirects wurden in Phase 7 entfernt. +Routen werden entsprechend umbenannt: `display-versions` → `display-modules`. ### 2.2 Neues mentales Modell @@ -182,7 +181,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 wurde in Phase 7 dropped. +display_display_version-Tabelle bleibt vorerst → wird in Phase 7 dropped. ``` **Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute. @@ -356,15 +355,15 @@ Jede Phase liefert ein in sich getestetes, deploybares Inkrement. - [ ] Player-Templates: Single-Module-Modus ### Phase 6 – Umbenennung & Onboarding (Tag 3) -- [x] Routen: `display-versions` → `display-modules` -- [x] Komponenten / Views umbenennen -- [x] Dashboard-Texte / Hilfe-Bausteine aktualisieren -- [x] Tooltips an Schlüsselstellen +- [ ] Routen: `display-versions` → `display-modules` (mit 301-Redirect) +- [ ] Komponenten / Views umbenennen +- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren +- [ ] Tooltips an Schlüsselstellen ### Phase 7 – Aufräumen (Tag 4) -- [x] `display_display_version`-Tabelle dropped -- [x] Alte Routen entfernt -- [x] Entwicklerdoku in `dev/displays-11-05-2026` aktualisiert +- [ ] `display_display_version`-Tabelle dropped +- [ ] Alte Routen entfernt +- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen) - [ ] 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 16628d7..e29d514 100644 --- a/dev/displays-11-05-2026/01-status.md +++ b/dev/displays-11-05-2026/01-status.md @@ -13,8 +13,7 @@ | **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 | ✅ 13.05.2026 | -| **8** | Review: Fehler / Optimierungen / Erweiterungen | 🟡 29.05.2026 (Befundaufnahme) | +| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen | Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert @@ -246,7 +245,7 @@ Umsetzung: ## Phase 6 – Umbenennung Versionen → Module + Onboarding -**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. +**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs bleiben kompatibel und leiten weiter. ### Stand 12.05.2026 – ✅ abgeschlossen @@ -262,9 +261,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 waren während der Übergangsphase als 301-Redirects aktiv und wurden in Phase 7 entfernt +- Alte `display-versions`-Routen bleiben erhalten und leiten per 301 auf die Modul-Routen weiter - Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“ -- Technische Modell-/Klassennamen bleiben bei `DisplayVersion`, da sie fachlich weiterhin die wiederverwendbaren Module abbilden +- Technische Modell-/Klassennamen bleiben bis Phase 7 kompatibel bei `DisplayVersion` #### Tests @@ -277,99 +276,3 @@ 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 f77ab25..e3a8b9e 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/100-b2in-upload-limits.ini:ro' + - './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-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 4924e6f..f571e05 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 90c1986..8b495b6 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:204800', + 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', ]); $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 ee4e9af..14eb146 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:204800', + 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240', ]); $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 059cdd0..c06737d 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:204800')] + #[Validate('file|max:10240')] public $file; public function updatedFile(): void diff --git a/phpunit.xml b/phpunit.xml index 4dbc029..5cf230b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,7 +20,6 @@ - diff --git a/public/_cabinet/_docs/QUICK_START.md b/public/_cabinet/_docs/QUICK_START.md index 3d2f744..69a1f80 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 200 MB** +- [ ] Dateigröße: **Max 100 MB** - [ ] Länge: **15-60 Sekunden** (optimal) ### ⚠️ Vermeiden: -- ❌ Zu große Dateien (>200MB) +- ❌ Zu große Dateien (>100MB) - ❌ 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 7e69043..a8b86d3 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:** 200 MB pro Video +- **Maximum:** 100 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 c166923..4873654 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -143,24 +143,6 @@ 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; @@ -189,8 +171,6 @@ font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); line-height: 1.4; } - /* Without footer the text reclaims the footer's space at the bottom */ - .b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; } /* B2in Footer */ .b2in-footer { @@ -224,9 +204,6 @@ 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); } @@ -348,10 +325,6 @@ 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 { @@ -438,66 +411,6 @@ .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); - } @@ -521,18 +434,6 @@
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 8af6917..0627b6c 100644 --- a/resources/views/components/layouts/auth/split.blade.php +++ b/resources/views/components/layouts/auth/split.blade.php @@ -43,7 +43,6 @@ - @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 deleted file mode 100644 index 7a8eb53..0000000 --- a/resources/views/components/media-thumb.blade.php +++ /dev/null @@ -1,27 +0,0 @@ -@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 64d4fa7..e488d9b 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, 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.
  • +
  • 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.
  • 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, 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.
  • +
  • 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.
  • 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 Dateien inklusive SVG-Logos 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 hochladen.
@@ -211,19 +211,19 @@ $tabletStatus = computed(function () {
  • Video-Display – - 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. + Für Video-Playlists mit optionalem Footer. Inhalte: Videos (Dateiname, Titel, Position/Ausschnitt) und Footer-Zeilen (Überschrift, Unterzeile, optionaler QR-Code-Link).
  • 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 sowie zentrale Meta-Einstellungen für Header-Logo, Claim, Footer-Domain und QR-Code. + 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.
  • Angebote – - 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. + Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes.

- 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. + Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren.

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

    -
  • 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.
  • +
  • Modul-Zuweisung: Jedem Display können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.
  • 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 Live-Inhalte über eine JSON-API ab (/api/display/{id}/config). Entwürfe laufen über Preview-Tokens, Module über eigene Preview-Endpunkte.
  • +
  • API-Anbindung: Jedes Display ruft seine Inhalte über eine JSON-API ab (/api/display/{id}/config). Änderungen werden beim nächsten Abruf automatisch übernommen.
@@ -284,12 +282,10 @@ $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. Meta-Einstellungen pflegen – Logo, Claim, Footer, QR-Code, Theme oder Anzeigezeiten einmal auf Modulebene setzen.
  5. -
  6. Inhalte hinzufügen – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen, aktivieren und per Vorschau prüfen.
  7. -
  8. Display-Entwurf erstellen – Unter „Displays" aus dem Live-Stand einen Entwurf erzeugen und dort Module hinzufügen, sortieren oder entfernen.
  9. -
  10. Prüfen und veröffentlichen – Entwurf in der 9:16-Vorschau oder im Vollbild testen und anschließend bewusst veröffentlichen.
  11. +
  12. Inhalte hinzufügen – Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.
  13. +
  14. Display zuweisen – Unter „Displays" das Modul einem physischen Bildschirm zuordnen.
  15. +
  16. Fertig – Das Display lädt die neuen Inhalte automatisch über die API.
diff --git a/resources/views/livewire/admin/cms/display-list.blade.php b/resources/views/livewire/admin/cms/display-list.blade.php index 0d129a2..4a29f64 100644 --- a/resources/views/livewire/admin/cms/display-list.blade.php +++ b/resources/views/livewire/admin/cms/display-list.blade.php @@ -10,29 +10,6 @@ @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') }} - -
-
-
@@ -53,7 +30,7 @@
@foreach($displays as $display) @php - $liveDisplayUrl = $displayPlayerUrl.'/?id='.$display->id; + $liveDisplayUrl = url('/_cabinet/display/index.html').'?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 @@ -400,7 +364,6 @@ 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 d490583..fe843f6 100644 --- a/resources/views/livewire/admin/cms/display-media-library.blade.php +++ b/resources/views/livewire/admin/cms/display-media-library.blade.php @@ -6,12 +6,11 @@ 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, WithPagination::class]); +uses([WithFileUploads::class]); state([ 'search' => '', @@ -48,16 +47,12 @@ $media = computed( default => $q, }) ->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection)) - ->when($this->search, fn ($q) => $q->search($this->search)) + ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") + ->orWhere('title', 'like', "%{$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', '!=', '') @@ -288,37 +283,27 @@ $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 }})"> - @php - $thumbSrc = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null); - $videoFrameSrc = (! $thumbSrc && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null; - @endphp -
- @if ($thumbSrc) - + @if ($item->isImage() && $item->isUpload()) + {{ $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()) @@ -367,17 +352,9 @@ $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 }})"> - @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) - +
+ @if ($item->isImage() && $item->isUpload()) + @elseif ($item->isVideo()) @else @@ -441,18 +418,17 @@ $closeDetail = function () { {{-- Preview --}}
- @if ($editMedia->isImage()) + @if ($editMedia->isImage() && $editMedia->isUpload()) {{ $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 f28e9dc..53e97ef 100644 --- a/resources/views/livewire/admin/cms/display-media-picker.blade.php +++ b/resources/views/livewire/admin/cms/display-media-picker.blade.php @@ -2,20 +2,11 @@
@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 ($selectedThumb) - isImage() && $selectedMedia->isUpload()) + {{ $selectedMedia->filename }} - @elseif ($selectedVideoFrame) - @elseif ($selectedMedia->isVideo())
@@ -97,18 +88,9 @@ {{ $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 }})">
- @php - $pickThumb = $item->getThumbnailUrl() - ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null); - $pickVideoFrame = (! $pickThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null; - @endphp - @if ($pickThumb) - isImage() && $item->isUpload()) + {{ $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 70e3992..70e4f2b 100644 --- a/resources/views/livewire/admin/cms/display-version-editor.blade.php +++ b/resources/views/livewire/admin/cms/display-version-editor.blade.php @@ -70,7 +70,6 @@ wire:key="module-preview-{{ $previewFrameRefreshCounter }}" src="{{ $this->modulePreviewUrl() }}" class="h-full w-full border-0" - loading="lazy" title="{{ __('Modul-Vorschau') }}" >
@@ -121,13 +120,9 @@ type="video" label="Video aus Mediathek" :key="'picker-video-' . ($itemId ?? 'new')" /> -
- - -
- + + @@ -144,29 +139,22 @@ {{-- Media fields (B2in) --}} @if($itemType === 'media') - -
- - -
-
- {{ __('Medientyp:') }} - - {{ $mediaType === 'video' ? __('Video') : __('Bild') }} - - {{ __('wird automatisch aus dem gewählten Medium erkannt') }} -
+ + + + - + + @if($mediaType === 'image') @@ -176,79 +164,47 @@ @endif - {{-- Slide fields (Offers) – einheitliches Detail-Layout mit Ein-/Ausblende-Schaltern --}} + {{-- Slide fields (Offers) --}} @if($itemType === 'slide') - - {{ __('Jedes Angebot nutzt dasselbe Detail-Layout. Über die Schalter blendest du einzelne Bausteine ein oder aus und befüllst sie mit Inhalten.') }} - + {{-- Basis --}} + + + + + + + + + + + + - {{-- Logo & Marke (Kopfbereich) --}} -
- {{ __('Logo & Marke') }} - - @if($slideShowLogo) - -
- - -
- - + {{-- Intro-spezifisch --}} + @if($slideType === 'intro') + + + @if($slideShowBrandText) + @endif -
+ @endif - {{-- Bild & Badge (wichtigstes Element – farblich hervorgehoben) --}} -
-
- {{ __('Bild & Badge') }} - {{ __('Wichtigstes Element') }} -
- -
- - -
- - @if($slideShowBadge) - - @endif -
+ {{-- Product-Hero --}} + @if($slideType === 'product-hero') + + + @endif - {{-- Texte --}} -
- {{ __('Texte') }} - - - @if($slideShowEyebrow) - - @endif - - @if($slideShowSubline) - - @endif -
- - {{-- Aufzählung --}} -
- {{ __('Aufzählungspunkte') }} - - @if($slideShowBullets) + {{-- Product-Details --}} + @if($slideType === 'product-details') +
+ {{ __('Aufzählungspunkte') }}
@foreach($slideBullets as $i => $bullet)
@@ -257,58 +213,30 @@
@endforeach
- + {{ __('Punkt hinzufügen') }} - @endif -
+
+ @endif - {{-- Preis --}} -
- {{ __('Preis') }} - - @if($slideShowPrice) - - - @if(trim($slideOriginalPrice) !== '') - - @endif - - @endif -
+ {{-- Product-Impulse --}} + @if($slideType === 'product-impulse') + + + + @endif - {{-- Hinweis --}} -
- {{ __('Hinweis') }} - - @if($slideShowDisclaimer) - - @endif -
- - {{-- QR & Kontakt --}} -
- {{ __('QR-Code & Kontakt') }} - - @if($slideShowQr) - + {{-- QR --}} +
+ {{ __('QR-Code & Kontakt') }} +
+ - @endif - - @if($slideShowContact) - @endif +
- {{-- Anzeige --}} -
- {{ __('Anzeige') }} - - -
+ @endif
@@ -317,37 +245,21 @@ {{ __('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
- @if($itemId) - - - {{ __('Vollbild öffnen') }} - - @endif + + + {{ __('Vollbild öffnen') }} +
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 4caecae..69866d0 100644 --- a/resources/views/livewire/admin/cms/display-version-list.blade.php +++ b/resources/views/livewire/admin/cms/display-version-list.blade.php @@ -10,15 +10,6 @@ @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 4924e6f..f571e05 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 dfbc8e6..29eccd3 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 @@
- {{ __('Angebote') }} - {{ __('Angebote werden im einheitlichen Detail-Layout in der angegebenen Reihenfolge angezeigt') }} + {{ __('Slides') }} + {{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }}
- {{ __('Angebot hinzufügen') }} + {{ __('Slide hinzufügen') }}
@if($slides->isEmpty())
-

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

+

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

@else
@@ -42,30 +42,29 @@
- @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') }} - {{ $c['title'] ?? '–' }} + + {{ 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'] ?? '–' }}
-
- {{ number_format(($c['duration'] ?? 8000) / 1000, 1) }}s - @if(!empty($c['price']) && ($c['show_price'] ?? ! empty($c['price']))) - {{ $c['price'] }} +
+ @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'] }} @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 ceda105..91180e8 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,74 +1,25 @@ @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
- {{ __('Marke') }} - {{ __('Logo und Claim. Standardmäßig oben im Header. Die Ecken lassen sich frei wählen.') }} + {{ __('Header') }} -
- - -
+ - -
- - -
- - @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') }} - - - +
@@ -81,14 +32,30 @@ @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 53f59ae..a63a855 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,61 +16,47 @@
- @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'] ?? '–' }}
- @php - $videoSource = $item->content['filename'] ?? ''; - $isMediaLibrarySource = - str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http'); - @endphp -
- - {{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }} - - {{ $videoSource ?: '–' }} +
+ {{ $item->content['filename'] ?? '–' }} Position: {{ $item->content['position'] ?? 25 }}%
- - - + + +
@endforeach @@ -83,40 +69,34 @@
{{ __('Footer-Inhalte') }} - {{ __('Inhalte werden im Footer rotiert / ohne Inhalte bleibt der untere Teil frei.') }} - + {{ __('Inhalte werden im Footer rotiert') }}
{{ __('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'] ?? '' }}
@@ -130,20 +110,16 @@
{{ $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 25aa71b..59cb86f 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,360 +119,364 @@ $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.eu/region/owl') }} - + + {{ __('URL-Slug') }} * + + {{ __('Für saubere URLs, z.B. b2in.de/region/owl') }} + - - {{ __('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') }}

-
-
- @endif -
-

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

-

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

-
- @if ($emblem) -
- Emblem -
- @endif -
-
+ + {{ __('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') }}

+
+
+ @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') }} - -
-
+ - {{-- 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') }} - -
-
- + {{-- 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') }} + +
+
+ @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') }} - - - {{ __('Einzeln') }} - {{ __('Bereich') }} - {{ __('CSV-Import') }} - - -
- {{-- 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 - - -
- - @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') + {{-- PLZ-Import Tools --}} - - {{ __('Partner in diesem Hub') }} - @if ($hubId) - - ({{ $this->partners->count() }} {{ __('Partner') }}) - - @endif - + {{ __('Postleitzahlen hinzufügen') }} - {{-- 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') }}
  • -
+ + {{ __('Einzeln') }} + {{ __('Bereich') }} + {{ __('CSV-Import') }} + + +
+ {{-- Einzelne PLZ --}} + @if($importMethod === 'single') +
+
+ + {{ __('Postleitzahl') }} * + + + + {{ __('Stadt') }} * + + +
+ + {{ __('PLZ hinzufügen') }} +
- + @endif - @if ($hubId) + {{-- 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 + + +
+ + @if($hubId) - {{ __('Name') }} - {{ __('Typ') }} + {{ __('PLZ') }} {{ __('Stadt') }} - {{ __('Lieferradius') }} - {{ __('Status') }} - {{ __('Aktion') }} + {{ __('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') }} - - -
+ @forelse($this->locations as $location) + + + {{ $location->zip_code }} + + {{ $location->city_name }} + + + + @empty - - - -

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

-

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

-
-
+ + + +

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

+
+
@endforelse
- @else -
- {{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }} + + {{-- Pagination --}} + @if($this->locations->hasPages()) +
+ {{ $this->locations->links() }}
- @endif + @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') }}) + + @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 22190d6..c6fe1f8 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:204800'; + $rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; $rules['newShowroomPhotos'] = 'nullable|array|max:20'; - $rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800'; + $rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; } 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:204800'; + $rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:10240'; } $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 200 MB groß sein.'), + 'newTeamPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), - 'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'), + 'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), 'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'), - 'newBrandImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'), + 'newBrandImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), ]); $specialties = array_values(array_filter( @@ -793,7 +793,7 @@ new class extends Component
{{ __('Team-Fotos') }} - {{ __('Nur JPG/PNG – max. 200 MB pro Bild') }} + {{ __('Nur JPG/PNG – max. 10 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. 200 MB') }} + {{ __('Bilder Ihres Showrooms für das öffentliche Profil – nur JPG/PNG, max. 10 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. 200 MB') }} + {{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) – nur JPG/PNG, max. 10 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 4e3c583..7d4da35 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:204800', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', // 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 200 MB groß sein.'), + 'mainImages.*.max' => __('Bilder dürfen maximal 10 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. 200 MB pro Bild, max. 10 Bilder') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }}
@@ -1309,7 +1309,7 @@ new class extends Component + text="{{ __('Nur JPEG oder PNG – max. 10 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 a0dff17..c13967b 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:204800', + 'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240', ]; $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 200 MB groß sein.'), + 'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'), ]; if ($isAdminWithoutPartner) { @@ -459,7 +459,7 @@ new class extends Component
{{ $isEditing ? __('Produktbilder') : __('Produktbild') }} - {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 200 MB pro Bild, max. 10 Bilder') }} + {{ __('Nur Bilder (JPG, JPEG, PNG) – max. 10 MB pro Bild, max. 10 Bilder') }}
@@ -539,7 +539,7 @@ new class extends Component + text="{{ __('Nur JPEG oder PNG – max. 10 MB') }}" with-progress /> @if (isset($mainImages) && count($mainImages) > 0) diff --git a/routes/admin.php b/routes/admin.php index 7984a4d..5b4ba9a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -66,6 +66,10 @@ 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 5759dde..7067d35 100644 --- a/routes/domains.php +++ b/routes/domains.php @@ -45,7 +45,6 @@ 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']); @@ -128,7 +127,6 @@ 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 b8b3726..9f21b37 100644 --- a/tests/Feature/Admin/Cms/MediaPickerTest.php +++ b/tests/Feature/Admin/Cms/MediaPickerTest.php @@ -29,10 +29,3 @@ 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 c289592..0616562 100644 --- a/tests/Feature/DisplayListTest.php +++ b/tests/Feature/DisplayListTest.php @@ -79,6 +79,9 @@ 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]); }); @@ -97,6 +100,8 @@ 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]); }); @@ -105,7 +110,10 @@ test('can remove version from playlist', function () { $version1 = DisplayVersion::factory()->create(); $version2 = DisplayVersion::factory()->create(); $display = Display::factory()->create(); - createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]); + $display->versions()->attach([ + $version1->id => ['sort_order' => 0], + $version2->id => ['sort_order' => 1], + ]); Livewire::actingAs($user) ->test(DisplayList::class) @@ -114,6 +122,8 @@ 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]); }); @@ -197,7 +207,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(['preview_token' => 'token-discard-123456789012345678901234']); + $display = Display::factory()->create(); createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]); Livewire::actingAs($user) @@ -205,57 +215,6 @@ 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 () { @@ -274,6 +233,7 @@ 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 () { @@ -317,6 +277,7 @@ 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 () { @@ -364,8 +325,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) - ->assertDontSee('Schon gewählt (') - ->assertSee('Noch verfügbar ('); + ->assertDontSeeHtml('